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:
parent
3be38e3794
commit
bb6d64a5ba
13 changed files with 1893 additions and 64 deletions
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
125
packages/rpc-client/README.md
Normal file
125
packages/rpc-client/README.md
Normal 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
|
||||
13
packages/rpc-client/examples/basic-usage.ts
Normal file
13
packages/rpc-client/examples/basic-usage.ts
Normal 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();
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
10
packages/rpc-client/src/index.ts
Normal file
10
packages/rpc-client/src/index.ts
Normal 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";
|
||||
64
packages/rpc-client/src/jsonl.ts
Normal file
64
packages/rpc-client/src/jsonl.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
568
packages/rpc-client/src/rpc-client.test.ts
Normal file
568
packages/rpc-client/src/rpc-client.test.ts
Normal 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], "*");
|
||||
});
|
||||
});
|
||||
666
packages/rpc-client/src/rpc-client.ts
Normal file
666
packages/rpc-client/src/rpc-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
399
packages/rpc-client/src/rpc-types.ts
Normal file
399
packages/rpc-client/src/rpc-types.ts
Normal 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"];
|
||||
17
packages/rpc-client/tsconfig.examples.json
Normal file
17
packages/rpc-client/tsconfig.examples.json
Normal 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"]
|
||||
}
|
||||
24
packages/rpc-client/tsconfig.json
Normal file
24
packages/rpc-client/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue