Code mode: make packaged builds work via managed engine provisioning (#625)

* fix(code-mode): make packaged code mode work via on-demand engine provisioning

Packaged builds could never run code mode: the Claude/Codex ACP adapters are
spawned as separate `node <entry>` processes resolved at runtime, but esbuild
can't inline a dynamic spawn target and Forge strips the workspace node_modules,
so every release threw `Cannot find module '@agentclientprotocol/...'`. Dev
worked only because of the pnpm symlink.

Rather than bundle the ~400 MB of native engines (one claude + one codex binary
per OS), provision them on demand:

- forge.config.cjs: stage the two ACP adapters + their JS dependency closure into
  .package/acp/node_modules (npm-style nested layout, native engines skipped),
  exempt .package from the node_modules ignore rule, and only sign/notarize when
  APPLE_ID is set so unsigned local/CI builds can package.
- agents.ts: resolve the adapter from the staged location first (node_modules
  fallback in dev); provision the pinned engine and point the adapter at it via
  CLAUDE_CODE_EXECUTABLE / CODEX_PATH. No dependence on a user's global install.
- engine-provisioner.ts: ensureEngine() downloads the per-platform engine package
  from npm AT THE EXACT VERSION THE ADAPTER WAS BUILT AGAINST, verifies its sha512
  integrity, extracts atomically into ~/.rowboat/engines/<agent>/<version>/, and
  caches it. Version-pinning keeps the ACP handshake compatible.
- engine-manifest.ts + scripts/gen-engine-manifest.mjs: committed manifest of
  tarball URLs + integrity for all platforms, regenerated from the adapters'
  pinned versions on a bump.

Verified on macOS arm64: both engines provision and run, and both adapters
complete the ACP initialize handshake from the packaged .app against the
provisioned engines. Installer drops from ~790 MB to 390 MB.

* feat(code-mode): explicit per-agent Enable in Settings; no silent chat download

Code mode now requires the user to explicitly enable an agent before use, instead
of silently downloading a ~200 MB engine on the first chat message.

- Settings → Code Mode: each agent shows "Not enabled" + an Enable button that
  downloads its engine with a live progress indicator (download % → verify →
  install), then flips to "Engine ready". Driven by a new codeMode:provisionEngine
  IPC call + a codeMode:engineProgress push channel. The section now states the
  prerequisite explicitly: the agent must be installed (Enable) and logged in
  (claude login / codex login — code mode reuses that saved credential).
- Chat path no longer auto-downloads: getProvisionedEnginePath() returns the
  enabled engine or throws a clear "enable it in Settings → Code Mode" error, so
  there's never a surprise mid-conversation download. getAgentLaunchSpec is sync
  again.
- Agent status: `installed` now means "engine provisioned" (downloaded), driving
  the Enable/Ready state; the new-session dialog shows "Enable in Settings" and
  disables un-enabled agents. Dropped the dead PATH-probing for a global CLI.

Verified: empty cache -> status installed=false and the chat path throws the
enable-in-Settings error (no download); core, renderer, and main typecheck/build;
no new lint errors.

* fix(code-mode): show only percentage during engine download in Settings

* feat(code-mode): prune superseded engine versions after install

After a successful provision, remove any other version dirs (and their .meta) for
that agent so old ~200 MB engines don't accumulate across version bumps. Best-effort;
never fails a good install. Verified: a planted stale version dir + meta are both
removed after provisioning the current version.

* fix(code-mode): keep showing engine download % after reopening Settings

Provisioning state lived in the row component, which unmounts when the Settings
dialog closes — so reopening mid-download showed the Enable button again even though
the download was still running in the main process. Move provisioning state to a
module-level store with one persistent listener on codeMode:engineProgress, so a row
remounting (dialog reopened) reflects the live % and resolves to Ready on completion.

* fix(code-mode): flip Enable row straight to Ready after install (no Enable flash)

On successful provision the in-flight flag was cleared before the async status
refresh completed, so the row briefly (or until reopen) showed the Enable button
again. Await the status refresh before clearing the flag so it transitions directly
to Ready.

* fix(code-mode): optimistically show Ready right after Enable completes

Awaiting the status refresh wasn't enough — setStatus re-renders the parent
separately from the row, leaving a window where the in-flight flag was cleared but
the status prop was stale, so the row flashed/stuck on the Enable button until
reopen. Track just-enabled agents in a module-level set and treat them as installed
immediately; loadStatus still syncs the real status in the background.

* fix(code-mode): graft login-shell PATH + add startup deadline

#1 (the gh/git "command not found" in packaged builds): GUI/Finder launches inherit
launchd's stripped PATH (/usr/bin:/bin:...), so tools the engine spawns — gh, git,
rg, bash — fail even though they work from a terminal (e.g. Homebrew's
/opt/homebrew/bin/gh). Probe the user's login-shell PATH and graft it onto the
engine's env before spawn (shell-env.ts; no-op on Windows / probe failure).

#2: add a 60s startup deadline (initialize / session create+load) so a wedged engine
fails with a clear, stderr-enriched error instead of an infinite "(pending...)".
Overridable via ROWBOAT_ACP_STARTUP_TIMEOUT_MS. Manager now disposes the client on
startup failure so the spawned adapter doesn't leak.

Verified: getAgentLaunchSpec's env.PATH now includes /opt/homebrew/bin (where gh
lives); core builds; no new lint errors.

* chore(code-mode): comment out signing/notarization for local builds

Revert to the explicit comment-out approach for osxSign/osxNotarize: uncomment them
(with APPLE_ID/APPLE_PASSWORD/APPLE_TEAM_ID) for a signed release build.

* chore(code-mode): keep signing/notarization active in committed config

The repo's forge.config ships with osxSign/osxNotarize enabled (release-ready).
Developers comment them out locally for unsigned test builds and don't commit that.

* chore: approve workspace build scripts so packaging runs non-interactively

The allowBuilds entries were left as "set this to true or false" placeholders, so
`pnpm install` / the pre-build deps check aborted with ERR_PNPM_IGNORED_BUILDS and
`npm run package` failed. Set them to true (and add node-pty, used by the code-mode
embedded terminal) so build scripts are approved and packaging works without a manual
`pnpm approve-builds`.
This commit is contained in:
gagan 2026-06-17 09:23:15 -07:00 committed by GitHub
parent 8ce24ebb33
commit 2ddec07712
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1175 additions and 96 deletions

View file

@ -1,7 +1,9 @@
import { createRequire } from 'module';
import * as path from 'path';
import { fileURLToPath } from 'url';
import type { CodingAgent } from './types.js';
import { resolveClaudeExecutable } from './claude-exec.js';
import { getProvisionedEnginePath } from './engine-provisioner.js';
import { loginShellPath } from './shell-env.js';
const require = createRequire(import.meta.url);
@ -20,13 +22,36 @@ export interface AgentLaunchSpec {
env: NodeJS.ProcessEnv;
}
// Locate an adapter's package.json. In packaged builds Electron Forge strips the
// workspace node_modules, so the adapters (+ their dependency closure) are staged
// next to the bundle at `.package/acp/node_modules` by the generateAssets hook (see
// apps/main/forge.config.cjs). In dev they resolve normally via the pnpm symlink.
// Try the staged location first, then fall back to ordinary resolution.
function resolveAdapterPkgJson(pkg: string): string {
// The main process is esbuild-bundled to `.package/dist/main.cjs`, so the staged
// adapters live one level up at `.package/acp`. (import.meta.url is rewritten to
// the bundle path by bundle.mjs, so this holds in both dev and packaged builds.)
const stagedRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'acp');
for (const opts of [{ paths: [stagedRoot] }, undefined]) {
try {
return require.resolve(`${pkg}/package.json`, opts);
} catch {
// not here — try the next resolution strategy
}
}
throw new Error(
`ACP adapter '${pkg}' not found — expected it staged at ` +
`${path.join(stagedRoot, 'node_modules', pkg)} (packaged build) or resolvable ` +
`from node_modules (dev).`,
);
}
// Resolve the adapter's executable ENTRY (its `bin`, not its library `main`) to an
// absolute path so we can spawn it directly with `node <entry>`. createRequire lets
// us resolve workspace/pnpm-installed packages from this module's location.
// absolute path so we can spawn it directly with `node <entry>`.
function resolveAdapterEntry(pkg: string): string {
const pkgJsonPath = require.resolve(`${pkg}/package.json`);
const pkgJsonPath = resolveAdapterPkgJson(pkg);
const pkgDir = path.dirname(pkgJsonPath);
const pkgJson = require(`${pkg}/package.json`) as { bin?: string | Record<string, string> };
const pkgJson = require(pkgJsonPath) as { bin?: string | Record<string, string> };
const bin = pkgJson.bin;
const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined;
if (!rel) {
@ -39,14 +64,31 @@ 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.)
if (agent === 'claude' && !env.CLAUDE_CODE_EXECUTABLE) {
const exe = resolveClaudeExecutable();
if (exe) env.CLAUDE_CODE_EXECUTABLE = exe;
// Graft the user's login-shell PATH onto the engine's env. GUI (Finder) launches
// inherit launchd's stripped PATH, so tools the engine spawns — git, gh, rg, bash —
// would otherwise fail with "command not found" even though they work from a
// terminal. No-op on Windows / when the probe fails.
const shellPath = loginShellPath();
if (shellPath && shellPath !== env.PATH) {
const dirs = [...shellPath.split(path.delimiter), ...(env.PATH ?? '').split(path.delimiter)];
env.PATH = [...new Set(dirs.filter(Boolean))].join(path.delimiter);
}
// Point the adapter at the engine the user already enabled in Settings. We do NOT
// download here — getProvisionedEnginePath throws a clear "enable it in Settings"
// error if the engine isn't present, so code mode never triggers a surprise
// mid-chat download. The version is locked to what the adapter was built against, so
// the ACP handshake is always compatible. The adapters honor these env vars
// (claude: CLAUDE_CODE_EXECUTABLE, codex: CODEX_PATH).
const executablePath = getProvisionedEnginePath(agent);
if (agent === 'claude') {
env.CLAUDE_CODE_EXECUTABLE = executablePath;
// Make the claude-agent-sdk log the exact spawn command + claude's stderr to
// ~/.claude/debug/sdk-*.txt, so a failed/hung launch has a diagnosable trail
// instead of a silently dropped connection.
env.DEBUG_CLAUDE_AGENT_SDK = '1';
} else {
env.CODEX_PATH = executablePath;
}
// We spawn the adapter with process.execPath. Inside Electron's main process

View file

@ -27,6 +27,16 @@ export interface AcpClientOptions {
onEvent: (event: CodeRunEvent) => void;
}
// Deadline for the startup phases (initialize / session create+load). A healthy cold
// start — adapter boot, engine spawn, SDK handshake, MCP connects — takes seconds; only
// a wedged engine takes this long. Without a deadline that failure mode is an infinite
// "(pending...)" with zero feedback. Prompts are intentionally NOT time-limited: turns
// legitimately run for many minutes and may wait on user permission asks. Overridable
// via ROWBOAT_ACP_STARTUP_TIMEOUT_MS (CI smoke test; escape hatch for MCP-heavy setups).
const STARTUP_TIMEOUT_MS = Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS) > 0
? Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS)
: 60_000;
// Map a raw ACP session/update notification onto our small CodeRunEvent union.
function toEvent(update: SessionUpdate): CodeRunEvent {
switch (update.sessionUpdate) {
@ -130,19 +140,40 @@ export class AcpClient {
this.connection = new ClientSideConnection(() => client, stream);
try {
const init = await this.connection.initialize({
const init = await this.withStartupTimeout(this.connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
}));
this.loadSession_ = init.agentCapabilities?.loadSession === true;
} catch (e) {
throw this.enrich(e, 'initialize');
}
}
// Race a startup-phase request against the deadline so a wedged engine fails with a
// clear, enriched error instead of leaving the turn pending forever. Callers dispose
// the client on failure, which kills the spawned adapter.
private async withStartupTimeout<T>(work: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(
`timed out after ${STARTUP_TIMEOUT_MS / 1000}s — the ${this.agent} engine failed to ` +
`complete startup (it may be wedged or misconfigured)`,
));
}, STARTUP_TIMEOUT_MS);
timer.unref?.();
});
try {
return await Promise.race([work, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
async newSession(): Promise<string> {
try {
const res = await this.conn().newSession({ cwd: this.cwd, mcpServers: [] });
const res = await this.withStartupTimeout(this.conn().newSession({ cwd: this.cwd, mcpServers: [] }));
return res.sessionId;
} catch (e) {
throw this.enrich(e, 'newSession');
@ -151,7 +182,7 @@ export class AcpClient {
async loadSession(sessionId: string): Promise<void> {
try {
await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] });
await this.withStartupTimeout(this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }));
} catch (e) {
throw this.enrich(e, 'loadSession');
}

View file

@ -0,0 +1,100 @@
// AUTO-GENERATED by packages/core/scripts/gen-engine-manifest.mjs — do not edit by hand.
// Regenerate after bumping the @agentclientprotocol/*-acp adapter (engine) versions.
// Maps each agent + platform to the npm tarball + integrity of its native engine.
export const ENGINE_MANIFEST = {
"claude": {
"version": "0.3.156",
"platforms": {
"darwin-arm64": {
"pkg": "@anthropic-ai/claude-agent-sdk-darwin-arm64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.156.tgz",
"integrity": "sha512-IkjcS9dqAUlD4Nb62L9AZtmAXCa+FV4ul8lIlyXXUprh3nlecbKsWOXVd/GORrzAhMmynJaX4+iV1JiutFKXUA=="
},
"darwin-x64": {
"pkg": "@anthropic-ai/claude-agent-sdk-darwin-x64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.156.tgz",
"integrity": "sha512-6PKi5fPmGRuzXu+Em/iwLmPG3mqg0hl92wcTU8fmChqyNtxhxsjCw7LTbdFqp/05o5NeZVVV4k3p7YUv5IFD6g=="
},
"linux-x64": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-x64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.156.tgz",
"integrity": "sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg=="
},
"linux-arm64": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-arm64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.156.tgz",
"integrity": "sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ=="
},
"linux-x64-musl": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-x64-musl",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.156.tgz",
"integrity": "sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA=="
},
"linux-arm64-musl": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-arm64-musl",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.156.tgz",
"integrity": "sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw=="
},
"win32-x64": {
"pkg": "@anthropic-ai/claude-agent-sdk-win32-x64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.156.tgz",
"integrity": "sha512-/PofeTWoiKgnWNSNk0wG4SsRn22GGLmnLhg2R94WcNhCRFOyOTmiZcYH2DBlWZBIRVTZDsSfa/Pl1DyPvYCGKw=="
},
"win32-arm64": {
"pkg": "@anthropic-ai/claude-agent-sdk-win32-arm64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.156.tgz",
"integrity": "sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw=="
}
}
},
"codex": {
"version": "0.128.0",
"platforms": {
"darwin-arm64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-darwin-arm64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-darwin-arm64.tgz",
"integrity": "sha512-w+6zohfHx/kHBdles/CyFKaY57u9I3nK8QI9+NrdwMliKA0b7xn13yblRNkMpe09j6vL1oAWoxYsMOQ/vjBGug=="
},
"darwin-x64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-darwin-x64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-darwin-x64.tgz",
"integrity": "sha512-SDbn6fO22Puy8xmMIbZi4f2znMrUEPwABApke4mo+4ihaauwuVjeqzXvW5SPJz5ty/bG11/mSupQgReT7T8BBw=="
},
"linux-x64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-linux-x64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-linux-x64.tgz",
"integrity": "sha512-2lnSPA05CRRuKAzFW8BCmmNCSieDcToLwfC2ALLbBYilGLgzhRibjlDglK9F1BkEzfohSSWJu4PBbRu/aG60lQ=="
},
"linux-arm64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-linux-arm64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-linux-arm64.tgz",
"integrity": "sha512-+SvH73H60qvCXFuQGP/EsmR//s1hHMBR22PvJkXvM/hdnTIGucx+JqRUjAWdmmQ1IU6j3kgwVvdLW/6ICB+M6w=="
},
"win32-x64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-win32-x64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-win32-x64.tgz",
"integrity": "sha512-k3jmUAFrzkUtvjGTXvSKjQqJLLlzjxp/VoHJDYedgmXUn6j70HxK38IwapzmnYfiBiTuzETvGwjXHzZgzKjhoQ=="
},
"win32-arm64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-win32-arm64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-win32-arm64.tgz",
"integrity": "sha512-ECJvsqmYFdA9pn42xxK3Odp/G16AjmBW0BglX8L0PwPjqbstbmlew9bfHf7xvL+SNfNl4NmyotW0+RNo1phgaA=="
}
}
}
} as const;

View file

@ -0,0 +1,284 @@
// Code-mode engine provisioner.
//
// Code mode drives Claude Code / Codex through their ACP adapters, which spawn a heavy
// native engine binary (~200 MB each). We do NOT bundle those engines into the installer
// (that would add ~400 MB). Instead we provision them on demand: the first time an agent
// is used we download the per-platform npm package AT THE EXACT VERSION OUR ADAPTER WAS
// BUILT AGAINST (see engine-manifest.ts), verify its integrity, and extract it into
// ~/.rowboat/engines/<agent>/<version>/. Subsequent runs reuse the cached copy.
//
// The adapters are then pointed at the provisioned binary via CLAUDE_CODE_EXECUTABLE /
// CODEX_PATH (see agents.ts). This keeps the installer small while making code mode work
// out of the box, with no dependency on the user having a global claude/codex install.
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as crypto from 'crypto';
import { spawnSync } from 'child_process';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { ENGINE_MANIFEST } from './engine-manifest.js';
import type { CodingAgent } from './types.js';
export const ENGINES_ROOT = path.join(os.homedir(), '.rowboat', 'engines');
interface PlatformEntry {
pkg: string;
pkgVersion: string;
tarball: string;
integrity: string;
}
export interface EngineProgress {
phase: 'check' | 'download' | 'verify' | 'extract' | 'done';
/** Bytes received so far (download phase). */
receivedBytes?: number;
/** Total bytes, when the server reports content-length. */
totalBytes?: number;
}
export interface EnsureEngineOptions {
onProgress?: (p: EngineProgress) => void;
signal?: AbortSignal;
}
export interface ProvisionedEngine {
executablePath: string;
version: string;
}
// Map this process's platform/arch (+ libc on linux) to a manifest platform key for the
// given agent. Returns null when no engine is published for this platform.
function platformKey(agent: CodingAgent): string | null {
const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null;
if (!arch) return null;
const plats = ENGINE_MANIFEST[agent].platforms as Record<string, PlatformEntry>;
const candidates: string[] = [];
if (process.platform === 'darwin') {
candidates.push(`darwin-${arch}`);
} else if (process.platform === 'win32') {
candidates.push(`win32-${arch}`);
} else if (process.platform === 'linux') {
// Prefer a musl build on musl systems (Alpine); fall back to the glibc build.
if (isMuslLibc()) candidates.push(`linux-${arch}-musl`);
candidates.push(`linux-${arch}`);
}
return candidates.find((c) => c in plats) ?? null;
}
// glibc builds expose a glibcVersionRuntime in the process report header; musl (Alpine)
// does not. Same heuristic Node's native-addon loaders use.
function isMuslLibc(): boolean {
try {
const report = (process as unknown as { report?: { getReport?: () => unknown } }).report?.getReport?.();
const header = (report as { header?: Record<string, unknown> } | undefined)?.header;
return !(header && 'glibcVersionRuntime' in header);
} catch {
return false;
}
}
// Locate the engine executable inside an extracted package root. We extract the whole npm
// package (so codex's bundled ripgrep travels with it), then find the binary.
function locateExecutable(agent: CodingAgent, root: string): string | null {
if (agent === 'claude') {
for (const name of ['claude', 'claude.exe']) {
const p = path.join(root, name);
if (fs.existsSync(p)) return p;
}
return null;
}
// codex: vendor/<target-triple>/codex/codex[.exe]
const vendor = path.join(root, 'vendor');
if (!fs.existsSync(vendor)) return null;
for (const triple of fs.readdirSync(vendor)) {
for (const name of ['codex', 'codex.exe']) {
const p = path.join(vendor, triple, 'codex', name);
if (fs.existsSync(p)) return p;
}
}
return null;
}
// True when this OS/arch has a published engine for `agent` — i.e. we can provision it.
// (Used for status: code mode no longer requires a user-installed CLI.)
export function isEngineSupported(agent: CodingAgent): boolean {
return platformKey(agent) !== null;
}
// True when the pinned engine for `agent` is already downloaded and intact locally.
export function isEngineProvisioned(agent: CodingAgent): boolean {
const version = ENGINE_MANIFEST[agent].version;
const versionDir = path.join(ENGINES_ROOT, agent, version);
const metaPath = path.join(ENGINES_ROOT, agent, '.meta', `${agent}-${version}.json`);
return locateExecutable(agent, versionDir) !== null && fs.existsSync(metaPath);
}
const AGENT_LABEL: Record<CodingAgent, string> = { claude: 'Claude Code', codex: 'Codex' };
// Return the provisioned engine's executable path, or throw a clear, user-facing error.
// The chat/run path uses this — we deliberately do NOT download here: the engine must be
// enabled up front in Settings → Code Mode, so the user never eats a surprise ~200 MB
// download mid-conversation. ensureEngine() (the downloading path) is driven only by the
// Settings "Enable" action.
export function getProvisionedEnginePath(agent: CodingAgent): string {
const version = ENGINE_MANIFEST[agent].version;
const exe = locateExecutable(agent, path.join(ENGINES_ROOT, agent, version));
if (!exe) {
throw new Error(
`${AGENT_LABEL[agent]} isn't enabled yet. Open Settings → Code Mode and click Enable to download it.`,
);
}
return exe;
}
// Remove every provisioned version of `agent` except `keepVersion`, plus its stale
// .meta entries. Called after a successful install so old engines don't pile up across
// version bumps. Best-effort — never throws (cleanup must not fail a good install).
function pruneOldVersions(agent: CodingAgent, keepVersion: string): void {
const agentRoot = path.join(ENGINES_ROOT, agent);
try {
for (const name of fs.readdirSync(agentRoot)) {
// Keep the active version, the meta dir, and any in-flight temp dirs.
if (name === keepVersion || name === '.meta' || name.startsWith('.tmp-')) continue;
const full = path.join(agentRoot, name);
try {
if (fs.statSync(full).isDirectory()) fs.rmSync(full, { recursive: true, force: true });
} catch { /* ignore a single stubborn entry */ }
}
const metaDir = path.join(agentRoot, '.meta');
if (fs.existsSync(metaDir)) {
for (const f of fs.readdirSync(metaDir)) {
if (f !== `${agent}-${keepVersion}.json`) {
try { fs.rmSync(path.join(metaDir, f), { force: true }); } catch { /* ignore */ }
}
}
}
} catch { /* agentRoot unreadable — nothing to prune */ }
}
async function downloadTo(url: string, dest: string, opts: EnsureEngineOptions): Promise<void> {
opts.onProgress?.({ phase: 'download', receivedBytes: 0 });
const res = await fetch(url, { signal: opts.signal });
if (!res.ok || !res.body) {
throw new Error(`Code mode: engine download failed (HTTP ${res.status}) — ${url}`);
}
const total = Number(res.headers.get('content-length')) || undefined;
let received = 0;
const body = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
body.on('data', (chunk: Buffer) => {
received += chunk.length;
opts.onProgress?.({ phase: 'download', receivedBytes: received, totalBytes: total });
});
await pipeline(body, fs.createWriteStream(dest));
}
// Verify the tarball against the npm Subresource Integrity string ("sha512-<base64>").
function verifyIntegrity(file: string, integrity: string): void {
const dash = integrity.indexOf('-');
const algo = integrity.slice(0, dash);
const expected = integrity.slice(dash + 1);
const actual = crypto.createHash(algo).update(fs.readFileSync(file)).digest('base64');
if (actual !== expected) {
throw new Error(`Code mode: engine integrity check failed (${algo}) — download may be corrupt.`);
}
}
// Extract an npm tarball, stripping its leading `package/` component so the package
// contents land directly in destDir. Uses the system tar (bsdtar on macOS/Windows 10+,
// GNU tar on Linux) — all support -xzf and --strip-components.
function extractTarball(tarPath: string, destDir: string): void {
const r = spawnSync('tar', ['-xzf', tarPath, '-C', destDir, '--strip-components=1'], { stdio: 'pipe' });
if (r.status !== 0) {
const err = r.stderr?.toString().trim() || r.error?.message || `tar exited ${r.status}`;
throw new Error(`Code mode: failed to extract engine — ${err}`);
}
}
// Mark the engine (and codex's bundled ripgrep) executable on unix.
function makeExecutable(agent: CodingAgent, root: string, exe: string): void {
fs.chmodSync(exe, 0o755);
if (agent === 'codex') {
const vendor = path.join(root, 'vendor');
for (const triple of fs.existsSync(vendor) ? fs.readdirSync(vendor) : []) {
const rg = path.join(vendor, triple, 'path', 'rg');
if (fs.existsSync(rg)) fs.chmodSync(rg, 0o755);
}
}
}
/**
* Ensure the pinned engine for `agent` is provisioned locally, downloading it on first
* use. Returns the absolute path to the engine executable. Idempotent and cached.
*/
export async function ensureEngine(agent: CodingAgent, opts: EnsureEngineOptions = {}): Promise<ProvisionedEngine> {
const entry = ENGINE_MANIFEST[agent];
const version = entry.version;
const key = platformKey(agent);
if (!key) {
throw new Error(`Code mode: no ${agent} engine is available for ${process.platform}/${process.arch}.`);
}
const plat = (entry.platforms as Record<string, PlatformEntry>)[key];
const agentRoot = path.join(ENGINES_ROOT, agent);
const versionDir = path.join(agentRoot, version);
const metaDir = path.join(agentRoot, '.meta');
const metaPath = path.join(metaDir, `${agent}-${version}.json`);
opts.onProgress?.({ phase: 'check' });
// Fast path: already provisioned and intact.
const existing = locateExecutable(agent, versionDir);
if (existing && fs.existsSync(metaPath)) {
opts.onProgress?.({ phase: 'done' });
return { executablePath: existing, version };
}
// Download to a unique temp dir, verify, extract, then swap into place. Concurrent
// callers each use their own temp dir; the final rename is idempotent (same content).
fs.mkdirSync(agentRoot, { recursive: true });
const tmpRoot = fs.mkdtempSync(path.join(agentRoot, `.tmp-${version}-`));
try {
const tarPath = path.join(tmpRoot, 'engine.tgz');
await downloadTo(plat.tarball, tarPath, opts);
opts.onProgress?.({ phase: 'verify' });
verifyIntegrity(tarPath, plat.integrity);
opts.onProgress?.({ phase: 'extract' });
const extractDir = path.join(tmpRoot, 'pkg');
fs.mkdirSync(extractDir);
extractTarball(tarPath, extractDir);
const exe = locateExecutable(agent, extractDir);
if (!exe) {
throw new Error(`Code mode: ${agent} engine binary not found in the downloaded package.`);
}
if (process.platform !== 'win32') makeExecutable(agent, extractDir, exe);
// Swap the freshly extracted package into the versioned location.
if (fs.existsSync(versionDir)) fs.rmSync(versionDir, { recursive: true, force: true });
fs.renameSync(extractDir, versionDir);
const finalExe = locateExecutable(agent, versionDir);
if (!finalExe) {
throw new Error(`Code mode: ${agent} engine binary missing after install.`);
}
fs.mkdirSync(metaDir, { recursive: true });
fs.writeFileSync(metaPath, JSON.stringify({
version,
platform: key,
integrity: plat.integrity,
binRelPath: path.relative(versionDir, finalExe),
}, null, 2));
// A new version is in place — remove superseded versions so old engines
// (~200 MB each) don't accumulate after a bump. Best-effort.
pruneOldVersions(agent, version);
opts.onProgress?.({ phase: 'done' });
return { executablePath: finalExe, version };
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
}

View file

@ -174,13 +174,19 @@ export class CodeModeManager {
broker,
onEvent: suppressReplay ? () => {} : onEvent,
});
await client.start();
const sessionId = await this.openSession(runId, agent, cwd, client);
if (suppressReplay) client.setHandlers(broker, onEvent);
const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 };
this.runs.set(runId, run);
return run;
// Dispose the client if startup fails (e.g. the startup-timeout fires) so the
// spawned adapter process doesn't leak.
try {
await client.start();
const sessionId = await this.openSession(runId, agent, cwd, client);
if (suppressReplay) client.setHandlers(broker, onEvent);
const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 };
this.runs.set(runId, run);
return run;
} catch (e) {
client.dispose();
throw e;
}
}
// Resume the persisted session for this chat when possible; else start a new one

View file

@ -0,0 +1,40 @@
import { execSync } from 'child_process';
import * as path from 'path';
let cached: string | null = null;
// The user's login-shell PATH (macOS/Linux; undefined on Windows or probe failure).
// GUI-launched Electron apps inherit launchd's stripped PATH (/usr/bin:/bin:...), so
// anything the engines spawn off process.env.PATH misses Homebrew/nvm/npm-global tools.
// The provisioned engine itself runs from an absolute path, but claude/codex spawn
// git, gh, rg, bash, etc. themselves — without this they fail with "command not found"
// on a Finder launch even though they work from a terminal.
export function loginShellPath(): string | undefined {
if (process.platform === 'win32') return undefined;
if (cached !== null) return cached || undefined;
// Prefer the user's own shell when it's POSIX-flavored, so its login profile
// (~/.zprofile for zsh — macOS default — ~/.profile for bash/sh) is the one that
// builds the PATH. fish et al. are skipped: their `echo $PATH` is space-joined.
const userShell = process.env.SHELL;
const shellOk = userShell && ['sh', 'bash', 'zsh', 'dash', 'ksh'].includes(path.basename(userShell));
const shells = [...new Set([...(shellOk ? [userShell] : []), '/bin/sh'])];
for (const shell of shells) {
try {
const out = execSync(`${shell} -lc 'echo $PATH'`, { timeout: 5000, encoding: 'utf-8' });
// Profile scripts may echo their own lines; our `echo $PATH` runs last,
// so take the last non-empty line and sanity-check it looks like a PATH.
const lines = out.split('\n').map((l) => l.trim()).filter(Boolean);
const last = lines[lines.length - 1];
if (last && last.includes('/')) {
cached = last;
return last;
}
} catch {
// probe failed — try the next shell
}
}
cached = ''; // remember the failure so we don't re-pay the probe every spawn
return undefined;
}

View file

@ -3,8 +3,8 @@ import { promisify } from 'util';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { existsSync } from 'fs';
import { CodeModeAgentStatus } from './types.js';
import { isEngineProvisioned } from './acp/engine-provisioner.js';
const execAsync = promisify(exec);
@ -40,30 +40,6 @@ export function commonInstallPaths(binary: string): string[] {
].map(dir => path.join(dir, binary));
}
async function probeShell(binary: string): Promise<boolean> {
try {
if (process.platform === 'win32') {
const { stdout } = await execAsync(`where ${binary}`, { timeout: 5000 });
return stdout.trim().length > 0;
}
// Login shell so ~/.zprofile / ~/.bashrc PATH additions are visible —
// essential for Homebrew, nvm, asdf, volta installs on macOS GUI launches.
const { stdout } = await execAsync(`/bin/sh -lc 'command -v ${binary}'`, { timeout: 5000 });
return stdout.trim().length > 0;
} catch {
return false;
}
}
async function isInstalled(binary: string): Promise<boolean> {
if (await probeShell(binary)) return true;
// Fallback: scan well-known install locations directly.
for (const candidate of commonInstallPaths(binary)) {
if (existsSync(candidate)) return true;
}
return false;
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
@ -186,14 +162,15 @@ async function checkCodexSignedIn(): Promise<boolean> {
export { decodeJwtPayload };
export async function checkCodeModeAgentStatus(): Promise<CodeModeAgentStatus> {
const [claudeInstalled, codexInstalled, claudeSignedIn, codexSignedIn] = await Promise.all([
isInstalled('claude'),
isInstalled('codex'),
const [claudeSignedIn, codexSignedIn] = await Promise.all([
checkClaudeSignedIn(),
checkCodexSignedIn(),
]);
// `installed` means the engine is provisioned (downloaded) locally — the user has
// clicked Enable in Settings → Code Mode. We no longer look for a global claude/codex
// CLI on PATH; code mode runs our own pinned engine from ~/.rowboat/engines.
return {
claude: { installed: claudeInstalled, signedIn: claudeSignedIn },
codex: { installed: codexInstalled, signedIn: codexSignedIn },
claude: { installed: isEngineProvisioned('claude'), signedIn: claudeSignedIn },
codex: { installed: isEngineProvisioned('codex'), signedIn: codexSignedIn },
};
}