move pi-mono into companion-cloud as apps/companion-os

- Copy all pi-mono source into apps/companion-os/
- Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases
- Update deploy-staging.yml to build pi from source (bun compile) before Docker build
- Add apps/companion-os/** to path triggers
- No more cross-repo dispatch needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Harivansh Rathi 2026-03-07 09:22:50 -08:00
commit 0250f72976
579 changed files with 206942 additions and 0 deletions

View file

@ -0,0 +1,552 @@
/**
* RPC Client for programmatic access to the coding agent.
*
* Spawns the agent in RPC mode and provides a typed API for all operations.
*/
import { type ChildProcess, spawn } from "node:child_process";
import * as readline from "node:readline";
import type {
AgentEvent,
AgentMessage,
ThinkingLevel,
} from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import type { SessionStats } from "../../core/agent-session.js";
import type { BashResult } from "../../core/bash-executor.js";
import type { CompactionResult } from "../../core/compaction/index.js";
import type {
RpcCommand,
RpcResponse,
RpcSessionState,
RpcSlashCommand,
} from "./rpc-types.js";
// ============================================================================
// Types
// ============================================================================
/** Distributive Omit that works with union types */
type DistributiveOmit<T, K extends keyof T> = T extends unknown
? Omit<T, K>
: never;
/** RpcCommand without the id field (for internal send) */
type RpcCommandBody = DistributiveOmit<RpcCommand, "id">;
export interface RpcClientOptions {
/** Path to the CLI entry point (default: searches for dist/cli.js) */
cliPath?: string;
/** Working directory for the agent */
cwd?: string;
/** Environment variables */
env?: Record<string, string>;
/** Provider to use */
provider?: string;
/** Model ID to use */
model?: string;
/** Additional CLI arguments */
args?: string[];
}
export interface ModelInfo {
provider: string;
id: string;
contextWindow: number;
reasoning: boolean;
}
export type RpcEventListener = (event: AgentEvent) => void;
// ============================================================================
// RPC Client
// ============================================================================
export class RpcClient {
private process: ChildProcess | null = null;
private rl: readline.Interface | null = null;
private eventListeners: RpcEventListener[] = [];
private pendingRequests: Map<
string,
{ resolve: (response: RpcResponse) => void; reject: (error: Error) => void }
> = new Map();
private requestId = 0;
private stderr = "";
constructor(private options: RpcClientOptions = {}) {}
/**
* Start the RPC agent process.
*/
async start(): Promise<void> {
if (this.process) {
throw new Error("Client already started");
}
const cliPath = this.options.cliPath ?? "dist/cli.js";
const args = ["--mode", "rpc"];
if (this.options.provider) {
args.push("--provider", this.options.provider);
}
if (this.options.model) {
args.push("--model", this.options.model);
}
if (this.options.args) {
args.push(...this.options.args);
}
this.process = spawn("node", [cliPath, ...args], {
cwd: this.options.cwd,
env: { ...process.env, ...this.options.env },
stdio: ["pipe", "pipe", "pipe"],
});
// Collect stderr for debugging
this.process.stderr?.on("data", (data) => {
this.stderr += data.toString();
});
// Set up line reader for stdout
this.rl = readline.createInterface({
input: this.process.stdout!,
terminal: false,
});
this.rl.on("line", (line) => {
this.handleLine(line);
});
// Wait a moment for process to initialize
await new Promise((resolve) => setTimeout(resolve, 100));
if (this.process.exitCode !== null) {
throw new Error(
`Agent process exited immediately with code ${this.process.exitCode}. Stderr: ${this.stderr}`,
);
}
}
/**
* Stop the RPC agent process.
*/
async stop(): Promise<void> {
if (!this.process) return;
this.rl?.close();
this.process.kill("SIGTERM");
// Wait for process to exit
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
this.process?.kill("SIGKILL");
resolve();
}, 1000);
this.process?.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
this.process = null;
this.rl = null;
this.pendingRequests.clear();
}
/**
* Subscribe to agent events.
*/
onEvent(listener: RpcEventListener): () => void {
this.eventListeners.push(listener);
return () => {
const index = this.eventListeners.indexOf(listener);
if (index !== -1) {
this.eventListeners.splice(index, 1);
}
};
}
/**
* Get collected stderr output (useful for debugging).
*/
getStderr(): string {
return this.stderr;
}
// =========================================================================
// Command Methods
// =========================================================================
/**
* Send a prompt to the agent.
* Returns immediately after sending; use onEvent() to receive streaming events.
* Use waitForIdle() to wait for completion.
*/
async prompt(message: string, images?: ImageContent[]): Promise<void> {
await this.send({ type: "prompt", message, images });
}
/**
* Queue a steering message to interrupt the agent mid-run.
*/
async steer(message: string, images?: ImageContent[]): Promise<void> {
await this.send({ type: "steer", message, images });
}
/**
* Queue a follow-up message to be processed after the agent finishes.
*/
async followUp(message: string, images?: ImageContent[]): Promise<void> {
await this.send({ type: "follow_up", message, images });
}
/**
* Abort current operation.
*/
async abort(): Promise<void> {
await this.send({ type: "abort" });
}
/**
* Start a new session, optionally with parent tracking.
* @param parentSession - Optional parent session path for lineage tracking
* @returns Object with `cancelled: true` if an extension cancelled the new session
*/
async newSession(parentSession?: string): Promise<{ cancelled: boolean }> {
const response = await this.send({ type: "new_session", parentSession });
return this.getData(response);
}
/**
* Get current session state.
*/
async getState(): Promise<RpcSessionState> {
const response = await this.send({ type: "get_state" });
return this.getData(response);
}
/**
* Set model by provider and ID.
*/
async setModel(
provider: string,
modelId: string,
): Promise<{ provider: string; id: string }> {
const response = await this.send({ type: "set_model", provider, modelId });
return this.getData(response);
}
/**
* Cycle to next model.
*/
async cycleModel(): Promise<{
model: { provider: string; id: string };
thinkingLevel: ThinkingLevel;
isScoped: boolean;
} | null> {
const response = await this.send({ type: "cycle_model" });
return this.getData(response);
}
/**
* Get list of available models.
*/
async getAvailableModels(): Promise<ModelInfo[]> {
const response = await this.send({ type: "get_available_models" });
return this.getData<{ models: ModelInfo[] }>(response).models;
}
/**
* Set thinking level.
*/
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
await this.send({ type: "set_thinking_level", level });
}
/**
* Cycle thinking level.
*/
async cycleThinkingLevel(): Promise<{ level: ThinkingLevel } | null> {
const response = await this.send({ type: "cycle_thinking_level" });
return this.getData(response);
}
/**
* Set steering mode.
*/
async setSteeringMode(mode: "all" | "one-at-a-time"): Promise<void> {
await this.send({ type: "set_steering_mode", mode });
}
/**
* Set follow-up mode.
*/
async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise<void> {
await this.send({ type: "set_follow_up_mode", mode });
}
/**
* Compact session context.
*/
async compact(customInstructions?: string): Promise<CompactionResult> {
const response = await this.send({ type: "compact", customInstructions });
return this.getData(response);
}
/**
* Set auto-compaction enabled/disabled.
*/
async setAutoCompaction(enabled: boolean): Promise<void> {
await this.send({ type: "set_auto_compaction", enabled });
}
/**
* Set auto-retry enabled/disabled.
*/
async setAutoRetry(enabled: boolean): Promise<void> {
await this.send({ type: "set_auto_retry", enabled });
}
/**
* Abort in-progress retry.
*/
async abortRetry(): Promise<void> {
await this.send({ type: "abort_retry" });
}
/**
* Execute a bash command.
*/
async bash(command: string): Promise<BashResult> {
const response = await this.send({ type: "bash", command });
return this.getData(response);
}
/**
* Abort running bash command.
*/
async abortBash(): Promise<void> {
await this.send({ type: "abort_bash" });
}
/**
* Get session statistics.
*/
async getSessionStats(): Promise<SessionStats> {
const response = await this.send({ type: "get_session_stats" });
return this.getData(response);
}
/**
* Export session to HTML.
*/
async exportHtml(outputPath?: string): Promise<{ path: string }> {
const response = await this.send({ type: "export_html", outputPath });
return this.getData(response);
}
/**
* Switch to a different session file.
* @returns Object with `cancelled: true` if an extension cancelled the switch
*/
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> {
const response = await this.send({ type: "switch_session", sessionPath });
return this.getData(response);
}
/**
* Fork from a specific message.
* @returns Object with `text` (the message text) and `cancelled` (if extension cancelled)
*/
async fork(entryId: string): Promise<{ text: string; cancelled: boolean }> {
const response = await this.send({ type: "fork", entryId });
return this.getData(response);
}
/**
* Get messages available for forking.
*/
async getForkMessages(): Promise<Array<{ entryId: string; text: string }>> {
const response = await this.send({ type: "get_fork_messages" });
return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(
response,
).messages;
}
/**
* Get text of last assistant message.
*/
async getLastAssistantText(): Promise<string | null> {
const response = await this.send({ type: "get_last_assistant_text" });
return this.getData<{ text: string | null }>(response).text;
}
/**
* Set the session display name.
*/
async setSessionName(name: string): Promise<void> {
await this.send({ type: "set_session_name", name });
}
/**
* Get all messages in the session.
*/
async getMessages(): Promise<AgentMessage[]> {
const response = await this.send({ type: "get_messages" });
return this.getData<{ messages: AgentMessage[] }>(response).messages;
}
/**
* Get available commands (extension commands, prompt templates, skills).
*/
async getCommands(): Promise<RpcSlashCommand[]> {
const response = await this.send({ type: "get_commands" });
return this.getData<{ commands: RpcSlashCommand[] }>(response).commands;
}
// =========================================================================
// Helpers
// =========================================================================
/**
* Wait for agent to become idle (no streaming).
* Resolves when agent_end event is received.
*/
waitForIdle(timeout = 60000): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
unsubscribe();
reject(
new Error(
`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`,
),
);
}, timeout);
const unsubscribe = this.onEvent((event) => {
if (event.type === "agent_end") {
clearTimeout(timer);
unsubscribe();
resolve();
}
});
});
}
/**
* Collect events until agent becomes idle.
*/
collectEvents(timeout = 60000): Promise<AgentEvent[]> {
return new Promise((resolve, reject) => {
const events: AgentEvent[] = [];
const timer = setTimeout(() => {
unsubscribe();
reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`));
}, timeout);
const unsubscribe = this.onEvent((event) => {
events.push(event);
if (event.type === "agent_end") {
clearTimeout(timer);
unsubscribe();
resolve(events);
}
});
});
}
/**
* Send prompt and wait for completion, returning all events.
*/
async promptAndWait(
message: string,
images?: ImageContent[],
timeout = 60000,
): Promise<AgentEvent[]> {
const eventsPromise = this.collectEvents(timeout);
await this.prompt(message, images);
return eventsPromise;
}
// =========================================================================
// Internal
// =========================================================================
private handleLine(line: string): void {
try {
const data = JSON.parse(line);
// Check if it's a response to a pending request
if (
data.type === "response" &&
data.id &&
this.pendingRequests.has(data.id)
) {
const pending = this.pendingRequests.get(data.id)!;
this.pendingRequests.delete(data.id);
pending.resolve(data as RpcResponse);
return;
}
// Otherwise it's an event
for (const listener of this.eventListeners) {
listener(data as AgentEvent);
}
} catch {
// Ignore non-JSON lines
}
}
private async send(command: RpcCommandBody): Promise<RpcResponse> {
if (!this.process?.stdin) {
throw new Error("Client not started");
}
const id = `req_${++this.requestId}`;
const fullCommand = { ...command, id } as RpcCommand;
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(
new Error(
`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`,
),
);
}, 30000);
this.pendingRequests.set(id, {
resolve: (response) => {
clearTimeout(timeout);
resolve(response);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
},
});
this.process!.stdin!.write(`${JSON.stringify(fullCommand)}\n`);
});
}
private getData<T>(response: RpcResponse): T {
if (!response.success) {
const errorResponse = response as Extract<
RpcResponse,
{ success: false }
>;
throw new Error(errorResponse.error);
}
// Type assertion: we trust response.data matches T based on the command sent.
// This is safe because each public method specifies the correct T for its command.
const successResponse = response as Extract<
RpcResponse,
{ success: true; data: unknown }
>;
return successResponse.data as T;
}
}

View file

@ -0,0 +1,715 @@
/**
* RPC mode: Headless operation with JSON stdin/stdout protocol.
*
* Used for embedding the agent in other applications.
* Receives commands as JSON on stdin, outputs events and responses as JSON on stdout.
*
* Protocol:
* - Commands: JSON objects with `type` field, optional `id` for correlation
* - Responses: JSON objects with `type: "response"`, `command`, `success`, and optional `data`/`error`
* - Events: AgentSessionEvent objects streamed as they occur
* - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
*/
import * as crypto from "node:crypto";
import * as readline from "readline";
import type { AgentSession } from "../../core/agent-session.js";
import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
} from "../../core/extensions/index.js";
import { type Theme, theme } from "../interactive/theme/theme.js";
import type {
RpcCommand,
RpcExtensionUIRequest,
RpcExtensionUIResponse,
RpcResponse,
RpcSessionState,
RpcSlashCommand,
} from "./rpc-types.js";
// Re-export types for consumers
export type {
RpcCommand,
RpcExtensionUIRequest,
RpcExtensionUIResponse,
RpcResponse,
RpcSessionState,
} from "./rpc-types.js";
/**
* Run in RPC mode.
* Listens for JSON commands on stdin, outputs events and responses on stdout.
*/
export async function runRpcMode(session: AgentSession): Promise<never> {
const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
console.log(JSON.stringify(obj));
};
const success = <T extends RpcCommand["type"]>(
id: string | undefined,
command: T,
data?: object | null,
): RpcResponse => {
if (data === undefined) {
return { id, type: "response", command, success: true } as RpcResponse;
}
return {
id,
type: "response",
command,
success: true,
data,
} as RpcResponse;
};
const error = (
id: string | undefined,
command: string,
message: string,
): RpcResponse => {
return { id, type: "response", command, success: false, error: message };
};
// Pending extension UI requests waiting for response
const pendingExtensionRequests = new Map<
string,
{ resolve: (value: any) => void; reject: (error: Error) => void }
>();
// Shutdown request flag
let shutdownRequested = false;
/** Helper for dialog methods with signal/timeout support */
function createDialogPromise<T>(
opts: ExtensionUIDialogOptions | undefined,
defaultValue: T,
request: Record<string, unknown>,
parseResponse: (response: RpcExtensionUIResponse) => T,
): Promise<T> {
if (opts?.signal?.aborted) return Promise.resolve(defaultValue);
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId);
opts?.signal?.removeEventListener("abort", onAbort);
pendingExtensionRequests.delete(id);
};
const onAbort = () => {
cleanup();
resolve(defaultValue);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });
if (opts?.timeout) {
timeoutId = setTimeout(() => {
cleanup();
resolve(defaultValue);
}, opts.timeout);
}
pendingExtensionRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
cleanup();
resolve(parseResponse(response));
},
reject,
});
output({
type: "extension_ui_request",
id,
...request,
} as RpcExtensionUIRequest);
});
}
/**
* Create an extension UI context that uses the RPC protocol.
*/
const createExtensionUIContext = (): ExtensionUIContext => ({
select: (title, options, opts) =>
createDialogPromise(
opts,
undefined,
{ method: "select", title, options, timeout: opts?.timeout },
(r) =>
"cancelled" in r && r.cancelled
? undefined
: "value" in r
? r.value
: undefined,
),
confirm: (title, message, opts) =>
createDialogPromise(
opts,
false,
{ method: "confirm", title, message, timeout: opts?.timeout },
(r) =>
"cancelled" in r && r.cancelled
? false
: "confirmed" in r
? r.confirmed
: false,
),
input: (title, placeholder, opts) =>
createDialogPromise(
opts,
undefined,
{ method: "input", title, placeholder, timeout: opts?.timeout },
(r) =>
"cancelled" in r && r.cancelled
? undefined
: "value" in r
? r.value
: undefined,
),
notify(message: string, type?: "info" | "warning" | "error"): void {
// Fire and forget - no response needed
output({
type: "extension_ui_request",
id: crypto.randomUUID(),
method: "notify",
message,
notifyType: type,
} as RpcExtensionUIRequest);
},
onTerminalInput(): () => void {
// Raw terminal input not supported in RPC mode
return () => {};
},
setStatus(key: string, text: string | undefined): void {
// Fire and forget - no response needed
output({
type: "extension_ui_request",
id: crypto.randomUUID(),
method: "setStatus",
statusKey: key,
statusText: text,
} as RpcExtensionUIRequest);
},
setWorkingMessage(_message?: string): void {
// Working message not supported in RPC mode - requires TUI loader access
},
setWidget(
key: string,
content: unknown,
options?: ExtensionWidgetOptions,
): void {
// Only support string arrays in RPC mode - factory functions are ignored
if (content === undefined || Array.isArray(content)) {
output({
type: "extension_ui_request",
id: crypto.randomUUID(),
method: "setWidget",
widgetKey: key,
widgetLines: content as string[] | undefined,
widgetPlacement: options?.placement,
} as RpcExtensionUIRequest);
}
// Component factories are not supported in RPC mode - would need TUI access
},
setFooter(_factory: unknown): void {
// Custom footer not supported in RPC mode - requires TUI access
},
setHeader(_factory: unknown): void {
// Custom header not supported in RPC mode - requires TUI access
},
setTitle(title: string): void {
// Fire and forget - host can implement terminal title control
output({
type: "extension_ui_request",
id: crypto.randomUUID(),
method: "setTitle",
title,
} as RpcExtensionUIRequest);
},
async custom() {
// Custom UI not supported in RPC mode
return undefined as never;
},
pasteToEditor(text: string): void {
// Paste handling not supported in RPC mode - falls back to setEditorText
this.setEditorText(text);
},
setEditorText(text: string): void {
// Fire and forget - host can implement editor control
output({
type: "extension_ui_request",
id: crypto.randomUUID(),
method: "set_editor_text",
text,
} as RpcExtensionUIRequest);
},
getEditorText(): string {
// Synchronous method can't wait for RPC response
// Host should track editor state locally if needed
return "";
},
async editor(title: string, prefill?: string): Promise<string | undefined> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingExtensionRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
if ("cancelled" in response && response.cancelled) {
resolve(undefined);
} else if ("value" in response) {
resolve(response.value);
} else {
resolve(undefined);
}
},
reject,
});
output({
type: "extension_ui_request",
id,
method: "editor",
title,
prefill,
} as RpcExtensionUIRequest);
});
},
setEditorComponent(): void {
// Custom editor components not supported in RPC mode
},
get theme() {
return theme;
},
getAllThemes() {
return [];
},
getTheme(_name: string) {
return undefined;
},
setTheme(_theme: string | Theme) {
// Theme switching not supported in RPC mode
return {
success: false,
error: "Theme switching not supported in RPC mode",
};
},
getToolsExpanded() {
// Tool expansion not supported in RPC mode - no TUI
return false;
},
setToolsExpanded(_expanded: boolean) {
// Tool expansion not supported in RPC mode - no TUI
},
});
// Set up extensions with RPC-based UI context
await session.bindExtensions({
uiContext: createExtensionUIContext(),
commandContextActions: {
waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => {
// Delegate to AgentSession (handles setup + agent state sync)
const success = await session.newSession(options);
return { cancelled: !success };
},
fork: async (entryId) => {
const result = await session.fork(entryId);
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) => {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
reload: async () => {
await session.reload();
},
},
shutdownHandler: () => {
shutdownRequested = true;
},
onError: (err) => {
output({
type: "extension_error",
extensionPath: err.extensionPath,
event: err.event,
error: err.error,
});
},
});
// Output all agent events as JSON
session.subscribe((event) => {
output(event);
});
// Handle a single command
const handleCommand = async (command: RpcCommand): Promise<RpcResponse> => {
const id = command.id;
switch (command.type) {
// =================================================================
// Prompting
// =================================================================
case "prompt": {
// 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
session
.prompt(command.message, {
images: command.images,
streamingBehavior: command.streamingBehavior,
source: "rpc",
})
.catch((e) => output(error(id, "prompt", e.message)));
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 "new_session": {
const options = command.parentSession
? { parentSession: command.parentSession }
: undefined;
const cancelled = !(await session.newSession(options));
return success(id, "new_session", { cancelled });
}
// =================================================================
// State
// =================================================================
case "get_state": {
const state: RpcSessionState = {
model: session.model,
thinkingLevel: session.thinkingLevel,
isStreaming: session.isStreaming,
isCompacting: session.isCompacting,
steeringMode: session.steeringMode,
followUpMode: session.followUpMode,
sessionFile: session.sessionFile,
sessionId: session.sessionId,
sessionName: session.sessionName,
autoCompactionEnabled: session.autoCompactionEnabled,
messageCount: session.messages.length,
pendingMessageCount: session.pendingMessageCount,
};
return success(id, "get_state", state);
}
// =================================================================
// Model
// =================================================================
case "set_model": {
const models = 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();
if (!result) {
return success(id, "cycle_model", null);
}
return success(id, "cycle_model", result);
}
case "get_available_models": {
const models = await session.modelRegistry.getAvailable();
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");
}
// =================================================================
// 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 "switch_session": {
const cancelled = !(await session.switchSession(command.sessionPath));
return success(id, "switch_session", { cancelled });
}
case "fork": {
const result = await session.fork(command.entryId);
return success(id, "fork", {
text: result.selectedText,
cancelled: result.cancelled,
});
}
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");
}
// =================================================================
// Messages
// =================================================================
case "get_messages": {
return success(id, "get_messages", { messages: session.messages });
}
// =================================================================
// Commands (available for invocation via prompt)
// =================================================================
case "get_commands": {
const commands: RpcSlashCommand[] = [];
// Extension commands
for (const {
command,
extensionPath,
} of session.extensionRunner?.getRegisteredCommandsWithPaths() ?? []) {
commands.push({
name: command.name,
description: command.description,
source: "extension",
path: extensionPath,
});
}
// Prompt templates (source is always "user" | "project" | "path" in coding-agent)
for (const template of session.promptTemplates) {
commands.push({
name: template.name,
description: template.description,
source: "prompt",
location: template.source as RpcSlashCommand["location"],
path: template.filePath,
});
}
// Skills (source is always "user" | "project" | "path" in coding-agent)
for (const skill of session.resourceLoader.getSkills().skills) {
commands.push({
name: `skill:${skill.name}`,
description: skill.description,
source: "skill",
location: skill.source as RpcSlashCommand["location"],
path: skill.filePath,
});
}
return success(id, "get_commands", { commands });
}
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 (!shutdownRequested) return;
const currentRunner = session.extensionRunner;
if (currentRunner?.hasHandlers("session_shutdown")) {
await currentRunner.emit({ type: "session_shutdown" });
}
// Close readline interface to stop waiting for input
rl.close();
process.exit(0);
}
// Listen for JSON input
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false,
});
rl.on("line", async (line: string) => {
try {
const parsed = JSON.parse(line);
// Handle extension UI responses
if (parsed.type === "extension_ui_response") {
const response = parsed as RpcExtensionUIResponse;
const pending = pendingExtensionRequests.get(response.id);
if (pending) {
pendingExtensionRequests.delete(response.id);
pending.resolve(response);
}
return;
}
// 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: any) {
output(
error(undefined, "parse", `Failed to parse command: ${e.message}`),
);
}
});
// Keep process alive forever
return new Promise(() => {});
}

View file

@ -0,0 +1,388 @@
/**
* RPC protocol types for headless operation.
*
* Commands are sent as JSON lines on stdin.
* Responses and events are emitted as JSON lines on stdout.
*/
import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { ImageContent, Model } from "@mariozechner/pi-ai";
import type { SessionStats } from "../../core/agent-session.js";
import type { BashResult } from "../../core/bash-executor.js";
import type { CompactionResult } from "../../core/compaction/index.js";
// ============================================================================
// RPC Commands (stdin)
// ============================================================================
export type RpcCommand =
// Prompting
| {
id?: string;
type: "prompt";
message: string;
images?: ImageContent[];
streamingBehavior?: "steer" | "followUp";
}
| { id?: string; type: "steer"; message: string; images?: ImageContent[] }
| { id?: string; type: "follow_up"; message: string; images?: ImageContent[] }
| { id?: string; type: "abort" }
| { id?: string; type: "new_session"; parentSession?: string }
// State
| { id?: string; type: "get_state" }
// Model
| { id?: string; type: "set_model"; provider: string; modelId: string }
| { id?: string; type: "cycle_model" }
| { id?: string; type: "get_available_models" }
// Thinking
| { id?: string; type: "set_thinking_level"; level: ThinkingLevel }
| { id?: string; type: "cycle_thinking_level" }
// Queue modes
| { id?: string; type: "set_steering_mode"; mode: "all" | "one-at-a-time" }
| { id?: string; type: "set_follow_up_mode"; mode: "all" | "one-at-a-time" }
// Compaction
| { id?: string; type: "compact"; customInstructions?: string }
| { id?: string; type: "set_auto_compaction"; enabled: boolean }
// Retry
| { id?: string; type: "set_auto_retry"; enabled: boolean }
| { id?: string; type: "abort_retry" }
// Bash
| { id?: string; type: "bash"; command: string }
| { id?: string; type: "abort_bash" }
// Session
| { id?: string; type: "get_session_stats" }
| { id?: string; type: "export_html"; outputPath?: string }
| { id?: string; type: "switch_session"; sessionPath: string }
| { id?: string; type: "fork"; entryId: string }
| { id?: string; type: "get_fork_messages" }
| { id?: string; type: "get_last_assistant_text" }
| { id?: string; type: "set_session_name"; name: string }
// Messages
| { id?: string; type: "get_messages" }
// Commands (available for invocation via prompt)
| { id?: string; type: "get_commands" };
// ============================================================================
// RPC Slash Command (for get_commands response)
// ============================================================================
/** A command available for invocation via prompt */
export interface RpcSlashCommand {
/** Command name (without leading slash) */
name: string;
/** Human-readable description */
description?: string;
/** What kind of command this is */
source: "extension" | "prompt" | "skill";
/** Where the command was loaded from (undefined for extensions) */
location?: "user" | "project" | "path";
/** File path to the command source */
path?: string;
}
// ============================================================================
// RPC State
// ============================================================================
export interface RpcSessionState {
model?: Model<any>;
thinkingLevel: ThinkingLevel;
isStreaming: boolean;
isCompacting: boolean;
steeringMode: "all" | "one-at-a-time";
followUpMode: "all" | "one-at-a-time";
sessionFile?: string;
sessionId: string;
sessionName?: string;
autoCompactionEnabled: boolean;
messageCount: number;
pendingMessageCount: number;
}
// ============================================================================
// RPC Responses (stdout)
// ============================================================================
// Success responses with data
export type RpcResponse =
// Prompting (async - events follow)
| { id?: string; type: "response"; command: "prompt"; success: true }
| { id?: string; type: "response"; command: "steer"; success: true }
| { id?: string; type: "response"; command: "follow_up"; success: true }
| { id?: string; type: "response"; command: "abort"; success: true }
| {
id?: string;
type: "response";
command: "new_session";
success: true;
data: { cancelled: boolean };
}
// State
| {
id?: string;
type: "response";
command: "get_state";
success: true;
data: RpcSessionState;
}
// Model
| {
id?: string;
type: "response";
command: "set_model";
success: true;
data: Model<any>;
}
| {
id?: string;
type: "response";
command: "cycle_model";
success: true;
data: {
model: Model<any>;
thinkingLevel: ThinkingLevel;
isScoped: boolean;
} | null;
}
| {
id?: string;
type: "response";
command: "get_available_models";
success: true;
data: { models: Model<any>[] };
}
// Thinking
| {
id?: string;
type: "response";
command: "set_thinking_level";
success: true;
}
| {
id?: string;
type: "response";
command: "cycle_thinking_level";
success: true;
data: { level: ThinkingLevel } | null;
}
// Queue modes
| {
id?: string;
type: "response";
command: "set_steering_mode";
success: true;
}
| {
id?: string;
type: "response";
command: "set_follow_up_mode";
success: true;
}
// Compaction
| {
id?: string;
type: "response";
command: "compact";
success: true;
data: CompactionResult;
}
| {
id?: string;
type: "response";
command: "set_auto_compaction";
success: true;
}
// Retry
| { id?: string; type: "response"; command: "set_auto_retry"; success: true }
| { id?: string; type: "response"; command: "abort_retry"; success: true }
// Bash
| {
id?: string;
type: "response";
command: "bash";
success: true;
data: BashResult;
}
| { id?: string; type: "response"; command: "abort_bash"; success: true }
// Session
| {
id?: string;
type: "response";
command: "get_session_stats";
success: true;
data: SessionStats;
}
| {
id?: string;
type: "response";
command: "export_html";
success: true;
data: { path: string };
}
| {
id?: string;
type: "response";
command: "switch_session";
success: true;
data: { cancelled: boolean };
}
| {
id?: string;
type: "response";
command: "fork";
success: true;
data: { text: string; cancelled: boolean };
}
| {
id?: string;
type: "response";
command: "get_fork_messages";
success: true;
data: { messages: Array<{ entryId: string; text: string }> };
}
| {
id?: string;
type: "response";
command: "get_last_assistant_text";
success: true;
data: { text: string | null };
}
| {
id?: string;
type: "response";
command: "set_session_name";
success: true;
}
// Messages
| {
id?: string;
type: "response";
command: "get_messages";
success: true;
data: { messages: AgentMessage[] };
}
// Commands
| {
id?: string;
type: "response";
command: "get_commands";
success: true;
data: { commands: RpcSlashCommand[] };
}
// Error response (any command can fail)
| {
id?: string;
type: "response";
command: string;
success: false;
error: string;
};
// ============================================================================
// Extension UI Events (stdout)
// ============================================================================
/** Emitted when an extension needs user input */
export type RpcExtensionUIRequest =
| {
type: "extension_ui_request";
id: string;
method: "select";
title: string;
options: string[];
timeout?: number;
}
| {
type: "extension_ui_request";
id: string;
method: "confirm";
title: string;
message: string;
timeout?: number;
}
| {
type: "extension_ui_request";
id: string;
method: "input";
title: string;
placeholder?: string;
timeout?: number;
}
| {
type: "extension_ui_request";
id: string;
method: "editor";
title: string;
prefill?: string;
}
| {
type: "extension_ui_request";
id: string;
method: "notify";
message: string;
notifyType?: "info" | "warning" | "error";
}
| {
type: "extension_ui_request";
id: string;
method: "setStatus";
statusKey: string;
statusText: string | undefined;
}
| {
type: "extension_ui_request";
id: string;
method: "setWidget";
widgetKey: string;
widgetLines: string[] | undefined;
widgetPlacement?: "aboveEditor" | "belowEditor";
}
| {
type: "extension_ui_request";
id: string;
method: "setTitle";
title: string;
}
| {
type: "extension_ui_request";
id: string;
method: "set_editor_text";
text: string;
};
// ============================================================================
// Extension UI Commands (stdin)
// ============================================================================
/** Response to an extension UI request */
export type RpcExtensionUIResponse =
| { type: "extension_ui_response"; id: string; value: string }
| { type: "extension_ui_response"; id: string; confirmed: boolean }
| { type: "extension_ui_response"; id: string; cancelled: true };
// ============================================================================
// Helper type for extracting command types
// ============================================================================
export type RpcCommandType = RpcCommand["type"];