mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-18 20:15:20 +02:00
Revert "fix(code-mode): make packaged code mode work and drop ~460MB bundled engines (#614)"
This reverts commit 33c15cfbd9.
This commit is contained in:
parent
ed8f6f7246
commit
8ce24ebb33
8 changed files with 51 additions and 500 deletions
|
|
@ -5,94 +5,6 @@
|
|||
const path = require('path');
|
||||
const pkg = require('./package.json');
|
||||
|
||||
// Stage the ACP coding-adapters (@agentclientprotocol/*-acp) and their full
|
||||
// production dependency closure into the packaged app.
|
||||
//
|
||||
// Why this is needed: code mode spawns each adapter as a SEPARATE `node <entry>`
|
||||
// process and locates it at runtime via require.resolve — so it must ship as a real
|
||||
// on-disk file. esbuild can't inline it (dynamic resolve + spawn target), and Forge
|
||||
// strips the workspace node_modules (see `ignore` below). Without this, packaged
|
||||
// builds throw `Cannot find module '@agentclientprotocol/...'`.
|
||||
//
|
||||
// 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). 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 = [
|
||||
'@agentclientprotocol/claude-agent-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,
|
||||
// looking for the package DIRECTORY. We deliberately do NOT use
|
||||
// require.resolve(`${key}/package.json`): that throws for packages whose `exports`
|
||||
// map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk), which would
|
||||
// silently drop them and their subtrees. realpathSync dereferences pnpm's symlinks.
|
||||
// Returns null for deps not installed for this OS (platform-optional binaries).
|
||||
const realDirOf = (key, fromDir) => {
|
||||
let dir = fromDir;
|
||||
for (;;) {
|
||||
const cand = path.join(dir, 'node_modules', ...key.split('/'));
|
||||
if (fs.existsSync(path.join(cand, 'package.json'))) return fs.realpathSync(cand);
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
if (chain.has(srcDir)) return; // dependency cycle — resolves to ancestor copy
|
||||
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
||||
fs.cpSync(srcDir, destDir, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (s) => path.basename(s) !== 'node_modules', // deps handled by recursion
|
||||
});
|
||||
copied++;
|
||||
const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of ADAPTERS) {
|
||||
const srcDir = realDirOf(key, mainDir);
|
||||
if (!srcDir) {
|
||||
throw new Error(`ACP adapter '${key}' is not installed in ${mainDir} — run pnpm install`);
|
||||
}
|
||||
install(srcDir, key, destNodeModules, new Set());
|
||||
}
|
||||
if (skippedEngines.size) {
|
||||
console.log(` (skipped bundled native engines — driven from local install: ${[...skippedEngines].join(', ')})`);
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
packagerConfig: {
|
||||
executableName: 'rowboat',
|
||||
|
|
@ -105,41 +17,31 @@ module.exports = {
|
|||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
||||
},
|
||||
// Sign/notarize only when release credentials are present (the release
|
||||
// workflow sets APPLE_ID). Local mac dev builds and the CI smoke matrix
|
||||
// have no signing identity — leaving these unconditional makes
|
||||
// `npm run package` fail there.
|
||||
...(process.env.APPLE_ID ? {
|
||||
osxSign: {
|
||||
batchCodesignCalls: true,
|
||||
optionsForFile: () => ({
|
||||
entitlements: path.join(__dirname, 'entitlements.plist'),
|
||||
'entitlements-inherit': path.join(__dirname, 'entitlements.plist'),
|
||||
}),
|
||||
},
|
||||
osxNotarize: {
|
||||
appleId: process.env.APPLE_ID,
|
||||
appleIdPassword: process.env.APPLE_PASSWORD,
|
||||
teamId: process.env.APPLE_TEAM_ID
|
||||
},
|
||||
} : {}),
|
||||
// Since we bundle the main process with esbuild, we don't need the workspace
|
||||
// node_modules. These settings prevent Forge's dependency walker (flora-colossus)
|
||||
// from trying to analyze/copy node_modules, which fails with pnpm's symlinked
|
||||
// workspaces.
|
||||
prune: false,
|
||||
// Strip the workspace src/node_modules (regexes ANCHORED to the app root), BUT
|
||||
// always keep everything under `.package/` — that's our staged output: the
|
||||
// bundled main process, the ACP adapters + their dependency closure (staged by
|
||||
// the generateAssets hook), and the native node-pty module (staged into
|
||||
// .package/node_modules by bundle.mjs). Without the `.package` exemption the
|
||||
// node_modules rule would strip those and code mode / the embedded terminal
|
||||
// would break in packaged builds.
|
||||
ignore: (p) => {
|
||||
if (p === '/.package' || p.startsWith('/.package/')) return false;
|
||||
return [/^\/src\//, /^\/node_modules\//, /\.gitignore/, /bundle\.mjs/, /tsconfig\.json/]
|
||||
.some((re) => re.test(p));
|
||||
osxSign: {
|
||||
batchCodesignCalls: true,
|
||||
optionsForFile: () => ({
|
||||
entitlements: path.join(__dirname, 'entitlements.plist'),
|
||||
'entitlements-inherit': path.join(__dirname, 'entitlements.plist'),
|
||||
}),
|
||||
},
|
||||
osxNotarize: {
|
||||
appleId: process.env.APPLE_ID,
|
||||
appleIdPassword: process.env.APPLE_PASSWORD,
|
||||
teamId: process.env.APPLE_TEAM_ID
|
||||
},
|
||||
// Since we bundle everything with esbuild, we don't need node_modules at all.
|
||||
// These settings prevent Forge's dependency walker (flora-colossus) from trying
|
||||
// to analyze/copy node_modules, which fails with pnpm's symlinked workspaces.
|
||||
// Regexes are ANCHORED to the app root: .package/node_modules (where
|
||||
// bundle.mjs stages the native node-pty module) must survive packaging.
|
||||
prune: false,
|
||||
ignore: [
|
||||
/^\/src\//,
|
||||
/^\/node_modules\//,
|
||||
/.gitignore/,
|
||||
/bundle\.mjs/,
|
||||
/tsconfig.json/,
|
||||
],
|
||||
},
|
||||
makers: [
|
||||
{
|
||||
|
|
@ -293,15 +195,6 @@ module.exports = {
|
|||
fs.mkdirSync(rendererDest, { recursive: true });
|
||||
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
|
||||
|
||||
// Stage the ACP coding-adapters (+ their dependency closure) into .package/acp.
|
||||
// They are spawned as separate node processes at runtime and Forge strips the
|
||||
// workspace node_modules, so they must be copied in explicitly. See
|
||||
// stageAcpAdapters() above for the why.
|
||||
console.log('Staging ACP adapters...');
|
||||
const acpDest = path.join(packageDir, 'acp', 'node_modules');
|
||||
const staged = stageAcpAdapters(__dirname, acpDest);
|
||||
console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules`);
|
||||
|
||||
console.log('✅ All assets staged in .package/');
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
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 { resolveCodexExecutable } from './codex-exec.js';
|
||||
import { loginShellPath } from './shell-env.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
|
|
@ -23,36 +20,13 @@ 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>`.
|
||||
// absolute path so we can spawn it directly with `node <entry>`. createRequire lets
|
||||
// us resolve workspace/pnpm-installed packages from this module's location.
|
||||
function resolveAdapterEntry(pkg: string): string {
|
||||
const pkgJsonPath = resolveAdapterPkgJson(pkg);
|
||||
const pkgJsonPath = require.resolve(`${pkg}/package.json`);
|
||||
const pkgDir = path.dirname(pkgJsonPath);
|
||||
const pkgJson = require(pkgJsonPath) as { bin?: string | Record<string, string> };
|
||||
const pkgJson = require(`${pkg}/package.json`) as { bin?: string | Record<string, string> };
|
||||
const bin = pkgJson.bin;
|
||||
const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined;
|
||||
if (!rel) {
|
||||
|
|
@ -65,56 +39,14 @@ export function getAgentLaunchSpec(agent: CodingAgent): AgentLaunchSpec {
|
|||
const entry = resolveAdapterEntry(ADAPTER_PACKAGE[agent]);
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
|
||||
// macOS/Linux GUI launches inherit launchd's stripped PATH. Resolving the engine
|
||||
// binary below isn't enough on its own: an npm-installed claude is a
|
||||
// `#!/usr/bin/env node` script (node must be on the ADAPTER's PATH when it spawns
|
||||
// it), and the engines spawn git/rg/bash themselves. Graft the user's real
|
||||
// login-shell PATH onto the adapter env so all of those resolve.
|
||||
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 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') {
|
||||
// The claude-agent-sdk discards the engine's stderr unless this is set. With
|
||||
// it, the SDK logs the exact spawn command + claude's stderr to a debug file
|
||||
// (~/.claude/debug/sdk-*.txt) and prints "SDK debug logs: <path>" on the
|
||||
// adapter's stderr — which we capture and attach to startup errors, so a
|
||||
// failed/hung launch points at the file with the real cause.
|
||||
env.DEBUG_CLAUDE_AGENT_SDK = '1';
|
||||
|
||||
if (!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) {
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// We spawn the adapter with process.execPath. Inside Electron's main process
|
||||
|
|
|
|||
|
|
@ -27,19 +27,6 @@ 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 (e.g. an outdated local CLI that launches but
|
||||
// never answers the handshake). 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 — used by the CI smoke test to
|
||||
// avoid waiting the full minute, and an escape hatch for genuinely slow setups
|
||||
// (e.g. many MCP servers configured in the engine's user settings).
|
||||
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) {
|
||||
|
|
@ -143,10 +130,10 @@ export class AcpClient {
|
|||
this.connection = new ClientSideConnection(() => client, stream);
|
||||
|
||||
try {
|
||||
const init = await this.withStartupTimeout(this.connection.initialize({
|
||||
const init = await 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');
|
||||
|
|
@ -155,7 +142,7 @@ export class AcpClient {
|
|||
|
||||
async newSession(): Promise<string> {
|
||||
try {
|
||||
const res = await this.withStartupTimeout(this.conn().newSession({ cwd: this.cwd, mcpServers: [] }));
|
||||
const res = await this.conn().newSession({ cwd: this.cwd, mcpServers: [] });
|
||||
return res.sessionId;
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'newSession');
|
||||
|
|
@ -164,35 +151,12 @@ export class AcpClient {
|
|||
|
||||
async loadSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await this.withStartupTimeout(this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }));
|
||||
await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] });
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'loadSession');
|
||||
}
|
||||
}
|
||||
|
||||
// Race a startup-phase request against the deadline. The timeout error flows
|
||||
// through enrich(), which appends the adapter's exit info / stderr tail — so a
|
||||
// hung startup reports WHY (including the "SDK debug logs: <path>" pointer)
|
||||
// instead of 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 local ${this.agent} CLI may be ` +
|
||||
`outdated or failing to launch (check \`${this.agent} --version\`)`,
|
||||
));
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
return await Promise.race([work, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string): Promise<PromptResponse> {
|
||||
try {
|
||||
return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] });
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -174,19 +174,13 @@ export class CodeModeManager {
|
|||
broker,
|
||||
onEvent: suppressReplay ? () => {} : onEvent,
|
||||
});
|
||||
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) {
|
||||
// Startup failed (e.g. handshake timeout). The client isn't in `runs`
|
||||
// yet, so dispose here or the spawned adapter process leaks.
|
||||
client.dispose();
|
||||
throw e;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Resume the persisted session for this chat when possible; else start a new one
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
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 resolved or spawned off process.env.PATH misses nvm/homebrew/npm-global
|
||||
// installs. claude-exec/codex-exec already login-shell-probe for their one binary;
|
||||
// this recovers the WHOLE PATH for transitive spawns the probes can't cover — an
|
||||
// npm-installed claude is a `#!/usr/bin/env node` script (node must be on the
|
||||
// spawner's PATH), and the engines spawn git/rg/bash themselves.
|
||||
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;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { promisify } from 'util';
|
|||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import { existsSync } from 'fs';
|
||||
import { CodeModeAgentStatus } from './types.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
|
@ -28,54 +28,16 @@ export function commonInstallPaths(binary: string): string[] {
|
|||
path.join(home, '.volta', 'bin', `${binary}.cmd`),
|
||||
];
|
||||
}
|
||||
const dirs = [
|
||||
return [
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin', // Apple Silicon Homebrew
|
||||
'/usr/bin',
|
||||
path.join(home, '.npm-global', 'bin'),
|
||||
path.join(home, '.local', 'bin'),
|
||||
path.join(home, '.volta', 'bin'),
|
||||
path.join(home, '.nvm', 'versions', 'node'), // partial; nvm has versioned subdirs
|
||||
path.join(home, 'bin'),
|
||||
// Claude Code's legacy local installer / `claude migrate-installer` target.
|
||||
path.join(home, '.claude', 'local'),
|
||||
// pnpm global bin: PNPM_HOME if set, else the platform default
|
||||
// (~/Library/pnpm on macOS, ~/.local/share/pnpm on Linux).
|
||||
process.env.PNPM_HOME ||
|
||||
(process.platform === 'darwin'
|
||||
? path.join(home, 'Library', 'pnpm')
|
||||
: path.join(home, '.local', 'share', 'pnpm')),
|
||||
// Node version managers install into versioned subdirs (e.g.
|
||||
// ~/.nvm/versions/node/v24.16.0/bin) — enumerate each version's bin dir.
|
||||
...versionManagerBinDirs(home),
|
||||
];
|
||||
return dirs.map(dir => path.join(dir, binary));
|
||||
}
|
||||
|
||||
// nvm/fnm/asdf keep a `bin` dir per installed Node version. A static path can't
|
||||
// match (the version segment is unknown), so list the version dirs and append
|
||||
// `bin`. Returns [] when a manager isn't present — readdir failures are ignored.
|
||||
function versionManagerBinDirs(home: string): string[] {
|
||||
const versionRoots = [
|
||||
path.join(home, '.nvm', 'versions', 'node'), // nvm
|
||||
path.join(home, '.local', 'share', 'fnm', 'node-versions'), // fnm (Linux)
|
||||
path.join(home, 'Library', 'Application Support', 'fnm', 'node-versions'),// fnm (macOS)
|
||||
path.join(home, '.asdf', 'installs', 'nodejs'), // asdf
|
||||
];
|
||||
const dirs: string[] = [];
|
||||
for (const root of versionRoots) {
|
||||
let versions: string[];
|
||||
try {
|
||||
versions = readdirSync(root);
|
||||
} catch {
|
||||
continue; // manager not installed
|
||||
}
|
||||
for (const version of versions) {
|
||||
// fnm nests another `installation` dir; nvm/asdf put bin directly under the version.
|
||||
dirs.push(path.join(root, version, 'bin'));
|
||||
dirs.push(path.join(root, version, 'installation', 'bin'));
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
].map(dir => path.join(dir, binary));
|
||||
}
|
||||
|
||||
async function probeShell(binary: string): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# One-shot code-mode diagnostics (macOS / Linux).
|
||||
#
|
||||
# When code mode misbehaves on your machine (stuck runs, "CLI not found",
|
||||
# startup timeouts), run this and send the FULL output back — it collects
|
||||
# everything needed to diagnose in one round trip:
|
||||
#
|
||||
# bash apps/x/scripts/diagnose-code-mode.sh
|
||||
#
|
||||
# Read-only except for one tiny `claude -p` probe (a single short API call).
|
||||
|
||||
section() { printf '\n=== %s ===\n' "$1"; }
|
||||
|
||||
# Portable timeout: mac has no `timeout` binary by default.
|
||||
run_with_timeout() {
|
||||
local secs="$1"; shift
|
||||
"$@" & local pid=$!
|
||||
( sleep "$secs" && kill "$pid" 2>/dev/null ) & local killer=$!
|
||||
wait "$pid" 2>/dev/null; local rc=$?
|
||||
kill "$killer" 2>/dev/null
|
||||
return $rc
|
||||
}
|
||||
|
||||
describe_binary() { # $1 = name
|
||||
local p
|
||||
p="$(/bin/sh -lc "command -v $1" 2>/dev/null)"
|
||||
if [ -z "$p" ]; then
|
||||
echo "$1: NOT on login-shell PATH"
|
||||
return
|
||||
fi
|
||||
echo "$1: $p"
|
||||
[ -L "$p" ] && echo " symlink -> $(readlink "$p")"
|
||||
# Node-shebang script vs native binary — the distinction that matters for
|
||||
# GUI launches (shebang scripts need `node` on the SPAWNING process's PATH).
|
||||
local head1
|
||||
head1="$(head -c 64 "$p" 2>/dev/null | head -n 1 | tr -d '\0')"
|
||||
case "$head1" in
|
||||
'#!'*) echo " type: script ($head1)" ;;
|
||||
*) echo " type: native binary" ;;
|
||||
esac
|
||||
echo " version: $(run_with_timeout 15 "$1" --version 2>&1 | head -n 1)"
|
||||
}
|
||||
|
||||
section "system"
|
||||
echo "os: $(uname -sr) ($(uname -m))"
|
||||
echo "shell: ${SHELL:-unset}"
|
||||
echo "date: $(date)"
|
||||
|
||||
section "engines"
|
||||
describe_binary claude
|
||||
describe_binary codex
|
||||
describe_binary node
|
||||
|
||||
section "PATH: login shell vs GUI"
|
||||
echo "login-shell PATH:"
|
||||
/bin/sh -lc 'echo " $PATH"'
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
echo "launchd (GUI) PATH:"
|
||||
echo " $(launchctl getenv PATH 2>/dev/null || echo '(unset — GUI apps get the system default)')"
|
||||
fi
|
||||
|
||||
section "auth presence (no secrets printed)"
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
if security find-generic-password -s "Claude Code-credentials" >/dev/null 2>&1; then
|
||||
echo "claude: keychain credential present"
|
||||
else
|
||||
echo "claude: NO keychain credential (signed in?)"
|
||||
fi
|
||||
fi
|
||||
[ -f "$HOME/.claude/.credentials.json" ] && echo "claude: ~/.claude/.credentials.json present"
|
||||
[ -f "$HOME/.codex/auth.json" ] && echo "codex: ~/.codex/auth.json present" || echo "codex: NO ~/.codex/auth.json"
|
||||
|
||||
section "claude stream-json probe (what the app does under the hood)"
|
||||
# A healthy claude prints a `system`/`init` JSON line within seconds. A hang or
|
||||
# error here reproduces the in-app failure WITHOUT the app.
|
||||
run_with_timeout 45 claude -p "reply with exactly: ok" --output-format stream-json --verbose 2>&1 | head -n 3
|
||||
echo "(probe exit: $? — 143 means it hung and was killed after 45s)"
|
||||
|
||||
section "newest SDK debug log (~/.claude/debug)"
|
||||
latest="$(ls -t "$HOME/.claude/debug"/sdk-*.txt 2>/dev/null | head -n 1)"
|
||||
if [ -n "$latest" ]; then
|
||||
echo "$latest:"
|
||||
tail -n 40 "$latest"
|
||||
else
|
||||
echo "(none found)"
|
||||
fi
|
||||
|
||||
printf '\ndone — send everything above.\n'
|
||||
Loading…
Add table
Add a link
Reference in a new issue