diff --git a/README.md b/README.md
index c2349e72860a..eba25e0bb737 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).
-Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.
+Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Oh My Pi. If they're set up on your computer, T3 Code can control them.
## "Wait, what are you selling me?"
@@ -13,13 +13,14 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
## Installation
> [!WARNING]
-> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
+> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode and Oh My Pi. Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
+> - OMP: install [OMP](https://omp.sh), run `omp`, and use `/login` to configure a model provider
### Try it out (install-free)
diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx
index bdddf2c45951..3c2b650b1738 100644
--- a/apps/mobile/src/components/ProviderIcon.tsx
+++ b/apps/mobile/src/components/ProviderIcon.tsx
@@ -1,6 +1,5 @@
import { useColorScheme } from "react-native";
-import { Path, Svg } from "react-native-svg";
-
+import { Circle, Path, Rect, Svg } from "react-native-svg";
type ProviderIconProps = {
readonly provider: string | null | undefined;
readonly size?: number;
@@ -58,6 +57,21 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}
+ if (props.provider === "omp") {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
// codex (and unknown drivers)
return (
diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts
index bc7828dd8547..5da6e704b790 100644
--- a/apps/server/scripts/acp-mock-agent.ts
+++ b/apps/server/scripts/acp-mock-agent.ts
@@ -18,6 +18,9 @@ const emitInterleavedAssistantToolCalls =
process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
+const emitElicitation = process.env.T3_ACP_EMIT_ELICITATION === "1";
+const emitUnsupportedElicitation = process.env.T3_ACP_EMIT_UNSUPPORTED_ELICITATION === "1";
+const extraModelId = process.env.T3_ACP_EXTRA_MODEL_ID?.trim();
const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1";
const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";
@@ -219,6 +222,7 @@ function configOptions(): ReadonlyArray {
{ value: "composer-2", name: "Composer 2" },
{ value: "composer-2[fast=true]", name: "Composer 2 Fast" },
{ value: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" },
+ ...(extraModelId ? [{ value: extraModelId, name: extraModelId }] : []),
],
},
];
@@ -772,6 +776,25 @@ const program = Effect.gen(function* () {
return { stopReason: "end_turn" };
}
+ if (emitElicitation) {
+ yield* agent.client.elicit({
+ mode: "form",
+ sessionId: requestedSessionId,
+ message: "Choose an OMP strategy",
+ requestedSchema: {
+ type: "object",
+ required: ["strategy"],
+ properties: {
+ strategy: {
+ type: "string",
+ title: "Strategy",
+ enum: ["safe", "fast"],
+ },
+ },
+ },
+ });
+ return { stopReason: "end_turn" };
+ }
if (emitXAiAskUserQuestion) {
const result = yield* agent.client.extRequest("_x.ai/ask_user_question", {
@@ -810,6 +833,25 @@ const program = Effect.gen(function* () {
return { stopReason: "end_turn" };
}
+ if (emitUnsupportedElicitation) {
+ yield* agent.client.elicit({
+ mode: "form",
+ sessionId: requestedSessionId,
+ message: "Describe the requested change",
+ requestedSchema: {
+ type: "object",
+ required: ["description"],
+ properties: {
+ description: {
+ type: "string",
+ title: "Description",
+ },
+ },
+ },
+ });
+ return { stopReason: "end_turn" };
+ }
+
if (emitForeignSessionUpdates) {
yield* agent.client.sessionUpdate({
sessionId: requestedSessionId,
diff --git a/apps/server/src/provider/Drivers/OmpDriver.ts b/apps/server/src/provider/Drivers/OmpDriver.ts
new file mode 100644
index 000000000000..c54a81135e19
--- /dev/null
+++ b/apps/server/src/provider/Drivers/OmpDriver.ts
@@ -0,0 +1,183 @@
+/**
+ * OmpDriver — provider driver for the Oh My Pi (`omp`) ACP runtime.
+ */
+import { OmpSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
+import * as Crypto from "effect/Crypto";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import { HttpClient } from "effect/unstable/http";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+import { ServerConfig } from "../../config.ts";
+import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
+import { ServerSettingsService } from "../../serverSettings.ts";
+import { makeOmpTextGeneration } from "../../textGeneration/OmpTextGeneration.ts";
+import { ProviderDriverError } from "../Errors.ts";
+import { makeOmpAdapter } from "../Layers/OmpAdapter.ts";
+import { checkOmpProviderStatus, makePendingOmpProvider } from "../Layers/OmpProvider.ts";
+import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
+import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
+import {
+ defaultProviderContinuationIdentity,
+ type ProviderDriver,
+ type ProviderInstance,
+} from "../ProviderDriver.ts";
+import type { ServerProviderDraft } from "../providerSnapshot.ts";
+import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
+import {
+ enrichProviderSnapshotWithVersionAdvisory,
+ makePackageManagedProviderMaintenanceResolver,
+ normalizeCommandPath,
+ resolveProviderMaintenanceCapabilitiesEffect,
+} from "../providerMaintenance.ts";
+import {
+ haveProviderSnapshotSettingsChanged,
+ makeProviderSnapshotSettingsSource,
+ type ProviderSnapshotSettings,
+} from "../providerUpdateSettings.ts";
+
+const decodeOmpSettings = Schema.decodeSync(OmpSettings);
+const DRIVER_KIND = ProviderDriverKind.make("omp");
+const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
+
+function isOmpNativeCommandPath(commandPath: string): boolean {
+ const normalized = normalizeCommandPath(commandPath);
+ return (
+ normalized.endsWith("/.local/bin/omp") ||
+ normalized.endsWith("/.local/bin/omp.exe") ||
+ normalized.endsWith("/.omp/bin/omp") ||
+ normalized.endsWith("/.omp/bin/omp.exe")
+ );
+}
+
+const UPDATE = makePackageManagedProviderMaintenanceResolver({
+ provider: DRIVER_KIND,
+ npmPackageName: "@oh-my-pi/pi-coding-agent",
+ homebrewFormula: "can1357/tap/omp",
+ nativeUpdate: {
+ executable: "omp",
+ args: ["update"],
+ lockKey: "omp-native",
+ isCommandPath: isOmpNativeCommandPath,
+ },
+});
+
+export type OmpDriverEnv =
+ | BackgroundPolicy.BackgroundPolicy
+ | ChildProcessSpawner.ChildProcessSpawner
+ | Crypto.Crypto
+ | FileSystem.FileSystem
+ | HttpClient.HttpClient
+ | Path.Path
+ | ProviderEventLoggers
+ | ServerConfig
+ | ServerSettingsService;
+
+const withInstanceIdentity =
+ (input: {
+ readonly instanceId: ProviderInstance["instanceId"];
+ readonly displayName: string | undefined;
+ readonly accentColor: string | undefined;
+ readonly continuationGroupKey: string;
+ }) =>
+ (snapshot: ServerProviderDraft): ServerProvider => ({
+ ...snapshot,
+ instanceId: input.instanceId,
+ driver: DRIVER_KIND,
+ ...(input.displayName ? { displayName: input.displayName } : {}),
+ ...(input.accentColor ? { accentColor: input.accentColor } : {}),
+ continuation: { groupKey: input.continuationGroupKey },
+ });
+
+export const OmpDriver: ProviderDriver = {
+ driverKind: DRIVER_KIND,
+ metadata: {
+ displayName: "OMP",
+ supportsMultipleInstances: true,
+ },
+ configSchema: OmpSettings,
+ defaultConfig: (): OmpSettings => decodeOmpSettings({}),
+ create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
+ Effect.gen(function* () {
+ const crypto = yield* Crypto.Crypto;
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const httpClient = yield* HttpClient.HttpClient;
+ const serverSettings = yield* ServerSettingsService;
+ const eventLoggers = yield* ProviderEventLoggers;
+ const processEnv = mergeProviderInstanceEnvironment(environment);
+ const continuationIdentity = defaultProviderContinuationIdentity({
+ driverKind: DRIVER_KIND,
+ instanceId,
+ });
+ const stampIdentity = withInstanceIdentity({
+ instanceId,
+ displayName,
+ accentColor,
+ continuationGroupKey: continuationIdentity.continuationKey,
+ });
+ const effectiveConfig = { ...config, enabled } satisfies OmpSettings;
+ const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
+ binaryPath: effectiveConfig.binaryPath,
+ env: processEnv,
+ });
+
+ const adapter = yield* makeOmpAdapter(effectiveConfig, {
+ environment: processEnv,
+ ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
+ instanceId,
+ });
+ const textGeneration = yield* makeOmpTextGeneration(effectiveConfig, processEnv);
+
+ const checkProvider = checkOmpProviderStatus(effectiveConfig, processEnv).pipe(
+ Effect.map(stampIdentity),
+ Effect.provideService(Crypto.Crypto, crypto),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
+ Effect.provideService(Path.Path, path),
+ );
+ const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
+ const snapshot = yield* makeManagedServerProvider>({
+ maintenanceCapabilities,
+ getSettings: snapshotSettings.getSettings,
+ streamSettings: snapshotSettings.streamSettings,
+ haveSettingsChanged: haveProviderSnapshotSettingsChanged,
+ initialSnapshot: (settings) =>
+ makePendingOmpProvider(settings.provider).pipe(Effect.map(stampIdentity)),
+ checkProvider,
+ enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
+ enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
+ enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
+ }).pipe(
+ Effect.provideService(HttpClient.HttpClient, httpClient),
+ Effect.flatMap(publishSnapshot),
+ ),
+ refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
+ }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: "Failed to build OMP snapshot.",
+ cause,
+ }),
+ ),
+ );
+
+ return {
+ instanceId,
+ driverKind: DRIVER_KIND,
+ continuationIdentity,
+ displayName,
+ accentColor,
+ enabled,
+ snapshot,
+ adapter,
+ textGeneration,
+ } satisfies ProviderInstance;
+ }),
+};
diff --git a/apps/server/src/provider/Layers/AcpAdapter.ts b/apps/server/src/provider/Layers/AcpAdapter.ts
new file mode 100644
index 000000000000..dcdaff0025b1
--- /dev/null
+++ b/apps/server/src/provider/Layers/AcpAdapter.ts
@@ -0,0 +1,1228 @@
+/**
+ * Provider-neutral ACP adapter lifecycle and factory.
+ *
+ * @module AcpAdapter
+ */
+
+import {
+ ApprovalRequestId,
+ type ProviderOptionSelection,
+ EventId,
+ type ProviderApprovalDecision,
+ type ProviderInteractionMode,
+ type ProviderRuntimeEvent,
+ type ProviderSession,
+ type ProviderUserInputAnswers,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ RuntimeRequestId,
+ type RuntimeMode,
+ type ThreadId,
+ TurnId,
+} from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+import * as Crypto from "effect/Crypto";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Fiber from "effect/Fiber";
+import * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as PubSub from "effect/PubSub";
+import * as Schema from "effect/Schema";
+import * as Scope from "effect/Scope";
+import * as Semaphore from "effect/Semaphore";
+import * as Stream from "effect/Stream";
+import * as SynchronizedRef from "effect/SynchronizedRef";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+import * as EffectAcpErrors from "effect-acp/errors";
+import type * as EffectAcpSchema from "effect-acp/schema";
+import { resolveAttachmentPath } from "../../attachmentStore.ts";
+import { ServerConfig } from "../../config.ts";
+import {
+ ProviderAdapterProcessError,
+ ProviderAdapterRequestError,
+ ProviderAdapterSessionNotFoundError,
+ ProviderAdapterValidationError,
+ type ProviderAdapterError,
+} from "../Errors.ts";
+import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts";
+import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts";
+import {
+ makeAcpAssistantItemEvent,
+ makeAcpContentDeltaEvent,
+ makeAcpPlanUpdatedEvent,
+ makeAcpRequestOpenedEvent,
+ makeAcpRequestResolvedEvent,
+ makeAcpToolCallEvent,
+} from "../acp/AcpCoreRuntimeEvents.ts";
+import {
+ type AcpSessionMode,
+ type AcpSessionModeState,
+ parsePermissionRequest,
+} from "../acp/AcpRuntimeModel.ts";
+import { buildAcpElicitationForm } from "../acp/AcpElicitation.ts";
+import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
+import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
+import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
+
+type AcpSessionRuntimeOptions = AcpSessionRuntime.AcpSessionRuntimeOptions;
+type AcpSessionRuntimeShape = AcpSessionRuntime.AcpSessionRuntime["Service"];
+
+const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
+const ACP_RESUME_VERSION = 1 as const;
+const ACP_PLAN_MODE_ALIASES = ["plan", "architect"];
+const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"];
+const ACP_APPROVAL_MODE_ALIASES = ["ask"];
+
+function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
+ const result = encodeUnknownJsonStringExit(input);
+ return Exit.isSuccess(result) ? result.value : undefined;
+}
+
+export interface AcpProviderAdapterLiveOptions {
+ readonly environment?: NodeJS.ProcessEnv;
+ readonly nativeEventLogPath?: string;
+ readonly nativeEventLogger?: EventNdjsonLogger;
+ /**
+ * Selections are honored when `modelSelection.instanceId` matches this value.
+ * Defaults to the legacy built-in instance id (`cursor`).
+ */
+ readonly instanceId?: typeof ProviderInstanceId.Type;
+ /**
+ * Optional per-session settings resolver. When provided the adapter yields
+ * this effect at the start of every session and uses the result instead of
+ * the settings captured at construction.
+ *
+ * Production instances bind settings to the instance scope (the hydration
+ * layer rebuilds the adapter on config change) and leave this undefined.
+ * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to
+ * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads
+ * the latest snapshot so the closure isn't stale.
+ */
+ readonly resolveSettings?: Effect.Effect;
+}
+
+interface AcpProviderRuntimeFactoryInput {
+ readonly settings: Settings;
+ readonly environment?: NodeJS.ProcessEnv;
+ readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
+ readonly cwd: string;
+ readonly threadId: ThreadId;
+ readonly runtimeMode: RuntimeMode;
+ readonly resumeSessionId?: string;
+ readonly clientInfo: AcpSessionRuntimeOptions["clientInfo"];
+ readonly nativeLoggers: Pick;
+}
+
+interface AcpProviderModelSelectionInput {
+ readonly runtime: AcpSessionRuntimeShape;
+ readonly threadId: ThreadId;
+ readonly model: string;
+ readonly selections: ReadonlyArray | null | undefined;
+}
+
+export interface AcpProviderAdapterDefinition {
+ readonly provider: typeof ProviderDriverKind.Type;
+ readonly defaultInstanceId: typeof ProviderInstanceId.Type;
+ readonly displayName: string;
+ readonly settings: Settings;
+ readonly options?: AcpProviderAdapterLiveOptions;
+ readonly registerExtensions?: (
+ input: AcpProviderExtensionRegistrationInput,
+ ) => Effect.Effect;
+ readonly userInputRequestMethod?: string;
+ readonly supportsRollback?: boolean;
+ readonly shouldAutoApprovePermission?: (input: {
+ readonly runtimeMode: RuntimeMode;
+ readonly permissionKind: string | "unknown";
+ }) => boolean;
+ readonly makeRuntime: (
+ input: AcpProviderRuntimeFactoryInput,
+ ) => Effect.Effect;
+ readonly applyModelSelection: (
+ input: AcpProviderModelSelectionInput,
+ ) => Effect.Effect;
+ readonly resolveModelId: (model: string | null | undefined) => string;
+}
+
+interface PendingApproval {
+ readonly decision: Deferred.Deferred;
+ readonly kind: string | "unknown";
+}
+
+interface PendingUserInput {
+ readonly answers: Deferred.Deferred;
+}
+
+export interface AcpSessionContext {
+ readonly threadId: ThreadId;
+ session: ProviderSession;
+ readonly scope: Scope.Closeable;
+ readonly acp: AcpSessionRuntimeShape;
+ notificationFiber: Fiber.Fiber | undefined;
+ readonly pendingApprovals: Map;
+ readonly pendingUserInputs: Map;
+ readonly turns: Array<{ id: TurnId; items: Array }>;
+ lastPlanFingerprint: string | undefined;
+ activeTurnId: TurnId | undefined;
+ promptsInFlight: number;
+ stopped: boolean;
+}
+
+export interface AcpProviderExtensionRegistrationInput {
+ readonly acp: AcpSessionRuntimeShape;
+ readonly threadId: ThreadId;
+ readonly getContext: () => AcpSessionContext | undefined;
+ readonly pendingUserInputs: Map;
+ readonly randomUUIDv4: Effect.Effect;
+ readonly makeEventStamp: () => Effect.Effect<
+ { eventId: EventId; createdAt: string },
+ ProviderAdapterRequestError,
+ never
+ >;
+ readonly offerRuntimeEvent: (event: ProviderRuntimeEvent) => Effect.Effect;
+ readonly mapExtensionFailure: (
+ effect: Effect.Effect ,
+ ) => Effect.Effect ;
+ readonly logNative: (
+ threadId: ThreadId,
+ method: string,
+ payload: unknown,
+ source: "acp.jsonrpc" | `acp.${string}.extension`,
+ ) => Effect.Effect;
+ readonly emitPlanUpdate: (
+ context: AcpSessionContext,
+ payload: {
+ readonly explanation?: string | null;
+ readonly plan: ReadonlyArray<{
+ readonly step: string;
+ readonly status: "pending" | "inProgress" | "completed";
+ }>;
+ },
+ rawPayload: unknown,
+ source: "acp.jsonrpc" | `acp.${string}.extension`,
+ method: string,
+ ) => Effect.Effect;
+}
+
+function settlePendingApprovalsAsCancelled(
+ pendingApprovals: ReadonlyMap,
+): Effect.Effect {
+ const pendingEntries = Array.from(pendingApprovals.values());
+ return Effect.forEach(
+ pendingEntries,
+ (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore),
+ {
+ discard: true,
+ },
+ );
+}
+
+function settlePendingUserInputsAsEmptyAnswers(
+ pendingUserInputs: ReadonlyMap,
+): Effect.Effect {
+ const pendingEntries = Array.from(pendingUserInputs.values());
+ return Effect.forEach(
+ pendingEntries,
+ (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore),
+ {
+ discard: true,
+ },
+ );
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function parseAcpResume(raw: unknown): { sessionId: string } | undefined {
+ if (!isRecord(raw)) return undefined;
+ if (raw.schemaVersion !== ACP_RESUME_VERSION) return undefined;
+ if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined;
+ return { sessionId: raw.sessionId.trim() };
+}
+
+function normalizeModeSearchText(mode: AcpSessionMode): string {
+ return [mode.id, mode.name, mode.description]
+ .filter((value): value is string => typeof value === "string" && value.length > 0)
+ .join(" ")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, " ")
+ .trim();
+}
+
+function findModeByAliases(
+ modes: ReadonlyArray,
+ aliases: ReadonlyArray,
+): AcpSessionMode | undefined {
+ const normalizedAliases = aliases.map((alias) => alias.toLowerCase());
+ for (const alias of normalizedAliases) {
+ const exact = modes.find((mode) => {
+ const id = mode.id.toLowerCase();
+ const name = mode.name.toLowerCase();
+ return id === alias || name === alias;
+ });
+ if (exact) {
+ return exact;
+ }
+ }
+ for (const alias of normalizedAliases) {
+ const partial = modes.find((mode) => normalizeModeSearchText(mode).split(" ").includes(alias));
+ if (partial) {
+ return partial;
+ }
+ }
+ return undefined;
+}
+
+function isPlanMode(mode: AcpSessionMode): boolean {
+ return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined;
+}
+
+function resolveRequestedModeId(input: {
+ readonly interactionMode: ProviderInteractionMode | undefined;
+ readonly runtimeMode: RuntimeMode;
+ readonly modeState: AcpSessionModeState | undefined;
+}): string | undefined {
+ const modeState = input.modeState;
+ if (!modeState) {
+ return undefined;
+ }
+
+ if (input.interactionMode === "plan") {
+ return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id;
+ }
+
+ if (input.runtimeMode === "approval-required") {
+ return (
+ findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
+ findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
+ modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
+ modeState.currentModeId
+ );
+ }
+
+ return (
+ findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
+ findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
+ modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
+ modeState.currentModeId
+ );
+}
+
+function applyRequestedSessionConfiguration(input: {
+ readonly runtime: AcpSessionRuntimeShape;
+ readonly runtimeMode: RuntimeMode;
+ readonly interactionMode: ProviderInteractionMode | undefined;
+ readonly modelSelection:
+ | {
+ readonly model: string;
+ readonly options?: ReadonlyArray | null | undefined;
+ }
+ | undefined;
+ readonly applyModelSelection: (input: {
+ readonly runtime: AcpSessionRuntimeShape;
+ readonly model: string;
+ readonly selections: ReadonlyArray | null | undefined;
+ }) => Effect.Effect;
+ readonly mapError: (context: {
+ readonly cause: import("effect-acp/errors").AcpError;
+ readonly method: "session/set_config_option" | "session/set_mode";
+ }) => E;
+}): Effect.Effect {
+ return Effect.gen(function* () {
+ if (input.modelSelection) {
+ yield* input.applyModelSelection({
+ runtime: input.runtime,
+ model: input.modelSelection.model,
+ selections: input.modelSelection.options,
+ });
+ }
+
+ const requestedModeId = resolveRequestedModeId({
+ interactionMode: input.interactionMode,
+ runtimeMode: input.runtimeMode,
+ modeState: yield* input.runtime.getModeState,
+ });
+ if (!requestedModeId) {
+ return;
+ }
+
+ yield* input.runtime.setMode(requestedModeId).pipe(
+ Effect.mapError((cause) =>
+ input.mapError({
+ cause,
+ method: "session/set_mode",
+ }),
+ ),
+ );
+ });
+}
+
+function selectAutoApprovedPermissionOption(
+ request: EffectAcpSchema.RequestPermissionRequest,
+): string | undefined {
+ const desiredKinds = ["allow_always", "allow_once"] as const;
+ for (const kind of desiredKinds) {
+ const option = request.options.find((candidate) => candidate.kind === kind);
+ if (typeof option?.optionId === "string" && option.optionId.trim()) {
+ return option.optionId.trim();
+ }
+ }
+
+ const aliases = new Set(desiredKinds.flatMap((kind) => [kind, kind.replaceAll("_", "-")]));
+ return request.options.find((option) => aliases.has(option.optionId))?.optionId;
+}
+
+export function makeAcpProviderAdapter(
+ definition: AcpProviderAdapterDefinition,
+) {
+ return Effect.gen(function* () {
+ const PROVIDER = definition.provider;
+ const options = definition.options;
+ const boundInstanceId = options?.instanceId ?? definition.defaultInstanceId;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const serverConfig = yield* Effect.service(ServerConfig);
+ const crypto = yield* Crypto.Crypto;
+ const nativeEventLogger =
+ options?.nativeEventLogger ??
+ (options?.nativeEventLogPath !== undefined
+ ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
+ stream: "native",
+ })
+ : undefined);
+ const managedNativeEventLogger =
+ options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
+ const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory();
+
+ const sessions = new Map();
+ const threadLocksRef = yield* SynchronizedRef.make(new Map());
+ const runtimeEventPubSub = yield* PubSub.unbounded();
+
+ const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
+ const randomUUIDv4 = crypto.randomUUIDv4.pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "crypto/randomUUIDv4",
+ detail: `Failed to generate ${definition.displayName} runtime identifier.`,
+ cause,
+ }),
+ ),
+ );
+ const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id));
+ const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });
+ const mapExtensionFailure = (effect: Effect.Effect ) =>
+ effect.pipe(
+ Effect.mapError(
+ (cause) =>
+ new EffectAcpErrors.AcpTransportError({
+ detail: `Failed to process ${definition.displayName} ACP extension event.`,
+ cause,
+ }),
+ ),
+ );
+
+ const offerRuntimeEvent = (event: ProviderRuntimeEvent) =>
+ PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid);
+
+ const getThreadSemaphore = (threadId: string) =>
+ SynchronizedRef.modifyEffect(threadLocksRef, (current) => {
+ const existing: Option.Option = Option.fromNullishOr(
+ current.get(threadId),
+ );
+ return Option.match(existing, {
+ onNone: () =>
+ Semaphore.make(1).pipe(
+ Effect.map((semaphore) => {
+ const next = new Map(current);
+ next.set(threadId, semaphore);
+ return [semaphore, next] as const;
+ }),
+ ),
+ onSome: (semaphore) => Effect.succeed([semaphore, current] as const),
+ });
+ });
+
+ const withThreadLock = (threadId: string, effect: Effect.Effect ) =>
+ Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
+
+ const logNative = (
+ threadId: ThreadId,
+ method: string,
+ payload: unknown,
+ _source: "acp.jsonrpc" | `acp.${string}.extension`,
+ ) =>
+ Effect.gen(function* () {
+ if (!nativeEventLogger) return;
+ const observedAt = yield* nowIso;
+ yield* nativeEventLogger.write(
+ {
+ observedAt,
+ event: {
+ id: yield* randomUUIDv4,
+ kind: "notification",
+ provider: PROVIDER,
+ createdAt: observedAt,
+ method,
+ threadId,
+ payload,
+ },
+ },
+ threadId,
+ );
+ });
+
+ const emitPlanUpdate = (
+ ctx: AcpSessionContext,
+ payload: {
+ readonly explanation?: string | null;
+ readonly plan: ReadonlyArray<{
+ readonly step: string;
+ readonly status: "pending" | "inProgress" | "completed";
+ }>;
+ },
+ rawPayload: unknown,
+ source: "acp.jsonrpc" | `acp.${string}.extension`,
+ method: string,
+ ) =>
+ Effect.gen(function* () {
+ const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`;
+ if (ctx.lastPlanFingerprint === fingerprint) {
+ return;
+ }
+ ctx.lastPlanFingerprint = fingerprint;
+ yield* offerRuntimeEvent(
+ makeAcpPlanUpdatedEvent({
+ stamp: yield* makeEventStamp(),
+ provider: PROVIDER,
+ threadId: ctx.threadId,
+ turnId: ctx.activeTurnId,
+ payload,
+ source,
+ method,
+ rawPayload,
+ }),
+ );
+ });
+
+ const requireSession = (
+ threadId: ThreadId,
+ ): Effect.Effect => {
+ const ctx = sessions.get(threadId);
+ if (!ctx || ctx.stopped) {
+ return Effect.fail(
+ new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }),
+ );
+ }
+ return Effect.succeed(ctx);
+ };
+
+ const stopSessionInternal = (ctx: AcpSessionContext) =>
+ Effect.gen(function* () {
+ if (ctx.stopped) return;
+ ctx.stopped = true;
+ yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
+ yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
+ if (ctx.notificationFiber) {
+ yield* Fiber.interrupt(ctx.notificationFiber);
+ }
+ yield* Effect.ignore(Scope.close(ctx.scope, Exit.void));
+ sessions.delete(ctx.threadId);
+ yield* offerRuntimeEvent({
+ type: "session.exited",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ threadId: ctx.threadId,
+ payload: { exitKind: "graceful" },
+ });
+ });
+
+ const startSession: ProviderAdapterShape["startSession"] = (input) =>
+ withThreadLock(
+ input.threadId,
+ Effect.gen(function* () {
+ if (input.provider !== undefined && input.provider !== PROVIDER) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "startSession",
+ issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
+ });
+ }
+ if (!input.cwd?.trim()) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "startSession",
+ issue: "cwd is required and must be non-empty.",
+ });
+ }
+
+ const cwd = path.resolve(input.cwd.trim());
+ const selectedModel =
+ input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
+ const existing = sessions.get(input.threadId);
+ if (existing && !existing.stopped) {
+ yield* stopSessionInternal(existing);
+ }
+
+ const pendingApprovals = new Map();
+ const pendingUserInputs = new Map();
+ const sessionScope = yield* Scope.make("sequential");
+ let sessionScopeTransferred = false;
+ yield* Effect.addFinalizer(() =>
+ sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void),
+ );
+ let ctx!: AcpSessionContext;
+
+ const resumeSessionId = parseAcpResume(input.resumeCursor)?.sessionId;
+ const acpNativeLoggers = makeAcpNativeLoggers({
+ nativeEventLogger,
+ provider: PROVIDER,
+ threadId: input.threadId,
+ });
+
+ // Resolve the provider settings used to spawn the ACP child. Production
+ // leaves `options.resolveSettings` undefined so we use the value
+ // captured at adapter construction — per-instance isolation is
+ // enforced by the hydration layer rebuilding this adapter whenever
+ // its config changes. Tests set `resolveSettings` to pull the latest
+ // snapshot from `ServerSettingsService` so that mid-suite
+ // `updateSettings({ providers: { cursor: { binaryPath } } })` calls
+ // actually take effect when the next session spawns.
+ const effectiveSettings = options?.resolveSettings
+ ? yield* options.resolveSettings
+ : definition.settings;
+
+ const acp = yield* definition
+ .makeRuntime({
+ settings: effectiveSettings,
+ ...(options?.environment ? { environment: options.environment } : {}),
+ childProcessSpawner,
+ cwd,
+ threadId: input.threadId,
+ runtimeMode: input.runtimeMode,
+ ...(resumeSessionId ? { resumeSessionId } : {}),
+ clientInfo: { name: "t3-code", version: "0.0.0" },
+ nativeLoggers: acpNativeLoggers,
+ })
+ .pipe(
+ Effect.provideService(Crypto.Crypto, crypto),
+ Effect.provideService(Scope.Scope, sessionScope),
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterProcessError({
+ provider: PROVIDER,
+ threadId: input.threadId,
+ detail: cause.message,
+ cause,
+ }),
+ ),
+ );
+ const started = yield* Effect.gen(function* () {
+ yield* acp.handleElicitation((params) =>
+ mapExtensionFailure(
+ Effect.gen(function* () {
+ yield* logNative(input.threadId, "session/elicitation", params, "acp.jsonrpc");
+ const form = buildAcpElicitationForm(params);
+ if (!form) {
+ return { action: { action: "cancel" as const } };
+ }
+ const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
+ const runtimeRequestId = RuntimeRequestId.make(requestId);
+ const answers = yield* Deferred.make();
+ pendingUserInputs.set(requestId, { answers });
+ yield* offerRuntimeEvent({
+ type: "user-input.requested",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ turnId: ctx?.activeTurnId,
+ requestId: runtimeRequestId,
+ payload: { questions: form.questions },
+ raw: {
+ source: "acp.jsonrpc",
+ method: "session/elicitation",
+ payload: params,
+ },
+ });
+ const resolved = yield* Deferred.await(answers);
+ pendingUserInputs.delete(requestId);
+ yield* offerRuntimeEvent({
+ type: "user-input.resolved",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ turnId: ctx?.activeTurnId,
+ requestId: runtimeRequestId,
+ payload: { answers: resolved },
+ });
+ return form.resolve(resolved);
+ }),
+ ),
+ );
+ if (definition.registerExtensions) {
+ yield* definition.registerExtensions({
+ acp,
+ threadId: input.threadId,
+ getContext: () => ctx,
+ pendingUserInputs,
+ randomUUIDv4,
+ makeEventStamp,
+ offerRuntimeEvent,
+ mapExtensionFailure,
+ logNative,
+ emitPlanUpdate,
+ });
+ }
+ yield* acp.handleRequestPermission((params) =>
+ mapExtensionFailure(
+ Effect.gen(function* () {
+ yield* logNative(
+ input.threadId,
+ "session/request_permission",
+ params,
+ "acp.jsonrpc",
+ );
+ const permissionRequest = parsePermissionRequest(params);
+ const autoApprove =
+ input.runtimeMode === "full-access" ||
+ definition.shouldAutoApprovePermission?.({
+ runtimeMode: input.runtimeMode,
+ permissionKind: permissionRequest.kind,
+ }) === true;
+ if (autoApprove) {
+ const autoApprovedOptionId = selectAutoApprovedPermissionOption(params);
+ if (autoApprovedOptionId !== undefined) {
+ return {
+ outcome: {
+ outcome: "selected" as const,
+ optionId: autoApprovedOptionId,
+ },
+ };
+ }
+ }
+ const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
+ const runtimeRequestId = RuntimeRequestId.make(requestId);
+ const decision = yield* Deferred.make();
+ pendingApprovals.set(requestId, {
+ decision,
+ kind: permissionRequest.kind,
+ });
+ yield* offerRuntimeEvent(
+ makeAcpRequestOpenedEvent({
+ stamp: yield* makeEventStamp(),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ turnId: ctx?.activeTurnId,
+ requestId: runtimeRequestId,
+ permissionRequest,
+ detail:
+ permissionRequest.detail ??
+ encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ??
+ "[unserializable params]",
+ args: params,
+ source: "acp.jsonrpc",
+ method: "session/request_permission",
+ rawPayload: params,
+ }),
+ );
+ const resolved = yield* Deferred.await(decision);
+ pendingApprovals.delete(requestId);
+ yield* offerRuntimeEvent(
+ makeAcpRequestResolvedEvent({
+ stamp: yield* makeEventStamp(),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ turnId: ctx?.activeTurnId,
+ requestId: runtimeRequestId,
+ permissionRequest,
+ decision: resolved,
+ }),
+ );
+ if (resolved === "cancel") {
+ return { outcome: { outcome: "cancelled" as const } };
+ }
+ const optionId = acpPermissionOutcome(resolved, params.options);
+ return optionId === undefined
+ ? { outcome: { outcome: "cancelled" as const } }
+ : { outcome: { outcome: "selected" as const, optionId } };
+ }),
+ ),
+ );
+ return yield* acp.start();
+ }).pipe(
+ Effect.mapError((error) =>
+ mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error),
+ ),
+ );
+
+ yield* applyRequestedSessionConfiguration({
+ runtime: acp,
+ runtimeMode: input.runtimeMode,
+ interactionMode: undefined,
+ modelSelection: selectedModel,
+ applyModelSelection: ({ runtime, model, selections }) =>
+ definition.applyModelSelection({
+ runtime,
+ threadId: input.threadId,
+ model,
+ selections,
+ }),
+ mapError: ({ cause, method }) =>
+ mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),
+ });
+
+ const now = yield* nowIso;
+ const session: ProviderSession = {
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ status: "ready",
+ runtimeMode: input.runtimeMode,
+ cwd,
+ model: selectedModel?.model,
+ threadId: input.threadId,
+ resumeCursor: {
+ schemaVersion: ACP_RESUME_VERSION,
+ sessionId: started.sessionId,
+ },
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ ctx = {
+ threadId: input.threadId,
+ session,
+ scope: sessionScope,
+ acp,
+ notificationFiber: undefined,
+ pendingApprovals,
+ pendingUserInputs,
+ turns: [],
+ lastPlanFingerprint: undefined,
+ activeTurnId: undefined,
+ promptsInFlight: 0,
+ stopped: false,
+ };
+
+ const nf = yield* Stream.runDrain(
+ Stream.mapEffect(acp.getEvents(), (event) =>
+ Effect.gen(function* () {
+ switch (event._tag) {
+ case "EventStreamBarrier":
+ yield* Deferred.succeed(event.acknowledge, undefined);
+ return;
+ case "AssistantItemStarted":
+ yield* offerRuntimeEvent(
+ makeAcpAssistantItemEvent({
+ stamp: yield* makeEventStamp(),
+ provider: PROVIDER,
+ threadId: ctx.threadId,
+ turnId: ctx.activeTurnId,
+ itemId: event.itemId,
+ lifecycle: "item.started",
+ }),
+ );
+ return;
+ case "AssistantItemCompleted":
+ yield* offerRuntimeEvent(
+ makeAcpAssistantItemEvent({
+ stamp: yield* makeEventStamp(),
+ provider: PROVIDER,
+ threadId: ctx.threadId,
+ turnId: ctx.activeTurnId,
+ itemId: event.itemId,
+ lifecycle: "item.completed",
+ }),
+ );
+ return;
+ case "PlanUpdated":
+ yield* logNative(
+ ctx.threadId,
+ "session/update",
+ event.rawPayload,
+ "acp.jsonrpc",
+ );
+ yield* emitPlanUpdate(
+ ctx,
+ event.payload,
+ event.rawPayload,
+ "acp.jsonrpc",
+ "session/update",
+ );
+ return;
+ case "ToolCallUpdated":
+ yield* logNative(
+ ctx.threadId,
+ "session/update",
+ event.rawPayload,
+ "acp.jsonrpc",
+ );
+ yield* offerRuntimeEvent(
+ makeAcpToolCallEvent({
+ stamp: yield* makeEventStamp(),
+ provider: PROVIDER,
+ threadId: ctx.threadId,
+ turnId: ctx.activeTurnId,
+ toolCall: event.toolCall,
+ rawPayload: event.rawPayload,
+ }),
+ );
+ return;
+ case "ContentDelta":
+ yield* logNative(
+ ctx.threadId,
+ "session/update",
+ event.rawPayload,
+ "acp.jsonrpc",
+ );
+ yield* offerRuntimeEvent(
+ makeAcpContentDeltaEvent({
+ stamp: yield* makeEventStamp(),
+ provider: PROVIDER,
+ threadId: ctx.threadId,
+ turnId: ctx.activeTurnId,
+ ...(event.itemId ? { itemId: event.itemId } : {}),
+ text: event.text,
+ rawPayload: event.rawPayload,
+ }),
+ );
+ return;
+ }
+ }),
+ ),
+ ).pipe(
+ Effect.catch((cause) =>
+ Effect.logError(`Failed to process ${definition.displayName} runtime notification.`, {
+ cause,
+ }),
+ ),
+ Effect.forkIn(sessionScope),
+ );
+
+ ctx.notificationFiber = nf;
+ sessions.set(input.threadId, ctx);
+ sessionScopeTransferred = true;
+
+ yield* offerRuntimeEvent({
+ type: "session.started",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ payload: { resume: started.initializeResult },
+ });
+ yield* offerRuntimeEvent({
+ type: "session.state.changed",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ payload: { state: "ready", reason: `${definition.displayName} ACP session ready` },
+ });
+ yield* offerRuntimeEvent({
+ type: "thread.started",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ payload: { providerThreadId: started.sessionId },
+ });
+
+ return session;
+ }).pipe(Effect.scoped),
+ );
+ const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => {
+ let cleanupContext: AcpSessionContext | undefined;
+ let promptIncremented = false;
+ return Effect.gen(function* () {
+ const { ctx, steeringTurnId, turnId } = yield* withThreadLock(
+ input.threadId,
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(input.threadId);
+ const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined;
+ const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4);
+ ctx.promptsInFlight += 1;
+ return { ctx, steeringTurnId, turnId };
+ }),
+ );
+ cleanupContext = ctx;
+ promptIncremented = true;
+ const turnModelSelection =
+ input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
+ const model = turnModelSelection?.model ?? ctx.session.model;
+ const resolvedModel = definition.resolveModelId(model);
+ yield* applyRequestedSessionConfiguration({
+ runtime: ctx.acp,
+ runtimeMode: ctx.session.runtimeMode,
+ interactionMode: input.interactionMode,
+ modelSelection:
+ model === undefined
+ ? undefined
+ : {
+ model,
+ options: turnModelSelection?.options,
+ },
+ applyModelSelection: ({ runtime, model: nextModel, selections }) =>
+ definition.applyModelSelection({
+ runtime,
+ threadId: input.threadId,
+ model: nextModel,
+ selections,
+ }),
+ mapError: ({ cause, method }) =>
+ mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),
+ });
+ ctx.activeTurnId = turnId;
+ if (steeringTurnId === undefined) {
+ ctx.lastPlanFingerprint = undefined;
+ }
+ ctx.session = {
+ ...ctx.session,
+ activeTurnId: turnId,
+ updatedAt: yield* nowIso,
+ };
+
+ if (steeringTurnId === undefined) {
+ yield* offerRuntimeEvent({
+ type: "turn.started",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ turnId,
+ payload: { model: resolvedModel },
+ });
+ }
+
+ const promptParts: Array = [];
+ if (input.input?.trim()) {
+ promptParts.push({ type: "text", text: input.input.trim() });
+ }
+ if (input.attachments && input.attachments.length > 0) {
+ for (const attachment of input.attachments) {
+ const attachmentPath = resolveAttachmentPath({
+ attachmentsDir: serverConfig.attachmentsDir,
+ attachment,
+ });
+ if (!attachmentPath) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "session/prompt",
+ detail: `Invalid attachment id '${attachment.id}'.`,
+ });
+ }
+ const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "session/prompt",
+ detail: cause.message,
+ cause,
+ }),
+ ),
+ );
+ promptParts.push({
+ type: "image",
+ data: Buffer.from(bytes).toString("base64"),
+ mimeType: attachment.mimeType,
+ });
+ }
+ }
+
+ if (promptParts.length === 0) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "sendTurn",
+ issue: "Turn requires non-empty text or attachments.",
+ });
+ }
+
+ const result = yield* ctx.acp
+ .prompt({
+ prompt: promptParts,
+ })
+ .pipe(
+ Effect.mapError((error) =>
+ mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
+ ),
+ );
+
+ const turnRecord = ctx.turns.find((turn) => turn.id === turnId);
+ if (turnRecord) {
+ turnRecord.items.push({ prompt: promptParts, result });
+ } else {
+ ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] });
+ }
+ ctx.session = {
+ ...ctx.session,
+ activeTurnId: turnId,
+ updatedAt: yield* nowIso,
+ model: resolvedModel,
+ };
+
+ if (ctx.promptsInFlight === 1) {
+ yield* offerRuntimeEvent({
+ type: "turn.completed",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ turnId,
+ payload: {
+ state: result.stopReason === "cancelled" ? "cancelled" : "completed",
+ stopReason: result.stopReason ?? null,
+ },
+ });
+ }
+
+ return {
+ threadId: input.threadId,
+ turnId,
+ resumeCursor: ctx.session.resumeCursor,
+ };
+ }).pipe(
+ Effect.ensuring(
+ Effect.sync(() => {
+ if (promptIncremented && cleanupContext) {
+ cleanupContext.promptsInFlight = Math.max(0, cleanupContext.promptsInFlight - 1);
+ }
+ }),
+ ),
+ );
+ };
+
+ const interruptTurn: ProviderAdapterShape["interruptTurn"] = (threadId) =>
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(threadId);
+ yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
+ yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
+ yield* Effect.ignore(
+ ctx.acp.cancel.pipe(
+ Effect.mapError((error) =>
+ mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error),
+ ),
+ ),
+ );
+ });
+
+ const respondToRequest: ProviderAdapterShape["respondToRequest"] = (
+ threadId,
+ requestId,
+ decision,
+ ) =>
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(threadId);
+ const pending = ctx.pendingApprovals.get(requestId);
+ if (!pending) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "session/request_permission",
+ detail: `Unknown pending approval request: ${requestId}`,
+ });
+ }
+ yield* Deferred.succeed(pending.decision, decision);
+ });
+
+ const respondToUserInput: ProviderAdapterShape["respondToUserInput"] = (
+ threadId,
+ requestId,
+ answers,
+ ) =>
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(threadId);
+ const pending = ctx.pendingUserInputs.get(requestId);
+ if (!pending) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: definition.userInputRequestMethod ?? "session/elicitation",
+ detail: `Unknown pending user-input request: ${requestId}`,
+ });
+ }
+ yield* Deferred.succeed(pending.answers, answers);
+ });
+
+ const readThread: ProviderAdapterShape["readThread"] = (threadId) =>
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(threadId);
+ return { threadId, turns: ctx.turns };
+ });
+
+ const rollbackThread: ProviderAdapterShape["rollbackThread"] = (
+ threadId,
+ numTurns,
+ ) =>
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(threadId);
+ if (!Number.isInteger(numTurns) || numTurns < 1) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "rollbackThread",
+ issue: "numTurns must be an integer >= 1.",
+ });
+ }
+ if (definition.supportsRollback) {
+ const nextLength = Math.max(0, ctx.turns.length - numTurns);
+ ctx.turns.splice(nextLength);
+ return { threadId, turns: ctx.turns };
+ }
+ // ACP has no rollback operation. Do not trim local history while the
+ // provider retains the removed turns; return an explicit error instead.
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "session/rollback",
+ detail: `${definition.displayName} ACP sessions do not support rollback.`,
+ });
+ });
+
+ const stopSession: ProviderAdapterShape["stopSession"] = (threadId) =>
+ withThreadLock(
+ threadId,
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(threadId);
+ yield* stopSessionInternal(ctx);
+ }),
+ );
+
+ const listSessions: ProviderAdapterShape["listSessions"] = () =>
+ Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session })));
+
+ const hasSession: ProviderAdapterShape["hasSession"] = (threadId) =>
+ Effect.sync(() => {
+ const c = sessions.get(threadId);
+ return c !== undefined && !c.stopped;
+ });
+
+ const stopAll: ProviderAdapterShape["stopAll"] = () =>
+ Effect.forEach(sessions.values(), stopSessionInternal, { discard: true });
+
+ yield* Effect.addFinalizer(() =>
+ Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe(
+ Effect.catch((cause) =>
+ Effect.logError(`Failed to stop ${definition.displayName} sessions.`, { cause }),
+ ),
+ Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)),
+ Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void),
+ ),
+ );
+
+ const streamEvents = Stream.fromPubSub(runtimeEventPubSub);
+
+ return {
+ provider: PROVIDER,
+ capabilities: { sessionModelSwitch: "in-session" },
+ startSession,
+ sendTurn,
+ interruptTurn,
+ readThread,
+ rollbackThread,
+ respondToRequest,
+ respondToUserInput,
+ stopSession,
+ listSessions,
+ hasSession,
+ stopAll,
+ streamEvents,
+ } satisfies ProviderAdapterShape;
+ });
+}
diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts
index 491f718a9776..ee0c8fff9dae 100644
--- a/apps/server/src/provider/Layers/CursorAdapter.test.ts
+++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts
@@ -250,6 +250,31 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
}),
);
+ it.effect("preserves Cursor's local rollback behavior through the shared ACP adapter", () =>
+ Effect.gen(function* () {
+ const adapter = yield* CursorAdapter;
+ const settings = yield* ServerSettingsService;
+ const threadId = ThreadId.make("cursor-rollback-thread");
+ const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper());
+ yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("cursor"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
+ });
+ yield* adapter.sendTurn({ threadId, input: "first", attachments: [] });
+ yield* adapter.sendTurn({ threadId, input: "second", attachments: [] });
+
+ const rolledBack = yield* adapter.rollbackThread(threadId, 1);
+ assert.equal(rolledBack.turns.length, 1);
+
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () =>
Effect.gen(function* () {
const adapter = yield* CursorAdapter;
@@ -310,6 +335,8 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
});
const firstTurn = yield* Fiber.join(firstTurnFiber);
assert.equal(String(steeredTurn.turnId), String(firstTurn.turnId));
+ const thread = yield* adapter.readThread(threadId);
+ assert.equal(thread.turns.length, 1);
const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
const turnStartedEvents = runtimeEvents.filter((event) => event.type === "turn.started");
diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts
index 80475a5c2695..ba756c9cc2ca 100644
--- a/apps/server/src/provider/Layers/CursorAdapter.ts
+++ b/apps/server/src/provider/Layers/CursorAdapter.ts
@@ -7,64 +7,19 @@
import {
ApprovalRequestId,
type CursorSettings,
- type ProviderOptionSelection,
- EventId,
- type ProviderApprovalDecision,
- type ProviderInteractionMode,
- type ProviderRuntimeEvent,
- type ProviderSession,
- type ProviderUserInputAnswers,
ProviderDriverKind,
ProviderInstanceId,
RuntimeRequestId,
- type RuntimeMode,
- type ThreadId,
- TurnId,
+ type ProviderUserInputAnswers,
} from "@t3tools/contracts";
-import * as DateTime from "effect/DateTime";
-import * as Crypto from "effect/Crypto";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
-import * as Exit from "effect/Exit";
-import * as Fiber from "effect/Fiber";
-import * as FileSystem from "effect/FileSystem";
-import * as Option from "effect/Option";
-import * as Path from "effect/Path";
-import * as PubSub from "effect/PubSub";
-import * as Schema from "effect/Schema";
-import * as Scope from "effect/Scope";
-import * as Semaphore from "effect/Semaphore";
-import * as Stream from "effect/Stream";
-import * as SynchronizedRef from "effect/SynchronizedRef";
-import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
-import * as EffectAcpErrors from "effect-acp/errors";
-import type * as EffectAcpSchema from "effect-acp/schema";
-
-import { resolveAttachmentPath } from "../../attachmentStore.ts";
-import { ServerConfig } from "../../config.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
-import {
- ProviderAdapterProcessError,
- ProviderAdapterRequestError,
- ProviderAdapterSessionNotFoundError,
- ProviderAdapterValidationError,
-} from "../Errors.ts";
-import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts";
-import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts";
-import {
- makeAcpAssistantItemEvent,
- makeAcpContentDeltaEvent,
- makeAcpPlanUpdatedEvent,
- makeAcpRequestOpenedEvent,
- makeAcpRequestResolvedEvent,
- makeAcpToolCallEvent,
-} from "../acp/AcpCoreRuntimeEvents.ts";
-import {
- type AcpSessionMode,
- type AcpSessionModeState,
- parsePermissionRequest,
-} from "../acp/AcpRuntimeModel.ts";
-import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
+import type {
+ AcpProviderAdapterLiveOptions,
+ AcpProviderExtensionRegistrationInput,
+} from "./AcpAdapter.ts";
+import { makeAcpProviderAdapter } from "./AcpAdapter.ts";
import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts";
import {
CursorAskQuestionRequest,
@@ -74,1109 +29,177 @@ import {
extractPlanMarkdown,
extractTodosAsPlan,
} from "../acp/CursorAcpExtension.ts";
+import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts";
import { type CursorAdapterShape } from "../Services/CursorAdapter.ts";
import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";
-import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
-const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
const PROVIDER = ProviderDriverKind.make("cursor");
-const CURSOR_RESUME_VERSION = 1 as const;
-const ACP_PLAN_MODE_ALIASES = ["plan", "architect"];
-const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"];
-const ACP_APPROVAL_MODE_ALIASES = ["ask"];
-
-function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
- const result = encodeUnknownJsonStringExit(input);
- return Exit.isSuccess(result) ? result.value : undefined;
-}
-
-export interface CursorAdapterLiveOptions {
- readonly environment?: NodeJS.ProcessEnv;
- readonly nativeEventLogPath?: string;
- readonly nativeEventLogger?: EventNdjsonLogger;
- /**
- * Selections are honored when `modelSelection.instanceId` matches this value.
- * Defaults to the legacy built-in instance id (`cursor`).
- */
- readonly instanceId?: ProviderInstanceId;
- /**
- * Optional per-session settings resolver. When provided the adapter yields
- * this effect at the start of every session and uses the result instead of
- * the `cursorSettings` captured at construction.
- *
- * Production instances bind settings to the instance scope (the hydration
- * layer rebuilds the adapter on config change) and leave this undefined.
- * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to
- * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads
- * the latest snapshot so the closure isn't stale.
- */
- readonly resolveSettings?: Effect.Effect;
-}
-
-interface PendingApproval {
- readonly decision: Deferred.Deferred;
- readonly kind: string | "unknown";
-}
-
-interface PendingUserInput {
- readonly answers: Deferred.Deferred;
-}
-
-interface CursorSessionContext {
- readonly threadId: ThreadId;
- session: ProviderSession;
- readonly scope: Scope.Closeable;
- readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"];
- notificationFiber: Fiber.Fiber | undefined;
- readonly pendingApprovals: Map;
- readonly pendingUserInputs: Map;
- readonly turns: Array<{ id: TurnId; items: Array }>;
- lastPlanFingerprint: string | undefined;
- activeTurnId: TurnId | undefined;
- /** Number of sendTurn prompts currently in flight or being prepared.
- * >0 means a turn is actively running, so a new sendTurn is a steer that
- * continues it, and only the last remaining prompt settles the turn. */
- promptsInFlight: number;
- stopped: boolean;
-}
-
-function settlePendingApprovalsAsCancelled(
- pendingApprovals: ReadonlyMap,
-): Effect.Effect {
- const pendingEntries = Array.from(pendingApprovals.values());
- return Effect.forEach(
- pendingEntries,
- (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore),
- {
- discard: true,
- },
- );
-}
-
-function settlePendingUserInputsAsEmptyAnswers(
- pendingUserInputs: ReadonlyMap,
-): Effect.Effect {
- const pendingEntries = Array.from(pendingUserInputs.values());
- return Effect.forEach(
- pendingEntries,
- (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore),
- {
- discard: true,
- },
- );
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function parseCursorResume(raw: unknown): { sessionId: string } | undefined {
- if (!isRecord(raw)) return undefined;
- if (raw.schemaVersion !== CURSOR_RESUME_VERSION) return undefined;
- if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined;
- return { sessionId: raw.sessionId.trim() };
-}
-
-function normalizeModeSearchText(mode: AcpSessionMode): string {
- return [mode.id, mode.name, mode.description]
- .filter((value): value is string => typeof value === "string" && value.length > 0)
- .join(" ")
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, " ")
- .trim();
-}
-
-function findModeByAliases(
- modes: ReadonlyArray,
- aliases: ReadonlyArray,
-): AcpSessionMode | undefined {
- const normalizedAliases = aliases.map((alias) => alias.toLowerCase());
- for (const alias of normalizedAliases) {
- const exact = modes.find((mode) => {
- const id = mode.id.toLowerCase();
- const name = mode.name.toLowerCase();
- return id === alias || name === alias;
- });
- if (exact) {
- return exact;
- }
- }
- for (const alias of normalizedAliases) {
- const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias));
- if (partial) {
- return partial;
- }
- }
- return undefined;
-}
-
-function isPlanMode(mode: AcpSessionMode): boolean {
- return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined;
-}
-function resolveRequestedModeId(input: {
- readonly interactionMode: ProviderInteractionMode | undefined;
- readonly runtimeMode: RuntimeMode;
- readonly modeState: AcpSessionModeState | undefined;
-}): string | undefined {
- const modeState = input.modeState;
- if (!modeState) {
- return undefined;
- }
-
- if (input.interactionMode === "plan") {
- return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id;
- }
-
- if (input.runtimeMode === "approval-required") {
- return (
- findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
- findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
- modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
- modeState.currentModeId
- );
- }
+export type CursorAdapterLiveOptions = AcpProviderAdapterLiveOptions;
- return (
- findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
- findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
- modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
- modeState.currentModeId
- );
-}
-
-function applyRequestedSessionConfiguration(input: {
- readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"];
- readonly runtimeMode: RuntimeMode;
- readonly interactionMode: ProviderInteractionMode | undefined;
- readonly modelSelection:
- | {
- readonly model: string;
- readonly options?: ReadonlyArray | null | undefined;
- }
- | undefined;
- readonly mapError: (context: {
- readonly cause: import("effect-acp/errors").AcpError;
- readonly method: "session/set_config_option" | "session/set_mode";
- }) => E;
-}): Effect.Effect {
- return Effect.gen(function* () {
- if (input.modelSelection) {
- yield* applyCursorAcpModelSelection({
- runtime: input.runtime,
- model: input.modelSelection.model,
- selections: input.modelSelection.options,
- mapError: ({ cause }) =>
- input.mapError({
- cause,
- method: "session/set_config_option",
- }),
- });
- }
-
- const requestedModeId = resolveRequestedModeId({
- interactionMode: input.interactionMode,
- runtimeMode: input.runtimeMode,
- modeState: yield* input.runtime.getModeState,
- });
- if (!requestedModeId) {
- return;
- }
-
- yield* input.runtime.setMode(requestedModeId).pipe(
- Effect.mapError((cause) =>
- input.mapError({
- cause,
- method: "session/set_mode",
- }),
- ),
- );
- });
-}
-
-function selectAutoApprovedPermissionOption(
- request: EffectAcpSchema.RequestPermissionRequest,
-): string | undefined {
- const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always");
- if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) {
- return allowAlwaysOption.optionId.trim();
- }
-
- const allowOnceOption = request.options.find((option) => option.kind === "allow_once");
- if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) {
- return allowOnceOption.optionId.trim();
- }
-
- return undefined;
-}
-
-export function makeCursorAdapter(
- cursorSettings: CursorSettings,
- options?: CursorAdapterLiveOptions,
-) {
- return Effect.gen(function* () {
- const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor");
- const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
- const serverConfig = yield* Effect.service(ServerConfig);
- const crypto = yield* Crypto.Crypto;
- const nativeEventLogger =
- options?.nativeEventLogger ??
- (options?.nativeEventLogPath !== undefined
- ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
- stream: "native",
- })
- : undefined);
- const managedNativeEventLogger =
- options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
- const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory();
-
- const sessions = new Map();
- const threadLocksRef = yield* SynchronizedRef.make(new Map());
- const runtimeEventPubSub = yield* PubSub.unbounded();
-
- const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
- const randomUUIDv4 = crypto.randomUUIDv4.pipe(
- Effect.mapError(
- (cause) =>
- new ProviderAdapterRequestError({
- provider: PROVIDER,
- method: "crypto/randomUUIDv4",
- detail: "Failed to generate Cursor runtime identifier.",
- cause,
- }),
- ),
- );
- const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id));
- const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });
- const mapExtensionFailure = (effect: Effect.Effect ) =>
- effect.pipe(
- Effect.mapError(
- (cause) =>
- new EffectAcpErrors.AcpTransportError({
- detail: "Failed to process Cursor ACP extension event.",
- cause,
- }),
- ),
- );
-
- const offerRuntimeEvent = (event: ProviderRuntimeEvent) =>
- PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid);
-
- const getThreadSemaphore = (threadId: string) =>
- SynchronizedRef.modifyEffect(threadLocksRef, (current) => {
- const existing: Option.Option = Option.fromNullishOr(
- current.get(threadId),
- );
- return Option.match(existing, {
- onNone: () =>
- Semaphore.make(1).pipe(
- Effect.map((semaphore) => {
- const next = new Map(current);
- next.set(threadId, semaphore);
- return [semaphore, next] as const;
- }),
- ),
- onSome: (semaphore) => Effect.succeed([semaphore, current] as const),
- });
- });
-
- const withThreadLock = (threadId: string, effect: Effect.Effect ) =>
- Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
-
- const logNative = (
- threadId: ThreadId,
- method: string,
- payload: unknown,
- _source: "acp.jsonrpc" | "acp.cursor.extension",
- ) =>
- Effect.gen(function* () {
- if (!nativeEventLogger) return;
- const observedAt = yield* nowIso;
- yield* nativeEventLogger.write(
- {
- observedAt,
- event: {
- id: yield* randomUUIDv4,
- kind: "notification",
- provider: PROVIDER,
- createdAt: observedAt,
- method,
- threadId,
- payload,
- },
- },
- threadId,
- );
- });
-
- const emitPlanUpdate = (
- ctx: CursorSessionContext,
- payload: {
- readonly explanation?: string | null;
- readonly plan: ReadonlyArray<{
- readonly step: string;
- readonly status: "pending" | "inProgress" | "completed";
- }>;
- },
- rawPayload: unknown,
- source: "acp.jsonrpc" | "acp.cursor.extension",
- method: string,
- ) =>
- Effect.gen(function* () {
- const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`;
- if (ctx.lastPlanFingerprint === fingerprint) {
- return;
- }
- ctx.lastPlanFingerprint = fingerprint;
- yield* offerRuntimeEvent(
- makeAcpPlanUpdatedEvent({
- stamp: yield* makeEventStamp(),
- provider: PROVIDER,
- threadId: ctx.threadId,
- turnId: ctx.activeTurnId,
- payload,
- source,
- method,
- rawPayload,
- }),
- );
- });
-
- const requireSession = (
- threadId: ThreadId,
- ): Effect.Effect => {
- const ctx = sessions.get(threadId);
- if (!ctx || ctx.stopped) {
- return Effect.fail(
- new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }),
- );
- }
- return Effect.succeed(ctx);
- };
-
- const stopSessionInternal = (ctx: CursorSessionContext) =>
- Effect.gen(function* () {
- if (ctx.stopped) return;
- ctx.stopped = true;
- yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
- yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
- if (ctx.notificationFiber) {
- yield* Fiber.interrupt(ctx.notificationFiber);
- }
- yield* Effect.ignore(Scope.close(ctx.scope, Exit.void));
- sessions.delete(ctx.threadId);
- yield* offerRuntimeEvent({
- type: "session.exited",
- ...(yield* makeEventStamp()),
- provider: PROVIDER,
- threadId: ctx.threadId,
- payload: { exitKind: "graceful" },
- });
- });
-
- const startSession: CursorAdapterShape["startSession"] = (input) =>
- withThreadLock(
- input.threadId,
+const registerCursorExtensions = (input: AcpProviderExtensionRegistrationInput) =>
+ Effect.gen(function* () {
+ yield* input.acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) =>
+ input.mapExtensionFailure(
Effect.gen(function* () {
- if (input.provider !== undefined && input.provider !== PROVIDER) {
- return yield* new ProviderAdapterValidationError({
- provider: PROVIDER,
- operation: "startSession",
- issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
- });
- }
- if (!input.cwd?.trim()) {
- return yield* new ProviderAdapterValidationError({
- provider: PROVIDER,
- operation: "startSession",
- issue: "cwd is required and must be non-empty.",
- });
- }
-
- const cwd = path.resolve(input.cwd.trim());
- const cursorModelSelection =
- input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
- const existing = sessions.get(input.threadId);
- if (existing && !existing.stopped) {
- yield* stopSessionInternal(existing);
- }
-
- const pendingApprovals = new Map();
- const pendingUserInputs = new Map();
- const sessionScope = yield* Scope.make("sequential");
- let sessionScopeTransferred = false;
- yield* Effect.addFinalizer(() =>
- sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void),
+ yield* input.logNative(
+ input.threadId,
+ "cursor/ask_question",
+ params,
+ "acp.cursor.extension",
);
- let ctx!: CursorSessionContext;
-
- const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId;
- const acpNativeLoggers = makeAcpNativeLoggers({
- nativeEventLogger,
+ const requestId = ApprovalRequestId.make(yield* input.randomUUIDv4);
+ const runtimeRequestId = RuntimeRequestId.make(requestId);
+ const answers = yield* Deferred.make();
+ input.pendingUserInputs.set(requestId, { answers });
+ const context = input.getContext();
+ yield* input.offerRuntimeEvent({
+ type: "user-input.requested",
+ ...(yield* input.makeEventStamp()),
provider: PROVIDER,
threadId: input.threadId,
- });
-
- // Resolve the CursorSettings used to spawn the ACP child. Production
- // leaves `options.resolveSettings` undefined so we use the value
- // captured at adapter construction — per-instance isolation is
- // enforced by the hydration layer rebuilding this adapter whenever
- // its config changes. Tests set `resolveSettings` to pull the latest
- // snapshot from `ServerSettingsService` so that mid-suite
- // `updateSettings({ providers: { cursor: { binaryPath } } })` calls
- // actually take effect when the next session spawns.
- const effectiveCursorSettings = options?.resolveSettings
- ? yield* options.resolveSettings
- : cursorSettings;
-
- const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
- const acp = yield* makeCursorAcpRuntime({
- cursorSettings: effectiveCursorSettings,
- ...(options?.environment ? { environment: options.environment } : {}),
- childProcessSpawner,
- cwd,
- ...(resumeSessionId ? { resumeSessionId } : {}),
- clientInfo: { name: "t3-code", version: "0.0.0" },
- ...(mcpSession
- ? {
- mcpServers: [
- {
- type: "http" as const,
- name: "t3-code",
- url: mcpSession.endpoint,
- headers: [
- {
- name: "Authorization",
- value: mcpSession.authorizationHeader,
- },
- ],
- },
- ],
- }
- : {}),
- ...acpNativeLoggers,
- }).pipe(
- Effect.provideService(Crypto.Crypto, crypto),
- Effect.provideService(Scope.Scope, sessionScope),
- Effect.mapError(
- (cause) =>
- new ProviderAdapterProcessError({
- provider: PROVIDER,
- threadId: input.threadId,
- detail: cause.message,
- cause,
- }),
- ),
- );
- const started = yield* Effect.gen(function* () {
- yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) =>
- mapExtensionFailure(
- Effect.gen(function* () {
- yield* logNative(
- input.threadId,
- "cursor/ask_question",
- params,
- "acp.cursor.extension",
- );
- const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
- const runtimeRequestId = RuntimeRequestId.make(requestId);
- const answers = yield* Deferred.make();
- pendingUserInputs.set(requestId, { answers });
- yield* offerRuntimeEvent({
- type: "user-input.requested",
- ...(yield* makeEventStamp()),
- provider: PROVIDER,
- threadId: input.threadId,
- turnId: ctx?.activeTurnId,
- requestId: runtimeRequestId,
- payload: { questions: extractAskQuestions(params) },
- raw: {
- source: "acp.cursor.extension",
- method: "cursor/ask_question",
- payload: params,
- },
- });
- const resolved = yield* Deferred.await(answers);
- pendingUserInputs.delete(requestId);
- yield* offerRuntimeEvent({
- type: "user-input.resolved",
- ...(yield* makeEventStamp()),
- provider: PROVIDER,
- threadId: input.threadId,
- turnId: ctx?.activeTurnId,
- requestId: runtimeRequestId,
- payload: { answers: resolved },
- });
- return { answers: resolved };
- }),
- ),
- );
- yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) =>
- mapExtensionFailure(
- Effect.gen(function* () {
- yield* logNative(
- input.threadId,
- "cursor/create_plan",
- params,
- "acp.cursor.extension",
- );
- yield* offerRuntimeEvent({
- type: "turn.proposed.completed",
- ...(yield* makeEventStamp()),
- provider: PROVIDER,
- threadId: input.threadId,
- turnId: ctx?.activeTurnId,
- payload: { planMarkdown: extractPlanMarkdown(params) },
- raw: {
- source: "acp.cursor.extension",
- method: "cursor/create_plan",
- payload: params,
- },
- });
- return { accepted: true } as const;
- }),
- ),
- );
- yield* acp.handleExtNotification(
- "cursor/update_todos",
- CursorUpdateTodosRequest,
- (params) =>
- mapExtensionFailure(
- Effect.gen(function* () {
- yield* logNative(
- input.threadId,
- "cursor/update_todos",
- params,
- "acp.cursor.extension",
- );
- if (ctx) {
- yield* emitPlanUpdate(
- ctx,
- extractTodosAsPlan(params),
- params,
- "acp.cursor.extension",
- "cursor/update_todos",
- );
- }
- }),
- ),
- );
- yield* acp.handleRequestPermission((params) =>
- mapExtensionFailure(
- Effect.gen(function* () {
- yield* logNative(
- input.threadId,
- "session/request_permission",
- params,
- "acp.jsonrpc",
- );
- if (input.runtimeMode === "full-access") {
- const autoApprovedOptionId = selectAutoApprovedPermissionOption(params);
- if (autoApprovedOptionId !== undefined) {
- return {
- outcome: {
- outcome: "selected" as const,
- optionId: autoApprovedOptionId,
- },
- };
- }
- }
- const permissionRequest = parsePermissionRequest(params);
- const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
- const runtimeRequestId = RuntimeRequestId.make(requestId);
- const decision = yield* Deferred.make();
- pendingApprovals.set(requestId, {
- decision,
- kind: permissionRequest.kind,
- });
- yield* offerRuntimeEvent(
- makeAcpRequestOpenedEvent({
- stamp: yield* makeEventStamp(),
- provider: PROVIDER,
- threadId: input.threadId,
- turnId: ctx?.activeTurnId,
- requestId: runtimeRequestId,
- permissionRequest,
- detail:
- permissionRequest.detail ??
- encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ??
- "[unserializable params]",
- args: params,
- source: "acp.jsonrpc",
- method: "session/request_permission",
- rawPayload: params,
- }),
- );
- const resolved = yield* Deferred.await(decision);
- pendingApprovals.delete(requestId);
- yield* offerRuntimeEvent(
- makeAcpRequestResolvedEvent({
- stamp: yield* makeEventStamp(),
- provider: PROVIDER,
- threadId: input.threadId,
- turnId: ctx?.activeTurnId,
- requestId: runtimeRequestId,
- permissionRequest,
- decision: resolved,
- }),
- );
- return {
- outcome:
- resolved === "cancel"
- ? ({ outcome: "cancelled" } as const)
- : {
- outcome: "selected" as const,
- optionId: acpPermissionOutcome(resolved),
- },
- };
- }),
- ),
- );
- return yield* acp.start();
- }).pipe(
- Effect.mapError((error) =>
- mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error),
- ),
- );
-
- yield* applyRequestedSessionConfiguration({
- runtime: acp,
- runtimeMode: input.runtimeMode,
- interactionMode: undefined,
- modelSelection: cursorModelSelection,
- mapError: ({ cause, method }) =>
- mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),
- });
-
- const now = yield* nowIso;
- const session: ProviderSession = {
- provider: PROVIDER,
- providerInstanceId: boundInstanceId,
- status: "ready",
- runtimeMode: input.runtimeMode,
- cwd,
- model: cursorModelSelection?.model,
- threadId: input.threadId,
- resumeCursor: {
- schemaVersion: CURSOR_RESUME_VERSION,
- sessionId: started.sessionId,
+ turnId: context?.activeTurnId,
+ requestId: runtimeRequestId,
+ payload: { questions: extractAskQuestions(params) },
+ raw: {
+ source: "acp.cursor.extension",
+ method: "cursor/ask_question",
+ payload: params,
},
- createdAt: now,
- updatedAt: now,
- };
-
- ctx = {
- threadId: input.threadId,
- session,
- scope: sessionScope,
- acp,
- notificationFiber: undefined,
- pendingApprovals,
- pendingUserInputs,
- turns: [],
- lastPlanFingerprint: undefined,
- activeTurnId: undefined,
- promptsInFlight: 0,
- stopped: false,
- };
-
- const nf = yield* Stream.runDrain(
- Stream.mapEffect(acp.getEvents(), (event) =>
- Effect.gen(function* () {
- switch (event._tag) {
- case "EventStreamBarrier":
- yield* Deferred.succeed(event.acknowledge, undefined);
- return;
- case "ModeChanged":
- return;
- case "AssistantItemStarted":
- yield* offerRuntimeEvent(
- makeAcpAssistantItemEvent({
- stamp: yield* makeEventStamp(),
- provider: PROVIDER,
- threadId: ctx.threadId,
- turnId: ctx.activeTurnId,
- itemId: event.itemId,
- lifecycle: "item.started",
- }),
- );
- return;
- case "AssistantItemCompleted":
- yield* offerRuntimeEvent(
- makeAcpAssistantItemEvent({
- stamp: yield* makeEventStamp(),
- provider: PROVIDER,
- threadId: ctx.threadId,
- turnId: ctx.activeTurnId,
- itemId: event.itemId,
- lifecycle: "item.completed",
- }),
- );
- return;
- case "PlanUpdated":
- yield* logNative(
- ctx.threadId,
- "session/update",
- event.rawPayload,
- "acp.jsonrpc",
- );
- yield* emitPlanUpdate(
- ctx,
- event.payload,
- event.rawPayload,
- "acp.jsonrpc",
- "session/update",
- );
- return;
- case "ToolCallUpdated":
- yield* logNative(
- ctx.threadId,
- "session/update",
- event.rawPayload,
- "acp.jsonrpc",
- );
- yield* offerRuntimeEvent(
- makeAcpToolCallEvent({
- stamp: yield* makeEventStamp(),
- provider: PROVIDER,
- threadId: ctx.threadId,
- turnId: ctx.activeTurnId,
- toolCall: event.toolCall,
- rawPayload: event.rawPayload,
- }),
- );
- return;
- case "ContentDelta":
- yield* logNative(
- ctx.threadId,
- "session/update",
- event.rawPayload,
- "acp.jsonrpc",
- );
- yield* offerRuntimeEvent(
- makeAcpContentDeltaEvent({
- stamp: yield* makeEventStamp(),
- provider: PROVIDER,
- threadId: ctx.threadId,
- turnId: ctx.activeTurnId,
- ...(event.itemId ? { itemId: event.itemId } : {}),
- text: event.text,
- rawPayload: event.rawPayload,
- }),
- );
- return;
- }
- }),
- ),
- ).pipe(
- Effect.catch((cause) =>
- Effect.logError("Failed to process Cursor runtime notification.", { cause }),
- ),
- Effect.forkChild,
- );
-
- ctx.notificationFiber = nf;
- sessions.set(input.threadId, ctx);
- sessionScopeTransferred = true;
-
- yield* offerRuntimeEvent({
- type: "session.started",
- ...(yield* makeEventStamp()),
- provider: PROVIDER,
- threadId: input.threadId,
- payload: { resume: started.initializeResult },
});
- yield* offerRuntimeEvent({
- type: "session.state.changed",
- ...(yield* makeEventStamp()),
+ const resolved = yield* Deferred.await(answers);
+ input.pendingUserInputs.delete(requestId);
+ yield* input.offerRuntimeEvent({
+ type: "user-input.resolved",
+ ...(yield* input.makeEventStamp()),
provider: PROVIDER,
threadId: input.threadId,
- payload: { state: "ready", reason: "Cursor ACP session ready" },
+ turnId: context?.activeTurnId,
+ requestId: runtimeRequestId,
+ payload: { answers: resolved },
});
- yield* offerRuntimeEvent({
- type: "thread.started",
- ...(yield* makeEventStamp()),
+ return { answers: resolved };
+ }),
+ ),
+ );
+ yield* input.acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) =>
+ input.mapExtensionFailure(
+ Effect.gen(function* () {
+ yield* input.logNative(
+ input.threadId,
+ "cursor/create_plan",
+ params,
+ "acp.cursor.extension",
+ );
+ const context = input.getContext();
+ yield* input.offerRuntimeEvent({
+ type: "turn.proposed.completed",
+ ...(yield* input.makeEventStamp()),
provider: PROVIDER,
threadId: input.threadId,
- payload: { providerThreadId: started.sessionId },
- });
-
- return session;
- }).pipe(Effect.scoped),
- );
-
- const sendTurn: CursorAdapterShape["sendTurn"] = (input) =>
- Effect.gen(function* () {
- const ctx = yield* requireSession(input.threadId);
- // A sendTurn while a prompt is in flight is a steer: the agent folds
- // the new prompt into the ongoing work, so the active turn id is
- // reused instead of opening a new turn.
- const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined;
- const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4);
- // Count this prompt immediately so a superseded in-flight prompt
- // resolving from here on does not settle the turn; the matching
- // decrement is the `ensuring` below.
- ctx.promptsInFlight += 1;
-
- return yield* Effect.gen(function* () {
- const turnModelSelection =
- input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
- const model = turnModelSelection?.model ?? ctx.session.model;
- const resolvedModel = resolveCursorAcpBaseModelId(model);
- yield* applyRequestedSessionConfiguration({
- runtime: ctx.acp,
- runtimeMode: ctx.session.runtimeMode,
- interactionMode: input.interactionMode,
- modelSelection:
- model === undefined
- ? undefined
- : {
- model,
- options: turnModelSelection?.options,
- },
- mapError: ({ cause, method }) =>
- mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),
+ turnId: context?.activeTurnId,
+ payload: { planMarkdown: extractPlanMarkdown(params) },
+ raw: {
+ source: "acp.cursor.extension",
+ method: "cursor/create_plan",
+ payload: params,
+ },
});
- ctx.activeTurnId = turnId;
- if (steeringTurnId === undefined) {
- ctx.lastPlanFingerprint = undefined;
- }
- ctx.session = {
- ...ctx.session,
- activeTurnId: turnId,
- updatedAt: yield* nowIso,
- };
-
- if (steeringTurnId === undefined) {
- yield* offerRuntimeEvent({
- type: "turn.started",
- ...(yield* makeEventStamp()),
- provider: PROVIDER,
- threadId: input.threadId,
- turnId,
- payload: { model: resolvedModel },
- });
- }
-
- const promptParts: Array = [];
- if (input.input?.trim()) {
- promptParts.push({ type: "text", text: input.input.trim() });
- }
- if (input.attachments && input.attachments.length > 0) {
- for (const attachment of input.attachments) {
- const attachmentPath = resolveAttachmentPath({
- attachmentsDir: serverConfig.attachmentsDir,
- attachment,
- });
- if (!attachmentPath) {
- return yield* new ProviderAdapterRequestError({
- provider: PROVIDER,
- method: "session/prompt",
- detail: `Invalid attachment id '${attachment.id}'.`,
- });
- }
- const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
- Effect.mapError(
- (cause) =>
- new ProviderAdapterRequestError({
- provider: PROVIDER,
- method: "session/prompt",
- detail: cause.message,
- cause,
- }),
- ),
+ return { accepted: true } as const;
+ }),
+ ),
+ );
+ yield* input.acp.handleExtNotification(
+ "cursor/update_todos",
+ CursorUpdateTodosRequest,
+ (params) =>
+ input.mapExtensionFailure(
+ Effect.gen(function* () {
+ yield* input.logNative(
+ input.threadId,
+ "cursor/update_todos",
+ params,
+ "acp.cursor.extension",
+ );
+ const context = input.getContext();
+ if (context) {
+ yield* input.emitPlanUpdate(
+ context,
+ extractTodosAsPlan(params),
+ params,
+ "acp.cursor.extension",
+ "cursor/update_todos",
);
- promptParts.push({
- type: "image",
- data: Buffer.from(bytes).toString("base64"),
- mimeType: attachment.mimeType,
- });
}
- }
-
- if (promptParts.length === 0) {
- return yield* new ProviderAdapterValidationError({
- provider: PROVIDER,
- operation: "sendTurn",
- issue: "Turn requires non-empty text or attachments.",
- });
- }
-
- const result = yield* ctx.acp
- .prompt({
- prompt: promptParts,
- })
- .pipe(
- Effect.mapError((error) =>
- mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
- ),
- );
-
- const turnRecord = ctx.turns.find((turn) => turn.id === turnId);
- if (turnRecord) {
- turnRecord.items.push({ prompt: promptParts, result });
- } else {
- ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] });
- }
- ctx.session = {
- ...ctx.session,
- activeTurnId: turnId,
- updatedAt: yield* nowIso,
- model: resolvedModel,
- };
-
- // Only the last remaining prompt settles the turn — a steer-
- // superseded prompt resolving (usually cancelled) while another is
- // in flight or pending must leave the merged turn running.
- if (ctx.promptsInFlight === 1) {
- yield* offerRuntimeEvent({
- type: "turn.completed",
- ...(yield* makeEventStamp()),
- provider: PROVIDER,
- threadId: input.threadId,
- turnId,
- payload: {
- state: result.stopReason === "cancelled" ? "cancelled" : "completed",
- stopReason: result.stopReason ?? null,
- },
- });
- }
-
- return {
- threadId: input.threadId,
- turnId,
- resumeCursor: ctx.session.resumeCursor,
- };
- }).pipe(
- Effect.ensuring(
- Effect.sync(() => {
- ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1);
- }),
- ),
- );
- });
-
- const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) =>
- Effect.gen(function* () {
- const ctx = yield* requireSession(threadId);
- yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
- yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
- yield* Effect.ignore(
- ctx.acp.cancel.pipe(
- Effect.mapError((error) =>
- mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error),
- ),
- ),
- );
- });
-
- const respondToRequest: CursorAdapterShape["respondToRequest"] = (
- threadId,
- requestId,
- decision,
- ) =>
- Effect.gen(function* () {
- const ctx = yield* requireSession(threadId);
- const pending = ctx.pendingApprovals.get(requestId);
- if (!pending) {
- return yield* new ProviderAdapterRequestError({
- provider: PROVIDER,
- method: "session/request_permission",
- detail: `Unknown pending approval request: ${requestId}`,
- });
- }
- yield* Deferred.succeed(pending.decision, decision);
- });
-
- const respondToUserInput: CursorAdapterShape["respondToUserInput"] = (
- threadId,
- requestId,
- answers,
- ) =>
- Effect.gen(function* () {
- const ctx = yield* requireSession(threadId);
- const pending = ctx.pendingUserInputs.get(requestId);
- if (!pending) {
- return yield* new ProviderAdapterRequestError({
- provider: PROVIDER,
- method: "cursor/ask_question",
- detail: `Unknown pending user-input request: ${requestId}`,
- });
- }
- yield* Deferred.succeed(pending.answers, answers);
- });
-
- const readThread: CursorAdapterShape["readThread"] = (threadId) =>
- Effect.gen(function* () {
- const ctx = yield* requireSession(threadId);
- return { threadId, turns: ctx.turns };
- });
-
- const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) =>
- Effect.gen(function* () {
- const ctx = yield* requireSession(threadId);
- if (!Number.isInteger(numTurns) || numTurns < 1) {
- return yield* new ProviderAdapterValidationError({
- provider: PROVIDER,
- operation: "rollbackThread",
- issue: "numTurns must be an integer >= 1.",
- });
- }
- const nextLength = Math.max(0, ctx.turns.length - numTurns);
- ctx.turns.splice(nextLength);
- return { threadId, turns: ctx.turns };
- });
-
- const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>
- withThreadLock(
- threadId,
- Effect.gen(function* () {
- const ctx = yield* requireSession(threadId);
- yield* stopSessionInternal(ctx);
- }),
- );
-
- const listSessions: CursorAdapterShape["listSessions"] = () =>
- Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session })));
-
- const hasSession: CursorAdapterShape["hasSession"] = (threadId) =>
- Effect.sync(() => {
- const c = sessions.get(threadId);
- return c !== undefined && !c.stopped;
- });
-
- const stopAll: CursorAdapterShape["stopAll"] = () =>
- Effect.forEach(sessions.values(), stopSessionInternal, { discard: true });
-
- yield* Effect.addFinalizer(() =>
- Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe(
- Effect.catch((cause) =>
- Effect.logError("Failed to emit Cursor session shutdown event.", { cause }),
+ }),
),
- Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)),
- Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void),
- ),
);
-
- const streamEvents = Stream.fromPubSub(runtimeEventPubSub);
-
- return {
- provider: PROVIDER,
- capabilities: { sessionModelSwitch: "in-session" },
- startSession,
- sendTurn,
- interruptTurn,
- readThread,
- rollbackThread,
- respondToRequest,
- respondToUserInput,
- stopSession,
- listSessions,
- hasSession,
- stopAll,
- streamEvents,
- } satisfies CursorAdapterShape;
});
+
+export function makeCursorAdapter(
+ cursorSettings: CursorSettings,
+ options?: CursorAdapterLiveOptions,
+) {
+ return makeAcpProviderAdapter({
+ provider: PROVIDER,
+ defaultInstanceId: ProviderInstanceId.make("cursor"),
+ displayName: "Cursor",
+ settings: cursorSettings,
+ supportsRollback: true,
+ ...(options ? { options } : {}),
+ registerExtensions: registerCursorExtensions,
+ userInputRequestMethod: "cursor/ask_question",
+ makeRuntime: ({
+ threadId,
+ settings,
+ environment,
+ childProcessSpawner,
+ cwd,
+ resumeSessionId,
+ clientInfo,
+ nativeLoggers,
+ }) => {
+ const mcpSession = McpProviderSession.readMcpProviderSession(threadId);
+ return makeCursorAcpRuntime({
+ cursorSettings: settings,
+ ...(environment ? { environment } : {}),
+ childProcessSpawner,
+ cwd,
+ ...(resumeSessionId ? { resumeSessionId } : {}),
+ clientInfo,
+ ...(mcpSession
+ ? {
+ mcpServers: [
+ {
+ type: "http" as const,
+ name: "t3-code",
+ url: mcpSession.endpoint,
+ headers: [
+ {
+ name: "Authorization",
+ value: mcpSession.authorizationHeader,
+ },
+ ],
+ },
+ ],
+ }
+ : {}),
+ ...nativeLoggers,
+ });
+ },
+ applyModelSelection: ({ runtime, threadId, model, selections }) =>
+ applyCursorAcpModelSelection({
+ runtime,
+ model,
+ selections,
+ mapError: ({ cause, step }) =>
+ mapAcpToAdapterError(
+ PROVIDER,
+ threadId,
+ step === "set-model" ? "session/set_model" : "session/set_config_option",
+ cause,
+ ),
+ }),
+ resolveModelId: resolveCursorAcpBaseModelId,
+ }).pipe(Effect.map((adapter) => adapter satisfies CursorAdapterShape));
}
diff --git a/apps/server/src/provider/Layers/OmpAdapter.test.ts b/apps/server/src/provider/Layers/OmpAdapter.test.ts
new file mode 100644
index 000000000000..10d7e97360fe
--- /dev/null
+++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts
@@ -0,0 +1,202 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as NodeFSP from "node:fs/promises";
+import * as NodeURL from "node:url";
+
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import {
+ ApprovalRequestId,
+ OmpSettings,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ThreadId,
+} from "@t3tools/contracts";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+
+import { ServerConfig } from "../../config.ts";
+import { makeOmpAdapter } from "./OmpAdapter.ts";
+
+const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
+const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
+const decodeOmpSettings = Schema.decodeSync(OmpSettings);
+
+async function makeMockOmpWrapper(input?: {
+ readonly argvLogPath?: string;
+ readonly environment?: Readonly>;
+}) {
+ const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-mock-"));
+ const wrapperPath = NodePath.join(dir, "fake-omp.sh");
+ const exports = Object.entries(input?.environment ?? {})
+ .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
+ .join("\n");
+ const argvLog = input?.argvLogPath
+ ? `printf '%s\\t' "$@" >> ${JSON.stringify(input.argvLogPath)}\nprintf '\\n' >> ${JSON.stringify(input.argvLogPath)}`
+ : "";
+ const script = `#!/bin/sh
+${exports}
+${argvLog}
+exec bun ${JSON.stringify(mockAgentPath)} "$@"
+`;
+ await NodeFSP.writeFile(wrapperPath, script, "utf8");
+ await NodeFSP.chmod(wrapperPath, 0o755);
+ return wrapperPath;
+}
+
+const testLayer = ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3code-omp-adapter-test-",
+}).pipe(Layer.provideMerge(NodeServices.layer));
+
+it.effect("OMP adapter starts a scoped ACP session with profile and runtime policy", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const tempDir = yield* Effect.promise(() =>
+ NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-argv-")),
+ );
+ const argvLogPath = NodePath.join(tempDir, "argv.txt");
+ yield* Effect.promise(() => NodeFSP.writeFile(argvLogPath, "", "utf8"));
+ const binaryPath = yield* Effect.promise(() => makeMockOmpWrapper({ argvLogPath }));
+ const adapter = yield* makeOmpAdapter(
+ decodeOmpSettings({ binaryPath, profile: "work", enabled: true }),
+ );
+ const threadId = ThreadId.make("omp-session");
+
+ const session = yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("omp"),
+ providerInstanceId: ProviderInstanceId.make("omp"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("omp"),
+ model: "default",
+ },
+ });
+
+ assert.equal(session.provider, "omp");
+ assert.deepStrictEqual(session.resumeCursor, {
+ schemaVersion: 1,
+ sessionId: "mock-session-1",
+ });
+ assert.deepStrictEqual(
+ (yield* Effect.promise(() => NodeFSP.readFile(argvLogPath, "utf8")))
+ .trim()
+ .split("\t")
+ .filter(Boolean),
+ ["acp", "--profile", "work", "--approval-mode", "yolo"],
+ );
+ const rollbackError = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip);
+ assert.equal(rollbackError._tag, "ProviderAdapterRequestError");
+
+ yield* adapter.stopSession(threadId);
+ }).pipe(Effect.provide(testLayer)),
+ ),
+);
+
+it.effect("OMP adapter bridges standard ACP form elicitation to provider user input", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const binaryPath = yield* Effect.promise(() =>
+ makeMockOmpWrapper({ environment: { T3_ACP_EMIT_ELICITATION: "1" } }),
+ );
+ const adapter = yield* makeOmpAdapter(decodeOmpSettings({ binaryPath, enabled: true }));
+ const threadId = ThreadId.make("omp-elicitation");
+ const requested = yield* Deferred.make<{
+ readonly requestId: string;
+ readonly questions: ReadonlyArray<{ readonly id: string }>;
+ }>();
+ const resolved = yield* Deferred.make>();
+
+ yield* Stream.runForEach(adapter.streamEvents, (event) => {
+ if (event.threadId !== threadId) return Effect.void;
+ if (event.type === "user-input.requested") {
+ return Deferred.succeed(requested, {
+ requestId: String(event.requestId),
+ questions: event.payload.questions,
+ }).pipe(Effect.ignore);
+ }
+ if (event.type === "user-input.resolved") {
+ return Deferred.succeed(resolved, event.payload.answers).pipe(Effect.ignore);
+ }
+ return Effect.void;
+ }).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("omp"),
+ providerInstanceId: ProviderInstanceId.make("omp"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("omp"),
+ model: "default",
+ },
+ });
+ const turnFiber = yield* adapter
+ .sendTurn({ threadId, input: "ask me", attachments: [] })
+ .pipe(Effect.forkChild);
+
+ const request = yield* Deferred.await(requested);
+ assert.deepStrictEqual(
+ request.questions.map((question) => question.id),
+ ["strategy"],
+ );
+ yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(request.requestId), {
+ strategy: "safe",
+ });
+ yield* Fiber.join(turnFiber);
+ assert.deepStrictEqual(yield* Deferred.await(resolved), { strategy: "safe" });
+
+ yield* adapter.stopSession(threadId);
+ }).pipe(Effect.provide(testLayer)),
+ ),
+);
+
+it.effect(
+ "OMP adapter cancels unsupported optionless elicitation without waiting for web input",
+ () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const binaryPath = yield* Effect.promise(() =>
+ makeMockOmpWrapper({
+ environment: { T3_ACP_EMIT_UNSUPPORTED_ELICITATION: "1" },
+ }),
+ );
+ const adapter = yield* makeOmpAdapter(decodeOmpSettings({ binaryPath, enabled: true }));
+ const threadId = ThreadId.make("omp-unsupported-elicitation");
+ let sawUserInputRequest = false;
+
+ yield* Stream.runForEach(adapter.streamEvents, (event) => {
+ if (event.threadId === threadId && event.type === "user-input.requested") {
+ sawUserInputRequest = true;
+ }
+ return Effect.void;
+ }).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("omp"),
+ providerInstanceId: ProviderInstanceId.make("omp"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("omp"),
+ model: "default",
+ },
+ });
+
+ // Completion proves the ACP request was answered immediately instead of
+ // leaving the turn blocked on a prompt the web client cannot render.
+ yield* adapter.sendTurn({ threadId, input: "ask for free text", attachments: [] });
+ assert.isFalse(sawUserInputRequest);
+
+ yield* adapter.stopSession(threadId);
+ }).pipe(Effect.provide(testLayer)),
+ ),
+);
diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts
new file mode 100644
index 000000000000..9595b5fb57b1
--- /dev/null
+++ b/apps/server/src/provider/Layers/OmpAdapter.ts
@@ -0,0 +1,67 @@
+/**
+ * OMP CLI (`omp acp`) adapter.
+ *
+ * OMP speaks the same standard ACP transport as Cursor, so all session,
+ * turn, event, attachment, resume, permission, and elicitation behavior lives
+ * in the shared ACP adapter factory. This module owns only OMP-specific spawn,
+ * authentication, model configuration, and approval-policy choices.
+ */
+import { type OmpSettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+
+import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts";
+import { applyOmpAcpModelSelection, makeOmpAcpRuntime } from "../acp/OmpAcpSupport.ts";
+import type { OmpAdapterShape } from "../Services/OmpAdapter.ts";
+import { makeAcpProviderAdapter, type AcpProviderAdapterLiveOptions } from "./AcpAdapter.ts";
+
+const PROVIDER = ProviderDriverKind.make("omp");
+
+export type OmpAdapterLiveOptions = AcpProviderAdapterLiveOptions;
+
+export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiveOptions) {
+ return makeAcpProviderAdapter({
+ provider: PROVIDER,
+ defaultInstanceId: ProviderInstanceId.make("omp"),
+ displayName: "OMP",
+ settings: ompSettings,
+ supportsRollback: false,
+ ...(options ? { options } : {}),
+ makeRuntime: ({
+ settings,
+ environment,
+ childProcessSpawner,
+ cwd,
+ runtimeMode,
+ resumeSessionId,
+ clientInfo,
+ nativeLoggers,
+ }) =>
+ makeOmpAcpRuntime({
+ ompSettings: settings,
+ ...(environment ? { environment } : {}),
+ childProcessSpawner,
+ cwd,
+ runtimeMode,
+ ...(resumeSessionId ? { resumeSessionId } : {}),
+ clientInfo,
+ ...nativeLoggers,
+ }),
+ applyModelSelection: ({ runtime, threadId, model, selections }) =>
+ applyOmpAcpModelSelection({
+ runtime,
+ model,
+ selections,
+ mapError: ({ cause, step }) =>
+ mapAcpToAdapterError(
+ PROVIDER,
+ threadId,
+ step === "set-model" ? "session/set_model" : "session/set_config_option",
+ cause,
+ ),
+ }),
+ shouldAutoApprovePermission: ({ runtimeMode, permissionKind }) =>
+ (runtimeMode === "auto-accept-edits" || runtimeMode === "auto") &&
+ (permissionKind === "edit" || permissionKind === "delete" || permissionKind === "move"),
+ resolveModelId: (model) => model?.trim() || "default",
+ }).pipe(Effect.map((adapter) => adapter satisfies OmpAdapterShape));
+}
diff --git a/apps/server/src/provider/Layers/OmpProvider.test.ts b/apps/server/src/provider/Layers/OmpProvider.test.ts
new file mode 100644
index 000000000000..490755c3855d
--- /dev/null
+++ b/apps/server/src/provider/Layers/OmpProvider.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { buildOmpCliArgs, buildOmpDiscoveredModels, parseOmpModelsJson } from "./OmpProvider.ts";
+
+describe("OmpProvider", () => {
+ it("parses OMP's machine-readable model inventory", () => {
+ const parsed = parseOmpModelsJson(
+ JSON.stringify({
+ models: [
+ {
+ provider: "anthropic",
+ id: "claude-sonnet-4-6",
+ selector: "anthropic/claude-sonnet-4-6",
+ name: "Claude Sonnet 4.6",
+ reasoning: true,
+ thinking: ["low", "medium", "high"],
+ input: ["text", "image"],
+ },
+ {
+ provider: "local",
+ id: "qwen",
+ selector: "local/qwen",
+ name: "Qwen",
+ reasoning: false,
+ thinking: null,
+ input: ["text"],
+ },
+ ],
+ }),
+ );
+
+ expect(parsed).toHaveLength(2);
+ expect(buildOmpDiscoveredModels(parsed ?? [])).toEqual([
+ {
+ slug: "anthropic/claude-sonnet-4-6",
+ name: "Claude Sonnet 4.6",
+ subProvider: "anthropic",
+ isCustom: false,
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "thinking",
+ label: "Thinking",
+ description: "Reasoning effort used by OMP for this model.",
+ type: "select",
+ currentValue: "auto",
+ options: [
+ { id: "off", label: "Off" },
+ { id: "auto", label: "Auto", isDefault: true },
+ { id: "low", label: "Low" },
+ { id: "medium", label: "Medium" },
+ { id: "high", label: "High" },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ slug: "local/qwen",
+ name: "Qwen",
+ subProvider: "local",
+ isCustom: false,
+ capabilities: { optionDescriptors: [] },
+ },
+ ]);
+ });
+
+ it("rejects malformed JSON and prepends the selected profile", () => {
+ expect(parseOmpModelsJson("not json")).toBeUndefined();
+ expect(buildOmpCliArgs({ profile: "work" }, ["models", "--json"])).toEqual([
+ "--profile",
+ "work",
+ "models",
+ "--json",
+ ]);
+ expect(buildOmpCliArgs({ profile: "" }, ["acp"])).toEqual(["acp"]);
+ });
+});
diff --git a/apps/server/src/provider/Layers/OmpProvider.ts b/apps/server/src/provider/Layers/OmpProvider.ts
new file mode 100644
index 000000000000..a0e3ac179542
--- /dev/null
+++ b/apps/server/src/provider/Layers/OmpProvider.ts
@@ -0,0 +1,350 @@
+import {
+ type ModelCapabilities,
+ type OmpSettings,
+ type ServerProviderModel,
+} from "@t3tools/contracts";
+import { createModelCapabilities } from "@t3tools/shared/model";
+import { compareSemverVersions } from "@t3tools/shared/semver";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Result from "effect/Result";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+
+import {
+ buildServerProvider,
+ collectStreamAsString,
+ detailFromResult,
+ isCommandMissingCause,
+ parseGenericCliVersion,
+ ProviderCommandNotFoundError,
+ providerModelsFromSettings,
+ type CommandResult,
+ type ServerProviderDraft,
+} from "../providerSnapshot.ts";
+
+const OMP_PRESENTATION = {
+ displayName: "OMP",
+ showInteractionModeToggle: true,
+} as const;
+const OMP_VERSION_TIMEOUT_MS = 5_000;
+const MINIMUM_OMP_ACP_VERSION = "15.13.1";
+const OMP_MODELS_TIMEOUT_MS = 20_000;
+
+const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
+ optionDescriptors: [],
+});
+
+interface OmpModelJson {
+ readonly provider: string;
+ readonly id: string;
+ readonly selector: string;
+ readonly name: string;
+ readonly reasoning: boolean;
+ readonly thinking: ReadonlyArray | null;
+}
+
+function nonEmptyString(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
+}
+
+function titleCase(value: string): string {
+ return value
+ .split(/[-_\s]+/u)
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+function parseOmpModel(value: unknown): OmpModelJson | undefined {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
+ const record = value as Record;
+ const provider = nonEmptyString(record.provider);
+ const id = nonEmptyString(record.id);
+ const name = nonEmptyString(record.name);
+ if (!provider || !id || !name) return undefined;
+ const selector = nonEmptyString(record.selector) ?? `${provider}/${id}`;
+ const thinking = Array.isArray(record.thinking)
+ ? record.thinking.flatMap((entry) => (nonEmptyString(entry) ? [nonEmptyString(entry)!] : []))
+ : null;
+ return {
+ provider,
+ id,
+ selector,
+ name,
+ reasoning: record.reasoning === true,
+ thinking,
+ };
+}
+
+export function parseOmpModelsJson(raw: string): ReadonlyArray | undefined {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return undefined;
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
+ const models = (parsed as Record).models;
+ if (!Array.isArray(models)) return undefined;
+ return models.flatMap((model) => {
+ const parsedModel = parseOmpModel(model);
+ return parsedModel ? [parsedModel] : [];
+ });
+}
+
+function ompModelCapabilities(model: OmpModelJson): ModelCapabilities {
+ if (!model.reasoning) return EMPTY_CAPABILITIES;
+ const efforts = Array.from(new Set(model.thinking ?? []));
+ return createModelCapabilities({
+ optionDescriptors: [
+ {
+ id: "thinking",
+ label: "Thinking",
+ description: "Reasoning effort used by OMP for this model.",
+ type: "select",
+ currentValue: "auto",
+ options: [
+ { id: "off", label: "Off" },
+ { id: "auto", label: "Auto", isDefault: true },
+ ...efforts
+ .filter((effort) => effort !== "off" && effort !== "auto")
+ .map((effort) => ({ id: effort, label: titleCase(effort) })),
+ ],
+ },
+ ],
+ });
+}
+
+export function buildOmpDiscoveredModels(
+ models: ReadonlyArray,
+): ReadonlyArray {
+ const seen = new Set();
+ const discovered: ServerProviderModel[] = [];
+ for (const model of models) {
+ if (seen.has(model.selector)) continue;
+ seen.add(model.selector);
+ discovered.push({
+ slug: model.selector,
+ name: model.name,
+ subProvider: model.provider,
+ isCustom: false,
+ capabilities: ompModelCapabilities(model),
+ });
+ }
+ return discovered;
+}
+
+export function buildOmpCliArgs(
+ settings: Pick,
+ args: ReadonlyArray,
+): ReadonlyArray {
+ const profile = settings.profile.trim();
+ // OMP stops parsing global profile flags after a non-launch subcommand such
+ // as `models`, so the profile must precede that subcommand. (`omp acp` is a
+ // launch-shaped exception and is assembled separately by OmpAcpSupport.)
+ return profile ? ["--profile", profile, ...args] : [...args];
+}
+
+const runOmpCommand = (
+ settings: OmpSettings,
+ args: ReadonlyArray,
+ environment: NodeJS.ProcessEnv,
+) =>
+ Effect.gen(function* () {
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const child = yield* spawner.spawn(
+ ChildProcess.make(settings.binaryPath, [...args], {
+ env: environment,
+ shell: false,
+ }),
+ );
+ const [stdout, stderr, exitCode] = yield* Effect.all(
+ [
+ collectStreamAsString(child.stdout),
+ collectStreamAsString(child.stderr),
+ child.exitCode.pipe(Effect.map(Number)),
+ ],
+ { concurrency: "unbounded" },
+ );
+ if (exitCode !== 0 && /is not recognized/u.test(stderr)) {
+ return yield* new ProviderCommandNotFoundError({
+ binaryPath: settings.binaryPath,
+ exitCode,
+ stdoutLength: stdout.length,
+ stderrLength: stderr.length,
+ });
+ }
+ return { stdout, stderr, code: exitCode } satisfies CommandResult;
+ }).pipe(Effect.scoped);
+
+export const makePendingOmpProvider = (settings: OmpSettings): Effect.Effect =>
+ Effect.gen(function* () {
+ const checkedAt = DateTime.formatIso(yield* DateTime.now);
+ const models = providerModelsFromSettings([], settings.customModels, EMPTY_CAPABILITIES);
+ return buildServerProvider({
+ presentation: OMP_PRESENTATION,
+ enabled: settings.enabled,
+ checkedAt,
+ models,
+ probe: {
+ installed: false,
+ version: null,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: settings.enabled
+ ? "Checking OMP availability..."
+ : "OMP is disabled in T3 Code settings.",
+ },
+ });
+ });
+
+function commandFailureMessage(command: string, result: CommandResult): string {
+ const detail = detailFromResult(result);
+ return detail
+ ? `OMP \`${command}\` failed: ${detail}`
+ : `OMP \`${command}\` exited without usable output.`;
+}
+
+export const checkOmpProviderStatus = Effect.fn("checkOmpProviderStatus")(function* (
+ settings: OmpSettings,
+ environment: NodeJS.ProcessEnv = process.env,
+): Effect.fn.Return {
+ const checkedAt = DateTime.formatIso(yield* DateTime.now);
+ const fallbackModels = providerModelsFromSettings([], settings.customModels, EMPTY_CAPABILITIES);
+ const buildFailure = (input: {
+ readonly installed: boolean;
+ readonly version?: string | null;
+ readonly message: string;
+ readonly status?: "warning" | "error";
+ }) =>
+ buildServerProvider({
+ presentation: OMP_PRESENTATION,
+ enabled: settings.enabled,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: input.installed,
+ version: input.version ?? null,
+ status: input.status ?? "error",
+ auth: { status: "unknown" },
+ message: input.message,
+ },
+ });
+
+ if (!settings.enabled) {
+ return buildFailure({
+ installed: false,
+ status: "warning",
+ message: "OMP is disabled in T3 Code settings.",
+ });
+ }
+
+ const versionProbe = yield* runOmpCommand(settings, ["--version"], environment).pipe(
+ Effect.timeoutOption(OMP_VERSION_TIMEOUT_MS),
+ Effect.result,
+ );
+ if (Result.isFailure(versionProbe)) {
+ return buildFailure({
+ installed: !isCommandMissingCause(versionProbe.failure),
+ message: isCommandMissingCause(versionProbe.failure)
+ ? "OMP CLI (`omp`) is not installed or not on PATH."
+ : `Failed to execute OMP CLI health check: ${versionProbe.failure instanceof Error ? versionProbe.failure.message : String(versionProbe.failure)}.`,
+ });
+ }
+ if (Option.isNone(versionProbe.success)) {
+ return buildFailure({
+ installed: true,
+ message: "OMP CLI timed out while running `omp --version`.",
+ });
+ }
+ const versionResult = versionProbe.success.value;
+ if (versionResult.code !== 0) {
+ return buildFailure({
+ installed: true,
+ message: commandFailureMessage("--version", versionResult),
+ });
+ }
+ const version = parseGenericCliVersion(`${versionResult.stdout}\n${versionResult.stderr}`);
+ if (!version) {
+ return buildFailure({
+ installed: true,
+ message: "Unable to determine the installed OMP version from `omp --version`.",
+ });
+ }
+ if (compareSemverVersions(version, MINIMUM_OMP_ACP_VERSION) < 0) {
+ return buildFailure({
+ installed: true,
+ version,
+ message: `OMP v${version} is too old. T3 Code requires v${MINIMUM_OMP_ACP_VERSION} or newer (ACP + profiles). Run \`omp update\`.`,
+ });
+ }
+
+ const modelsArgs = buildOmpCliArgs(settings, ["models", "--json"]);
+ const modelsProbe = yield* runOmpCommand(settings, modelsArgs, environment).pipe(
+ Effect.timeoutOption(OMP_MODELS_TIMEOUT_MS),
+ Effect.result,
+ );
+ if (Result.isFailure(modelsProbe)) {
+ return buildFailure({
+ installed: true,
+ version,
+ status: fallbackModels.length > 0 ? "warning" : "error",
+ message: `Failed to discover OMP models: ${modelsProbe.failure instanceof Error ? modelsProbe.failure.message : String(modelsProbe.failure)}.`,
+ });
+ }
+ if (Option.isNone(modelsProbe.success)) {
+ return buildFailure({
+ installed: true,
+ version,
+ status: fallbackModels.length > 0 ? "warning" : "error",
+ message: "OMP model discovery timed out while running `omp models --json`.",
+ });
+ }
+ const modelsResult = modelsProbe.success.value;
+ if (modelsResult.code !== 0) {
+ return buildFailure({
+ installed: true,
+ version,
+ status: fallbackModels.length > 0 ? "warning" : "error",
+ message: commandFailureMessage("models --json", modelsResult),
+ });
+ }
+ const parsedModels = parseOmpModelsJson(modelsResult.stdout);
+ if (!parsedModels) {
+ return buildFailure({
+ installed: true,
+ version,
+ status: fallbackModels.length > 0 ? "warning" : "error",
+ message: "OMP returned invalid JSON from `omp models --json`.",
+ });
+ }
+
+ const discoveredModels = buildOmpDiscoveredModels(parsedModels);
+ const models = providerModelsFromSettings(
+ discoveredModels,
+ settings.customModels,
+ EMPTY_CAPABILITIES,
+ );
+ const hasDiscoveredModel = discoveredModels.length > 0;
+ return buildServerProvider({
+ presentation: OMP_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models,
+ probe: {
+ installed: true,
+ version,
+ status: hasDiscoveredModel ? "ready" : models.length > 0 ? "warning" : "error",
+ auth: hasDiscoveredModel ? { status: "authenticated" } : { status: "unauthenticated" },
+ ...(!hasDiscoveredModel
+ ? {
+ message:
+ models.length > 0
+ ? "OMP model discovery found no authenticated models; only custom models are available."
+ : "OMP has no authenticated models. Run `omp` and use `/login`, or configure a provider API key.",
+ }
+ : {}),
+ },
+ });
+});
diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
index dcc3ac0b5db7..fa96bf71c219 100644
--- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
+++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
@@ -10,7 +10,7 @@
*
* 2. **Many drivers, one registry** — the "all drivers slice" describe
* block below configures one instance of every shipped driver
- * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single
+ * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`, `omp`) in a single
* `ProviderInstanceConfigMap` and asserts the registry boots them all
* without cross-contamination. This proves the driver SPI is uniform
* across every provider — any driver plugs into the registry through
@@ -30,6 +30,7 @@ import {
type CursorSettings,
type GrokSettings,
type OpenCodeSettings,
+ type OmpSettings,
ProviderDriverKind,
type ProviderInstanceConfigMap,
ProviderInstanceId,
@@ -48,6 +49,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts";
import { CursorDriver } from "../Drivers/CursorDriver.ts";
import { GrokDriver } from "../Drivers/GrokDriver.ts";
import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";
+import { OmpDriver } from "../Drivers/OmpDriver.ts";
import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";
@@ -133,6 +135,14 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti
...overrides,
});
+const makeOmpConfig = (overrides: Partial): OmpSettings => ({
+ enabled: false,
+ binaryPath: "omp",
+ profile: "",
+ customModels: [],
+ ...overrides,
+});
+
describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
// `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch
// directory. `Layer.merge` just unions requirements, so we have to push
@@ -295,12 +305,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
const cursorId = ProviderInstanceId.make("cursor_default");
const grokId = ProviderInstanceId.make("grok_default");
const openCodeId = ProviderInstanceId.make("opencode_default");
+ const ompId = ProviderInstanceId.make("omp_default");
const codexDriverKind = ProviderDriverKind.make("codex");
const claudeDriverKind = ProviderDriverKind.make("claudeAgent");
const cursorDriverKind = ProviderDriverKind.make("cursor");
const grokDriverKind = ProviderDriverKind.make("grok");
const openCodeDriverKind = ProviderDriverKind.make("opencode");
+ const ompDriverKind = ProviderDriverKind.make("omp");
const configMap: ProviderInstanceConfigMap = {
[codexId]: {
@@ -336,10 +348,16 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
enabled: false,
config: makeOpenCodeConfig({}),
},
+ [ompId]: {
+ driver: ompDriverKind,
+ displayName: "OMP",
+ enabled: false,
+ config: makeOmpConfig({ profile: "work" }),
+ },
};
const { registry } = yield* makeProviderInstanceRegistry({
- drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver],
+ drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver, OmpDriver],
configMap,
});
@@ -349,9 +367,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
expect(unavailable).toEqual([]);
const instances = yield* registry.listInstances;
- expect(instances).toHaveLength(5);
+ expect(instances).toHaveLength(6);
expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual(
- [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(),
+ [codexId, claudeId, cursorId, grokId, openCodeId, ompId].toSorted(),
);
// Instance lookup by id resolves each instance to its own bundle —
@@ -362,16 +380,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
const cursor = yield* registry.getInstance(cursorId);
const grok = yield* registry.getInstance(grokId);
const openCode = yield* registry.getInstance(openCodeId);
+ const omp = yield* registry.getInstance(ompId);
expect(codex?.driverKind).toBe(codexDriverKind);
expect(claude?.driverKind).toBe(claudeDriverKind);
expect(cursor?.driverKind).toBe(cursorDriverKind);
expect(grok?.driverKind).toBe(grokDriverKind);
expect(openCode?.driverKind).toBe(openCodeDriverKind);
+ expect(omp?.driverKind).toBe(ompDriverKind);
expect(codex?.displayName).toBe("Codex");
expect(claude?.displayName).toBe("Claude");
expect(cursor?.displayName).toBe("Cursor");
expect(grok?.displayName).toBe("Grok");
expect(openCode?.displayName).toBe("OpenCode");
+ expect(omp?.displayName).toBe("OMP");
// Every instance owns its own set of closures — no sharing across
// drivers. `adapter` / `textGeneration` / `snapshot` are all
@@ -384,6 +405,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
cursor!.adapter,
grok!.adapter,
openCode!.adapter,
+ omp!.adapter,
];
expect(new Set(adapters).size).toBe(adapters.length);
const textGenerations = [
@@ -392,6 +414,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
cursor!.textGeneration,
grok!.textGeneration,
openCode!.textGeneration,
+ omp!.textGeneration,
];
expect(new Set(textGenerations).size).toBe(textGenerations.length);
const snapshots = [
@@ -400,6 +423,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
cursor!.snapshot,
grok!.snapshot,
openCode!.snapshot,
+ omp!.snapshot,
];
expect(new Set(snapshots).size).toBe(snapshots.length);
@@ -442,6 +466,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
expect(openCodeSnapshot.continuation?.groupKey).toBe(
`${openCodeDriverKind}:instance:${openCodeId}`,
);
+
+ const ompSnapshot = yield* omp!.snapshot.getSnapshot;
+ expect(ompSnapshot.instanceId).toBe(ompId);
+ expect(ompSnapshot.driver).toBe(ompDriverKind);
+ expect(ompSnapshot.enabled).toBe(false);
+ expect(ompSnapshot.continuation?.groupKey).toBe(`${ompDriverKind}:instance:${ompId}`);
}).pipe(Effect.provide(testLayer)),
);
});
diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
index c78ecb3952a3..0d79661828c3 100644
--- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts
+++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
@@ -1443,6 +1443,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
cursor: { enabled: false },
grok: { enabled: false },
opencode: { enabled: false },
+ omp: { enabled: false },
},
// `providerInstances` keys are branded `ProviderInstanceId`;
// the branded index signature rejects plain string literals
@@ -1555,6 +1556,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
cursor: { enabled: false },
grok: { enabled: false },
opencode: { enabled: false },
+ omp: { enabled: false },
},
}),
),
@@ -1669,6 +1671,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
cursor: { enabled: false },
grok: { enabled: false },
opencode: { enabled: false },
+ omp: { enabled: false },
},
providerInstances: {
ghost_main: {
@@ -1738,6 +1741,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
grok: {
enabled: false,
},
+ omp: {
+ enabled: false,
+ },
},
}),
),
@@ -1807,6 +1813,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
"codex",
"cursor",
"grok",
+ "omp",
"opencode",
]);
assert.strictEqual(cursorProvider?.enabled, false);
diff --git a/apps/server/src/provider/Services/OmpAdapter.ts b/apps/server/src/provider/Services/OmpAdapter.ts
new file mode 100644
index 000000000000..deda46f66071
--- /dev/null
+++ b/apps/server/src/provider/Services/OmpAdapter.ts
@@ -0,0 +1,7 @@
+/**
+ * OmpAdapter — per-instance adapter shape for OMP's ACP runtime.
+ */
+import type { ProviderAdapterError } from "../Errors.ts";
+import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
+
+export interface OmpAdapterShape extends ProviderAdapterShape {}
diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts
index 0aebe0ca6d82..a832cb115ced 100644
--- a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts
+++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts
@@ -6,9 +6,21 @@ import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.
describe("AcpAdapterSupport", () => {
it("maps ACP approval decisions to permission outcomes", () => {
- expect(acpPermissionOutcome("accept")).toBe("allow-once");
- expect(acpPermissionOutcome("acceptForSession")).toBe("allow-always");
- expect(acpPermissionOutcome("decline")).toBe("reject-once");
+ const options = [
+ { optionId: "once", name: "Allow once", kind: "allow_once" as const },
+ { optionId: "always", name: "Always allow", kind: "allow_always" as const },
+ { optionId: "no", name: "Reject", kind: "reject_once" as const },
+ ];
+ expect(acpPermissionOutcome("accept", options)).toBe("once");
+ expect(acpPermissionOutcome("acceptForSession", options)).toBe("always");
+ expect(acpPermissionOutcome("decline", options)).toBe("no");
+ });
+
+ it("falls back to reject_always when reject_once is unavailable", () => {
+ const options = [
+ { optionId: "always-reject", name: "Always reject", kind: "reject_always" as const },
+ ];
+ expect(acpPermissionOutcome("decline", options)).toBe("always-reject");
});
it("maps ACP request errors to provider adapter request errors", () => {
diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts
index cde110e6dd99..3968f5d27c7c 100644
--- a/apps/server/src/provider/acp/AcpAdapterSupport.ts
+++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts
@@ -5,6 +5,7 @@ import {
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import * as EffectAcpErrors from "effect-acp/errors";
+import type * as EffectAcpSchema from "effect-acp/schema";
import {
ProviderAdapterRequestError,
@@ -43,14 +44,24 @@ export function mapAcpToAdapterError(
});
}
-export function acpPermissionOutcome(decision: ProviderApprovalDecision): string {
- switch (decision) {
- case "acceptForSession":
- return "allow-always";
- case "accept":
- return "allow-once";
- case "decline":
- default:
- return "reject-once";
+export function acpPermissionOutcome(
+ decision: ProviderApprovalDecision,
+ options: ReadonlyArray,
+): string | undefined {
+ const desiredKinds =
+ decision === "acceptForSession"
+ ? (["allow_always"] as const)
+ : decision === "accept"
+ ? (["allow_once"] as const)
+ : (["reject_once", "reject_always"] as const);
+ for (const kind of desiredKinds) {
+ const byKind = options.find((option) => option.kind === kind)?.optionId;
+ if (byKind?.trim()) return byKind;
}
+
+ // Some pre-standard ACP agents used kebab-case ids while newer agents,
+ // including OMP, use snake_case ids. `kind` is authoritative, but retain
+ // both id spellings for agents that omit it.
+ const aliases = new Set(desiredKinds.flatMap((kind) => [kind, kind.replaceAll("_", "-")]));
+ return options.find((option) => aliases.has(option.optionId))?.optionId;
}
diff --git a/apps/server/src/provider/acp/AcpElicitation.test.ts b/apps/server/src/provider/acp/AcpElicitation.test.ts
new file mode 100644
index 000000000000..2d28f199545a
--- /dev/null
+++ b/apps/server/src/provider/acp/AcpElicitation.test.ts
@@ -0,0 +1,233 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { buildAcpElicitationForm } from "./AcpElicitation.ts";
+
+describe("AcpElicitation", () => {
+ it("maps labeled selects and booleans back to ACP form values", () => {
+ const form = buildAcpElicitationForm({
+ mode: "form",
+ sessionId: "session-1",
+ message: "Choose how to continue",
+ requestedSchema: {
+ type: "object",
+ required: ["strategy", "confirmed"],
+ properties: {
+ strategy: {
+ type: "string",
+ title: "Strategy",
+ oneOf: [
+ { const: "safe", title: "Safe route" },
+ { const: "fast", title: "Fast route" },
+ ],
+ },
+ confirmed: {
+ type: "boolean",
+ title: "Confirm",
+ description: "Proceed with this strategy?",
+ },
+ },
+ },
+ });
+
+ expect(form?.questions).toEqual([
+ {
+ id: "strategy",
+ header: "Strategy",
+ question: "Strategy",
+ options: [
+ { label: "Safe route", description: "safe" },
+ { label: "Fast route", description: "fast" },
+ ],
+ },
+ {
+ id: "confirmed",
+ header: "Confirm",
+ question: "Proceed with this strategy?",
+ options: [
+ { label: "Yes", description: "true" },
+ { label: "No", description: "false" },
+ ],
+ },
+ ]);
+ expect(
+ form?.resolve({
+ strategy: "Safe route",
+ confirmed: "Yes",
+ }),
+ ).toEqual({
+ action: {
+ action: "accept",
+ content: { strategy: "safe", confirmed: true },
+ },
+ });
+ });
+
+ it("keeps duplicate choice titles distinguishable", () => {
+ const form = buildAcpElicitationForm({
+ mode: "form",
+ sessionId: "session-1",
+ message: "Choose a route",
+ requestedSchema: {
+ required: ["route"],
+ properties: {
+ route: {
+ type: "string",
+ oneOf: [
+ { const: "first", title: "Same label" },
+ { const: "second", title: "Same label" },
+ ],
+ },
+ },
+ },
+ });
+
+ expect(form?.questions[0]?.options).toEqual([
+ { label: "Same label", description: "first" },
+ { label: "Same label (2)", description: "second" },
+ ]);
+ expect(form?.resolve({ route: "Same label (2)" })).toEqual({
+ action: { action: "accept", content: { route: "second" } },
+ });
+ });
+
+ it("accepts defaults for required optionless properties", () => {
+ const form = buildAcpElicitationForm({
+ mode: "form",
+ sessionId: "session-1",
+ message: "Choose a route",
+ requestedSchema: {
+ required: ["route"],
+ properties: {
+ route: { type: "string", default: "default-route" },
+ strategy: { type: "string", enum: ["safe", "fast"] },
+ },
+ },
+ });
+
+ expect(form?.resolve({ strategy: "safe" })).toEqual({
+ action: {
+ action: "accept",
+ content: { route: "default-route", strategy: "safe" },
+ },
+ });
+ });
+
+ it("cancels optionless forms without questions", () => {
+ expect(
+ buildAcpElicitationForm({
+ mode: "form",
+ sessionId: "session-1",
+ message: "Provide context",
+ requestedSchema: {
+ properties: { notes: { type: "string" } },
+ },
+ }),
+ ).toBeUndefined();
+ });
+
+ it("supports multi-select answers", () => {
+ const form = buildAcpElicitationForm({
+ mode: "form",
+ sessionId: "session-1",
+ message: "Select targets",
+ requestedSchema: {
+ required: ["targets"],
+ properties: {
+ targets: {
+ type: "array",
+ description: "Select targets",
+ items: {
+ anyOf: [
+ { const: "web", title: "Web app" },
+ { const: "server", title: "Server" },
+ ],
+ },
+ },
+ },
+ },
+ });
+
+ expect(form?.questions[0]?.multiSelect).toBe(true);
+ expect(
+ form?.resolve({
+ targets: ["Web app", "Server"],
+ }),
+ ).toEqual({
+ action: {
+ action: "accept",
+ content: { targets: ["web", "server"] },
+ },
+ });
+ });
+
+ it("rejects forms containing required optionless free-form or numeric properties", () => {
+ expect(
+ buildAcpElicitationForm({
+ mode: "form",
+ sessionId: "session-1",
+ message: "Name the change",
+ requestedSchema: {
+ required: ["name"],
+ properties: { name: { type: "string" } },
+ },
+ }),
+ ).toBeUndefined();
+ expect(
+ buildAcpElicitationForm({
+ mode: "form",
+ sessionId: "session-1",
+ message: "Choose and explain",
+ requestedSchema: {
+ required: ["strategy", "count"],
+ properties: {
+ strategy: { type: "string", enum: ["safe", "fast"] },
+ count: { type: "integer" },
+ },
+ },
+ }),
+ ).toBeUndefined();
+ });
+
+ it("omits optional optionless properties from the form", () => {
+ const form = buildAcpElicitationForm({
+ mode: "form",
+ sessionId: "session-1",
+ message: "Choose a strategy",
+ requestedSchema: {
+ required: ["strategy"],
+ properties: {
+ strategy: { type: "string", enum: ["safe", "fast"] },
+ notes: { type: "string" },
+ },
+ },
+ });
+
+ expect(form?.questions).toHaveLength(1);
+ expect(form?.questions[0]?.id).toBe("strategy");
+ });
+
+ it("cancels a missing required choice and ignores URL mode", () => {
+ const form = buildAcpElicitationForm({
+ mode: "form",
+ sessionId: "session-1",
+ message: "Choose a strategy",
+ requestedSchema: {
+ required: ["strategy"],
+ properties: { strategy: { type: "string", enum: ["safe", "fast"] } },
+ },
+ });
+
+ expect(form?.resolve({})).toEqual({
+ action: { action: "cancel" },
+ });
+ expect(
+ buildAcpElicitationForm({
+ mode: "url",
+ sessionId: "session-1",
+ elicitationId: "auth-1",
+ message: "Open a browser",
+ url: "https://example.com",
+ }),
+ ).toBeUndefined();
+ });
+});
diff --git a/apps/server/src/provider/acp/AcpElicitation.ts b/apps/server/src/provider/acp/AcpElicitation.ts
new file mode 100644
index 000000000000..ceaa539c8391
--- /dev/null
+++ b/apps/server/src/provider/acp/AcpElicitation.ts
@@ -0,0 +1,206 @@
+import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";
+import type * as EffectAcpSchema from "effect-acp/schema";
+
+type FormElicitationRequest = Extract<
+ EffectAcpSchema.ElicitationRequest,
+ { readonly mode: "form" }
+>;
+type ElicitationProperty = EffectAcpSchema.ElicitationPropertySchema;
+type ElicitationValue = EffectAcpSchema.ElicitationContentValue;
+
+interface ElicitationChoice {
+ readonly label: string;
+ readonly value: string;
+}
+
+export interface AcpElicitationForm {
+ readonly questions: ReadonlyArray;
+ readonly resolve: (answers: ProviderUserInputAnswers) => EffectAcpSchema.ElicitationResponse;
+}
+
+function nonEmpty(value: string | null | undefined): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function withUniqueLabels(
+ choices: ReadonlyArray,
+): ReadonlyArray {
+ const counts = new Map();
+ const used = new Set();
+ return choices.map((choice) => {
+ let occurrence = counts.get(choice.label) ?? 0;
+ let label = choice.label;
+ do {
+ occurrence += 1;
+ label = occurrence === 1 ? choice.label : `${choice.label} (${occurrence})`;
+ } while (used.has(label));
+ counts.set(choice.label, occurrence);
+ used.add(label);
+ return label === choice.label ? choice : { ...choice, label };
+ });
+}
+
+function choicesForProperty(property: ElicitationProperty): ReadonlyArray {
+ if (property.type === "string") {
+ if (property.oneOf && property.oneOf.length > 0) {
+ return withUniqueLabels(
+ property.oneOf.map((option) => ({
+ label: nonEmpty(option.title) ?? option.const,
+ value: option.const,
+ })),
+ );
+ }
+ return withUniqueLabels((property.enum ?? []).map((value) => ({ label: value, value })));
+ }
+ if (property.type === "boolean") {
+ return [
+ { label: "Yes", value: "true" },
+ { label: "No", value: "false" },
+ ];
+ }
+ if (property.type === "array") {
+ if ("anyOf" in property.items) {
+ return withUniqueLabels(
+ property.items.anyOf.map((option) => ({
+ label: nonEmpty(option.title) ?? option.const,
+ value: option.const,
+ })),
+ );
+ }
+ return withUniqueLabels(property.items.enum.map((value) => ({ label: value, value })));
+ }
+ return [];
+}
+
+function questionForProperty(input: {
+ readonly request: FormElicitationRequest;
+ readonly id: string;
+ readonly property: ElicitationProperty;
+ readonly propertyCount: number;
+}): UserInputQuestion {
+ const { request, id, property, propertyCount } = input;
+ const choices = choicesForProperty(property);
+ const propertyTitle = nonEmpty(property.title);
+ const formTitle = nonEmpty(request.requestedSchema.title);
+ const requestMessage = nonEmpty(request.message) ?? "The agent needs additional input.";
+ const propertyDescription = nonEmpty(property.description);
+
+ return {
+ id,
+ header: propertyTitle ?? formTitle ?? "Question",
+ question: propertyDescription ?? (propertyCount === 1 ? requestMessage : (propertyTitle ?? id)),
+ options: choices.map((choice) => ({
+ label: choice.label,
+ description: choice.label === choice.value ? choice.label : choice.value,
+ })),
+ ...(property.type === "array" ? { multiSelect: true } : {}),
+ };
+}
+
+function firstAnswer(value: unknown): unknown {
+ return Array.isArray(value) ? value[0] : value;
+}
+
+function labelToValue(property: ElicitationProperty, raw: string): string {
+ const choice = choicesForProperty(property).find((candidate) => candidate.label === raw);
+ return choice?.value ?? raw;
+}
+
+function resolvePropertyValue(
+ property: ElicitationProperty,
+ rawAnswer: unknown,
+): ElicitationValue | undefined {
+ if (rawAnswer === undefined || rawAnswer === null) {
+ return property.default ?? undefined;
+ }
+
+ switch (property.type) {
+ case "string": {
+ const raw = firstAnswer(rawAnswer);
+ if (typeof raw !== "string") return undefined;
+ return labelToValue(property, raw);
+ }
+ case "boolean": {
+ const raw = firstAnswer(rawAnswer);
+ if (typeof raw === "boolean") return raw;
+ if (typeof raw !== "string") return undefined;
+ const normalized = labelToValue(property, raw).trim().toLowerCase();
+ if (normalized === "true" || normalized === "yes") return true;
+ if (normalized === "false" || normalized === "no") return false;
+ return undefined;
+ }
+ case "integer":
+ case "number": {
+ const raw = firstAnswer(rawAnswer);
+ const parsed = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
+ if (!Number.isFinite(parsed)) return undefined;
+ if (property.type === "integer" && !Number.isInteger(parsed)) return undefined;
+ return parsed;
+ }
+ case "array": {
+ const rawValues = Array.isArray(rawAnswer) ? rawAnswer : [rawAnswer];
+ if (!rawValues.every((value) => typeof value === "string")) return undefined;
+ return rawValues.map((value) => labelToValue(property, value));
+ }
+ }
+}
+/**
+ * Project an ACP form elicitation onto T3 Code's provider-neutral structured
+ * question surface. URL elicitations are intentionally not handled because
+ * the client advertises only `elicitation.form` support.
+ */
+export function buildAcpElicitationForm(
+ request: EffectAcpSchema.ElicitationRequest,
+): AcpElicitationForm | undefined {
+ if (request.mode !== "form") return undefined;
+
+ const propertyEntries = Object.entries(request.requestedSchema.properties ?? {});
+ if (propertyEntries.length === 0) return undefined;
+ const required = new Set(request.requestedSchema.required ?? []);
+ // T3's structured user-input surface currently renders selectable options
+ // only. A required optionless question cannot be answered, so cancel the
+ // whole form. Optional optionless properties are omitted and resolve as
+ // absent.
+ if (
+ propertyEntries.some(
+ ([id, property]) =>
+ required.has(id) &&
+ property.default === undefined &&
+ choicesForProperty(property).length === 0,
+ )
+ ) {
+ return undefined;
+ }
+
+ const questionEntries = propertyEntries.filter(
+ ([, property]) => choicesForProperty(property).length > 0,
+ );
+ if (questionEntries.length === 0) return undefined;
+ const questions = questionEntries.map(([id, property]) =>
+ questionForProperty({
+ request,
+ id,
+ property,
+ propertyCount: questionEntries.length,
+ }),
+ );
+
+ return {
+ questions,
+ resolve: (answers) => {
+ const content: Record = {};
+ for (const [id, property] of propertyEntries) {
+ const value = resolvePropertyValue(property, answers[id]);
+ if (value === undefined) {
+ if (required.has(id)) {
+ return { action: { action: "cancel" } };
+ }
+ continue;
+ }
+ content[id] = value;
+ }
+ return { action: { action: "accept", content } };
+ },
+ };
+}
diff --git a/apps/server/src/provider/acp/OmpAcpSupport.test.ts b/apps/server/src/provider/acp/OmpAcpSupport.test.ts
new file mode 100644
index 000000000000..5a980a248c1d
--- /dev/null
+++ b/apps/server/src/provider/acp/OmpAcpSupport.test.ts
@@ -0,0 +1,108 @@
+import * as Effect from "effect/Effect";
+import type * as EffectAcpSchema from "effect-acp/schema";
+import { describe, expect } from "vite-plus/test";
+import { it } from "@effect/vitest";
+
+import { applyOmpAcpModelSelection, buildOmpAcpSpawnInput } from "./OmpAcpSupport.ts";
+
+describe("buildOmpAcpSpawnInput", () => {
+ it("maps T3 runtime modes to explicit OMP approval behavior", () => {
+ expect(buildOmpAcpSpawnInput(undefined, "/tmp/project", "approval-required")).toEqual({
+ command: "omp",
+ args: ["acp", "--approval-mode", "always-ask"],
+ cwd: "/tmp/project",
+ });
+ expect(buildOmpAcpSpawnInput(undefined, "/tmp/project", "auto-accept-edits")).toEqual({
+ command: "omp",
+ args: ["acp", "--approval-mode", "write"],
+ cwd: "/tmp/project",
+ });
+ expect(buildOmpAcpSpawnInput(undefined, "/tmp/project", "auto")).toEqual({
+ command: "omp",
+ args: ["acp", "--approval-mode", "write"],
+ cwd: "/tmp/project",
+ });
+ expect(buildOmpAcpSpawnInput(undefined, "/tmp/project", "full-access")).toEqual({
+ command: "omp",
+ args: ["acp", "--approval-mode", "yolo"],
+ cwd: "/tmp/project",
+ });
+ });
+
+ it("uses the configured binary, profile, and environment", () => {
+ const environment = { OMP_TEST: "1" };
+ expect(
+ buildOmpAcpSpawnInput(
+ { binaryPath: "/opt/omp/bin/omp", profile: "work" },
+ "/tmp/project",
+ "full-access",
+ environment,
+ ),
+ ).toEqual({
+ command: "/opt/omp/bin/omp",
+ args: ["acp", "--profile", "work", "--approval-mode", "yolo"],
+ cwd: "/tmp/project",
+ env: environment,
+ });
+ });
+});
+
+describe("applyOmpAcpModelSelection", () => {
+ it.effect("sets the exact model before applying options advertised by that model", () =>
+ Effect.gen(function* () {
+ const calls: Array<
+ | { readonly type: "model"; readonly value: string }
+ | { readonly type: "config"; readonly configId: string; readonly value: string | boolean }
+ > = [];
+ let configOptions: ReadonlyArray = [];
+ const runtime = {
+ getConfigOptions: Effect.sync(() => configOptions),
+ setModel: (value: string) =>
+ Effect.sync(() => {
+ calls.push({ type: "model", value });
+ configOptions = [
+ {
+ id: "model",
+ name: "Model",
+ category: "model",
+ type: "select",
+ currentValue: value,
+ options: [{ value, name: value }],
+ },
+ {
+ id: "thinking",
+ name: "Thinking",
+ category: "thought_level",
+ type: "select",
+ currentValue: "off",
+ options: [
+ { value: "off", name: "Off" },
+ { value: "high", name: "High" },
+ ],
+ },
+ ];
+ }),
+ setConfigOption: (configId: string, value: string | boolean) =>
+ Effect.sync(() => {
+ calls.push({ type: "config", configId, value });
+ }),
+ };
+
+ yield* applyOmpAcpModelSelection({
+ runtime,
+ model: "openai-codex/gpt-5.5",
+ selections: [
+ { id: "thinking", value: "high" },
+ { id: "stale-option", value: true },
+ { id: "model", value: "must-not-be-replayed" },
+ ],
+ mapError: ({ cause }) => cause.message,
+ });
+
+ expect(calls).toEqual([
+ { type: "model", value: "openai-codex/gpt-5.5" },
+ { type: "config", configId: "thinking", value: "high" },
+ ]);
+ }),
+ );
+});
diff --git a/apps/server/src/provider/acp/OmpAcpSupport.ts b/apps/server/src/provider/acp/OmpAcpSupport.ts
new file mode 100644
index 000000000000..53de4a3e1059
--- /dev/null
+++ b/apps/server/src/provider/acp/OmpAcpSupport.ts
@@ -0,0 +1,152 @@
+import {
+ type OmpSettings,
+ type ProviderOptionSelection,
+ type RuntimeMode,
+} from "@t3tools/contracts";
+import * as Crypto from "effect/Crypto";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Scope from "effect/Scope";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+import type * as EffectAcpErrors from "effect-acp/errors";
+
+import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
+import { findSessionConfigOption } from "./AcpRuntimeModel.ts";
+
+type AcpSpawnInput = AcpSessionRuntime.AcpSpawnInput;
+type AcpSessionRuntimeShape = AcpSessionRuntime.AcpSessionRuntime["Service"];
+
+type OmpAcpRuntimeSettings = Pick;
+
+export interface OmpAcpRuntimeInput extends Omit<
+ AcpSessionRuntime.AcpSessionRuntimeOptions,
+ "authMethodId" | "clientCapabilities" | "spawn"
+> {
+ readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
+ readonly ompSettings: OmpAcpRuntimeSettings | null | undefined;
+ readonly environment?: NodeJS.ProcessEnv;
+ /**
+ * Text-generation and other non-session callers default to full access so
+ * a headless OMP child cannot deadlock on an unhandled approval request.
+ */
+ readonly runtimeMode?: RuntimeMode;
+}
+
+export interface OmpAcpModelSelectionErrorContext {
+ readonly cause: EffectAcpErrors.AcpError;
+ readonly step: "set-config-option" | "set-model";
+ readonly configId?: string;
+}
+
+function approvalModeArgs(runtimeMode: RuntimeMode): ReadonlyArray {
+ switch (runtimeMode) {
+ case "approval-required":
+ return ["--approval-mode", "always-ask"];
+ case "auto-accept-edits":
+ case "auto":
+ // OMP has no AI-review approval mode; "auto" degrades to auto-accept
+ // edits while still surfacing execution approvals to the user.
+ return ["--approval-mode", "write"];
+ case "full-access":
+ return ["--approval-mode", "yolo"];
+ }
+}
+
+export function buildOmpAcpSpawnInput(
+ ompSettings: OmpAcpRuntimeSettings | null | undefined,
+ cwd: string,
+ runtimeMode: RuntimeMode = "full-access",
+ environment?: NodeJS.ProcessEnv,
+): AcpSpawnInput {
+ const profile = ompSettings?.profile?.trim();
+ return {
+ command: ompSettings?.binaryPath || "omp",
+ args: ["acp", ...(profile ? ["--profile", profile] : []), ...approvalModeArgs(runtimeMode)],
+ cwd,
+ ...(environment ? { env: environment } : {}),
+ };
+}
+
+export const makeOmpAcpRuntime = (
+ input: OmpAcpRuntimeInput,
+): Effect.Effect<
+ AcpSessionRuntime.AcpSessionRuntime["Service"],
+ EffectAcpErrors.AcpError,
+ Crypto.Crypto | Scope.Scope
+> =>
+ Effect.gen(function* () {
+ const acpContext = yield* Layer.build(
+ AcpSessionRuntime.layer({
+ ...input,
+ spawn: buildOmpAcpSpawnInput(
+ input.ompSettings,
+ input.cwd,
+ input.runtimeMode ?? "full-access",
+ input.environment,
+ ),
+ authMethodId: "agent",
+ clientCapabilities: {
+ elicitation: { form: {} },
+ },
+ }).pipe(
+ Layer.provide(
+ Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
+ ),
+ ),
+ );
+ return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
+ Effect.provide(acpContext),
+ );
+ });
+
+interface OmpAcpModelSelectionRuntime {
+ readonly getConfigOptions: AcpSessionRuntimeShape["getConfigOptions"];
+ readonly setConfigOption: (
+ configId: string,
+ value: string | boolean,
+ ) => Effect.Effect;
+ readonly setModel: (model: string) => Effect.Effect;
+}
+
+/**
+ * Apply OMP's exact ACP model id, then replay provider-option selections that
+ * still exist for that model. OMP refreshes `configOptions` after a model
+ * switch, so stale options from a previous model are safely ignored.
+ */
+export function applyOmpAcpModelSelection(input: {
+ readonly runtime: OmpAcpModelSelectionRuntime;
+ readonly model: string | null | undefined;
+ readonly selections: ReadonlyArray | null | undefined;
+ readonly mapError: (context: OmpAcpModelSelectionErrorContext) => E;
+}): Effect.Effect {
+ return Effect.gen(function* () {
+ const model = input.model?.trim();
+ if (model) {
+ yield* input.runtime.setModel(model).pipe(
+ Effect.mapError((cause) =>
+ input.mapError({
+ cause,
+ step: "set-model",
+ }),
+ ),
+ );
+ }
+
+ const configOptions = yield* input.runtime.getConfigOptions;
+ for (const selection of input.selections ?? []) {
+ const option = findSessionConfigOption(configOptions, selection.id);
+ if (!option || option.category === "model" || option.category === "mode") {
+ continue;
+ }
+ yield* input.runtime.setConfigOption(option.id, selection.value).pipe(
+ Effect.mapError((cause) =>
+ input.mapError({
+ cause,
+ step: "set-config-option",
+ configId: option.id,
+ }),
+ ),
+ );
+ }
+ });
+}
diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts
index 791a96e1da3c..5300e6908a85 100644
--- a/apps/server/src/provider/builtInDrivers.ts
+++ b/apps/server/src/provider/builtInDrivers.ts
@@ -25,6 +25,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
+import { OmpDriver, type OmpDriverEnv } from "./Drivers/OmpDriver.ts";
import type { AnyProviderDriver } from "./ProviderDriver.ts";
/**
@@ -37,7 +38,8 @@ export type BuiltInDriversEnv =
| CodexDriverEnv
| CursorDriverEnv
| GrokDriverEnv
- | OpenCodeDriverEnv;
+ | OpenCodeDriverEnv
+ | OmpDriverEnv;
/**
* Ordered list of built-in drivers. Order matters only for tie-breaking in
@@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray {
);
}).pipe(Effect.provide(makeServerSettingsLayer())),
);
+ it.effect("does not invent a static model selector when only OMP is enabled", () =>
+ Effect.gen(function* () {
+ const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
+ const provisionalModel = "gpt-5.4-mini";
+
+ const next = yield* serverSettings.updateSettings({
+ providers: {
+ codex: { enabled: false },
+ claudeAgent: { enabled: false },
+ grok: { enabled: false },
+ cursor: { enabled: false },
+ opencode: { enabled: false },
+ omp: { enabled: true },
+ },
+ textGenerationModelSelection: {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: provisionalModel,
+ },
+ });
+
+ assert.deepEqual(next.textGenerationModelSelection, {
+ instanceId: ProviderInstanceId.make("omp"),
+ model: provisionalModel,
+ });
+ }).pipe(Effect.provide(makeServerSettingsLayer())),
+ );
it.effect("drops stale text generation options when resetting model selection", () =>
Effect.gen(function* () {
diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts
index 2798faf6f006..1f3373c675b8 100644
--- a/apps/server/src/serverSettings.ts
+++ b/apps/server/src/serverSettings.ts
@@ -11,7 +11,6 @@
* @module ServerSettings
*/
import {
- DEFAULT_TEXT_GENERATION_MODEL,
DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
DEFAULT_MODEL_BY_PROVIDER,
DEFAULT_SERVER_SETTINGS,
@@ -199,10 +198,16 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
...settings,
textGenerationModelSelection: {
instanceId: ProviderInstanceId.make(fallback),
+ // Some providers (notably OMP) expose a credential-dependent model
+ // inventory and therefore have no universally valid static default.
+ // Keep the previous model as a provisional value for those providers;
+ // the text-generation router resolves it against the selected
+ // instance's live snapshot before dispatch. This avoids manufacturing a
+ // provider/model selector that the provider is guaranteed to reject.
model:
DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??
DEFAULT_MODEL_BY_PROVIDER[fallback] ??
- DEFAULT_TEXT_GENERATION_MODEL,
+ settings.textGenerationModelSelection.model,
} satisfies ModelSelection,
};
}
diff --git a/apps/server/src/textGeneration/OmpTextGeneration.test.ts b/apps/server/src/textGeneration/OmpTextGeneration.test.ts
new file mode 100644
index 000000000000..b3d357e56001
--- /dev/null
+++ b/apps/server/src/textGeneration/OmpTextGeneration.test.ts
@@ -0,0 +1,129 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeFS from "node:fs";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as NodeURL from "node:url";
+
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { it } from "@effect/vitest";
+import { OmpSettings, ProviderInstanceId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import { expect } from "vite-plus/test";
+
+import { ServerConfig } from "../config.ts";
+import { makeOmpTextGeneration } from "./OmpTextGeneration.ts";
+
+const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
+const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");
+const decodeOmpSettings = Schema.decodeSync(OmpSettings);
+const encodeUnknownJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
+const decodeRequestLogEntry = Schema.decodeUnknownSync(
+ Schema.fromJsonString(
+ Schema.Struct({
+ method: Schema.optionalKey(Schema.String),
+ params: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
+ }),
+ ),
+);
+const OmpTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3code-omp-text-generation-test-",
+}).pipe(Layer.provideMerge(NodeServices.layer));
+
+function shellSingleQuote(value: string): string {
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
+}
+
+function makeOmpWrapper(dir: string, env: Record): string {
+ const binDir = NodePath.join(dir, "bin");
+ const ompPath = NodePath.join(binDir, "omp");
+ NodeFS.mkdirSync(binDir, { recursive: true });
+ NodeFS.writeFileSync(
+ ompPath,
+ [
+ "#!/bin/sh",
+ ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
+ 'if [ "$1" != "acp" ]; then',
+ ' printf "%s\\n" "unexpected args: $*" >&2',
+ " exit 11",
+ "fi",
+ `exec bun ${JSON.stringify(mockAgentPath)}`,
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ NodeFS.chmodSync(ompPath, 0o755);
+ return ompPath;
+}
+
+it.layer(OmpTextGenerationTestLayer)("OmpTextGeneration", (it) => {
+ it.effect("uses the exact OMP provider/model selector for structured generation", () =>
+ Effect.gen(function* () {
+ const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-omp-text-acp-"));
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ NodeFS.rmSync(tempDir, { recursive: true, force: true });
+ }),
+ );
+
+ const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
+ const model = "openai-codex/gpt-5.4";
+ const ompPath = makeOmpWrapper(tempDir, {
+ T3_ACP_EXTRA_MODEL_ID: model,
+ T3_ACP_REQUEST_LOG_PATH: requestLogPath,
+ T3_ACP_PROMPT_RESPONSE_TEXT: encodeUnknownJson({
+ subject: "Add OMP structured generation",
+ body: "- select the exact provider model",
+ }),
+ });
+ const textGeneration = yield* makeOmpTextGeneration(
+ decodeOmpSettings({ binaryPath: ompPath }),
+ );
+
+ const generated = yield* textGeneration.generateCommitMessage({
+ cwd: process.cwd(),
+ branch: "feature/omp-text-generation",
+ stagedSummary: "M apps/server/src/textGeneration/OmpTextGeneration.ts",
+ stagedPatch:
+ "diff --git a/apps/server/src/textGeneration/OmpTextGeneration.ts b/apps/server/src/textGeneration/OmpTextGeneration.ts",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("omp"),
+ model,
+ },
+ });
+
+ expect(generated).toEqual({
+ subject: "Add OMP structured generation",
+ body: "- select the exact provider model",
+ });
+
+ const requests = NodeFS.readFileSync(requestLogPath, "utf8")
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => decodeRequestLogEntry(line));
+ expect(requests.find((request) => request.method === "authenticate")?.params).toMatchObject({
+ methodId: "agent",
+ });
+ expect(
+ requests.some(
+ (request) =>
+ request.method === "session/set_config_option" &&
+ request.params?.configId === "model" &&
+ request.params?.value === model,
+ ),
+ ).toBe(true);
+ expect(
+ requests.find((request) => request.method === "session/prompt")?.params?.prompt,
+ ).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: "text",
+ text: expect.stringContaining("Staged patch:"),
+ }),
+ ]),
+ );
+ }).pipe(Effect.scoped),
+ );
+});
diff --git a/apps/server/src/textGeneration/OmpTextGeneration.ts b/apps/server/src/textGeneration/OmpTextGeneration.ts
new file mode 100644
index 000000000000..2bb169e225e9
--- /dev/null
+++ b/apps/server/src/textGeneration/OmpTextGeneration.ts
@@ -0,0 +1,232 @@
+import * as Crypto from "effect/Crypto";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Ref from "effect/Ref";
+import * as Schema from "effect/Schema";
+import { ChildProcessSpawner } from "effect/unstable/process";
+
+import { TextGenerationError, type ModelSelection, type OmpSettings } from "@t3tools/contracts";
+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
+import { extractJsonObject } from "@t3tools/shared/schemaJson";
+
+import * as TextGeneration from "./TextGeneration.ts";
+import {
+ buildBranchNamePrompt,
+ buildCommitMessagePrompt,
+ buildPrContentPrompt,
+ buildThreadTitlePrompt,
+} from "./TextGenerationPrompts.ts";
+import {
+ sanitizeCommitSubject,
+ sanitizePrTitle,
+ sanitizeThreadTitle,
+} from "./TextGenerationUtils.ts";
+import { applyOmpAcpModelSelection, makeOmpAcpRuntime } from "../provider/acp/OmpAcpSupport.ts";
+
+const OMP_TIMEOUT_MS = 180_000;
+const isTextGenerationError = Schema.is(TextGenerationError);
+
+/** Build an OMP text-generation closure bound to one provider instance. */
+export const makeOmpTextGeneration = Effect.fn("makeOmpTextGeneration")(function* (
+ ompSettings: OmpSettings,
+ environment?: NodeJS.ProcessEnv,
+) {
+ const crypto = yield* Crypto.Crypto;
+ const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const resolvedEnvironment = environment ?? process.env;
+
+ const runOmpJson = ({
+ operation,
+ cwd,
+ prompt,
+ outputSchemaJson,
+ modelSelection,
+ }: {
+ operation:
+ | "generateCommitMessage"
+ | "generatePrContent"
+ | "generateBranchName"
+ | "generateThreadTitle";
+ cwd: string;
+ prompt: string;
+ outputSchemaJson: S;
+ modelSelection: ModelSelection;
+ }): Effect.Effect =>
+ Effect.gen(function* () {
+ const outputRef = yield* Ref.make("");
+ const runtime = yield* makeOmpAcpRuntime({
+ ompSettings,
+ environment: resolvedEnvironment,
+ childProcessSpawner: commandSpawner,
+ cwd,
+ runtimeMode: "full-access",
+ clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
+ }).pipe(Effect.provideService(Crypto.Crypto, crypto));
+
+ yield* runtime.handleSessionUpdate((notification) => {
+ const update = notification.update;
+ if (update.sessionUpdate !== "agent_message_chunk") return Effect.void;
+ const content = update.content;
+ if (content.type !== "text") return Effect.void;
+ return Ref.update(outputRef, (current) => current + content.text);
+ });
+
+ const promptResult = yield* Effect.gen(function* () {
+ yield* runtime.start();
+ yield* Effect.ignore(runtime.setMode("ask"));
+ yield* applyOmpAcpModelSelection({
+ runtime,
+ model: modelSelection.model,
+ selections: modelSelection.options,
+ mapError: ({ cause, configId, step }) =>
+ new TextGenerationError({
+ operation,
+ detail:
+ step === "set-config-option"
+ ? `Failed to set OMP ACP config option "${configId}" for text generation.`
+ : "Failed to set OMP ACP model for text generation.",
+ cause,
+ }),
+ });
+ return yield* runtime.prompt({ prompt: [{ type: "text", text: prompt }] });
+ }).pipe(
+ Effect.timeoutOption(OMP_TIMEOUT_MS),
+ Effect.flatMap(
+ Option.match({
+ onNone: () =>
+ Effect.fail(new TextGenerationError({ operation, detail: "OMP request timed out." })),
+ onSome: (value) => Effect.succeed(value),
+ }),
+ ),
+ Effect.mapError((cause) =>
+ isTextGenerationError(cause)
+ ? cause
+ : new TextGenerationError({ operation, detail: "OMP ACP request failed.", cause }),
+ ),
+ );
+
+ const rawResult = (yield* Ref.get(outputRef)).trim();
+ if (!rawResult) {
+ return yield* new TextGenerationError({
+ operation,
+ detail:
+ promptResult.stopReason === "cancelled"
+ ? "OMP ACP request was cancelled."
+ : "OMP returned empty output.",
+ });
+ }
+
+ const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson));
+ return yield* decodeOutput(extractJsonObject(rawResult)).pipe(
+ Effect.catchTags({
+ SchemaError: (cause) =>
+ Effect.fail(
+ new TextGenerationError({
+ operation,
+ detail: "OMP returned invalid structured output.",
+ cause,
+ }),
+ ),
+ }),
+ );
+ }).pipe(
+ Effect.mapError((cause) =>
+ isTextGenerationError(cause)
+ ? cause
+ : new TextGenerationError({
+ operation,
+ detail: "OMP ACP text generation failed.",
+ cause,
+ }),
+ ),
+ Effect.scoped,
+ );
+
+ const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
+ Effect.fn("OmpTextGeneration.generateCommitMessage")(function* (input) {
+ const { prompt, outputSchema } = buildCommitMessagePrompt({
+ branch: input.branch,
+ stagedSummary: input.stagedSummary,
+ stagedPatch: input.stagedPatch,
+ includeBranch: input.includeBranch === true,
+ policy: input.policy,
+ });
+ const generated = yield* runOmpJson({
+ operation: "generateCommitMessage",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return {
+ subject: sanitizeCommitSubject(generated.subject),
+ body: generated.body.trim(),
+ ...("branch" in generated && typeof generated.branch === "string"
+ ? { branch: sanitizeFeatureBranchName(generated.branch) }
+ : {}),
+ };
+ });
+
+ const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
+ Effect.fn("OmpTextGeneration.generatePrContent")(function* (input) {
+ const { prompt, outputSchema } = buildPrContentPrompt({
+ baseBranch: input.baseBranch,
+ headBranch: input.headBranch,
+ commitSummary: input.commitSummary,
+ diffSummary: input.diffSummary,
+ diffPatch: input.diffPatch,
+ policy: input.policy,
+ changeRequestTemplate: input.changeRequestTemplate,
+ });
+ const generated = yield* runOmpJson({
+ operation: "generatePrContent",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return { title: sanitizePrTitle(generated.title), body: generated.body.trim() };
+ });
+
+ const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
+ Effect.fn("OmpTextGeneration.generateBranchName")(function* (input) {
+ const { prompt, outputSchema } = buildBranchNamePrompt({
+ message: input.message,
+ attachments: input.attachments,
+ });
+ const generated = yield* runOmpJson({
+ operation: "generateBranchName",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return { branch: sanitizeBranchFragment(generated.branch) };
+ });
+
+ const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
+ Effect.fn("OmpTextGeneration.generateThreadTitle")(function* (input) {
+ const { prompt, outputSchema } = buildThreadTitlePrompt({
+ message: input.message,
+ previousTitle: input.previousTitle,
+ attachments: input.attachments,
+ });
+ const generated = yield* runOmpJson({
+ operation: "generateThreadTitle",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return {
+ title: sanitizeThreadTitle(generated.title),
+ } satisfies TextGeneration.ThreadTitleGenerationResult;
+ });
+
+ return {
+ generateCommitMessage,
+ generatePrContent,
+ generateBranchName,
+ generateThreadTitle,
+ } satisfies TextGeneration.TextGeneration["Service"];
+});
diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts
index 9bccb9c1fc5b..5b23f8a42da3 100644
--- a/apps/server/src/textGeneration/TextGeneration.test.ts
+++ b/apps/server/src/textGeneration/TextGeneration.test.ts
@@ -5,7 +5,12 @@ import * as Result from "effect/Result";
import * as Stream from "effect/Stream";
import { describe, expect } from "vite-plus/test";
-import { ProviderInstanceId } from "@t3tools/contracts";
+import {
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type ServerProvider,
+ type ServerProviderModel,
+} from "@t3tools/contracts";
import { createModelSelection } from "@t3tools/shared/model";
import type { ProviderInstance } from "../provider/ProviderDriver.ts";
@@ -27,21 +32,44 @@ const makeStubTextGeneration = (
const makeStubInstance = (
instanceId: ProviderInstanceId,
textGeneration: TextGeneration.TextGeneration["Service"],
+ options?: {
+ readonly driverKind?: ProviderDriverKind;
+ readonly currentModels?: ReadonlyArray;
+ readonly refreshedModels?: ReadonlyArray;
+ readonly onRefresh?: () => void;
+ },
): ProviderInstance =>
({
instanceId,
- driverKind: instanceId as unknown as ProviderInstance["driverKind"],
+ driverKind: options?.driverKind ?? (instanceId as unknown as ProviderInstance["driverKind"]),
continuationIdentity: {
- driverKind: instanceId as unknown as ProviderInstance["driverKind"],
+ driverKind: options?.driverKind ?? (instanceId as unknown as ProviderInstance["driverKind"]),
continuationKey: `${instanceId}:test`,
},
displayName: undefined,
enabled: true,
- snapshot: {} as ProviderInstance["snapshot"],
+ snapshot: {
+ maintenanceCapabilities: {} as ProviderInstance["snapshot"]["maintenanceCapabilities"],
+ getSnapshot: Effect.succeed({ models: options?.currentModels ?? [] } as ServerProvider),
+ refresh: Effect.sync(() => {
+ options?.onRefresh?.();
+ return {
+ models: options?.refreshedModels ?? options?.currentModels ?? [],
+ } as ServerProvider;
+ }),
+ streamChanges: Stream.empty,
+ },
adapter: {} as ProviderInstance["adapter"],
textGeneration,
}) satisfies ProviderInstance;
+const serverModel = (slug: string, isCustom = false): ServerProviderModel => ({
+ slug,
+ name: slug,
+ isCustom,
+ capabilities: null,
+});
+
const makeStubRegistry = (
instances: ReadonlyArray,
): ProviderInstanceRegistry.ProviderInstanceRegistry["Service"] => {
@@ -118,4 +146,112 @@ describe("makeTextGenerationFromRegistry", () => {
}
}),
);
+
+ it.effect("resolves a provisional OMP model from the refreshed live inventory", () =>
+ Effect.gen(function* () {
+ const instanceId = ProviderInstanceId.make("omp");
+ let refreshCount = 0;
+ let dispatchedSelection:
+ | Parameters<
+ TextGeneration.TextGeneration["Service"]["generateBranchName"]
+ >[0]["modelSelection"]
+ | undefined;
+ const instance = makeStubInstance(
+ instanceId,
+ makeStubTextGeneration({
+ generateBranchName: (input) => {
+ dispatchedSelection = input.modelSelection;
+ return Effect.succeed({ branch: "omp-branch" });
+ },
+ }),
+ {
+ driverKind: ProviderDriverKind.make("omp"),
+ currentModels: [],
+ refreshedModels: [
+ serverModel("anthropic/claude-sonnet-4-6"),
+ serverModel("local/qwen", true),
+ ],
+ onRefresh: () => {
+ refreshCount += 1;
+ },
+ },
+ );
+ const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([instance]));
+
+ const result = yield* tg.generateBranchName({
+ cwd: process.cwd(),
+ message: "Use the live OMP model inventory",
+ modelSelection: createModelSelection(instanceId, "gpt-5.4-mini", [
+ { id: "thinking", value: "high" },
+ ]),
+ });
+
+ expect(result.branch).toBe("omp-branch");
+ expect(refreshCount).toBe(1);
+ expect(dispatchedSelection).toEqual({
+ instanceId,
+ model: "anthropic/claude-sonnet-4-6",
+ });
+ }),
+ );
+
+ it.effect("fails clearly when OMP refresh still exposes no models", () =>
+ Effect.gen(function* () {
+ const instanceId = ProviderInstanceId.make("omp");
+ const instance = makeStubInstance(instanceId, makeStubTextGeneration({}), {
+ driverKind: ProviderDriverKind.make("omp"),
+ currentModels: [],
+ refreshedModels: [],
+ });
+ const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([instance]));
+
+ const result = yield* tg
+ .generateBranchName({
+ cwd: process.cwd(),
+ message: "anything",
+ modelSelection: createModelSelection(instanceId, "gpt-5.4-mini"),
+ })
+ .pipe(Effect.result);
+
+ expect(Result.isFailure(result)).toBe(true);
+ if (Result.isFailure(result)) {
+ expect(result.failure.operation).toBe("generateBranchName");
+ expect(result.failure.detail).toContain("has no available models");
+ }
+ }),
+ );
+ it.effect("refreshes a non-empty OMP cache when the requested model is missing", () =>
+ Effect.gen(function* () {
+ const instanceId = ProviderInstanceId.make("omp");
+ let refreshCount = 0;
+ let dispatchedModel: string | undefined;
+ const instance = makeStubInstance(
+ instanceId,
+ makeStubTextGeneration({
+ generateBranchName: (input) => {
+ dispatchedModel = input.modelSelection.model;
+ return Effect.succeed({ branch: "omp-branch" });
+ },
+ }),
+ {
+ driverKind: ProviderDriverKind.make("omp"),
+ currentModels: [serverModel("stale/model")],
+ refreshedModels: [serverModel("new/model")],
+ onRefresh: () => {
+ refreshCount += 1;
+ },
+ },
+ );
+ const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([instance]));
+
+ yield* tg.generateBranchName({
+ cwd: process.cwd(),
+ message: "Use the refreshed OMP model",
+ modelSelection: createModelSelection(instanceId, "new/model"),
+ });
+
+ expect(refreshCount).toBe(1);
+ expect(dispatchedModel).toBe("new/model");
+ }),
+ );
});
diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts
index 66b7ccd465f1..d2b5c72dad47 100644
--- a/apps/server/src/textGeneration/TextGeneration.ts
+++ b/apps/server/src/textGeneration/TextGeneration.ts
@@ -1,14 +1,22 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
-import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
-import { TextGenerationError } from "@t3tools/contracts";
+import {
+ ProviderDriverKind,
+ TextGenerationError,
+ type ChatAttachment,
+ type ModelSelection,
+ type ProviderInstanceId,
+} from "@t3tools/contracts";
import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
import type { ProviderInstance } from "../provider/ProviderDriver.ts";
import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
-export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
+/** Any registered provider driver can supply the required text-generation shape. */
+export type TextGenerationProvider = ProviderDriverKind;
+
+const OMP_DRIVER_KIND = ProviderDriverKind.make("omp");
export interface CommitMessageGenerationInput {
cwd: string;
@@ -129,11 +137,11 @@ const resolveInstance = (
registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"],
operation: TextGenerationOp,
instanceId: ProviderInstanceId,
-): Effect.Effect =>
+): Effect.Effect =>
registry.getInstance(instanceId).pipe(
Effect.flatMap((instance) =>
instance
- ? Effect.succeed(instance.textGeneration)
+ ? Effect.succeed(instance)
: Effect.fail(
new TextGenerationError({
operation,
@@ -143,25 +151,103 @@ const resolveInstance = (
),
);
+/**
+ * OMP's available selectors depend on the credentials and provider
+ * configuration of each instance, so no static fallback can be valid for
+ * every installation. Resolve provisional or stale OMP selections at the
+ * dispatch boundary, where both the live model snapshot and text-generation
+ * closure are available without introducing a settings/registry cycle.
+ */
+const resolveOmpModelSelection = (
+ instance: ProviderInstance,
+ operation: TextGenerationOp,
+ selection: ModelSelection,
+): Effect.Effect => {
+ if (instance.driverKind !== OMP_DRIVER_KIND) {
+ return Effect.succeed(selection);
+ }
+
+ return Effect.gen(function* () {
+ const current = yield* instance.snapshot.getSnapshot;
+ if (current.models.some((model) => model.slug === selection.model)) {
+ return selection;
+ }
+
+ // Refresh whenever the requested selector is absent. A non-empty cache can
+ // still be stale after switching to a newly discovered OMP model.
+ const resolvedSnapshot = yield* instance.snapshot.refresh;
+ if (resolvedSnapshot.models.some((model) => model.slug === selection.model)) {
+ return selection;
+ }
+
+ const fallback =
+ resolvedSnapshot.models.find((model) => !model.isCustom) ?? resolvedSnapshot.models[0];
+ if (!fallback) {
+ return yield* new TextGenerationError({
+ operation,
+ detail: `OMP provider instance '${instance.instanceId}' has no available models for text generation. Configure OMP credentials or add a custom model, then refresh providers.`,
+ });
+ }
+
+ // Provider options belong to the stale model and may not exist on the
+ // discovered fallback. Do not send them through OMP's strict ACP option
+ // validation.
+ return {
+ instanceId: instance.instanceId,
+ model: fallback.slug,
+ } satisfies ModelSelection;
+ });
+};
+
+const resolveTextGenerationTarget = (
+ registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"],
+ operation: TextGenerationOp,
+ selection: ModelSelection,
+): Effect.Effect<
+ {
+ readonly textGeneration: ProviderInstance["textGeneration"];
+ readonly modelSelection: ModelSelection;
+ },
+ TextGenerationError
+> =>
+ resolveInstance(registry, operation, selection.instanceId).pipe(
+ Effect.flatMap((instance) =>
+ resolveOmpModelSelection(instance, operation, selection).pipe(
+ Effect.map((modelSelection) => ({
+ textGeneration: instance.textGeneration,
+ modelSelection,
+ })),
+ ),
+ ),
+ );
+
export const makeTextGenerationFromRegistry = (
registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"],
): TextGeneration["Service"] =>
TextGeneration.of({
generateCommitMessage: (input) =>
- resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe(
- Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)),
+ resolveTextGenerationTarget(registry, "generateCommitMessage", input.modelSelection).pipe(
+ Effect.flatMap(({ textGeneration, modelSelection }) =>
+ textGeneration.generateCommitMessage({ ...input, modelSelection }),
+ ),
),
generatePrContent: (input) =>
- resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe(
- Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)),
+ resolveTextGenerationTarget(registry, "generatePrContent", input.modelSelection).pipe(
+ Effect.flatMap(({ textGeneration, modelSelection }) =>
+ textGeneration.generatePrContent({ ...input, modelSelection }),
+ ),
),
generateBranchName: (input) =>
- resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe(
- Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)),
+ resolveTextGenerationTarget(registry, "generateBranchName", input.modelSelection).pipe(
+ Effect.flatMap(({ textGeneration, modelSelection }) =>
+ textGeneration.generateBranchName({ ...input, modelSelection }),
+ ),
),
generateThreadTitle: (input) =>
- resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe(
- Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)),
+ resolveTextGenerationTarget(registry, "generateThreadTitle", input.modelSelection).pipe(
+ Effect.flatMap(({ textGeneration, modelSelection }) =>
+ textGeneration.generateThreadTitle({ ...input, modelSelection }),
+ ),
),
});
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx
index 8ea38c519588..e6c4d41dd178 100644
--- a/apps/web/src/components/Icons.tsx
+++ b/apps/web/src/components/Icons.tsx
@@ -663,6 +663,25 @@ export const OpenCodeIcon: Icon = (props) => (
);
+/** Adapted from Oh My Pi's official `assets/icon.svg`. */
+export const OmpIcon: Icon = ({ className, ...props }) => (
+
+
+
+
+
+
+
+
+
+
+);
+
export const GithubCopilotIcon: Icon = ({ className, ...props }) => (
> = {
@@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial
[ProviderDriverKind.make("opencode")]: OpenCodeIcon,
[ProviderDriverKind.make("cursor")]: CursorIcon,
[ProviderDriverKind.make("grok")]: GrokIcon,
+ [ProviderDriverKind.make("omp")]: OmpIcon,
};
function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx
index 5bd4fdc08c4a..9290ff6cb188 100644
--- a/apps/web/src/components/settings/DiagnosticsSettings.tsx
+++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx
@@ -299,7 +299,7 @@ function formatProcessName(command: string): string {
function formatProcessType(process: ServerProcessDiagnosticsEntry): string {
if (process.depth > 0) return "Subprocess";
- if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent";
+ if (/\b(codex|claude|opencode|cursor|omp)\b/i.test(process.command)) return "Agent";
return "Process";
}
diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx
index 7ae26b278657..46fa2b8288b3 100644
--- a/apps/web/src/components/settings/ProviderModelsSection.tsx
+++ b/apps/web/src/components/settings/ProviderModelsSection.tsx
@@ -35,6 +35,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial {
});
});
+ it("derives OMP binary and profile fields from its settings schema", () => {
+ const omp = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("omp")];
+
+ expect(omp).toBeDefined();
+ expect(deriveProviderSettingsFields(omp!)).toMatchObject([
+ {
+ key: "binaryPath",
+ label: "Binary path",
+ placeholder: "omp",
+ },
+ {
+ key: "profile",
+ label: "Profile",
+ placeholder: "e.g. work",
+ },
+ ]);
+ });
+
it("preserves unknown config keys while omitting empty configurable fields", () => {
const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];
expect(opencode).toBeDefined();
diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts
index bfee6a8d6807..fc5c49ad99fc 100644
--- a/apps/web/src/components/settings/providerDriverMeta.ts
+++ b/apps/web/src/components/settings/providerDriverMeta.ts
@@ -3,11 +3,12 @@ import {
CodexSettings,
CursorSettings,
GrokSettings,
+ OmpSettings,
OpenCodeSettings,
ProviderDriverKind,
} from "@t3tools/contracts";
import type * as Schema from "effect/Schema";
-import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";
+import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OmpIcon, OpenAI, OpenCodeIcon } from "../Icons";
type ProviderSettingsSchema = {
readonly fields: Readonly>;
@@ -67,6 +68,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
icon: OpenCodeIcon,
settingsSchema: OpenCodeSettings,
},
+ {
+ value: ProviderDriverKind.make("omp"),
+ label: "OMP",
+ icon: OmpIcon,
+ settingsSchema: OmpSettings,
+ },
];
export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial<
diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts
index c127dfba175e..d0610e253a72 100644
--- a/apps/web/src/composerDraftStore.test.ts
+++ b/apps/web/src/composerDraftStore.test.ts
@@ -24,9 +24,11 @@ const CODEX_INSTANCE = ProviderInstanceId.make("codex");
const CODEX_SECONDARY_INSTANCE = ProviderInstanceId.make("codex_secondary");
const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent");
const CURSOR_INSTANCE = ProviderInstanceId.make("cursor");
+const OMP_INSTANCE = ProviderInstanceId.make("omp");
const CODEX_DRIVER = ProviderDriverKind.make("codex");
const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent");
const CURSOR_DRIVER = ProviderDriverKind.make("cursor");
+const OMP_DRIVER = ProviderDriverKind.make("omp");
type ProviderOptionSelectionBag = ReadonlyArray;
type ProviderOptionSelectionsByProvider = Partial>;
@@ -134,6 +136,18 @@ function resetComposerDraftStore() {
});
}
+function mergePersistedComposerDraftState(persistedState: unknown) {
+ const persistApi = useComposerDraftStore.persist as unknown as {
+ getOptions: () => {
+ merge: (
+ persistedState: unknown,
+ currentState: ReturnType,
+ ) => ReturnType;
+ };
+ };
+ return persistApi.getOptions().merge(persistedState, useComposerDraftStore.getInitialState());
+}
+
function modelSelection(
provider: ProviderDriverKind,
model: string,
@@ -446,38 +460,27 @@ describe("composerDraftStore terminal contexts", () => {
});
it("hydrates persisted terminal contexts without in-memory snapshot text", () => {
- const persistApi = useComposerDraftStore.persist as unknown as {
- getOptions: () => {
- merge: (
- persistedState: unknown,
- currentState: ReturnType,
- ) => ReturnType;
- };
- };
- const mergedState = persistApi.getOptions().merge(
- {
- draftsByThreadId: {
- [threadId]: {
- prompt: INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
- attachments: [],
- terminalContexts: [
- {
- id: "ctx-rehydrated",
- threadId,
- createdAt: "2026-03-13T12:00:00.000Z",
- terminalId: "default",
- terminalLabel: "Terminal 1",
- lineStart: 4,
- lineEnd: 5,
- },
- ],
- },
+ const mergedState = mergePersistedComposerDraftState({
+ draftsByThreadId: {
+ [threadId]: {
+ prompt: INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
+ attachments: [],
+ terminalContexts: [
+ {
+ id: "ctx-rehydrated",
+ threadId,
+ createdAt: "2026-03-13T12:00:00.000Z",
+ terminalId: "default",
+ terminalLabel: "Terminal 1",
+ lineStart: 4,
+ lineEnd: 5,
+ },
+ ],
},
- draftThreadsByThreadId: {},
- projectDraftThreadIdByProjectKey: {},
},
- useComposerDraftStore.getInitialState(),
- );
+ draftThreadsByThreadId: {},
+ projectDraftThreadIdByProjectKey: {},
+ });
expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.terminalContexts).toMatchObject([
{
@@ -492,30 +495,19 @@ describe("composerDraftStore terminal contexts", () => {
});
it("sanitizes malformed persisted drafts during merge", () => {
- const persistApi = useComposerDraftStore.persist as unknown as {
- getOptions: () => {
- merge: (
- persistedState: unknown,
- currentState: ReturnType,
- ) => ReturnType;
- };
- };
- const mergedState = persistApi.getOptions().merge(
- {
- draftsByThreadId: {
- [threadId]: {
- prompt: "",
- attachments: "not-an-array",
- terminalContexts: "not-an-array",
- provider: "bogus-provider",
- modelOptions: "not-an-object",
- },
+ const mergedState = mergePersistedComposerDraftState({
+ draftsByThreadId: {
+ [threadId]: {
+ prompt: "",
+ attachments: "not-an-array",
+ terminalContexts: "not-an-array",
+ provider: "bogus-provider",
+ modelOptions: "not-an-object",
},
- draftThreadsByThreadId: "not-an-object",
- projectDraftThreadIdByProjectKey: "not-an-object",
},
- useComposerDraftStore.getInitialState(),
- );
+ draftThreadsByThreadId: "not-an-object",
+ projectDraftThreadIdByProjectKey: "not-an-object",
+ });
expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined();
expect(mergedState.draftThreadsByThreadKey).toEqual({});
@@ -678,6 +670,33 @@ describe("composerDraftStore review comments", () => {
]);
});
});
+describe("composerDraftStore legacy provider option migration", () => {
+ it("preserves OMP options and the exact provider/model selector", () => {
+ const threadId = ThreadId.make("thread-legacy-omp-options");
+ const mergedState = mergePersistedComposerDraftState({
+ draftsByThreadId: {
+ [threadId]: {
+ prompt: "",
+ attachments: [],
+ provider: "omp",
+ model: "openai-codex/gpt-5.4",
+ modelOptions: {
+ omp: { thinking: "high" },
+ },
+ },
+ },
+ draftThreadsByThreadId: {},
+ projectDraftThreadIdByProjectKey: {},
+ });
+
+ expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toMatchObject({
+ modelSelectionByProvider: {
+ omp: modelSelection(OMP_DRIVER, "openai-codex/gpt-5.4", { thinking: "high" }),
+ },
+ activeProvider: OMP_INSTANCE,
+ });
+ });
+});
describe("composerDraftStore project draft thread mapping", () => {
const projectId = ProjectId.make("project-a");
@@ -1440,6 +1459,17 @@ describe("composerDraftStore modelSelection", () => {
expect(draft?.activeProvider).toBe("claudeAgent");
});
+ it("updates OMP options without a hard-coded provider allowlist", () => {
+ const store = useComposerDraftStore.getState();
+
+ store.setModelSelection(threadRef, modelSelection(OMP_DRIVER, "openai-codex/gpt-5.4"));
+ store.setModelOptions(threadRef, providerModelOptions({ omp: { thinking: "high" } }));
+
+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[OMP_INSTANCE]).toEqual(
+ modelSelection(OMP_DRIVER, "openai-codex/gpt-5.4", { thinking: "high" }),
+ );
+ });
+
it("creates the first sticky snapshot from provider option changes", () => {
const store = useComposerDraftStore.getState();
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
index ebafd3b04d29..7efb752c40be 100644
--- a/apps/web/src/composerDraftStore.ts
+++ b/apps/web/src/composerDraftStore.ts
@@ -788,6 +788,31 @@ function coerceProviderOptionSelections(
return undefined;
}
+/**
+ * Read a provider-keyed option bag without baking the currently installed
+ * drivers into draft persistence. `ProviderDriverKind` is deliberately open,
+ * so migrations and store updates must preserve any valid driver slug.
+ */
+function providerModelOptionEntries(
+ value: unknown,
+): ReadonlyArray<
+ readonly [ProviderDriverKind, ReadonlyArray | undefined]
+> {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return [];
+ }
+ const result: Array<
+ readonly [ProviderDriverKind, ReadonlyArray | undefined]
+ > = [];
+ for (const [rawProvider, rawSelections] of Object.entries(value)) {
+ const provider = normalizeProviderDriverKind(rawProvider);
+ if (provider) {
+ result.push([provider, coerceProviderOptionSelections(rawSelections)]);
+ }
+ }
+ return result;
+}
+
/**
* Normalize a per-provider options bag from either the v3 or legacy v2 shape.
*
@@ -800,12 +825,10 @@ function normalizeProviderModelOptions(
provider?: ProviderDriverKind | null,
legacy?: LegacyCodexFields,
): ProviderOptionSelectionsByProvider | null {
- const candidate = value && typeof value === "object" ? (value as Record) : null;
const result: ProviderOptionSelectionsByProvider = {};
- for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
- const selections = coerceProviderOptionSelections(candidate?.[providerKey]);
+ for (const [provider, selections] of providerModelOptionEntries(value)) {
if (selections) {
- result[providerKey] = selections;
+ result[provider] = selections;
}
}
@@ -961,10 +984,8 @@ function legacyToModelSelectionByProvider(
): Partial> {
const result: Partial> = {};
if (modelOptions) {
- for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
- const options = modelOptions[provider];
+ for (const [driverKind, options] of providerModelOptionEntries(modelOptions)) {
if (options && options.length > 0) {
- const driverKind = ProviderDriverKind.make(provider);
const instanceKey = defaultInstanceIdForDriver(driverKind);
result[instanceKey] = createModelSelection(
instanceKey,
@@ -2763,10 +2784,7 @@ const composerDraftStore = create()(
}
const base = existing ?? createEmptyThreadDraft();
const nextMap = { ...base.modelSelectionByProvider };
- for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
- if (!modelOptions || !(provider in modelOptions)) continue;
- const opts = modelOptions[provider];
- const driverKind = ProviderDriverKind.make(provider);
+ for (const [driverKind, opts] of providerModelOptionEntries(modelOptions)) {
const instanceKey = defaultInstanceIdForDriver(driverKind);
const current = nextMap[instanceKey];
if (opts && opts.length > 0) {
diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts
index 2d0d7bb02134..4702887e2211 100644
--- a/apps/web/src/providerInstances.test.ts
+++ b/apps/web/src/providerInstances.test.ts
@@ -130,6 +130,15 @@ describe("deriveProviderInstanceEntries", () => {
expect(entry?.driverKind).toBe("codex");
expect(entry?.isDefault).toBe(false);
});
+
+ it("uses the OMP brand label for its default instance", () => {
+ const [entry] = deriveProviderInstanceEntries([
+ provider({ provider: ProviderDriverKind.make("omp"), instanceId: "omp" }),
+ ]);
+
+ expect(entry?.displayName).toBe("OMP");
+ expect(entry?.isDefault).toBe(true);
+ });
});
describe("resolveSelectableProviderInstance", () => {
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 4d0a76cf133b..e6aa8f99a85f 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{
available: true,
pickerSidebarBadge: "new",
},
+ {
+ value: ProviderDriverKind.make("omp"),
+ label: "OMP",
+ available: true,
+ pickerSidebarBadge: "new",
+ },
];
export type WorkLogToolLifecycleStatus =
diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md
index da16f74d339f..9e3eca2fe2ac 100644
--- a/docs/internals/glossary.md
+++ b/docs/internals/glossary.md
@@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is
#### Provider
-The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter.
+The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, OpenCode, and OMP. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter.
#### Session
diff --git a/docs/internals/overview.md b/docs/internals/overview.md
index b9454f7b58d0..3514dca806c7 100644
--- a/docs/internals/overview.md
+++ b/docs/internals/overview.md
@@ -18,13 +18,13 @@ there, never in the client.
┌──────────────────▼─────────────────────────────┐
│ apps/server │
│ orchestration engine (event-sourced) │
-│ provider driver registry (5 built-in drivers) │
+│ provider driver registry (6 built-in drivers) │
│ checkpointing, VCS, terminals, filesystem │
└──────────────────┬─────────────────────────────┘
│ per-driver transport
┌──────────────────▼─────────────────────────────┐
│ Agent CLIs: Codex, Claude, Cursor, Grok, │
-│ OpenCode │
+│ OpenCode, Oh My Pi │
└────────────────────────────────────────────────┘
```
@@ -106,8 +106,8 @@ build production behavior on receipts.
## Provider drivers
-Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`:
-Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a
+Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`:
+Codex, Claude, Cursor, Grok, OpenCode, and OMP. A driver declares its kind and config schema and creates a
scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves
an instance to its adapter, so `ProviderService` routes session and turn operations without knowing
which agent is behind them. See [providers.md](./providers.md).
diff --git a/docs/internals/providers.md b/docs/internals/providers.md
index a309d70f03de..6d7f478732ef 100644
--- a/docs/internals/providers.md
+++ b/docs/internals/providers.md
@@ -7,7 +7,7 @@ orchestration layer does not know which one is behind a thread.
## Built-in drivers
-[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries:
+[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries:
| Driver kind | Driver source |
| ------------- | --------------------------------------- |
@@ -16,6 +16,7 @@ orchestration layer does not know which one is behind a thread.
| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |
| `grok` | [`Drivers/GrokDriver.ts`][grok] |
| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |
+| `omp` | [`Drivers/OmpDriver.ts`][omp] |
Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an
adapter in a child scope. Adapter implementations live beside them in
@@ -80,6 +81,7 @@ when a request opens (approval) or user input is requested, via
[claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts
[cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts
[grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts
+[omp]: ../../apps/server/src/provider/Drivers/OmpDriver.ts
[opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts
[adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts
[instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts
diff --git a/docs/user/install.md b/docs/user/install.md
index fe0b418ca1e5..8d92d48bfe21 100644
--- a/docs/user/install.md
+++ b/docs/user/install.md
@@ -53,6 +53,7 @@ to use, then authenticate it.
| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` |
| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` |
| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` |
+| Oh My Pi | [OMP](https://omp.sh) | `omp` | `omp` then `/login` |
Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that
T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`.
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index 9fcd0d266dd6..5851e45086ad 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
+const OMP_DRIVER_KIND = ProviderDriverKind.make("omp");
export const DEFAULT_MODEL = "gpt-5.6-sol";
@@ -222,4 +223,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial>
[CURSOR_DRIVER_KIND]: "Cursor",
[GROK_DRIVER_KIND]: "Grok",
[OPENCODE_DRIVER_KIND]: "OpenCode",
+ [OMP_DRIVER_KIND]: "OMP",
};
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 46705837afa4..8d49baaaa47e 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -122,6 +122,12 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
// Legacy `providers` struct is still hydrated with its per-driver defaults
// so existing call sites keep working through the migration.
expect(decoded.providers.codex.enabled).toBe(true);
+ expect(decoded.providers.omp).toEqual({
+ enabled: true,
+ binaryPath: "omp",
+ profile: "",
+ customModels: [],
+ });
});
it("decodes a multi-instance map mixing first-party and fork drivers", () => {
@@ -250,6 +256,10 @@ describe("ServerSettingsPatch string normalization", () => {
homePath: " ~/.codex ",
launchArgs: " --strict-config --enable foo ",
},
+ omp: {
+ binaryPath: " /opt/homebrew/bin/omp ",
+ profile: " work ",
+ },
},
providerInstances: {
codex_personal: {
@@ -266,6 +276,8 @@ describe("ServerSettingsPatch string normalization", () => {
expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");
expect(patch.providers?.codex?.homePath).toBe("~/.codex");
expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo");
+ expect(patch.providers?.omp?.binaryPath).toBe("/opt/homebrew/bin/omp");
+ expect(patch.providers?.omp?.profile).toBe("work");
expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe(
"codex",
);
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 388205649c85..6f89ebd3982c 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -471,6 +471,41 @@ export const OpenCodeSettings = makeProviderSettingsSchema(
);
export type OpenCodeSettings = typeof OpenCodeSettings.Type;
+export const OmpSettings = makeProviderSettingsSchema(
+ {
+ enabled: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(true)),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ binaryPath: makeBinaryPathSetting("omp").pipe(
+ Schema.annotateKey({
+ title: "Binary path",
+ description: "Path to the OMP binary used by this instance.",
+ providerSettingsForm: { placeholder: "omp", clearWhenEmpty: "omit" },
+ }),
+ ),
+ profile: TrimmedString.pipe(
+ Schema.withDecodingDefault(Effect.succeed("")),
+ Schema.annotateKey({
+ title: "Profile",
+ description: "OMP profile to use for this instance.",
+ providerSettingsForm: {
+ placeholder: "e.g. work",
+ clearWhenEmpty: "omit",
+ },
+ }),
+ ),
+ customModels: Schema.Array(Schema.String).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ },
+ {
+ order: ["binaryPath", "profile"],
+ },
+);
+export type OmpSettings = typeof OmpSettings.Type;
+
export const ObservabilitySettings = Schema.Struct({
otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
@@ -601,6 +636,7 @@ export const ServerSettings = Schema.Struct({
cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ omp: OmpSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
}).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
// New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
// are `ProviderInstanceConfig` envelopes. The driver-specific config blob
@@ -704,6 +740,13 @@ const OpenCodeSettingsPatch = Schema.Struct({
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
});
+const OmpSettingsPatch = Schema.Struct({
+ enabled: Schema.optionalKey(Schema.Boolean),
+ binaryPath: Schema.optionalKey(TrimmedString),
+ profile: Schema.optionalKey(TrimmedString),
+ customModels: Schema.optionalKey(Schema.Array(Schema.String)),
+});
+
export const ServerSettingsPatch = Schema.Struct({
// Server settings
enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
@@ -744,6 +787,7 @@ export const ServerSettingsPatch = Schema.Struct({
cursor: Schema.optionalKey(CursorSettingsPatch),
grok: Schema.optionalKey(GrokSettingsPatch),
opencode: Schema.optionalKey(OpenCodeSettingsPatch),
+ omp: Schema.optionalKey(OmpSettingsPatch),
}),
),
// Whole-map replacement for the new instance config. Patching individual