diff --git a/package-lock.json b/package-lock.json index 79eb7b36f..7f83b1cea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9220,6 +9220,7 @@ "name": "@gsd/pi-coding-agent", "version": "2.51.0", "dependencies": { + "@gsd/rpc-client": "2.51.0", "@mariozechner/jiti": "^2.6.2", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", diff --git a/packages/pi-coding-agent/package.json b/packages/pi-coding-agent/package.json index 7d3cb624e..37ddf8fe6 100644 --- a/packages/pi-coding-agent/package.json +++ b/packages/pi-coding-agent/package.json @@ -20,6 +20,7 @@ "copy-assets": "node scripts/copy-assets.cjs" }, "dependencies": { + "@gsd/rpc-client": "2.51.0", "@mariozechner/jiti": "^2.6.2", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", diff --git a/packages/pi-coding-agent/src/modes/rpc/jsonl.ts b/packages/pi-coding-agent/src/modes/rpc/jsonl.ts index 5392defef..42cbbe93a 100644 --- a/packages/pi-coding-agent/src/modes/rpc/jsonl.ts +++ b/packages/pi-coding-agent/src/modes/rpc/jsonl.ts @@ -1,64 +1 @@ -import type { Readable } from "node:stream"; -import { StringDecoder } from "node:string_decoder"; - -/** - * Serialize a single strict JSONL record. - * - * Framing is LF-only. Payload strings may contain other Unicode separators such as - * U+2028 and U+2029. Clients must split records on `\n` only. - */ -export function serializeJsonLine(value: unknown): string { - return `${JSON.stringify(value)}\n`; -} - -/** - * Attach an LF-only JSONL reader to a stream. - * - * This intentionally does not use Node readline. Readline splits on additional - * Unicode separators that are valid inside JSON strings and therefore does not - * implement strict JSONL framing. - */ -export function attachJsonlLineReader(stream: Readable, onLine: (line: string) => void): () => void { - const decoder = new StringDecoder("utf8"); - let buffer = ""; - - const emitLine = (line: string) => { - onLine(line.endsWith("\r") ? line.slice(0, -1) : line); - }; - - const onData = (chunk: string | Buffer) => { - buffer += typeof chunk === "string" ? chunk : decoder.write(chunk); - - while (true) { - const newlineIndex = buffer.indexOf("\n"); - if (newlineIndex === -1) { - return; - } - - emitLine(buffer.slice(0, newlineIndex)); - buffer = buffer.slice(newlineIndex + 1); - } - }; - - const onEnd = () => { - buffer += decoder.end(); - if (buffer.length > 0) { - emitLine(buffer); - buffer = ""; - } - }; - - const onError = (_err: Error) => { - // Stream errors are non-fatal for JSONL reading - }; - - stream.on("data", onData); - stream.on("end", onEnd); - stream.on("error", onError); - - return () => { - stream.off("data", onData); - stream.off("end", onEnd); - stream.off("error", onError); - }; -} +export { serializeJsonLine, attachJsonlLineReader } from '@gsd/rpc-client'; diff --git a/packages/rpc-client/README.md b/packages/rpc-client/README.md new file mode 100644 index 000000000..500322f67 --- /dev/null +++ b/packages/rpc-client/README.md @@ -0,0 +1,125 @@ +# @gsd/rpc-client + +Standalone RPC client SDK for GSD. Spawn the agent process, perform a v2 protocol handshake, send commands, and consume typed events via an async generator — all in a few lines of TypeScript. + +Zero internal dependencies. Ships its own inlined types. + +## Installation + +```bash +npm install @gsd/rpc-client +``` + +## Quick Start + +```typescript +import { RpcClient } from '@gsd/rpc-client'; + +const client = new RpcClient({ cwd: process.cwd() }); +await client.start(); +const { sessionId } = await client.init({ clientId: 'my-app' }); +console.log(`Session: ${sessionId}`); + +await client.prompt('Create a hello world script'); +for await (const event of client.events()) { + if (event.type === 'execution_complete') break; + console.log(event.type); +} +await client.shutdown(); +``` + +## API + +### Constructor + +```typescript +const client = new RpcClient(options?: RpcClientOptions); +``` + +| Option | Type | Description | +|------------|--------------------------|------------------------------------------| +| `cliPath` | `string` | Path to the CLI entry point | +| `cwd` | `string` | Working directory for the agent | +| `env` | `Record` | Environment variables | +| `provider` | `string` | AI provider (e.g. `"anthropic"`) | +| `model` | `string` | Model ID (e.g. `"claude-sonnet"`) | +| `args` | `string[]` | Additional CLI arguments | + +### Lifecycle + +| Method | Description | +|---------------|------------------------------------------------| +| `start()` | Spawn the agent process | +| `init(opts?)` | v2 handshake — returns `sessionId`, capabilities | +| `shutdown()` | Graceful shutdown | +| `stop()` | Force-kill the process | + +### Commands + +| Method | Description | +|--------------------------------|----------------------------------------| +| `prompt(message, images?)` | Send a prompt | +| `steer(message, images?)` | Interrupt with a steering message | +| `followUp(message, images?)` | Queue a follow-up message | +| `abort()` | Abort current operation | +| `subscribe(events)` | Subscribe to event types (`["*"]` for all) | + +### Events + +```typescript +// Async generator — recommended +for await (const event of client.events()) { + console.log(event.type); +} + +// Callback-based +const unsubscribe = client.onEvent((event) => { + console.log(event.type); +}); +``` + +### Helpers + +| Method | Description | +|---------------------------------------|------------------------------------------| +| `waitForIdle(timeout?)` | Wait for `agent_end` event | +| `collectEvents(timeout?)` | Collect events until idle | +| `promptAndWait(message, images?, t?)` | Send prompt and collect events | + +### Session & Model + +| Method | Description | +|----------------------------------|-----------------------------------| +| `getState()` | Get session state | +| `setModel(provider, modelId)` | Set model | +| `cycleModel()` | Cycle to next model | +| `getAvailableModels()` | List available models | +| `setThinkingLevel(level)` | Set thinking level | +| `cycleThinkingLevel()` | Cycle thinking level | +| `compact(instructions?)` | Compact session context | +| `getSessionStats()` | Get session statistics | +| `bash(command)` | Execute a bash command | +| `newSession(parent?)` | Start a new session | +| `sendUIResponse(id, response)` | Respond to extension UI requests | + +## Type Exports + +All protocol types are exported from the package root: + +```typescript +import type { + RpcCommand, + RpcResponse, + RpcInitResult, + RpcExecutionCompleteEvent, + RpcCostUpdateEvent, + RpcV2Event, + SessionStats, + SdkAgentEvent, + RpcClientOptions, +} from '@gsd/rpc-client'; +``` + +## License + +MIT diff --git a/packages/rpc-client/examples/basic-usage.ts b/packages/rpc-client/examples/basic-usage.ts new file mode 100644 index 000000000..bd821aa86 --- /dev/null +++ b/packages/rpc-client/examples/basic-usage.ts @@ -0,0 +1,13 @@ +import { RpcClient } from '@gsd/rpc-client'; + +const client = new RpcClient({ cwd: process.cwd() }); +await client.start(); +const { sessionId } = await client.init({ clientId: 'my-app' }); +console.log(`Session: ${sessionId}`); + +await client.prompt('Create a hello world script'); +for await (const event of client.events()) { + if (event.type === 'execution_complete') break; + console.log(event.type); +} +await client.shutdown(); diff --git a/packages/rpc-client/package.json b/packages/rpc-client/package.json index 50461c856..e6e51e4a9 100644 --- a/packages/rpc-client/package.json +++ b/packages/rpc-client/package.json @@ -14,6 +14,10 @@ "files": [ "dist" ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node --test dist/rpc-client.test.js" + }, "engines": { "node": ">=22.0.0" } diff --git a/packages/rpc-client/src/index.ts b/packages/rpc-client/src/index.ts new file mode 100644 index 000000000..329e36560 --- /dev/null +++ b/packages/rpc-client/src/index.ts @@ -0,0 +1,10 @@ +/** + * @gsd/rpc-client — standalone RPC client SDK for GSD. + * + * Re-exports all types, JSONL utilities, and the RpcClient class. + */ + +export * from "./rpc-types.js"; +export { serializeJsonLine, attachJsonlLineReader } from "./jsonl.js"; +export { RpcClient } from "./rpc-client.js"; +export type { RpcClientOptions, RpcEventListener, SdkAgentEvent } from "./rpc-client.js"; diff --git a/packages/rpc-client/src/jsonl.ts b/packages/rpc-client/src/jsonl.ts new file mode 100644 index 000000000..5392defef --- /dev/null +++ b/packages/rpc-client/src/jsonl.ts @@ -0,0 +1,64 @@ +import type { Readable } from "node:stream"; +import { StringDecoder } from "node:string_decoder"; + +/** + * Serialize a single strict JSONL record. + * + * Framing is LF-only. Payload strings may contain other Unicode separators such as + * U+2028 and U+2029. Clients must split records on `\n` only. + */ +export function serializeJsonLine(value: unknown): string { + return `${JSON.stringify(value)}\n`; +} + +/** + * Attach an LF-only JSONL reader to a stream. + * + * This intentionally does not use Node readline. Readline splits on additional + * Unicode separators that are valid inside JSON strings and therefore does not + * implement strict JSONL framing. + */ +export function attachJsonlLineReader(stream: Readable, onLine: (line: string) => void): () => void { + const decoder = new StringDecoder("utf8"); + let buffer = ""; + + const emitLine = (line: string) => { + onLine(line.endsWith("\r") ? line.slice(0, -1) : line); + }; + + const onData = (chunk: string | Buffer) => { + buffer += typeof chunk === "string" ? chunk : decoder.write(chunk); + + while (true) { + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) { + return; + } + + emitLine(buffer.slice(0, newlineIndex)); + buffer = buffer.slice(newlineIndex + 1); + } + }; + + const onEnd = () => { + buffer += decoder.end(); + if (buffer.length > 0) { + emitLine(buffer); + buffer = ""; + } + }; + + const onError = (_err: Error) => { + // Stream errors are non-fatal for JSONL reading + }; + + stream.on("data", onData); + stream.on("end", onEnd); + stream.on("error", onError); + + return () => { + stream.off("data", onData); + stream.off("end", onEnd); + stream.off("error", onError); + }; +} diff --git a/packages/rpc-client/src/rpc-client.test.ts b/packages/rpc-client/src/rpc-client.test.ts new file mode 100644 index 000000000..9fcb7874f --- /dev/null +++ b/packages/rpc-client/src/rpc-client.test.ts @@ -0,0 +1,568 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import { serializeJsonLine, attachJsonlLineReader } from "./jsonl.js"; +import type { + RpcInitResult, + RpcExecutionCompleteEvent, + RpcCostUpdateEvent, + RpcProtocolVersion, + SessionStats, + RpcV2Event, +} from "./rpc-types.js"; +import { RpcClient } from "./rpc-client.js"; +import type { SdkAgentEvent } from "./rpc-client.js"; + +// ============================================================================ +// JSONL Tests +// ============================================================================ + +describe("serializeJsonLine", () => { + it("produces valid JSON terminated with LF", () => { + const result = serializeJsonLine({ type: "test", value: 42 }); + assert.ok(result.endsWith("\n"), "must end with LF"); + const parsed = JSON.parse(result.trim()); + assert.equal(parsed.type, "test"); + assert.equal(parsed.value, 42); + }); + + it("serializes strings with special characters", () => { + const result = serializeJsonLine({ msg: "hello\nworld" }); + assert.ok(result.endsWith("\n")); + // The embedded \n must be escaped inside the JSON — only the trailing LF is the framing delimiter + const lines = result.split("\n"); + // Should be exactly 2 parts: the JSON line and the empty string after trailing LF + assert.equal(lines.length, 2); + assert.equal(lines[1], ""); + const parsed = JSON.parse(lines[0]); + assert.equal(parsed.msg, "hello\nworld"); + }); + + it("handles empty objects", () => { + const result = serializeJsonLine({}); + assert.equal(result, "{}\n"); + }); +}); + +describe("attachJsonlLineReader", () => { + it("splits on LF correctly", async () => { + const stream = new PassThrough(); + const lines: string[] = []; + + attachJsonlLineReader(stream, (line) => lines.push(line)); + + stream.write('{"a":1}\n{"b":2}\n'); + stream.end(); + + // Let microtask queue flush + await new Promise((r) => setTimeout(r, 10)); + + assert.equal(lines.length, 2); + assert.equal(JSON.parse(lines[0]).a, 1); + assert.equal(JSON.parse(lines[1]).b, 2); + }); + + it("handles chunked data across boundaries", async () => { + const stream = new PassThrough(); + const lines: string[] = []; + + attachJsonlLineReader(stream, (line) => lines.push(line)); + + // Write in fragments that split mid-line + stream.write('{"type":"hel'); + stream.write('lo"}\n{"type":"w'); + stream.write('orld"}\n'); + stream.end(); + + await new Promise((r) => setTimeout(r, 10)); + + assert.equal(lines.length, 2); + assert.equal(JSON.parse(lines[0]).type, "hello"); + assert.equal(JSON.parse(lines[1]).type, "world"); + }); + + it("emits trailing data on stream end", async () => { + const stream = new PassThrough(); + const lines: string[] = []; + + attachJsonlLineReader(stream, (line) => lines.push(line)); + + stream.write('{"final":true}'); + stream.end(); + + await new Promise((r) => setTimeout(r, 10)); + + assert.equal(lines.length, 1); + assert.equal(JSON.parse(lines[0]).final, true); + }); + + it("returns a detach function that stops reading", async () => { + const stream = new PassThrough(); + const lines: string[] = []; + + const detach = attachJsonlLineReader(stream, (line) => lines.push(line)); + + stream.write('{"a":1}\n'); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(lines.length, 1); + + detach(); + + stream.write('{"b":2}\n'); + stream.end(); + await new Promise((r) => setTimeout(r, 10)); + + // Should still be 1 — detach removed listeners + assert.equal(lines.length, 1); + }); + + it("strips CR from CRLF line endings", async () => { + const stream = new PassThrough(); + const lines: string[] = []; + + attachJsonlLineReader(stream, (line) => lines.push(line)); + + stream.write('{"v":1}\r\n'); + stream.end(); + + await new Promise((r) => setTimeout(r, 10)); + + assert.equal(lines.length, 1); + assert.equal(JSON.parse(lines[0]).v, 1); + }); +}); + +// ============================================================================ +// Type Shape Tests +// ============================================================================ + +describe("type shapes", () => { + it("RpcInitResult has protocolVersion, sessionId, capabilities", () => { + const init: RpcInitResult = { + protocolVersion: 2, + sessionId: "sess_123", + capabilities: { + events: ["execution_complete", "cost_update"], + commands: ["prompt", "steer"], + }, + }; + assert.equal(init.protocolVersion, 2); + assert.equal(init.sessionId, "sess_123"); + assert.ok(Array.isArray(init.capabilities.events)); + assert.ok(Array.isArray(init.capabilities.commands)); + }); + + it("RpcExecutionCompleteEvent has required fields", () => { + const event: RpcExecutionCompleteEvent = { + type: "execution_complete", + runId: "run_abc", + status: "completed", + stats: { + sessionFile: "/tmp/session.json", + sessionId: "sess_123", + userMessages: 5, + assistantMessages: 5, + toolCalls: 3, + toolResults: 3, + totalMessages: 10, + tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100, total: 1800 }, + cost: 0.05, + }, + }; + assert.equal(event.type, "execution_complete"); + assert.equal(event.runId, "run_abc"); + assert.equal(event.status, "completed"); + assert.ok(event.stats); + assert.equal(event.stats.sessionId, "sess_123"); + }); + + it("RpcCostUpdateEvent has required fields", () => { + const event: RpcCostUpdateEvent = { + type: "cost_update", + runId: "run_abc", + turnCost: 0.01, + cumulativeCost: 0.05, + tokens: { input: 500, output: 200, cacheRead: 100, cacheWrite: 50 }, + }; + assert.equal(event.type, "cost_update"); + assert.equal(event.runId, "run_abc"); + assert.equal(event.turnCost, 0.01); + assert.equal(event.cumulativeCost, 0.05); + assert.ok(event.tokens); + }); + + it("SessionStats has all expected fields", () => { + const stats: SessionStats = { + sessionFile: "/tmp/session.json", + sessionId: "s1", + userMessages: 10, + assistantMessages: 10, + toolCalls: 5, + toolResults: 5, + totalMessages: 20, + tokens: { input: 2000, output: 1000, cacheRead: 500, cacheWrite: 200, total: 3700 }, + cost: 0.10, + }; + assert.equal(stats.sessionId, "s1"); + assert.equal(stats.userMessages, 10); + assert.equal(stats.tokens.total, 3700); + assert.equal(stats.cost, 0.10); + }); + + it("RpcProtocolVersion accepts 1 and 2", () => { + const v1: RpcProtocolVersion = 1; + const v2: RpcProtocolVersion = 2; + assert.equal(v1, 1); + assert.equal(v2, 2); + }); + + it("RpcV2Event discriminated union covers both event types", () => { + const events: RpcV2Event[] = [ + { + type: "execution_complete", + runId: "r1", + status: "completed", + stats: { + sessionFile: undefined, + sessionId: "s1", + userMessages: 1, + assistantMessages: 1, + toolCalls: 0, + toolResults: 0, + totalMessages: 2, + tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, total: 150 }, + cost: 0.001, + }, + }, + { + type: "cost_update", + runId: "r1", + turnCost: 0.001, + cumulativeCost: 0.001, + tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 }, + }, + ]; + assert.equal(events.length, 2); + assert.equal(events[0].type, "execution_complete"); + assert.equal(events[1].type, "cost_update"); + }); +}); + +// ============================================================================ +// RpcClient Construction Tests +// ============================================================================ + +describe("RpcClient construction", () => { + it("creates with default options", () => { + const client = new RpcClient(); + assert.ok(client); + }); + + it("creates with custom options", () => { + const client = new RpcClient({ + cliPath: "/usr/local/bin/gsd", + cwd: "/tmp", + env: { NODE_ENV: "test" }, + provider: "anthropic", + model: "claude-sonnet", + args: ["--verbose"], + }); + assert.ok(client); + }); +}); + +// ============================================================================ +// events() Generator Tests +// ============================================================================ + +describe("events() async generator", () => { + it("yields events from a mock stream in order", async () => { + const client = new RpcClient(); + + // Reach into the client to set up a mock process with a PassThrough stdout + const mockStdout = new PassThrough(); + const mockStderr = new PassThrough(); + const mockStdin = new PassThrough(); + + // Simulate a started process by setting internal state + // We use Object.assign to set private fields for testing + const clientAny = client as any; + clientAny.process = { + stdout: mockStdout, + stderr: mockStderr, + stdin: mockStdin, + exitCode: null, + kill: () => {}, + on: (event: string, handler: (...args: any[]) => void) => { + if (event === "exit") { + // Store exit handler so we can trigger it + clientAny._testExitHandler = handler; + } + }, + removeListener: () => {}, + }; + + // Attach the JSONL reader like start() does + clientAny.stopReadingStdout = attachJsonlLineReader(mockStdout, (line: string) => { + clientAny.handleLine(line); + }); + + // Collect events from the generator + const received: SdkAgentEvent[] = []; + const genPromise = (async () => { + for await (const event of client.events()) { + received.push(event); + if (event.type === "done") break; + } + })(); + + // Simulate server sending events + await new Promise((r) => setTimeout(r, 20)); + mockStdout.write(serializeJsonLine({ type: "agent_start", runId: "r1" })); + await new Promise((r) => setTimeout(r, 20)); + mockStdout.write(serializeJsonLine({ type: "token", text: "hello" })); + await new Promise((r) => setTimeout(r, 20)); + mockStdout.write(serializeJsonLine({ type: "done" })); + + await genPromise; + + assert.equal(received.length, 3); + assert.equal(received[0].type, "agent_start"); + assert.equal(received[1].type, "token"); + assert.equal(received[2].type, "done"); + }); + + it("terminates when process exits", async () => { + const client = new RpcClient(); + const mockStdout = new PassThrough(); + const mockStderr = new PassThrough(); + const mockStdin = new PassThrough(); + + const exitHandlers: Array<() => void> = []; + const clientAny = client as any; + clientAny.process = { + stdout: mockStdout, + stderr: mockStderr, + stdin: mockStdin, + exitCode: null, + kill: () => {}, + on: (event: string, handler: () => void) => { + if (event === "exit") exitHandlers.push(handler); + }, + removeListener: (event: string, handler: () => void) => { + const idx = exitHandlers.indexOf(handler); + if (idx !== -1) exitHandlers.splice(idx, 1); + }, + }; + + clientAny.stopReadingStdout = attachJsonlLineReader(mockStdout, (line: string) => { + clientAny.handleLine(line); + }); + + const received: SdkAgentEvent[] = []; + const genPromise = (async () => { + for await (const event of client.events()) { + received.push(event); + } + })(); + + // Send one event, then simulate process exit + await new Promise((r) => setTimeout(r, 20)); + mockStdout.write(serializeJsonLine({ type: "agent_start" })); + await new Promise((r) => setTimeout(r, 20)); + + // Fire exit handlers + for (const h of exitHandlers) h(); + + await genPromise; + + assert.equal(received.length, 1); + assert.equal(received[0].type, "agent_start"); + }); + + it("throws if client not started", async () => { + const client = new RpcClient(); + await assert.rejects(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _event of client.events()) { + // should not reach + } + }, /Client not started/); + }); +}); + +// ============================================================================ +// sendUIResponse Serialization Test +// ============================================================================ + +describe("sendUIResponse serialization", () => { + it("writes correct JSONL to stdin", () => { + const client = new RpcClient(); + const chunks: string[] = []; + const mockStdin = { + write: (data: string) => { + chunks.push(data); + return true; + }, + }; + + const clientAny = client as any; + clientAny.process = { stdin: mockStdin }; + + client.sendUIResponse("ui_1", { value: "hello" }); + + assert.equal(chunks.length, 1); + const parsed = JSON.parse(chunks[0].trim()); + assert.equal(parsed.type, "extension_ui_response"); + assert.equal(parsed.id, "ui_1"); + assert.equal(parsed.value, "hello"); + }); + + it("serializes confirmed response", () => { + const client = new RpcClient(); + const chunks: string[] = []; + const mockStdin = { + write: (data: string) => { + chunks.push(data); + return true; + }, + }; + const clientAny = client as any; + clientAny.process = { stdin: mockStdin }; + + client.sendUIResponse("ui_2", { confirmed: true }); + + const parsed = JSON.parse(chunks[0].trim()); + assert.equal(parsed.confirmed, true); + assert.equal(parsed.id, "ui_2"); + }); + + it("serializes cancelled response", () => { + const client = new RpcClient(); + const chunks: string[] = []; + const mockStdin = { + write: (data: string) => { + chunks.push(data); + return true; + }, + }; + const clientAny = client as any; + clientAny.process = { stdin: mockStdin }; + + client.sendUIResponse("ui_3", { cancelled: true }); + + const parsed = JSON.parse(chunks[0].trim()); + assert.equal(parsed.cancelled, true); + }); +}); + +// ============================================================================ +// init/shutdown/subscribe Serialization Tests +// ============================================================================ + +describe("v2 command serialization", () => { + // Helper: capture what the client sends to stdin + function createMockClient(): { client: RpcClient; sent: any[]; respondNext: (data?: any) => void } { + const client = new RpcClient(); + const sent: any[] = []; + let respondFn: ((data: any) => void) | null = null; + + const clientAny = client as any; + clientAny.process = { + stdin: { + write: (data: string) => { + const parsed = JSON.parse(data.trim()); + sent.push(parsed); + // Auto-respond with success after a tick + if (respondFn) { + setTimeout(() => respondFn!(parsed), 5); + } + return true; + }, + }, + stderr: new PassThrough(), + exitCode: null, + kill: () => {}, + on: () => {}, + removeListener: () => {}, + }; + + const respondNext = (overrides: any = {}) => { + respondFn = (parsed) => { + const response = { + type: "response", + id: parsed.id, + command: parsed.type, + success: true, + data: {}, + ...overrides, + }; + clientAny.handleLine(JSON.stringify(response)); + }; + }; + + return { client, sent, respondNext }; + } + + it("init sends correct v2 init command", async () => { + const { client, sent, respondNext } = createMockClient(); + respondNext({ data: { protocolVersion: 2, sessionId: "s1", capabilities: { events: [], commands: [] } } }); + + const result = await client.init({ clientId: "test-app" }); + + assert.equal(sent.length, 1); + assert.equal(sent[0].type, "init"); + assert.equal(sent[0].protocolVersion, 2); + assert.equal(sent[0].clientId, "test-app"); + assert.equal(result.protocolVersion, 2); + assert.equal(result.sessionId, "s1"); + }); + + it("shutdown sends shutdown command", async () => { + const { client, sent, respondNext } = createMockClient(); + + // Override the process exit wait + const clientAny = client as any; + const originalProcess = clientAny.process; + const exitHandlers: Array<(code: number) => void> = []; + clientAny.process = { + ...originalProcess, + on: (event: string, handler: (code: number) => void) => { + if (event === "exit") exitHandlers.push(handler); + }, + }; + + respondNext(); + + // Call shutdown and simulate process exit + const shutdownPromise = client.shutdown(); + await new Promise((r) => setTimeout(r, 20)); + for (const h of exitHandlers) h(0); + + await shutdownPromise; + + assert.equal(sent.length, 1); + assert.equal(sent[0].type, "shutdown"); + }); + + it("subscribe sends subscribe command with event list", async () => { + const { client, sent, respondNext } = createMockClient(); + respondNext(); + + await client.subscribe(["execution_complete", "cost_update"]); + + assert.equal(sent.length, 1); + assert.equal(sent[0].type, "subscribe"); + assert.deepEqual(sent[0].events, ["execution_complete", "cost_update"]); + }); + + it("subscribe with wildcard", async () => { + const { client, sent, respondNext } = createMockClient(); + respondNext(); + + await client.subscribe(["*"]); + + assert.equal(sent[0].events.length, 1); + assert.equal(sent[0].events[0], "*"); + }); +}); diff --git a/packages/rpc-client/src/rpc-client.ts b/packages/rpc-client/src/rpc-client.ts new file mode 100644 index 000000000..4d5edc53c --- /dev/null +++ b/packages/rpc-client/src/rpc-client.ts @@ -0,0 +1,666 @@ +/** + * RPC Client for programmatic access to the coding agent. + * + * Spawns the agent in RPC mode and provides a typed API for all operations. + * This is a standalone SDK client — all types are inlined with zero internal + * package dependencies. + */ + +import { type ChildProcess, spawn } from "node:child_process"; +import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js"; +import type { + BashResult, + CompactionResult, + ImageContent, + ModelInfo, + RpcCommand, + RpcInitResult, + RpcResponse, + RpcSessionState, + RpcSlashCommand, + ThinkingLevel, + SessionStats, +} from "./rpc-types.js"; + +// ============================================================================ +// Types +// ============================================================================ + +/** Distributive Omit that works with union types */ +type DistributiveOmit = T extends unknown ? Omit : never; + +/** RpcCommand without the id field (for internal send) */ +type RpcCommandBody = DistributiveOmit; + +/** Agent event — a loosely-typed record from the server. The `type` field is always present. */ +export interface SdkAgentEvent { + type: string; + [key: string]: unknown; +} + +export interface RpcClientOptions { + /** Path to the CLI entry point (default: searches for dist/cli.js) */ + cliPath?: string; + /** Working directory for the agent */ + cwd?: string; + /** Environment variables */ + env?: Record; + /** Provider to use */ + provider?: string; + /** Model ID to use */ + model?: string; + /** Additional CLI arguments */ + args?: string[]; +} + +export type RpcEventListener = (event: SdkAgentEvent) => void; + +// ============================================================================ +// RPC Client +// ============================================================================ + +export class RpcClient { + private process: ChildProcess | null = null; + private stopReadingStdout: (() => void) | null = null; + private _stderrHandler?: (data: Buffer) => void; + private eventListeners: RpcEventListener[] = []; + private pendingRequests: Map void; reject: (error: Error) => void }> = + new Map(); + private requestId = 0; + private stderr = ""; + private _stopped = false; + + constructor(private options: RpcClientOptions = {}) {} + + /** + * Start the RPC agent process. + */ + async start(): Promise { + if (this.process) { + throw new Error("Client already started"); + } + + this._stopped = false; + + const cliPath = this.options.cliPath ?? "dist/cli.js"; + const args = ["--mode", "rpc"]; + + if (this.options.provider) { + args.push("--provider", this.options.provider); + } + if (this.options.model) { + args.push("--model", this.options.model); + } + if (this.options.args) { + args.push(...this.options.args); + } + + this.process = spawn("node", [cliPath, ...args], { + cwd: this.options.cwd, + env: { ...process.env, ...this.options.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + + // Collect stderr for debugging + this._stderrHandler = (data: Buffer) => { + this.stderr += data.toString(); + }; + this.process.stderr?.on("data", this._stderrHandler); + + // Set up strict JSONL reader for stdout. + this.stopReadingStdout = attachJsonlLineReader(this.process.stdout!, (line) => { + this.handleLine(line); + }); + + // Detect unexpected subprocess exit and reject all pending requests + this.process.on("exit", (code, signal) => { + if (this.pendingRequests.size > 0) { + const reason = signal ? `signal ${signal}` : `code ${code}`; + const error = new Error(`Agent process exited unexpectedly (${reason}). Stderr: ${this.stderr}`); + for (const [id, pending] of this.pendingRequests) { + this.pendingRequests.delete(id); + pending.reject(error); + } + } + }); + + // Wait a moment for process to initialize + await new Promise((resolve) => setTimeout(resolve, 100)); + + if (this.process.exitCode !== null) { + throw new Error(`Agent process exited immediately with code ${this.process.exitCode}. Stderr: ${this.stderr}`); + } + } + + /** + * Stop the RPC agent process. + */ + async stop(): Promise { + if (!this.process) return; + + this._stopped = true; + + this.stopReadingStdout?.(); + this.stopReadingStdout = null; + if (this._stderrHandler) { + this.process.stderr?.removeListener("data", this._stderrHandler); + this._stderrHandler = undefined; + } + this.process.kill("SIGTERM"); + + // Wait for process to exit + await new Promise((resolve) => { + const timeout = setTimeout(() => { + this.process?.kill("SIGKILL"); + resolve(); + }, 1000); + + this.process?.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + + this.process = null; + this.pendingRequests.clear(); + } + + /** + * Subscribe to agent events via callback. + */ + onEvent(listener: RpcEventListener): () => void { + this.eventListeners.push(listener); + return () => { + const index = this.eventListeners.indexOf(listener); + if (index !== -1) { + this.eventListeners.splice(index, 1); + } + }; + } + + /** + * Async generator that yields agent events as they arrive. + * + * Usage: + * ```ts + * for await (const event of client.events()) { + * console.log(event.type, event); + * } + * ``` + * + * The generator terminates when: + * - `stop()` is called + * - The agent process exits + * - The consumer breaks out of the loop + */ + async *events(): AsyncGenerator { + if (!this.process) { + throw new Error("Client not started — call start() before events()"); + } + + if (this._stopped) { + return; + } + + const buffer: SdkAgentEvent[] = []; + let resolve: ((value: void) => void) | null = null; + let done = false; + + // When a new event arrives, either push to buffer or wake up the awaiting generator + const listener = (event: SdkAgentEvent) => { + buffer.push(event); + if (resolve) { + const r = resolve; + resolve = null; + r(); + } + }; + + // When the process exits, signal the generator to stop + const onExit = () => { + done = true; + if (resolve) { + const r = resolve; + resolve = null; + r(); + } + }; + + const unsubscribe = this.onEvent(listener); + this.process.on("exit", onExit); + + try { + while (!done && !this._stopped) { + // Drain buffer first + while (buffer.length > 0) { + yield buffer.shift()!; + } + + // If done after draining, break + if (done || this._stopped) { + break; + } + + // Wait for next event or process exit + await new Promise((r) => { + resolve = r; + }); + } + + // Drain any remaining events that arrived with the exit signal + while (buffer.length > 0) { + yield buffer.shift()!; + } + } finally { + unsubscribe(); + this.process?.removeListener("exit", onExit); + } + } + + /** + * Get collected stderr output (useful for debugging). + */ + getStderr(): string { + return this.stderr; + } + + // ========================================================================= + // Command Methods + // ========================================================================= + + /** + * Send a prompt to the agent. + * Returns immediately after sending; use onEvent() or events() to receive streaming events. + * Use waitForIdle() to wait for completion. + */ + async prompt(message: string, images?: ImageContent[]): Promise { + await this.send({ type: "prompt", message, images }); + } + + /** + * Queue a steering message to interrupt the agent mid-run. + */ + async steer(message: string, images?: ImageContent[]): Promise { + await this.send({ type: "steer", message, images }); + } + + /** + * Queue a follow-up message to be processed after the agent finishes. + */ + async followUp(message: string, images?: ImageContent[]): Promise { + await this.send({ type: "follow_up", message, images }); + } + + /** + * Abort current operation. + */ + async abort(): Promise { + await this.send({ type: "abort" }); + } + + /** + * Start a new session, optionally with parent tracking. + * @param parentSession - Optional parent session path for lineage tracking + * @returns Object with `cancelled: true` if an extension cancelled the new session + */ + async newSession(parentSession?: string): Promise<{ cancelled: boolean }> { + const response = await this.send({ type: "new_session", parentSession }); + return this.getData(response); + } + + /** + * Get current session state. + */ + async getState(): Promise { + const response = await this.send({ type: "get_state" }); + return this.getData(response); + } + + /** + * Set model by provider and ID. + */ + async setModel(provider: string, modelId: string): Promise<{ provider: string; id: string }> { + const response = await this.send({ type: "set_model", provider, modelId }); + return this.getData(response); + } + + /** + * Cycle to next model. + */ + async cycleModel(): Promise<{ + model: { provider: string; id: string }; + thinkingLevel: ThinkingLevel; + isScoped: boolean; + } | null> { + const response = await this.send({ type: "cycle_model" }); + return this.getData(response); + } + + /** + * Get list of available models. + */ + async getAvailableModels(): Promise { + const response = await this.send({ type: "get_available_models" }); + return this.getData<{ models: ModelInfo[] }>(response).models; + } + + /** + * Set thinking level. + */ + async setThinkingLevel(level: ThinkingLevel): Promise { + await this.send({ type: "set_thinking_level", level }); + } + + /** + * Cycle thinking level. + */ + async cycleThinkingLevel(): Promise<{ level: ThinkingLevel } | null> { + const response = await this.send({ type: "cycle_thinking_level" }); + return this.getData(response); + } + + /** + * Set steering mode. + */ + async setSteeringMode(mode: "all" | "one-at-a-time"): Promise { + await this.send({ type: "set_steering_mode", mode }); + } + + /** + * Set follow-up mode. + */ + async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise { + await this.send({ type: "set_follow_up_mode", mode }); + } + + /** + * Compact session context. + */ + async compact(customInstructions?: string): Promise { + const response = await this.send({ type: "compact", customInstructions }); + return this.getData(response); + } + + /** + * Set auto-compaction enabled/disabled. + */ + async setAutoCompaction(enabled: boolean): Promise { + await this.send({ type: "set_auto_compaction", enabled }); + } + + /** + * Set auto-retry enabled/disabled. + */ + async setAutoRetry(enabled: boolean): Promise { + await this.send({ type: "set_auto_retry", enabled }); + } + + /** + * Abort in-progress retry. + */ + async abortRetry(): Promise { + await this.send({ type: "abort_retry" }); + } + + /** + * Execute a bash command. + */ + async bash(command: string): Promise { + const response = await this.send({ type: "bash", command }); + return this.getData(response); + } + + /** + * Abort running bash command. + */ + async abortBash(): Promise { + await this.send({ type: "abort_bash" }); + } + + /** + * Get session statistics. + */ + async getSessionStats(): Promise { + const response = await this.send({ type: "get_session_stats" }); + return this.getData(response); + } + + /** + * Export session to HTML. + */ + async exportHtml(outputPath?: string): Promise<{ path: string }> { + const response = await this.send({ type: "export_html", outputPath }); + return this.getData(response); + } + + /** + * Switch to a different session file. + * @returns Object with `cancelled: true` if an extension cancelled the switch + */ + async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> { + const response = await this.send({ type: "switch_session", sessionPath }); + return this.getData(response); + } + + /** + * Fork from a specific message. + * @returns Object with `text` (the message text) and `cancelled` (if extension cancelled) + */ + async fork(entryId: string): Promise<{ text: string; cancelled: boolean }> { + const response = await this.send({ type: "fork", entryId }); + return this.getData(response); + } + + /** + * Get messages available for forking. + */ + async getForkMessages(): Promise> { + const response = await this.send({ type: "get_fork_messages" }); + return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages; + } + + /** + * Get text of last assistant message. + */ + async getLastAssistantText(): Promise { + const response = await this.send({ type: "get_last_assistant_text" }); + return this.getData<{ text: string | null }>(response).text; + } + + /** + * Set the session display name. + */ + async setSessionName(name: string): Promise { + await this.send({ type: "set_session_name", name }); + } + + /** + * Get all messages in the session. + * Messages are returned as opaque objects — the internal structure may vary. + */ + async getMessages(): Promise { + const response = await this.send({ type: "get_messages" }); + return this.getData<{ messages: unknown[] }>(response).messages; + } + + /** + * Get available commands (extension commands, prompt templates, skills). + */ + async getCommands(): Promise { + const response = await this.send({ type: "get_commands" }); + return this.getData<{ commands: RpcSlashCommand[] }>(response).commands; + } + + /** + * Send a UI response to a pending extension_ui_request. + * Fire-and-forget — no request/response correlation. + */ + sendUIResponse(id: string, response: { value?: string; values?: string[]; confirmed?: boolean; cancelled?: boolean }): void { + if (!this.process?.stdin) { + throw new Error("Client not started"); + } + this.process.stdin.write(serializeJsonLine({ + type: "extension_ui_response", + id, + ...response, + })); + } + + /** + * Initialize a v2 protocol session. Must be sent as the first command. + * Returns the negotiated protocol version, session ID, and server capabilities. + */ + async init(options?: { clientId?: string }): Promise { + const response = await this.send({ type: "init", protocolVersion: 2, clientId: options?.clientId }); + return this.getData(response); + } + + /** + * Request a graceful shutdown of the agent process. + * Waits for the response before the process exits. + */ + async shutdown(): Promise { + await this.send({ type: "shutdown" }); + // Wait for process to exit after shutdown acknowledgment + if (this.process) { + await new Promise((resolve) => { + const timeout = setTimeout(() => { + this.process?.kill("SIGKILL"); + resolve(); + }, 5000); + this.process?.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + } + + /** + * Subscribe to specific event types (v2 only). + * Pass ["*"] to receive all events, or a list of event type strings to filter. + */ + async subscribe(events: string[]): Promise { + await this.send({ type: "subscribe", events }); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Wait for agent to become idle (no streaming). + * Resolves when agent_end event is received. + */ + waitForIdle(timeout = 60000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`)); + }, timeout); + + const unsubscribe = this.onEvent((event) => { + if (event.type === "agent_end") { + clearTimeout(timer); + unsubscribe(); + resolve(); + } + }); + }); + } + + /** + * Collect events until agent becomes idle. + */ + collectEvents(timeout = 60000): Promise { + return new Promise((resolve, reject) => { + const events: SdkAgentEvent[] = []; + const timer = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`)); + }, timeout); + + const unsubscribe = this.onEvent((event) => { + events.push(event); + if (event.type === "agent_end") { + clearTimeout(timer); + unsubscribe(); + resolve(events); + } + }); + }); + } + + /** + * Send prompt and wait for completion, returning all events. + */ + async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise { + const eventsPromise = this.collectEvents(timeout); + await this.prompt(message, images); + return eventsPromise; + } + + // ========================================================================= + // Internal + // ========================================================================= + + private handleLine(line: string): void { + try { + const data = JSON.parse(line); + + // Check if it's a response to a pending request + if (data.type === "response" && data.id && this.pendingRequests.has(data.id)) { + const pending = this.pendingRequests.get(data.id)!; + this.pendingRequests.delete(data.id); + pending.resolve(data as RpcResponse); + return; + } + + // Otherwise it's an event — dispatch to listeners + for (const listener of this.eventListeners) { + listener(data as SdkAgentEvent); + } + } catch { + // Ignore non-JSON lines + } + } + + private async send(command: RpcCommandBody): Promise { + if (!this.process?.stdin) { + throw new Error("Client not started"); + } + + const id = `req_${++this.requestId}`; + const fullCommand = { ...command, id } as RpcCommand; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`)); + }, 30000); + + this.pendingRequests.set(id, { + resolve: (response) => { + clearTimeout(timeout); + resolve(response); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + + this.process!.stdin!.write(serializeJsonLine(fullCommand)); + }); + } + + private getData(response: RpcResponse): T { + if (!response.success) { + const errorResponse = response as Extract; + throw new Error(errorResponse.error); + } + // Type assertion: we trust response.data matches T based on the command sent. + const successResponse = response as Extract; + return successResponse.data as T; + } +} diff --git a/packages/rpc-client/src/rpc-types.ts b/packages/rpc-client/src/rpc-types.ts new file mode 100644 index 000000000..be8bca73b --- /dev/null +++ b/packages/rpc-client/src/rpc-types.ts @@ -0,0 +1,399 @@ +/** + * RPC protocol types for headless operation. + * + * Commands are sent as JSON lines on stdin. + * Responses and events are emitted as JSON lines on stdout. + * + * This file is self-contained — all types that were previously imported from + * internal packages are inlined so that this package has zero internal + * dependencies. + */ + +// ============================================================================ +// Inlined types (originally from internal packages) +// ============================================================================ + +/** Thinking budget level (inlined from agent-core) */ +export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +/** Image attachment (inlined from pi-ai) */ +export interface ImageContent { + type: "image"; + data: string; // base64 encoded image data + mimeType: string; // e.g., "image/jpeg", "image/png" +} + +/** Model descriptor — opaque for SDK consumers */ +export interface ModelInfo { + provider: string; + id: string; + contextWindow?: number; + reasoning?: boolean; + [key: string]: unknown; +} + +/** Session statistics (from agent-session.ts) */ +export interface SessionStats { + sessionFile: string | undefined; + sessionId: string; + userMessages: number; + assistantMessages: number; + toolCalls: number; + toolResults: number; + totalMessages: number; + tokens: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + cost: number; +} + +/** Bash command result (from bash-executor.ts) */ +export interface BashResult { + /** Combined stdout + stderr output (sanitized, possibly truncated) */ + output: string; + /** Process exit code (undefined if killed/cancelled) */ + exitCode: number | undefined; + /** Whether the command was cancelled via signal */ + cancelled: boolean; + /** Whether the output was truncated */ + truncated: boolean; + /** Path to temp file containing full output (if output exceeded truncation threshold) */ + fullOutputPath?: string; +} + +/** Compaction result (from compaction.ts) */ +export interface CompactionResult { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ + details?: T; +} + +// ============================================================================ +// RPC Protocol Versioning +// ============================================================================ + +/** Supported protocol versions. v1 is the implicit default; v2 requires an init handshake. */ +export type RpcProtocolVersion = 1 | 2; + +// ============================================================================ +// RPC Commands (stdin) +// ============================================================================ + +export type RpcCommand = + // Prompting + | { id?: string; type: "prompt"; message: string; images?: ImageContent[]; streamingBehavior?: "steer" | "followUp" } + | { id?: string; type: "steer"; message: string; images?: ImageContent[] } + | { id?: string; type: "follow_up"; message: string; images?: ImageContent[] } + | { id?: string; type: "abort" } + | { id?: string; type: "new_session"; parentSession?: string } + + // State + | { id?: string; type: "get_state" } + + // Model + | { id?: string; type: "set_model"; provider: string; modelId: string } + | { id?: string; type: "cycle_model" } + | { id?: string; type: "get_available_models" } + + // Thinking + | { id?: string; type: "set_thinking_level"; level: ThinkingLevel } + | { id?: string; type: "cycle_thinking_level" } + + // Queue modes + | { id?: string; type: "set_steering_mode"; mode: "all" | "one-at-a-time" } + | { id?: string; type: "set_follow_up_mode"; mode: "all" | "one-at-a-time" } + + // Compaction + | { id?: string; type: "compact"; customInstructions?: string } + | { id?: string; type: "set_auto_compaction"; enabled: boolean } + + // Retry + | { id?: string; type: "set_auto_retry"; enabled: boolean } + | { id?: string; type: "abort_retry" } + + // Bash + | { id?: string; type: "bash"; command: string } + | { id?: string; type: "abort_bash" } + + // Session + | { id?: string; type: "get_session_stats" } + | { id?: string; type: "export_html"; outputPath?: string } + | { id?: string; type: "switch_session"; sessionPath: string } + | { id?: string; type: "fork"; entryId: string } + | { id?: string; type: "get_fork_messages" } + | { id?: string; type: "get_last_assistant_text" } + | { id?: string; type: "set_session_name"; name: string } + + // Messages + | { id?: string; type: "get_messages" } + + // Commands (available for invocation via prompt) + | { id?: string; type: "get_commands" } + + // Bridge-hosted native terminal + | { id?: string; type: "terminal_input"; data: string } + | { id?: string; type: "terminal_resize"; cols: number; rows: number } + | { id?: string; type: "terminal_redraw" } + + // v2 Protocol + | { id?: string; type: "init"; protocolVersion: 2; clientId?: string } + | { id?: string; type: "shutdown"; graceful?: boolean } + | { id?: string; type: "subscribe"; events: string[] }; + +// ============================================================================ +// RPC Slash Command (for get_commands response) +// ============================================================================ + +/** A command available for invocation via prompt */ +export interface RpcSlashCommand { + /** Command name (without leading slash) */ + name: string; + /** Human-readable description */ + description?: string; + /** What kind of command this is */ + source: "extension" | "prompt" | "skill"; + /** Where the command was loaded from (undefined for extensions) */ + location?: "user" | "project" | "path"; + /** File path to the command source */ + path?: string; +} + +// ============================================================================ +// RPC State +// ============================================================================ + +export interface RpcSessionState { + model?: ModelInfo; + thinkingLevel: ThinkingLevel; + isStreaming: boolean; + isCompacting: boolean; + steeringMode: "all" | "one-at-a-time"; + followUpMode: "all" | "one-at-a-time"; + sessionFile?: string; + sessionId: string; + sessionName?: string; + autoCompactionEnabled: boolean; + autoRetryEnabled: boolean; + retryInProgress: boolean; + retryAttempt: number; + messageCount: number; + pendingMessageCount: number; + /** Whether extension loading has completed. Commands from `get_commands` may be incomplete until true. */ + extensionsReady: boolean; +} + +// ============================================================================ +// RPC Responses (stdout) +// ============================================================================ + +// Success responses with data +export type RpcResponse = + // Prompting (async - events follow) + | { id?: string; type: "response"; command: "prompt"; success: true; runId?: string } + | { id?: string; type: "response"; command: "steer"; success: true; runId?: string } + | { id?: string; type: "response"; command: "follow_up"; success: true; runId?: string } + | { id?: string; type: "response"; command: "abort"; success: true } + | { id?: string; type: "response"; command: "new_session"; success: true; data: { cancelled: boolean } } + + // State + | { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState } + + // Model + | { + id?: string; + type: "response"; + command: "set_model"; + success: true; + data: ModelInfo; + } + | { + id?: string; + type: "response"; + command: "cycle_model"; + success: true; + data: { model: ModelInfo; thinkingLevel: ThinkingLevel; isScoped: boolean } | null; + } + | { + id?: string; + type: "response"; + command: "get_available_models"; + success: true; + data: { models: ModelInfo[] }; + } + + // Thinking + | { id?: string; type: "response"; command: "set_thinking_level"; success: true } + | { + id?: string; + type: "response"; + command: "cycle_thinking_level"; + success: true; + data: { level: ThinkingLevel } | null; + } + + // Queue modes + | { id?: string; type: "response"; command: "set_steering_mode"; success: true } + | { id?: string; type: "response"; command: "set_follow_up_mode"; success: true } + + // Compaction + | { id?: string; type: "response"; command: "compact"; success: true; data: CompactionResult } + | { id?: string; type: "response"; command: "set_auto_compaction"; success: true } + + // Retry + | { id?: string; type: "response"; command: "set_auto_retry"; success: true } + | { id?: string; type: "response"; command: "abort_retry"; success: true } + + // Bash + | { id?: string; type: "response"; command: "bash"; success: true; data: BashResult } + | { id?: string; type: "response"; command: "abort_bash"; success: true } + + // Session + | { id?: string; type: "response"; command: "get_session_stats"; success: true; data: SessionStats } + | { id?: string; type: "response"; command: "export_html"; success: true; data: { path: string } } + | { id?: string; type: "response"; command: "switch_session"; success: true; data: { cancelled: boolean } } + | { id?: string; type: "response"; command: "fork"; success: true; data: { text: string; cancelled: boolean } } + | { + id?: string; + type: "response"; + command: "get_fork_messages"; + success: true; + data: { messages: Array<{ entryId: string; text: string }> }; + } + | { + id?: string; + type: "response"; + command: "get_last_assistant_text"; + success: true; + data: { text: string | null }; + } + | { id?: string; type: "response"; command: "set_session_name"; success: true } + + // Messages — AgentMessage is opaque for SDK consumers + | { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: unknown[] } } + + // Commands + | { + id?: string; + type: "response"; + command: "get_commands"; + success: true; + data: { commands: RpcSlashCommand[] }; + } + + // Bridge-hosted native terminal + | { id?: string; type: "response"; command: "terminal_input"; success: true } + | { id?: string; type: "response"; command: "terminal_resize"; success: true } + | { id?: string; type: "response"; command: "terminal_redraw"; success: true } + + // v2 Protocol + | { id?: string; type: "response"; command: "init"; success: true; data: RpcInitResult } + | { id?: string; type: "response"; command: "shutdown"; success: true } + | { id?: string; type: "response"; command: "subscribe"; success: true } + + // Error response (any command can fail) + | { id?: string; type: "response"; command: string; success: false; error: string }; + +// ============================================================================ +// v2 Protocol Types +// ============================================================================ + +/** Result of the init handshake (v2 only) */ +export interface RpcInitResult { + protocolVersion: 2; + sessionId: string; + capabilities: { + events: string[]; + commands: string[]; + }; +} + +/** v2 execution_complete event — emitted when a prompt/steer/follow_up finishes */ +export interface RpcExecutionCompleteEvent { + type: "execution_complete"; + runId: string; + status: "completed" | "error" | "cancelled"; + reason?: string; + stats: SessionStats; +} + +/** v2 cost_update event — emitted per-turn with running cost data */ +export interface RpcCostUpdateEvent { + type: "cost_update"; + runId: string; + turnCost: number; + cumulativeCost: number; + tokens: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; +} + +/** Discriminated union of all v2-only event types */ +export type RpcV2Event = RpcExecutionCompleteEvent | RpcCostUpdateEvent; + +// ============================================================================ +// Extension UI Events (stdout) +// ============================================================================ + +/** Emitted when an extension needs user input */ +export type RpcExtensionUIRequest = + | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number; allowMultiple?: boolean } + | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number } + | { + type: "extension_ui_request"; + id: string; + method: "input"; + title: string; + placeholder?: string; + timeout?: number; + } + | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } + | { + type: "extension_ui_request"; + id: string; + method: "notify"; + message: string; + notifyType?: "info" | "warning" | "error"; + } + | { + type: "extension_ui_request"; + id: string; + method: "setStatus"; + statusKey: string; + statusText: string | undefined; + } + | { + type: "extension_ui_request"; + id: string; + method: "setWidget"; + widgetKey: string; + widgetLines: string[] | undefined; + widgetPlacement?: "aboveEditor" | "belowEditor"; + } + | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string } + | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }; + +// ============================================================================ +// Extension UI Commands (stdin) +// ============================================================================ + +/** Response to an extension UI request */ +export type RpcExtensionUIResponse = + | { type: "extension_ui_response"; id: string; value: string } + | { type: "extension_ui_response"; id: string; values: string[] } + | { type: "extension_ui_response"; id: string; confirmed: boolean } + | { type: "extension_ui_response"; id: string; cancelled: true }; + +// ============================================================================ +// Helper type for extracting command types +// ============================================================================ + +export type RpcCommandType = RpcCommand["type"]; diff --git a/packages/rpc-client/tsconfig.examples.json b/packages/rpc-client/tsconfig.examples.json new file mode 100644 index 000000000..819d2d06c --- /dev/null +++ b/packages/rpc-client/tsconfig.examples.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "Node16", + "lib": ["ES2024"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "moduleResolution": "Node16", + "noEmit": true, + "types": ["node"], + "paths": { + "@gsd/rpc-client": ["./src/index.ts"] + } + }, + "include": ["examples/**/*.ts"] +} diff --git a/packages/rpc-client/tsconfig.json b/packages/rpc-client/tsconfig.json new file mode 100644 index 000000000..779b48aca --- /dev/null +++ b/packages/rpc-client/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "Node16", + "lib": ["ES2024"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "inlineSources": true, + "inlineSourceMap": false, + "moduleResolution": "Node16", + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "types": ["node"], + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] +}