16 KiB
Code Mode — Managed Engine Provisioning Plan
Branch: feat/code-mode-managed-engines (off dev @ 8ce24ebb)
1. Problem & Goal
Code mode runs two coding agents — Claude Code and Codex — by spawning their ACP adapters, which in turn spawn a heavy native engine binary (~205 MB claude, ~194 MB codex). We need code mode to work in packaged releases with ~99% reliability for both agents, without shipping a ~400 MB installer.
Current state (HEAD = revert of #614)
- Packaged builds do not stage the ACP adapters, and
forge.config.cjsignore: /^\/node_modules\//strips them. At runtimeagents.tsresolves the adapter viarequire.resolve(...)then spawns it — which throwsCannot find module '@agentclientprotocol/...'. - Net: packaged code mode is broken in every release. It only works in
devbecause pnpm symlinks exist. There is no 400 MB bloat today — but no function either.
Why the two prior approaches are insufficient
- Bundle engines (A): +~400 MB per installer (one claude + one codex native binary per OS/arch). Works offline, but installer is huge.
- Drive from user's local install (B) — what #614 settled on and was reverted: requires the user to have both CLIs installed and logged in, correct version, on the right PATH. Depends on the user's machine → structurally cannot hit 99% (version skew, GUI-launch PATH stripping, missing installs). This is why #614 was reverted.
2. Chosen Architecture — Managed Engine Provisioning (validated against Conductor)
Split the problem into engine vs auth, treat them differently — exactly what Conductor (conductor.build) does:
- Engine = owned by the app. We provision version-pinned engine binaries into
app-support (
~/.rowboat/engines/<agent>/<version>/), download-on-demand on first use, sha256-verified, symlink/path-pinned. Not the user's global npm, not their PATH. → no version skew, no PATH quirks → this is what delivers the 99%. - Auth = reused from the user. The engines read existing credentials
(
~/.claudeAPI key / Pro / Max,~/.codexauth.json). No second login.status.tsalready inspects these.
Empirical proof from Conductor on this machine
- DMG is 123 MB — far smaller than the ~400 MB of engines → engines are not in the installer; they're downloaded after install.
- Layout observed at
~/Library/Application Support/com.conductor.app/:agent-binaries/claude/2.1.170/claude (222 MB, single Mach-O arm64) agent-binaries/codex/0.138.0/codex (205 MB, single Mach-O arm64) bin/claude -> agent-binaries/claude/2.1.170/claude (symlink to active version) bin/codex -> agent-binaries/codex/0.138.0/codex agent-binaries/.meta/claude-2.1.170.json = {sha256, size, downloaded_at_unix_ms, ...} - So: versioned dirs + stable symlink + sha256
.metaledger + download. We mirror this.
3. Concrete facts that make this clean (verified)
Adapters honor an external engine via env var (no code change in adapters needed)
- Claude —
@agentclientprotocol/claude-agent-acp@0.39.0,dist/acp-agent.js:39:if (process.env.CLAUDE_CODE_EXECUTABLE) return it;and line 1552pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? .... If unset and no bundled native dep → it throws "set CLAUDE_CODE_EXECUTABLE". - Codex —
@agentclientprotocol/codex-acp@0.0.44,dist/index.js:20900:const codexPath = process.env["CODEX_PATH"] ?? "codex";→spawn(codexPath, ["app-server"]). - Implication: provisioning + setting these two env vars is the entire engine story.
Our engine packages are already single self-contained binaries
@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156→ contains oneclaudeexecutable (+ LICENSE/README).@openai/codex@0.128.0-darwin-arm64→vendor/<target>/codex/codexnative binary plus a bundledrg(ripgrep) atvendor/<target>/path/rg— see Risk R1.
Pinned versions + platform package names (read from the installed adapter trees)
- Claude: adapter
claude-agent-acp@0.39.0→ engine@anthropic-ai/claude-agent-sdk@0.3.156. Platform optional deps@anthropic-ai/claude-agent-sdk-<platform>@0.3.156:darwin-arm64,darwin-x64,linux-x64,linux-arm64,linux-x64-musl,linux-arm64-musl,win32-x64,win32-arm64. - Codex: adapter
codex-acp@0.0.44→@openai/codex@^0.128.0(pnpm-patched, see R2). Platform deps aliased@openai/codex-<platform>→npm:@openai/codex@0.128.0-<platform>:darwin-arm64,darwin-x64,linux-x64,linux-arm64,win32-x64,win32-arm64.
4. Distribution source — npm platform packages, adapter-pinned (DECIDED)
We fetch the per-platform engine packages from the npm registry at the exact versions
our ACP adapters depend on, extract the native binary, and provision it into
~/.rowboat/engines/.... No self-host, no curl installer, no fallback.
- Tarball URL:
https://registry.npmjs.org/<pkg>/-/<file>-<version>.tgz- claude:
@anthropic-ai/claude-agent-sdk-<platform>@0.3.156→ extract theclaudebinary. - codex:
@openai/codex@0.128.0-<platform>→ extractvendor/<target>/codex/codexand keepvendor/<target>/path/rg(see R1).
- claude:
- Why adapter-pinned npm (not curl installer, not release bucket, not self-host):
- The binary is the exact version our adapter was built/tested against → ACP handshake guaranteed → the key to ~99% reliability.
- npm registry is highly available, immutable per-version, and the packument provides
dist.integrity(sha512) +dist.shasum(sha1) → integrity verification with zero infra. - The official
curl | bashinstaller is for end-users: it installs globally to~/.local/binand auto-updates in the background — exactly what we must avoid. We want an isolated, pinned, app-managed copy that never moves under the adapter. (Conductor likewise fetches the raw binary rather than running the installer.)
- Versions are read from our lockfile at build time and embedded in
engine-manifest.json, so the manifest can never drift from the adapters we ship.
Reference: how this relates to Conductor / official installers
- Conductor self-hosts the same kind of native binaries on
storage.conductor.build(raw/.gz/.zst+{url,gzipUrl,zstdUrl,sha256}manifest), pairing the adapters with recent standalone CLI versions (claude 2.1.x, codex 0.138). That proves recent versions work — but we deliberately pin to the adapter's own engine version for determinism. - Official Claude releases also publish a GPG-signed
manifest.json(SHA256/platform) atdownloads.claude.ai/claude-code-releases/<ver>/. We don't use it now (npm is simpler and adapter-matched), but it's the upgrade path if we ever want the latest CLI line. - If we later want control/availability/compression, we can mirror these npm tarballs to our own bucket — runtime stays identical, only manifest URLs change.
Locked decisions
- Source: npm platform packages, adapter-pinned versions (user).
- No self-host (user).
- Always provision; no fallback to a user's pre-installed
claude/codex(user). Reverted path B is fully retired. - Codex pnpm patch — IRRELEVANT (user): it targets the JS launcher; we point
CODEX_PATHat the native binary, so it does not apply. (Risk R2 removed.)
5. Design
5.1 Build-time: generate an engine manifest + stage adapter JS
(a) Engine manifest (engine-manifest.json, embedded in the app).
A build script reads the installed adapter dependency trees and emits, per agent:
{
"claude": {
"version": "0.3.156",
"platforms": {
"darwin-arm64": { "pkg": "@anthropic-ai/claude-agent-sdk-darwin-arm64",
"tarball": "https://registry.npmjs.org/.../-/...-0.3.156.tgz",
"integrity": "sha512-...",
"binRelPath": "claude" },
"...": {}
}
},
"codex": {
"version": "0.128.0",
"platforms": {
"darwin-arm64": { "pkg": "@openai/codex-darwin-arm64",
"tarball": "https://registry.npmjs.org/...",
"integrity": "sha512-...",
"binRelPath": "vendor/aarch64-apple-darwin/codex/codex",
"extraPaths": ["vendor/aarch64-apple-darwin/path/rg"] }
}
}
}
- Versions + tarball URLs + integrity are pulled from the lockfile / npm packument so the manifest is always in sync with the adapters we ship. (Generating it at build time sidesteps hardcoding fragile package names.)
binRelPath/extraPathscapture where the executable (and codex'srg) live inside each tarball.
(b) Stage the ACP adapter JS into the package (the part of #614 we KEEP).
The adapters themselves (tiny, ~15 MB total incl. their non-native JS deps) must exist on
disk in packaged builds so agents.ts can resolve + spawn them. In forge.config.cjs
generateAssets, stage the two adapters and their non-native production dependency
closure into .package/acp/node_modules (npm-style nested layout), and exempt
.package from the node_modules ignore rule. We drop #614's native-engine staging
entirely — engines come from provisioning, not the bundle.
5.2 Runtime: engine provisioner (new module in packages/core)
New file: packages/core/src/code-mode/acp/engine-provisioner.ts.
ensureEngine(agent): Promise<{ executablePath: string }>
1. Read manifest entry for (agent, currentPlatform). Error clearly if unsupported.
2. dir = ~/.rowboat/engines/<agent>/<version>/
3. If dir exists AND .meta/<agent>-<version>.json sha256 matches → return binPath.
4. Else acquire a cross-process lock (avoid double download), then:
a. Download tarball to a temp file, streaming, with progress events.
b. Verify integrity (sha512/sha256 from manifest).
c. Extract into a temp dir, then atomic rename into <version>/ (tar gzip).
d. chmod +x the binary (and codex's rg) on unix.
e. Write .meta/<agent>-<version>.json {sha256, size, downloaded_at_unix_ms}.
5. Return absolute path to the engine executable (binRelPath joined to dir).
- Progress + cancellation surfaced over IPC for the first-run "Downloading engine…" UI.
- Offline / failure → typed error with a clear, actionable message (not a hang).
- Resumability / atomicity: download to temp, verify, then rename; never leave a half-extracted version dir that passes the existence check.
5.3 Wire provisioning into launch
In agents.ts getAgentLaunchSpec() (currently sets only CLAUDE_CODE_EXECUTABLE):
- Make it (or its caller in
client.ts/manager.ts)await ensureEngine(agent)first, then set:- claude →
env.CLAUDE_CODE_EXECUTABLE = <provisioned claude> - codex →
env.CODEX_PATH = <provisioned codex>(and ensure itsrgsibling resolves; keep the vendor dir layout intact so codex finds../path/rg).
- claude →
- Keep
ELECTRON_RUN_AS_NODE=1for the adapter spawn (unchanged). - The provisioner replaces both the bundled-engine dependency and the reverted "resolve user's local install" path. (Optionally: if a healthy provisioned engine is absent but a compatible local install exists, we may fall back to it — but the default and reliable path is the provisioned engine. Decide in review; default = provisioned only.)
5.4 Status & UX (status.ts + renderer)
checkCodeModeAgentStatus()becomes: engine = provisioned? (instead of "installed on PATH?"); auth = existing credentials present? (unchanged logic).- First-run flow: user picks agent → if engine missing, show "Downloading engine (~200 MB), one time…" with progress; on completion, proceed. Subsequent uses: instant.
- Clear error states: download failed / offline / unsupported platform / auth missing.
6. File-by-file change plan
| File | Change |
|---|---|
apps/main/forge.config.cjs |
Stage adapter JS closure into .package/acp/node_modules; exempt .package from node_modules ignore; generate + copy engine-manifest.json. (No native engines staged.) |
apps/main/scripts/gen-engine-manifest.mjs (new) |
Build-time: read lockfile/packuments → emit engine-manifest.json (versions, tarball URLs, integrity, bin paths). |
packages/core/src/code-mode/acp/engine-provisioner.ts (new) |
ensureEngine() — download, verify, extract, lock, progress, .meta ledger. |
packages/core/src/code-mode/acp/agents.ts |
Resolve adapter from staged .package/acp first (fallback to node_modules in dev); await ensureEngine; set CLAUDE_CODE_EXECUTABLE/CODEX_PATH to provisioned binaries. |
packages/core/src/code-mode/acp/client.ts / manager.ts |
Await provisioning before spawn; surface provisioning progress/errors; keep startup deadline. |
packages/core/src/code-mode/status.ts |
Engine status = provisioned (not PATH); keep auth checks. |
apps/main/src/ipc.ts + preload + renderer |
IPC for provisioning progress + first-run download UI + error states. |
| CI (optional) | Smoke: packaged app boots each adapter, provisions a (small fake) engine, sets env var, answers ACP initialize; offline → clear error not a hang. |
7. Edge cases & risks
- R1 — Codex needs
rg. The codex platform package bundles ripgrep atvendor/<target>/path/rg. We must extract/keep the vendor layout so codex finds it (don't extract the bare binary). Verify codexapp-serverboots from the provisioned dir. - R2 — Codex pnpm patch. RESOLVED (irrelevant). The patch targets the JS launcher; we
point
CODEX_PATHat the native binary, so it does not apply. - R3 — Platform/arch matrix. Manifest must cover darwin x64/arm64, linux x64/arm64
(+musl for claude), win32 x64/arm64. Windows engine is
claude.exe/codex.exe. - R4 — Integrity & supply chain. Always verify the manifest integrity hash before chmod/exec. Treat a hash mismatch as a hard failure.
- R5 — Disk + upgrades. Versioned dirs accumulate. Add a cleanup that keeps only the active pinned version per agent.
- R6 — First-run network. Required once per agent; cached forever after. Must be a clear, cancellable UX, never a silent hang (reuse the #614 startup-deadline lesson).
- R7 — code signing / Gatekeeper (macOS). Downloaded native binaries aren't covered by our app's signature. Verify they run under Gatekeeper (they're already signed/notarized by Anthropic/OpenAI; quarantine attr may need clearing). Conductor runs them fine from app-support — confirm we do too.
- R8 —
engine-manifeststaleness. Manifest must regenerate whenever the adapter/engine versions change; tie generation into the build so it can't drift.
8. Phasing
- P1 — Packaging fix (unblocks dev→packaged parity): stage adapter JS into
.package, resolver checks staged path first. Verify packaged code mode can at least spawn the adapter. (Independent of provisioning; the part of #614 worth keeping.) - P2 — Provisioner (core):
ensureEngine()+ manifest + wire env vars. Verify both engines provision + boot from~/.rowboat/engineson macOS arm64. - P3 — UX + status: first-run download UI, status panel, error states.
- P4 — Cross-platform + CI smoke: matrix manifest, mac/linux/win smoke.
- P5 — Polish: version cleanup, cancellation, offline messaging, optional local-install fallback.
9. Decisions & remaining questions
Resolved (locked)
- Source = npm platform packages, adapter-pinned versions (not self-host, not curl installer, not release bucket).
- Always provision; no local-install fallback.
- Codex pnpm patch irrelevant.
Still open (can decide during implementation)
- Provision timing: on first code-mode use (lazy) vs first app launch (eager, background download). Plan assumes lazy + cached.
- Version-dir cleanup: keep only the active pinned version per agent (R5).
- Verify R1 (codex
rg) and R7 (Gatekeeper on downloaded macOS binaries) during P2.