feat: Added RPC protocol v2 types, init handshake with version detectio…

- "packages/pi-coding-agent/src/modes/rpc/rpc-types.ts"
- "packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts"
- "packages/pi-coding-agent/src/modes/rpc/rpc-client.ts"
- "packages/pi-coding-agent/src/modes/index.ts"
- "packages/pi-coding-agent/src/index.ts"

GSD-Task: S01/T01
This commit is contained in:
Lex Christopherson 2026-03-26 11:01:58 -06:00
parent 0db5edd7fe
commit 01e37670e1
5 changed files with 130 additions and 7 deletions

View file

@ -314,8 +314,11 @@ export {
type RpcClientOptions,
type RpcEventListener,
type RpcCommand,
type RpcInitResult,
type RpcProtocolVersion,
type RpcResponse,
type RpcSessionState,
type RpcV2Event,
} from "./modes/index.js";
// RPC JSONL utilities
export { attachJsonlLineReader, serializeJsonLine } from "./modes/rpc/jsonl.js";

View file

@ -6,4 +6,11 @@ export { InteractiveMode, type InteractiveModeOptions } from "./interactive/inte
export { type PrintModeOptions, runPrintMode } from "./print-mode.js";
export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.js";
export { runRpcMode } from "./rpc/rpc-mode.js";
export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc/rpc-types.js";
export type {
RpcCommand,
RpcInitResult,
RpcProtocolVersion,
RpcResponse,
RpcSessionState,
RpcV2Event,
} from "./rpc/rpc-types.js";

View file

@ -398,6 +398,21 @@ export class RpcClient {
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,
}));
}
// =========================================================================
// Helpers
// =========================================================================

View file

@ -27,6 +27,7 @@ import type {
RpcCommand,
RpcExtensionUIRequest,
RpcExtensionUIResponse,
RpcInitResult,
RpcResponse,
RpcSessionState,
RpcSlashCommand,
@ -37,8 +38,11 @@ export type {
RpcCommand,
RpcExtensionUIRequest,
RpcExtensionUIResponse,
RpcInitResult,
RpcProtocolVersion,
RpcResponse,
RpcSessionState,
RpcV2Event,
} from "./rpc-types.js";
/**
@ -74,6 +78,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
// Shutdown request flag
let shutdownRequested = false;
// v2 protocol version detection state
let protocolVersion: 1 | 2 = 1;
let protocolLocked = false;
const embeddedTerminalEnabled = process.env.GSD_WEB_BRIDGE_TUI === "1";
const remoteTerminal = embeddedTerminalEnabled
? new RemoteTerminal({
@ -709,6 +717,15 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return success(id, "terminal_redraw");
}
// =================================================================
// v2 Protocol: shutdown
// =================================================================
case "shutdown": {
shutdownRequested = true;
return success(id, "shutdown");
}
default: {
const unknownCommand = command as { type: string; id?: string };
return error(unknownCommand.id, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
@ -741,7 +758,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
try {
const parsed = JSON.parse(line);
// Handle extension UI responses
// Handle extension UI responses (bypass protocol detection)
if (parsed.type === "extension_ui_response") {
const response = parsed as RpcExtensionUIResponse;
const pending = pendingExtensionRequests.get(response.id);
@ -752,8 +769,33 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return;
}
// Handle regular commands
const command = parsed as RpcCommand;
// Protocol version detection: first non-UI-response command locks the version
if (!protocolLocked) {
protocolLocked = true;
if (command.type === "init") {
protocolVersion = 2;
const initResult: RpcInitResult = {
protocolVersion: 2,
sessionId: session.sessionId,
capabilities: {
events: ["execution_complete", "cost_update"],
commands: ["init", "shutdown", "subscribe"],
},
};
output(success(command.id, "init", initResult));
return;
}
// Non-init first message: lock to v1, fall through to normal handling
protocolVersion = 1;
} else if (command.type === "init") {
// Already locked — reject re-init
output(error(command.id, "init", "Protocol version already locked. init must be the first command."));
return;
}
// Handle regular commands
const response = await handleCommand(command);
output(response);

View file

@ -11,6 +11,13 @@ import type { SessionStats } from "../../core/agent-session.js";
import type { BashResult } from "../../core/bash-executor.js";
import type { CompactionResult } from "../../core/compaction/index.js";
// ============================================================================
// RPC Protocol Versioning
// ============================================================================
/** Supported protocol versions. v1 is the implicit default; v2 requires an init handshake. */
export type RpcProtocolVersion = 1 | 2;
// ============================================================================
// RPC Commands (stdin)
// ============================================================================
@ -69,7 +76,12 @@ export type RpcCommand =
// 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" };
| { 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)
@ -120,9 +132,9 @@ export interface RpcSessionState {
// Success responses with data
export type RpcResponse =
// Prompting (async - events follow)
| { id?: string; type: "response"; command: "prompt"; success: true }
| { id?: string; type: "response"; command: "steer"; success: true }
| { id?: string; type: "response"; command: "follow_up"; success: true }
| { 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 } }
@ -216,9 +228,53 @@ export type RpcResponse =
| { 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 }
// 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)
// ============================================================================