1103 lines
35 KiB
TypeScript
1103 lines
35 KiB
TypeScript
/**
|
|
* 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, 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 } 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 } 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,
|
|
RpcHostUriCancelRequest,
|
|
RpcHostUriRequest,
|
|
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<AgentSession, "newSession" | "switchSession" | "branch">;
|
|
|
|
export type RpcSkillCommandSession = Pick<AgentSession, "promptCustomMessage" | "skills" | "skillsSettings">;
|
|
export type RpcSkillCommandResult = { agentInvoked: true };
|
|
|
|
export async function tryRunRpcSkillCommand(
|
|
session: RpcSkillCommandSession,
|
|
text: string,
|
|
): Promise<RpcSkillCommandResult | false> {
|
|
if (!text.startsWith("/skill:")) return false;
|
|
if (!session.skillsSettings?.enableSkillCommands) return false;
|
|
const spaceIndex = text.indexOf(" ");
|
|
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
|
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
|
|
const skillName = commandName.slice("skill:".length);
|
|
const skill = session.skills.find(candidate => candidate.name === skillName);
|
|
if (!skill) return false;
|
|
const built = await buildSkillPromptMessage(skill, args);
|
|
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<boolean>;
|
|
output: (obj: object) => void;
|
|
onError: (error: Error) => void;
|
|
hasExtensionUserMessageTask?: () => boolean;
|
|
}): void {
|
|
void input.prompt
|
|
.then(agentInvoked => {
|
|
if (!agentInvoked && !input.hasExtensionUserMessageTask?.()) {
|
|
input.output({ type: "prompt_result", id: input.id, agentInvoked: false });
|
|
}
|
|
})
|
|
.catch(error => {
|
|
input.onError(error instanceof Error ? error : new Error(String(error)));
|
|
});
|
|
}
|
|
|
|
type RpcExtensionUserMessageScope = {
|
|
hasUserMessageTask: boolean;
|
|
};
|
|
|
|
/**
|
|
* Tracks extension-originated user 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(); that prompt must not
|
|
* report agentInvoked:false to the host.
|
|
*/
|
|
export class RpcExtensionUserMessageTracker {
|
|
#activePromptScopes = new Set<RpcExtensionUserMessageScope>();
|
|
|
|
track(task: Promise<void>): void {
|
|
void task;
|
|
for (const scope of this.#activePromptScopes) {
|
|
scope.hasUserMessageTask = true;
|
|
}
|
|
}
|
|
|
|
watchPrompt<T>(startPrompt: () => Promise<T>): {
|
|
prompt: Promise<T>;
|
|
hasUserMessageTask: () => boolean;
|
|
} {
|
|
const scope: RpcExtensionUserMessageScope = { hasUserMessageTask: false };
|
|
this.#activePromptScopes.add(scope);
|
|
let prompt: Promise<T>;
|
|
try {
|
|
prompt = startPrompt();
|
|
} catch (error) {
|
|
this.#activePromptScopes.delete(scope);
|
|
throw error;
|
|
}
|
|
return {
|
|
prompt: prompt.finally(() => {
|
|
this.#activePromptScopes.delete(scope);
|
|
}),
|
|
hasUserMessageTask: () => scope.hasUserMessageTask,
|
|
};
|
|
}
|
|
}
|
|
|
|
export type RpcSubagentResetRegistry = Pick<RpcSubagentRegistry, "clear">;
|
|
|
|
export async function handleRpcSessionChange(
|
|
session: RpcSessionChangeSession,
|
|
command: RpcSessionChangeCommand,
|
|
subagentRegistry?: RpcSubagentResetRegistry,
|
|
): Promise<RpcSessionChangeResult> {
|
|
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<string, PendingExtensionRequest>,
|
|
output: RpcOutput,
|
|
title: string,
|
|
prefill?: string,
|
|
dialogOptions?: ExtensionUIDialogOptions,
|
|
editorOptions?: { promptStyle?: boolean },
|
|
): Promise<string | undefined> {
|
|
if (dialogOptions?.signal?.aborted) return Promise.resolve(undefined);
|
|
|
|
const id = Snowflake.next() as string;
|
|
const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
|
|
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<never> {
|
|
// 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 = <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 };
|
|
};
|
|
|
|
const extensionUserMessageTracker = new RpcExtensionUserMessageTracker();
|
|
|
|
const pendingExtensionRequests = new Map<string, PendingExtensionRequest>();
|
|
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<string, PendingExtensionRequest>,
|
|
private output: (obj: RpcResponse | RpcExtensionUIRequest | object) => void,
|
|
) {}
|
|
|
|
/** Helper for dialog methods with signal/timeout support */
|
|
#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 = Snowflake.next() as string;
|
|
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
|
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<string | undefined> {
|
|
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<boolean> {
|
|
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<string | undefined> {
|
|
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<never> {
|
|
// 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<string | undefined> {
|
|
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<Theme | undefined> {
|
|
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;
|
|
},
|
|
trackUserMessageTask: task => {
|
|
extensionUserMessageTracker.track(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<RpcResponse> => {
|
|
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) {
|
|
session
|
|
.prompt(builtinResult.prompt, { images: command.images })
|
|
.catch(e => output(error(id, "prompt", e.message)));
|
|
return success(id, "prompt", { agentInvoked: true });
|
|
}
|
|
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
|
|
const trackedPrompt = extensionUserMessageTracker.watchPrompt(() =>
|
|
session.prompt(command.message, {
|
|
images: command.images,
|
|
streamingBehavior: command.streamingBehavior,
|
|
}),
|
|
);
|
|
reportLocalOnlyPromptResult({
|
|
id,
|
|
prompt: trackedPrompt.prompt,
|
|
output,
|
|
onError: promptError => output(error(id, "prompt", promptError.message)),
|
|
hasExtensionUserMessageTask: trackedPrompt.hasUserMessageTask,
|
|
});
|
|
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();
|
|
return success(id, "abort");
|
|
}
|
|
|
|
case "abort_and_prompt": {
|
|
await session.abort();
|
|
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,
|
|
})),
|
|
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": {
|
|
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,
|
|
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<string>(() => {});
|
|
},
|
|
});
|
|
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}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check if shutdown was requested and perform shutdown if so.
|
|
* Called after handling each command when waiting for the next command.
|
|
*/
|
|
async function checkShutdownRequested(): Promise<void> {
|
|
if (!shutdownState.requested) return;
|
|
|
|
if (session.extensionRunner?.hasHandlers("session_shutdown")) {
|
|
await session.extensionRunner.emit({ type: "session_shutdown" });
|
|
}
|
|
|
|
process.exit(0);
|
|
}
|
|
|
|
// Listen for JSON input using Bun's stdin
|
|
for await (const parsed of readJsonl(Bun.stdin.stream())) {
|
|
try {
|
|
// Handle extension UI responses
|
|
if ((parsed as RpcExtensionUIResponse).type === "extension_ui_response") {
|
|
const response = parsed as RpcExtensionUIResponse;
|
|
const pending = pendingExtensionRequests.get(response.id);
|
|
if (pending) {
|
|
pending.resolve(response);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isRpcHostToolResult(parsed)) {
|
|
hostToolBridge.handleResult(parsed);
|
|
continue;
|
|
}
|
|
|
|
if (isRpcHostToolUpdate(parsed)) {
|
|
hostToolBridge.handleUpdate(parsed);
|
|
continue;
|
|
}
|
|
|
|
if (isRpcHostUriResult(parsed)) {
|
|
hostUriBridge.handleResult(parsed);
|
|
continue;
|
|
}
|
|
|
|
// Handle regular commands
|
|
const command = parsed as RpcCommand;
|
|
const response = await handleCommand(command);
|
|
output(response);
|
|
|
|
// Check for deferred shutdown request (idle between commands)
|
|
await checkShutdownRequested();
|
|
} catch (e: unknown) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
output(error(undefined, "parse", `Failed to parse command: ${message}`));
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|