diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 7806f6cd..dc2cc13f 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -5,6 +5,77 @@ 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, and the @openai/codex +// launcher-vs-platform-binary alias). We rebuild an npm-style nested node_modules — +// dereferencing symlinks and nesting on version conflict — which resolves correctly +// regardless of pnpm layout. Platform-specific optional deps that aren't installed +// for the current OS resolve to null and are skipped, so each OS ships its own binary. +function stageAcpAdapters(mainDir, destNodeModules) { + const fs = require('fs'); + const ADAPTERS = [ + '@agentclientprotocol/claude-agent-acp', + '@agentclientprotocol/codex-acp', + ]; + + // 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 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)) { + 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()); + } + return copied; +} + module.exports = { packagerConfig: { executableName: 'rowboat', @@ -29,17 +100,21 @@ module.exports = { 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. + // 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 node_modules, BUT always keep everything under `.package/` + // — that's our staged output, which now also includes the ACP adapters + their + // dependency closure (staged by the generateAssets hook). Without the `.package` + // exemption the /node_modules/ rule would strip the staged adapters and code mode + // 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: [ { @@ -178,6 +253,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..7d0828bc 100644 --- a/apps/x/packages/core/src/code-mode/acp/agents.ts +++ b/apps/x/packages/core/src/code-mode/acp/agents.ts @@ -1,5 +1,6 @@ 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'; @@ -20,13 +21,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) {