From 01e37670e1ceaab2dad4b8365e971d8f3bbc076c Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Thu, 26 Mar 2026 11:01:58 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20RPC=20protocol=20v2=20types,=20?= =?UTF-8?q?init=20handshake=20with=20version=20detectio=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "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 --- packages/pi-coding-agent/src/index.ts | 3 + packages/pi-coding-agent/src/modes/index.ts | 9 ++- .../src/modes/rpc/rpc-client.ts | 15 +++++ .../pi-coding-agent/src/modes/rpc/rpc-mode.ts | 46 ++++++++++++- .../src/modes/rpc/rpc-types.ts | 64 +++++++++++++++++-- 5 files changed, 130 insertions(+), 7 deletions(-) diff --git a/packages/pi-coding-agent/src/index.ts b/packages/pi-coding-agent/src/index.ts index b8bdcb430..12327173b 100644 --- a/packages/pi-coding-agent/src/index.ts +++ b/packages/pi-coding-agent/src/index.ts @@ -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"; diff --git a/packages/pi-coding-agent/src/modes/index.ts b/packages/pi-coding-agent/src/modes/index.ts index 205e9f54c..1e31e54e0 100644 --- a/packages/pi-coding-agent/src/modes/index.ts +++ b/packages/pi-coding-agent/src/modes/index.ts @@ -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"; diff --git a/packages/pi-coding-agent/src/modes/rpc/rpc-client.ts b/packages/pi-coding-agent/src/modes/rpc/rpc-client.ts index 7ffd94b65..197dee8a0 100644 --- a/packages/pi-coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/pi-coding-agent/src/modes/rpc/rpc-client.ts @@ -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 // ========================================================================= diff --git a/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts b/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts index 8f0f6a488..27a898765 100644 --- a/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts @@ -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 { // 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 { 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 { 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 { 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); diff --git a/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts b/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts index a1b7a7711..957e0f3ac 100644 --- a/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts @@ -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) // ============================================================================