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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -5,6 +5,92 @@
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 / codex, ~200 MB each, shipped
// as platform-specific packages). Those are PROVISIONED on demand into
// ~/.rowboat/engines/<agent>/<version>/ and the adapters are pointed at them via
// CLAUDE_CODE_EXECUTABLE / CODEX_PATH (see packages/core/src/code-mode/acp/). Skipping
// them keeps each OS installer ~400 MB smaller while code mode stays fully functional.
function stageAcpAdapters(mainDir, destNodeModules) {
const fs = require('fs');
const ADAPTERS = [
'@agentclientprotocol/claude-agent-acp',
'@agentclientprotocol/codex-acp',
];
// The native engines, shipped as platform packages. Provisioned on demand instead
// (see comment above), so they're excluded from staging.
const isNativeEngine = (key) =>
/^@anthropic-ai\/claude-agent-sdk-(win32|darwin|linux)/.test(key) || // native claude
/^@openai\/codex-(win32|darwin|linux)/.test(key); // native codex
// 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 native engines — provisioned on demand: ${[...skippedEngines].join(', ')})`);
}
return copied;
}
module.exports = {
packagerConfig: {
executableName: 'rowboat',
@ -29,19 +115,23 @@ 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.
// Regexes are ANCHORED to the app root: .package/node_modules (where
// bundle.mjs stages the native node-pty module) must survive packaging.
// 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 (paths are 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 +285,15 @@ module.exports = {
fs.mkdirSync(rendererDest, { recursive: true });
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
// Stage the ACP coding-adapters (+ their JS dependency closure, minus native
// engines) into .package/acp/node_modules. 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/');
},
}

View file

@ -34,6 +34,7 @@ import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
import { ensureEngine } from '@x/core/dist/code-mode/acp/engine-provisioner.js';
import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js';
import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js';
import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js';
@ -714,6 +715,26 @@ export function setupIpcHandlers() {
'codeMode:checkAgentStatus': async () => {
return await checkCodeModeAgentStatus();
},
'codeMode:provisionEngine': async (_event, args) => {
// Download + install the agent's engine, streaming progress back to the
// requesting window so Settings can show a live bar. 'check' is instant — skip it.
try {
await ensureEngine(args.agent, {
onProgress: (p) => {
if (p.phase === 'check') return;
_event.sender.send('codeMode:engineProgress', {
agent: args.agent,
phase: p.phase,
receivedBytes: p.receivedBytes,
totalBytes: p.totalBytes,
});
},
});
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
'codeProject:add': async (_event, args) => {
const repo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
const project = await repo.add(args.path);

View file

@ -178,7 +178,7 @@ export function NewSessionDialog({
>
<div className="font-medium">{a === 'claude' ? 'Claude Code' : 'Codex'}</div>
<div className="text-[11px] text-muted-foreground">
{ready ? 'Ready' : agentStatus?.[a]?.installed ? 'Not signed in' : 'Not installed'}
{ready ? 'Ready' : agentStatus?.[a]?.installed ? 'Not signed in' : 'Enable in Settings'}
</div>
</button>
)

View file

@ -1759,52 +1759,122 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
type AgentStatus = { installed: boolean; signedIn: boolean }
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
// Engine provisioning runs in the main process and keeps going even if the Settings
// dialog is closed. Track its state at MODULE level (not in the row component, which
// unmounts on close) so reopening Settings still shows the live % instead of the Enable
// button. A single persistent listener on the progress channel feeds this store.
type ProvState = { pct: number | null; error?: string }
const provStore: Record<string, ProvState | undefined> = {}
// Agents we provisioned this session — used to show "Ready" immediately on success
// without waiting for the async status refresh to round-trip (which caused the row to
// briefly flash the Enable button again).
const enabledOptimistic = new Set<string>()
const provListeners = new Set<() => void>()
let provChannelHooked = false
function notifyProv() { provListeners.forEach((l) => l()) }
function startProvisioning(agent: 'claude' | 'codex', onDone: () => void | Promise<void>): void {
if (provStore[agent] && !provStore[agent]!.error) return // already in flight
provStore[agent] = { pct: null }
notifyProv()
if (!provChannelHooked) {
provChannelHooked = true
window.ipc.on('codeMode:engineProgress', (p) => {
const cur = provStore[p.agent]
if (!cur) return
const pct = p.totalBytes ? Math.floor(((p.receivedBytes ?? 0) / p.totalBytes) * 100) : cur.pct
provStore[p.agent] = { pct }
notifyProv()
})
}
window.ipc.invoke('codeMode:provisionEngine', { agent })
.then((res) => {
if (res.success) {
// Mark installed optimistically so the row shows "Ready" the instant the flag
// clears — don't depend on the async status refresh (which re-renders the parent
// separately and left a window showing the Enable button). loadStatus still runs
// in the background to sync the real status.
enabledOptimistic.add(agent)
provStore[agent] = undefined
void onDone()
} else {
provStore[agent] = { pct: null, error: res.error ?? 'Failed to enable' }
}
})
.catch((e) => { provStore[agent] = { pct: null, error: e instanceof Error ? e.message : 'Failed to enable' } })
.finally(notifyProv)
}
function useProvisioning(agent: string): ProvState | undefined {
const [, force] = useState(0)
useEffect(() => {
const l = () => force((n) => n + 1)
provListeners.add(l)
return () => { provListeners.delete(l) }
}, [])
return provStore[agent]
}
function AgentStatusRow({
name,
installLink,
agent,
signInCommand,
status,
onProvisioned,
}: {
name: string
installLink: string
agent: 'claude' | 'codex'
signInCommand: string
status: AgentStatus | null
onProvisioned: () => void
}) {
const ready = status?.installed && status?.signedIn
const needsSignInOnly = status?.installed && !status?.signedIn
const prov = useProvisioning(agent)
const provisioning = prov !== undefined && prov.error === undefined
const error = prov?.error ?? null
const enable = useCallback(() => startProvisioning(agent, onProvisioned), [agent, onProvisioned])
// Treat a just-enabled engine as installed even before the status refresh lands.
const installed = (status?.installed ?? false) || enabledOptimistic.has(agent)
const ready = installed && status?.signedIn
return (
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
<Terminal className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{name}</div>
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Installed
<span className={cn("inline-flex items-center gap-1", installed ? "text-green-600" : "text-muted-foreground")}>
{installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
{installed ? 'Engine ready' : 'Not enabled'}
</span>
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Signed in
</span>
</div>
{error && <div className="text-xs text-red-600 mt-1 break-words">{error}</div>}
</div>
{ready ? (
{provisioning ? (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground shrink-0 tabular-nums">
<Loader2 className="size-3 animate-spin" />
{prov?.pct != null ? `${prov.pct}%` : null}
</span>
) : ready ? (
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
Ready
</span>
) : needsSignInOnly ? (
) : !installed ? (
<button
type="button"
onClick={enable}
className="rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:opacity-90 shrink-0"
>
Enable
</button>
) : (
<span className="text-xs text-muted-foreground shrink-0">
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
</span>
) : (
<a
href={installLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline shrink-0"
>
Install &amp; sign in
</a>
)}
</div>
)
@ -1907,6 +1977,14 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
</p>
<p>
For each agent you want to use, you must have it{' '}
<strong className="text-foreground">installed and logged in</strong> on this machine: click{' '}
<strong className="text-foreground">Enable</strong> below to download its engine, and sign in by
running <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">claude login</code>{' '}
or <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">codex login</code>{' '}
in your terminal. Code mode uses that saved login.
</p>
</div>
<div className="space-y-2">
@ -1924,15 +2002,17 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="space-y-2">
<AgentStatusRow
name="Claude Code"
installLink="https://claude.ai/code"
agent="claude"
signInCommand="claude login"
status={status?.claude ?? null}
onProvisioned={loadStatus}
/>
<AgentStatusRow
name="Codex"
installLink="https://developers.openai.com/codex/cli"
agent="codex"
signInCommand="codex login"
status={status?.codex ?? null}
onProvisioned={loadStatus}
/>
</div>
</div>
@ -1984,8 +2064,8 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
<div className="text-amber-900 dark:text-amber-200">
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
account, then click Re-check.
Neither Claude Code nor Codex is ready. Click Enable above to download an engine, sign in with a
subscription account, then click Re-check.
</div>
</div>
)}