/** * 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 { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { $env, isRecord, readJsonl, Snowflake } from "@oh-my-pi/pi-utils"; import { reset as resetCapabilities } from "../../capability"; import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers"; import { type ExtensionUIContext, type ExtensionUIDialogOptions, type ExtensionUISelectItem, type ExtensionWidgetOptions, getExtensionUISelectOptionLabel, } from "../../extensibility/extensions"; import { buildSkillPromptMessage, parseSkillInvocation } from "../../extensibility/skills"; import { loadSlashCommands } from "../../extensibility/slash-commands"; import { type Theme, theme } from "../../modes/theme/theme"; import type { AgentSession } from "../../session/agent-session"; import { SKILL_PROMPT_MESSAGE_TYPE, USER_INTERRUPT_LABEL } from "../../session/messages"; import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins"; import { buildAvailableSlashCommands } from "../../slash-commands/available-commands"; import type { EventBus } from "../../utils/event-bus"; import { initializeExtensions } from "../runtime-init"; import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools"; import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris"; import { RpcSubagentRegistry, readRpcSubagentTranscript } from "./rpc-subagents"; import type { RpcCommand, RpcExtensionUIRequest, RpcExtensionUIResponse, RpcHostToolCallRequest, RpcHostToolCancelRequest, RpcHostToolDefinition, RpcHostToolResult, RpcHostToolUpdate, RpcHostUriCancelRequest, RpcHostUriRequest, RpcHostUriResult, RpcResponse, RpcSessionState, RpcSubagentSubscriptionLevel, } from "./rpc-types"; // Re-export types for consumers export type * from "./rpc-types"; export type PendingExtensionRequest = { resolve: (response: RpcExtensionUIResponse) => void; reject: (error: Error) => void; }; type RpcOutput = ( obj: | RpcResponse | RpcExtensionUIRequest | RpcHostToolCallRequest | RpcHostToolCancelRequest | RpcHostUriRequest | RpcHostUriCancelRequest | object, ) => void; export type RpcSessionChangeCommand = Extract< RpcCommand, { type: "new_session" } | { type: "switch_session" } | { type: "branch" } >; export type RpcSessionChangeResult = | { type: "new_session"; data: { cancelled: boolean } } | { type: "switch_session"; data: { cancelled: boolean } } | { type: "branch"; data: { text: string; cancelled: boolean } }; export type RpcSessionChangeSession = Pick; export type RpcSkillCommandSession = Pick; export type RpcSkillCommandResult = { agentInvoked: true }; export async function tryRunRpcSkillCommand( session: RpcSkillCommandSession, text: string, ): Promise { if (!session.skillsSettings?.enableSkillCommands) return false; const parsed = parseSkillInvocation(text); if (!parsed) return false; const skill = session.skills.find(candidate => candidate.name === parsed.name); if (!skill) return false; const built = await buildSkillPromptMessage(skill, parsed.args, "user"); await session.promptCustomMessage({ customType: SKILL_PROMPT_MESSAGE_TYPE, content: built.message, display: true, details: built.details, attribution: "user", }); return { agentInvoked: true }; } export function reportLocalOnlyPromptResult(input: { id: string | undefined; prompt: Promise; output: (obj: object) => void; onError: (error: Error) => void; hasExtensionAgentMessageTask?: () => boolean; waitForExtensionAgentMessageTasks?: () => Promise; }): void { void input.prompt .then(async agentInvoked => { if (agentInvoked) return; await input.waitForExtensionAgentMessageTasks?.(); if (!input.hasExtensionAgentMessageTask?.()) { input.output({ type: "prompt_result", id: input.id, agentInvoked: false }); } }) .catch(error => { input.onError(error instanceof Error ? error : new Error(String(error))); }); } type RpcExtensionUserMessageScope = { hasAgentMessageTask: boolean; pendingAgentMessageTasks: Set>; }; /** * Tracks extension-originated messages while an RPC prompt is executing. * A slash command can resolve the outer prompt as local-only while also * scheduling agent work through pi.sendUserMessage() or pi.sendMessage() * with triggerTurn; that prompt must not report agentInvoked:false to the host. */ export class RpcExtensionUserMessageTracker { #activePromptScopes = new Set(); markAgentMessageTask(): void { for (const scope of this.#activePromptScopes) { scope.hasAgentMessageTask = true; } } trackAgentMessageTask(task: Promise): void { for (const scope of this.#activePromptScopes) { this.#trackAgentMessageTaskForScope(scope, task); } } #trackAgentMessageTaskForScope(scope: RpcExtensionUserMessageScope, task: Promise): void { const scopedTask = task.then( () => { scope.hasAgentMessageTask = true; }, () => {}, ); scope.pendingAgentMessageTasks.add(scopedTask); void scopedTask.finally(() => { scope.pendingAgentMessageTasks.delete(scopedTask); }); } async #waitForAgentMessageTasks(scope: RpcExtensionUserMessageScope): Promise { while (scope.pendingAgentMessageTasks.size > 0) { await Promise.allSettled(Array.from(scope.pendingAgentMessageTasks)); } } watchPrompt(startPrompt: () => Promise): { prompt: Promise; hasAgentMessageTask: () => boolean; waitForAgentMessageTasks: () => Promise; } { const scope: RpcExtensionUserMessageScope = { hasAgentMessageTask: false, pendingAgentMessageTasks: new Set(), }; this.#activePromptScopes.add(scope); let prompt: Promise; try { prompt = startPrompt(); } catch (error) { this.#activePromptScopes.delete(scope); throw error; } return { prompt: prompt.finally(() => { this.#activePromptScopes.delete(scope); }), hasAgentMessageTask: () => scope.hasAgentMessageTask, waitForAgentMessageTasks: () => this.#waitForAgentMessageTasks(scope), }; } } export function watchAndReportLocalOnlyPromptResult(input: { id: string | undefined; startPrompt: () => Promise; output: (obj: object) => void; onError: (error: Error) => void; extensionUserMessageTracker: RpcExtensionUserMessageTracker; }): void { const trackedPrompt = input.extensionUserMessageTracker.watchPrompt(input.startPrompt); reportLocalOnlyPromptResult({ id: input.id, prompt: trackedPrompt.prompt, output: input.output, onError: input.onError, hasExtensionAgentMessageTask: trackedPrompt.hasAgentMessageTask, waitForExtensionAgentMessageTasks: trackedPrompt.waitForAgentMessageTasks, }); } /** * Dependencies for {@link dispatchRpcInputFrame}. Provided by the RPC mode * entrypoint; broken out so tests can drive the input loop with stubs. */ export interface RpcInputFrameDeps { handleCommand: (command: RpcCommand) => Promise; output: RpcOutput; errorResponse: (id: string | undefined, command: string, message: string) => RpcResponse; trackBackgroundTask?: (task: Promise) => void; pendingExtensionRequests: Map; onHostToolResult: (frame: RpcHostToolResult) => void; onHostToolUpdate: (frame: RpcHostToolUpdate) => void; onHostUriResult: (frame: RpcHostUriResult) => void; } /** * Structural guard for a well-formed extension UI response frame. Mirrors the * shape declared in {@link RpcExtensionUIResponse} — a truthy record with * `type === "extension_ui_response"` and a string `id`. Payload variants (value, * confirmed, cancelled) are validated at the read site. */ function isRpcExtensionUIResponse(value: unknown): value is RpcExtensionUIResponse { if (!isRecord(value)) return false; return value.type === "extension_ui_response" && typeof value.id === "string"; } /** * Dispatch a single parsed frame from the RPC input stream. * * Bash commands are dispatched in the background so the caller (the stdin loop * in {@link runRpcMode}) can keep reading subsequent frames while a shell * command is still running. This lets a client send `abort_bash` (or any other * command) while a long-running `bash` is in flight. Response correlation is * preserved via each command's `id`; ordering across concurrent commands is * not guaranteed and clients MUST match on `id`. * * @returns `undefined` when the frame was routed to a side-channel handler * (extension UI response, host tool/URI frames) or dispatched in the * background (`bash`). Otherwise a promise that resolves once the response * for the command has been emitted via `output`. Errors from `handleCommand` * on non-`bash` commands propagate; the caller is expected to wrap them. */ export function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps): Promise | undefined { // Side-channel: extension UI responses resolve a pending dialog promise. if (isRpcExtensionUIResponse(parsed)) { const pending = deps.pendingExtensionRequests.get(parsed.id); if (pending) pending.resolve(parsed); return undefined; } if (isRpcHostToolResult(parsed)) { deps.onHostToolResult(parsed); return undefined; } if (isRpcHostToolUpdate(parsed)) { deps.onHostToolUpdate(parsed); return undefined; } if (isRpcHostUriResult(parsed)) { deps.onHostUriResult(parsed); return undefined; } // Regular RPC command. The transport contract states each remaining frame // is an {@link RpcCommand}; `handleCommand`'s `default` arm surfaces // unknown discriminants as an error response, so we do not shape-check // the union here. const command = parsed as RpcCommand; // `bash` can run for a long time. Dispatch it in the background so a // subsequent `abort_bash` frame can be read and handled without waiting // for the shell command to finish on its own. The response is emitted // when `handleCommand` resolves; clients correlate via `command.id`. if (command.type === "bash") { const task = (async () => { try { deps.output(await deps.handleCommand(command)); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); deps.output(deps.errorResponse(command.id, "bash", message)); } })(); deps.trackBackgroundTask?.(task); return undefined; } return (async () => { deps.output(await deps.handleCommand(command)); })(); } /** * Coordinates deferred shutdown with in-flight background input tasks. * * `pi.shutdown()` from an extension only *requests* shutdown; the process must * not exit while a background-dispatched command (`bash`, see * {@link dispatchRpcInputFrame}) still owes the client a response frame. The * coordinator tracks those tasks, re-checks the shutdown request whenever one * settles (covering a shutdown requested mid-bash with no follow-up client * frame), and drains every tracked task before invoking `performShutdown`. * The shutdown sequence is latched so concurrent triggers (input loop and * settling tasks) run it exactly once. */ export class RpcShutdownCoordinator { #tasks = new Set>(); #shutdown: Promise | undefined; readonly #isShutdownRequested: () => boolean; readonly #performShutdown: () => Promise; constructor(options: { isShutdownRequested: () => boolean; performShutdown: () => Promise }) { this.#isShutdownRequested = options.isShutdownRequested; this.#performShutdown = options.performShutdown; } /** * Track a background input task. When it settles it is untracked and the * shutdown request is re-checked, so a deferred shutdown fires even when * no further client frames arrive. */ track(task: Promise): void { this.#tasks.add(task); void task.finally(() => { this.#tasks.delete(task); // Fire-and-forget: performShutdown ends the process. Rejections are // not expected — hook errors are caught inside extensionRunner.emit, // and background tasks catch their own dispatch errors. void this.checkShutdownRequested(); }); } /** Await every tracked task, including tasks tracked while draining. */ async drain(): Promise { while (this.#tasks.size > 0) { await Promise.allSettled(Array.from(this.#tasks)); } } /** * If shutdown was requested, drain background tasks (so every owed * response frame is written) before running the shutdown sequence. */ checkShutdownRequested(): Promise { if (!this.#shutdown) { if (!this.#isShutdownRequested()) return Promise.resolve(); this.#shutdown = this.drain().then(() => this.#performShutdown()); } return this.#shutdown; } } export type RpcSubagentResetRegistry = Pick; export async function handleRpcSessionChange( session: RpcSessionChangeSession, command: RpcSessionChangeCommand, subagentRegistry?: RpcSubagentResetRegistry, ): Promise { switch (command.type) { case "new_session": { const options = command.parentSession ? { parentSession: command.parentSession } : undefined; const cancelled = !(await session.newSession(options)); if (!cancelled) subagentRegistry?.clear(); return { type: "new_session", data: { cancelled } }; } case "switch_session": { const cancelled = !(await session.switchSession(command.sessionPath)); if (!cancelled) subagentRegistry?.clear(); return { type: "switch_session", data: { cancelled } }; } case "branch": { const result = await session.branch(command.entryId); if (!result.cancelled) subagentRegistry?.clear(); return { type: "branch", data: { text: result.selectedText, cancelled: result.cancelled } }; } } throw new Error("Unsupported RPC session change command"); } function normalizeHostToolDefinitions(tools: RpcHostToolDefinition[]): RpcHostToolDefinition[] { return tools.map((tool, index) => { const name = typeof tool.name === "string" ? tool.name.trim() : ""; if (!name) { throw new Error(`Host tool at index ${index} must provide a non-empty name`); } const description = typeof tool.description === "string" ? tool.description.trim() : ""; if (!description) { throw new Error(`Host tool "${name}" must provide a non-empty description`); } if (!tool.parameters || typeof tool.parameters !== "object" || Array.isArray(tool.parameters)) { throw new Error(`Host tool "${name}" must provide a JSON Schema object`); } const label = typeof tool.label === "string" && tool.label.trim() ? tool.label.trim() : name; return { name, label, description, parameters: tool.parameters, hidden: tool.hidden === true, }; }); } function parseValueDialogResponse( response: RpcExtensionUIResponse, dialogOptions: ExtensionUIDialogOptions | undefined, ): string | undefined { if ("cancelled" in response && response.cancelled) { if (response.timedOut) dialogOptions?.onTimeout?.(); return undefined; } if ("value" in response) return response.value; return undefined; } function shouldEmitRpcTitles(): boolean { const raw = $env.PI_RPC_EMIT_TITLE; if (!raw) return false; const normalized = raw.trim().toLowerCase(); return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; } function isSubagentSubscriptionLevel(value: unknown): value is RpcSubagentSubscriptionLevel { return value === "off" || value === "progress" || value === "events"; } export function requestRpcEditor( pendingRequests: Map, output: RpcOutput, title: string, prefill?: string, dialogOptions?: ExtensionUIDialogOptions, editorOptions?: { promptStyle?: boolean }, ): Promise { if (dialogOptions?.signal?.aborted) return Promise.resolve(undefined); const id = Snowflake.next() as string; const { promise, resolve, reject } = Promise.withResolvers(); let settled = false; const cleanup = () => { dialogOptions?.signal?.removeEventListener("abort", onAbort); pendingRequests.delete(id); }; const finish = (value: string | undefined) => { if (settled) return; settled = true; cleanup(); resolve(value); }; const fail = (error: Error) => { if (settled) return; settled = true; cleanup(); reject(error); }; const onAbort = () => { output({ type: "extension_ui_request", id: Snowflake.next() as string, method: "cancel", targetId: id, } as RpcExtensionUIRequest); finish(undefined); }; dialogOptions?.signal?.addEventListener("abort", onAbort, { once: true }); pendingRequests.set(id, { resolve: response => { if ("cancelled" in response && response.cancelled) { finish(undefined); } else if ("value" in response) { finish(response.value); } else { finish(undefined); } }, reject: fail, }); output({ type: "extension_ui_request", id, method: "editor", title, prefill, promptStyle: editorOptions?.promptStyle, } as RpcExtensionUIRequest); return promise; } /** * Run in RPC mode. * Listens for JSON commands on stdin, outputs events and responses on stdout. */ export async function runRpcMode( session: AgentSession, setToolUIContext?: (uiContext: ExtensionUIContext, hasUI: boolean) => void, eventBus?: EventBus, ): Promise { // Signal to RPC clients that the server is ready to accept commands // Suppress terminal notifications: they write \x07 (BEL) or OSC sequences directly to // process.stdout with no newline, which the reader merges with the next JSON line and // breaks JSON.parse. In RPC mode stdout is the JSON protocol channel — nothing else // may write there. process.env.PI_NOTIFICATIONS = "off"; process.stdout.write(`${JSON.stringify({ type: "ready" })}\n`); const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { process.stdout.write(`${JSON.stringify(obj)}\n`); }; const emitRpcTitles = shouldEmitRpcTitles(); const success = ( 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 }; }; const extensionUserMessageTracker = new RpcExtensionUserMessageTracker(); const pendingExtensionRequests = new Map(); const hostToolBridge = new RpcHostToolBridge(output); const hostUriBridge = new RpcHostUriBridge(output); const subagentRegistry = eventBus ? new RpcSubagentRegistry(eventBus, output) : undefined; // Shutdown request flag (wrapped in object to allow mutation with const) const shutdownState = { requested: false }; /** * Extension UI context that uses the RPC protocol. */ class RpcExtensionUIContext implements ExtensionUIContext { constructor( private pendingRequests: Map, private output: (obj: RpcResponse | RpcExtensionUIRequest | object) => void, ) {} /** Helper for dialog methods with signal/timeout support */ #createDialogPromise( opts: ExtensionUIDialogOptions | undefined, defaultValue: T, request: Record, parseResponse: (response: RpcExtensionUIResponse) => T, ): Promise { if (opts?.signal?.aborted) return Promise.resolve(defaultValue); const id = Snowflake.next() as string; const { promise, resolve, reject } = Promise.withResolvers(); let timeoutId: NodeJS.Timeout | undefined; const cleanup = () => { if (timeoutId) clearTimeout(timeoutId); opts?.signal?.removeEventListener("abort", onAbort); this.pendingRequests.delete(id); }; const onAbort = () => { cleanup(); resolve(defaultValue); }; opts?.signal?.addEventListener("abort", onAbort, { once: true }); if (opts?.timeout !== undefined) { timeoutId = setTimeout(() => { opts.onTimeout?.(); cleanup(); resolve(defaultValue); }, opts.timeout); } this.pendingRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { cleanup(); resolve(parseResponse(response)); }, reject, }); this.output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest); return promise; } select( title: string, options: ExtensionUISelectItem[], dialogOptions?: ExtensionUIDialogOptions, ): Promise { return this.#createDialogPromise( dialogOptions, undefined, { method: "select", title, options: options.map(getExtensionUISelectOptionLabel), timeout: dialogOptions?.timeout, }, response => parseValueDialogResponse(response, dialogOptions), ); } confirm(title: string, message: string, dialogOptions?: ExtensionUIDialogOptions): Promise { return this.#createDialogPromise( dialogOptions, false, { method: "confirm", title, message, timeout: dialogOptions?.timeout }, response => { if ("cancelled" in response && response.cancelled) { if (response.timedOut) dialogOptions?.onTimeout?.(); return false; } if ("confirmed" in response) return response.confirmed; return false; }, ); } input( title: string, placeholder?: string, dialogOptions?: ExtensionUIDialogOptions, ): Promise { return this.#createDialogPromise( dialogOptions, undefined, { method: "input", title, placeholder, timeout: dialogOptions?.timeout }, response => parseValueDialogResponse(response, dialogOptions), ); } onTerminalInput(): () => void { // Raw terminal input not supported in RPC mode return () => {}; } notify(message: string, type?: "info" | "warning" | "error"): void { // Fire and forget - no response needed this.output({ type: "extension_ui_request", id: Snowflake.next() as string, method: "notify", message, notifyType: type, } as RpcExtensionUIRequest); } setStatus(key: string, text: string | undefined): void { // Fire and forget - no response needed this.output({ type: "extension_ui_request", id: Snowflake.next() as string, method: "setStatus", statusKey: key, statusText: text, } as RpcExtensionUIRequest); } setWorkingMessage(_message?: string): void { // Not supported in RPC mode } 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)) { this.output({ type: "extension_ui_request", id: Snowflake.next() as string, 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 { // Title updates are low-value noise for most RPC hosts; opt in via PI_RPC_EMIT_TITLE=1. if (!emitRpcTitles) return; this.output({ type: "extension_ui_request", id: Snowflake.next() as string, method: "setTitle", title, } as RpcExtensionUIRequest); } async custom(): Promise { // 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 this.output({ type: "extension_ui_request", id: Snowflake.next() as string, 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, dialogOptions?: ExtensionUIDialogOptions, editorOptions?: { promptStyle?: boolean }, ): Promise { return requestRpcEditor(this.pendingRequests, this.output, title, prefill, dialogOptions, editorOptions); } get theme(): Theme { return theme; } getAllThemes(): Promise<{ name: string; path: string | undefined }[]> { return Promise.resolve([]); } getTheme(_name: string): Promise { return Promise.resolve(undefined); } setTheme(_theme: string | Theme): Promise<{ success: boolean; error?: string }> { // Theme switching not supported in RPC mode return Promise.resolve({ 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 } setEditorComponent(): void { // Custom editor components not supported in RPC mode } } // Wire up UI context for tool execution (ask tool, etc.) and extensions. // A single shared instance routes all responses received on stdin to the // correct waiting promise regardless of which code path created the request. const rpcUiContext = new RpcExtensionUIContext(pendingExtensionRequests, output); setToolUIContext?.(rpcUiContext, true); // Set up extensions with RPC-based UI context await initializeExtensions(session, { reportSendError: (action, err) => { output(error(undefined, action, err.message)); }, reportRuntimeError: err => { output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error }); }, onShutdown: () => { shutdownState.requested = true; }, trackAgentInvokingMessage: task => { extensionUserMessageTracker.trackAgentMessageTask(task); }, uiContext: rpcUiContext, }); // Output all agent events as JSON session.subscribe(event => { output(event); }); const getAvailableCommands = async () => buildAvailableSlashCommands(session); const reloadPluginState = async () => { const cwd = session.sessionManager.getCwd(); const projectPath = await resolveActiveProjectRegistryPath(cwd); clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); resetCapabilities(); session.setSlashCommands(await loadSlashCommands({ cwd })); await session.refreshSshTool({ activateIfAvailable: true }); await emitAvailableCommandsUpdate(); }; const emitAvailableCommandsUpdate = async () => { output({ type: "available_commands_update", commands: await getAvailableCommands() }); }; session.subscribeCommandMetadataChanged(() => { void emitAvailableCommandsUpdate(); }); await emitAvailableCommandsUpdate(); // Handle a single command const handleCommand = async (command: RpcCommand): Promise => { const id = command.id; switch (command.type) { // ================================================================= // Prompting // ================================================================= case "prompt": { const skillResult = await tryRunRpcSkillCommand(session, command.message); if (skillResult) { return success(id, "prompt", skillResult); } const builtinResult = await executeAcpBuiltinSlashCommand(command.message, { session, sessionManager: session.sessionManager, settings: session.settings, cwd: session.sessionManager.getCwd(), output: text => output({ type: "command_output", text }), refreshCommands: emitAvailableCommandsUpdate, reloadPlugins: reloadPluginState, notifyTitleChanged: async () => { output({ type: "session_info_update", title: session.sessionName, sessionId: session.sessionId }); }, notifyConfigChanged: async () => { output({ type: "config_update", model: session.model, thinkingLevel: session.thinkingLevel }); }, }); if (builtinResult !== false) { if ("prompt" in builtinResult) { watchAndReportLocalOnlyPromptResult({ id, startPrompt: () => session.prompt(builtinResult.prompt, { images: command.images }), output, onError: promptError => output(error(id, "prompt", promptError.message)), extensionUserMessageTracker, }); return success(id, "prompt"); } return success(id, "prompt", { agentInvoked: false }); } // Don't await - events will stream // Extension commands are executed immediately, file prompt templates are expanded // If streaming and streamingBehavior specified, queues via steer/followUp watchAndReportLocalOnlyPromptResult({ id, startPrompt: () => session.prompt(command.message, { images: command.images, streamingBehavior: command.streamingBehavior, }), output, onError: promptError => output(error(id, "prompt", promptError.message)), extensionUserMessageTracker, }); return success(id, "prompt"); } 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({ reason: USER_INTERRUPT_LABEL }); return success(id, "abort"); } case "abort_and_prompt": { await session.abort({ reason: USER_INTERRUPT_LABEL }); session .prompt(command.message, { images: command.images }) .catch(e => output(error(id, "abort_and_prompt", e.message))); return success(id, "abort_and_prompt"); } case "new_session": case "switch_session": case "branch": { const result = await handleRpcSessionChange(session, command, subagentRegistry); if (!result.data.cancelled) await emitAvailableCommandsUpdate(); return success(id, result.type, result.data); } // ================================================================= // 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, interruptMode: session.interruptMode, sessionFile: session.sessionFile, sessionId: session.sessionId, sessionName: session.sessionName, autoCompactionEnabled: session.autoCompactionEnabled, messageCount: session.messages.length, queuedMessageCount: session.queuedMessageCount, todoPhases: session.getTodoPhases(), systemPrompt: session.systemPrompt, dumpTools: session.agent.state.tools.map(tool => ({ name: tool.name, description: tool.description, parameters: isZodSchema(tool.parameters) ? zodToWireSchema(tool.parameters) : tool.parameters, examples: tool.examples, })), contextUsage: session.getContextUsage(), }; return success(id, "get_state", state); } case "get_available_commands": { return success(id, "get_available_commands", { commands: await getAvailableCommands() }); } case "set_todos": { session.setTodoPhases(command.phases); return success(id, "set_todos", { todoPhases: session.getTodoPhases() }); } case "set_host_tools": { const tools = normalizeHostToolDefinitions(command.tools); const rpcTools = hostToolBridge.setTools(tools); await session.refreshRpcHostTools(rpcTools); return success(id, "set_host_tools", { toolNames: tools.map(tool => tool.name) }); } case "set_host_uri_schemes": { try { const schemes = hostUriBridge.setSchemes(command.schemes); return success(id, "set_host_uri_schemes", { schemes }); } catch (err) { return error(id, "set_host_uri_schemes", err instanceof Error ? err.message : String(err)); } } case "set_subagent_subscription": { if (!subagentRegistry) { return error(id, "set_subagent_subscription", "Subagent event bus is unavailable"); } if (!isSubagentSubscriptionLevel(command.level)) { return error( id, "set_subagent_subscription", `Invalid subagent subscription level: ${String(command.level)}`, ); } subagentRegistry.setSubscriptionLevel(command.level); return success(id, "set_subagent_subscription", { level: subagentRegistry.getSubscriptionLevel() }); } case "get_subagents": { if (!subagentRegistry) { return error(id, "get_subagents", "Subagent event bus is unavailable"); } return success(id, "get_subagents", { subagents: subagentRegistry.getSubagents() }); } case "get_subagent_messages": { if (!subagentRegistry) { return error(id, "get_subagent_messages", "Subagent event bus is unavailable"); } try { if (command.fromByte !== undefined && !Number.isFinite(command.fromByte)) { return error(id, "get_subagent_messages", "fromByte must be a finite number"); } const sessionFile = subagentRegistry.resolveSessionFile(command); const transcript = await readRpcSubagentTranscript(sessionFile, command.fromByte); return success(id, "get_subagent_messages", transcript); } catch (err) { return error(id, "get_subagent_messages", err instanceof Error ? err.message : String(err)); } } // ================================================================= // Model // ================================================================= case "set_model": { const models = session.getAvailableModels(); 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.getAvailableModels(); 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 }); } // ================================================================= // 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"); } case "set_interrupt_mode": { session.setInterruptMode(command.mode); return success(id, "set_interrupt_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 result = await session.executeBash(command.command); 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 "get_branch_messages": { const messages = session.getUserMessagesForBranching(); return success(id, "get_branch_messages", { messages }); } 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"); } const applied = await session.setSessionName(name, "user"); if (!applied) { return error(id, "set_session_name", "Session name cannot be empty"); } return success(id, "set_session_name"); } case "handoff": { // Resetting the agent mid-stream lets the live turn keep emitting into a // session that handoff has already torn down. Refuse while a prompt is in // flight (mirrors the TUI /handoff guard). if (session.isStreaming) { return error(id, "handoff", "Cannot hand off while a response is in progress"); } const result = await session.handoff(command.customInstructions); return success(id, "handoff", result ? { savedPath: result.savedPath } : null); } // ================================================================= // Messages // ================================================================= case "get_messages": { return success(id, "get_messages", { messages: session.messages }); } // ================================================================= // Login // ================================================================= case "get_login_providers": { const providers = getOAuthProviders().map(provider => ({ id: provider.id, name: provider.name, available: provider.available, authenticated: session.modelRegistry.authStorage.hasAuth(provider.id), })); return success(id, "get_login_providers", { providers }); } case "login": { const knownProvider = getOAuthProviders().find(p => p.id === command.providerId); if (!knownProvider) { return error(id, "login", `Unknown OAuth provider: ${command.providerId}`); } const uiCtx = new RpcExtensionUIContext(pendingExtensionRequests, output); // Track whether onAuth has fired. Providers that use OAuthCallbackFlow // always call onAuth first (emit browser URL), then onManualCodeInput as // a fallback. Providers that require interactive input (API-key paste, // GitHub Enterprise URL, device-code entry) call onPrompt before onAuth. // We use this ordering to self-classify at runtime — no static allowlist. let authEmitted = false; try { await session.modelRegistry.authStorage.login(command.providerId, { onAuth: info => { authEmitted = true; output({ type: "extension_ui_request", id: Snowflake.next() as string, method: "open_url", url: info.url, launchUrl: info.launchUrl, instructions: info.instructions, } as RpcExtensionUIRequest); }, onProgress: message => { uiCtx.notify(message, "info"); }, onPrompt: () => { if (!authEmitted) { // onPrompt called before any auth URL — provider requires // interactive input that cannot be satisfied headlessly. return Promise.reject( new Error( `Provider '${command.providerId}' requires interactive prompts ` + "which are not supported in RPC mode. Use the terminal UI to log in.", ), ); } // onAuth has already fired — we are inside OAuthCallbackFlow's // manual-redirect fallback race. Returning a never-settling promise // lets the race block until the callback server wins; a rejection // would be caught as null and spin the while(true) loop. return new Promise(() => {}); }, }); await session.modelRegistry.refresh(); return success(id, "login", { providerId: command.providerId }); } catch (err: unknown) { return error(id, "login", err instanceof Error ? err.message : String(err)); } } default: { const unknownCommand = command as { type: string }; return error(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`); } } }; // Deferred shutdown (pi.shutdown() from an extension) must not kill the // process while a background-dispatched bash still owes the client its // response frame. The coordinator drains tracked tasks before exiting and // re-checks the request as each task settles. const shutdownCoordinator = new RpcShutdownCoordinator({ isShutdownRequested: () => shutdownState.requested, performShutdown: async () => { if (session.extensionRunner?.hasHandlers("session_shutdown")) { await session.extensionRunner.emit({ type: "session_shutdown" }); } process.exit(0); }, }); const dispatchFrameDeps: RpcInputFrameDeps = { handleCommand, output, errorResponse: error, trackBackgroundTask: task => shutdownCoordinator.track(task), pendingExtensionRequests, onHostToolResult: frame => hostToolBridge.handleResult(frame), onHostToolUpdate: frame => hostToolBridge.handleUpdate(frame), onHostUriResult: frame => hostUriBridge.handleResult(frame), }; // Listen for JSON input using Bun's stdin. Frame dispatch lives in // dispatchRpcInputFrame so it can be exercised directly by tests; see the // helper's docstring for the concurrency contract. for await (const parsed of readJsonl(Bun.stdin.stream())) { try { const awaited = dispatchRpcInputFrame(parsed, dispatchFrameDeps); if (awaited) { await awaited; // Check for deferred shutdown request (idle between commands). // Background-dispatched bash frames skip this check so a later // abort_bash can still be read; the coordinator re-checks when // each tracked task settles, so a shutdown requested mid-bash // fires once the response frame is written even if no further // client frames arrive. await shutdownCoordinator.checkShutdownRequested(); } } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); output(error(undefined, "parse", `Failed to parse command: ${message}`)); } } // Background bash tasks may still owe response frames; drain them before // tearing down (stdin EOF ends the frame stream, not in-flight work). await shutdownCoordinator.drain(); // stdin closed — RPC client is gone, exit cleanly hostToolBridge.rejectAllPending("RPC client disconnected before host tool execution completed"); hostUriBridge.clear("RPC client disconnected before host URI request completed"); subagentRegistry?.dispose(); process.exit(0); }