From 94eedd7c7716ecc954723c4b6ce9cd3fec06d500 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 01:38:31 -0700 Subject: [PATCH 1/6] Add ACP registry provider support - Introduce ACP Registry driver and client scaffolding - Thread provider icon URLs through server and UI models - Expand Cursor ACP adapter hooks for alternate providers --- .../src/provider/Drivers/AcpRegistryDriver.ts | 181 +++ .../src/provider/Drivers/ClaudeDriver.ts | 6 +- .../src/provider/Drivers/CodexDriver.ts | 6 +- .../src/provider/Drivers/CursorDriver.ts | 6 +- .../src/provider/Drivers/OpenCodeDriver.ts | 6 +- .../src/provider/Layers/CursorAdapter.ts | 145 ++- .../Layers/ProviderInstanceRegistryLive.ts | 4 + apps/server/src/provider/ProviderDriver.ts | 2 + .../provider/Services/AcpRegistryClient.ts | 13 + .../src/provider/acp/CursorAcpSupport.ts | 13 +- apps/server/src/provider/builtInDrivers.ts | 5 +- apps/server/src/provider/providerSnapshot.ts | 2 + .../provider/unavailableProviderSnapshot.ts | 2 + apps/server/src/ws.ts | 462 +++++++ .../components/KeybindingsToast.browser.tsx | 1 + apps/web/src/components/chat/ChatComposer.tsx | 8 +- apps/web/src/components/chat/ModelListRow.tsx | 12 +- .../components/chat/ModelPickerContent.tsx | 26 +- .../components/chat/ModelPickerSidebar.tsx | 1 + .../components/chat/ProviderInstanceIcon.tsx | 23 +- .../components/chat/ProviderModelPicker.tsx | 1 + .../src/components/chat/providerIconUtils.ts | 3 +- .../settings/AddProviderInstanceDialog.tsx | 1137 +++++++++++++---- .../settings/ProviderInstanceCard.tsx | 1 + .../components/settings/SettingsPanels.tsx | 3 +- .../components/settings/providerDriverMeta.ts | 9 +- apps/web/src/localApi.ts | 8 + apps/web/src/modelSelection.ts | 20 +- apps/web/src/providerInstances.test.ts | 20 + apps/web/src/providerInstances.ts | 13 + apps/web/src/rpc/wsRpcClient.ts | 8 + packages/contracts/src/acp.ts | 104 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 9 + packages/contracts/src/model.ts | 5 + packages/contracts/src/providerInstance.ts | 1 + packages/contracts/src/rpc.ts | 22 + packages/contracts/src/server.ts | 1 + packages/contracts/src/settings.ts | 35 + 39 files changed, 1979 insertions(+), 346 deletions(-) create mode 100644 apps/server/src/provider/Drivers/AcpRegistryDriver.ts create mode 100644 apps/server/src/provider/Services/AcpRegistryClient.ts create mode 100644 packages/contracts/src/acp.ts diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts new file mode 100644 index 0000000000..2b69b3d461 --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -0,0 +1,181 @@ +import { + AcpRegistrySettings, + ProviderDriverKind, + TextGenerationError, + type ServerProvider, +} from "@t3tools/contracts"; +import { Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { + buildServerProvider, + providerModelsFromSettings, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); + +export type AcpRegistryDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +const unsupportedTextGeneration = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Generic ACP providers do not support git text generation yet.", + }), + ); + +const makeTextGeneration = () => ({ + generateCommitMessage: () => unsupportedTextGeneration("generateCommitMessage"), + generatePrContent: () => unsupportedTextGeneration("generatePrContent"), + generateBranchName: () => unsupportedTextGeneration("generateBranchName"), + generateThreadTitle: () => unsupportedTextGeneration("generateThreadTitle"), +}); + +function withIdentity(input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly iconUrl: string | undefined; + readonly continuationGroupKey: string; +}) { + return (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + ...(input.iconUrl ? { iconUrl: input.iconUrl } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); +} + +function initialSnapshot(settings: AcpRegistrySettings): ServerProviderDraft { + const checkedAt = new Date().toISOString(); + const command = settings.command.trim(); + const enabled = settings.enabled && command.length > 0; + return buildServerProvider({ + presentation: { + displayName: "ACP Registry", + badgeLabel: "ACP", + showInteractionModeToggle: true, + }, + enabled, + checkedAt, + models: providerModelsFromSettings( + [{ slug: "default", name: "Default", isCustom: false, capabilities: null }], + DRIVER_KIND, + settings.customModels, + { optionDescriptors: [] }, + ), + probe: { + installed: enabled, + version: null, + status: enabled ? "ready" : "warning", + auth: { status: "unknown" }, + message: enabled ? "ACP provider configured." : "Configure a launch command to enable.", + }, + }); +} + +export const AcpRegistryDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "ACP Registry", + supportsMultipleInstances: true, + }, + configSchema: AcpRegistrySettings, + defaultConfig: (): AcpRegistrySettings => Schema.decodeSync(AcpRegistrySettings)({}), + create: ({ instanceId, displayName, accentColor, iconUrl, environment, enabled, config }) => + Effect.gen(function* () { + const eventLoggers = yield* ProviderEventLoggers; + const effectiveConfig = { ...config, enabled: enabled && config.enabled }; + const effectiveIconUrl = iconUrl ?? effectiveConfig.iconUrl; + const processEnv = { + ...effectiveConfig.env, + ...mergeProviderInstanceEnvironment(environment), + }; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stamp = withIdentity({ + instanceId, + displayName, + accentColor, + iconUrl: effectiveIconUrl, + continuationGroupKey: continuationIdentity.continuationKey, + }); + + const adapter = yield* makeCursorAdapter( + { + enabled: effectiveConfig.enabled, + binaryPath: effectiveConfig.command || "acp", + apiEndpoint: "", + customModels: effectiveConfig.customModels, + }, + { + provider: DRIVER_KIND, + instanceId, + environment: processEnv, + readyReason: "ACP session ready", + applyCursorModelOptions: false, + normalizeModel: (model) => model?.trim() || "default", + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + spawn: ({ cwd, environment: spawnEnv }) => ({ + command: effectiveConfig.command.trim(), + args: effectiveConfig.args, + cwd, + ...(spawnEnv ? { env: spawnEnv } : {}), + }), + }, + ); + + const snapshot = yield* makeManagedServerProvider({ + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => stamp(initialSnapshot(settings)), + checkProvider: Effect.succeed(stamp(initialSnapshot(effectiveConfig))), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build ACP Registry snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + iconUrl: effectiveIconUrl, + enabled: effectiveConfig.enabled, + snapshot, + adapter, + textGeneration: makeTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 311f495865..f9ecdef7b1 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -52,6 +52,7 @@ const withInstanceIdentity = readonly instanceId: ProviderInstance["instanceId"]; readonly displayName: string | undefined; readonly accentColor: string | undefined; + readonly iconUrl: string | undefined; readonly continuationGroupKey: string; }) => (snapshot: ServerProviderDraft): ServerProvider => ({ @@ -60,6 +61,7 @@ const withInstanceIdentity = driver: DRIVER_KIND, ...(input.displayName ? { displayName: input.displayName } : {}), ...(input.accentColor ? { accentColor: input.accentColor } : {}), + ...(input.iconUrl ? { iconUrl: input.iconUrl } : {}), continuation: { groupKey: input.continuationGroupKey }, }); @@ -71,7 +73,7 @@ export const ClaudeDriver: ProviderDriver = { }, configSchema: ClaudeSettings, defaultConfig: (): ClaudeSettings => Schema.decodeSync(ClaudeSettings)({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + create: ({ instanceId, displayName, accentColor, iconUrl, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; @@ -87,6 +89,7 @@ export const ClaudeDriver: ProviderDriver = { instanceId, displayName, accentColor, + iconUrl, continuationGroupKey, }); @@ -148,6 +151,7 @@ export const ClaudeDriver: ProviderDriver = { }, displayName, accentColor, + iconUrl, enabled, snapshot, adapter, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 26fffd5e21..cb4c42171e 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -67,6 +67,7 @@ const withInstanceIdentity = readonly instanceId: ProviderInstance["instanceId"]; readonly displayName: string | undefined; readonly accentColor: string | undefined; + readonly iconUrl: string | undefined; readonly continuationGroupKey: string; }) => (snapshot: ServerProviderDraft): ServerProvider => ({ @@ -75,6 +76,7 @@ const withInstanceIdentity = driver: DRIVER_KIND, ...(input.displayName ? { displayName: input.displayName } : {}), ...(input.accentColor ? { accentColor: input.accentColor } : {}), + ...(input.iconUrl ? { iconUrl: input.iconUrl } : {}), continuation: { groupKey: input.continuationGroupKey }, }); @@ -86,7 +88,7 @@ export const CodexDriver: ProviderDriver = { }, configSchema: CodexSettings, defaultConfig: (): CodexSettings => Schema.decodeSync(CodexSettings)({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + create: ({ instanceId, displayName, accentColor, iconUrl, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const eventLoggers = yield* ProviderEventLoggers; @@ -97,6 +99,7 @@ export const CodexDriver: ProviderDriver = { instanceId, displayName, accentColor, + iconUrl, continuationGroupKey: continuationIdentity.continuationKey, }); yield* materializeCodexShadowHome(homeLayout).pipe( @@ -162,6 +165,7 @@ export const CodexDriver: ProviderDriver = { continuationIdentity, displayName, accentColor, + iconUrl, enabled, snapshot, adapter, diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index cd058800f2..3097245a64 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -50,6 +50,7 @@ const withInstanceIdentity = readonly instanceId: ProviderInstance["instanceId"]; readonly displayName: string | undefined; readonly accentColor: string | undefined; + readonly iconUrl: string | undefined; readonly continuationGroupKey: string; }) => (snapshot: ServerProviderDraft): ServerProvider => ({ @@ -58,6 +59,7 @@ const withInstanceIdentity = driver: DRIVER_KIND, ...(input.displayName ? { displayName: input.displayName } : {}), ...(input.accentColor ? { accentColor: input.accentColor } : {}), + ...(input.iconUrl ? { iconUrl: input.iconUrl } : {}), continuation: { groupKey: input.continuationGroupKey }, }); @@ -69,7 +71,7 @@ export const CursorDriver: ProviderDriver = { }, configSchema: CursorSettings, defaultConfig: (): CursorSettings => Schema.decodeSync(CursorSettings)({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + create: ({ instanceId, displayName, accentColor, iconUrl, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; @@ -84,6 +86,7 @@ export const CursorDriver: ProviderDriver = { instanceId, displayName, accentColor, + iconUrl, continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies CursorSettings; @@ -139,6 +142,7 @@ export const CursorDriver: ProviderDriver = { continuationIdentity, displayName, accentColor, + iconUrl, enabled, snapshot, adapter, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 27f98a9830..2d232f750f 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -51,6 +51,7 @@ const withInstanceIdentity = readonly instanceId: ProviderInstance["instanceId"]; readonly displayName: string | undefined; readonly accentColor: string | undefined; + readonly iconUrl: string | undefined; readonly continuationGroupKey: string; }) => (snapshot: ServerProviderDraft): ServerProvider => ({ @@ -59,6 +60,7 @@ const withInstanceIdentity = driver: DRIVER_KIND, ...(input.displayName ? { displayName: input.displayName } : {}), ...(input.accentColor ? { accentColor: input.accentColor } : {}), + ...(input.iconUrl ? { iconUrl: input.iconUrl } : {}), continuation: { groupKey: input.continuationGroupKey }, }); @@ -70,7 +72,7 @@ export const OpenCodeDriver: ProviderDriver }, configSchema: OpenCodeSettings, defaultConfig: (): OpenCodeSettings => Schema.decodeSync(OpenCodeSettings)({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + create: ({ instanceId, displayName, accentColor, iconUrl, environment, enabled, config }) => Effect.gen(function* () { const openCodeRuntime = yield* OpenCodeRuntime; const serverConfig = yield* ServerConfig; @@ -84,6 +86,7 @@ export const OpenCodeDriver: ProviderDriver instanceId, displayName, accentColor, + iconUrl, continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; @@ -126,6 +129,7 @@ export const OpenCodeDriver: ProviderDriver continuationIdentity, displayName, accentColor, + iconUrl, enabled, snapshot, adapter, diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 34d1221b02..3518006737 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -87,6 +87,20 @@ export interface CursorAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; + readonly provider?: ProviderDriverKind; + readonly readyReason?: string; + readonly spawn?: (input: { + readonly settings: CursorSettings; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; + }) => { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + }; + readonly normalizeModel?: (model: string | null | undefined) => string; + readonly applyCursorModelOptions?: boolean; /** * Selections are honored when `modelSelection.instanceId` matches this value. * Defaults to the legacy built-in instance id (`cursor`). @@ -244,6 +258,8 @@ function applyRequestedSessionConfiguration(input: { readonly options?: ReadonlyArray | null | undefined; } | undefined; + readonly normalizeModel: (model: string | null | undefined) => string; + readonly applyCursorModelOptions: boolean; readonly mapError: (context: { readonly cause: import("effect-acp/errors").AcpError; readonly method: "session/set_config_option" | "session/set_mode"; @@ -251,16 +267,30 @@ function applyRequestedSessionConfiguration(input: { }): 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", - }), - }); + if (input.applyCursorModelOptions) { + yield* applyCursorAcpModelSelection({ + runtime: input.runtime, + model: input.modelSelection.model, + selections: input.modelSelection.options, + mapError: ({ cause }) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + }); + } else { + const normalizedModel = input.normalizeModel(input.modelSelection.model); + if (normalizedModel !== "default") { + yield* input.runtime.setModel(normalizedModel).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + ), + ); + } + } } const requestedModeId = resolveRequestedModeId({ @@ -304,6 +334,10 @@ export function makeCursorAdapter( options?: CursorAdapterLiveOptions, ) { return Effect.gen(function* () { + const providerKind = options?.provider ?? PROVIDER; + const readyReason = options?.readyReason ?? "Cursor ACP session ready"; + const normalizeModel = options?.normalizeModel ?? resolveCursorAcpBaseModelId; + const applyCursorModelOptions = options?.applyCursorModelOptions ?? true; const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor"); const fileSystem = yield* FileSystem.FileSystem; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -365,7 +399,7 @@ export function makeCursorAdapter( event: { id: crypto.randomUUID(), kind: "notification", - provider: PROVIDER, + provider: providerKind, createdAt: observedAt, method, threadId, @@ -398,7 +432,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpPlanUpdatedEvent({ stamp: yield* makeEventStamp(), - provider: PROVIDER, + provider: providerKind, threadId: ctx.threadId, turnId: ctx.activeTurnId, payload, @@ -415,7 +449,7 @@ export function makeCursorAdapter( const ctx = sessions.get(threadId); if (!ctx || ctx.stopped) { return Effect.fail( - new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + new ProviderAdapterSessionNotFoundError({ provider: providerKind, threadId }), ); } return Effect.succeed(ctx); @@ -435,7 +469,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent({ type: "session.exited", ...(yield* makeEventStamp()), - provider: PROVIDER, + provider: providerKind, threadId: ctx.threadId, payload: { exitKind: "graceful" }, }); @@ -445,16 +479,16 @@ export function makeCursorAdapter( withThreadLock( input.threadId, Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== PROVIDER) { + if (input.provider !== undefined && input.provider !== providerKind) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider: providerKind, operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + issue: `Expected provider '${providerKind}' but received '${input.provider}'.`, }); } if (!input.cwd?.trim()) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider: providerKind, operation: "startSession", issue: "cwd is required and must be non-empty.", }); @@ -480,7 +514,7 @@ export function makeCursorAdapter( const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; const acpNativeLoggers = makeAcpNativeLoggers({ nativeEventLogger, - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, }); @@ -499,6 +533,15 @@ export function makeCursorAdapter( const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, ...(options?.environment ? { environment: options.environment } : {}), + ...(options?.spawn + ? { + spawn: options.spawn({ + settings: effectiveCursorSettings, + cwd, + ...(options.environment ? { environment: options.environment } : {}), + }), + } + : {}), childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), @@ -509,7 +552,7 @@ export function makeCursorAdapter( Effect.mapError( (cause) => new ProviderAdapterProcessError({ - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, detail: cause.message, cause, @@ -532,7 +575,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent({ type: "user-input.requested", ...(yield* makeEventStamp()), - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, @@ -548,7 +591,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent({ type: "user-input.resolved", ...(yield* makeEventStamp()), - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, @@ -568,7 +611,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent({ type: "turn.proposed.completed", ...(yield* makeEventStamp()), - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, turnId: ctx?.activeTurnId, payload: { planMarkdown: extractPlanMarkdown(params) }, @@ -633,7 +676,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpRequestOpenedEvent({ stamp: yield* makeEventStamp(), - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, @@ -650,7 +693,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpRequestResolvedEvent({ stamp: yield* makeEventStamp(), - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, @@ -672,7 +715,7 @@ export function makeCursorAdapter( return yield* acp.start(); }).pipe( Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + mapAcpToAdapterError(providerKind, input.threadId, "session/start", error), ), ); @@ -681,13 +724,15 @@ export function makeCursorAdapter( runtimeMode: input.runtimeMode, interactionMode: undefined, modelSelection: cursorModelSelection, + normalizeModel, + applyCursorModelOptions, mapError: ({ cause, method }) => - mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + mapAcpToAdapterError(providerKind, input.threadId, method, cause), }); const now = yield* nowIso; const session: ProviderSession = { - provider: PROVIDER, + provider: providerKind, providerInstanceId: boundInstanceId, status: "ready", runtimeMode: input.runtimeMode, @@ -726,7 +771,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpAssistantItemEvent({ stamp: yield* makeEventStamp(), - provider: PROVIDER, + provider: providerKind, threadId: ctx.threadId, turnId: ctx.activeTurnId, itemId: event.itemId, @@ -738,7 +783,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpAssistantItemEvent({ stamp: yield* makeEventStamp(), - provider: PROVIDER, + provider: providerKind, threadId: ctx.threadId, turnId: ctx.activeTurnId, itemId: event.itemId, @@ -771,7 +816,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpToolCallEvent({ stamp: yield* makeEventStamp(), - provider: PROVIDER, + provider: providerKind, threadId: ctx.threadId, turnId: ctx.activeTurnId, toolCall: event.toolCall, @@ -789,7 +834,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpContentDeltaEvent({ stamp: yield* makeEventStamp(), - provider: PROVIDER, + provider: providerKind, threadId: ctx.threadId, turnId: ctx.activeTurnId, ...(event.itemId ? { itemId: event.itemId } : {}), @@ -810,21 +855,21 @@ export function makeCursorAdapter( yield* offerRuntimeEvent({ type: "session.started", ...(yield* makeEventStamp()), - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, payload: { resume: started.initializeResult }, }); yield* offerRuntimeEvent({ type: "session.state.changed", ...(yield* makeEventStamp()), - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, - payload: { state: "ready", reason: "Cursor ACP session ready" }, + payload: { state: "ready", reason: readyReason }, }); yield* offerRuntimeEvent({ type: "thread.started", ...(yield* makeEventStamp()), - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, payload: { providerThreadId: started.sessionId }, }); @@ -840,7 +885,7 @@ export function makeCursorAdapter( const turnModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const model = turnModelSelection?.model ?? ctx.session.model; - const resolvedModel = resolveCursorAcpBaseModelId(model); + const resolvedModel = normalizeModel(model); yield* applyRequestedSessionConfiguration({ runtime: ctx.acp, runtimeMode: ctx.session.runtimeMode, @@ -852,8 +897,10 @@ export function makeCursorAdapter( model, options: turnModelSelection?.options, }, + normalizeModel, + applyCursorModelOptions, mapError: ({ cause, method }) => - mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + mapAcpToAdapterError(providerKind, input.threadId, method, cause), }); ctx.activeTurnId = turnId; ctx.lastPlanFingerprint = undefined; @@ -866,7 +913,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent({ type: "turn.started", ...(yield* makeEventStamp()), - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, turnId, payload: { model: resolvedModel }, @@ -884,7 +931,7 @@ export function makeCursorAdapter( }); if (!attachmentPath) { return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: providerKind, method: "session/prompt", detail: `Invalid attachment id '${attachment.id}'.`, }); @@ -893,7 +940,7 @@ export function makeCursorAdapter( Effect.mapError( (cause) => new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: providerKind, method: "session/prompt", detail: cause.message, cause, @@ -910,7 +957,7 @@ export function makeCursorAdapter( if (promptParts.length === 0) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider: providerKind, operation: "sendTurn", issue: "Turn requires non-empty text or attachments.", }); @@ -922,7 +969,7 @@ export function makeCursorAdapter( }) .pipe( Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + mapAcpToAdapterError(providerKind, input.threadId, "session/prompt", error), ), ); @@ -937,7 +984,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), - provider: PROVIDER, + provider: providerKind, threadId: input.threadId, turnId, payload: { @@ -961,7 +1008,7 @@ export function makeCursorAdapter( yield* Effect.ignore( ctx.acp.cancel.pipe( Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + mapAcpToAdapterError(providerKind, threadId, "session/cancel", error), ), ), ); @@ -977,7 +1024,7 @@ export function makeCursorAdapter( const pending = ctx.pendingApprovals.get(requestId); if (!pending) { return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: providerKind, method: "session/request_permission", detail: `Unknown pending approval request: ${requestId}`, }); @@ -995,7 +1042,7 @@ export function makeCursorAdapter( const pending = ctx.pendingUserInputs.get(requestId); if (!pending) { return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: providerKind, method: "cursor/ask_question", detail: `Unknown pending user-input request: ${requestId}`, }); @@ -1014,7 +1061,7 @@ export function makeCursorAdapter( const ctx = yield* requireSession(threadId); if (!Number.isInteger(numTurns) || numTurns < 1) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider: providerKind, operation: "rollbackThread", issue: "numTurns must be an integer >= 1.", }); @@ -1055,7 +1102,7 @@ export function makeCursorAdapter( const streamEvents = Stream.fromPubSub(runtimeEventPubSub); return { - provider: PROVIDER, + provider: providerKind, capabilities: { sessionModelSwitch: "in-session" }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts index 63f687f55b..3b8d742437 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts @@ -120,6 +120,7 @@ const buildEntry = (input: { instanceId, displayName: entry.displayName, accentColor: entry.accentColor, + iconUrl: entry.iconUrl, reason: `Driver '${entry.driver}' is not registered in this build.`, }), }; @@ -142,6 +143,7 @@ const buildEntry = (input: { instanceId, displayName: entry.displayName, accentColor: entry.accentColor, + iconUrl: entry.iconUrl, reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, }), }; @@ -161,6 +163,7 @@ const buildEntry = (input: { instanceId, displayName: entry.displayName, accentColor: entry.accentColor, + iconUrl: entry.iconUrl, environment: entry.environment ?? [], enabled: entry.enabled ?? decodedConfigEnabled(typedConfig) ?? true, config: typedConfig, @@ -180,6 +183,7 @@ const buildEntry = (input: { instanceId, displayName: entry.displayName, accentColor: entry.accentColor, + iconUrl: entry.iconUrl, reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, }), }; diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index e7d3e40cd8..68e6df30dc 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -65,6 +65,7 @@ export interface ProviderInstance { readonly continuationIdentity: ProviderContinuationIdentity; readonly displayName: string | undefined; readonly accentColor?: string | undefined; + readonly iconUrl?: string | undefined; readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; @@ -96,6 +97,7 @@ export interface ProviderDriverCreateInput { readonly instanceId: ProviderInstanceId; readonly displayName: string | undefined; readonly accentColor?: string | undefined; + readonly iconUrl?: string | undefined; readonly environment: ProviderInstanceEnvironment; readonly enabled: boolean; readonly config: Config; diff --git a/apps/server/src/provider/Services/AcpRegistryClient.ts b/apps/server/src/provider/Services/AcpRegistryClient.ts new file mode 100644 index 0000000000..b521d1e496 --- /dev/null +++ b/apps/server/src/provider/Services/AcpRegistryClient.ts @@ -0,0 +1,13 @@ +import { Schema } from "effect"; + +export class AcpRegistryClientError extends Schema.TaggedErrorClass()( + "AcpRegistryClientError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return this.detail; + } +} diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 3e405dd7ff..7bd4edcab2 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -2,6 +2,7 @@ import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/cont import { Effect, Layer, Scope } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; import { CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, @@ -24,6 +25,9 @@ export interface CursorAcpRuntimeInput extends Omit< readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; + readonly spawn?: AcpSpawnInput; + readonly authMethodId?: string; + readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; } export interface CursorAcpModelSelectionErrorContext { @@ -55,9 +59,12 @@ export const makeCursorAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd, input.environment), - authMethodId: "cursor_login", - clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, + spawn: + input.spawn ?? + buildCursorAcpSpawnInput(input.cursorSettings, input.cwd, input.environment), + authMethodId: input.authMethodId ?? "cursor_login", + clientCapabilities: + input.clientCapabilities ?? CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, }).pipe( Layer.provide( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 5af56dc6b0..4c5a5bb7c5 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegistryDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -35,7 +36,8 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | AcpRegistryDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -47,4 +49,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray)[getAcpBinaryPlatformKey()]; + if (!target || typeof target !== "object" || globalThis.Array.isArray(target)) return null; + const record = target as Record; + if (typeof record.archive !== "string" || typeof record.cmd !== "string") return null; + return { archive: record.archive, cmd: record.cmd }; +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function normalizeArchiveCommandPath(command: string): string { + const trimmed = command.trim(); + if (!trimmed) { + throw new Error("Registry binary command must not be empty."); + } + if (isAbsolute(trimmed)) { + throw new Error("Registry binary command must be a relative archive path."); + } + const withoutLeadingDot = trimmed.replace(/^(?:\.[/\\])+/u, ""); + const parts = withoutLeadingDot + .split(/[/\\]+/u) + .filter((part) => part.length > 0 && part !== "."); + if (parts.length === 0 || parts.some((part) => part === "..")) { + throw new Error("Registry binary command resolves outside the archive."); + } + return join(...parts); +} + +function resolveInstallRootFromBinaryPath(binaryPath: string, commandRelativePath: string): string { + const depth = commandRelativePath.split(/[/\\]+/u).filter((part) => part.length > 0).length; + let installRoot = binaryPath; + for (let index = 0; index < depth; index += 1) { + installRoot = dirname(installRoot); + } + return installRoot; +} + +function resolveAcpBinaryInstallPath( + config: { readonly stateDir: string }, + agent: AcpRegistryAgent, +) { + const commandPath = normalizeArchiveCommandPath(getBinaryTarget(agent)?.cmd ?? agent.id); + return join(config.stateDir, ACP_BINARY_INSTALLS_DIR, agent.id, agent.version, commandPath); +} + +function expandUserPath(path: string): string { + if (path === "~") return homedir(); + if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2)); + return path; +} + +function normalizeAcpBinaryInstallPath(path: string): string { + const expanded = expandUserPath(path.trim()); + return isAbsolute(expanded) ? expanded : resolve(expanded); +} + +function displayPath(path: string): string { + const home = homedir(); + return path === home || path.startsWith(`${home}/`) || path.startsWith(`${home}\\`) + ? `~${path.slice(home.length)}` + : path; +} + +function isPathInside(parent: string, child: string): boolean { + const relativePath = relative(parent, child); + return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); +} + +function toBinaryInstallPreview( + config: { readonly stateDir: string }, + agent: AcpRegistryAgent, + target: BinaryDistributionTarget, +) { + const defaultInstallPath = resolveAcpBinaryInstallPath(config, agent); + return { + archiveUrl: target.archive, + defaultInstallPath: displayPath(defaultInstallPath), + platformKey: getAcpBinaryPlatformKey(), + command: target.cmd, + }; +} + +function resolveAcpBinaryManifestPath( + config: { readonly stateDir: string }, + agent: AcpRegistryAgent, +) { + return join(dirname(resolveAcpBinaryInstallPath(config, agent)), ACP_BINARY_MANIFEST_FILE); +} + +async function readAcpBinaryManifest( + config: { readonly stateDir: string }, + agent: AcpRegistryAgent, +): Promise { + const manifestPath = resolveAcpBinaryManifestPath(config, agent); + try { + const raw = await readFile(manifestPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.layoutVersion === 2 && + parsed.agentId === agent.id && + parsed.version === agent.version && + parsed.platformKey === getAcpBinaryPlatformKey() && + typeof parsed.command === "string" && + (await fileExists(parsed.command)) + ) { + return parsed as AcpBinaryInstallManifest; + } + return null; + } catch { + return null; + } +} + +async function downloadFile(url: string, destination: string): Promise { + const response = await fetch(url); + if (!response.ok || !response.body) { + throw new Error(`Download failed with status ${response.status}`); + } + await pipeline(response.body, createWriteStream(destination)); +} + +async function extractArchive(archivePath: string, destinationDir: string): Promise { + if (archivePath.endsWith(".tar.gz") || archivePath.endsWith(".tgz")) { + await execFileAsync("tar", ["-xzf", archivePath, "-C", destinationDir]); + return; + } + if (archivePath.endsWith(".zip")) { + if (platform() === "win32") { + await execFileAsync("powershell.exe", [ + "-NoProfile", + "-Command", + "Expand-Archive", + "-LiteralPath", + archivePath, + "-DestinationPath", + destinationDir, + "-Force", + ]); + return; + } + await execFileAsync("unzip", ["-q", archivePath, "-d", destinationDir]); + return; + } + throw new Error("Unsupported binary archive format."); +} + +async function installAcpBinaryAgent(input: { + readonly config: { readonly stateDir: string }; + readonly agent: AcpRegistryAgent; + readonly installPath?: string | undefined; +}): Promise { + const target = getBinaryTarget(input.agent); + if (!target) { + throw new Error(`No binary is available for ${getAcpBinaryPlatformKey()}.`); + } + const installPath = normalizeAcpBinaryInstallPath( + input.installPath?.trim() || resolveAcpBinaryInstallPath(input.config, input.agent), + ); + const commandPath = normalizeArchiveCommandPath(target.cmd); + const installRoot = resolveInstallRootFromBinaryPath(installPath, commandPath); + if (installRoot === dirname(installRoot)) { + throw new Error(`Binary path is too shallow for registry command '${target.cmd}'.`); + } + const manifestPath = resolveAcpBinaryManifestPath(input.config, input.agent); + const tempDir = await mkdtemp(join(tmpdir(), "t3-acp-agent-")); + try { + const archivePath = join( + tempDir, + basename(new URL(target.archive).pathname) || "agent.archive", + ); + const extractDir = join(tempDir, "extract"); + await mkdir(extractDir, { recursive: true }); + await downloadFile(target.archive, archivePath); + await extractArchive(archivePath, extractDir); + + const extractedCommand = resolve(extractDir, commandPath); + if (!isPathInside(extractDir, extractedCommand)) { + throw new Error(`Registry binary command resolves outside the archive.`); + } + if (!(await fileExists(extractedCommand))) { + throw new Error(`Installed archive did not contain expected command '${target.cmd}'.`); + } + await mkdir(installRoot, { recursive: true }); + await cp(extractDir, installRoot, { + recursive: true, + force: true, + errorOnExist: false, + }); + if (!(await fileExists(installPath))) { + await mkdir(dirname(installPath), { recursive: true }); + await copyFile(extractedCommand, installPath); + } + if (platform() !== "win32") { + await chmod(installPath, 0o755); + } + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + const manifest: AcpBinaryInstallManifest = { + layoutVersion: 2, + agentId: input.agent.id, + version: input.agent.version, + platformKey: getAcpBinaryPlatformKey(), + command: installPath, + archiveUrl: target.archive, + }; + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + return { ...target, command: installPath }; +} + +async function toAcpLaunchSpec( + agent: AcpRegistryAgent, + config: { readonly stateDir: string }, +): Promise<{ + readonly supported: boolean; + readonly distributionType: "npx" | "uvx" | "binary" | "binaryUnsupported"; + readonly launch: { + readonly command: string; + readonly args: readonly string[]; + readonly env: Record; + } | null; + readonly binaryInstall?: { + readonly archiveUrl: string; + readonly defaultInstallPath: string; + readonly platformKey: string; + readonly command: string; + }; +}> { + if (agent.distribution.npx) { + return { + supported: true as const, + distributionType: "npx" as const, + launch: { + command: "npx", + args: ["-y", agent.distribution.npx.package, ...(agent.distribution.npx.args ?? [])], + env: agent.distribution.npx.env ?? {}, + }, + }; + } + if (agent.distribution.uvx) { + return { + supported: true as const, + distributionType: "uvx" as const, + launch: { + command: "uvx", + args: [agent.distribution.uvx.package, ...(agent.distribution.uvx.args ?? [])], + env: agent.distribution.uvx.env ?? {}, + }, + }; + } + const manifest = await readAcpBinaryManifest(config, agent); + const target = getBinaryTarget(agent); + if (manifest) { + return { + supported: true as const, + distributionType: "binary" as const, + launch: { + command: manifest.command, + args: [], + env: {}, + }, + ...(target ? { binaryInstall: toBinaryInstallPreview(config, agent, target) } : {}), + }; + } + return { + supported: false as const, + distributionType: "binaryUnsupported" as const, + launch: null, + ...(target ? { binaryInstall: toBinaryInstallPreview(config, agent, target) } : {}), + }; +} function toAuthAccessStreamEvent( change: BootstrapCredentialChange | SessionCredentialChange, @@ -175,6 +507,110 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => clientSessions: serverAuth.listClientSessions(currentSessionId).pipe(Effect.orDie), }); + const loadAcpRegistryIndex = serverSettings.getSettings.pipe( + Effect.flatMap((settings) => + Effect.tryPromise({ + try: async () => { + const response = await fetch(settings.providers.acpRegistry.registryUrl); + if (!response.ok) { + throw new Error(`Registry request failed with status ${response.status}`); + } + return response.json(); + }, + catch: (cause) => + new AcpRegistryClientError({ + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }), + ), + Effect.flatMap((raw) => Schema.decodeUnknownEffect(AcpRegistryIndex)(raw)), + ); + + const listAcpRegistry = Effect.all({ + registry: loadAcpRegistryIndex, + config: Effect.succeed(config), + }).pipe( + Effect.flatMap(({ registry, config }) => + Effect.tryPromise({ + try: async (): Promise => ({ + registryVersion: registry.version, + agents: ( + await Promise.all( + registry.agents.map(async (agent) => { + const resolved = await toAcpLaunchSpec(agent, config); + return { + agent, + supported: resolved.supported, + distributionType: resolved.distributionType, + launch: resolved.launch, + ...(resolved.binaryInstall ? { binaryInstall: resolved.binaryInstall } : {}), + }; + }), + ) + ).toSorted((left, right) => left.agent.name.localeCompare(right.agent.name)), + }), + catch: (cause) => + new AcpRegistryClientError({ + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }), + ), + ); + + const installAcpRegistryBinary = (input: { + readonly agentId: string; + readonly installPath?: string | undefined; + }) => + Effect.all({ + registry: loadAcpRegistryIndex, + config: Effect.succeed(config), + }).pipe( + Effect.flatMap(({ registry, config }) => + Effect.tryPromise({ + try: async (): Promise => { + const agent = registry.agents.find((entry) => entry.id === input.agentId); + if (!agent) { + return { + ok: false, + error: `No ACP registry agent found for '${input.agentId}'.`, + }; + } + const installed = await installAcpBinaryAgent({ + config, + agent, + installPath: input.installPath, + }); + return { + ok: true, + agent: { + agent, + supported: true, + distributionType: "binary", + binaryInstall: toBinaryInstallPreview(config, agent, installed), + launch: { + command: installed.command, + args: [], + env: {}, + }, + }, + }; + }, + catch: (cause): AcpRegistryInstallBinaryResult => ({ + ok: false, + error: cause instanceof Error ? cause.message : String(cause), + }), + }), + ), + Effect.catch((cause: unknown) => + Effect.succeed({ + ok: false, + error: cause instanceof Error ? cause.message : String(cause), + } satisfies AcpRegistryInstallBinaryResult), + ), + ); + const appendSetupScriptActivity = (input: { readonly threadId: ThreadId; readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; @@ -807,6 +1243,32 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "rpc.aggregate": "server", }, ), + [WS_METHODS.serverListAcpRegistry]: (_input) => + observeRpcEffect( + WS_METHODS.serverListAcpRegistry, + listAcpRegistry.pipe( + Effect.tapError((error) => + Effect.logWarning("failed to list ACP registry agents", { + error: error.message, + }), + ), + Effect.orElseSucceed(() => ({ + registryVersion: "unavailable", + agents: [], + })), + ), + { + "rpc.aggregate": "server", + }, + ), + [WS_METHODS.serverInstallAcpRegistryBinary]: (input) => + observeRpcEffect( + WS_METHODS.serverInstallAcpRegistryBinary, + installAcpRegistryBinary(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index b6df9712bb..7b59100d93 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -103,6 +103,7 @@ function createBaseServerConfig(): ServerConfig { model: "gpt-5.4-mini", }, providers: { + ...DEFAULT_SERVER_SETTINGS.providers, codex: { enabled: true, binaryPath: "", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d64d031668..44f1c6869d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -99,6 +99,7 @@ import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderInteractionModeToggle } from "../../providerModels"; import { deriveProviderInstanceEntries, + isModelPickerProviderInstanceEntry, resolveProviderDriverKindForInstanceSelection, sortProviderInstanceEntries, type ProviderInstanceEntry, @@ -595,7 +596,12 @@ export const ChatComposer = memo( // configured instance (default built-in + any custom `providerInstances.*`), // sorted default-first per driver kind for a stable picker order. const providerInstanceEntries = useMemo>( - () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), + () => + sortProviderInstanceEntries( + deriveProviderInstanceEntries(providerStatuses).filter( + isModelPickerProviderInstanceEntry, + ), + ), [providerStatuses], ); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 064df338e4..6661d0aacd 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -5,8 +5,8 @@ import { getDisplayModelName, getTriggerDisplayModelLabel, type ModelEsque, - PROVIDER_ICON_BY_PROVIDER, } from "./providerIconUtils"; +import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ComboboxItem } from "../ui/combobox"; import { Kbd } from "../ui/kbd"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -26,6 +26,7 @@ export const ModelListRow = memo(function ModelListRow(props: { */ providerDisplayName: string; providerAccentColor?: string | undefined; + providerIconUrl?: string | undefined; isFavorite: boolean; showProvider: boolean; preferShortName?: boolean; @@ -34,7 +35,6 @@ export const ModelListRow = memo(function ModelListRow(props: { jumpLabel?: string | null; onToggleFavorite: () => void; }) { - const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; const providerLabel = props.model.subProvider ? `${props.providerDisplayName} · ${props.model.subProvider}` : props.providerDisplayName; @@ -104,7 +104,13 @@ export const ModelListRow = memo(function ModelListRow(props: { {props.showProvider && (
- {ProviderIcon ? : null} + {props.providerAccentColor ? ( { + const lockedHeaderEntry = useMemo(() => { if (!isLocked || !props.lockedProvider) return null; const matches = instanceEntries.filter((entry) => matchesLockedProvider(entry)); if (matches.length === 0) return null; const active = matches.find((entry) => entry.instanceId === props.activeInstanceId); - return (active ?? matches[0])?.displayName ?? null; + return active ?? matches[0] ?? null; }, [ isLocked, matchesLockedProvider, @@ -524,12 +525,18 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { )} > {/* Locked provider header (only shown in locked mode) */} - {isLocked && !showLockedInstanceSidebar && LockedProviderIcon && lockedHeaderLabel && ( + {isLocked && !showLockedInstanceSidebar && lockedHeaderEntry ? (
- - {lockedHeaderLabel} + + {lockedHeaderEntry.displayName}
- )} + ) : null} {/* Sidebar (only in unlocked mode) */} {showSidebar && ( @@ -626,6 +633,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { driverKind={model.driverKind} providerDisplayName={model.instanceDisplayName} providerAccentColor={model.instanceAccentColor} + providerIconUrl={model.instanceIconUrl} isFavorite={favoritesSet.has(modelKey)} showProvider={!isLocked || showLockedInstanceSidebar} preferShortName={!isLocked} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 121b5267a3..90fa124a1f 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -158,6 +158,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { driverKind={entry.driverKind} displayName={entry.displayName} accentColor={entry.accentColor} + iconUrl={entry.iconUrl} showBadge={showInstanceBadge} className="size-6" iconClassName="size-5" diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 154cada19a..88701f3c55 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -1,4 +1,4 @@ -import { type CSSProperties, memo } from "react"; +import { type CSSProperties, memo, useEffect, useState } from "react"; import { type ProviderDriverKind } from "@t3tools/contracts"; import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; @@ -18,6 +18,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { driverKind: ProviderDriverKind; displayName: string; accentColor?: string | undefined; + iconUrl?: string | undefined; showBadge?: boolean; className?: string; iconClassName?: string; @@ -25,10 +26,17 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { statusDotClassName?: string; }) { const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; + const [imageFailed, setImageFailed] = useState(false); + const customIconUrl = props.iconUrl?.trim(); + const showCustomIcon = Boolean(customIconUrl) && !imageFailed; const accentStyle = props.accentColor ? ({ "--provider-accent": props.accentColor } as CSSProperties) : undefined; + useEffect(() => { + setImageFailed(false); + }, [customIconUrl]); + return ( - {Icon ? ( + {showCustomIcon ? ( + setImageFailed(true)} + draggable={false} + /> + ) : Icon ? ( ) : ( diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 4a2860ee46..30b1f3f753 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -129,6 +129,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { driverKind={activeEntry.driverKind} displayName={activeEntry.displayName} accentColor={activeEntry.accentColor} + iconUrl={activeEntry.iconUrl} showBadge={showInstanceBadge} className={showInstanceBadge ? "size-5" : "size-4"} iconClassName={cn("size-4", props.activeProviderIconClassName)} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 88b56295f3..77880818a0 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ACPRegistryIcon, ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, + [ProviderDriverKind.make("acpRegistry")]: ACPRegistryIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index affa35ff26..7e18eecd78 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -1,19 +1,42 @@ "use client"; -import { CheckIcon } from "lucide-react"; +import { + ArrowRightIcon, + CheckIcon, + Loader2Icon, + PlusIcon, + RefreshCwIcon, + SearchIcon, + XIcon, +} from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { useCallback, useEffect, useMemo, useState } from "react"; import { - ProviderInstanceId, ProviderDriverKind, + ProviderInstanceId, type ProviderInstanceConfig, + type ProviderInstanceEnvironmentVariable, + type ResolvedRegistryAcpAgent, } from "@t3tools/contracts"; +import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; +import { ensureLocalApi } from "../../localApi"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; +import { AnimatedHeight } from "../AnimatedHeight"; +import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; -import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; import { Dialog, DialogDescription, @@ -22,13 +45,12 @@ import { DialogPopup, DialogTitle, } from "../ui/dialog"; -import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; +import { ScrollArea } from "../ui/scroll-area"; import { toastManager } from "../ui/toast"; import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; -import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; -import { AnimatedHeight } from "../AnimatedHeight"; +import { deriveProviderSettingsFields, ProviderSettingsForm } from "./ProviderSettingsForm"; const PROVIDER_ACCENT_SWATCHES = [ "#2563eb", @@ -39,13 +61,6 @@ const PROVIDER_ACCENT_SWATCHES = [ "#0891b2", ] as const; -/** - * Normalize a user-provided label into a slug suffix for the instance id. - * The full id is formed by prefixing the driver slug — e.g. label "Work" on - * driver "codex" becomes `codex_work`. Output is trimmed to 48 chars so the - * final composed id stays under the 64-char slug cap enforced by - * `ProviderInstanceId` in `@t3tools/contracts`. - */ function slugifyLabel(value: string): string { return value .trim() @@ -61,9 +76,13 @@ function deriveInstanceId(driver: ProviderDriverKind, label: string): string { } const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; +const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); +const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; const EMPTY_CONFIG_DRAFT: Record = {}; +const MANUAL_ACP_REGISTRY_OPTION = "__manual__"; + interface ComingSoonDriverOption { readonly value: ProviderDriverKind; readonly label: string; @@ -81,11 +100,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ label: "Gemini", icon: Gemini, }, - { - value: ProviderDriverKind.make("acpRegistry"), - label: "ACP Registry", - icon: ACPRegistryIcon, - }, { value: ProviderDriverKind.make("piAgent"), label: "Pi Agent", @@ -93,11 +107,13 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ }, ]; -/** - * Validate an instance id against the same slug rules the server applies in - * `ProviderInstanceId` (see `packages/contracts/src/providerInstance.ts`). - * Returns a user-facing error string, or `null` if valid. - */ +let environmentVariableDraftId = 0; +const nextEnvironmentVariableDraftId = () => `add-provider-env-${environmentVariableDraftId++}`; + +type EnvironmentVariableDraft = ProviderInstanceEnvironmentVariable & { + readonly id: string; +}; + function validateInstanceId(id: string, existing: ReadonlySet): string | null { if (id.length === 0) return "Instance ID is required."; if (id.length > 64) return "Instance ID must be 64 characters or fewer."; @@ -108,6 +124,99 @@ function validateInstanceId(id: string, existing: ReadonlySet): string | return null; } +function envRecordToVariables( + env: Record | null | undefined, +): ReadonlyArray { + return Object.entries(env ?? {}) + .filter(([name]) => ENVIRONMENT_VARIABLE_NAME_PATTERN.test(name)) + .map(([name, value]) => ({ + id: nextEnvironmentVariableDraftId(), + name, + value, + sensitive: false, + })); +} + +function emptyEnvironmentVariable(): EnvironmentVariableDraft { + return { + id: nextEnvironmentVariableDraftId(), + name: "", + value: "", + sensitive: true, + }; +} + +function acpDistributionLabel(entry: ResolvedRegistryAcpAgent): string { + switch (entry.distributionType) { + case "npx": + return "npx"; + case "uvx": + return "uvx"; + case "binary": + return "Installed"; + case "binaryUnsupported": + return "Binary"; + case "manual": + return "Manual"; + } +} + +function acpRegistryAgentSearchFields(entry: ResolvedRegistryAcpAgent): readonly string[] { + return [ + entry.agent.name, + entry.agent.id, + entry.agent.description, + entry.agent.repository, + entry.agent.website, + entry.distributionType, + entry.launch?.command, + ...(entry.launch?.args ?? []), + ] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .map((value) => normalizeSearchQuery(value)); +} + +function scoreAcpRegistryAgentSearch(entry: ResolvedRegistryAcpAgent, query: string): number | null { + const tokens = normalizeSearchQuery(query) + .split(/\s+/u) + .filter((token) => token.length > 0); + if (tokens.length === 0) return 0; + + const fields = acpRegistryAgentSearchFields(entry); + let score = 0; + for (const token of tokens) { + const tokenScores = fields + .map((field, index) => + scoreQueryMatch({ + value: field, + query: token, + exactBase: index * 10, + prefixBase: index * 10 + 2, + boundaryBase: index * 10 + 4, + includesBase: index * 10 + 6, + ...(token.length >= 3 ? { fuzzyBase: index * 10 + 100 } : {}), + }), + ) + .filter((fieldScore): fieldScore is number => fieldScore !== null); + if (tokenScores.length === 0) return null; + score += Math.min(...tokenScores); + } + return score; +} + +type AcpRegistryState = + | { readonly status: "idle" | "loading"; readonly agents: readonly ResolvedRegistryAcpAgent[] } + | { + readonly status: "loaded"; + readonly registryVersion: string; + readonly agents: readonly ResolvedRegistryAcpAgent[]; + } + | { + readonly status: "error"; + readonly agents: readonly ResolvedRegistryAcpAgent[]; + readonly error: string; + }; + interface AddProviderInstanceDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -120,42 +229,63 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); const [label, setLabel] = useState(""); + const [labelDirty, setLabelDirty] = useState(false); const [accentColor, setAccentColor] = useState(""); const [instanceId, setInstanceId] = useState(""); const [instanceIdDirty, setInstanceIdDirty] = useState(false); - // Driver-specific config drafts keyed by driver so toggling between drivers - // during the same dialog session does not lose in-progress input. const [configByDriver, setConfigByDriver] = useState>>({}); - // Errors are suppressed until the user has tried to submit once. After that - // they update live so fixing the problem clears the message in place. + const [environmentVariables, setEnvironmentVariables] = useState< + ReadonlyArray + >([]); + const [acpRegistryState, setAcpRegistryState] = useState({ + status: "idle", + agents: [], + }); + const [acpRegistrySearch, setAcpRegistrySearch] = useState(""); + const [selectedAcpRegistryAgentId, setSelectedAcpRegistryAgentId] = useState( + MANUAL_ACP_REGISTRY_OPTION, + ); + const [installingAcpAgentId, setInstallingAcpAgentId] = useState(null); + const [installConfirmAgent, setInstallConfirmAgent] = useState( + null, + ); + const [installPath, setInstallPath] = useState(""); const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); const existingIds = useMemo( () => new Set(Object.keys(settings.providerInstances ?? {})), [settings.providerInstances], ); + const isAcpRegistryDriver = driver === ACP_REGISTRY_DRIVER_KIND; - // Reset the form every time the dialog opens so each creation starts - // from a clean slate. useEffect(() => { if (!open) return; setDriver(DEFAULT_DRIVER_KIND); setLabel(""); + setLabelDirty(false); setAccentColor(""); setInstanceId(""); setWizardStep(0); setInstanceIdDirty(false); setConfigByDriver({}); + setEnvironmentVariables([]); + setAcpRegistrySearch(""); + setSelectedAcpRegistryAgentId(MANUAL_ACP_REGISTRY_OPTION); + setInstallingAcpAgentId(null); + setInstallConfirmAgent(null); + setInstallPath(""); setHasAttemptedSubmit(false); }, [open]); - // Auto-derive the instance id from driver + label until the user types - // in the Instance ID field directly (after which they own its value). useEffect(() => { if (instanceIdDirty) return; setInstanceId(deriveInstanceId(driver, label)); }, [driver, label, instanceIdDirty]); + useEffect(() => { + setWizardStep((step) => Math.min(step, isAcpRegistryDriver ? 3 : 2)); + }, [isAcpRegistryDriver]); + const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; const driverSettingsFields = useMemo( () => deriveProviderSettingsFields(driverOption), @@ -164,8 +294,71 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns const instanceIdError = validateInstanceId(instanceId, existingIds); const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; const previewLabel = label.trim() || `${driverOption.label} Workspace`; - const wizardSteps = ["Driver", "Identity", "Config"] as const; - const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; + const wizardSteps = isAcpRegistryDriver + ? (["Driver", "Registry", "Identity", "Config"] as const) + : (["Driver", "Identity", "Config"] as const); + const registryStepIndex = isAcpRegistryDriver ? 1 : -1; + const identityStepIndex = isAcpRegistryDriver ? 2 : 1; + const configStepIndex = wizardSteps.length - 1; + const selectedAcpRegistryAgent = useMemo( + () => + acpRegistryState.agents.find((entry) => entry.agent.id === selectedAcpRegistryAgentId) ?? + null, + [acpRegistryState.agents, selectedAcpRegistryAgentId], + ); + const selectedAcpRegistryAgentNeedsInstall = + selectedAcpRegistryAgent?.distributionType === "binaryUnsupported" && + Boolean(selectedAcpRegistryAgent.binaryInstall); + const installingConfirmAgent = + installConfirmAgent !== null && installingAcpAgentId === installConfirmAgent.agent.id; + const wizardStepSummaries = [ + driverOption.label, + ...(isAcpRegistryDriver + ? [ + selectedAcpRegistryAgent + ? selectedAcpRegistryAgent.agent.name + : selectedAcpRegistryAgentId === MANUAL_ACP_REGISTRY_OPTION + ? "Manual" + : null, + ] + : []), + previewLabel, + null, + ] as const; + const filteredAcpRegistryAgents = useMemo(() => { + const query = acpRegistrySearch.trim(); + if (!query) return acpRegistryState.agents; + return acpRegistryState.agents + .map((entry) => ({ entry, score: scoreAcpRegistryAgentSearch(entry, query) })) + .filter((result): result is { entry: ResolvedRegistryAcpAgent; score: number } => { + return result.score !== null; + }) + .toSorted((left, right) => left.score - right.score || left.entry.agent.name.localeCompare(right.entry.agent.name)) + .map((result) => result.entry); + }, [acpRegistrySearch, acpRegistryState.agents]); + + const loadAcpRegistry = useCallback(async () => { + setAcpRegistryState((current) => ({ status: "loading", agents: current.agents })); + try { + const result = await ensureLocalApi().server.listAcpRegistry(); + setAcpRegistryState({ + status: "loaded", + registryVersion: result.registryVersion, + agents: result.agents, + }); + } catch (error) { + setAcpRegistryState((current) => ({ + status: "error", + agents: current.agents, + error: error instanceof Error ? error.message : "Registry request failed.", + })); + } + }, []); + + useEffect(() => { + if (!open || !isAcpRegistryDriver || acpRegistryState.status !== "idle") return; + void loadAcpRegistry(); + }, [acpRegistryState.status, isAcpRegistryDriver, loadAcpRegistry, open]); const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; const setConfigDraft = useCallback( @@ -183,25 +376,162 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns [driver], ); + const applyAcpRegistryAgent = useCallback( + (entry: ResolvedRegistryAcpAgent) => { + const launch = entry.launch; + if (!launch) return; + setSelectedAcpRegistryAgentId(entry.agent.id); + setConfigByDriver((existing) => ({ + ...existing, + [ACP_REGISTRY_DRIVER_KIND]: { + ...(existing[ACP_REGISTRY_DRIVER_KIND] ?? {}), + command: launch.command, + args: launch.args, + env: launch.env, + iconUrl: entry.agent.icon ?? "", + registryAgentId: entry.agent.id, + importedVersion: entry.agent.version, + distributionType: entry.distributionType, + }, + })); + setEnvironmentVariables(envRecordToVariables(launch.env)); + if (!labelDirty) { + setLabel(entry.agent.name); + } + if (!instanceIdDirty) { + setInstanceId(deriveInstanceId(ACP_REGISTRY_DRIVER_KIND, entry.agent.name)); + } + }, + [instanceIdDirty, labelDirty], + ); + + const selectInstallableAcpRegistryAgent = useCallback( + (entry: ResolvedRegistryAcpAgent) => { + setSelectedAcpRegistryAgentId(entry.agent.id); + setInstallPath(entry.binaryInstall?.defaultInstallPath ?? ""); + setEnvironmentVariables([]); + if (!labelDirty) { + setLabel(entry.agent.name); + } + if (!instanceIdDirty) { + setInstanceId(deriveInstanceId(ACP_REGISTRY_DRIVER_KIND, entry.agent.name)); + } + }, + [instanceIdDirty, labelDirty], + ); + + const selectManualAcpRegistryAgent = useCallback(() => { + setSelectedAcpRegistryAgentId(MANUAL_ACP_REGISTRY_OPTION); + setEnvironmentVariables([]); + }, []); + + const handleInstallAcpRegistryAgent = useCallback(async () => { + const entry = installConfirmAgent; + if (!entry) return; + setInstallingAcpAgentId(entry.agent.id); + try { + const trimmedInstallPath = installPath.trim(); + const result = await ensureLocalApi().server.installAcpRegistryBinary({ + agentId: entry.agent.id, + ...(trimmedInstallPath ? { installPath: trimmedInstallPath } : {}), + }); + if (!result.ok || !result.agent) { + throw new Error(result.error ?? "Install failed."); + } + setAcpRegistryState((current) => ({ + ...current, + agents: current.agents.map((candidate) => + candidate.agent.id === result.agent?.agent.id ? result.agent : candidate, + ), + })); + applyAcpRegistryAgent(result.agent); + setInstallConfirmAgent(null); + setWizardStep(identityStepIndex); + toastManager.add({ + type: "success", + title: "ACP agent installed", + description: `${entry.agent.name} is ready to use.`, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not install ACP agent", + description: error instanceof Error ? error.message : "Install failed.", + }); + } finally { + setInstallingAcpAgentId(null); + } + }, [applyAcpRegistryAgent, identityStepIndex, installConfirmAgent, installPath]); + + const handleAdvanceWizard = useCallback(() => { + if (wizardStep === registryStepIndex && selectedAcpRegistryAgentNeedsInstall) { + setInstallPath(selectedAcpRegistryAgent?.binaryInstall?.defaultInstallPath ?? ""); + setInstallConfirmAgent(selectedAcpRegistryAgent); + return; + } + setWizardStep((step) => Math.min(configStepIndex, step + 1)); + }, [ + configStepIndex, + registryStepIndex, + selectedAcpRegistryAgent, + selectedAcpRegistryAgentNeedsInstall, + wizardStep, + ]); + + const updateEnvironmentVariable = useCallback( + ( + index: number, + patch: Partial>, + ) => { + setEnvironmentVariables((existing) => + existing.map((variable, variableIndex) => + variableIndex === index ? { ...variable, ...patch } : variable, + ), + ); + }, + [], + ); + + const removeEnvironmentVariable = useCallback((index: number) => { + setEnvironmentVariables((existing) => + existing.filter((_variable, variableIndex) => variableIndex !== index), + ); + }, []); + const handleSave = useCallback(() => { setHasAttemptedSubmit(true); if (instanceIdError !== null) return; - const config = configByDriver[driver] ?? {}; + const config: Record = { ...(configByDriver[driver] ?? {}) }; + if (driver === ACP_REGISTRY_DRIVER_KIND && selectedAcpRegistryAgent) { + config.registryAgentId = selectedAcpRegistryAgent.agent.id; + config.importedVersion = selectedAcpRegistryAgent.agent.version; + config.distributionType = selectedAcpRegistryAgent.distributionType; + if (selectedAcpRegistryAgent.agent.icon) { + config.iconUrl = selectedAcpRegistryAgent.agent.icon; + } + } const hasConfig = Object.keys(config).length > 0; const normalizedAccentColor = normalizeProviderAccentColor(accentColor); + const cleanedEnvironment = environmentVariables + .map((variable) => ({ + value: variable.value, + sensitive: variable.sensitive, + name: variable.name.trim(), + })) + .filter((variable) => ENVIRONMENT_VARIABLE_NAME_PATTERN.test(variable.name)); const nextInstance: ProviderInstanceConfig = { driver, enabled: true, ...(label.trim().length > 0 ? { displayName: label.trim() } : {}), ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}), + ...(cleanedEnvironment.length > 0 ? { environment: cleanedEnvironment } : {}), + ...(selectedAcpRegistryAgent?.agent.icon + ? { iconUrl: selectedAcpRegistryAgent.agent.icon } + : {}), ...(hasConfig ? { config } : {}), }; - // `ProviderInstanceId.make` revalidates the slug; we've already checked - // it via `validateInstanceId`, but going through the brand constructor - // keeps the type boundary honest and guards against any future drift in - // the slug rules. const brandedId = ProviderInstanceId.make(instanceId); const nextMap = { ...settings.providerInstances, @@ -223,269 +553,546 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns }); } }, [ + accentColor, + configByDriver, driver, driverOption, - configByDriver, + environmentVariables, instanceId, instanceIdError, label, - accentColor, onOpenChange, + selectedAcpRegistryAgent, settings.providerInstances, updateSettings, ]); - return ( - - -
- - Add provider instance - - Configure an additional provider instance — for example, a second Codex install - pointed at a different workspace. - -
- {wizardSteps.map((step, index) => ( - +
+ {environmentVariables.length === 0 ? ( +

+ Add variables to pass API keys, base URLs, or other per-instance CLI settings. +

+ ) : ( +
+ {environmentVariables.map((variable, index) => { + const name = variable.name.trim(); + const nameInvalid = name.length > 0 && !ENVIRONMENT_VARIABLE_NAME_PATTERN.test(name); + return ( +
+ + updateEnvironmentVariable(index, { name: event.target.value }) + } + placeholder="VARIABLE_NAME" + spellCheck={false} + aria-label={`Environment variable name ${index + 1}`} + className={cn("bg-background", nameInvalid && "border-destructive")} + /> + + updateEnvironmentVariable(index, { value: event.target.value }) + } + type={variable.sensitive ? "password" : undefined} + autoComplete="off" + placeholder="Value" + spellCheck={false} + aria-label={`Environment variable value ${index + 1}`} + className="bg-background" + /> + + +
+ ); + })} +
+ )} + + Sensitive values are stored separately and are not returned to the app after saving. + +
+ ); + + return ( + <> + + +
+ + Add provider instance + + Configure an additional provider instance — for example, a second Codex install + pointed at a different workspace. + +
+ {wizardSteps.map((step, index) => ( + - ))} -
-
- -
- -
- - Driver - - setDriver(ProviderDriverKind.make(value))} - aria-labelledby="add-instance-driver-label" - className="grid grid-cols-2 gap-2.5" - > - {DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - const isSelected = option.value === driver; - return ( - - - - {option.label} - - {option.badgeLabel ? ( - - {option.badgeLabel} - - ) : null} - - ); - })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - - - - {option.label} - - - Coming Soon - - - ); - })} - + + {index < wizardStep ? : null} + + + Step {index + 1} + + + {step} + {index < wizardStep && wizardStepSummaries[index] + ? `: ${wizardStepSummaries[index]}` + : ""} + + + ))}
+ - - -
+ { + if (!nextOpen && !installingConfirmAgent) setInstallConfirmAgent(null); + }} + > + + + Install {installConfirmAgent?.agent.name}? + + T3 Code will download and extract this agent binary from the archive URL published in + the ACP registry. The registry does not currently provide checksums or signatures, so + only install agents from publishers you trust. + + {installConfirmAgent?.binaryInstall ? ( +
+
+ Download URL + +
+ {installConfirmAgent.binaryInstall.archiveUrl} +
+
+
+
+ +
+ +
+ ) : null} +
+ + }> + Cancel + - {wizardStep < wizardSteps.length - 1 ? ( - - ) : ( - - )} - -
- - + + + + ); } diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 236e1db565..071fb7f453 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -546,6 +546,7 @@ export function ProviderInstanceCard({ driverKind={driverKind} displayName={displayName} accentColor={accentColor} + iconUrl={instance.iconUrl ?? liveProvider?.iconUrl} showBadge={Boolean(accentColor)} statusDotClassName={statusStyle.dot} className="size-5" diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8fc36d4a32..b4ceb78176 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -38,6 +38,7 @@ import { } from "../../modelSelection"; import { deriveProviderInstanceEntries, + isModelPickerProviderInstanceEntry, sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; @@ -514,7 +515,7 @@ export function GeneralSettingsPanel() { const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; const gitModelInstanceEntries = sortProviderInstanceEntries( - deriveProviderInstanceEntries(serverProviders), + deriveProviderInstanceEntries(serverProviders).filter(isModelPickerProviderInstanceEntry), ); const textGenInstanceEntry = gitModelInstanceEntries.find( (entry) => entry.instanceId === textGenInstanceId, diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index ccdfd9a64d..958c5f7d8d 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,4 +1,5 @@ import { + AcpRegistrySettings, ClaudeSettings, CodexSettings, CursorSettings, @@ -6,7 +7,7 @@ import { ProviderDriverKind, } from "@t3tools/contracts"; import type { Schema } from "effect"; -import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ACPRegistryIcon, ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -59,6 +60,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("acpRegistry"), + label: "ACP Registry", + icon: ACPRegistryIcon, + settingsSchema: AcpRegistrySettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index c9120e53bb..5678bfbb67 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -139,6 +139,14 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { rpcClient ? rpcClient.server.discoverSourceControl() : Promise.reject(unavailableLocalBackendError()), + listAcpRegistry: () => + rpcClient + ? rpcClient.server.listAcpRegistry() + : Promise.reject(unavailableLocalBackendError()), + installAcpRegistryBinary: (input) => + rpcClient + ? rpcClient.server.installAcpRegistryBinary(input) + : Promise.reject(unavailableLocalBackendError()), }, }; } diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 8ba0c8fa5d..5fb79a8db9 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -20,7 +20,11 @@ import { resolveSelectableProvider, } from "./providerModels"; import { ModelEsque } from "./components/chat/providerIconUtils"; -import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances"; +import { + type ProviderInstanceEntry, + deriveProviderInstanceEntries, + isModelPickerProviderInstanceEntry, +} from "./providerInstances"; import { sortModelsForProviderInstance } from "./modelOrdering"; const MAX_CUSTOM_MODEL_COUNT = 32; @@ -238,9 +242,9 @@ export function resolveAppModelSelectionForInstance( providers: ReadonlyArray, selectedModel: string | null | undefined, ): string | null { - const entry = deriveProviderInstanceEntries(providers).find( - (candidate) => candidate.instanceId === instanceId, - ); + const entry = deriveProviderInstanceEntries(providers) + .filter(isModelPickerProviderInstanceEntry) + .find((candidate) => candidate.instanceId === instanceId); if (!entry) return null; const options = getAppModelOptionsForInstance(settings, entry); return ( @@ -263,7 +267,9 @@ export function getCustomModelOptionsByInstance( _selectedModel?: string | null, ): ReadonlyMap> { const out = new Map>(); - for (const entry of deriveProviderInstanceEntries(providers)) { + for (const entry of deriveProviderInstanceEntries(providers).filter( + isModelPickerProviderInstanceEntry, + )) { out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); } return out; @@ -277,7 +283,9 @@ export function resolveAppModelSelectionState( instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID, model: DEFAULT_GIT_TEXT_GENERATION_MODEL, }; - const entries = deriveProviderInstanceEntries(providers); + const entries = deriveProviderInstanceEntries(providers).filter( + isModelPickerProviderInstanceEntry, + ); const selectedEntry = entries.find( (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable, ); diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 7104f365eb..9c2c3225fc 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -2,6 +2,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 import { describe, expect, it } from "vitest"; import { deriveProviderInstanceEntries, + isModelPickerProviderInstanceEntry, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, } from "./providerInstances"; @@ -44,6 +45,25 @@ describe("deriveProviderInstanceEntries", () => { }); }); +describe("isModelPickerProviderInstanceEntry", () => { + it("hides the default ACP Registry catalog while keeping imported ACP agents", () => { + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("acpRegistry"), + instanceId: "acpRegistry", + displayName: "ACP Registry", + }), + provider({ + provider: ProviderDriverKind.make("acpRegistry"), + instanceId: "acpRegistry_auggie_cli", + displayName: "Auggie CLI", + }), + ]); + + expect(entries.map(isModelPickerProviderInstanceEntry)).toEqual([false, true]); + }); +}); + describe("resolveSelectableProviderInstance", () => { it("returns the requested instance when it is enabled and available", () => { const requested = ProviderInstanceId.make("claude_work"); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 6ff0bd1ab9..154b0e4aa3 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -24,6 +24,8 @@ import { import { formatProviderDriverKindLabel } from "./providerModels"; +const ACP_REGISTRY_DRIVER_KIND = "acpRegistry"; + /** * UI-facing projection of one configured provider instance. Carries the * snapshot verbatim for callers that need server-side fields we don't @@ -35,6 +37,7 @@ export interface ProviderInstanceEntry { readonly driverKind: ProviderDriverKind; readonly displayName: string; readonly accentColor?: string | undefined; + readonly iconUrl?: string | undefined; readonly continuationGroupKey?: string | undefined; readonly enabled: boolean; readonly installed: boolean; @@ -140,6 +143,7 @@ export function deriveProviderInstanceEntries( driverKind, displayName, accentColor: normalizeProviderAccentColor(snapshot.accentColor), + ...(snapshot.iconUrl ? { iconUrl: snapshot.iconUrl } : {}), continuationGroupKey: snapshot.continuation?.groupKey, enabled: snapshot.enabled, installed: snapshot.installed, @@ -184,6 +188,15 @@ export function sortProviderInstanceEntries( return sorted; } +/** + * The ACP Registry default slot is a catalog/import surface, not an agent + * users should chat with. Registry-created provider instances share the same + * driver kind, but have their own non-default ids and remain selectable. + */ +export function isModelPickerProviderInstanceEntry(entry: ProviderInstanceEntry): boolean { + return !(entry.driverKind === ACP_REGISTRY_DRIVER_KIND && entry.isDefault); +} + /** * Look up a single instance entry by exact `instanceId`. Missing snapshots * are not inferred from driver kind in UI routing code. diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 8e5819032d..da14cf4110 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -127,6 +127,10 @@ export interface WsRpcClient { readonly discoverSourceControl: RpcUnaryNoArgMethod< typeof WS_METHODS.serverDiscoverSourceControl >; + readonly listAcpRegistry: RpcUnaryNoArgMethod; + readonly installAcpRegistryBinary: RpcUnaryMethod< + typeof WS_METHODS.serverInstallAcpRegistryBinary + >; readonly subscribeConfig: RpcStreamMethod; readonly subscribeLifecycle: RpcStreamMethod; readonly subscribeAuthAccess: RpcStreamMethod; @@ -241,6 +245,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), discoverSourceControl: () => transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), + listAcpRegistry: () => + transport.request((client) => client[WS_METHODS.serverListAcpRegistry]({})), + installAcpRegistryBinary: (input) => + transport.request((client) => client[WS_METHODS.serverInstallAcpRegistryBinary](input)), subscribeConfig: (listener, options) => transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener, { ...options, diff --git a/packages/contracts/src/acp.ts b/packages/contracts/src/acp.ts new file mode 100644 index 0000000000..cd26f4696c --- /dev/null +++ b/packages/contracts/src/acp.ts @@ -0,0 +1,104 @@ +import { Effect, Schema } from "effect"; +import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const AcpDistributionType = Schema.Literals([ + "manual", + "npx", + "uvx", + "binary", + "binaryUnsupported", +]); +export type AcpDistributionType = typeof AcpDistributionType.Type; + +export const AcpLaunchSpec = Schema.Struct({ + command: TrimmedNonEmptyString, + args: Schema.Array(TrimmedNonEmptyString).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + env: Schema.Record(Schema.String, Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), +}); +export type AcpLaunchSpec = typeof AcpLaunchSpec.Type; + +export const AcpRegistryAgent = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + version: TrimmedNonEmptyString, + description: TrimmedNonEmptyString, + repository: Schema.optional(TrimmedNonEmptyString), + website: Schema.optional(TrimmedNonEmptyString), + authors: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + license: Schema.optional(TrimmedNonEmptyString), + icon: Schema.optional(TrimmedNonEmptyString), + distribution: Schema.Struct({ + npx: Schema.optional( + Schema.Struct({ + package: TrimmedNonEmptyString, + args: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), + }), + ), + uvx: Schema.optional( + Schema.Struct({ + package: TrimmedNonEmptyString, + args: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), + }), + ), + binary: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + }), +}); +export type AcpRegistryAgent = typeof AcpRegistryAgent.Type; + +export const AcpRegistryIndex = Schema.Struct({ + version: TrimmedNonEmptyString, + agents: Schema.Array(AcpRegistryAgent), +}); +export type AcpRegistryIndex = typeof AcpRegistryIndex.Type; + +export const ResolvedRegistryAcpAgent = Schema.Struct({ + agent: AcpRegistryAgent, + supported: Schema.Boolean, + distributionType: AcpDistributionType, + launch: Schema.NullOr(AcpLaunchSpec), + binaryInstall: Schema.optional( + Schema.Struct({ + archiveUrl: TrimmedNonEmptyString, + defaultInstallPath: TrimmedNonEmptyString, + platformKey: TrimmedNonEmptyString, + command: TrimmedNonEmptyString, + }), + ), +}); +export type ResolvedRegistryAcpAgent = typeof ResolvedRegistryAcpAgent.Type; + +export const AcpRegistryListResult = Schema.Struct({ + registryVersion: TrimmedNonEmptyString, + agents: Schema.Array(ResolvedRegistryAcpAgent), +}); +export type AcpRegistryListResult = typeof AcpRegistryListResult.Type; + +export const AcpRegistryInstallBinaryInput = Schema.Struct({ + agentId: TrimmedNonEmptyString, + installPath: Schema.optional(TrimmedNonEmptyString), +}); +export type AcpRegistryInstallBinaryInput = typeof AcpRegistryInstallBinaryInput.Type; + +export const AcpRegistryInstallBinaryResult = Schema.Struct({ + ok: Schema.Boolean, + agent: Schema.optional(ResolvedRegistryAcpAgent), + error: Schema.optional(TrimmedNonEmptyString), +}); +export type AcpRegistryInstallBinaryResult = typeof AcpRegistryInstallBinaryResult.Type; + +export const ServerAcpAgentStatus = Schema.Struct({ + agentServerId: TrimmedNonEmptyString, + enabled: Schema.Boolean, + installed: Schema.Boolean, + status: Schema.Literals(["ready", "warning", "error", "disabled"]), + authStatus: Schema.Literals(["authenticated", "unauthenticated", "unknown"]), + checkedAt: IsoDateTime, + displayName: TrimmedNonEmptyString, + message: Schema.optional(TrimmedNonEmptyString), + version: Schema.NullOr(TrimmedNonEmptyString), +}); +export type ServerAcpAgentStatus = typeof ServerAcpAgentStatus.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1a3647eb31..fb2936d9d9 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -7,6 +7,7 @@ export * from "./terminal.ts"; export * from "./provider.ts"; export * from "./providerInstance.ts"; export * from "./providerRuntime.ts"; +export * from "./acp.ts"; export * from "./model.ts"; export * from "./keybindings.ts"; export * from "./server.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f480912920..fb8eec9103 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -18,6 +18,11 @@ import type { VcsStatusResult, VcsCreateRefResult, } from "./git.ts"; +import type { + AcpRegistryInstallBinaryInput, + AcpRegistryInstallBinaryResult, + AcpRegistryListResult, +} from "./acp.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { ProjectSearchEntriesInput, @@ -300,6 +305,10 @@ export interface LocalApi { getSettings: () => Promise; updateSettings: (patch: ServerSettingsPatch) => Promise; discoverSourceControl: () => Promise; + listAcpRegistry: () => Promise; + installAcpRegistryBinary: ( + input: AcpRegistryInstallBinaryInput, + ) => Promise; }; } diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index cd8ab45a4b..5b521ed58f 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -129,6 +129,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); export const DEFAULT_MODEL = "gpt-5.4"; export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; @@ -138,6 +139,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [OPENCODE_DRIVER_KIND]: "OpenCode", + [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", }; diff --git a/packages/contracts/src/providerInstance.ts b/packages/contracts/src/providerInstance.ts index d5bb25f772..f62cafa52e 100644 --- a/packages/contracts/src/providerInstance.ts +++ b/packages/contracts/src/providerInstance.ts @@ -124,6 +124,7 @@ export const ProviderInstanceConfig = Schema.Struct({ driver: ProviderDriverKind, displayName: Schema.optional(TrimmedNonEmptyString), accentColor: Schema.optional(TrimmedNonEmptyString), + iconUrl: Schema.optional(TrimmedNonEmptyString), environment: Schema.optionalKey(ProviderInstanceEnvironment), enabled: Schema.optionalKey(Schema.Boolean), config: Schema.optionalKey(Schema.Unknown), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 4167bd0a76..8be128793d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -4,6 +4,11 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { OpenError, OpenInEditorInput } from "./editor.ts"; import { AuthAccessStreamEvent } from "./auth.ts"; +import { + AcpRegistryInstallBinaryInput, + AcpRegistryInstallBinaryResult, + AcpRegistryListResult, +} from "./acp.ts"; import { FilesystemBrowseInput, FilesystemBrowseResult, @@ -133,6 +138,8 @@ export const WS_METHODS = { serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", + serverListAcpRegistry: "server.listAcpRegistry", + serverInstallAcpRegistryBinary: "server.installAcpRegistryBinary", // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", @@ -189,6 +196,19 @@ export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscov success: SourceControlDiscoveryResult, }); +export const WsServerListAcpRegistryRpc = Rpc.make(WS_METHODS.serverListAcpRegistry, { + payload: Schema.Struct({}), + success: AcpRegistryListResult, +}); + +export const WsServerInstallAcpRegistryBinaryRpc = Rpc.make( + WS_METHODS.serverInstallAcpRegistryBinary, + { + payload: AcpRegistryInstallBinaryInput, + success: AcpRegistryInstallBinaryResult, + }, +); + export const WsSourceControlLookupRepositoryRpc = Rpc.make( WS_METHODS.sourceControlLookupRepository, { @@ -419,6 +439,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, + WsServerListAcpRegistryRpc, + WsServerInstallAcpRegistryBinaryRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index a3d07ea69c..2a0a5f44c6 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -118,6 +118,7 @@ export const ServerProvider = Schema.Struct({ driver: ProviderDriverKind, displayName: Schema.optional(TrimmedNonEmptyString), accentColor: Schema.optional(TrimmedNonEmptyString), + iconUrl: Schema.optional(TrimmedNonEmptyString), badgeLabel: Schema.optional(TrimmedNonEmptyString), continuation: Schema.optional(ServerProviderContinuation), showInteractionModeToggle: Schema.optional(Schema.Boolean), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 90b9099d17..f635642092 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -313,6 +313,26 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const AcpRegistrySettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + registryUrl: TrimmedString.pipe( + Schema.withDecodingDefault( + Effect.succeed("https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"), + ), + ), + command: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + args: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + env: Schema.Record(Schema.String, Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + iconUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + registryAgentId: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + importedVersion: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + distributionType: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), +}); +export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -345,6 +365,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + acpRegistry: AcpRegistrySettings.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 @@ -420,6 +441,19 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const AcpRegistrySettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + registryUrl: Schema.optionalKey(Schema.String), + command: Schema.optionalKey(Schema.String), + args: Schema.optionalKey(Schema.Array(Schema.String)), + env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + iconUrl: Schema.optionalKey(Schema.String), + registryAgentId: Schema.optionalKey(Schema.String), + importedVersion: Schema.optionalKey(Schema.String), + distributionType: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -438,6 +472,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + acpRegistry: Schema.optionalKey(AcpRegistrySettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual From 1d1d67c4bd4fe6c44efcdeb2f970c4d5c7e6c7ed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 01:54:42 -0700 Subject: [PATCH 2/6] Add generic ACP adapter support - Route ACP registry sessions through a generic ACP runtime - Make ACP auth optional and skip Cursor-only extensions for generic providers --- .../src/provider/Drivers/AcpRegistryDriver.ts | 7 +- .../src/provider/Layers/CursorAdapter.ts | 229 ++++++++++-------- .../src/provider/Layers/GenericAcpAdapter.ts | 38 +++ .../src/provider/acp/AcpSessionRuntime.ts | 20 +- .../src/provider/acp/GenericAcpSupport.ts | 29 +++ 5 files changed, 214 insertions(+), 109 deletions(-) create mode 100644 apps/server/src/provider/Layers/GenericAcpAdapter.ts create mode 100644 apps/server/src/provider/acp/GenericAcpSupport.ts diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index 2b69b3d461..c40706365f 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -9,7 +9,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config.ts"; import { ProviderDriverError } from "../Errors.ts"; -import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; +import { makeGenericAcpAdapter } from "../Layers/GenericAcpAdapter.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { @@ -123,11 +123,10 @@ export const AcpRegistryDriver: ProviderDriver model?.trim() || "default", ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), spawn: ({ cwd, environment: spawnEnv }) => ({ command: effectiveConfig.command.trim(), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 3518006737..263a41e1bb 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -65,6 +65,7 @@ import { } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; +import { makeGenericAcpRuntime } from "../acp/GenericAcpSupport.ts"; import { CursorAskQuestionRequest, CursorCreatePlanRequest, @@ -101,6 +102,9 @@ export interface CursorAdapterLiveOptions { }; readonly normalizeModel?: (model: string | null | undefined) => string; readonly applyCursorModelOptions?: boolean; + readonly cursorExtensions?: boolean; + readonly authMethodId?: string | undefined; + readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; /** * Selections are honored when `modelSelection.instanceId` matches this value. * Defaults to the legacy built-in instance id (`cursor`). @@ -338,6 +342,7 @@ export function makeCursorAdapter( const readyReason = options?.readyReason ?? "Cursor ACP session ready"; const normalizeModel = options?.normalizeModel ?? resolveCursorAcpBaseModelId; const applyCursorModelOptions = options?.applyCursorModelOptions ?? true; + const cursorExtensions = options?.cursorExtensions ?? true; const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor"); const fileSystem = yield* FileSystem.FileSystem; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -530,24 +535,53 @@ export function makeCursorAdapter( ? yield* options.resolveSettings : cursorSettings; - const acp = yield* makeCursorAcpRuntime({ - cursorSettings: effectiveCursorSettings, - ...(options?.environment ? { environment: options.environment } : {}), - ...(options?.spawn - ? { - spawn: options.spawn({ - settings: effectiveCursorSettings, - cwd, - ...(options.environment ? { environment: options.environment } : {}), - }), - } - : {}), - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...acpNativeLoggers, - }).pipe( + const spawn = options?.spawn + ? options.spawn({ + settings: effectiveCursorSettings, + cwd, + ...(options.environment ? { environment: options.environment } : {}), + }) + : undefined; + const acp = yield* ( + cursorExtensions + ? makeCursorAcpRuntime({ + cursorSettings: effectiveCursorSettings, + ...(options?.environment ? { environment: options.environment } : {}), + ...(spawn ? { spawn } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + }) + : makeGenericAcpRuntime({ + spawn: + spawn ?? + (() => { + const fallback = options?.spawn?.({ + settings: effectiveCursorSettings, + cwd, + ...(options.environment ? { environment: options.environment } : {}), + }); + if (fallback) return fallback; + return { + command: effectiveCursorSettings.binaryPath, + args: [], + cwd, + ...(options?.environment ? { env: options.environment } : {}), + }; + })(), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(options?.authMethodId ? { authMethodId: options.authMethodId } : {}), + ...(options?.clientCapabilities + ? { clientCapabilities: options.clientCapabilities } + : {}), + ...acpNativeLoggers, + }) + ).pipe( Effect.provideService(Scope.Scope, sessionScope), Effect.mapError( (cause) => @@ -560,92 +594,97 @@ export function makeCursorAdapter( ), ); const started = yield* Effect.gen(function* () { - yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/ask_question", - params, - "acp.cursor.extension", - ); - const requestId = ApprovalRequestId.make(crypto.randomUUID()); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const answers = yield* Deferred.make(); - pendingUserInputs.set(requestId, { answers }); - yield* offerRuntimeEvent({ - type: "user-input.requested", - ...(yield* makeEventStamp()), - provider: providerKind, - 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: providerKind, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - payload: { answers: resolved }, - }); - return { answers: resolved }; - }), - ); - yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/create_plan", - params, - "acp.cursor.extension", - ); - yield* offerRuntimeEvent({ - type: "turn.proposed.completed", - ...(yield* makeEventStamp()), - provider: providerKind, - 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) => + if (cursorExtensions) { + yield* acp.handleExtRequest( + "cursor/ask_question", + CursorAskQuestionRequest, + (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/ask_question", + params, + "acp.cursor.extension", + ); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const answers = yield* Deferred.make(); + pendingUserInputs.set(requestId, { answers }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), + provider: providerKind, + 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: providerKind, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { answers: resolved }, + }); + return { answers: resolved }; + }), + ); + yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => Effect.gen(function* () { yield* logNative( input.threadId, - "cursor/update_todos", + "cursor/create_plan", params, "acp.cursor.extension", ); - if (ctx) { - yield* emitPlanUpdate( - ctx, - extractTodosAsPlan(params), + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* makeEventStamp()), + provider: providerKind, + 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) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/update_todos", params, "acp.cursor.extension", - "cursor/update_todos", ); - } - }), - ); + if (ctx) { + yield* emitPlanUpdate( + ctx, + extractTodosAsPlan(params), + params, + "acp.cursor.extension", + "cursor/update_todos", + ); + } + }), + ); + } yield* acp.handleRequestPermission((params) => Effect.gen(function* () { yield* logNative( diff --git a/apps/server/src/provider/Layers/GenericAcpAdapter.ts b/apps/server/src/provider/Layers/GenericAcpAdapter.ts new file mode 100644 index 0000000000..d41f0dc5ec --- /dev/null +++ b/apps/server/src/provider/Layers/GenericAcpAdapter.ts @@ -0,0 +1,38 @@ +import { + type CursorSettings, + type ProviderDriverKind, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { makeCursorAdapter, type CursorAdapterLiveOptions } from "./CursorAdapter.ts"; + +type GenericAcpAdapterSettings = Pick; + +type GenericAcpAdapterOptions = Omit< + CursorAdapterLiveOptions, + "applyCursorModelOptions" | "authMethodId" | "clientCapabilities" | "cursorExtensions" +> & { + readonly provider: ProviderDriverKind; + readonly instanceId: typeof ProviderInstanceId.Type; + readonly authMethodId?: string | undefined; + readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; +}; + +export function makeGenericAcpAdapter( + settings: GenericAcpAdapterSettings, + options: GenericAcpAdapterOptions, +) { + return makeCursorAdapter( + { + ...settings, + apiEndpoint: "", + }, + { + ...options, + applyCursorModelOptions: false, + cursorExtensions: false, + normalizeModel: options.normalizeModel ?? ((model) => model?.trim() || "default"), + }, + ); +} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b4cf665608..b12ff073f0 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -33,7 +33,7 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + readonly authMethodId?: string; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; @@ -365,15 +365,17 @@ const makeAcpSessionRuntime = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + if (options.authMethodId) { + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: diff --git a/apps/server/src/provider/acp/GenericAcpSupport.ts b/apps/server/src/provider/acp/GenericAcpSupport.ts new file mode 100644 index 0000000000..3dcbd55497 --- /dev/null +++ b/apps/server/src/provider/acp/GenericAcpSupport.ts @@ -0,0 +1,29 @@ +import { Effect, Layer, Scope } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { + AcpSessionRuntime, + type AcpSessionRuntimeOptions, + type AcpSessionRuntimeShape, + type AcpSpawnInput, +} from "./AcpSessionRuntime.ts"; + +export interface GenericAcpRuntimeInput extends Omit { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly spawn: AcpSpawnInput; +} + +export const makeGenericAcpRuntime = ( + input: GenericAcpRuntimeInput, +): Effect.Effect => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer(input).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + }); From 4e10a99aa97095449cbdd4e21dc105c39c87c1a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 12:41:54 -0700 Subject: [PATCH 3/6] Use generic ACP adapter for registry sessions - Route ACP registry through the shared generic adapter - Simplify cursor adapter session setup and model selection - Preserve cursor-specific extension handling in the cursor layer --- .../src/provider/Drivers/AcpRegistryDriver.ts | 10 +- .../src/provider/Layers/CursorAdapter.ts | 350 +++----- .../src/provider/Layers/GenericAcpAdapter.ts | 751 +++++++++++++++++- .../src/provider/acp/AcpSessionRuntime.ts | 2 +- .../src/provider/acp/CursorAcpSupport.ts | 13 +- 5 files changed, 866 insertions(+), 260 deletions(-) diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index c40706365f..a7996095a7 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -126,8 +126,8 @@ export const AcpRegistryDriver: ProviderDriver ({ - command: effectiveConfig.command.trim(), - args: effectiveConfig.args, - cwd, - ...(spawnEnv ? { env: spawnEnv } : {}), - }), }, ); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 263a41e1bb..34d1221b02 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -65,7 +65,6 @@ import { } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; -import { makeGenericAcpRuntime } from "../acp/GenericAcpSupport.ts"; import { CursorAskQuestionRequest, CursorCreatePlanRequest, @@ -88,23 +87,6 @@ export interface CursorAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; - readonly provider?: ProviderDriverKind; - readonly readyReason?: string; - readonly spawn?: (input: { - readonly settings: CursorSettings; - readonly cwd: string; - readonly environment?: NodeJS.ProcessEnv; - }) => { - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd?: string; - readonly env?: NodeJS.ProcessEnv; - }; - readonly normalizeModel?: (model: string | null | undefined) => string; - readonly applyCursorModelOptions?: boolean; - readonly cursorExtensions?: boolean; - readonly authMethodId?: string | undefined; - readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; /** * Selections are honored when `modelSelection.instanceId` matches this value. * Defaults to the legacy built-in instance id (`cursor`). @@ -262,8 +244,6 @@ function applyRequestedSessionConfiguration(input: { readonly options?: ReadonlyArray | null | undefined; } | undefined; - readonly normalizeModel: (model: string | null | undefined) => string; - readonly applyCursorModelOptions: boolean; readonly mapError: (context: { readonly cause: import("effect-acp/errors").AcpError; readonly method: "session/set_config_option" | "session/set_mode"; @@ -271,30 +251,16 @@ function applyRequestedSessionConfiguration(input: { }): Effect.Effect { return Effect.gen(function* () { if (input.modelSelection) { - if (input.applyCursorModelOptions) { - yield* applyCursorAcpModelSelection({ - runtime: input.runtime, - model: input.modelSelection.model, - selections: input.modelSelection.options, - mapError: ({ cause }) => - input.mapError({ - cause, - method: "session/set_config_option", - }), - }); - } else { - const normalizedModel = input.normalizeModel(input.modelSelection.model); - if (normalizedModel !== "default") { - yield* input.runtime.setModel(normalizedModel).pipe( - Effect.mapError((cause) => - input.mapError({ - cause, - method: "session/set_config_option", - }), - ), - ); - } - } + 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({ @@ -338,11 +304,6 @@ export function makeCursorAdapter( options?: CursorAdapterLiveOptions, ) { return Effect.gen(function* () { - const providerKind = options?.provider ?? PROVIDER; - const readyReason = options?.readyReason ?? "Cursor ACP session ready"; - const normalizeModel = options?.normalizeModel ?? resolveCursorAcpBaseModelId; - const applyCursorModelOptions = options?.applyCursorModelOptions ?? true; - const cursorExtensions = options?.cursorExtensions ?? true; const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor"); const fileSystem = yield* FileSystem.FileSystem; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -404,7 +365,7 @@ export function makeCursorAdapter( event: { id: crypto.randomUUID(), kind: "notification", - provider: providerKind, + provider: PROVIDER, createdAt: observedAt, method, threadId, @@ -437,7 +398,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpPlanUpdatedEvent({ stamp: yield* makeEventStamp(), - provider: providerKind, + provider: PROVIDER, threadId: ctx.threadId, turnId: ctx.activeTurnId, payload, @@ -454,7 +415,7 @@ export function makeCursorAdapter( const ctx = sessions.get(threadId); if (!ctx || ctx.stopped) { return Effect.fail( - new ProviderAdapterSessionNotFoundError({ provider: providerKind, threadId }), + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), ); } return Effect.succeed(ctx); @@ -474,7 +435,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent({ type: "session.exited", ...(yield* makeEventStamp()), - provider: providerKind, + provider: PROVIDER, threadId: ctx.threadId, payload: { exitKind: "graceful" }, }); @@ -484,16 +445,16 @@ export function makeCursorAdapter( withThreadLock( input.threadId, Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== providerKind) { + if (input.provider !== undefined && input.provider !== PROVIDER) { return yield* new ProviderAdapterValidationError({ - provider: providerKind, + provider: PROVIDER, operation: "startSession", - issue: `Expected provider '${providerKind}' but received '${input.provider}'.`, + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, }); } if (!input.cwd?.trim()) { return yield* new ProviderAdapterValidationError({ - provider: providerKind, + provider: PROVIDER, operation: "startSession", issue: "cwd is required and must be non-empty.", }); @@ -519,7 +480,7 @@ export function makeCursorAdapter( const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; const acpNativeLoggers = makeAcpNativeLoggers({ nativeEventLogger, - provider: providerKind, + provider: PROVIDER, threadId: input.threadId, }); @@ -535,58 +496,20 @@ export function makeCursorAdapter( ? yield* options.resolveSettings : cursorSettings; - const spawn = options?.spawn - ? options.spawn({ - settings: effectiveCursorSettings, - cwd, - ...(options.environment ? { environment: options.environment } : {}), - }) - : undefined; - const acp = yield* ( - cursorExtensions - ? makeCursorAcpRuntime({ - cursorSettings: effectiveCursorSettings, - ...(options?.environment ? { environment: options.environment } : {}), - ...(spawn ? { spawn } : {}), - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...acpNativeLoggers, - }) - : makeGenericAcpRuntime({ - spawn: - spawn ?? - (() => { - const fallback = options?.spawn?.({ - settings: effectiveCursorSettings, - cwd, - ...(options.environment ? { environment: options.environment } : {}), - }); - if (fallback) return fallback; - return { - command: effectiveCursorSettings.binaryPath, - args: [], - cwd, - ...(options?.environment ? { env: options.environment } : {}), - }; - })(), - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...(options?.authMethodId ? { authMethodId: options.authMethodId } : {}), - ...(options?.clientCapabilities - ? { clientCapabilities: options.clientCapabilities } - : {}), - ...acpNativeLoggers, - }) - ).pipe( + const acp = yield* makeCursorAcpRuntime({ + cursorSettings: effectiveCursorSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + }).pipe( Effect.provideService(Scope.Scope, sessionScope), Effect.mapError( (cause) => new ProviderAdapterProcessError({ - provider: providerKind, + provider: PROVIDER, threadId: input.threadId, detail: cause.message, cause, @@ -594,97 +517,92 @@ export function makeCursorAdapter( ), ); const started = yield* Effect.gen(function* () { - if (cursorExtensions) { - yield* acp.handleExtRequest( - "cursor/ask_question", - CursorAskQuestionRequest, - (params) => - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/ask_question", - params, - "acp.cursor.extension", - ); - const requestId = ApprovalRequestId.make(crypto.randomUUID()); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const answers = yield* Deferred.make(); - pendingUserInputs.set(requestId, { answers }); - yield* offerRuntimeEvent({ - type: "user-input.requested", - ...(yield* makeEventStamp()), - provider: providerKind, - 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: providerKind, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - payload: { answers: resolved }, - }); - return { answers: resolved }; - }), - ); - yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => + yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/ask_question", + params, + "acp.cursor.extension", + ); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + 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) => + 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) => Effect.gen(function* () { yield* logNative( input.threadId, - "cursor/create_plan", + "cursor/update_todos", params, "acp.cursor.extension", ); - yield* offerRuntimeEvent({ - type: "turn.proposed.completed", - ...(yield* makeEventStamp()), - provider: providerKind, - 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) => - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/update_todos", + if (ctx) { + yield* emitPlanUpdate( + ctx, + extractTodosAsPlan(params), params, "acp.cursor.extension", + "cursor/update_todos", ); - if (ctx) { - yield* emitPlanUpdate( - ctx, - extractTodosAsPlan(params), - params, - "acp.cursor.extension", - "cursor/update_todos", - ); - } - }), - ); - } + } + }), + ); yield* acp.handleRequestPermission((params) => Effect.gen(function* () { yield* logNative( @@ -715,7 +633,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpRequestOpenedEvent({ stamp: yield* makeEventStamp(), - provider: providerKind, + provider: PROVIDER, threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, @@ -732,7 +650,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpRequestResolvedEvent({ stamp: yield* makeEventStamp(), - provider: providerKind, + provider: PROVIDER, threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, @@ -754,7 +672,7 @@ export function makeCursorAdapter( return yield* acp.start(); }).pipe( Effect.mapError((error) => - mapAcpToAdapterError(providerKind, input.threadId, "session/start", error), + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), ), ); @@ -763,15 +681,13 @@ export function makeCursorAdapter( runtimeMode: input.runtimeMode, interactionMode: undefined, modelSelection: cursorModelSelection, - normalizeModel, - applyCursorModelOptions, mapError: ({ cause, method }) => - mapAcpToAdapterError(providerKind, input.threadId, method, cause), + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), }); const now = yield* nowIso; const session: ProviderSession = { - provider: providerKind, + provider: PROVIDER, providerInstanceId: boundInstanceId, status: "ready", runtimeMode: input.runtimeMode, @@ -810,7 +726,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpAssistantItemEvent({ stamp: yield* makeEventStamp(), - provider: providerKind, + provider: PROVIDER, threadId: ctx.threadId, turnId: ctx.activeTurnId, itemId: event.itemId, @@ -822,7 +738,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpAssistantItemEvent({ stamp: yield* makeEventStamp(), - provider: providerKind, + provider: PROVIDER, threadId: ctx.threadId, turnId: ctx.activeTurnId, itemId: event.itemId, @@ -855,7 +771,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpToolCallEvent({ stamp: yield* makeEventStamp(), - provider: providerKind, + provider: PROVIDER, threadId: ctx.threadId, turnId: ctx.activeTurnId, toolCall: event.toolCall, @@ -873,7 +789,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent( makeAcpContentDeltaEvent({ stamp: yield* makeEventStamp(), - provider: providerKind, + provider: PROVIDER, threadId: ctx.threadId, turnId: ctx.activeTurnId, ...(event.itemId ? { itemId: event.itemId } : {}), @@ -894,21 +810,21 @@ export function makeCursorAdapter( yield* offerRuntimeEvent({ type: "session.started", ...(yield* makeEventStamp()), - provider: providerKind, + provider: PROVIDER, threadId: input.threadId, payload: { resume: started.initializeResult }, }); yield* offerRuntimeEvent({ type: "session.state.changed", ...(yield* makeEventStamp()), - provider: providerKind, + provider: PROVIDER, threadId: input.threadId, - payload: { state: "ready", reason: readyReason }, + payload: { state: "ready", reason: "Cursor ACP session ready" }, }); yield* offerRuntimeEvent({ type: "thread.started", ...(yield* makeEventStamp()), - provider: providerKind, + provider: PROVIDER, threadId: input.threadId, payload: { providerThreadId: started.sessionId }, }); @@ -924,7 +840,7 @@ export function makeCursorAdapter( const turnModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const model = turnModelSelection?.model ?? ctx.session.model; - const resolvedModel = normalizeModel(model); + const resolvedModel = resolveCursorAcpBaseModelId(model); yield* applyRequestedSessionConfiguration({ runtime: ctx.acp, runtimeMode: ctx.session.runtimeMode, @@ -936,10 +852,8 @@ export function makeCursorAdapter( model, options: turnModelSelection?.options, }, - normalizeModel, - applyCursorModelOptions, mapError: ({ cause, method }) => - mapAcpToAdapterError(providerKind, input.threadId, method, cause), + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), }); ctx.activeTurnId = turnId; ctx.lastPlanFingerprint = undefined; @@ -952,7 +866,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent({ type: "turn.started", ...(yield* makeEventStamp()), - provider: providerKind, + provider: PROVIDER, threadId: input.threadId, turnId, payload: { model: resolvedModel }, @@ -970,7 +884,7 @@ export function makeCursorAdapter( }); if (!attachmentPath) { return yield* new ProviderAdapterRequestError({ - provider: providerKind, + provider: PROVIDER, method: "session/prompt", detail: `Invalid attachment id '${attachment.id}'.`, }); @@ -979,7 +893,7 @@ export function makeCursorAdapter( Effect.mapError( (cause) => new ProviderAdapterRequestError({ - provider: providerKind, + provider: PROVIDER, method: "session/prompt", detail: cause.message, cause, @@ -996,7 +910,7 @@ export function makeCursorAdapter( if (promptParts.length === 0) { return yield* new ProviderAdapterValidationError({ - provider: providerKind, + provider: PROVIDER, operation: "sendTurn", issue: "Turn requires non-empty text or attachments.", }); @@ -1008,7 +922,7 @@ export function makeCursorAdapter( }) .pipe( Effect.mapError((error) => - mapAcpToAdapterError(providerKind, input.threadId, "session/prompt", error), + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), ), ); @@ -1023,7 +937,7 @@ export function makeCursorAdapter( yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), - provider: providerKind, + provider: PROVIDER, threadId: input.threadId, turnId, payload: { @@ -1047,7 +961,7 @@ export function makeCursorAdapter( yield* Effect.ignore( ctx.acp.cancel.pipe( Effect.mapError((error) => - mapAcpToAdapterError(providerKind, threadId, "session/cancel", error), + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), ), ), ); @@ -1063,7 +977,7 @@ export function makeCursorAdapter( const pending = ctx.pendingApprovals.get(requestId); if (!pending) { return yield* new ProviderAdapterRequestError({ - provider: providerKind, + provider: PROVIDER, method: "session/request_permission", detail: `Unknown pending approval request: ${requestId}`, }); @@ -1081,7 +995,7 @@ export function makeCursorAdapter( const pending = ctx.pendingUserInputs.get(requestId); if (!pending) { return yield* new ProviderAdapterRequestError({ - provider: providerKind, + provider: PROVIDER, method: "cursor/ask_question", detail: `Unknown pending user-input request: ${requestId}`, }); @@ -1100,7 +1014,7 @@ export function makeCursorAdapter( const ctx = yield* requireSession(threadId); if (!Number.isInteger(numTurns) || numTurns < 1) { return yield* new ProviderAdapterValidationError({ - provider: providerKind, + provider: PROVIDER, operation: "rollbackThread", issue: "numTurns must be an integer >= 1.", }); @@ -1141,7 +1055,7 @@ export function makeCursorAdapter( const streamEvents = Stream.fromPubSub(runtimeEventPubSub); return { - provider: providerKind, + provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session" }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/GenericAcpAdapter.ts b/apps/server/src/provider/Layers/GenericAcpAdapter.ts index d41f0dc5ec..a151653fd1 100644 --- a/apps/server/src/provider/Layers/GenericAcpAdapter.ts +++ b/apps/server/src/provider/Layers/GenericAcpAdapter.ts @@ -1,38 +1,743 @@ +import * as nodePath from "node:path"; + import { - type CursorSettings, + ApprovalRequestId, + type ProviderApprovalDecision, type ProviderDriverKind, - type ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + RuntimeRequestId, + type ThreadId, + TurnId, + EventId, } from "@t3tools/contracts"; +import { + DateTime, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + PubSub, + Random, + Scope, + Semaphore, + Stream, + SynchronizedRef, +} from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { makeCursorAdapter, type CursorAdapterLiveOptions } from "./CursorAdapter.ts"; +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 { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeGenericAcpRuntime } from "../acp/GenericAcpSupport.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { type ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +const GENERIC_ACP_RESUME_VERSION = 1 as const; -type GenericAcpAdapterSettings = Pick; +export interface GenericAcpAdapterSettings { + readonly enabled: boolean; + readonly command: string; + readonly args: ReadonlyArray; +} -type GenericAcpAdapterOptions = Omit< - CursorAdapterLiveOptions, - "applyCursorModelOptions" | "authMethodId" | "clientCapabilities" | "cursorExtensions" -> & { +export interface GenericAcpAdapterOptions { readonly provider: ProviderDriverKind; - readonly instanceId: typeof ProviderInstanceId.Type; - readonly authMethodId?: string | undefined; + readonly instanceId: ProviderSession["providerInstanceId"]; + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly readyReason?: string; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; -}; + readonly authMethodId?: string | undefined; + readonly normalizeModel?: (model: string | null | undefined) => string; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface GenericAcpSessionContext { + 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 }>; + activeTurnId: TurnId | undefined; + stopped: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseGenericResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== GENERIC_ACP_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + pendingApprovals.values(), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + pendingUserInputs.values(), + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { discard: true }, + ); +} export function makeGenericAcpAdapter( settings: GenericAcpAdapterSettings, options: GenericAcpAdapterOptions, ) { - return makeCursorAdapter( - { - ...settings, - apiEndpoint: "", - }, - { - ...options, - applyCursorModelOptions: false, - cursorExtensions: false, - normalizeModel: options.normalizeModel ?? ((model) => model?.trim() || "default"), - }, - ); + return Effect.gen(function* () { + const providerKind = options.provider; + const readyReason = options.readyReason ?? "ACP session ready"; + const normalizeModel = options.normalizeModel ?? ((model) => model?.trim() || "default"); + const fileSystem = yield* FileSystem.FileSystem; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const nativeEventLogger = + options.nativeEventLogger ?? + (options.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options.nativeEventLogger === undefined ? nativeEventLogger : undefined; + + 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 nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing = current.get(threadId); + if (existing) return Effect.succeed([existing, current] as const); + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: providerKind, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: GenericAcpSessionContext) => + 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: providerKind, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: ProviderAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== providerKind) { + return yield* new ProviderAdapterValidationError({ + provider: providerKind, + operation: "startSession", + issue: `Expected provider '${providerKind}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: providerKind, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const command = settings.command.trim(); + if (!settings.enabled || command.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: providerKind, + operation: "startSession", + issue: "ACP command is not configured.", + }); + } + + const cwd = nodePath.resolve(input.cwd.trim()); + 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!: GenericAcpSessionContext; + + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: providerKind, + threadId: input.threadId, + }); + const resumeSessionId = parseGenericResume(input.resumeCursor)?.sessionId; + const acp = yield* makeGenericAcpRuntime({ + spawn: { + command, + args: settings.args, + cwd, + ...(options.environment ? { env: options.environment } : {}), + }, + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(options.authMethodId ? { authMethodId: options.authMethodId } : {}), + ...(options.clientCapabilities + ? { clientCapabilities: options.clientCapabilities } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: providerKind, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + Effect.gen(function* () { + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = + params.options.find((option) => option.kind === "allow_always")?.optionId ?? + params.options.find((option) => option.kind === "allow_once")?.optionId; + if (typeof autoApprovedOptionId === "string" && autoApprovedOptionId.trim()) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId.trim(), + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: providerKind, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: permissionRequest.detail ?? JSON.stringify(params).slice(0, 2000), + 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: providerKind, + 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(providerKind, input.threadId, "session/start", error), + ), + ); + + const initialModelSelection = + input.modelSelection?.instanceId === options.instanceId + ? input.modelSelection + : undefined; + if (initialModelSelection?.model) { + const model = normalizeModel(initialModelSelection.model); + if (model !== "default") { + yield* acp + .setModel(model) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError( + providerKind, + input.threadId, + "session/set_config_option", + error, + ), + ), + ); + } + } + + const now = yield* nowIso; + const session: ProviderSession = { + provider: providerKind, + providerInstanceId: options.instanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: initialModelSelection?.model, + threadId: input.threadId, + resumeCursor: { + schemaVersion: GENERIC_ACP_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + activeTurnId: undefined, + stopped: false, + }; + + const notificationFiber = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: providerKind, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: providerKind, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: providerKind, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: event.payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload: event.rawPayload, + }), + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: providerKind, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: providerKind, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe(Effect.forkChild); + + ctx.notificationFiber = notificationFiber; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: providerKind, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: providerKind, + threadId: input.threadId, + payload: { state: "ready", reason: readyReason }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: providerKind, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(crypto.randomUUID()); + const turnModelSelection = + input.modelSelection?.instanceId === options.instanceId + ? input.modelSelection + : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + const resolvedModel = normalizeModel(model); + if (model !== undefined && resolvedModel !== "default") { + yield* ctx.acp + .setModel(resolvedModel) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError( + providerKind, + input.threadId, + "session/set_config_option", + error, + ), + ), + ); + } + + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: providerKind, + 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: providerKind, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: providerKind, + 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: providerKind, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const result = yield* ctx.acp + .prompt({ prompt: promptParts }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(providerKind, input.threadId, "session/prompt", error), + ), + ); + + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolvedModel, + }; + + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: providerKind, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }); + + 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(providerKind, 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: providerKind, + 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: providerKind, + method: "elicitation/create", + 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: providerKind, + 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: ProviderAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions = () => + Effect.sync(() => Array.from(sessions.values(), (ctx) => ({ ...ctx.session }))); + const hasSession = (threadId: ThreadId) => + Effect.sync(() => { + const ctx = sessions.get(threadId); + return ctx !== undefined && !ctx.stopped; + }); + const stopAll = () => Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + return { + provider: providerKind, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromPubSub(runtimeEventPubSub), + } satisfies ProviderAdapterShape; + }); } diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b12ff073f0..034319a56f 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -382,7 +382,7 @@ const makeAcpSessionRuntime = ( | EffectAcpSchema.LoadSessionResponse | EffectAcpSchema.NewSessionResponse | EffectAcpSchema.ResumeSessionResponse; - if (options.resumeSessionId) { + if (options.resumeSessionId && initializeResult.agentCapabilities?.loadSession === true) { const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 7bd4edcab2..3e405dd7ff 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -2,7 +2,6 @@ import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/cont import { Effect, Layer, Scope } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpErrors from "effect-acp/errors"; -import type * as EffectAcpSchema from "effect-acp/schema"; import { CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, @@ -25,9 +24,6 @@ export interface CursorAcpRuntimeInput extends Omit< readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; - readonly spawn?: AcpSpawnInput; - readonly authMethodId?: string; - readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; } export interface CursorAcpModelSelectionErrorContext { @@ -59,12 +55,9 @@ export const makeCursorAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: - input.spawn ?? - buildCursorAcpSpawnInput(input.cursorSettings, input.cwd, input.environment), - authMethodId: input.authMethodId ?? "cursor_login", - clientCapabilities: - input.clientCapabilities ?? CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, + spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd, input.environment), + authMethodId: "cursor_login", + clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, }).pipe( Layer.provide( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), From 792e19fc268aa17e0aa760c27b9a371d6b76c2d5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 16:44:29 -0700 Subject: [PATCH 4/6] Refactor ACP binary registry installation - Move ACP binary install and launch resolution into a shared module - Switch stream exposure to a getter so runtime events stay fresh - Keep WebSocket RPC handlers thin and reuse registry install helpers --- .../src/provider/Layers/GenericAcpAdapter.ts | 4 +- .../acp/AcpRegistryBinaryInstaller.ts | 432 ++++++++++++++++++ apps/server/src/ws.ts | 415 +---------------- 3 files changed, 453 insertions(+), 398 deletions(-) create mode 100644 apps/server/src/provider/acp/AcpRegistryBinaryInstaller.ts diff --git a/apps/server/src/provider/Layers/GenericAcpAdapter.ts b/apps/server/src/provider/Layers/GenericAcpAdapter.ts index a151653fd1..cc9a7c0edb 100644 --- a/apps/server/src/provider/Layers/GenericAcpAdapter.ts +++ b/apps/server/src/provider/Layers/GenericAcpAdapter.ts @@ -737,7 +737,9 @@ export function makeGenericAcpAdapter( listSessions, hasSession, stopAll, - streamEvents: Stream.fromPubSub(runtimeEventPubSub), + get streamEvents() { + return Stream.fromPubSub(runtimeEventPubSub); + }, } satisfies ProviderAdapterShape; }); } diff --git a/apps/server/src/provider/acp/AcpRegistryBinaryInstaller.ts b/apps/server/src/provider/acp/AcpRegistryBinaryInstaller.ts new file mode 100644 index 0000000000..9d9e2e28f0 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryBinaryInstaller.ts @@ -0,0 +1,432 @@ +import { Data, Effect, FileSystem, Path, Schema } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + type AcpRegistryAgent, + type AcpRegistryInstallBinaryResult, + type AcpRegistryListResult, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { collectStreamAsString } from "../providerSnapshot.ts"; + +const ACP_BINARY_INSTALLS_DIR = "acp_agents"; +const ACP_BINARY_MANIFEST_FILE = "install.json"; + +type BinaryDistributionTarget = { + readonly archive: string; + readonly cmd: string; +}; + +class AcpRegistryBinaryInstallError extends Data.TaggedError("AcpRegistryBinaryInstallError")<{ + readonly detail: string; + readonly cause?: unknown; +}> {} + +const AcpBinaryInstallManifest = Schema.Struct({ + layoutVersion: Schema.Literal(2), + agentId: Schema.String, + version: Schema.String, + platformKey: Schema.String, + command: Schema.String, + archiveUrl: Schema.String, +}); + +type AcpBinaryInstallManifest = typeof AcpBinaryInstallManifest.Type; + +function getAcpBinaryPlatformKey(): string { + const os = process.platform; + const cpu = process.arch; + const osKey = + os === "darwin" ? "darwin" : os === "win32" ? "windows" : os === "linux" ? "linux" : os; + const archKey = cpu === "arm64" ? "aarch64" : cpu === "x64" ? "x86_64" : cpu; + return `${osKey}-${archKey}`; +} + +function getBinaryTarget(agent: AcpRegistryAgent): BinaryDistributionTarget | null { + const binary = agent.distribution.binary; + if (!binary || typeof binary !== "object" || globalThis.Array.isArray(binary)) return null; + const target = (binary as Record)[getAcpBinaryPlatformKey()]; + if (!target || typeof target !== "object" || globalThis.Array.isArray(target)) return null; + const record = target as Record; + if (typeof record.archive !== "string" || typeof record.cmd !== "string") return null; + return { archive: record.archive, cmd: record.cmd }; +} + +function resolveHomeDirectory(): string { + return process.env.HOME ?? process.env.USERPROFILE ?? ""; +} + +function normalizeArchiveCommandPath(path: Path.Path, command: string): string { + const trimmed = command.trim(); + if (!trimmed) { + throw new Error("Registry binary command must not be empty."); + } + if (path.isAbsolute(trimmed)) { + throw new Error("Registry binary command must be a relative archive path."); + } + const withoutLeadingDot = trimmed.replace(/^(?:\.[/\\])+/u, ""); + const parts = withoutLeadingDot + .split(/[/\\]+/u) + .filter((part) => part.length > 0 && part !== "."); + if (parts.length === 0 || parts.some((part) => part === "..")) { + throw new Error("Registry binary command resolves outside the archive."); + } + return path.join(...parts); +} + +function resolveInstallRootFromBinaryPath( + path: Path.Path, + binaryPath: string, + commandRelativePath: string, +): string { + const depth = commandRelativePath.split(/[/\\]+/u).filter((part) => part.length > 0).length; + let installRoot = binaryPath; + for (let index = 0; index < depth; index += 1) { + installRoot = path.dirname(installRoot); + } + return installRoot; +} + +function resolveAcpBinaryInstallPath( + path: Path.Path, + config: { readonly stateDir: string }, + agent: AcpRegistryAgent, +) { + const commandPath = normalizeArchiveCommandPath(path, getBinaryTarget(agent)?.cmd ?? agent.id); + return path.join(config.stateDir, ACP_BINARY_INSTALLS_DIR, agent.id, agent.version, commandPath); +} + +function expandUserPath(path: Path.Path, inputPath: string): string { + const home = resolveHomeDirectory(); + if (!home) return inputPath; + if (inputPath === "~") return home; + if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) { + return path.join(home, inputPath.slice(2)); + } + return inputPath; +} + +function normalizeAcpBinaryInstallPath(path: Path.Path, inputPath: string): string { + const expanded = expandUserPath(path, inputPath.trim()); + return path.isAbsolute(expanded) ? expanded : path.resolve(expanded); +} + +function displayPath(path: Path.Path, inputPath: string): string { + const home = resolveHomeDirectory(); + return home && (inputPath === home || inputPath.startsWith(`${home}${path.sep}`)) + ? `~${inputPath.slice(home.length)}` + : inputPath; +} + +function isPathInside(path: Path.Path, parent: string, child: string): boolean { + const relativePath = path.relative(parent, child); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +function toBinaryInstallPreview( + path: Path.Path, + config: { readonly stateDir: string }, + agent: AcpRegistryAgent, + target: BinaryDistributionTarget, +) { + const defaultInstallPath = resolveAcpBinaryInstallPath(path, config, agent); + return { + archiveUrl: target.archive, + defaultInstallPath: displayPath(path, defaultInstallPath), + platformKey: getAcpBinaryPlatformKey(), + command: target.cmd, + }; +} + +function resolveAcpBinaryManifestPath( + path: Path.Path, + config: { readonly stateDir: string }, + agent: AcpRegistryAgent, +) { + return path.join( + path.dirname(resolveAcpBinaryInstallPath(path, config, agent)), + ACP_BINARY_MANIFEST_FILE, + ); +} + +const readAcpBinaryManifest = (config: { readonly stateDir: string }, agent: AcpRegistryAgent) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const manifestPath = resolveAcpBinaryManifestPath(path, config, agent); + const raw = yield* fs.readFileString(manifestPath).pipe(Effect.option); + if (raw._tag === "None") return null; + const json = yield* Effect.try({ + try: () => JSON.parse(raw.value) as unknown, + catch: () => null, + }); + if (json === null) return null; + const parsed = yield* Schema.decodeUnknownEffect(AcpBinaryInstallManifest)(json).pipe( + Effect.option, + ); + if (parsed._tag === "None") return null; + const manifest = parsed.value; + if ( + manifest.agentId === agent.id && + manifest.version === agent.version && + manifest.platformKey === getAcpBinaryPlatformKey() && + (yield* fs.exists(manifest.command)) + ) { + return manifest; + } + return null; + }); + +const downloadFile = (url: string, destination: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const bytes = yield* Effect.tryPromise({ + try: async () => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Download failed with status ${response.status}`); + } + return new Uint8Array(await response.arrayBuffer()); + }, + catch: (cause) => + new AcpRegistryBinaryInstallError({ + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + yield* fs.writeFile(destination, bytes); + }); + +const runArchiveCommand = (command: string, args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(ChildProcess.make(command, [...args])); + const [stdout, stderr, exitCode] = yield* Effect.all( + [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode], + { concurrency: "unbounded" }, + ); + if (Number(exitCode) !== 0) { + return yield* Effect.fail( + new Error( + `Archive command '${command}' failed with exit code ${String(exitCode)}: ${ + stderr || stdout + }`, + ), + ); + } + }).pipe(Effect.scoped); + +const extractArchive = (archivePath: string, destinationDir: string) => { + if (archivePath.endsWith(".tar.gz") || archivePath.endsWith(".tgz")) { + return runArchiveCommand("tar", ["-xzf", archivePath, "-C", destinationDir]); + } + if (archivePath.endsWith(".zip")) { + if (process.platform === "win32") { + return runArchiveCommand("powershell.exe", [ + "-NoProfile", + "-Command", + "Expand-Archive", + "-LiteralPath", + archivePath, + "-DestinationPath", + destinationDir, + "-Force", + ]); + } + return runArchiveCommand("unzip", ["-q", archivePath, "-d", destinationDir]); + } + return Effect.fail(new Error("Unsupported binary archive format.")); +}; + +function toInstallError(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +const installAcpBinaryAgent = (input: { + readonly config: { readonly stateDir: string }; + readonly agent: AcpRegistryAgent; + readonly installPath?: string | undefined; +}) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = getBinaryTarget(input.agent); + if (!target) { + return yield* Effect.fail( + new Error(`No binary is available for ${getAcpBinaryPlatformKey()}.`), + ); + } + const installPath = normalizeAcpBinaryInstallPath( + path, + input.installPath?.trim() || resolveAcpBinaryInstallPath(path, input.config, input.agent), + ); + const commandPath = normalizeArchiveCommandPath(path, target.cmd); + const installRoot = resolveInstallRootFromBinaryPath(path, installPath, commandPath); + if (installRoot === path.dirname(installRoot)) { + return yield* Effect.fail( + new Error(`Binary path is too shallow for registry command '${target.cmd}'.`), + ); + } + const manifestPath = resolveAcpBinaryManifestPath(path, input.config, input.agent); + const tempDir = yield* fs.makeTempDirectory({ prefix: "t3-acp-agent-" }); + yield* Effect.addFinalizer(() => + fs.remove(tempDir, { recursive: true, force: true }).pipe(Effect.ignore), + ); + + const archivePath = path.join( + tempDir, + path.basename(new URL(target.archive).pathname) || "agent.archive", + ); + const extractDir = path.join(tempDir, "extract"); + yield* fs.makeDirectory(extractDir, { recursive: true }); + yield* downloadFile(target.archive, archivePath); + yield* extractArchive(archivePath, extractDir); + + const extractedCommand = path.resolve(extractDir, commandPath); + if (!isPathInside(path, extractDir, extractedCommand)) { + return yield* Effect.fail(new Error("Registry binary command resolves outside the archive.")); + } + if (!(yield* fs.exists(extractedCommand))) { + return yield* Effect.fail( + new Error(`Installed archive did not contain expected command '${target.cmd}'.`), + ); + } + yield* fs.makeDirectory(installRoot, { recursive: true }); + yield* fs.copy(extractDir, installRoot, { overwrite: true }); + if (!(yield* fs.exists(installPath))) { + yield* fs.makeDirectory(path.dirname(installPath), { recursive: true }); + yield* fs.copyFile(extractedCommand, installPath); + } + if (process.platform !== "win32") { + yield* fs.chmod(installPath, 0o755); + } + const manifest: AcpBinaryInstallManifest = { + layoutVersion: 2, + agentId: input.agent.id, + version: input.agent.version, + platformKey: getAcpBinaryPlatformKey(), + command: installPath, + archiveUrl: target.archive, + }; + yield* fs.makeDirectory(path.dirname(manifestPath), { recursive: true }); + yield* fs.writeFileString(manifestPath, JSON.stringify(manifest, null, 2)); + return { ...target, command: installPath }; + }).pipe(Effect.scoped); + +export const toAcpLaunchSpec = (agent: AcpRegistryAgent) => + Effect.gen(function* () { + const config = yield* ServerConfig; + const path = yield* Path.Path; + if (agent.distribution.npx) { + return { + supported: true as const, + distributionType: "npx" as const, + launch: { + command: "npx", + args: ["-y", agent.distribution.npx.package, ...(agent.distribution.npx.args ?? [])], + env: agent.distribution.npx.env ?? {}, + }, + }; + } + if (agent.distribution.uvx) { + return { + supported: true as const, + distributionType: "uvx" as const, + launch: { + command: "uvx", + args: [agent.distribution.uvx.package, ...(agent.distribution.uvx.args ?? [])], + env: agent.distribution.uvx.env ?? {}, + }, + }; + } + const manifest = yield* readAcpBinaryManifest(config, agent); + const target = getBinaryTarget(agent); + if (manifest) { + return { + supported: true as const, + distributionType: "binary" as const, + launch: { + command: manifest.command, + args: [], + env: {}, + }, + ...(target ? { binaryInstall: toBinaryInstallPreview(path, config, agent, target) } : {}), + }; + } + return { + supported: false as const, + distributionType: "binaryUnsupported" as const, + launch: null, + ...(target ? { binaryInstall: toBinaryInstallPreview(path, config, agent, target) } : {}), + }; + }); + +export const listAcpRegistryAgents = (registry: { + readonly version: string; + readonly agents: ReadonlyArray; +}) => + Effect.gen(function* () { + const agents = yield* Effect.forEach(registry.agents, (agent) => + toAcpLaunchSpec(agent).pipe( + Effect.map((resolved) => ({ + agent, + supported: resolved.supported, + distributionType: resolved.distributionType, + launch: resolved.launch, + ...("binaryInstall" in resolved && resolved.binaryInstall + ? { binaryInstall: resolved.binaryInstall } + : {}), + })), + ), + ); + return { + registryVersion: registry.version, + agents: agents.toSorted((left, right) => left.agent.name.localeCompare(right.agent.name)), + } satisfies AcpRegistryListResult; + }); + +export const installAcpRegistryBinaryAgent = (input: { + readonly registry: { + readonly agents: ReadonlyArray; + }; + readonly agentId: string; + readonly installPath?: string | undefined; +}) => + Effect.gen(function* () { + const config = yield* ServerConfig; + const path = yield* Path.Path; + const agent = input.registry.agents.find((entry) => entry.id === input.agentId); + if (!agent) { + return { + ok: false, + error: `No ACP registry agent found for '${input.agentId}'.`, + } satisfies AcpRegistryInstallBinaryResult; + } + const installed = yield* installAcpBinaryAgent({ + config, + agent, + installPath: input.installPath, + }); + return { + ok: true, + agent: { + agent, + supported: true, + distributionType: "binary", + binaryInstall: toBinaryInstallPreview(path, config, agent, installed), + launch: { + command: installed.command, + args: [], + env: {}, + }, + }, + } satisfies AcpRegistryInstallBinaryResult; + }).pipe( + Effect.catch((cause: unknown) => + Effect.succeed({ + ok: false, + error: toInstallError(cause), + } satisfies AcpRegistryInstallBinaryResult), + ), + ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9669c40edd..a69665418e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,28 +1,7 @@ -import { execFile } from "node:child_process"; -import { createWriteStream } from "node:fs"; -import { - access, - chmod, - copyFile, - cp, - mkdir, - mkdtemp, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { arch, homedir, platform, tmpdir } from "node:os"; -import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; -import { pipeline } from "node:stream/promises"; -import { promisify } from "node:util"; - import { Cause, Duration, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; import { type AuthAccessStreamEvent, AcpRegistryIndex, - type AcpRegistryAgent, - type AcpRegistryInstallBinaryResult, - type AcpRegistryListResult, AuthSessionId, CommandId, EventId, @@ -97,8 +76,10 @@ import { type SessionCredentialChange, } from "./auth/Services/SessionCredentialService.ts"; import { respondToAuthError } from "./auth/http.ts"; - -const execFileAsync = promisify(execFile); +import { + installAcpRegistryBinaryAgent, + listAcpRegistryAgents, +} from "./provider/acp/AcpRegistryBinaryInstaller.ts"; function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, @@ -123,313 +104,6 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< } const PROVIDER_STATUS_DEBOUNCE_MS = 200; -const ACP_BINARY_INSTALLS_DIR = "acp_agents"; -const ACP_BINARY_MANIFEST_FILE = "install.json"; - -type BinaryDistributionTarget = { - readonly archive: string; - readonly cmd: string; -}; - -type AcpBinaryInstallManifest = { - readonly layoutVersion: 2; - readonly agentId: string; - readonly version: string; - readonly platformKey: string; - readonly command: string; - readonly archiveUrl: string; -}; - -function getAcpBinaryPlatformKey(): string { - const os = platform(); - const cpu = arch(); - const osKey = - os === "darwin" ? "darwin" : os === "win32" ? "windows" : os === "linux" ? "linux" : os; - const archKey = cpu === "arm64" ? "aarch64" : cpu === "x64" ? "x86_64" : cpu; - return `${osKey}-${archKey}`; -} - -function getBinaryTarget(agent: AcpRegistryAgent): BinaryDistributionTarget | null { - const binary = agent.distribution.binary; - if (!binary || typeof binary !== "object" || globalThis.Array.isArray(binary)) return null; - const target = (binary as Record)[getAcpBinaryPlatformKey()]; - if (!target || typeof target !== "object" || globalThis.Array.isArray(target)) return null; - const record = target as Record; - if (typeof record.archive !== "string" || typeof record.cmd !== "string") return null; - return { archive: record.archive, cmd: record.cmd }; -} - -async function fileExists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - -function normalizeArchiveCommandPath(command: string): string { - const trimmed = command.trim(); - if (!trimmed) { - throw new Error("Registry binary command must not be empty."); - } - if (isAbsolute(trimmed)) { - throw new Error("Registry binary command must be a relative archive path."); - } - const withoutLeadingDot = trimmed.replace(/^(?:\.[/\\])+/u, ""); - const parts = withoutLeadingDot - .split(/[/\\]+/u) - .filter((part) => part.length > 0 && part !== "."); - if (parts.length === 0 || parts.some((part) => part === "..")) { - throw new Error("Registry binary command resolves outside the archive."); - } - return join(...parts); -} - -function resolveInstallRootFromBinaryPath(binaryPath: string, commandRelativePath: string): string { - const depth = commandRelativePath.split(/[/\\]+/u).filter((part) => part.length > 0).length; - let installRoot = binaryPath; - for (let index = 0; index < depth; index += 1) { - installRoot = dirname(installRoot); - } - return installRoot; -} - -function resolveAcpBinaryInstallPath( - config: { readonly stateDir: string }, - agent: AcpRegistryAgent, -) { - const commandPath = normalizeArchiveCommandPath(getBinaryTarget(agent)?.cmd ?? agent.id); - return join(config.stateDir, ACP_BINARY_INSTALLS_DIR, agent.id, agent.version, commandPath); -} - -function expandUserPath(path: string): string { - if (path === "~") return homedir(); - if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2)); - return path; -} - -function normalizeAcpBinaryInstallPath(path: string): string { - const expanded = expandUserPath(path.trim()); - return isAbsolute(expanded) ? expanded : resolve(expanded); -} - -function displayPath(path: string): string { - const home = homedir(); - return path === home || path.startsWith(`${home}/`) || path.startsWith(`${home}\\`) - ? `~${path.slice(home.length)}` - : path; -} - -function isPathInside(parent: string, child: string): boolean { - const relativePath = relative(parent, child); - return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); -} - -function toBinaryInstallPreview( - config: { readonly stateDir: string }, - agent: AcpRegistryAgent, - target: BinaryDistributionTarget, -) { - const defaultInstallPath = resolveAcpBinaryInstallPath(config, agent); - return { - archiveUrl: target.archive, - defaultInstallPath: displayPath(defaultInstallPath), - platformKey: getAcpBinaryPlatformKey(), - command: target.cmd, - }; -} - -function resolveAcpBinaryManifestPath( - config: { readonly stateDir: string }, - agent: AcpRegistryAgent, -) { - return join(dirname(resolveAcpBinaryInstallPath(config, agent)), ACP_BINARY_MANIFEST_FILE); -} - -async function readAcpBinaryManifest( - config: { readonly stateDir: string }, - agent: AcpRegistryAgent, -): Promise { - const manifestPath = resolveAcpBinaryManifestPath(config, agent); - try { - const raw = await readFile(manifestPath, "utf8"); - const parsed = JSON.parse(raw) as Partial; - if ( - parsed.layoutVersion === 2 && - parsed.agentId === agent.id && - parsed.version === agent.version && - parsed.platformKey === getAcpBinaryPlatformKey() && - typeof parsed.command === "string" && - (await fileExists(parsed.command)) - ) { - return parsed as AcpBinaryInstallManifest; - } - return null; - } catch { - return null; - } -} - -async function downloadFile(url: string, destination: string): Promise { - const response = await fetch(url); - if (!response.ok || !response.body) { - throw new Error(`Download failed with status ${response.status}`); - } - await pipeline(response.body, createWriteStream(destination)); -} - -async function extractArchive(archivePath: string, destinationDir: string): Promise { - if (archivePath.endsWith(".tar.gz") || archivePath.endsWith(".tgz")) { - await execFileAsync("tar", ["-xzf", archivePath, "-C", destinationDir]); - return; - } - if (archivePath.endsWith(".zip")) { - if (platform() === "win32") { - await execFileAsync("powershell.exe", [ - "-NoProfile", - "-Command", - "Expand-Archive", - "-LiteralPath", - archivePath, - "-DestinationPath", - destinationDir, - "-Force", - ]); - return; - } - await execFileAsync("unzip", ["-q", archivePath, "-d", destinationDir]); - return; - } - throw new Error("Unsupported binary archive format."); -} - -async function installAcpBinaryAgent(input: { - readonly config: { readonly stateDir: string }; - readonly agent: AcpRegistryAgent; - readonly installPath?: string | undefined; -}): Promise { - const target = getBinaryTarget(input.agent); - if (!target) { - throw new Error(`No binary is available for ${getAcpBinaryPlatformKey()}.`); - } - const installPath = normalizeAcpBinaryInstallPath( - input.installPath?.trim() || resolveAcpBinaryInstallPath(input.config, input.agent), - ); - const commandPath = normalizeArchiveCommandPath(target.cmd); - const installRoot = resolveInstallRootFromBinaryPath(installPath, commandPath); - if (installRoot === dirname(installRoot)) { - throw new Error(`Binary path is too shallow for registry command '${target.cmd}'.`); - } - const manifestPath = resolveAcpBinaryManifestPath(input.config, input.agent); - const tempDir = await mkdtemp(join(tmpdir(), "t3-acp-agent-")); - try { - const archivePath = join( - tempDir, - basename(new URL(target.archive).pathname) || "agent.archive", - ); - const extractDir = join(tempDir, "extract"); - await mkdir(extractDir, { recursive: true }); - await downloadFile(target.archive, archivePath); - await extractArchive(archivePath, extractDir); - - const extractedCommand = resolve(extractDir, commandPath); - if (!isPathInside(extractDir, extractedCommand)) { - throw new Error(`Registry binary command resolves outside the archive.`); - } - if (!(await fileExists(extractedCommand))) { - throw new Error(`Installed archive did not contain expected command '${target.cmd}'.`); - } - await mkdir(installRoot, { recursive: true }); - await cp(extractDir, installRoot, { - recursive: true, - force: true, - errorOnExist: false, - }); - if (!(await fileExists(installPath))) { - await mkdir(dirname(installPath), { recursive: true }); - await copyFile(extractedCommand, installPath); - } - if (platform() !== "win32") { - await chmod(installPath, 0o755); - } - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - const manifest: AcpBinaryInstallManifest = { - layoutVersion: 2, - agentId: input.agent.id, - version: input.agent.version, - platformKey: getAcpBinaryPlatformKey(), - command: installPath, - archiveUrl: target.archive, - }; - await mkdir(dirname(manifestPath), { recursive: true }); - await writeFile(manifestPath, JSON.stringify(manifest, null, 2)); - return { ...target, command: installPath }; -} - -async function toAcpLaunchSpec( - agent: AcpRegistryAgent, - config: { readonly stateDir: string }, -): Promise<{ - readonly supported: boolean; - readonly distributionType: "npx" | "uvx" | "binary" | "binaryUnsupported"; - readonly launch: { - readonly command: string; - readonly args: readonly string[]; - readonly env: Record; - } | null; - readonly binaryInstall?: { - readonly archiveUrl: string; - readonly defaultInstallPath: string; - readonly platformKey: string; - readonly command: string; - }; -}> { - if (agent.distribution.npx) { - return { - supported: true as const, - distributionType: "npx" as const, - launch: { - command: "npx", - args: ["-y", agent.distribution.npx.package, ...(agent.distribution.npx.args ?? [])], - env: agent.distribution.npx.env ?? {}, - }, - }; - } - if (agent.distribution.uvx) { - return { - supported: true as const, - distributionType: "uvx" as const, - launch: { - command: "uvx", - args: [agent.distribution.uvx.package, ...(agent.distribution.uvx.args ?? [])], - env: agent.distribution.uvx.env ?? {}, - }, - }; - } - const manifest = await readAcpBinaryManifest(config, agent); - const target = getBinaryTarget(agent); - if (manifest) { - return { - supported: true as const, - distributionType: "binary" as const, - launch: { - command: manifest.command, - args: [], - env: {}, - }, - ...(target ? { binaryInstall: toBinaryInstallPreview(config, agent, target) } : {}), - }; - } - return { - supported: false as const, - distributionType: "binaryUnsupported" as const, - launch: null, - ...(target ? { binaryInstall: toBinaryInstallPreview(config, agent, target) } : {}), - }; -} function toAuthAccessStreamEvent( change: BootstrapCredentialChange | SessionCredentialChange, @@ -527,35 +201,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => Effect.flatMap((raw) => Schema.decodeUnknownEffect(AcpRegistryIndex)(raw)), ); - const listAcpRegistry = Effect.all({ - registry: loadAcpRegistryIndex, - config: Effect.succeed(config), - }).pipe( - Effect.flatMap(({ registry, config }) => - Effect.tryPromise({ - try: async (): Promise => ({ - registryVersion: registry.version, - agents: ( - await Promise.all( - registry.agents.map(async (agent) => { - const resolved = await toAcpLaunchSpec(agent, config); - return { - agent, - supported: resolved.supported, - distributionType: resolved.distributionType, - launch: resolved.launch, - ...(resolved.binaryInstall ? { binaryInstall: resolved.binaryInstall } : {}), - }; - }), - ) - ).toSorted((left, right) => left.agent.name.localeCompare(right.agent.name)), + const listAcpRegistry = loadAcpRegistryIndex.pipe( + Effect.flatMap((registry) => listAcpRegistryAgents(registry)), + Effect.mapError( + (cause) => + new AcpRegistryClientError({ + detail: cause instanceof Error ? cause.message : String(cause), + cause, }), - catch: (cause) => - new AcpRegistryClientError({ - detail: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }), ), ); @@ -563,51 +216,19 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => readonly agentId: string; readonly installPath?: string | undefined; }) => - Effect.all({ - registry: loadAcpRegistryIndex, - config: Effect.succeed(config), - }).pipe( - Effect.flatMap(({ registry, config }) => - Effect.tryPromise({ - try: async (): Promise => { - const agent = registry.agents.find((entry) => entry.id === input.agentId); - if (!agent) { - return { - ok: false, - error: `No ACP registry agent found for '${input.agentId}'.`, - }; - } - const installed = await installAcpBinaryAgent({ - config, - agent, - installPath: input.installPath, - }); - return { - ok: true, - agent: { - agent, - supported: true, - distributionType: "binary", - binaryInstall: toBinaryInstallPreview(config, agent, installed), - launch: { - command: installed.command, - args: [], - env: {}, - }, - }, - }; - }, - catch: (cause): AcpRegistryInstallBinaryResult => ({ - ok: false, - error: cause instanceof Error ? cause.message : String(cause), - }), + loadAcpRegistryIndex.pipe( + Effect.flatMap((registry) => + installAcpRegistryBinaryAgent({ + registry, + agentId: input.agentId, + installPath: input.installPath, }), ), Effect.catch((cause: unknown) => Effect.succeed({ ok: false, error: cause instanceof Error ? cause.message : String(cause), - } satisfies AcpRegistryInstallBinaryResult), + }), ), ); From 0beb4f5e1505ec67afe46e2389beddafcb98fd0d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 17:53:16 -0700 Subject: [PATCH 5/6] Refactor ACP binary path helpers into Effect - Convert ACP registry binary path helpers to Effect-based access - Reuse shared path resolution for install previews and installs - Keep archive command validation and manifest lookup behavior intact --- .../acp/AcpRegistryBinaryInstaller.ts | 197 ++++++++++-------- 1 file changed, 106 insertions(+), 91 deletions(-) diff --git a/apps/server/src/provider/acp/AcpRegistryBinaryInstaller.ts b/apps/server/src/provider/acp/AcpRegistryBinaryInstaller.ts index 9d9e2e28f0..6b0981630d 100644 --- a/apps/server/src/provider/acp/AcpRegistryBinaryInstaller.ts +++ b/apps/server/src/provider/acp/AcpRegistryBinaryInstaller.ts @@ -57,104 +57,121 @@ function resolveHomeDirectory(): string { return process.env.HOME ?? process.env.USERPROFILE ?? ""; } -function normalizeArchiveCommandPath(path: Path.Path, command: string): string { - const trimmed = command.trim(); - if (!trimmed) { - throw new Error("Registry binary command must not be empty."); - } - if (path.isAbsolute(trimmed)) { - throw new Error("Registry binary command must be a relative archive path."); - } - const withoutLeadingDot = trimmed.replace(/^(?:\.[/\\])+/u, ""); - const parts = withoutLeadingDot - .split(/[/\\]+/u) - .filter((part) => part.length > 0 && part !== "."); - if (parts.length === 0 || parts.some((part) => part === "..")) { - throw new Error("Registry binary command resolves outside the archive."); - } - return path.join(...parts); -} +const normalizeArchiveCommandPath = (command: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const trimmed = command.trim(); + if (!trimmed) { + return yield* Effect.fail(new Error("Registry binary command must not be empty.")); + } + if (path.isAbsolute(trimmed)) { + return yield* Effect.fail( + new Error("Registry binary command must be a relative archive path."), + ); + } + const withoutLeadingDot = trimmed.replace(/^(?:\.[/\\])+/u, ""); + const parts = withoutLeadingDot + .split(/[/\\]+/u) + .filter((part) => part.length > 0 && part !== "."); + if (parts.length === 0 || parts.some((part) => part === "..")) { + return yield* Effect.fail(new Error("Registry binary command resolves outside the archive.")); + } + return path.join(...parts); + }); -function resolveInstallRootFromBinaryPath( - path: Path.Path, - binaryPath: string, - commandRelativePath: string, -): string { - const depth = commandRelativePath.split(/[/\\]+/u).filter((part) => part.length > 0).length; - let installRoot = binaryPath; - for (let index = 0; index < depth; index += 1) { - installRoot = path.dirname(installRoot); - } - return installRoot; -} +const resolveInstallRootFromBinaryPath = (binaryPath: string, commandRelativePath: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const depth = commandRelativePath.split(/[/\\]+/u).filter((part) => part.length > 0).length; + let installRoot = binaryPath; + for (let index = 0; index < depth; index += 1) { + installRoot = path.dirname(installRoot); + } + return installRoot; + }); -function resolveAcpBinaryInstallPath( - path: Path.Path, +const resolveAcpBinaryInstallPath = ( config: { readonly stateDir: string }, agent: AcpRegistryAgent, -) { - const commandPath = normalizeArchiveCommandPath(path, getBinaryTarget(agent)?.cmd ?? agent.id); - return path.join(config.stateDir, ACP_BINARY_INSTALLS_DIR, agent.id, agent.version, commandPath); -} +) => + Effect.gen(function* () { + const path = yield* Path.Path; + const commandPath = yield* normalizeArchiveCommandPath(getBinaryTarget(agent)?.cmd ?? agent.id); + return path.join( + config.stateDir, + ACP_BINARY_INSTALLS_DIR, + agent.id, + agent.version, + commandPath, + ); + }); -function expandUserPath(path: Path.Path, inputPath: string): string { - const home = resolveHomeDirectory(); - if (!home) return inputPath; - if (inputPath === "~") return home; - if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) { - return path.join(home, inputPath.slice(2)); - } - return inputPath; -} +const expandUserPath = (inputPath: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = resolveHomeDirectory(); + if (!home) return inputPath; + if (inputPath === "~") return home; + if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) { + return path.join(home, inputPath.slice(2)); + } + return inputPath; + }); -function normalizeAcpBinaryInstallPath(path: Path.Path, inputPath: string): string { - const expanded = expandUserPath(path, inputPath.trim()); - return path.isAbsolute(expanded) ? expanded : path.resolve(expanded); -} +const normalizeAcpBinaryInstallPath = (inputPath: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const expanded = yield* expandUserPath(inputPath.trim()); + return path.isAbsolute(expanded) ? expanded : path.resolve(expanded); + }); -function displayPath(path: Path.Path, inputPath: string): string { - const home = resolveHomeDirectory(); - return home && (inputPath === home || inputPath.startsWith(`${home}${path.sep}`)) - ? `~${inputPath.slice(home.length)}` - : inputPath; -} +const displayPath = (inputPath: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = resolveHomeDirectory(); + return home && (inputPath === home || inputPath.startsWith(`${home}${path.sep}`)) + ? `~${inputPath.slice(home.length)}` + : inputPath; + }); -function isPathInside(path: Path.Path, parent: string, child: string): boolean { - const relativePath = path.relative(parent, child); - return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); -} +const isPathInside = (parent: string, child: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const relativePath = path.relative(parent, child); + return ( + relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) + ); + }); -function toBinaryInstallPreview( - path: Path.Path, +const toBinaryInstallPreview = ( config: { readonly stateDir: string }, agent: AcpRegistryAgent, target: BinaryDistributionTarget, -) { - const defaultInstallPath = resolveAcpBinaryInstallPath(path, config, agent); - return { - archiveUrl: target.archive, - defaultInstallPath: displayPath(path, defaultInstallPath), - platformKey: getAcpBinaryPlatformKey(), - command: target.cmd, - }; -} +) => + Effect.gen(function* () { + const defaultInstallPath = yield* resolveAcpBinaryInstallPath(config, agent); + return { + archiveUrl: target.archive, + defaultInstallPath: yield* displayPath(defaultInstallPath), + platformKey: getAcpBinaryPlatformKey(), + command: target.cmd, + }; + }); -function resolveAcpBinaryManifestPath( - path: Path.Path, +const resolveAcpBinaryManifestPath = ( config: { readonly stateDir: string }, agent: AcpRegistryAgent, -) { - return path.join( - path.dirname(resolveAcpBinaryInstallPath(path, config, agent)), - ACP_BINARY_MANIFEST_FILE, - ); -} +) => + Effect.gen(function* () { + const path = yield* Path.Path; + const installPath = yield* resolveAcpBinaryInstallPath(config, agent); + return path.join(path.dirname(installPath), ACP_BINARY_MANIFEST_FILE); + }); const readAcpBinaryManifest = (config: { readonly stateDir: string }, agent: AcpRegistryAgent) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const manifestPath = resolveAcpBinaryManifestPath(path, config, agent); + const manifestPath = yield* resolveAcpBinaryManifestPath(config, agent); const raw = yield* fs.readFileString(manifestPath).pipe(Effect.option); if (raw._tag === "None") return null; const json = yield* Effect.try({ @@ -257,18 +274,18 @@ const installAcpBinaryAgent = (input: { new Error(`No binary is available for ${getAcpBinaryPlatformKey()}.`), ); } - const installPath = normalizeAcpBinaryInstallPath( - path, - input.installPath?.trim() || resolveAcpBinaryInstallPath(path, input.config, input.agent), + const defaultInstallPath = yield* resolveAcpBinaryInstallPath(input.config, input.agent); + const installPath = yield* normalizeAcpBinaryInstallPath( + input.installPath?.trim() || defaultInstallPath, ); - const commandPath = normalizeArchiveCommandPath(path, target.cmd); - const installRoot = resolveInstallRootFromBinaryPath(path, installPath, commandPath); + const commandPath = yield* normalizeArchiveCommandPath(target.cmd); + const installRoot = yield* resolveInstallRootFromBinaryPath(installPath, commandPath); if (installRoot === path.dirname(installRoot)) { return yield* Effect.fail( new Error(`Binary path is too shallow for registry command '${target.cmd}'.`), ); } - const manifestPath = resolveAcpBinaryManifestPath(path, input.config, input.agent); + const manifestPath = yield* resolveAcpBinaryManifestPath(input.config, input.agent); const tempDir = yield* fs.makeTempDirectory({ prefix: "t3-acp-agent-" }); yield* Effect.addFinalizer(() => fs.remove(tempDir, { recursive: true, force: true }).pipe(Effect.ignore), @@ -284,7 +301,7 @@ const installAcpBinaryAgent = (input: { yield* extractArchive(archivePath, extractDir); const extractedCommand = path.resolve(extractDir, commandPath); - if (!isPathInside(path, extractDir, extractedCommand)) { + if (!(yield* isPathInside(extractDir, extractedCommand))) { return yield* Effect.fail(new Error("Registry binary command resolves outside the archive.")); } if (!(yield* fs.exists(extractedCommand))) { @@ -317,7 +334,6 @@ const installAcpBinaryAgent = (input: { export const toAcpLaunchSpec = (agent: AcpRegistryAgent) => Effect.gen(function* () { const config = yield* ServerConfig; - const path = yield* Path.Path; if (agent.distribution.npx) { return { supported: true as const, @@ -351,14 +367,14 @@ export const toAcpLaunchSpec = (agent: AcpRegistryAgent) => args: [], env: {}, }, - ...(target ? { binaryInstall: toBinaryInstallPreview(path, config, agent, target) } : {}), + ...(target ? { binaryInstall: yield* toBinaryInstallPreview(config, agent, target) } : {}), }; } return { supported: false as const, distributionType: "binaryUnsupported" as const, launch: null, - ...(target ? { binaryInstall: toBinaryInstallPreview(path, config, agent, target) } : {}), + ...(target ? { binaryInstall: yield* toBinaryInstallPreview(config, agent, target) } : {}), }; }); @@ -395,7 +411,6 @@ export const installAcpRegistryBinaryAgent = (input: { }) => Effect.gen(function* () { const config = yield* ServerConfig; - const path = yield* Path.Path; const agent = input.registry.agents.find((entry) => entry.id === input.agentId); if (!agent) { return { @@ -414,7 +429,7 @@ export const installAcpRegistryBinaryAgent = (input: { agent, supported: true, distributionType: "binary", - binaryInstall: toBinaryInstallPreview(path, config, agent, installed), + binaryInstall: yield* toBinaryInstallPreview(config, agent, installed), launch: { command: installed.command, args: [], From 9b74682d59052fe660128312c5e4999024b2b245 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 18:07:04 -0700 Subject: [PATCH 6/6] Rank ACP registry agent search results - Normalize search tokens and score field matches - Sort dialog results by best query match instead of substring order --- .../settings/AddProviderInstanceDialog.tsx | 145 +++++++++++------- 1 file changed, 92 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 7e18eecd78..1b5223bde1 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -124,6 +124,71 @@ function validateInstanceId(id: string, existing: ReadonlySet): string | return null; } +function acpRegistryAgentSearchFields(entry: ResolvedRegistryAcpAgent): string[] { + return [ + entry.agent.name, + entry.agent.id, + entry.agent.description, + entry.agent.repository, + entry.agent.website, + entry.distributionType, + entry.launch?.command, + ...(entry.launch?.args ?? []), + ] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .map((value) => normalizeSearchQuery(value)); +} + +function acpRegistryAgentSearchText(entry: ResolvedRegistryAcpAgent): string { + return acpRegistryAgentSearchFields(entry).join(" "); +} + +function scoreAcpRegistryAgentSearchToken( + field: string, + token: string, + fieldBase: number, +): number | null { + return scoreQueryMatch({ + value: field, + query: token, + exactBase: fieldBase, + prefixBase: fieldBase + 2, + boundaryBase: fieldBase + 4, + includesBase: fieldBase + 6, + ...(token.length >= 3 ? { fuzzyBase: fieldBase + 100 } : {}), + }); +} + +function scoreAcpRegistryAgentSearch( + entry: ResolvedRegistryAcpAgent, + query: string, +): number | null { + const tokens = normalizeSearchQuery(query) + .split(/\s+/u) + .filter((token) => token.length > 0); + + if (tokens.length === 0) { + return 0; + } + + const fields = acpRegistryAgentSearchFields(entry); + const combinedField = acpRegistryAgentSearchText(entry); + let score = 0; + + for (const token of tokens) { + const tokenScores = [...fields, combinedField] + .map((field, index) => scoreAcpRegistryAgentSearchToken(field, token, index * 10)) + .filter((fieldScore): fieldScore is number => fieldScore !== null); + + if (tokenScores.length === 0) { + return null; + } + + score += Math.min(...tokenScores); + } + + return score; +} function envRecordToVariables( env: Record | null | undefined, ): ReadonlyArray { @@ -161,49 +226,6 @@ function acpDistributionLabel(entry: ResolvedRegistryAcpAgent): string { } } -function acpRegistryAgentSearchFields(entry: ResolvedRegistryAcpAgent): readonly string[] { - return [ - entry.agent.name, - entry.agent.id, - entry.agent.description, - entry.agent.repository, - entry.agent.website, - entry.distributionType, - entry.launch?.command, - ...(entry.launch?.args ?? []), - ] - .filter((value): value is string => typeof value === "string" && value.length > 0) - .map((value) => normalizeSearchQuery(value)); -} - -function scoreAcpRegistryAgentSearch(entry: ResolvedRegistryAcpAgent, query: string): number | null { - const tokens = normalizeSearchQuery(query) - .split(/\s+/u) - .filter((token) => token.length > 0); - if (tokens.length === 0) return 0; - - const fields = acpRegistryAgentSearchFields(entry); - let score = 0; - for (const token of tokens) { - const tokenScores = fields - .map((field, index) => - scoreQueryMatch({ - value: field, - query: token, - exactBase: index * 10, - prefixBase: index * 10 + 2, - boundaryBase: index * 10 + 4, - includesBase: index * 10 + 6, - ...(token.length >= 3 ? { fuzzyBase: index * 10 + 100 } : {}), - }), - ) - .filter((fieldScore): fieldScore is number => fieldScore !== null); - if (tokenScores.length === 0) return null; - score += Math.min(...tokenScores); - } - return score; -} - type AcpRegistryState = | { readonly status: "idle" | "loading"; readonly agents: readonly ResolvedRegistryAcpAgent[] } | { @@ -327,14 +349,29 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns ] as const; const filteredAcpRegistryAgents = useMemo(() => { const query = acpRegistrySearch.trim(); - if (!query) return acpRegistryState.agents; - return acpRegistryState.agents - .map((entry) => ({ entry, score: scoreAcpRegistryAgentSearch(entry, query) })) - .filter((result): result is { entry: ResolvedRegistryAcpAgent; score: number } => { - return result.score !== null; + const agents = acpRegistryState.agents; + if (!query) return agents; + return agents + .map((entry) => ({ + entry, + score: scoreAcpRegistryAgentSearch(entry, query), + tieBreaker: acpRegistryAgentSearchText(entry), + })) + .filter( + ( + rankedAgent, + ): rankedAgent is { + entry: ResolvedRegistryAcpAgent; + score: number; + tieBreaker: string; + } => rankedAgent.score !== null, + ) + .toSorted((left, right) => { + const scoreDelta = left.score - right.score; + if (scoreDelta !== 0) return scoreDelta; + return left.tieBreaker.localeCompare(right.tieBreaker); }) - .toSorted((left, right) => left.score - right.score || left.entry.agent.name.localeCompare(right.entry.agent.name)) - .map((result) => result.entry); + .map((rankedAgent) => rankedAgent.entry); }, [acpRegistrySearch, acpRegistryState.agents]); const loadAcpRegistry = useCallback(async () => { @@ -384,7 +421,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns setConfigByDriver((existing) => ({ ...existing, [ACP_REGISTRY_DRIVER_KIND]: { - ...(existing[ACP_REGISTRY_DRIVER_KIND] ?? {}), + ...existing[ACP_REGISTRY_DRIVER_KIND], command: launch.command, args: launch.args, env: launch.env, @@ -502,7 +539,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns setHasAttemptedSubmit(true); if (instanceIdError !== null) return; - const config: Record = { ...(configByDriver[driver] ?? {}) }; + const config: Record = { ...configByDriver[driver] }; if (driver === ACP_REGISTRY_DRIVER_KIND && selectedAcpRegistryAgent) { config.registryAgentId = selectedAcpRegistryAgent.agent.id; config.importedVersion = selectedAcpRegistryAgent.agent.version; @@ -1080,7 +1117,9 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns ) : null} - }> + } + > Cancel