Files
paseo/packages/server/src/server/agent/providers/omp/cli-runtime.ts
T
+1 264f6e1cca feat(omp): make OMP a first-class provider (#5550)
Makes OMP a first-class provider: import keeps the session model, replay matches live rendering, /compact waits for OMP, Auto thinking, working multi-select and Other answers, mid-turn steering, fallback model and Fast mode, archived agents open without starting omp, profile-aware import, crash recovery and approval-mode changes, readable tool-call rows, one bubble per /skill, blocked and abandoned todos, OMP notifications, hidden todo reminders on reload, and session MCP servers for schedules and Hub runs.

Fixes #2727, fixes #2857, fixes #4000, fixes #4790, fixes #5354, fixes #5099

Co-authored-by: Abhishek Gupta <183943363+abhi-wan-kenobi@users.noreply.github.com>
Co-authored-by: Jegor Kitskerkin <jegor.kitskerkin@gmail.com>
Co-authored-by: Kh05ifr4nD <meandSSH0219@gmail.com>
Co-authored-by: Nguyen Quang Minh <135627235+n24q02m@users.noreply.github.com>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
Co-authored-by: Thomas Van <minhthong@gmail.com>
Co-authored-by: allo <alloevil@hotmail.com>
Co-authored-by: gray-graff <gray.graff@gmail.com>
Co-authored-by: kasrakhosravi <kasra@goldsky.com>
Co-authored-by: viet-initx <viet@initx.io>
2026-09-29 14:12:53 +01:00

321 lines
10 KiB
TypeScript

import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { z } from "zod";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import {
JSONL_RPC_NO_TIMEOUT,
JsonlRpcProcess,
type JsonlRpcLaunch,
} from "../jsonl-rpc-process.js";
import { establishOmpProtocol } from "./protocol-session.js";
import {
buildOmpLaunch,
type OmpRuntime,
type OmpRuntimeLaunch,
type OmpRuntimeSession,
type OmpStartSessionInput,
} from "./runtime.js";
import {
OmpBranchMessagesResultSchema,
OmpBranchResultSchema,
OmpCommandsResultSchema,
OmpHostToolsResultSchema,
OmpMessagesResultSchema,
OmpModelSchema,
OmpModelsResultSchema,
OmpPromptAckSchema,
OmpRpcCommandSchema,
OmpRuntimeEventSchema,
OmpSessionStateSchema,
OmpSessionStatsSchema,
type OmpThinkingLevel,
type OmpAgentMessage,
type OmpModel,
type OmpPromptAck,
type OmpRpcCommand,
type OmpRpcHostToolDefinition,
type OmpRpcHostToolResult,
type OmpRpcHostToolUpdate,
type OmpRpcSlashCommand,
type OmpRuntimeEvent,
type OmpSessionState,
type OmpSessionStats,
type OmpSubagentSubscriptionLevel,
} from "./rpc-types.js";
const DEFAULT_OMP_COMMAND: [string, ...string[]] = [process.env.OMP_COMMAND ?? "omp"];
const DEFAULT_COMMANDS_RPC_NAME = "get_available_commands";
export interface OmpCliRuntimeOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
command?: [string, ...string[]];
commandsRpcName?: "get_available_commands";
readyTimeoutMs?: number;
requestTimeoutMs?: number;
spawnProcess?: (launch: OmpRuntimeLaunch) => ChildProcessWithoutNullStreams;
}
export class OmpCliRuntime implements OmpRuntime {
private readonly command: [string, ...string[]];
private readonly commandsRpcName: "get_available_commands";
private readonly spawnProcess?: (launch: OmpRuntimeLaunch) => ChildProcessWithoutNullStreams;
constructor(private readonly options: OmpCliRuntimeOptions) {
this.command = options.command ?? DEFAULT_OMP_COMMAND;
this.commandsRpcName = options.commandsRpcName ?? DEFAULT_COMMANDS_RPC_NAME;
this.spawnProcess = options.spawnProcess;
}
async startSession(input: OmpStartSessionInput): Promise<OmpRuntimeSession> {
const launch = buildOmpLaunch({
command: this.command,
runtimeSettings: this.options.runtimeSettings,
session: input,
});
const [command, ...args] = launch.argv;
const processLaunch: JsonlRpcLaunch = {
command,
args,
cwd: launch.cwd,
env: launch.env,
};
const spawn = this.spawnProcess;
const processOptions = {
launch: processLaunch,
logger: this.options.logger,
diagnosticName: "OMP RPC",
defaultRequestTimeoutMs: this.options.requestTimeoutMs,
...(spawn ? { spawn: () => spawn(launch) } : {}),
};
const process = new JsonlRpcProcess(processOptions);
const handleAbort = () => void process.close(input.signal?.reason).catch(() => undefined);
input.signal?.addEventListener("abort", handleAbort, { once: true });
try {
await establishOmpProtocol(process, this.options.logger, {
readyTimeoutMs: this.options.readyTimeoutMs,
requestTimeoutMs: this.options.requestTimeoutMs,
});
input.signal?.throwIfAborted();
return new OmpCliRuntimeSession(process, this.commandsRpcName);
} catch (error) {
const startupError = error instanceof Error ? error : new Error(String(error));
await process.close(startupError);
throw startupError;
} finally {
input.signal?.removeEventListener("abort", handleAbort);
}
}
}
class OmpCliRuntimeSession implements OmpRuntimeSession {
private readonly subscribers = new Set<(event: OmpRuntimeEvent) => void>();
activeBranchEntryId?: string;
constructor(
private readonly process: JsonlRpcProcess,
private readonly commandsRpcName: "get_available_commands",
) {
process.onMessage((message) => {
const event = OmpRuntimeEventSchema.safeParse(message);
if (event.success) {
this.emit(event.data);
}
});
process.onExit(({ error }) => {
this.emit({ type: "process_exit", error: error.message });
});
}
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck> {
const { id: requestId, promise } = this.process.startRequest({
type: "prompt",
message,
...(images?.length ? { images } : {}),
});
const ack = OmpPromptAckSchema.parse(await promise) ?? {};
return { requestId, ...ack };
}
async compact(customInstructions?: string): Promise<void> {
await this.request(
{
type: "compact",
...(customInstructions ? { customInstructions } : {}),
},
JSONL_RPC_NO_TIMEOUT,
);
}
async setAutoCompaction(enabled: boolean): Promise<void> {
await this.request({ type: "set_auto_compaction", enabled });
}
async abort(): Promise<void> {
await this.requestStopWork({ type: "abort" });
}
async getState(): Promise<OmpSessionState> {
return OmpSessionStateSchema.parse(await this.request({ type: "get_state" }));
}
async setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }> {
const result = await this.request({ type: "set_fast_mode", enabled });
return z.object({ enabled: z.boolean(), active: z.boolean() }).parse(result);
}
async getMessages(): Promise<OmpAgentMessage[]> {
const data = OmpMessagesResultSchema.parse(await this.request({ type: "get_messages" }));
return data.messages ?? [];
}
async getAvailableModels(timeoutMs?: number | null): Promise<OmpModel[]> {
const data = OmpModelsResultSchema.parse(
await this.request({ type: "get_available_models" }, timeoutMs),
);
return data.models ?? [];
}
async setModel(provider: string, modelId: string): Promise<OmpModel> {
return OmpModelSchema.parse(await this.request({ type: "set_model", provider, modelId }));
}
async setThinkingLevel(level: OmpThinkingLevel): Promise<void> {
await this.request({ type: "set_thinking_level", level });
}
async getSessionStats(): Promise<OmpSessionStats> {
// COMPAT(ompGetStateFallback): added in v0.1.105 — older OMP binaries
// lack the `get_session_stats` RPC command; fall back to extracting
// context window usage from `get_state`. Remove after 2027-01-10 once the
// supported OMP floor includes `get_session_stats`.
let stats: OmpSessionStats | undefined;
try {
stats = OmpSessionStatsSchema.parse(await this.request({ type: "get_session_stats" }));
} catch {
// get_session_stats not supported by this binary — will try get_state below
}
if (stats?.tokens == null && stats?.cost == null && stats?.contextUsage == null) {
try {
const state = OmpSessionStateSchema.parse(await this.request({ type: "get_state" }));
const ctx = state.contextUsage;
if (ctx) {
return {
contextUsage: {
tokens: typeof ctx.tokens === "number" ? ctx.tokens : undefined,
contextWindow: typeof ctx.contextWindow === "number" ? ctx.contextWindow : undefined,
},
};
}
} catch {
// get_state also failed — nothing we can do
}
}
return stats ?? {};
}
async getCommands(): Promise<OmpRpcSlashCommand[]> {
const data = OmpCommandsResultSchema.parse(await this.request({ type: this.commandsRpcName }));
return data.commands ?? [];
}
async setSubagentSubscription(level: OmpSubagentSubscriptionLevel): Promise<void> {
await this.request({ type: "set_subagent_subscription", level });
}
async setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]> {
const data = OmpHostToolsResultSchema.parse(
await this.request({ type: "set_host_tools", tools }),
);
return data.toolNames ?? [];
}
sendHostToolResult(result: OmpRpcHostToolResult): void {
this.process.send({ ...result });
}
sendHostToolUpdate(update: OmpRpcHostToolUpdate): void {
this.process.send({ ...update });
}
async branch(entryId: string): Promise<{ text: string }> {
const data = OmpBranchResultSchema.parse(await this.request({ type: "branch", entryId }));
if (data.cancelled === true) {
throw new Error("OMP branch was cancelled");
}
if (typeof data.text !== "string") {
throw new Error("OMP branch response did not include restored prompt text");
}
this.activeBranchEntryId = entryId;
return { text: data.text };
}
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
const data = OmpBranchMessagesResultSchema.parse(
await this.request({ type: "get_branch_messages" }),
);
return data.messages ?? [];
}
async steer(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> {
await this.request({ type: "steer", message, ...(images?.length ? { images } : {}) });
}
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void {
this.process.send({ type: "follow_up", message, ...(images?.length ? { images } : {}) });
}
async handoff(customInstructions?: string): Promise<void> {
await this.request({
type: "handoff",
...(customInstructions ? { customInstructions } : {}),
});
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void {
this.process.send({ type: "extension_ui_response", id, ...response });
}
cancelExtensionUiRequest(id: string): void {
this.respondToExtensionUiRequest(id, { cancelled: true });
}
async close(): Promise<void> {
await this.process.close(new Error("OMP RPC session is closed"));
}
private request(command: OmpRpcCommand, timeoutMs?: number | null): Promise<unknown> {
return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs);
}
private requestStopWork(command: OmpRpcCommand): Promise<void> {
return this.process.requestStopWork(OmpRpcCommandSchema.parse(command));
}
private emit(event: OmpRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
}