chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
58
third_party/pi/packages/coding-agent/src/modes/rpc/jsonl.ts
vendored
Normal file
58
third_party/pi/packages/coding-agent/src/modes/rpc/jsonl.ts
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
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 = "";
|
||||
}
|
||||
};
|
||||
|
||||
stream.on("data", onData);
|
||||
stream.on("end", onEnd);
|
||||
|
||||
return () => {
|
||||
stream.off("data", onData);
|
||||
stream.off("end", onEnd);
|
||||
};
|
||||
}
|
||||
609
third_party/pi/packages/coding-agent/src/modes/rpc/rpc-client.ts
vendored
Normal file
609
third_party/pi/packages/coding-agent/src/modes/rpc/rpc-client.ts
vendored
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* RPC Client for programmatic access to the coding agent.
|
||||
*
|
||||
* Spawns the agent in RPC mode and provides a typed API for all operations.
|
||||
*/
|
||||
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import type { SessionStats } from "../../core/agent-session.ts";
|
||||
import type { BashResult } from "../../core/bash-executor.ts";
|
||||
import type { CompactionResult } from "../../core/compaction/index.ts";
|
||||
import type { SessionEntry, SessionTreeNode } from "../../core/session-manager.ts";
|
||||
import type { JsonAgentSessionEvent } from "../json-event.ts";
|
||||
import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.ts";
|
||||
import type { RpcCommand, RpcResponse, RpcSessionState, RpcSlashCommand } from "./rpc-types.ts";
|
||||
|
||||
// ============================================================================
|
||||
// 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">;
|
||||
|
||||
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 interface ModelInfo {
|
||||
provider: string;
|
||||
id: string;
|
||||
contextWindow: number;
|
||||
reasoning: boolean;
|
||||
}
|
||||
|
||||
export type RpcEventListener = (event: JsonAgentSessionEvent) => void;
|
||||
|
||||
// ============================================================================
|
||||
// RPC Client
|
||||
// ============================================================================
|
||||
|
||||
export class RpcClient {
|
||||
private process: ChildProcess | null = null;
|
||||
private stopReadingStdout: (() => void) | null = null;
|
||||
private eventListeners: RpcEventListener[] = [];
|
||||
private pendingRequests: Map<string, { resolve: (response: RpcResponse) => void; reject: (error: Error) => void }> =
|
||||
new Map();
|
||||
private requestId = 0;
|
||||
private stderr = "";
|
||||
private exitError: Error | null = null;
|
||||
private options: RpcClientOptions;
|
||||
|
||||
constructor(options: RpcClientOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the RPC agent process.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.process) {
|
||||
throw new Error("Client already started");
|
||||
}
|
||||
|
||||
this.exitError = null;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const childProcess = spawn("node", [cliPath, ...args], {
|
||||
cwd: this.options.cwd,
|
||||
env: { ...process.env, ...this.options.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
this.process = childProcess;
|
||||
|
||||
// Collect stderr for debugging
|
||||
childProcess.stderr?.on("data", (data) => {
|
||||
this.stderr += data.toString();
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
childProcess.once("exit", (code, signal) => {
|
||||
if (this.process !== childProcess) return;
|
||||
const error = this.createProcessExitError(code, signal);
|
||||
this.exitError = error;
|
||||
this.rejectPendingRequests(error);
|
||||
});
|
||||
childProcess.once("error", (error) => {
|
||||
if (this.process !== childProcess) return;
|
||||
const processError = new Error(`Agent process error: ${error.message}. Stderr: ${this.stderr}`);
|
||||
this.exitError = processError;
|
||||
this.rejectPendingRequests(processError);
|
||||
});
|
||||
childProcess.stdin?.on("error", (error) => {
|
||||
if (this.process !== childProcess) return;
|
||||
const stdinError =
|
||||
this.exitError ?? new Error(`Agent process stdin error: ${error.message}. Stderr: ${this.stderr}`);
|
||||
this.exitError = stdinError;
|
||||
this.rejectPendingRequests(stdinError);
|
||||
});
|
||||
|
||||
// Set up strict JSONL reader for stdout.
|
||||
this.stopReadingStdout = attachJsonlLineReader(childProcess.stdout!, (line) => {
|
||||
this.handleLine(line);
|
||||
});
|
||||
|
||||
// Wait a moment for process to initialize
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (this.process.exitCode !== null) {
|
||||
const error = this.exitError ?? this.createProcessExitError(this.process.exitCode, this.process.signalCode);
|
||||
this.exitError = error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the RPC agent process.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.process) return;
|
||||
|
||||
this.stopReadingStdout?.();
|
||||
this.stopReadingStdout = null;
|
||||
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.
|
||||
*/
|
||||
onEvent(listener: RpcEventListener): () => void {
|
||||
this.eventListeners.push(listener);
|
||||
return () => {
|
||||
const index = this.eventListeners.indexOf(listener);
|
||||
if (index !== -1) {
|
||||
this.eventListeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() 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" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear queued steering and follow-up messages, returning their text.
|
||||
*/
|
||||
async clearQueue(): Promise<{ steering: string[]; followUp: string[] }> {
|
||||
const response = await this.send({ type: "clear_queue" });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available thinking levels for the current model.
|
||||
*/
|
||||
async getAvailableThinkingLevels(): Promise<ThinkingLevel[]> {
|
||||
const response = await this.send({ type: "get_available_thinking_levels" });
|
||||
return this.getData<{ levels: ThinkingLevel[] }>(response).levels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the current active branch into a new session.
|
||||
* @returns Object with `cancelled: true` if an extension cancelled the clone
|
||||
*/
|
||||
async clone(): Promise<{ cancelled: boolean }> {
|
||||
const response = await this.send({ type: "clone" });
|
||||
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 session entries in append order, optionally only those after the `since` entry id.
|
||||
*/
|
||||
async getEntries(since?: string): Promise<{ entries: SessionEntry[]; leafId: string | null }> {
|
||||
const response = await this.send({ type: "get_entries", since });
|
||||
return this.getData<{ entries: SessionEntry[]; leafId: string | null }>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session entry tree.
|
||||
*/
|
||||
async getTree(): Promise<{ tree: SessionTreeNode[]; leafId: string | null }> {
|
||||
const response = await this.send({ type: "get_tree" });
|
||||
return this.getData<{ tree: SessionTreeNode[]; leafId: string | null }>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async getMessages(): Promise<AgentMessage[]> {
|
||||
const response = await this.send({ type: "get_messages" });
|
||||
return this.getData<{ messages: AgentMessage[] }>(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;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Wait for agent to become idle (no streaming).
|
||||
* Resolves when agent_settled 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_settled") {
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect events until agent becomes idle.
|
||||
*/
|
||||
collectEvents(timeout = 60000): Promise<JsonAgentSessionEvent[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const events: JsonAgentSessionEvent[] = [];
|
||||
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_settled") {
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
resolve(events);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send prompt and wait for completion, returning all events.
|
||||
*/
|
||||
async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise<JsonAgentSessionEvent[]> {
|
||||
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
|
||||
for (const listener of this.eventListeners) {
|
||||
listener(data as JsonAgentSessionEvent);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
private createProcessExitError(code: number | null, signal: NodeJS.Signals | null): Error {
|
||||
return new Error(`Agent process exited (code=${code} signal=${signal}). Stderr: ${this.stderr}`);
|
||||
}
|
||||
|
||||
private rejectPendingRequests(error: Error): void {
|
||||
for (const pending of this.pendingRequests.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
private async send(command: RpcCommandBody): Promise<RpcResponse> {
|
||||
const childProcess = this.process;
|
||||
const stdin = childProcess?.stdin;
|
||||
if (!childProcess || !stdin) {
|
||||
throw new Error("Client not started");
|
||||
}
|
||||
if (this.exitError) {
|
||||
throw this.exitError;
|
||||
}
|
||||
if (childProcess.exitCode !== null) {
|
||||
const error = this.createProcessExitError(childProcess.exitCode, childProcess.signalCode);
|
||||
this.exitError = error;
|
||||
throw error;
|
||||
}
|
||||
if (stdin.destroyed || !stdin.writable) {
|
||||
const error = new Error(`Agent process stdin is not writable. Stderr: ${this.stderr}`);
|
||||
this.exitError = error;
|
||||
throw error;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
stdin.write(serializeJsonLine(fullCommand));
|
||||
} catch (error: unknown) {
|
||||
const writeError = error instanceof Error ? error : new Error(String(error));
|
||||
const pending = this.pendingRequests.get(id);
|
||||
this.pendingRequests.delete(id);
|
||||
pending?.reject(writeError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
// This is safe because each public method specifies the correct T for its command.
|
||||
const successResponse = response as Extract<RpcResponse, { success: true; data: unknown }>;
|
||||
return successResponse.data as T;
|
||||
}
|
||||
}
|
||||
821
third_party/pi/packages/coding-agent/src/modes/rpc/rpc-mode.ts
vendored
Normal file
821
third_party/pi/packages/coding-agent/src/modes/rpc/rpc-mode.ts
vendored
Normal file
@@ -0,0 +1,821 @@
|
||||
/**
|
||||
* RPC mode: Headless operation with JSON stdin/stdout protocol.
|
||||
*
|
||||
* Used for embedding the agent in other applications.
|
||||
* Receives commands as JSON on stdin, outputs events and responses as JSON on stdout.
|
||||
*
|
||||
* Protocol:
|
||||
* - Commands: JSON objects with `type` field, optional `id` for correlation
|
||||
* - Responses: JSON objects with `type: "response"`, `command`, `success`, and optional `data`/`error`
|
||||
* - Events: AgentSessionEvent objects streamed as they occur
|
||||
* - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
|
||||
*/
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import type { AgentSessionRuntime } from "../../core/agent-session-runtime.ts";
|
||||
import type {
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
ExtensionWidgetOptions,
|
||||
WorkingIndicatorOptions,
|
||||
} from "../../core/extensions/index.ts";
|
||||
import {
|
||||
flushRawStdout,
|
||||
takeOverStdout,
|
||||
waitForRawStdoutBackpressure,
|
||||
writeRawStdout,
|
||||
} from "../../core/output-guard.ts";
|
||||
import { killTrackedDetachedChildren } from "../../utils/shell.ts";
|
||||
import { type Theme, theme } from "../interactive/theme/theme.ts";
|
||||
import { toJsonEvent } from "../json-event.ts";
|
||||
import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.ts";
|
||||
import type {
|
||||
RpcCommand,
|
||||
RpcExtensionUIRequest,
|
||||
RpcExtensionUIResponse,
|
||||
RpcResponse,
|
||||
RpcSessionState,
|
||||
RpcSlashCommand,
|
||||
} from "./rpc-types.ts";
|
||||
|
||||
// Re-export types for consumers
|
||||
export type {
|
||||
RpcCommand,
|
||||
RpcExtensionUIRequest,
|
||||
RpcExtensionUIResponse,
|
||||
RpcResponse,
|
||||
RpcSessionState,
|
||||
} from "./rpc-types.ts";
|
||||
|
||||
/**
|
||||
* Run in RPC mode.
|
||||
* Listens for JSON commands on stdin, outputs events and responses on stdout.
|
||||
*/
|
||||
export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<never> {
|
||||
takeOverStdout();
|
||||
let session = runtimeHost.session;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let unsubscribeBackpressure: (() => void) | undefined;
|
||||
|
||||
const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
|
||||
writeRawStdout(serializeJsonLine(obj));
|
||||
};
|
||||
|
||||
const success = <T extends RpcCommand["type"]>(
|
||||
id: string | undefined,
|
||||
command: T,
|
||||
data?: object | null,
|
||||
): RpcResponse => {
|
||||
if (data === undefined) {
|
||||
return { id, type: "response", command, success: true } as RpcResponse;
|
||||
}
|
||||
return { id, type: "response", command, success: true, data } as RpcResponse;
|
||||
};
|
||||
|
||||
const error = (id: string | undefined, command: string, message: string): RpcResponse => {
|
||||
return { id, type: "response", command, success: false, error: message };
|
||||
};
|
||||
|
||||
// Pending extension UI requests waiting for response
|
||||
const pendingExtensionRequests = new Map<
|
||||
string,
|
||||
{ resolve: (value: any) => void; reject: (error: Error) => void }
|
||||
>();
|
||||
|
||||
// Shutdown request flag
|
||||
let shutdownRequested = false;
|
||||
let shuttingDown = false;
|
||||
const signalCleanupHandlers: Array<() => void> = [];
|
||||
|
||||
/** Helper for dialog methods with signal/timeout support */
|
||||
function createDialogPromise<T>(
|
||||
opts: ExtensionUIDialogOptions | undefined,
|
||||
defaultValue: T,
|
||||
request: Record<string, unknown>,
|
||||
parseResponse: (response: RpcExtensionUIResponse) => T,
|
||||
): Promise<T> {
|
||||
if (opts?.signal?.aborted) return Promise.resolve(defaultValue);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
pendingExtensionRequests.delete(id);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
resolve(defaultValue);
|
||||
};
|
||||
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
if (opts?.timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(defaultValue);
|
||||
}, opts.timeout);
|
||||
}
|
||||
|
||||
pendingExtensionRequests.set(id, {
|
||||
resolve: (response: RpcExtensionUIResponse) => {
|
||||
cleanup();
|
||||
resolve(parseResponse(response));
|
||||
},
|
||||
reject,
|
||||
});
|
||||
output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an extension UI context that uses the RPC protocol.
|
||||
*/
|
||||
const createExtensionUIContext = (): ExtensionUIContext => ({
|
||||
select: (title, options, opts) =>
|
||||
createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout }, (r) =>
|
||||
"cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
|
||||
),
|
||||
|
||||
confirm: (title, message, opts) =>
|
||||
createDialogPromise(opts, false, { method: "confirm", title, message, timeout: opts?.timeout }, (r) =>
|
||||
"cancelled" in r && r.cancelled ? false : "confirmed" in r ? r.confirmed : false,
|
||||
),
|
||||
|
||||
input: (title, placeholder, opts) =>
|
||||
createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout }, (r) =>
|
||||
"cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
|
||||
),
|
||||
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void {
|
||||
// Fire and forget - no response needed
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "notify",
|
||||
message,
|
||||
notifyType: type,
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
onTerminalInput(): () => void {
|
||||
// Raw terminal input not supported in RPC mode
|
||||
return () => {};
|
||||
},
|
||||
|
||||
setStatus(key: string, text: string | undefined): void {
|
||||
// Fire and forget - no response needed
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "setStatus",
|
||||
statusKey: key,
|
||||
statusText: text,
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
setWorkingMessage(_message?: string): void {
|
||||
// Working message not supported in RPC mode - requires TUI loader access
|
||||
},
|
||||
|
||||
setWorkingVisible(_visible: boolean): void {
|
||||
// Working visibility not supported in RPC mode - requires TUI loader access
|
||||
},
|
||||
|
||||
setWorkingIndicator(_options?: WorkingIndicatorOptions): void {
|
||||
// Working indicator customization not supported in RPC mode - requires TUI loader access
|
||||
},
|
||||
|
||||
setHiddenThinkingLabel(_label?: string): void {
|
||||
// Hidden thinking label not supported in RPC mode - requires TUI message rendering access
|
||||
},
|
||||
|
||||
setWidget(key: string, content: unknown, options?: ExtensionWidgetOptions): void {
|
||||
// Only support string arrays in RPC mode - factory functions are ignored
|
||||
if (content === undefined || Array.isArray(content)) {
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "setWidget",
|
||||
widgetKey: key,
|
||||
widgetLines: content as string[] | undefined,
|
||||
widgetPlacement: options?.placement,
|
||||
} as RpcExtensionUIRequest);
|
||||
}
|
||||
// Component factories are not supported in RPC mode - would need TUI access
|
||||
},
|
||||
|
||||
setFooter(_factory: unknown): void {
|
||||
// Custom footer not supported in RPC mode - requires TUI access
|
||||
},
|
||||
|
||||
setHeader(_factory: unknown): void {
|
||||
// Custom header not supported in RPC mode - requires TUI access
|
||||
},
|
||||
|
||||
setTitle(title: string): void {
|
||||
// Fire and forget - host can implement terminal title control
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "setTitle",
|
||||
title,
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
async custom() {
|
||||
// Custom UI not supported in RPC mode
|
||||
return undefined as never;
|
||||
},
|
||||
|
||||
pasteToEditor(text: string): void {
|
||||
// Paste handling not supported in RPC mode - falls back to setEditorText
|
||||
this.setEditorText(text);
|
||||
},
|
||||
|
||||
setEditorText(text: string): void {
|
||||
// Fire and forget - host can implement editor control
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "set_editor_text",
|
||||
text,
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
getEditorText(): string {
|
||||
// Synchronous method can't wait for RPC response
|
||||
// Host should track editor state locally if needed
|
||||
return "";
|
||||
},
|
||||
|
||||
async editor(title: string, prefill?: string): Promise<string | undefined> {
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingExtensionRequests.set(id, {
|
||||
resolve: (response: RpcExtensionUIResponse) => {
|
||||
if ("cancelled" in response && response.cancelled) {
|
||||
resolve(undefined);
|
||||
} else if ("value" in response) {
|
||||
resolve(response.value);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
});
|
||||
output({ type: "extension_ui_request", id, method: "editor", title, prefill } as RpcExtensionUIRequest);
|
||||
});
|
||||
},
|
||||
|
||||
addAutocompleteProvider(): void {
|
||||
// Autocomplete provider composition is not supported in RPC mode
|
||||
},
|
||||
|
||||
setEditorComponent(): void {
|
||||
// Custom editor components not supported in RPC mode
|
||||
},
|
||||
|
||||
getEditorComponent() {
|
||||
// Custom editor components not supported in RPC mode
|
||||
return undefined;
|
||||
},
|
||||
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
|
||||
getAllThemes() {
|
||||
return [];
|
||||
},
|
||||
|
||||
getTheme(_name: string) {
|
||||
return undefined;
|
||||
},
|
||||
|
||||
setTheme(_theme: string | Theme) {
|
||||
// Theme switching not supported in RPC mode
|
||||
return { success: false, error: "Theme switching not supported in RPC mode" };
|
||||
},
|
||||
|
||||
getToolsExpanded() {
|
||||
// Tool expansion not supported in RPC mode - no TUI
|
||||
return false;
|
||||
},
|
||||
|
||||
setToolsExpanded(_expanded: boolean) {
|
||||
// Tool expansion not supported in RPC mode - no TUI
|
||||
},
|
||||
});
|
||||
|
||||
runtimeHost.setRebindSession(async () => {
|
||||
await rebindSession();
|
||||
});
|
||||
|
||||
const rebindSession = async (): Promise<void> => {
|
||||
session = runtimeHost.session;
|
||||
await session.bindExtensions({
|
||||
uiContext: createExtensionUIContext(),
|
||||
mode: "rpc",
|
||||
commandContextActions: {
|
||||
waitForIdle: () => session.waitForIdle(),
|
||||
newSession: async (options) => runtimeHost.newSession(options),
|
||||
fork: async (entryId, forkOptions) => {
|
||||
const result = await runtimeHost.fork(entryId, forkOptions);
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
navigateTree: async (targetId, options) => {
|
||||
const result = await session.navigateTree(targetId, {
|
||||
summarize: options?.summarize,
|
||||
customInstructions: options?.customInstructions,
|
||||
replaceInstructions: options?.replaceInstructions,
|
||||
label: options?.label,
|
||||
});
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
switchSession: async (sessionPath, options) => {
|
||||
return runtimeHost.switchSession(sessionPath, options);
|
||||
},
|
||||
reload: async () => {
|
||||
await session.reload();
|
||||
},
|
||||
},
|
||||
shutdownHandler: () => {
|
||||
shutdownRequested = true;
|
||||
},
|
||||
onError: (err) => {
|
||||
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
|
||||
},
|
||||
});
|
||||
|
||||
unsubscribe?.();
|
||||
unsubscribeBackpressure?.();
|
||||
unsubscribe = session.subscribe((event) => {
|
||||
output(toJsonEvent(event));
|
||||
if (event.type === "agent_settled") {
|
||||
void checkShutdownRequested();
|
||||
}
|
||||
});
|
||||
unsubscribeBackpressure = session.agent.subscribe(async () => {
|
||||
await waitForRawStdoutBackpressure();
|
||||
});
|
||||
};
|
||||
|
||||
const registerSignalHandlers = (): void => {
|
||||
const signals: NodeJS.Signals[] = ["SIGTERM"];
|
||||
if (process.platform !== "win32") {
|
||||
signals.push("SIGHUP");
|
||||
}
|
||||
|
||||
for (const signal of signals) {
|
||||
const handler = () => {
|
||||
killTrackedDetachedChildren();
|
||||
void shutdown(signal === "SIGHUP" ? 129 : 143, signal);
|
||||
};
|
||||
process.on(signal, handler);
|
||||
signalCleanupHandlers.push(() => process.off(signal, handler));
|
||||
}
|
||||
};
|
||||
|
||||
await rebindSession();
|
||||
registerSignalHandlers();
|
||||
|
||||
// Handle a single command
|
||||
const handleCommand = async (command: RpcCommand): Promise<RpcResponse | undefined> => {
|
||||
const id = command.id;
|
||||
|
||||
switch (command.type) {
|
||||
// =================================================================
|
||||
// Prompting
|
||||
// =================================================================
|
||||
|
||||
case "prompt": {
|
||||
// Start prompt handling immediately, but emit the authoritative response only after
|
||||
// prompt preflight succeeds. Queued and immediately handled prompts also count as success.
|
||||
let preflightSucceeded = false;
|
||||
void session
|
||||
.prompt(command.message, {
|
||||
images: command.images,
|
||||
streamingBehavior: command.streamingBehavior,
|
||||
source: "rpc",
|
||||
preflightResult: (didSucceed) => {
|
||||
if (didSucceed) {
|
||||
preflightSucceeded = true;
|
||||
output(success(id, "prompt"));
|
||||
}
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!preflightSucceeded) {
|
||||
output(error(id, "prompt", e.message));
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
case "steer": {
|
||||
await session.steer(command.message, command.images);
|
||||
return success(id, "steer");
|
||||
}
|
||||
|
||||
case "follow_up": {
|
||||
await session.followUp(command.message, command.images);
|
||||
return success(id, "follow_up");
|
||||
}
|
||||
|
||||
case "abort": {
|
||||
await session.abort();
|
||||
return success(id, "abort");
|
||||
}
|
||||
|
||||
case "clear_queue": {
|
||||
return success(id, "clear_queue", session.clearQueue());
|
||||
}
|
||||
|
||||
case "new_session": {
|
||||
const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
|
||||
const result = await runtimeHost.newSession(options);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return success(id, "new_session", result);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// State
|
||||
// =================================================================
|
||||
|
||||
case "get_state": {
|
||||
const state: RpcSessionState = {
|
||||
model: session.model,
|
||||
thinkingLevel: session.thinkingLevel,
|
||||
isStreaming: session.isStreaming,
|
||||
isCompacting: session.isCompacting,
|
||||
steeringMode: session.steeringMode,
|
||||
followUpMode: session.followUpMode,
|
||||
sessionFile: session.sessionFile,
|
||||
sessionId: session.sessionId,
|
||||
sessionName: session.sessionName,
|
||||
autoCompactionEnabled: session.autoCompactionEnabled,
|
||||
messageCount: session.messages.length,
|
||||
pendingMessageCount: session.pendingMessageCount,
|
||||
};
|
||||
return success(id, "get_state", state);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Model
|
||||
// =================================================================
|
||||
|
||||
case "set_model": {
|
||||
const models = session.modelRuntime.getAvailableSnapshot();
|
||||
const model = models.find((m) => m.provider === command.provider && m.id === command.modelId);
|
||||
if (!model) {
|
||||
return error(id, "set_model", `Model not found: ${command.provider}/${command.modelId}`);
|
||||
}
|
||||
await session.setModel(model);
|
||||
return success(id, "set_model", model);
|
||||
}
|
||||
|
||||
case "cycle_model": {
|
||||
const result = await session.cycleModel();
|
||||
if (!result) {
|
||||
return success(id, "cycle_model", null);
|
||||
}
|
||||
return success(id, "cycle_model", result);
|
||||
}
|
||||
|
||||
case "get_available_models": {
|
||||
const models = session.modelRuntime.getAvailableSnapshot();
|
||||
return success(id, "get_available_models", { models });
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Thinking
|
||||
// =================================================================
|
||||
|
||||
case "set_thinking_level": {
|
||||
session.setThinkingLevel(command.level);
|
||||
return success(id, "set_thinking_level");
|
||||
}
|
||||
|
||||
case "cycle_thinking_level": {
|
||||
const level = session.cycleThinkingLevel();
|
||||
if (!level) {
|
||||
return success(id, "cycle_thinking_level", null);
|
||||
}
|
||||
return success(id, "cycle_thinking_level", { level });
|
||||
}
|
||||
|
||||
case "get_available_thinking_levels": {
|
||||
const levels = session.getAvailableThinkingLevels();
|
||||
return success(id, "get_available_thinking_levels", { levels });
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Queue Modes
|
||||
// =================================================================
|
||||
|
||||
case "set_steering_mode": {
|
||||
session.setSteeringMode(command.mode);
|
||||
return success(id, "set_steering_mode");
|
||||
}
|
||||
|
||||
case "set_follow_up_mode": {
|
||||
session.setFollowUpMode(command.mode);
|
||||
return success(id, "set_follow_up_mode");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Compaction
|
||||
// =================================================================
|
||||
|
||||
case "compact": {
|
||||
const result = await session.compact(command.customInstructions);
|
||||
return success(id, "compact", result);
|
||||
}
|
||||
|
||||
case "set_auto_compaction": {
|
||||
session.setAutoCompactionEnabled(command.enabled);
|
||||
return success(id, "set_auto_compaction");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Retry
|
||||
// =================================================================
|
||||
|
||||
case "set_auto_retry": {
|
||||
session.setAutoRetryEnabled(command.enabled);
|
||||
return success(id, "set_auto_retry");
|
||||
}
|
||||
|
||||
case "abort_retry": {
|
||||
session.abortRetry();
|
||||
return success(id, "abort_retry");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Bash
|
||||
// =================================================================
|
||||
|
||||
case "bash": {
|
||||
const eventResult = await session.extensionRunner.emitUserBash({
|
||||
type: "user_bash",
|
||||
command: command.command,
|
||||
excludeFromContext: command.excludeFromContext ?? false,
|
||||
cwd: session.sessionManager.getCwd(),
|
||||
});
|
||||
|
||||
if (eventResult?.result) {
|
||||
session.recordBashResult(command.command, eventResult.result, {
|
||||
excludeFromContext: command.excludeFromContext,
|
||||
});
|
||||
return success(id, "bash", eventResult.result);
|
||||
}
|
||||
|
||||
const result = await session.executeBash(command.command, undefined, {
|
||||
excludeFromContext: command.excludeFromContext,
|
||||
id,
|
||||
operations: eventResult?.operations,
|
||||
});
|
||||
return success(id, "bash", result);
|
||||
}
|
||||
|
||||
case "abort_bash": {
|
||||
session.abortBash();
|
||||
return success(id, "abort_bash");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Session
|
||||
// =================================================================
|
||||
|
||||
case "get_session_stats": {
|
||||
const stats = session.getSessionStats();
|
||||
return success(id, "get_session_stats", stats);
|
||||
}
|
||||
|
||||
case "export_html": {
|
||||
const path = await session.exportToHtml(command.outputPath);
|
||||
return success(id, "export_html", { path });
|
||||
}
|
||||
|
||||
case "switch_session": {
|
||||
const result = await runtimeHost.switchSession(command.sessionPath);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return success(id, "switch_session", result);
|
||||
}
|
||||
|
||||
case "fork": {
|
||||
const result = await runtimeHost.fork(command.entryId);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return success(id, "fork", { text: result.selectedText, cancelled: result.cancelled });
|
||||
}
|
||||
|
||||
case "clone": {
|
||||
const leafId = session.sessionManager.getLeafId();
|
||||
if (!leafId) {
|
||||
return error(id, "clone", "Cannot clone session: no current entry selected");
|
||||
}
|
||||
const result = await runtimeHost.fork(leafId, { position: "at" });
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return success(id, "clone", { cancelled: result.cancelled });
|
||||
}
|
||||
|
||||
case "get_fork_messages": {
|
||||
const messages = session.getUserMessagesForForking();
|
||||
return success(id, "get_fork_messages", { messages });
|
||||
}
|
||||
|
||||
case "get_entries": {
|
||||
const sessionManager = session.sessionManager;
|
||||
let entries = sessionManager.getEntries();
|
||||
if (command.since !== undefined) {
|
||||
const sinceIndex = entries.findIndex((e) => e.id === command.since);
|
||||
if (sinceIndex === -1) {
|
||||
return error(id, "get_entries", `Entry not found: ${command.since}`);
|
||||
}
|
||||
entries = entries.slice(sinceIndex + 1);
|
||||
}
|
||||
return success(id, "get_entries", { entries, leafId: sessionManager.getLeafId() });
|
||||
}
|
||||
|
||||
case "get_tree": {
|
||||
const sessionManager = session.sessionManager;
|
||||
return success(id, "get_tree", { tree: sessionManager.getTree(), leafId: sessionManager.getLeafId() });
|
||||
}
|
||||
|
||||
case "get_last_assistant_text": {
|
||||
const text = session.getLastAssistantText();
|
||||
return success(id, "get_last_assistant_text", { text });
|
||||
}
|
||||
|
||||
case "set_session_name": {
|
||||
const name = command.name.trim();
|
||||
if (!name) {
|
||||
return error(id, "set_session_name", "Session name cannot be empty");
|
||||
}
|
||||
session.setSessionName(name);
|
||||
return success(id, "set_session_name");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Messages
|
||||
// =================================================================
|
||||
|
||||
case "get_messages": {
|
||||
return success(id, "get_messages", { messages: session.messages });
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Commands (available for invocation via prompt)
|
||||
// =================================================================
|
||||
|
||||
case "get_commands": {
|
||||
const commands: RpcSlashCommand[] = [];
|
||||
|
||||
for (const command of session.extensionRunner.getRegisteredCommands()) {
|
||||
commands.push({
|
||||
name: command.invocationName,
|
||||
description: command.description,
|
||||
source: "extension",
|
||||
sourceInfo: command.sourceInfo,
|
||||
});
|
||||
}
|
||||
|
||||
for (const template of session.promptTemplates) {
|
||||
commands.push({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
source: "prompt",
|
||||
sourceInfo: template.sourceInfo,
|
||||
});
|
||||
}
|
||||
|
||||
for (const skill of session.resourceLoader.getSkills().skills) {
|
||||
commands.push({
|
||||
name: `skill:${skill.name}`,
|
||||
description: skill.description,
|
||||
source: "skill",
|
||||
sourceInfo: skill.sourceInfo,
|
||||
});
|
||||
}
|
||||
|
||||
return success(id, "get_commands", { commands });
|
||||
}
|
||||
|
||||
default: {
|
||||
const unknownCommand = command as { type: string };
|
||||
return error(id, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if shutdown was requested and perform shutdown if so.
|
||||
* Called after handling each command when waiting for the next command.
|
||||
*/
|
||||
let detachInput = () => {};
|
||||
|
||||
async function shutdown(exitCode = 0, signal?: NodeJS.Signals): Promise<never> {
|
||||
if (shuttingDown) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
shuttingDown = true;
|
||||
for (const cleanup of signalCleanupHandlers) {
|
||||
cleanup();
|
||||
}
|
||||
unsubscribe?.();
|
||||
unsubscribeBackpressure?.();
|
||||
await runtimeHost.dispose();
|
||||
detachInput();
|
||||
process.stdin.pause();
|
||||
if (signal !== "SIGTERM") {
|
||||
await flushRawStdout();
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
async function checkShutdownRequested(): Promise<void> {
|
||||
if (!shutdownRequested) return;
|
||||
await shutdown();
|
||||
}
|
||||
|
||||
const handleInputLine = async (line: string) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (parseError: unknown) {
|
||||
output(
|
||||
error(
|
||||
undefined,
|
||||
"parse",
|
||||
`Failed to parse command: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
|
||||
),
|
||||
);
|
||||
await waitForRawStdoutBackpressure();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle extension UI responses
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
"type" in parsed &&
|
||||
parsed.type === "extension_ui_response"
|
||||
) {
|
||||
const response = parsed as RpcExtensionUIResponse;
|
||||
const pending = pendingExtensionRequests.get(response.id);
|
||||
if (pending) {
|
||||
pendingExtensionRequests.delete(response.id);
|
||||
pending.resolve(response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const command = parsed as RpcCommand;
|
||||
try {
|
||||
const response = await handleCommand(command);
|
||||
if (response) {
|
||||
output(response);
|
||||
await waitForRawStdoutBackpressure();
|
||||
}
|
||||
await checkShutdownRequested();
|
||||
} catch (commandError: unknown) {
|
||||
output(
|
||||
error(
|
||||
command.id,
|
||||
command.type,
|
||||
commandError instanceof Error ? commandError.message : String(commandError),
|
||||
),
|
||||
);
|
||||
await waitForRawStdoutBackpressure();
|
||||
}
|
||||
};
|
||||
|
||||
const onInputEnd = () => {
|
||||
void shutdown();
|
||||
};
|
||||
process.stdin.on("end", onInputEnd);
|
||||
|
||||
detachInput = (() => {
|
||||
const detachJsonl = attachJsonlLineReader(process.stdin, (line) => {
|
||||
void handleInputLine(line);
|
||||
});
|
||||
return () => {
|
||||
detachJsonl();
|
||||
process.stdin.off("end", onInputEnd);
|
||||
};
|
||||
})();
|
||||
|
||||
// Keep process alive forever
|
||||
return new Promise(() => {});
|
||||
}
|
||||
297
third_party/pi/packages/coding-agent/src/modes/rpc/rpc-types.ts
vendored
Normal file
297
third_party/pi/packages/coding-agent/src/modes/rpc/rpc-types.ts
vendored
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* RPC protocol types for headless operation.
|
||||
*
|
||||
* Commands are sent as JSON lines on stdin.
|
||||
* Responses and events are emitted as JSON lines on stdout.
|
||||
*/
|
||||
|
||||
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { ImageContent, Model } from "@earendil-works/pi-ai";
|
||||
import type { SessionStats } from "../../core/agent-session.ts";
|
||||
import type { BashResult } from "../../core/bash-executor.ts";
|
||||
import type { CompactionResult } from "../../core/compaction/index.ts";
|
||||
import type { SessionEntry, SessionTreeNode } from "../../core/session-manager.ts";
|
||||
import type { SourceInfo } from "../../core/source-info.ts";
|
||||
|
||||
// ============================================================================
|
||||
// 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: "clear_queue" }
|
||||
| { 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" }
|
||||
| { id?: string; type: "get_available_thinking_levels" }
|
||||
|
||||
// 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; excludeFromContext?: boolean }
|
||||
| { 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: "clone" }
|
||||
| { id?: string; type: "get_fork_messages" }
|
||||
| { id?: string; type: "get_entries"; since?: string }
|
||||
| { id?: string; type: "get_tree" }
|
||||
| { 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" };
|
||||
|
||||
// ============================================================================
|
||||
// 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";
|
||||
/** Source metadata for the owning resource */
|
||||
sourceInfo: SourceInfo;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RPC State
|
||||
// ============================================================================
|
||||
|
||||
export interface RpcSessionState {
|
||||
model?: Model<any>;
|
||||
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;
|
||||
messageCount: number;
|
||||
pendingMessageCount: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RPC Responses (stdout)
|
||||
// ============================================================================
|
||||
|
||||
// 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: "abort"; success: true }
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "clear_queue";
|
||||
success: true;
|
||||
data: { steering: string[]; followUp: string[] };
|
||||
}
|
||||
| { 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: Model<any>;
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "cycle_model";
|
||||
success: true;
|
||||
data: { model: Model<any>; thinkingLevel: ThinkingLevel; isScoped: boolean } | null;
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_available_models";
|
||||
success: true;
|
||||
data: { models: Model<any>[] };
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_available_thinking_levels";
|
||||
success: true;
|
||||
data: { levels: ThinkingLevel[] };
|
||||
}
|
||||
|
||||
// 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: "clone"; success: true; data: { 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_entries";
|
||||
success: true;
|
||||
data: { entries: SessionEntry[]; leafId: string | null };
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_tree";
|
||||
success: true;
|
||||
data: { tree: SessionTreeNode[]; leafId: string | null };
|
||||
}
|
||||
| {
|
||||
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
|
||||
| { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AgentMessage[] } }
|
||||
|
||||
// Commands
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_commands";
|
||||
success: true;
|
||||
data: { commands: RpcSlashCommand[] };
|
||||
}
|
||||
|
||||
// Error response (any command can fail)
|
||||
| { id?: string; type: "response"; command: string; success: false; error: string };
|
||||
|
||||
// ============================================================================
|
||||
// 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 }
|
||||
| { 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; confirmed: boolean }
|
||||
| { type: "extension_ui_response"; id: string; cancelled: true };
|
||||
|
||||
// ============================================================================
|
||||
// Helper type for extracting command types
|
||||
// ============================================================================
|
||||
|
||||
export type RpcCommandType = RpcCommand["type"];
|
||||
Reference in New Issue
Block a user