test: Wired pi-coding-agent to re-export JSONL utils from @gsd/rpc-clie…

- "packages/pi-coding-agent/src/modes/rpc/jsonl.ts"
- "packages/pi-coding-agent/package.json"
- "packages/rpc-client/src/index.ts"
- "packages/rpc-client/src/jsonl.ts"
- "packages/rpc-client/src/rpc-client.ts"
- "packages/rpc-client/src/rpc-types.ts"
- "packages/rpc-client/src/rpc-client.test.ts"
- "packages/rpc-client/package.json"

GSD-Task: S06/T03
This commit is contained in:
Lex Christopherson 2026-03-26 18:08:02 -06:00
parent 3be38e3794
commit bb6d64a5ba
13 changed files with 1893 additions and 64 deletions

1
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -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<string, string>` | 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

View file

@ -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();

View file

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

View file

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

View file

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

View file

@ -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], "*");
});
});

View file

@ -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, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
/** RpcCommand without the id field (for internal send) */
type RpcCommandBody = DistributiveOmit<RpcCommand, "id">;
/** 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<string, string>;
/** 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<string, { resolve: (response: RpcResponse) => 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<void> {
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<void> {
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<void>((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<SdkAgentEvent, void, undefined> {
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<void>((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<void> {
await this.send({ type: "prompt", message, images });
}
/**
* Queue a steering message to interrupt the agent mid-run.
*/
async steer(message: string, images?: ImageContent[]): Promise<void> {
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<void> {
await this.send({ type: "follow_up", message, images });
}
/**
* Abort current operation.
*/
async abort(): Promise<void> {
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<RpcSessionState> {
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<ModelInfo[]> {
const response = await this.send({ type: "get_available_models" });
return this.getData<{ models: ModelInfo[] }>(response).models;
}
/**
* Set thinking level.
*/
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
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<void> {
await this.send({ type: "set_steering_mode", mode });
}
/**
* Set follow-up mode.
*/
async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise<void> {
await this.send({ type: "set_follow_up_mode", mode });
}
/**
* Compact session context.
*/
async compact(customInstructions?: string): Promise<CompactionResult> {
const response = await this.send({ type: "compact", customInstructions });
return this.getData(response);
}
/**
* Set auto-compaction enabled/disabled.
*/
async setAutoCompaction(enabled: boolean): Promise<void> {
await this.send({ type: "set_auto_compaction", enabled });
}
/**
* Set auto-retry enabled/disabled.
*/
async setAutoRetry(enabled: boolean): Promise<void> {
await this.send({ type: "set_auto_retry", enabled });
}
/**
* Abort in-progress retry.
*/
async abortRetry(): Promise<void> {
await this.send({ type: "abort_retry" });
}
/**
* Execute a bash command.
*/
async bash(command: string): Promise<BashResult> {
const response = await this.send({ type: "bash", command });
return this.getData(response);
}
/**
* Abort running bash command.
*/
async abortBash(): Promise<void> {
await this.send({ type: "abort_bash" });
}
/**
* Get session statistics.
*/
async getSessionStats(): Promise<SessionStats> {
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<Array<{ entryId: string; text: string }>> {
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<string | null> {
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<void> {
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<unknown[]> {
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<RpcSlashCommand[]> {
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<RpcInitResult> {
const response = await this.send({ type: "init", protocolVersion: 2, clientId: options?.clientId });
return this.getData<RpcInitResult>(response);
}
/**
* Request a graceful shutdown of the agent process.
* Waits for the response before the process exits.
*/
async shutdown(): Promise<void> {
await this.send({ type: "shutdown" });
// Wait for process to exit after shutdown acknowledgment
if (this.process) {
await new Promise<void>((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<void> {
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<void> {
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<SdkAgentEvent[]> {
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<SdkAgentEvent[]> {
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<RpcResponse> {
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<T>(response: RpcResponse): T {
if (!response.success) {
const errorResponse = response as Extract<RpcResponse, { success: false }>;
throw new Error(errorResponse.error);
}
// Type assertion: we trust response.data matches T based on the command sent.
const successResponse = response as Extract<RpcResponse, { success: true; data: unknown }>;
return successResponse.data as T;
}
}

View file

@ -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<T = unknown> {
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"];

View file

@ -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"]
}

View file

@ -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"]
}