fix: raw rpc

This commit is contained in:
Cristina Poncela Cubeiro 2026-06-22 14:45:56 +02:00
parent ac92f92b65
commit a7b0138ef0
9 changed files with 315 additions and 642 deletions

View file

@ -1,216 +0,0 @@
import { randomUUID } from "node:crypto";
import type {
AgentSessionRuntime,
ExtensionUIContext,
RpcExtensionUIRequest,
RpcExtensionUIResponse,
} from "@earendil-works/pi-coding-agent";
type DialogRequest =
| Extract<RpcExtensionUIRequest, { method: "select" }>
| Extract<RpcExtensionUIRequest, { method: "confirm" }>
| Extract<RpcExtensionUIRequest, { method: "input" }>
| Extract<RpcExtensionUIRequest, { method: "editor" }>;
type FireAndForgetRequest = Exclude<RpcExtensionUIRequest, DialogRequest>;
interface PendingExtensionRequest {
resolve(response: RpcExtensionUIResponse): void;
cancel(): void;
}
export class AttachUiBridge {
private readonly pendingRequests = new Map<string, PendingExtensionRequest>();
private onRequest?: (request: RpcExtensionUIRequest) => void;
private themeOverride?: unknown;
attach(onRequest: (request: RpcExtensionUIRequest) => void): () => void {
this.onRequest = onRequest;
return () => {
if (this.onRequest === onRequest) {
this.onRequest = undefined;
}
};
}
setThemeOverride(theme: unknown): void {
// This comes from attach host context over JSONL IPC. Pi's Theme is a runtime class,
// not a wire-safe protocol type, so this remains opaque until we define a proper
// serializable theme snapshot/DTO for the attach protocol.
this.themeOverride = theme;
}
handleResponse(response: RpcExtensionUIResponse): void {
const pending = this.pendingRequests.get(response.id);
if (!pending) {
return;
}
this.pendingRequests.delete(response.id);
pending.resolve(response);
}
createUiContext(): ExtensionUIContext {
const uiBridge = this;
const requestDialog = <T>(
request: DialogRequest,
fallbackValue: T,
parseResponse: (response: RpcExtensionUIResponse) => T,
): Promise<T> => {
if (!this.onRequest) {
return Promise.resolve(fallbackValue);
}
return new Promise<T>((resolve) => {
this.pendingRequests.set(request.id, {
resolve: (response) => resolve(parseResponse(response)),
cancel: () => resolve(fallbackValue),
});
this.onRequest?.(request);
});
};
const emit = (request: FireAndForgetRequest): void => {
if (!this.onRequest) {
return;
}
this.onRequest(request);
};
return {
select: async (title, options, opts) =>
requestDialog(
{
type: "extension_ui_request",
id: randomUUID(),
method: "select",
title,
options,
timeout: opts?.timeout,
},
undefined,
(response) => ("value" in response ? response.value : undefined),
),
confirm: async (title, message, opts) =>
requestDialog(
{
type: "extension_ui_request",
id: randomUUID(),
method: "confirm",
title,
message,
timeout: opts?.timeout,
},
false,
(response) => ("confirmed" in response ? response.confirmed : false),
),
input: async (title, placeholder, opts) =>
requestDialog(
{
type: "extension_ui_request",
id: randomUUID(),
method: "input",
title,
placeholder,
timeout: opts?.timeout,
},
undefined,
(response) => ("value" in response ? response.value : undefined),
),
notify: (message, notifyType) => {
emit({ type: "extension_ui_request", id: randomUUID(), method: "notify", message, notifyType });
},
onTerminalInput: () => () => {},
setStatus: (statusKey, statusText) => {
emit({ type: "extension_ui_request", id: randomUUID(), method: "setStatus", statusKey, statusText });
},
setWorkingMessage: () => {},
setWorkingVisible: () => {},
setWorkingIndicator: () => {},
setHiddenThinkingLabel: () => {},
setWidget: (widgetKey, widgetLines, options) => {
if (widgetLines === undefined || Array.isArray(widgetLines)) {
emit({
type: "extension_ui_request",
id: randomUUID(),
method: "setWidget",
widgetKey,
widgetLines,
widgetPlacement: options?.placement,
});
}
},
setFooter: () => {},
setHeader: () => {},
setTitle: (title) => {
emit({ type: "extension_ui_request", id: randomUUID(), method: "setTitle", title });
},
custom: async () => Promise.reject(new Error("Custom UI not supported in orchestrator attach mode")),
pasteToEditor: (text) => {
emit({ type: "extension_ui_request", id: randomUUID(), method: "set_editor_text", text });
},
setEditorText: (text) => {
emit({ type: "extension_ui_request", id: randomUUID(), method: "set_editor_text", text });
},
getEditorText: () => "",
editor: async (title, prefill) =>
requestDialog(
{ type: "extension_ui_request", id: randomUUID(), method: "editor", title, prefill },
undefined,
(response) => ("value" in response ? response.value : undefined),
),
addAutocompleteProvider: () => {},
setEditorComponent: () => {},
getEditorComponent: () => undefined,
get theme(): ExtensionUIContext["theme"] {
// If the attach host provides a theme object, forward it. Otherwise attach mode
// still has no real TUI/theme runtime, so fall back to an empty placeholder.
return (uiBridge.themeOverride ?? {}) as ExtensionUIContext["theme"];
},
getAllThemes: () => [],
getTheme: () => undefined,
setTheme: () => ({ success: false, error: "Theme switching not supported in orchestrator attach mode" }),
getToolsExpanded: () => false,
setToolsExpanded: () => {},
};
}
cancelPendingRequests(): void {
for (const [id, pending] of this.pendingRequests) {
this.pendingRequests.delete(id);
pending.cancel();
}
}
}
export async function bindAttachExtensions(runtime: AgentSessionRuntime, uiBridge: AttachUiBridge): Promise<void> {
const session = runtime.session;
await session.bindExtensions({
uiContext: uiBridge.createUiContext(),
mode: "rpc",
commandContextActions: {
waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => runtime.newSession(options),
fork: async (entryId, forkOptions) => {
const result = await runtime.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) => runtime.switchSession(sessionPath, options),
reload: async () => {
await session.reload();
},
},
shutdownHandler: () => {},
onError: (error) => {
console.error("Extension error in orchestrator attach mode", error);
},
});
}

View file

@ -18,7 +18,7 @@ const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"),
function printHelp(): void {
console.log(
`orchestrator v${packageJson.version}\n\nUsage:\n orchestrator serve\n orchestrator list\n orchestrator spawn [--cwd <path>] [--label <label>]\n orchestrator status <instance-id>\n orchestrator stop <instance-id>\n orchestrator rpc <instance-id> <json-command>\n orchestrator attach <instance-id>\n orchestrator --help\n orchestrator --version\n\nAttach stdin expects JSONL RpcCommand or extension_ui_response messages.`,
`orchestrator v${packageJson.version}\n\nUsage:\n orchestrator serve\n orchestrator list\n orchestrator spawn [--cwd <path>] [--label <label>]\n orchestrator status <instance-id>\n orchestrator stop <instance-id>\n orchestrator rpc <instance-id> <json-command>\n orchestrator rpc-stream <instance-id>\n orchestrator --help\n orchestrator --version\n\nRPC stream stdin expects JSONL RpcCommand or extension_ui_response messages.`,
);
}
@ -34,14 +34,14 @@ function getFlagValue(args: string[], flag: string): string | undefined {
return args[index + 1];
}
async function attach(instanceId: string): Promise<void> {
async function rpcStream(instanceId: string): Promise<void> {
const socket = createConnection(getSocketPath());
let stdinBuffer = "";
process.stdin.setEncoding("utf8");
await new Promise<void>((resolve, reject) => {
socket.once("connect", () => {
socket.write(encodeMessage({ type: "attach", instanceId }));
socket.write(encodeMessage({ type: "rpc", instanceId }));
resolve();
});
socket.once("error", reject);
@ -50,7 +50,7 @@ async function attach(instanceId: string): Promise<void> {
socket.on("data", (chunk: Buffer | string) => {
process.stdout.write(chunk.toString());
});
console.error(`attached to ${instanceId}; send JSONL RpcCommand or extension_ui_response on stdin`);
console.error(`connected to rpc stream ${instanceId}; send JSONL RpcCommand or extension_ui_response on stdin`);
socket.on("error", (error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
@ -71,11 +71,7 @@ async function attach(instanceId: string): Promise<void> {
continue;
}
const parsed = JSON.parse(line) as RpcCommand | RpcExtensionUIResponse;
if (parsed.type === "extension_ui_response") {
socket.write(encodeMessage(parsed));
continue;
}
socket.write(encodeMessage({ type: "attach_rpc", command: parsed }));
socket.write(encodeMessage(parsed));
}
});
}
@ -147,13 +143,13 @@ async function main(): Promise<void> {
return;
}
if (args[0] === "attach") {
if (args[0] === "rpc-stream") {
const instanceId = args[1];
if (!instanceId) {
console.error("Usage: orchestrator attach <instance-id>");
console.error("Usage: orchestrator rpc-stream <instance-id>");
process.exit(1);
}
await attach(instanceId);
await rpcStream(instanceId);
return;
}

View file

@ -3,12 +3,9 @@ import type {
RpcCommand,
RpcExtensionUIRequest,
RpcExtensionUIResponse,
RpcResponse,
} from "@earendil-works/pi-coding-agent";
import type {
AttachHostContextRequest,
AttachReadyResponse,
AttachRequest,
AttachRpcResponse,
ErrorResponse,
InstanceSummary,
ListRequest,
@ -16,6 +13,7 @@ import type {
OrchestratorRequest,
OrchestratorResponse,
RpcBridgeResponse,
RpcReadyResponse,
RpcRequest,
SpawnRequest,
SpawnResponse,
@ -52,8 +50,9 @@ export async function handleIpcRequest(request: SpawnRequest): Promise<SpawnResp
export async function handleIpcRequest(request: ListRequest): Promise<ListResponse | ErrorResponse>;
export async function handleIpcRequest(request: StopRequest): Promise<StopResponse | ErrorResponse>;
export async function handleIpcRequest(request: StatusRequest): Promise<StatusResponse | ErrorResponse>;
export async function handleIpcRequest(request: RpcRequest): Promise<RpcBridgeResponse | ErrorResponse>;
export async function handleIpcRequest(request: AttachRequest): Promise<AttachReadyResponse | ErrorResponse>;
export async function handleIpcRequest(
request: RpcRequest,
): Promise<RpcBridgeResponse | RpcReadyResponse | ErrorResponse>;
export async function handleIpcRequest(request: OrchestratorRequest): Promise<OrchestratorResponse>;
export async function handleIpcRequest(request: OrchestratorRequest): Promise<OrchestratorResponse> {
switch (request.type) {
@ -104,6 +103,17 @@ export async function handleIpcRequest(request: OrchestratorRequest): Promise<Or
}
case "rpc": {
if (!request.command) {
const instance = supervisor.getInstance(request.instanceId);
if (!instance) {
return unknownInstanceError(request.instanceId);
}
return {
type: "rpc_ready",
ok: true,
instance: toInstanceSummary(instance),
};
}
const response = await supervisor.handleRpc(request.instanceId, request.command);
if (!response) {
return unknownInstanceError(request.instanceId);
@ -115,31 +125,17 @@ export async function handleIpcRequest(request: OrchestratorRequest): Promise<Or
response,
};
}
case "attach": {
const instance = supervisor.getInstance(request.instanceId);
if (!instance) {
return unknownInstanceError(request.instanceId);
}
return {
type: "attach_ready",
ok: true,
instance: toInstanceSummary(instance),
};
}
}
}
export function attachIpcInstance(
instanceId: string,
onResponse: (response: AttachRpcResponse) => void,
onResponse: (response: RpcResponse) => void,
onSessionEvent: (event: AgentSessionEvent) => void,
onUiRequest: (request: RpcExtensionUIRequest) => void,
):
| {
handleRequest(
request: { type: "attach_rpc"; command: RpcCommand } | AttachHostContextRequest | RpcExtensionUIResponse,
): Promise<void>;
handleRequest(request: RpcCommand | RpcExtensionUIResponse): Promise<void>;
close(): void;
}
| undefined {
@ -150,16 +146,12 @@ export function attachIpcInstance(
return {
async handleRequest(request): Promise<void> {
if (request.type === "attach_rpc") {
const response = await handle.handleRpc(request.command);
onResponse({ type: "attach_rpc_result", response });
if (request.type === "extension_ui_response") {
handle.handleUiResponse(request);
return;
}
if (request.type === "attach_host_context") {
handle.setHostTheme(request.theme);
return;
}
handle.handleUiResponse(request);
const response = await handle.handleRpc(request);
onResponse(response);
},
close(): void {
handle.close();

View file

@ -3,6 +3,7 @@ export * from "./handler.ts";
export * from "./ipc/client.ts";
export * from "./ipc/protocol.ts";
export * from "./ipc/server.ts";
export * from "./rpc-process.ts";
export * from "./serve.ts";
export * from "./storage.ts";
export * from "./supervisor.ts";

View file

@ -32,22 +32,7 @@ export interface StatusRequest {
export interface RpcRequest {
type: "rpc";
instanceId: string;
command: RpcCommand;
}
export interface AttachRequest {
type: "attach";
instanceId: string;
}
export interface AttachRpcRequest {
type: "attach_rpc";
command: RpcCommand;
}
export interface AttachHostContextRequest {
type: "attach_host_context";
theme?: unknown;
command?: RpcCommand;
}
export interface RequestMap {
@ -56,7 +41,6 @@ export interface RequestMap {
stop: StopRequest;
status: StatusRequest;
rpc: RpcRequest;
attach: AttachRequest;
}
export type OrchestratorRequest = RequestMap[keyof RequestMap];
@ -101,21 +85,11 @@ export interface RpcBridgeResponse extends ResponseBase {
response: RpcResponse;
}
export interface AttachReadyResponse extends ResponseBase {
type: "attach_ready";
export interface RpcReadyResponse extends ResponseBase {
type: "rpc_ready";
instance?: InstanceSummary;
}
export interface AttachEventResponse {
type: "attach_event";
event: AgentSessionEvent;
}
export interface AttachRpcResponse {
type: "attach_rpc_result";
response: RpcResponse;
}
export interface ErrorResponse extends ResponseBase {
type: "error";
ok: false;
@ -127,19 +101,18 @@ export interface ResponseMap {
list: ListResponse;
stop: StopResponse;
status: StatusResponse;
rpc: RpcBridgeResponse;
attach: AttachReadyResponse;
rpc: RpcBridgeResponse | RpcReadyResponse;
}
export type OrchestratorResponse = ResponseMap[keyof ResponseMap] | ErrorResponse;
export type AttachClientRequest = AttachRpcRequest | AttachHostContextRequest | RpcExtensionUIResponse;
export type AttachServerResponse =
| AttachReadyResponse
| AttachEventResponse
| AttachRpcResponse
export type RpcClientMessage = RpcCommand | RpcExtensionUIResponse;
export type RpcServerMessage =
| RpcReadyResponse
| RpcResponse
| AgentSessionEvent
| RpcExtensionUIRequest
| ErrorResponse;
export type ProtocolMessage = OrchestratorRequest | OrchestratorResponse | AttachClientRequest | AttachServerResponse;
export type ProtocolMessage = OrchestratorRequest | OrchestratorResponse | RpcClientMessage | RpcServerMessage;
export type ResponseFor<T extends OrchestratorRequest> = T extends { type: infer K }
? K extends keyof ResponseMap

View file

@ -2,10 +2,6 @@ import { existsSync, unlinkSync } from "node:fs";
import { createConnection, createServer, type Server } from "node:net";
import { getSocketPath } from "../config.ts";
import {
type AttachClientRequest,
type AttachReadyResponse,
type AttachRequest,
type AttachRpcResponse,
type ErrorResponse,
encodeMessage,
type ListRequest,
@ -14,6 +10,8 @@ import {
type OrchestratorResponse,
parseRequestLine,
type RpcBridgeResponse,
type RpcClientMessage,
type RpcReadyResponse,
type RpcRequest,
type SpawnRequest,
type SpawnResponse,
@ -28,17 +26,22 @@ export interface IpcRequestHandler {
(request: ListRequest): Promise<ListResponse | ErrorResponse> | ListResponse | ErrorResponse;
(request: StopRequest): Promise<StopResponse | ErrorResponse> | StopResponse | ErrorResponse;
(request: StatusRequest): Promise<StatusResponse | ErrorResponse> | StatusResponse | ErrorResponse;
(request: RpcRequest): Promise<RpcBridgeResponse | ErrorResponse> | RpcBridgeResponse | ErrorResponse;
(request: AttachRequest): Promise<AttachReadyResponse | ErrorResponse> | AttachReadyResponse | ErrorResponse;
(
request: RpcRequest,
):
| Promise<RpcBridgeResponse | RpcReadyResponse | ErrorResponse>
| RpcBridgeResponse
| RpcReadyResponse
| ErrorResponse;
(request: OrchestratorRequest): Promise<OrchestratorResponse> | OrchestratorResponse;
attach(
instanceId: string,
onEvent: (response: AttachRpcResponse) => void,
onResponse: (response: import("@earendil-works/pi-coding-agent").RpcResponse) => void,
onSessionEvent: (event: import("@earendil-works/pi-coding-agent").AgentSessionEvent) => void,
onUiRequest: (request: import("@earendil-works/pi-coding-agent").RpcExtensionUIRequest) => void,
):
| {
handleRequest(request: AttachClientRequest): Promise<void>;
handleRequest(request: RpcClientMessage): Promise<void>;
close(): void;
}
| undefined;
@ -66,9 +69,9 @@ export async function startIpcServer(handler: IpcRequestHandler): Promise<Server
try {
const request = parseRequestLine(line);
if (request.type === "attach") {
if (request.type === "rpc" && request.command === undefined) {
const response = await handler(request);
if (!response.ok || !response.instance) {
if (!response.ok || response.type !== "rpc_ready" || !response.instance) {
socket.end(encodeMessage(response));
return;
}
@ -79,7 +82,7 @@ export async function startIpcServer(handler: IpcRequestHandler): Promise<Server
socket.write(encodeMessage(response));
},
(event) => {
socket.write(encodeMessage({ type: "attach_event", event }));
socket.write(encodeMessage(event));
},
(request) => {
socket.write(encodeMessage(request));
@ -94,28 +97,28 @@ export async function startIpcServer(handler: IpcRequestHandler): Promise<Server
socket.write(encodeMessage(response));
socket.removeAllListeners("data");
socket.on("data", (attachChunk: Buffer | string) => {
buffer += attachChunk.toString();
socket.on("data", (rpcChunk: Buffer | string) => {
buffer += rpcChunk.toString();
for (;;) {
const attachNewlineIndex = buffer.indexOf("\n");
if (attachNewlineIndex === -1) {
const rpcNewlineIndex = buffer.indexOf("\n");
if (rpcNewlineIndex === -1) {
break;
}
const attachLine = buffer.slice(0, attachNewlineIndex).trim();
buffer = buffer.slice(attachNewlineIndex + 1);
if (!attachLine) {
const rpcLine = buffer.slice(0, rpcNewlineIndex).trim();
buffer = buffer.slice(rpcNewlineIndex + 1);
if (!rpcLine) {
continue;
}
void (async () => {
try {
const attachRequest = JSON.parse(attachLine) as AttachClientRequest;
await attachment.handleRequest(attachRequest);
} catch (attachError) {
const rpcRequest = JSON.parse(rpcLine) as RpcClientMessage;
await attachment.handleRequest(rpcRequest);
} catch (rpcError) {
socket.write(
encodeMessage({
type: "error",
ok: false,
error: attachError instanceof Error ? attachError.message : String(attachError),
error: rpcError instanceof Error ? rpcError.message : String(rpcError),
}),
);
}

View file

@ -1,246 +0,0 @@
import type { AgentSessionRuntime, RpcCommand, RpcResponse, RpcSessionState } from "@earendil-works/pi-coding-agent";
function 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;
}
function error(id: string | undefined, command: string, message: string): RpcResponse {
return { id, type: "response", command, success: false, error: message };
}
function assertNeverRpcCommand(command: never): never {
throw new Error(`Unknown command: ${(command as { type: string }).type}`);
}
export async function handleRpcCommand(runtime: AgentSessionRuntime, command: RpcCommand): Promise<RpcResponse> {
const session = runtime.session;
const id = command.id;
switch (command.type) {
case "prompt": {
return await new Promise<RpcResponse>((resolve) => {
let settled = false;
void session
.prompt(command.message, {
images: command.images,
streamingBehavior: command.streamingBehavior,
source: "rpc",
preflightResult: (didSucceed) => {
if (!settled && didSucceed) {
settled = true;
resolve(success(id, "prompt"));
}
},
})
.catch((rpcError: unknown) => {
if (!settled) {
settled = true;
resolve(error(id, "prompt", rpcError instanceof Error ? rpcError.message : String(rpcError)));
}
});
});
}
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 "new_session": {
const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
const result = await runtime.newSession(options);
return success(id, "new_session", result);
}
case "switch_session": {
const result = await runtime.switchSession(command.sessionPath);
return success(id, "switch_session", result);
}
case "fork": {
const result = await runtime.fork(command.entryId);
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 runtime.fork(leafId, { position: "at" });
return success(id, "clone", { cancelled: result.cancelled });
}
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);
}
case "set_model": {
const models = await session.modelRegistry.getAvailable();
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();
return success(id, "cycle_model", result ?? null);
}
case "get_available_models": {
const models = await session.modelRegistry.getAvailable();
return success(id, "get_available_models", { models });
}
case "set_thinking_level": {
session.setThinkingLevel(command.level);
return success(id, "set_thinking_level");
}
case "cycle_thinking_level": {
const level = session.cycleThinkingLevel();
return success(id, "cycle_thinking_level", level ? { level } : null);
}
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 "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");
}
case "set_auto_retry": {
session.setAutoRetryEnabled(command.enabled);
return success(id, "set_auto_retry");
}
case "abort_retry": {
session.abortRetry();
return success(id, "abort_retry");
}
case "bash": {
const result = await session.executeBash(command.command, undefined, {
excludeFromContext: command.excludeFromContext,
});
return success(id, "bash", result);
}
case "abort_bash": {
session.abortBash();
return success(id, "abort_bash");
}
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_fork_messages": {
const messages = session.getUserMessagesForForking();
return success(id, "get_fork_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");
}
session.setSessionName(name);
return success(id, "set_session_name");
}
case "get_messages": {
return success(id, "get_messages", { messages: session.messages });
}
case "get_commands": {
const commands = [];
for (const registeredCommand of session.extensionRunner.getRegisteredCommands()) {
commands.push({
name: registeredCommand.invocationName,
description: registeredCommand.description,
source: "extension",
sourceInfo: registeredCommand.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:
return assertNeverRpcCommand(command);
}
}

View file

@ -0,0 +1,183 @@
import { type ChildProcess, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import type {
AgentSessionEvent,
RpcCommand,
RpcExtensionUIRequest,
RpcExtensionUIResponse,
RpcResponse,
} from "@earendil-works/pi-coding-agent";
interface PendingRequest {
resolve(response: RpcResponse): void;
reject(error: Error): void;
}
export interface RpcProcessInstance {
process: ChildProcess;
send(command: RpcCommand): Promise<RpcResponse>;
handleUiResponse(response: RpcExtensionUIResponse): void;
setUiRequestHandler(handler?: (request: RpcExtensionUIRequest) => void): void;
onEvent(listener: (event: AgentSessionEvent) => void): () => void;
onExit(listener: (error?: Error) => void): () => void;
dispose(): Promise<void>;
}
function resolveCodingAgentCli(): string {
const require = createRequire(import.meta.url);
return require.resolve("@earendil-works/pi-coding-agent/dist/cli.js");
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
export function createRpcProcessInstance(options: { cwd: string }): RpcProcessInstance {
const cliPath = resolveCodingAgentCli();
const child = spawn(process.execPath, [cliPath, "--mode", "rpc"], {
cwd: options.cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
if (!child.stdin || !child.stdout) {
throw new Error("Failed to create RPC process stdio");
}
let exited = false;
let nextRequestId = 0;
let stdoutBuffer = "";
let stderrBuffer = "";
const pendingRequests = new Map<string, PendingRequest>();
const eventListeners = new Set<(event: AgentSessionEvent) => void>();
const exitListeners = new Set<(error?: Error) => void>();
const rejectAllPending = (error: Error) => {
for (const [id, pending] of pendingRequests) {
pendingRequests.delete(id);
pending.reject(error);
}
};
const notifyExit = (error?: Error) => {
for (const listener of exitListeners) {
listener(error);
}
};
let uiRequestHandler: ((request: RpcExtensionUIRequest) => void) | undefined;
const handleLine = (line: string) => {
const parsed = JSON.parse(line) as { type?: string; id?: string };
if (parsed.type === "response") {
if (parsed.id) {
const pending = pendingRequests.get(parsed.id);
if (pending) {
pendingRequests.delete(parsed.id);
pending.resolve(parsed as RpcResponse);
}
}
return;
}
if (parsed.type === "extension_ui_request") {
uiRequestHandler?.(parsed as RpcExtensionUIRequest);
return;
}
for (const listener of eventListeners) {
listener(parsed as AgentSessionEvent);
}
};
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdoutBuffer += chunk;
while (true) {
const newlineIndex = stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = stdoutBuffer.slice(0, newlineIndex).trim();
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
handleLine(line);
}
});
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
stderrBuffer += chunk;
});
child.once("error", (error) => {
exited = true;
const wrapped = new Error(`RPC process error: ${error.message}. Stderr: ${stderrBuffer}`);
rejectAllPending(wrapped);
notifyExit(wrapped);
});
child.once("exit", (code, signal) => {
exited = true;
const error = new Error(`RPC process exited (code=${code} signal=${signal}). Stderr: ${stderrBuffer}`);
rejectAllPending(error);
notifyExit(error);
});
const send = async (command: RpcCommand): Promise<RpcResponse> => {
if (exited) {
throw new Error(`RPC process is not running. Stderr: ${stderrBuffer}`);
}
const id = command.id ?? `orchestrator_${++nextRequestId}_${randomUUID()}`;
const fullCommand = { ...command, id };
return new Promise<RpcResponse>((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
child.stdin.write(`${JSON.stringify(fullCommand)}\n`, (error) => {
if (!error) {
return;
}
pendingRequests.delete(id);
reject(toError(error));
});
});
};
return {
process: child,
send,
handleUiResponse(response) {
if (exited) {
return;
}
child.stdin.write(`${JSON.stringify(response)}\n`);
},
setUiRequestHandler(handler) {
uiRequestHandler = handler;
},
onEvent(listener) {
eventListeners.add(listener);
return () => {
eventListeners.delete(listener);
};
},
onExit(listener) {
exitListeners.add(listener);
return () => {
exitListeners.delete(listener);
};
},
async dispose() {
uiRequestHandler = undefined;
rejectAllPending(new Error("RPC process disposed"));
if (exited) {
return;
}
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
child.once("exit", () => resolve());
});
},
};
}

View file

@ -1,90 +1,80 @@
import { randomUUID } from "node:crypto";
import {
type AgentSessionEvent,
type AgentSessionEventListener,
type AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
type RpcCommand,
type RpcExtensionUIRequest,
type RpcExtensionUIResponse,
type RpcResponse,
SessionManager,
import type {
AgentSessionEvent,
AgentSessionEventListener,
RpcCommand,
RpcExtensionUIRequest,
RpcExtensionUIResponse,
RpcResponse,
} from "@earendil-works/pi-coding-agent";
import { AttachUiBridge, bindAttachExtensions } from "./attach-ui.ts";
import { radiusPresence } from "./radius.ts";
import { handleRpcCommand } from "./rpc-bridge.ts";
import { createRpcProcessInstance, type RpcProcessInstance } from "./rpc-process.ts";
import { getInstance, loadInstances, removeInstance, saveInstances, upsertInstance } from "./storage.ts";
import type { InstanceRecord } from "./types.ts";
interface LiveInstance {
runtime: AgentSessionRuntime;
rpc: RpcProcessInstance;
record: InstanceRecord;
subscribers: Set<AgentSessionEventListener>;
uiBridge: AttachUiBridge;
unsubscribeSession?: () => void;
onUiRequest?: (request: RpcExtensionUIRequest) => void;
unsubscribeEvents?: () => void;
unsubscribeExit?: () => void;
}
function cloneInstance(record: InstanceRecord): InstanceRecord {
return { ...record };
}
async function createRuntime(cwd: string): Promise<AgentSessionRuntime> {
const agentDir = getAgentDir();
const sessionManager = SessionManager.create(cwd);
const runtimeFactory: CreateAgentSessionRuntimeFactory = async ({
cwd,
agentDir,
sessionManager,
sessionStartEvent,
}) => {
const services = await createAgentSessionServices({ cwd, agentDir });
const created = await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
});
return {
...created,
services,
diagnostics: services.diagnostics,
};
};
return createAgentSessionRuntime(runtimeFactory, {
cwd,
agentDir,
sessionManager,
});
function isGetStateSuccess(
response: RpcResponse,
): response is Extract<
RpcResponse,
{ success: true; command: "get_state"; data: { sessionId: string; sessionFile?: string } }
> {
return response.success === true && response.command === "get_state" && "data" in response;
}
export class OrchestratorSupervisor {
private readonly liveInstances = new Map<string, LiveInstance>();
private syncInstanceRecord(live: LiveInstance): void {
private async syncInstanceRecord(live: LiveInstance): Promise<void> {
const response = await live.rpc.send({ type: "get_state" });
if (!isGetStateSuccess(response)) {
live.record = {
...live.record,
lastSeenAt: new Date().toISOString(),
};
upsertInstance(live.record);
return;
}
live.record = {
...live.record,
sessionId: live.runtime.session.sessionId,
sessionFile: live.runtime.session.sessionFile,
sessionId: response.data.sessionId,
sessionFile: response.data.sessionFile,
lastSeenAt: new Date().toISOString(),
};
upsertInstance(live.record);
}
private async bindLiveInstance(live: LiveInstance): Promise<void> {
await bindAttachExtensions(live.runtime, live.uiBridge);
live.unsubscribeSession?.();
live.unsubscribeSession = live.runtime.session.subscribe((event) => {
private bindLiveInstance(live: LiveInstance): void {
live.unsubscribeEvents?.();
live.unsubscribeExit?.();
live.unsubscribeEvents = live.rpc.onEvent((event) => {
for (const subscriber of live.subscribers) {
subscriber(event);
}
});
live.runtime.setRebindSession(async () => {
this.syncInstanceRecord(live);
await this.bindLiveInstance(live);
live.unsubscribeExit = live.rpc.onExit(() => {
live.record = {
...live.record,
status: "stopped",
lastSeenAt: new Date().toISOString(),
};
upsertInstance(live.record);
this.liveInstances.delete(live.record.id);
});
live.rpc.setUiRequestHandler((request) => {
live.onUiRequest?.(request);
});
}
@ -113,23 +103,22 @@ export class OrchestratorSupervisor {
return undefined;
}
live.subscribers.add(onEvent);
const detachUi = live.uiBridge.attach(onUiRequest);
live.onUiRequest = onUiRequest;
return {
handleRpc: async (command) => {
const response = await handleRpcCommand(live.runtime, command);
this.syncInstanceRecord(live);
const response = await live.rpc.send(command);
await this.syncInstanceRecord(live);
return response;
},
handleUiResponse: (response) => {
live.uiBridge.handleResponse(response);
},
setHostTheme: (theme) => {
live.uiBridge.setThemeOverride(theme);
live.rpc.handleUiResponse(response);
},
setHostTheme: (_theme) => {},
close: () => {
detachUi();
if (live.onUiRequest === onUiRequest) {
live.onUiRequest = undefined;
}
live.subscribers.delete(onEvent);
live.uiBridge.cancelPendingRequests();
},
};
}
@ -170,7 +159,7 @@ export class OrchestratorSupervisor {
}
async spawnInstance(options: { cwd: string; label?: string }): Promise<InstanceRecord> {
const runtime = await createRuntime(options.cwd);
const rpc = createRpcProcessInstance({ cwd: options.cwd });
const now = new Date().toISOString();
const record: InstanceRecord = {
id: randomUUID(),
@ -179,21 +168,19 @@ export class OrchestratorSupervisor {
createdAt: now,
lastSeenAt: now,
label: options.label,
sessionId: runtime.session.sessionId,
sessionFile: runtime.session.sessionFile,
};
const registeredRecord = await radiusPresence.registerPi(record);
const live: LiveInstance = {
runtime,
rpc,
record: registeredRecord,
subscribers: new Set(),
uiBridge: new AttachUiBridge(),
};
await this.bindLiveInstance(live);
this.bindLiveInstance(live);
this.liveInstances.set(registeredRecord.id, live);
upsertInstance(registeredRecord);
return cloneInstance(registeredRecord);
await this.syncInstanceRecord(live);
upsertInstance(live.record);
return cloneInstance(live.record);
}
async stopInstance(instanceId: string): Promise<InstanceRecord | undefined> {
@ -203,10 +190,10 @@ export class OrchestratorSupervisor {
}
await radiusPresence.disconnectPi(live.record);
live.unsubscribeSession?.();
live.uiBridge.cancelPendingRequests();
live.runtime.setRebindSession(undefined);
await live.runtime.dispose();
live.unsubscribeEvents?.();
live.unsubscribeExit?.();
live.onUiRequest = undefined;
await live.rpc.dispose();
this.liveInstances.delete(instanceId);
removeInstance(instanceId);
return cloneInstance(live.record);
@ -218,8 +205,8 @@ export class OrchestratorSupervisor {
return undefined;
}
const response = await handleRpcCommand(live.runtime, command);
this.syncInstanceRecord(live);
const response = await live.rpc.send(command);
await this.syncInstanceRecord(live);
return response;
}