fix(code-mode): run the ACP adapter as Node under Electron + resolve it from main

Two runtime failures that only surfaced inside the packaged/bundled Electron app
(the headless harness used real node, so neither showed there):

- "ACP connection closed": the main process spawns the adapter via
  process.execPath, which inside Electron is the Electron binary, not node — so
  the child never ran as Node and its ACP stdio stream closed immediately. Set
  ELECTRON_RUN_AS_NODE=1 on the adapter env (a no-op under real node).
- "Cannot find module '@agentclientprotocol/claude-agent-acp'": the adapters were
  transitive (core) deps, unreachable from the esbuild-bundled main.cjs. Add them
  as direct deps of the main app so require.resolve finds them at runtime (and so
  they ship when packaged).

Also capture the adapter's stderr + exit code and enrich connection errors, so a
future failure reports the real cause instead of the opaque "ACP connection closed".
This commit is contained in:
Gagancreates 2026-06-02 00:57:53 +05:30
parent 10ce73ae24
commit a845eb14a6
4 changed files with 66 additions and 10 deletions

View file

@ -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",

View file

@ -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 };
}

View file

@ -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<Uint8Array>,
@ -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<string> {
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<void> {
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<PromptResponse> {
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<void> {

6
apps/x/pnpm-lock.yaml generated
View file

@ -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