mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-18 20:15:20 +02:00
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:
parent
8ce24ebb33
commit
2ddec07712
15 changed files with 1175 additions and 96 deletions
271
apps/x/CODE_MODE_ENGINES_PLAN.md
Normal file
271
apps/x/CODE_MODE_ENGINES_PLAN.md
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# 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.cjs`
|
||||
`ignore: /^\/node_modules\//` strips them. At runtime `agents.ts` resolves the
|
||||
adapter via `require.resolve(...)` then spawns it — which **throws
|
||||
`Cannot find module '@agentclientprotocol/...'`**.
|
||||
- **Net: packaged code mode is broken in every release.** It only works in `dev`
|
||||
because 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:
|
||||
|
||||
1. **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%.
|
||||
2. **Auth = reused from the user.** The engines read existing credentials
|
||||
(`~/.claude` API key / Pro / Max, `~/.codex` auth.json). No second login. `status.ts`
|
||||
already 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 `.meta` ledger + 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 1552 `pathToClaudeCodeExecutable: 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 one `claude`
|
||||
executable (+ LICENSE/README).
|
||||
- `@openai/codex@0.128.0-darwin-arm64` → `vendor/<target>/codex/codex` native binary
|
||||
**plus a bundled `rg` (ripgrep) at `vendor/<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 the `claude` binary.
|
||||
- codex: `@openai/codex@0.128.0-<platform>` → extract `vendor/<target>/codex/codex`
|
||||
**and keep `vendor/<target>/path/rg`** (see R1).
|
||||
- **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 | bash` installer is for end-users: it installs globally to
|
||||
`~/.local/bin` and **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) at
|
||||
`downloads.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_PATH` at 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:
|
||||
```jsonc
|
||||
{
|
||||
"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` / `extraPaths` capture where the executable (and codex's `rg`) 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 its `rg` sibling resolves;
|
||||
keep the vendor dir layout intact so codex finds `../path/rg`).
|
||||
- Keep `ELECTRON_RUN_AS_NODE=1` for 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 <agent> 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 at
|
||||
`vendor/<target>/path/rg`. We must extract/keep the vendor layout so codex finds it
|
||||
(don't extract the bare binary). Verify codex `app-server` boots from the provisioned dir.
|
||||
- **R2 — Codex pnpm patch. RESOLVED (irrelevant).** The patch targets the JS launcher; we
|
||||
point `CODEX_PATH` at 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-manifest` staleness.** Manifest must regenerate whenever the adapter/engine
|
||||
versions change; tie generation into the build so it can't drift.
|
||||
|
||||
## 8. Phasing
|
||||
|
||||
1. **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.)
|
||||
2. **P2 — Provisioner (core):** `ensureEngine()` + manifest + wire env vars. Verify both
|
||||
engines provision + boot from `~/.rowboat/engines` on macOS arm64.
|
||||
3. **P3 — UX + status:** first-run download UI, status panel, error states.
|
||||
4. **P4 — Cross-platform + CI smoke:** matrix manifest, mac/linux/win smoke.
|
||||
5. **P5 — Polish:** version cleanup, cancellation, offline messaging, optional local-install
|
||||
fallback.
|
||||
|
||||
## 9. Decisions & remaining questions
|
||||
|
||||
### Resolved (locked)
|
||||
1. **Source = npm platform packages, adapter-pinned versions** (not self-host, not curl
|
||||
installer, not release bucket).
|
||||
2. **Always provision; no local-install fallback.**
|
||||
3. **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.
|
||||
|
|
@ -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/');
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
)}
|
||||
|
|
|
|||
111
apps/x/packages/core/scripts/gen-engine-manifest.mjs
Normal file
111
apps/x/packages/core/scripts/gen-engine-manifest.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Build-time generator for the code-mode engine manifest.
|
||||
//
|
||||
// Code mode provisions each agent's native engine on demand by downloading the
|
||||
// per-platform npm package AT THE EXACT VERSION OUR ACP ADAPTER DEPENDS ON, so the
|
||||
// adapter <-> engine handshake is always in lockstep. This script reads those pinned
|
||||
// versions from the installed adapter package.json files, queries the npm registry for
|
||||
// each platform package's tarball URL + integrity, and writes them to
|
||||
// `src/code-mode/acp/engine-manifest.ts` (committed; regenerate on a version bump).
|
||||
//
|
||||
// Usage: node scripts/gen-engine-manifest.mjs (run from packages/core)
|
||||
//
|
||||
// Why a committed .ts (not a fetched-at-build .json): it needs no network at app build
|
||||
// time, works offline in dev, is reviewable in PRs, and esbuild inlines it into the
|
||||
// packaged main bundle.
|
||||
|
||||
import { createRequire } from 'module';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as path from 'path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const REGISTRY = 'https://registry.npmjs.org';
|
||||
|
||||
// Platform keys we publish for each agent. These mirror the optionalDependencies of the
|
||||
// engine packages. claude ships musl variants; codex does not.
|
||||
const CLAUDE_PLATFORMS = [
|
||||
'darwin-arm64', 'darwin-x64',
|
||||
'linux-x64', 'linux-arm64', 'linux-x64-musl', 'linux-arm64-musl',
|
||||
'win32-x64', 'win32-arm64',
|
||||
];
|
||||
const CODEX_PLATFORMS = [
|
||||
'darwin-arm64', 'darwin-x64',
|
||||
'linux-x64', 'linux-arm64',
|
||||
'win32-x64', 'win32-arm64',
|
||||
];
|
||||
|
||||
// Read a pinned dependency version from an adapter's package.json, stripping any range
|
||||
// prefix (^, ~). createRequire resolves the adapter from the installed node_modules.
|
||||
function pinnedDep(adapterPkg, depName) {
|
||||
const pj = require(`${adapterPkg}/package.json`);
|
||||
const spec = (pj.dependencies || {})[depName] || (pj.optionalDependencies || {})[depName];
|
||||
if (!spec) throw new Error(`${adapterPkg} has no dependency on ${depName}`);
|
||||
return spec.replace(/^[\^~]/, '');
|
||||
}
|
||||
|
||||
// Fetch a single version's manifest from the registry and return its dist coordinates.
|
||||
async function distFor(pkg, version) {
|
||||
const url = `${REGISTRY}/${pkg}/${version}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return null; // platform not published for this version
|
||||
throw new Error(`registry ${res.status} for ${pkg}@${version}`);
|
||||
}
|
||||
const doc = await res.json();
|
||||
return { tarball: doc.dist.tarball, integrity: doc.dist.integrity };
|
||||
}
|
||||
|
||||
// Build one agent's manifest entry: { version, platforms: { <key>: {tarball, integrity} } }.
|
||||
// pkgFor(platform) -> { pkg, version } gives the registry coordinates for each platform.
|
||||
async function buildAgent(version, platforms, pkgFor) {
|
||||
const out = { version, platforms: {} };
|
||||
for (const key of platforms) {
|
||||
const { pkg, version: pv } = pkgFor(key);
|
||||
const dist = await distFor(pkg, pv);
|
||||
if (!dist) {
|
||||
console.warn(` ! ${pkg}@${pv} not found on registry — skipping ${key}`);
|
||||
continue;
|
||||
}
|
||||
out.platforms[key] = { pkg, pkgVersion: pv, ...dist };
|
||||
console.log(` ✓ ${key}: ${pkg}@${pv}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Pinned engine versions, read straight from the adapters we ship.
|
||||
const claudeVersion = pinnedDep('@agentclientprotocol/claude-agent-acp', '@anthropic-ai/claude-agent-sdk');
|
||||
const codexVersion = pinnedDep('@agentclientprotocol/codex-acp', '@openai/codex');
|
||||
console.log(`claude engine: @anthropic-ai/claude-agent-sdk@${claudeVersion}`);
|
||||
console.log(`codex engine: @openai/codex@${codexVersion}`);
|
||||
|
||||
const manifest = {
|
||||
claude: await buildAgent(claudeVersion, CLAUDE_PLATFORMS, (key) => ({
|
||||
pkg: `@anthropic-ai/claude-agent-sdk-${key}`,
|
||||
version: claudeVersion,
|
||||
})),
|
||||
codex: await buildAgent(codexVersion, CODEX_PLATFORMS, (key) => ({
|
||||
// codex publishes platform binaries as VERSIONS of @openai/codex
|
||||
// (e.g. 0.128.0-darwin-arm64), not as separate package names.
|
||||
pkg: '@openai/codex',
|
||||
version: `${codexVersion}-${key}`,
|
||||
})),
|
||||
};
|
||||
|
||||
const outPath = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..', 'src', 'code-mode', 'acp', 'engine-manifest.ts',
|
||||
);
|
||||
const banner =
|
||||
'// AUTO-GENERATED by packages/core/scripts/gen-engine-manifest.mjs — do not edit by hand.\n' +
|
||||
'// Regenerate after bumping the @agentclientprotocol/*-acp adapter (engine) versions.\n' +
|
||||
'// Maps each agent + platform to the npm tarball + integrity of its native engine.\n\n';
|
||||
const body = `export const ENGINE_MANIFEST = ${JSON.stringify(manifest, null, 4)} as const;\n`;
|
||||
writeFileSync(outPath, banner + body);
|
||||
console.log(`\nWrote ${outPath}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
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 { getProvisionedEnginePath } from './engine-provisioner.js';
|
||||
import { loginShellPath } from './shell-env.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
|
|
@ -20,13 +22,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 <entry>`. createRequire lets
|
||||
// us resolve workspace/pnpm-installed packages from this module's location.
|
||||
// absolute path so we can spawn it directly with `node <entry>`.
|
||||
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<string, string> };
|
||||
const pkgJson = require(pkgJsonPath) as { bin?: string | Record<string, string> };
|
||||
const bin = pkgJson.bin;
|
||||
const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined;
|
||||
if (!rel) {
|
||||
|
|
@ -39,14 +64,31 @@ 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;
|
||||
// Graft the user's login-shell PATH onto the engine's env. GUI (Finder) launches
|
||||
// inherit launchd's stripped PATH, so tools the engine spawns — git, gh, rg, bash —
|
||||
// would otherwise fail with "command not found" even though they work from a
|
||||
// terminal. No-op on Windows / when the probe fails.
|
||||
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 the adapter at the engine the user already enabled in Settings. We do NOT
|
||||
// download here — getProvisionedEnginePath throws a clear "enable it in Settings"
|
||||
// error if the engine isn't present, so code mode never triggers a surprise
|
||||
// mid-chat download. The version is locked to what the adapter was built against, so
|
||||
// the ACP handshake is always compatible. The adapters honor these env vars
|
||||
// (claude: CLAUDE_CODE_EXECUTABLE, codex: CODEX_PATH).
|
||||
const executablePath = getProvisionedEnginePath(agent);
|
||||
if (agent === 'claude') {
|
||||
env.CLAUDE_CODE_EXECUTABLE = executablePath;
|
||||
// Make the claude-agent-sdk log the exact spawn command + claude's stderr to
|
||||
// ~/.claude/debug/sdk-*.txt, so a failed/hung launch has a diagnosable trail
|
||||
// instead of a silently dropped connection.
|
||||
env.DEBUG_CLAUDE_AGENT_SDK = '1';
|
||||
} else {
|
||||
env.CODEX_PATH = executablePath;
|
||||
}
|
||||
|
||||
// We spawn the adapter with process.execPath. Inside Electron's main process
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@ 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. 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 (CI smoke test; escape hatch for MCP-heavy setups).
|
||||
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,19 +140,40 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
// Race a startup-phase request against the deadline so a wedged engine fails with a
|
||||
// clear, enriched error instead of leaving the turn pending forever. Callers dispose
|
||||
// the client on failure, which kills the spawned adapter.
|
||||
private async withStartupTimeout<T>(work: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(
|
||||
`timed out after ${STARTUP_TIMEOUT_MS / 1000}s — the ${this.agent} engine failed to ` +
|
||||
`complete startup (it may be wedged or misconfigured)`,
|
||||
));
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
return await Promise.race([work, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async newSession(): Promise<string> {
|
||||
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,7 +182,7 @@ export class AcpClient {
|
|||
|
||||
async loadSession(sessionId: string): Promise<void> {
|
||||
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');
|
||||
}
|
||||
|
|
|
|||
100
apps/x/packages/core/src/code-mode/acp/engine-manifest.ts
Normal file
100
apps/x/packages/core/src/code-mode/acp/engine-manifest.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// AUTO-GENERATED by packages/core/scripts/gen-engine-manifest.mjs — do not edit by hand.
|
||||
// Regenerate after bumping the @agentclientprotocol/*-acp adapter (engine) versions.
|
||||
// Maps each agent + platform to the npm tarball + integrity of its native engine.
|
||||
|
||||
export const ENGINE_MANIFEST = {
|
||||
"claude": {
|
||||
"version": "0.3.156",
|
||||
"platforms": {
|
||||
"darwin-arm64": {
|
||||
"pkg": "@anthropic-ai/claude-agent-sdk-darwin-arm64",
|
||||
"pkgVersion": "0.3.156",
|
||||
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.156.tgz",
|
||||
"integrity": "sha512-IkjcS9dqAUlD4Nb62L9AZtmAXCa+FV4ul8lIlyXXUprh3nlecbKsWOXVd/GORrzAhMmynJaX4+iV1JiutFKXUA=="
|
||||
},
|
||||
"darwin-x64": {
|
||||
"pkg": "@anthropic-ai/claude-agent-sdk-darwin-x64",
|
||||
"pkgVersion": "0.3.156",
|
||||
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.156.tgz",
|
||||
"integrity": "sha512-6PKi5fPmGRuzXu+Em/iwLmPG3mqg0hl92wcTU8fmChqyNtxhxsjCw7LTbdFqp/05o5NeZVVV4k3p7YUv5IFD6g=="
|
||||
},
|
||||
"linux-x64": {
|
||||
"pkg": "@anthropic-ai/claude-agent-sdk-linux-x64",
|
||||
"pkgVersion": "0.3.156",
|
||||
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.156.tgz",
|
||||
"integrity": "sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg=="
|
||||
},
|
||||
"linux-arm64": {
|
||||
"pkg": "@anthropic-ai/claude-agent-sdk-linux-arm64",
|
||||
"pkgVersion": "0.3.156",
|
||||
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.156.tgz",
|
||||
"integrity": "sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ=="
|
||||
},
|
||||
"linux-x64-musl": {
|
||||
"pkg": "@anthropic-ai/claude-agent-sdk-linux-x64-musl",
|
||||
"pkgVersion": "0.3.156",
|
||||
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.156.tgz",
|
||||
"integrity": "sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA=="
|
||||
},
|
||||
"linux-arm64-musl": {
|
||||
"pkg": "@anthropic-ai/claude-agent-sdk-linux-arm64-musl",
|
||||
"pkgVersion": "0.3.156",
|
||||
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.156.tgz",
|
||||
"integrity": "sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw=="
|
||||
},
|
||||
"win32-x64": {
|
||||
"pkg": "@anthropic-ai/claude-agent-sdk-win32-x64",
|
||||
"pkgVersion": "0.3.156",
|
||||
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.156.tgz",
|
||||
"integrity": "sha512-/PofeTWoiKgnWNSNk0wG4SsRn22GGLmnLhg2R94WcNhCRFOyOTmiZcYH2DBlWZBIRVTZDsSfa/Pl1DyPvYCGKw=="
|
||||
},
|
||||
"win32-arm64": {
|
||||
"pkg": "@anthropic-ai/claude-agent-sdk-win32-arm64",
|
||||
"pkgVersion": "0.3.156",
|
||||
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.156.tgz",
|
||||
"integrity": "sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"codex": {
|
||||
"version": "0.128.0",
|
||||
"platforms": {
|
||||
"darwin-arm64": {
|
||||
"pkg": "@openai/codex",
|
||||
"pkgVersion": "0.128.0-darwin-arm64",
|
||||
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-darwin-arm64.tgz",
|
||||
"integrity": "sha512-w+6zohfHx/kHBdles/CyFKaY57u9I3nK8QI9+NrdwMliKA0b7xn13yblRNkMpe09j6vL1oAWoxYsMOQ/vjBGug=="
|
||||
},
|
||||
"darwin-x64": {
|
||||
"pkg": "@openai/codex",
|
||||
"pkgVersion": "0.128.0-darwin-x64",
|
||||
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-darwin-x64.tgz",
|
||||
"integrity": "sha512-SDbn6fO22Puy8xmMIbZi4f2znMrUEPwABApke4mo+4ihaauwuVjeqzXvW5SPJz5ty/bG11/mSupQgReT7T8BBw=="
|
||||
},
|
||||
"linux-x64": {
|
||||
"pkg": "@openai/codex",
|
||||
"pkgVersion": "0.128.0-linux-x64",
|
||||
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-linux-x64.tgz",
|
||||
"integrity": "sha512-2lnSPA05CRRuKAzFW8BCmmNCSieDcToLwfC2ALLbBYilGLgzhRibjlDglK9F1BkEzfohSSWJu4PBbRu/aG60lQ=="
|
||||
},
|
||||
"linux-arm64": {
|
||||
"pkg": "@openai/codex",
|
||||
"pkgVersion": "0.128.0-linux-arm64",
|
||||
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-linux-arm64.tgz",
|
||||
"integrity": "sha512-+SvH73H60qvCXFuQGP/EsmR//s1hHMBR22PvJkXvM/hdnTIGucx+JqRUjAWdmmQ1IU6j3kgwVvdLW/6ICB+M6w=="
|
||||
},
|
||||
"win32-x64": {
|
||||
"pkg": "@openai/codex",
|
||||
"pkgVersion": "0.128.0-win32-x64",
|
||||
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-win32-x64.tgz",
|
||||
"integrity": "sha512-k3jmUAFrzkUtvjGTXvSKjQqJLLlzjxp/VoHJDYedgmXUn6j70HxK38IwapzmnYfiBiTuzETvGwjXHzZgzKjhoQ=="
|
||||
},
|
||||
"win32-arm64": {
|
||||
"pkg": "@openai/codex",
|
||||
"pkgVersion": "0.128.0-win32-arm64",
|
||||
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-win32-arm64.tgz",
|
||||
"integrity": "sha512-ECJvsqmYFdA9pn42xxK3Odp/G16AjmBW0BglX8L0PwPjqbstbmlew9bfHf7xvL+SNfNl4NmyotW0+RNo1phgaA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
284
apps/x/packages/core/src/code-mode/acp/engine-provisioner.ts
Normal file
284
apps/x/packages/core/src/code-mode/acp/engine-provisioner.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
// Code-mode engine provisioner.
|
||||
//
|
||||
// Code mode drives Claude Code / Codex through their ACP adapters, which spawn a heavy
|
||||
// native engine binary (~200 MB each). We do NOT bundle those engines into the installer
|
||||
// (that would add ~400 MB). Instead we provision them on demand: the first time an agent
|
||||
// is used we download the per-platform npm package AT THE EXACT VERSION OUR ADAPTER WAS
|
||||
// BUILT AGAINST (see engine-manifest.ts), verify its integrity, and extract it into
|
||||
// ~/.rowboat/engines/<agent>/<version>/. Subsequent runs reuse the cached copy.
|
||||
//
|
||||
// The adapters are then pointed at the provisioned binary via CLAUDE_CODE_EXECUTABLE /
|
||||
// CODEX_PATH (see agents.ts). This keeps the installer small while making code mode work
|
||||
// out of the box, with no dependency on the user having a global claude/codex install.
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { ENGINE_MANIFEST } from './engine-manifest.js';
|
||||
import type { CodingAgent } from './types.js';
|
||||
|
||||
export const ENGINES_ROOT = path.join(os.homedir(), '.rowboat', 'engines');
|
||||
|
||||
interface PlatformEntry {
|
||||
pkg: string;
|
||||
pkgVersion: string;
|
||||
tarball: string;
|
||||
integrity: string;
|
||||
}
|
||||
|
||||
export interface EngineProgress {
|
||||
phase: 'check' | 'download' | 'verify' | 'extract' | 'done';
|
||||
/** Bytes received so far (download phase). */
|
||||
receivedBytes?: number;
|
||||
/** Total bytes, when the server reports content-length. */
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
export interface EnsureEngineOptions {
|
||||
onProgress?: (p: EngineProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ProvisionedEngine {
|
||||
executablePath: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
// Map this process's platform/arch (+ libc on linux) to a manifest platform key for the
|
||||
// given agent. Returns null when no engine is published for this platform.
|
||||
function platformKey(agent: CodingAgent): string | null {
|
||||
const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null;
|
||||
if (!arch) return null;
|
||||
const plats = ENGINE_MANIFEST[agent].platforms as Record<string, PlatformEntry>;
|
||||
const candidates: string[] = [];
|
||||
if (process.platform === 'darwin') {
|
||||
candidates.push(`darwin-${arch}`);
|
||||
} else if (process.platform === 'win32') {
|
||||
candidates.push(`win32-${arch}`);
|
||||
} else if (process.platform === 'linux') {
|
||||
// Prefer a musl build on musl systems (Alpine); fall back to the glibc build.
|
||||
if (isMuslLibc()) candidates.push(`linux-${arch}-musl`);
|
||||
candidates.push(`linux-${arch}`);
|
||||
}
|
||||
return candidates.find((c) => c in plats) ?? null;
|
||||
}
|
||||
|
||||
// glibc builds expose a glibcVersionRuntime in the process report header; musl (Alpine)
|
||||
// does not. Same heuristic Node's native-addon loaders use.
|
||||
function isMuslLibc(): boolean {
|
||||
try {
|
||||
const report = (process as unknown as { report?: { getReport?: () => unknown } }).report?.getReport?.();
|
||||
const header = (report as { header?: Record<string, unknown> } | undefined)?.header;
|
||||
return !(header && 'glibcVersionRuntime' in header);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Locate the engine executable inside an extracted package root. We extract the whole npm
|
||||
// package (so codex's bundled ripgrep travels with it), then find the binary.
|
||||
function locateExecutable(agent: CodingAgent, root: string): string | null {
|
||||
if (agent === 'claude') {
|
||||
for (const name of ['claude', 'claude.exe']) {
|
||||
const p = path.join(root, name);
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// codex: vendor/<target-triple>/codex/codex[.exe]
|
||||
const vendor = path.join(root, 'vendor');
|
||||
if (!fs.existsSync(vendor)) return null;
|
||||
for (const triple of fs.readdirSync(vendor)) {
|
||||
for (const name of ['codex', 'codex.exe']) {
|
||||
const p = path.join(vendor, triple, 'codex', name);
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// True when this OS/arch has a published engine for `agent` — i.e. we can provision it.
|
||||
// (Used for status: code mode no longer requires a user-installed CLI.)
|
||||
export function isEngineSupported(agent: CodingAgent): boolean {
|
||||
return platformKey(agent) !== null;
|
||||
}
|
||||
|
||||
// True when the pinned engine for `agent` is already downloaded and intact locally.
|
||||
export function isEngineProvisioned(agent: CodingAgent): boolean {
|
||||
const version = ENGINE_MANIFEST[agent].version;
|
||||
const versionDir = path.join(ENGINES_ROOT, agent, version);
|
||||
const metaPath = path.join(ENGINES_ROOT, agent, '.meta', `${agent}-${version}.json`);
|
||||
return locateExecutable(agent, versionDir) !== null && fs.existsSync(metaPath);
|
||||
}
|
||||
|
||||
const AGENT_LABEL: Record<CodingAgent, string> = { claude: 'Claude Code', codex: 'Codex' };
|
||||
|
||||
// Return the provisioned engine's executable path, or throw a clear, user-facing error.
|
||||
// The chat/run path uses this — we deliberately do NOT download here: the engine must be
|
||||
// enabled up front in Settings → Code Mode, so the user never eats a surprise ~200 MB
|
||||
// download mid-conversation. ensureEngine() (the downloading path) is driven only by the
|
||||
// Settings "Enable" action.
|
||||
export function getProvisionedEnginePath(agent: CodingAgent): string {
|
||||
const version = ENGINE_MANIFEST[agent].version;
|
||||
const exe = locateExecutable(agent, path.join(ENGINES_ROOT, agent, version));
|
||||
if (!exe) {
|
||||
throw new Error(
|
||||
`${AGENT_LABEL[agent]} isn't enabled yet. Open Settings → Code Mode and click Enable to download it.`,
|
||||
);
|
||||
}
|
||||
return exe;
|
||||
}
|
||||
|
||||
// Remove every provisioned version of `agent` except `keepVersion`, plus its stale
|
||||
// .meta entries. Called after a successful install so old engines don't pile up across
|
||||
// version bumps. Best-effort — never throws (cleanup must not fail a good install).
|
||||
function pruneOldVersions(agent: CodingAgent, keepVersion: string): void {
|
||||
const agentRoot = path.join(ENGINES_ROOT, agent);
|
||||
try {
|
||||
for (const name of fs.readdirSync(agentRoot)) {
|
||||
// Keep the active version, the meta dir, and any in-flight temp dirs.
|
||||
if (name === keepVersion || name === '.meta' || name.startsWith('.tmp-')) continue;
|
||||
const full = path.join(agentRoot, name);
|
||||
try {
|
||||
if (fs.statSync(full).isDirectory()) fs.rmSync(full, { recursive: true, force: true });
|
||||
} catch { /* ignore a single stubborn entry */ }
|
||||
}
|
||||
const metaDir = path.join(agentRoot, '.meta');
|
||||
if (fs.existsSync(metaDir)) {
|
||||
for (const f of fs.readdirSync(metaDir)) {
|
||||
if (f !== `${agent}-${keepVersion}.json`) {
|
||||
try { fs.rmSync(path.join(metaDir, f), { force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* agentRoot unreadable — nothing to prune */ }
|
||||
}
|
||||
|
||||
async function downloadTo(url: string, dest: string, opts: EnsureEngineOptions): Promise<void> {
|
||||
opts.onProgress?.({ phase: 'download', receivedBytes: 0 });
|
||||
const res = await fetch(url, { signal: opts.signal });
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`Code mode: engine download failed (HTTP ${res.status}) — ${url}`);
|
||||
}
|
||||
const total = Number(res.headers.get('content-length')) || undefined;
|
||||
let received = 0;
|
||||
const body = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
body.on('data', (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
opts.onProgress?.({ phase: 'download', receivedBytes: received, totalBytes: total });
|
||||
});
|
||||
await pipeline(body, fs.createWriteStream(dest));
|
||||
}
|
||||
|
||||
// Verify the tarball against the npm Subresource Integrity string ("sha512-<base64>").
|
||||
function verifyIntegrity(file: string, integrity: string): void {
|
||||
const dash = integrity.indexOf('-');
|
||||
const algo = integrity.slice(0, dash);
|
||||
const expected = integrity.slice(dash + 1);
|
||||
const actual = crypto.createHash(algo).update(fs.readFileSync(file)).digest('base64');
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Code mode: engine integrity check failed (${algo}) — download may be corrupt.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract an npm tarball, stripping its leading `package/` component so the package
|
||||
// contents land directly in destDir. Uses the system tar (bsdtar on macOS/Windows 10+,
|
||||
// GNU tar on Linux) — all support -xzf and --strip-components.
|
||||
function extractTarball(tarPath: string, destDir: string): void {
|
||||
const r = spawnSync('tar', ['-xzf', tarPath, '-C', destDir, '--strip-components=1'], { stdio: 'pipe' });
|
||||
if (r.status !== 0) {
|
||||
const err = r.stderr?.toString().trim() || r.error?.message || `tar exited ${r.status}`;
|
||||
throw new Error(`Code mode: failed to extract engine — ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the engine (and codex's bundled ripgrep) executable on unix.
|
||||
function makeExecutable(agent: CodingAgent, root: string, exe: string): void {
|
||||
fs.chmodSync(exe, 0o755);
|
||||
if (agent === 'codex') {
|
||||
const vendor = path.join(root, 'vendor');
|
||||
for (const triple of fs.existsSync(vendor) ? fs.readdirSync(vendor) : []) {
|
||||
const rg = path.join(vendor, triple, 'path', 'rg');
|
||||
if (fs.existsSync(rg)) fs.chmodSync(rg, 0o755);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the pinned engine for `agent` is provisioned locally, downloading it on first
|
||||
* use. Returns the absolute path to the engine executable. Idempotent and cached.
|
||||
*/
|
||||
export async function ensureEngine(agent: CodingAgent, opts: EnsureEngineOptions = {}): Promise<ProvisionedEngine> {
|
||||
const entry = ENGINE_MANIFEST[agent];
|
||||
const version = entry.version;
|
||||
const key = platformKey(agent);
|
||||
if (!key) {
|
||||
throw new Error(`Code mode: no ${agent} engine is available for ${process.platform}/${process.arch}.`);
|
||||
}
|
||||
const plat = (entry.platforms as Record<string, PlatformEntry>)[key];
|
||||
|
||||
const agentRoot = path.join(ENGINES_ROOT, agent);
|
||||
const versionDir = path.join(agentRoot, version);
|
||||
const metaDir = path.join(agentRoot, '.meta');
|
||||
const metaPath = path.join(metaDir, `${agent}-${version}.json`);
|
||||
|
||||
opts.onProgress?.({ phase: 'check' });
|
||||
// Fast path: already provisioned and intact.
|
||||
const existing = locateExecutable(agent, versionDir);
|
||||
if (existing && fs.existsSync(metaPath)) {
|
||||
opts.onProgress?.({ phase: 'done' });
|
||||
return { executablePath: existing, version };
|
||||
}
|
||||
|
||||
// Download to a unique temp dir, verify, extract, then swap into place. Concurrent
|
||||
// callers each use their own temp dir; the final rename is idempotent (same content).
|
||||
fs.mkdirSync(agentRoot, { recursive: true });
|
||||
const tmpRoot = fs.mkdtempSync(path.join(agentRoot, `.tmp-${version}-`));
|
||||
try {
|
||||
const tarPath = path.join(tmpRoot, 'engine.tgz');
|
||||
await downloadTo(plat.tarball, tarPath, opts);
|
||||
|
||||
opts.onProgress?.({ phase: 'verify' });
|
||||
verifyIntegrity(tarPath, plat.integrity);
|
||||
|
||||
opts.onProgress?.({ phase: 'extract' });
|
||||
const extractDir = path.join(tmpRoot, 'pkg');
|
||||
fs.mkdirSync(extractDir);
|
||||
extractTarball(tarPath, extractDir);
|
||||
|
||||
const exe = locateExecutable(agent, extractDir);
|
||||
if (!exe) {
|
||||
throw new Error(`Code mode: ${agent} engine binary not found in the downloaded package.`);
|
||||
}
|
||||
if (process.platform !== 'win32') makeExecutable(agent, extractDir, exe);
|
||||
|
||||
// Swap the freshly extracted package into the versioned location.
|
||||
if (fs.existsSync(versionDir)) fs.rmSync(versionDir, { recursive: true, force: true });
|
||||
fs.renameSync(extractDir, versionDir);
|
||||
|
||||
const finalExe = locateExecutable(agent, versionDir);
|
||||
if (!finalExe) {
|
||||
throw new Error(`Code mode: ${agent} engine binary missing after install.`);
|
||||
}
|
||||
fs.mkdirSync(metaDir, { recursive: true });
|
||||
fs.writeFileSync(metaPath, JSON.stringify({
|
||||
version,
|
||||
platform: key,
|
||||
integrity: plat.integrity,
|
||||
binRelPath: path.relative(versionDir, finalExe),
|
||||
}, null, 2));
|
||||
|
||||
// A new version is in place — remove superseded versions so old engines
|
||||
// (~200 MB each) don't accumulate after a bump. Best-effort.
|
||||
pruneOldVersions(agent, version);
|
||||
|
||||
opts.onProgress?.({ phase: 'done' });
|
||||
return { executablePath: finalExe, version };
|
||||
} finally {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
// Dispose the client if startup fails (e.g. the startup-timeout fires) so the
|
||||
// spawned adapter process doesn't leak.
|
||||
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) {
|
||||
client.dispose();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Resume the persisted session for this chat when possible; else start a new one
|
||||
|
|
|
|||
40
apps/x/packages/core/src/code-mode/acp/shell-env.ts
Normal file
40
apps/x/packages/core/src/code-mode/acp/shell-env.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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 the engines spawn off process.env.PATH misses Homebrew/nvm/npm-global tools.
|
||||
// The provisioned engine itself runs from an absolute path, but claude/codex spawn
|
||||
// git, gh, rg, bash, etc. themselves — without this they fail with "command not found"
|
||||
// on a Finder launch even though they work from a terminal.
|
||||
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;
|
||||
}
|
||||
|
|
@ -3,8 +3,8 @@ import { promisify } from 'util';
|
|||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { CodeModeAgentStatus } from './types.js';
|
||||
import { isEngineProvisioned } from './acp/engine-provisioner.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
|
|
@ -40,30 +40,6 @@ export function commonInstallPaths(binary: string): string[] {
|
|||
].map(dir => path.join(dir, binary));
|
||||
}
|
||||
|
||||
async function probeShell(binary: string): Promise<boolean> {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const { stdout } = await execAsync(`where ${binary}`, { timeout: 5000 });
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
// Login shell so ~/.zprofile / ~/.bashrc PATH additions are visible —
|
||||
// essential for Homebrew, nvm, asdf, volta installs on macOS GUI launches.
|
||||
const { stdout } = await execAsync(`/bin/sh -lc 'command -v ${binary}'`, { timeout: 5000 });
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isInstalled(binary: string): Promise<boolean> {
|
||||
if (await probeShell(binary)) return true;
|
||||
// Fallback: scan well-known install locations directly.
|
||||
for (const candidate of commonInstallPaths(binary)) {
|
||||
if (existsSync(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
|
|
@ -186,14 +162,15 @@ async function checkCodexSignedIn(): Promise<boolean> {
|
|||
export { decodeJwtPayload };
|
||||
|
||||
export async function checkCodeModeAgentStatus(): Promise<CodeModeAgentStatus> {
|
||||
const [claudeInstalled, codexInstalled, claudeSignedIn, codexSignedIn] = await Promise.all([
|
||||
isInstalled('claude'),
|
||||
isInstalled('codex'),
|
||||
const [claudeSignedIn, codexSignedIn] = await Promise.all([
|
||||
checkClaudeSignedIn(),
|
||||
checkCodexSignedIn(),
|
||||
]);
|
||||
// `installed` means the engine is provisioned (downloaded) locally — the user has
|
||||
// clicked Enable in Settings → Code Mode. We no longer look for a global claude/codex
|
||||
// CLI on PATH; code mode runs our own pinned engine from ~/.rowboat/engines.
|
||||
return {
|
||||
claude: { installed: claudeInstalled, signedIn: claudeSignedIn },
|
||||
codex: { installed: codexInstalled, signedIn: codexSignedIn },
|
||||
claude: { installed: isEngineProvisioned('claude'), signedIn: claudeSignedIn },
|
||||
codex: { installed: isEngineProvisioned('codex'), signedIn: codexSignedIn },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -481,6 +481,22 @@ const ipcSchemas = {
|
|||
codex: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
|
||||
}),
|
||||
},
|
||||
// Download + install an agent's native engine (the Settings "Enable" action).
|
||||
// Streams progress over the 'codeMode:engineProgress' push channel while it runs.
|
||||
'codeMode:provisionEngine': {
|
||||
req: z.object({ agent: z.enum(['claude', 'codex']) }),
|
||||
res: z.object({ success: z.boolean(), error: z.string().optional() }),
|
||||
},
|
||||
// Push (main -> renderer): engine provisioning progress for the Settings UI.
|
||||
'codeMode:engineProgress': {
|
||||
req: z.object({
|
||||
agent: z.enum(['claude', 'codex']),
|
||||
phase: z.enum(['download', 'verify', 'extract', 'done']),
|
||||
receivedBytes: z.number().optional(),
|
||||
totalBytes: z.number().optional(),
|
||||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
// ==========================================================================
|
||||
// Code section: project registry + coding sessions
|
||||
// ==========================================================================
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ packages:
|
|||
- packages/*
|
||||
|
||||
allowBuilds:
|
||||
core-js: set this to true or false
|
||||
electron: set this to true or false
|
||||
electron-winstaller: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
fs-xattr: set this to true or false
|
||||
macos-alias: set this to true or false
|
||||
protobufjs: set this to true or false
|
||||
core-js: true
|
||||
electron: true
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
fs-xattr: true
|
||||
macos-alias: true
|
||||
node-pty: true
|
||||
protobufjs: true
|
||||
|
||||
catalog:
|
||||
vitest: 4.1.7
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue