diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 7596cb2c2..2b08b0f6b 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -123,23 +123,54 @@ export class RpcClient { stdin: "pipe", }); - // Process lines in background + // Wait for the "ready" signal or process exit + const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers(); + let readySettled = false; + + // Process lines in background, intercepting the ready signal const lines = readJsonl(this.#process.stdout, this.#abortController.signal); void (async () => { for await (const line of lines) { + if (!readySettled && isRecord(line) && line.type === "ready") { + readySettled = true; + readyResolve(); + continue; + } this.#handleLine(line); } - })().catch(() => {}); + // Stream ended without ready signal — process exited + if (!readySettled) { + readySettled = true; + readyReject(new Error(`Agent process exited before ready. Stderr: ${this.#process?.peekStderr() ?? ""}`)); + } + })().catch((err: Error) => { + if (!readySettled) { + readySettled = true; + readyReject(err); + } + }); - // Wait a moment for process to initialize - await Bun.sleep(100); + // Also race against process exit (in case stdout closes before we read it) + void this.#process.exited.then((exitCode: number) => { + if (!readySettled) { + readySettled = true; + readyReject( + new Error(`Agent process exited with code ${exitCode}. Stderr: ${this.#process?.peekStderr() ?? ""}`), + ); + } + }); - const exitCode = await Promise.race([this.#process.exited, Bun.sleep(500).then(() => null)]); - if (exitCode !== null) { - throw new Error( - `Agent process exited immediately with code ${exitCode}. Stderr: ${this.#process.peekStderr()}`, - ); - } + // Timeout to prevent hanging forever + void Bun.sleep(30000).then(() => { + if (!readySettled) { + readySettled = true; + readyReject( + new Error(`Timeout waiting for agent to become ready. Stderr: ${this.#process?.peekStderr() ?? ""}`), + ); + } + }); + + await readyPromise; } /** diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 4988913f3..66c75893a 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -36,6 +36,8 @@ export type { * Listens for JSON commands on stdin, outputs events and responses on stdout. */ export async function runRpcMode(session: AgentSession): Promise { + // Signal to RPC clients that the server is ready to accept commands + process.stdout.write(`${JSON.stringify({ type: "ready" })}\n`); const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { process.stdout.write(`${JSON.stringify(obj)}\n`); }; diff --git a/packages/coding-agent/test/rpc-client.start.test.ts b/packages/coding-agent/test/rpc-client.start.test.ts index 7009b93ef..f938564b1 100644 --- a/packages/coding-agent/test/rpc-client.start.test.ts +++ b/packages/coding-agent/test/rpc-client.start.test.ts @@ -12,6 +12,6 @@ describe("RpcClient.start", () => { env: { PI_NO_TITLE: "1" }, }); - await expect(client.start()).rejects.toThrow('Unknown provider "__missing_provider__"'); + await expect(client.start()).rejects.toThrow(/Unknown provider.*__missing_provider__/); }); });