diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index b202888a..4cca4194 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -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 ` -// 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/'); }, } diff --git a/apps/x/packages/core/src/code-mode/acp/agents.ts b/apps/x/packages/core/src/code-mode/acp/agents.ts index 5b8cf130..da06d8ea 100644 --- a/apps/x/packages/core/src/code-mode/acp/agents.ts +++ b/apps/x/packages/core/src/code-mode/acp/agents.ts @@ -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 `. +// absolute path so we can spawn it directly with `node `. 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 }; + const pkgJson = require(`${pkg}/package.json`) as { bin?: string | Record }; 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: " 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 diff --git a/apps/x/packages/core/src/code-mode/acp/client.ts b/apps/x/packages/core/src/code-mode/acp/client.ts index b079b530..5c2bd1ba 100644 --- a/apps/x/packages/core/src/code-mode/acp/client.ts +++ b/apps/x/packages/core/src/code-mode/acp/client.ts @@ -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 { 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 { 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: " pointer) - // instead of pending forever. Callers dispose the client on failure, which - // kills the spawned adapter. - private async withStartupTimeout(work: Promise): Promise { - let timer: ReturnType | undefined; - const timeout = new Promise((_, 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 { try { return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] }); diff --git a/apps/x/packages/core/src/code-mode/acp/codex-exec.ts b/apps/x/packages/core/src/code-mode/acp/codex-exec.ts deleted file mode 100644 index 31640ead..00000000 --- a/apps/x/packages/core/src/code-mode/acp/codex-exec.ts +++ /dev/null @@ -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; -} diff --git a/apps/x/packages/core/src/code-mode/acp/manager.ts b/apps/x/packages/core/src/code-mode/acp/manager.ts index 299de1ad..033bf994 100644 --- a/apps/x/packages/core/src/code-mode/acp/manager.ts +++ b/apps/x/packages/core/src/code-mode/acp/manager.ts @@ -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 diff --git a/apps/x/packages/core/src/code-mode/acp/shell-env.ts b/apps/x/packages/core/src/code-mode/acp/shell-env.ts deleted file mode 100644 index acff79b5..00000000 --- a/apps/x/packages/core/src/code-mode/acp/shell-env.ts +++ /dev/null @@ -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; -} diff --git a/apps/x/packages/core/src/code-mode/status.ts b/apps/x/packages/core/src/code-mode/status.ts index 41ef5d76..a78b23f4 100644 --- a/apps/x/packages/core/src/code-mode/status.ts +++ b/apps/x/packages/core/src/code-mode/status.ts @@ -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 { diff --git a/apps/x/scripts/diagnose-code-mode.sh b/apps/x/scripts/diagnose-code-mode.sh deleted file mode 100644 index 16ebdd69..00000000 --- a/apps/x/scripts/diagnose-code-mode.sh +++ /dev/null @@ -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'