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:
2026-09-14 10:48:47 -04:00
parent 5ebc7018c3
commit 4def357d09
1669 changed files with 397627 additions and 0 deletions

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

View 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;
}
}

View 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(() => {});
}

View 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"];