Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 271 additions & 0 deletions apps/x/CODE_MODE_ENGINES_PLAN.md

Large diffs are not rendered by default.

123 changes: 111 additions & 12 deletions apps/x/apps/main/forge.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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: [
{
Expand Down Expand Up @@ -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/');
},
}
Expand Down
21 changes: 21 additions & 0 deletions apps/x/apps/main/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)
Expand Down
124 changes: 102 additions & 22 deletions apps/x/apps/renderer/src/components/settings-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1759,52 +1759,122 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
type AgentStatus = { installed: boolean; signedIn: boolean }
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }

// Engine provisioning runs in the main process and keeps going even if the Settings
// dialog is closed. Track its state at MODULE level (not in the row component, which
// unmounts on close) so reopening Settings still shows the live % instead of the Enable
// button. A single persistent listener on the progress channel feeds this store.
type ProvState = { pct: number | null; error?: string }
const provStore: Record<string, ProvState | undefined> = {}
// Agents we provisioned this session — used to show "Ready" immediately on success
// without waiting for the async status refresh to round-trip (which caused the row to
// briefly flash the Enable button again).
const enabledOptimistic = new Set<string>()
const provListeners = new Set<() => void>()
let provChannelHooked = false

function notifyProv() { provListeners.forEach((l) => l()) }

function startProvisioning(agent: 'claude' | 'codex', onDone: () => void | Promise<void>): void {
if (provStore[agent] && !provStore[agent]!.error) return // already in flight
provStore[agent] = { pct: null }
notifyProv()
if (!provChannelHooked) {
provChannelHooked = true
window.ipc.on('codeMode:engineProgress', (p) => {
const cur = provStore[p.agent]
if (!cur) return
const pct = p.totalBytes ? Math.floor(((p.receivedBytes ?? 0) / p.totalBytes) * 100) : cur.pct
provStore[p.agent] = { pct }
notifyProv()
})
}
window.ipc.invoke('codeMode:provisionEngine', { agent })
.then((res) => {
if (res.success) {
// Mark installed optimistically so the row shows "Ready" the instant the flag
// clears — don't depend on the async status refresh (which re-renders the parent
// separately and left a window showing the Enable button). loadStatus still runs
// in the background to sync the real status.
enabledOptimistic.add(agent)
provStore[agent] = undefined
void onDone()
} else {
provStore[agent] = { pct: null, error: res.error ?? 'Failed to enable' }
}
})
.catch((e) => { provStore[agent] = { pct: null, error: e instanceof Error ? e.message : 'Failed to enable' } })
.finally(notifyProv)
}

function useProvisioning(agent: string): ProvState | undefined {
const [, force] = useState(0)
useEffect(() => {
const l = () => force((n) => n + 1)
provListeners.add(l)
return () => { provListeners.delete(l) }
}, [])
return provStore[agent]
}

function AgentStatusRow({
name,
installLink,
agent,
signInCommand,
status,
onProvisioned,
}: {
name: string
installLink: string
agent: 'claude' | 'codex'
signInCommand: string
status: AgentStatus | null
onProvisioned: () => void
}) {
const ready = status?.installed && status?.signedIn
const needsSignInOnly = status?.installed && !status?.signedIn
const prov = useProvisioning(agent)
const provisioning = prov !== undefined && prov.error === undefined
const error = prov?.error ?? null
const enable = useCallback(() => startProvisioning(agent, onProvisioned), [agent, onProvisioned])

// Treat a just-enabled engine as installed even before the status refresh lands.
const installed = (status?.installed ?? false) || enabledOptimistic.has(agent)
const ready = installed && status?.signedIn
return (
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
<Terminal className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{name}</div>
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Installed
<span className={cn("inline-flex items-center gap-1", installed ? "text-green-600" : "text-muted-foreground")}>
{installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
{installed ? 'Engine ready' : 'Not enabled'}
</span>
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Signed in
</span>
</div>
{error && <div className="text-xs text-red-600 mt-1 break-words">{error}</div>}
</div>
{ready ? (
{provisioning ? (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground shrink-0 tabular-nums">
<Loader2 className="size-3 animate-spin" />
{prov?.pct != null ? `${prov.pct}%` : null}
</span>
) : ready ? (
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
Ready
</span>
) : needsSignInOnly ? (
) : !installed ? (
<button
type="button"
onClick={enable}
className="rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:opacity-90 shrink-0"
>
Enable
</button>
) : (
<span className="text-xs text-muted-foreground shrink-0">
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
</span>
) : (
<a
href={installLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline shrink-0"
>
Install &amp; sign in
</a>
)}
</div>
)
Expand Down Expand Up @@ -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">
Expand All @@ -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>
Expand Down Expand Up @@ -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>
)}
Expand Down
Loading