diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 4cca4194..b202888a 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -5,6 +5,94 @@ 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', @@ -17,31 +105,41 @@ module.exports = { extendInfo: { NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', }, - 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. + // 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, - ignore: [ - /^\/src\//, - /^\/node_modules\//, - /.gitignore/, - /bundle\.mjs/, - /tsconfig.json/, - ], + // 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)); + }, }, makers: [ { @@ -195,6 +293,15 @@ 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 da06d8ea..5b8cf130 100644 --- a/apps/x/packages/core/src/code-mode/acp/agents.ts +++ b/apps/x/packages/core/src/code-mode/acp/agents.ts @@ -1,7 +1,10 @@ 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); @@ -20,13 +23,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 `. createRequire lets -// us resolve workspace/pnpm-installed packages from this module's location. +// absolute path so we can spawn it directly with `node `. 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 }; + const pkgJson = require(pkgJsonPath) as { bin?: string | Record }; const bin = pkgJson.bin; const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined; if (!rel) { @@ -39,14 +65,56 @@ 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; + // 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; } // 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 5c2bd1ba..b079b530 100644 --- a/apps/x/packages/core/src/code-mode/acp/client.ts +++ b/apps/x/packages/core/src/code-mode/acp/client.ts @@ -27,6 +27,19 @@ 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) { @@ -130,10 +143,10 @@ 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'); @@ -142,7 +155,7 @@ export class AcpClient { async newSession(): Promise { 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,12 +164,35 @@ export class AcpClient { async loadSession(sessionId: string): Promise { 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'); } } + // 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 new file mode 100644 index 00000000..31640ead --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/codex-exec.ts @@ -0,0 +1,65 @@ +import { execSync } from 'child_process'; +import * as path from 'path'; +import { existsSync } from 'fs'; +import { commonInstallPaths } from '../status.js'; + +let cached: string | undefined; + +// Resolve the user's local `codex` launcher to hand the codex-acp adapter via +// CODEX_PATH. We deliberately do NOT bundle Codex's ~230 MB native engine — the +// adapter only falls back to a bundled `@openai/codex` when CODEX_PATH is unset, so +// pointing it at the local install keeps packaged builds small. +// +// Unlike claude (which the adapter spawns directly, hitting the Windows .cmd EINVAL +// trap), codex-acp spawns this with `shell: true` on Windows and via PATH on unix — so +// a `.cmd` shim is fine and we don't need to dig out a raw `.exe`. We still resolve an +// explicit path because Electron's runtime PATH can omit npm/pnpm global bin dirs even +// when the user's shell has them. Returns undefined if codex can't be found — callers +// then surface a clear "Codex CLI not found" error. +export function resolveCodexExecutable(): string | undefined { + if (cached) return cached; + const resolved = process.platform === 'win32' ? resolveCodexOnWindows() : resolveCodexOnUnix(); + if (resolved) cached = resolved; + return resolved; +} + +// Windows: scan PATH (for codex.cmd/.exe) plus well-known npm/pnpm global bin dirs that +// Electron's runtime PATH can omit. No login-shell trick here; codex-acp spawns with +// shell:true so a `.cmd` shim is fine. +function resolveCodexOnWindows(): string | undefined { + const exts = ['.cmd', '.exe', '']; + const pathDirs = (process.env.PATH ?? '') + .split(path.delimiter) + .map((d) => d.trim()) + .filter(Boolean); + const fromPath = pathDirs.flatMap((dir) => exts.map((ext) => path.join(dir, `codex${ext}`))); + for (const candidate of [...fromPath, ...commonInstallPaths('codex')]) { + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +// macOS/Linux: GUI-launched Electron apps (Dock/Finder) often don't inherit the login +// shell's PATH, so a node-version-manager install (nvm/fnm/asdf) won't be on +// process.env.PATH. Ask a login shell first — it sees the user's full PATH — then fall +// back to scanning the runtime PATH and well-known install dirs. Mirrors +// resolveClaudeBinaryUnix in claude-exec.ts. +function resolveCodexOnUnix(): string | undefined { + // Primary: a login shell sees the user's full PATH (~/.zprofile, nvm, homebrew, …). + try { + const out = execSync("/bin/sh -lc 'command -v codex'", { timeout: 5000, encoding: 'utf-8' }).trim(); + if (out && existsSync(out)) return out; + } catch { + // not found on the login-shell PATH + } + // Fallback: scan the runtime PATH and well-known install locations directly. + const pathDirs = (process.env.PATH ?? '') + .split(path.delimiter) + .map((d) => d.trim()) + .filter(Boolean); + const fromPath = pathDirs.map((dir) => path.join(dir, 'codex')); + for (const candidate of [...fromPath, ...commonInstallPaths('codex')]) { + if (existsSync(candidate)) return candidate; + } + return undefined; +} 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 033bf994..299de1ad 100644 --- a/apps/x/packages/core/src/code-mode/acp/manager.ts +++ b/apps/x/packages/core/src/code-mode/acp/manager.ts @@ -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; + 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; + } } // 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 new file mode 100644 index 00000000..acff79b5 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/shell-env.ts @@ -0,0 +1,41 @@ +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 a78b23f4..41ef5d76 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 } from 'fs'; +import { existsSync, readdirSync } from 'fs'; import { CodeModeAgentStatus } from './types.js'; const execAsync = promisify(exec); @@ -28,16 +28,54 @@ export function commonInstallPaths(binary: string): string[] { path.join(home, '.volta', 'bin', `${binary}.cmd`), ]; } - return [ + const dirs = [ '/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'), - ].map(dir => path.join(dir, binary)); + // 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; } async function probeShell(binary: string): Promise { diff --git a/apps/x/scripts/diagnose-code-mode.sh b/apps/x/scripts/diagnose-code-mode.sh new file mode 100644 index 00000000..16ebdd69 --- /dev/null +++ b/apps/x/scripts/diagnose-code-mode.sh @@ -0,0 +1,88 @@ +#!/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'