diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 74cb1598..3330c3c0 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -13,6 +13,8 @@ "make": "electron-forge make" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.39.0", + "@agentclientprotocol/codex-acp": "^0.0.44", "@x/core": "workspace:*", "@x/shared": "workspace:*", "chokidar": "^4.0.3", diff --git a/apps/x/packages/core/src/code-mode/acp/agents.ts b/apps/x/packages/core/src/code-mode/acp/agents.ts index cf9e74e3..da06d8ea 100644 --- a/apps/x/packages/core/src/code-mode/acp/agents.ts +++ b/apps/x/packages/core/src/code-mode/acp/agents.ts @@ -49,5 +49,12 @@ export function getAgentLaunchSpec(agent: CodingAgent): AgentLaunchSpec { if (exe) env.CLAUDE_CODE_EXECUTABLE = exe; } + // We spawn the adapter with process.execPath. Inside Electron's main process + // that is the Electron binary, NOT node — so set ELECTRON_RUN_AS_NODE=1 to make + // it behave as a plain Node runtime. (Harmless under a real node process, which + // ignores the var.) Without this the child never runs as node and the ACP stdio + // stream closes immediately ("ACP connection closed"). + env.ELECTRON_RUN_AS_NODE = '1'; + return { command: process.execPath, args: [entry], env }; } diff --git a/apps/x/packages/core/src/code-mode/acp/client.ts b/apps/x/packages/core/src/code-mode/acp/client.ts index 8f73f7a7..5c2bd1ba 100644 --- a/apps/x/packages/core/src/code-mode/acp/client.ts +++ b/apps/x/packages/core/src/code-mode/acp/client.ts @@ -80,6 +80,10 @@ export class AcpClient { private child?: ChildProcess; private connection?: ClientSideConnection; private loadSession_ = false; + // Diagnostics: the adapter's stderr/exit are captured so a dropped connection + // reports WHY (e.g. a crash) instead of the SDK's bare "ACP connection closed". + private stderrTail = ''; + private exitInfo: string | null = null; constructor(opts: AcpClientOptions) { this.agent = opts.agent; @@ -104,9 +108,19 @@ export class AcpClient { const child = spawn(spec.command, spec.args, { cwd: this.cwd, env: spec.env, - stdio: ['pipe', 'pipe', 'inherit'], + // Capture stderr (not inherit) so we can attribute a dropped connection. + stdio: ['pipe', 'pipe', 'pipe'], }); this.child = child; + child.stderr?.on('data', (d: Buffer) => { + this.stderrTail = (this.stderrTail + d.toString()).slice(-4000); + }); + child.on('exit', (code, signal) => { + this.exitInfo = `adapter exited (code ${code}${signal ? `, signal ${signal}` : ''})`; + }); + child.on('error', (err) => { + this.stderrTail = (this.stderrTail + `\nspawn error: ${err.message}`).slice(-4000); + }); const stream = ndJsonStream( Writable.toWeb(child.stdin!) as WritableStream, @@ -115,24 +129,51 @@ export class AcpClient { const client = this.buildClient(); this.connection = new ClientSideConnection(() => client, stream); - const init = await this.connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, - }); - this.loadSession_ = init.agentCapabilities?.loadSession === true; + try { + const init = await 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'); + } } async newSession(): Promise { - const res = await this.conn().newSession({ cwd: this.cwd, mcpServers: [] }); - return res.sessionId; + try { + const res = await this.conn().newSession({ cwd: this.cwd, mcpServers: [] }); + return res.sessionId; + } catch (e) { + throw this.enrich(e, 'newSession'); + } } async loadSession(sessionId: string): Promise { - await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }); + try { + await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }); + } catch (e) { + throw this.enrich(e, 'loadSession'); + } } async prompt(sessionId: string, text: string): Promise { - return this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] }); + try { + return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] }); + } catch (e) { + throw this.enrich(e, 'prompt'); + } + } + + // Wrap a connection error with the adapter's exit/stderr so failures are + // self-explanatory rather than the SDK's opaque "ACP connection closed". + private enrich(err: unknown, phase: string): Error { + const base = err instanceof Error ? err.message : String(err); + const parts = [ + this.exitInfo, + this.stderrTail.trim() ? `adapter output: ${this.stderrTail.trim().slice(-1200)}` : '', + ].filter(Boolean); + return new Error(parts.length ? `${base} — ${parts.join(' | ')} [during ${phase}]` : `${base} [during ${phase}]`); } async cancel(sessionId: string): Promise { diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 02cb3068..65eb7f36 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -47,6 +47,12 @@ importers: apps/main: dependencies: + '@agentclientprotocol/claude-agent-acp': + specifier: ^0.39.0 + version: 0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1)) + '@agentclientprotocol/codex-acp': + specifier: ^0.0.44 + version: 0.0.44(zod@4.2.1) '@x/core': specifier: workspace:* version: link:../../packages/core