diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts new file mode 100644 index 0000000000..a7996095a7 --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -0,0 +1,172 @@ +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 { makeGenericAcpAdapter } from "../Layers/GenericAcpAdapter.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* makeGenericAcpAdapter( + { + enabled: effectiveConfig.enabled, + command: effectiveConfig.command || "acp", + args: effectiveConfig.args, + }, + { + provider: DRIVER_KIND, + instanceId, + environment: processEnv, + readyReason: "ACP session ready", + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }, + ); + + 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/GenericAcpAdapter.ts b/apps/server/src/provider/Layers/GenericAcpAdapter.ts new file mode 100644 index 0000000000..cc9a7c0edb --- /dev/null +++ b/apps/server/src/provider/Layers/GenericAcpAdapter.ts @@ -0,0 +1,745 @@ +import * as nodePath from "node:path"; + +import { + ApprovalRequestId, + type ProviderApprovalDecision, + type ProviderDriverKind, + 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 { 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; + +export interface GenericAcpAdapterSettings { + readonly enabled: boolean; + readonly command: string; + readonly args: ReadonlyArray; +} + +export interface GenericAcpAdapterOptions { + readonly provider: ProviderDriverKind; + 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 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, + get streamEvents() { + return Stream.fromPubSub(runtimeEventPubSub); + }, + } satisfies ProviderAdapterShape; + }); +} 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/AcpRegistryBinaryInstaller.ts b/apps/server/src/provider/acp/AcpRegistryBinaryInstaller.ts new file mode 100644 index 0000000000..6b0981630d --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryBinaryInstaller.ts @@ -0,0 +1,447 @@ +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 ?? ""; +} + +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); + }); + +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; + }); + +const resolveAcpBinaryInstallPath = ( + config: { readonly stateDir: string }, + agent: AcpRegistryAgent, +) => + 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, + ); + }); + +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; + }); + +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); + }); + +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; + }); + +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)) + ); + }); + +const toBinaryInstallPreview = ( + config: { readonly stateDir: string }, + agent: AcpRegistryAgent, + target: BinaryDistributionTarget, +) => + Effect.gen(function* () { + const defaultInstallPath = yield* resolveAcpBinaryInstallPath(config, agent); + return { + archiveUrl: target.archive, + defaultInstallPath: yield* displayPath(defaultInstallPath), + platformKey: getAcpBinaryPlatformKey(), + command: target.cmd, + }; + }); + +const resolveAcpBinaryManifestPath = ( + config: { readonly stateDir: string }, + agent: AcpRegistryAgent, +) => + 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 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({ + 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 defaultInstallPath = yield* resolveAcpBinaryInstallPath(input.config, input.agent); + const installPath = yield* normalizeAcpBinaryInstallPath( + input.installPath?.trim() || defaultInstallPath, + ); + 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 = 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), + ); + + 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 (!(yield* isPathInside(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; + 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: yield* toBinaryInstallPreview(config, agent, target) } : {}), + }; + } + return { + supported: false as const, + distributionType: "binaryUnsupported" as const, + launch: null, + ...(target ? { binaryInstall: yield* toBinaryInstallPreview(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 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: yield* toBinaryInstallPreview(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/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b4cf665608..034319a56f 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,22 +365,24 @@ 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: | 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/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)); + }); 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 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 = loadAcpRegistryIndex.pipe( + Effect.flatMap((registry) => listAcpRegistryAgents(registry)), + Effect.mapError( + (cause) => + new AcpRegistryClientError({ + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + ); + + const installAcpRegistryBinary = (input: { + readonly agentId: string; + readonly installPath?: string | undefined; + }) => + 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), + }), + ), + ); + const appendSetupScriptActivity = (input: { readonly threadId: ThreadId; readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; @@ -807,6 +864,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..1b5223bde1 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,121 @@ 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 { + 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"; + } +} + +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 +251,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 +316,86 @@ 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(); + 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); + }) + .map((rankedAgent) => rankedAgent.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 +413,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 +590,548 @@ 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