From 8c96e1e26e0055471365e3cf83b842708d19a819 Mon Sep 17 00:00:00 2001 From: Livio Gamassia Date: Sun, 17 May 2026 10:25:30 +0200 Subject: [PATCH] feat: add ACP registry providers --- AGENTS.md | 10 + .../acpRegistry/AcpRegistryService.test.ts | 14 + .../src/acpRegistry/AcpRegistryService.ts | 489 +++++++ .../src/acpRegistry/installManifest.test.ts | 128 ++ .../server/src/acpRegistry/installManifest.ts | 155 +++ apps/server/src/acpRegistry/installer.test.ts | 151 +++ apps/server/src/acpRegistry/installer.ts | 448 ++++++ apps/server/src/acpRegistry/platform.ts | 21 + apps/server/src/config.ts | 4 + .../Drivers/AcpRegistryDriver.test.ts | 101 ++ .../src/provider/Drivers/AcpRegistryDriver.ts | 303 +++++ .../Layers/AcpRegistryAdapterLayer.ts | 700 ++++++++++ .../ProviderInstanceRegistryHydration.ts | 1 + .../acpRegistryAdapter/eventForwarding.ts | 83 ++ .../acpRegistryAdapter/fileHandlers.test.ts | 21 + .../Layers/acpRegistryAdapter/fileHandlers.ts | 71 + .../Layers/acpRegistryAdapter/helpers.ts | 28 + .../acpRegistryAdapter/permissionHandlers.ts | 69 + .../Layers/acpRegistryAdapter/types.ts | 47 + .../provider/Services/AcpRegistryAdapter.ts | 4 + .../provider/acp/AcpAdapterSupport.test.ts | 33 +- .../src/provider/acp/AcpAdapterSupport.ts | 20 + apps/server/src/provider/acp/AcpConnection.ts | 427 ++++++ .../src/provider/acp/AcpMultiSession.ts | 357 +++++ .../src/provider/acp/AcpSessionRuntime.ts | 41 +- .../src/provider/acp/configOptionModels.ts | 99 ++ apps/server/src/provider/builtInDrivers.ts | 15 +- apps/server/src/server.test.ts | 15 + apps/server/src/serverSettings.test.ts | 2 + .../AcpRegistryTextGeneration.ts | 22 + apps/server/src/ws.ts | 27 + apps/web/public/acp-icons/agoragentic-acp.svg | 5 + apps/web/public/acp-icons/amp-acp.svg | 6 + apps/web/public/acp-icons/auggie.svg | 4 + apps/web/public/acp-icons/autohand.svg | 26 + apps/web/public/acp-icons/claude-acp.svg | 3 + apps/web/public/acp-icons/cline.svg | 6 + apps/web/public/acp-icons/codebuddy-code.svg | 5 + apps/web/public/acp-icons/codex-acp.svg | 3 + apps/web/public/acp-icons/cortex-code.svg | 3 + apps/web/public/acp-icons/corust-agent.svg | 10 + apps/web/public/acp-icons/crow-cli.svg | 9 + apps/web/public/acp-icons/cursor.svg | 3 + apps/web/public/acp-icons/deepagents.svg | 6 + apps/web/public/acp-icons/dimcode.svg | 3 + apps/web/public/acp-icons/dirac.svg | 6 + apps/web/public/acp-icons/factory-droid.svg | 1 + apps/web/public/acp-icons/fast-agent.svg | 293 ++++ apps/web/public/acp-icons/gemini.svg | 3 + .../public/acp-icons/github-copilot-cli.svg | 5 + apps/web/public/acp-icons/glm-acp-agent.svg | 1 + apps/web/public/acp-icons/goose.svg | 3 + apps/web/public/acp-icons/junie.svg | 5 + apps/web/public/acp-icons/kilo.svg | 3 + apps/web/public/acp-icons/kimi.svg | 3 + apps/web/public/acp-icons/minion-code.svg | 19 + apps/web/public/acp-icons/mistral-vibe.svg | 12 + apps/web/public/acp-icons/nova.svg | 10 + apps/web/public/acp-icons/opencode.svg | 3 + apps/web/public/acp-icons/pi-acp.svg | 4 + apps/web/public/acp-icons/poolside.svg | 3 + apps/web/public/acp-icons/qoder.svg | 3 + apps/web/public/acp-icons/qwen-code.svg | 1 + apps/web/public/acp-icons/sigit.svg | 98 ++ apps/web/public/acp-icons/stakpak.svg | 3 + apps/web/public/acp-icons/vtcode.svg | 4 + apps/web/src/components/AcpRegistryIcon.tsx | 65 + apps/web/src/components/chat/ChatComposer.tsx | 26 +- ...ntent.tsx => InlineModelPickerContent.tsx} | 321 ++--- ...rSidebar.tsx => InlineProviderTileRow.tsx} | 187 ++- .../components/chat/ProviderInstanceIcon.tsx | 9 +- .../chat/ProviderModelPicker.browser.tsx | 60 +- .../components/chat/ProviderModelPicker.tsx | 189 +-- .../components/chat/ProviderStatusBanner.tsx | 21 +- .../settings/AddOrInstallProviderPanel.tsx | 721 ++++++++++ .../settings/AddProviderInstanceDialog.tsx | 491 ------- .../settings/ProviderInstanceCard.tsx | 83 +- .../components/settings/SettingsPanels.tsx | 81 +- apps/web/src/localApi.ts | 16 + apps/web/src/modelSelection.test.ts | 2 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/settings.acp-registry.tsx | 10 + apps/web/src/rpc/wsRpcClient.ts | 15 + bun.lock | 1 + docs/providers/acp-registry.md | 120 ++ package.json | 3 +- packages/contracts/src/acpRegistry.ts | 147 ++ packages/contracts/src/index.ts | 2 + packages/contracts/src/ipc.ts | 10 + packages/contracts/src/providerInstance.ts | 1 + .../src/registry/icons/agoragentic-acp.svg | 5 + .../contracts/src/registry/icons/amp-acp.svg | 6 + .../contracts/src/registry/icons/auggie.svg | 4 + .../contracts/src/registry/icons/autohand.svg | 26 + .../src/registry/icons/claude-acp.svg | 3 + .../contracts/src/registry/icons/cline.svg | 6 + .../src/registry/icons/codebuddy-code.svg | 5 + .../src/registry/icons/codex-acp.svg | 3 + .../src/registry/icons/cortex-code.svg | 3 + .../src/registry/icons/corust-agent.svg | 10 + .../contracts/src/registry/icons/crow-cli.svg | 9 + .../contracts/src/registry/icons/cursor.svg | 3 + .../src/registry/icons/deepagents.svg | 6 + .../contracts/src/registry/icons/dimcode.svg | 3 + .../contracts/src/registry/icons/dirac.svg | 6 + .../src/registry/icons/factory-droid.svg | 1 + .../src/registry/icons/fast-agent.svg | 293 ++++ .../contracts/src/registry/icons/gemini.svg | 3 + .../src/registry/icons/github-copilot-cli.svg | 5 + .../src/registry/icons/glm-acp-agent.svg | 1 + .../contracts/src/registry/icons/goose.svg | 3 + .../contracts/src/registry/icons/junie.svg | 5 + .../contracts/src/registry/icons/kilo.svg | 3 + .../contracts/src/registry/icons/kimi.svg | 3 + .../src/registry/icons/minion-code.svg | 19 + .../src/registry/icons/mistral-vibe.svg | 12 + .../contracts/src/registry/icons/nova.svg | 10 + .../contracts/src/registry/icons/opencode.svg | 3 + .../contracts/src/registry/icons/pi-acp.svg | 4 + .../contracts/src/registry/icons/poolside.svg | 3 + .../contracts/src/registry/icons/qoder.svg | 3 + .../src/registry/icons/qwen-code.svg | 1 + .../contracts/src/registry/icons/sigit.svg | 98 ++ .../contracts/src/registry/icons/stakpak.svg | 3 + .../contracts/src/registry/icons/vtcode.svg | 4 + packages/contracts/src/registry/index.ts | 17 + packages/contracts/src/registry/registry.json | 1203 +++++++++++++++++ packages/contracts/src/rpc.ts | 39 + packages/contracts/src/server.ts | 7 + packages/contracts/src/settings.ts | 43 + packages/effect-acp/src/client.ts | 8 +- packages/shared/src/serverSettings.test.ts | 34 + packages/shared/src/serverSettings.ts | 6 + scripts/sync-acp-registry.ts | 150 ++ 134 files changed, 8188 insertions(+), 977 deletions(-) create mode 100644 apps/server/src/acpRegistry/AcpRegistryService.test.ts create mode 100644 apps/server/src/acpRegistry/AcpRegistryService.ts create mode 100644 apps/server/src/acpRegistry/installManifest.test.ts create mode 100644 apps/server/src/acpRegistry/installManifest.ts create mode 100644 apps/server/src/acpRegistry/installer.test.ts create mode 100644 apps/server/src/acpRegistry/installer.ts create mode 100644 apps/server/src/acpRegistry/platform.ts create mode 100644 apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts create mode 100644 apps/server/src/provider/Drivers/AcpRegistryDriver.ts create mode 100644 apps/server/src/provider/Layers/AcpRegistryAdapterLayer.ts create mode 100644 apps/server/src/provider/Layers/acpRegistryAdapter/eventForwarding.ts create mode 100644 apps/server/src/provider/Layers/acpRegistryAdapter/fileHandlers.test.ts create mode 100644 apps/server/src/provider/Layers/acpRegistryAdapter/fileHandlers.ts create mode 100644 apps/server/src/provider/Layers/acpRegistryAdapter/helpers.ts create mode 100644 apps/server/src/provider/Layers/acpRegistryAdapter/permissionHandlers.ts create mode 100644 apps/server/src/provider/Layers/acpRegistryAdapter/types.ts create mode 100644 apps/server/src/provider/Services/AcpRegistryAdapter.ts create mode 100644 apps/server/src/provider/acp/AcpConnection.ts create mode 100644 apps/server/src/provider/acp/AcpMultiSession.ts create mode 100644 apps/server/src/provider/acp/configOptionModels.ts create mode 100644 apps/server/src/textGeneration/AcpRegistryTextGeneration.ts create mode 100644 apps/web/public/acp-icons/agoragentic-acp.svg create mode 100644 apps/web/public/acp-icons/amp-acp.svg create mode 100644 apps/web/public/acp-icons/auggie.svg create mode 100644 apps/web/public/acp-icons/autohand.svg create mode 100644 apps/web/public/acp-icons/claude-acp.svg create mode 100644 apps/web/public/acp-icons/cline.svg create mode 100644 apps/web/public/acp-icons/codebuddy-code.svg create mode 100644 apps/web/public/acp-icons/codex-acp.svg create mode 100644 apps/web/public/acp-icons/cortex-code.svg create mode 100644 apps/web/public/acp-icons/corust-agent.svg create mode 100644 apps/web/public/acp-icons/crow-cli.svg create mode 100644 apps/web/public/acp-icons/cursor.svg create mode 100644 apps/web/public/acp-icons/deepagents.svg create mode 100644 apps/web/public/acp-icons/dimcode.svg create mode 100644 apps/web/public/acp-icons/dirac.svg create mode 100644 apps/web/public/acp-icons/factory-droid.svg create mode 100644 apps/web/public/acp-icons/fast-agent.svg create mode 100644 apps/web/public/acp-icons/gemini.svg create mode 100644 apps/web/public/acp-icons/github-copilot-cli.svg create mode 100644 apps/web/public/acp-icons/glm-acp-agent.svg create mode 100644 apps/web/public/acp-icons/goose.svg create mode 100644 apps/web/public/acp-icons/junie.svg create mode 100644 apps/web/public/acp-icons/kilo.svg create mode 100644 apps/web/public/acp-icons/kimi.svg create mode 100644 apps/web/public/acp-icons/minion-code.svg create mode 100644 apps/web/public/acp-icons/mistral-vibe.svg create mode 100644 apps/web/public/acp-icons/nova.svg create mode 100644 apps/web/public/acp-icons/opencode.svg create mode 100644 apps/web/public/acp-icons/pi-acp.svg create mode 100644 apps/web/public/acp-icons/poolside.svg create mode 100644 apps/web/public/acp-icons/qoder.svg create mode 100644 apps/web/public/acp-icons/qwen-code.svg create mode 100644 apps/web/public/acp-icons/sigit.svg create mode 100644 apps/web/public/acp-icons/stakpak.svg create mode 100644 apps/web/public/acp-icons/vtcode.svg create mode 100644 apps/web/src/components/AcpRegistryIcon.tsx rename apps/web/src/components/chat/{ModelPickerContent.tsx => InlineModelPickerContent.tsx} (62%) rename apps/web/src/components/chat/{ModelPickerSidebar.tsx => InlineProviderTileRow.tsx} (52%) create mode 100644 apps/web/src/components/settings/AddOrInstallProviderPanel.tsx delete mode 100644 apps/web/src/components/settings/AddProviderInstanceDialog.tsx create mode 100644 apps/web/src/routes/settings.acp-registry.tsx create mode 100644 docs/providers/acp-registry.md create mode 100644 packages/contracts/src/acpRegistry.ts create mode 100644 packages/contracts/src/registry/icons/agoragentic-acp.svg create mode 100644 packages/contracts/src/registry/icons/amp-acp.svg create mode 100644 packages/contracts/src/registry/icons/auggie.svg create mode 100644 packages/contracts/src/registry/icons/autohand.svg create mode 100644 packages/contracts/src/registry/icons/claude-acp.svg create mode 100644 packages/contracts/src/registry/icons/cline.svg create mode 100644 packages/contracts/src/registry/icons/codebuddy-code.svg create mode 100644 packages/contracts/src/registry/icons/codex-acp.svg create mode 100644 packages/contracts/src/registry/icons/cortex-code.svg create mode 100644 packages/contracts/src/registry/icons/corust-agent.svg create mode 100644 packages/contracts/src/registry/icons/crow-cli.svg create mode 100644 packages/contracts/src/registry/icons/cursor.svg create mode 100644 packages/contracts/src/registry/icons/deepagents.svg create mode 100644 packages/contracts/src/registry/icons/dimcode.svg create mode 100644 packages/contracts/src/registry/icons/dirac.svg create mode 100644 packages/contracts/src/registry/icons/factory-droid.svg create mode 100644 packages/contracts/src/registry/icons/fast-agent.svg create mode 100644 packages/contracts/src/registry/icons/gemini.svg create mode 100644 packages/contracts/src/registry/icons/github-copilot-cli.svg create mode 100644 packages/contracts/src/registry/icons/glm-acp-agent.svg create mode 100644 packages/contracts/src/registry/icons/goose.svg create mode 100644 packages/contracts/src/registry/icons/junie.svg create mode 100644 packages/contracts/src/registry/icons/kilo.svg create mode 100644 packages/contracts/src/registry/icons/kimi.svg create mode 100644 packages/contracts/src/registry/icons/minion-code.svg create mode 100644 packages/contracts/src/registry/icons/mistral-vibe.svg create mode 100644 packages/contracts/src/registry/icons/nova.svg create mode 100644 packages/contracts/src/registry/icons/opencode.svg create mode 100644 packages/contracts/src/registry/icons/pi-acp.svg create mode 100644 packages/contracts/src/registry/icons/poolside.svg create mode 100644 packages/contracts/src/registry/icons/qoder.svg create mode 100644 packages/contracts/src/registry/icons/qwen-code.svg create mode 100644 packages/contracts/src/registry/icons/sigit.svg create mode 100644 packages/contracts/src/registry/icons/stakpak.svg create mode 100644 packages/contracts/src/registry/icons/vtcode.svg create mode 100644 packages/contracts/src/registry/index.ts create mode 100644 packages/contracts/src/registry/registry.json create mode 100644 scripts/sync-acp-registry.ts diff --git a/AGENTS.md b/AGENTS.md index cea5090cce0..8392d3f9321 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,16 @@ Docs: - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server +## ACP Registry + +In addition to the four bespoke providers (Codex, Claude, Cursor, OpenCode), +T3 Code bundles a snapshot of the +[Agent Client Protocol registry](https://agentclientprotocol.com/get-started/registry) +and exposes a provider installer at **Settings → Providers**. See +[docs/providers/acp-registry.md](./docs/providers/acp-registry.md) for the +install pipeline, distribution channels, and how to refresh the bundled +snapshot (`bun run sync:acp-registry`). + ## Reference Repos - Open-source Codex repo: https://github.com/openai/codex diff --git a/apps/server/src/acpRegistry/AcpRegistryService.test.ts b/apps/server/src/acpRegistry/AcpRegistryService.test.ts new file mode 100644 index 00000000000..bcdaf855125 --- /dev/null +++ b/apps/server/src/acpRegistry/AcpRegistryService.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { authProbeTimeoutForDistribution } from "./AcpRegistryService.ts"; + +describe("authProbeTimeoutForDistribution", () => { + it("keeps binary auth probes tight", () => { + expect(authProbeTimeoutForDistribution("binary")).toBe("4 seconds"); + }); + + it("gives package-managed agents more first-start time", () => { + expect(authProbeTimeoutForDistribution("npx")).toBe("25 seconds"); + expect(authProbeTimeoutForDistribution("uvx")).toBe("25 seconds"); + }); +}); diff --git a/apps/server/src/acpRegistry/AcpRegistryService.ts b/apps/server/src/acpRegistry/AcpRegistryService.ts new file mode 100644 index 00000000000..a5a46fa559f --- /dev/null +++ b/apps/server/src/acpRegistry/AcpRegistryService.ts @@ -0,0 +1,489 @@ +import { + ACP_REGISTRY, + acpRegistryDriverKindFor, + acpRegistryEntryById, + AcpRegistryError, + acpRegistryIdFromDriverKind, + type AcpRegistryDistributionKind, + type AcpRegistryEntry, + type AcpRegistryEntryWithStatus, + type AcpRegistryInstallState, + type AcpRegistryInstallStatus, + ProviderDriverKind, + type ProviderInstanceConfig, + type ProviderInstanceConfigMap, + ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import type * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { ServerConfig } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { ProviderSessionRuntimeRepository } from "../persistence/Services/ProviderSessionRuntime.ts"; + +import { + availableChannels, + installAgent, + resolveSpawnTarget, + uninstallAgent, +} from "./installer.ts"; +import { resolveCurrentPlatform } from "./platform.ts"; +import { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts"; +import type { SpawnTarget } from "./installer.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import * as os from "node:os"; +import { + readInstalls as readManifestInstalls, + writeInstalls as writeManifestInstalls, +} from "./installManifest.ts"; + +export interface AcpRegistryServiceShape { + readonly list: () => Effect.Effect< + ReadonlyArray, + AcpRegistryError, + ServerConfig | ServerSettingsService | FileSystem.FileSystem | Path.Path + >; + readonly install: ( + agentId: string, + ) => Effect.Effect< + AcpRegistryInstallState, + AcpRegistryError, + ServerConfig | ServerSettingsService | FileSystem.FileSystem | Path.Path + >; + readonly uninstall: ( + agentId: string, + ) => Effect.Effect< + void, + AcpRegistryError, + ServerConfig | ServerSettingsService | FileSystem.FileSystem | Path.Path + >; + readonly authenticate: ( + instanceId: ProviderInstanceId, + methodId: string, + ) => Effect.Effect< + void, + AcpRegistryError, + ServerConfig | ServerSettingsService | FileSystem.FileSystem | Path.Path + >; +} + +export class AcpRegistryService extends Context.Service< + AcpRegistryService, + AcpRegistryServiceShape +>()("t3/acpRegistry/AcpRegistryService") {} + +export function authProbeTimeoutForDistribution( + distribution: AcpRegistryDistributionKind, +): Duration.Input { + switch (distribution) { + case "binary": + return "4 seconds"; + case "npx": + case "uvx": + return "25 seconds"; + } +} + +export const layer: Layer.Layer< + AcpRegistryService, + never, + | ServerConfig + | ServerSettingsService + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path +> = Layer.effect( + AcpRegistryService, + Effect.gen(function* () { + const config = yield* ServerConfig; + const settingsService = yield* ServerSettingsService; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const sessionRuntimeRepoOpt = yield* Effect.serviceOption(ProviderSessionRuntimeRepository); + const platform = resolveCurrentPlatform(); + const cacheRoot = config.acpRegistryCacheDir; + + const probeAuthMethods = ( + spawnTarget: SpawnTarget, + ): Effect.Effect< + | ReadonlyArray<{ + readonly id: string; + readonly name: string; + readonly description?: string; + }> + | undefined, + never + > => { + const probeCwd = os.tmpdir(); + const runtimeLayer = AcpSessionRuntime.layer({ + spawn: { + command: spawnTarget.command, + args: [...spawnTarget.args], + cwd: probeCwd, + env: { ...spawnTarget.env }, + }, + cwd: probeCwd, + clientInfo: { name: "t3-code", version: "0.0.0" }, + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))); + + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + const timeout = authProbeTimeoutForDistribution(spawnTarget.distribution); + const startExit = yield* Effect.exit(runtime.start().pipe(Effect.timeout(timeout))); + const methods: ReadonlyArray = Exit.isSuccess(startExit) + ? (startExit.value.initializeResult.authMethods ?? []) + : yield* runtime.getAuthMethods; + if (methods.length === 0) return undefined; + return methods.map((method: EffectAcpSchema.AuthMethod) => { + if (method.description) { + return { + id: method.id, + name: method.name, + description: method.description, + }; + } + return { + id: method.id, + name: method.name, + }; + }); + }).pipe( + Effect.provide(runtimeLayer), + Effect.scoped, + Effect.catchCause(() => Effect.sync(() => undefined)), + ); + }; + + const wrapSettingsError = (detail: string) => (cause: unknown) => + new AcpRegistryError({ detail, cause }); + + const readSettings = settingsService.getSettings.pipe( + Effect.mapError(wrapSettingsError("Failed to read server settings")), + ); + + const readInstalls = readManifestInstalls.pipe( + Effect.mapError( + (cause) => new AcpRegistryError({ detail: "Failed to read install manifest", cause }), + ), + ); + + const writeInstalls = (next: Readonly>) => + writeManifestInstalls(next).pipe( + Effect.mapError( + (cause) => new AcpRegistryError({ detail: "Failed to write install manifest", cause }), + ), + ); + + const writeInstances = (nextInstances: ProviderInstanceConfigMap) => + settingsService + .updateSettings({ providerInstances: nextInstances }) + .pipe( + Effect.asVoid, + Effect.mapError(wrapSettingsError("Failed to persist server settings")), + ); + + const requireEntry = (agentId: string): Effect.Effect => { + const entry = acpRegistryEntryById(agentId); + return entry + ? Effect.succeed(entry) + : Effect.fail( + new AcpRegistryError({ + agentId, + detail: "Unknown ACP registry agent.", + }), + ); + }; + + yield* Effect.gen(function* () { + const settings = yield* readSettings; + const installs = yield* readInstalls; + const installedAgentIds = Object.keys(installs); + if (installedAgentIds.length === 0) return; + const existingInstances = settings.providerInstances ?? {}; + const existingDrivers = new Set( + Object.values(existingInstances).map((instance) => instance.driver), + ); + const existingIds = new Set(Object.keys(existingInstances)); + let nextInstances: Record | undefined; + let changed = false; + for (const agentId of installedAgentIds) { + const entry = acpRegistryEntryById(agentId); + if (!entry) continue; + const driverKind = ProviderDriverKind.make(acpRegistryDriverKindFor(entry.id)); + if (existingDrivers.has(driverKind)) continue; + const instanceId = pickFreeAutoInstanceId(driverKind, existingIds); + nextInstances ??= { ...existingInstances }; + nextInstances[instanceId] = { + driver: driverKind, + displayName: entry.name, + enabled: true, + }; + existingDrivers.add(driverKind); + existingIds.add(instanceId); + changed = true; + } + if (!changed) return; + yield* writeInstances(nextInstances as ProviderInstanceConfigMap); + }).pipe( + Effect.catch((error: AcpRegistryError) => + Effect.logWarning("acp.registry backfill skipped", { + detail: error.detail, + }), + ), + ); + + const isAcpRegistryError = Schema.is(AcpRegistryError); + const toAcpRegistryError = + (agentId: string, fallback: string) => + (cause: unknown): AcpRegistryError => { + if (isAcpRegistryError(cause)) return cause; + return new AcpRegistryError({ + agentId, + detail: cause instanceof Error ? cause.message : fallback, + cause, + }); + }; + + return { + list: () => + Effect.gen(function* () { + const installs = yield* readInstalls; + return ACP_REGISTRY.map((entry) => + buildEntryStatus(entry, installs[entry.id], availableChannels(entry, platform)), + ); + }), + + install: (agentId) => + Effect.gen(function* () { + const entry = yield* requireEntry(agentId); + const result = yield* Effect.tryPromise({ + try: () => installAgent(entry, { cacheRoot }), + catch: toAcpRegistryError(agentId, "Install failed"), + }); + + const spawnTargetForProbe = resolveSpawnTarget(entry, result.state); + const authMethods = spawnTargetForProbe + ? yield* probeAuthMethods(spawnTargetForProbe) + : undefined; + + const installs = yield* readInstalls; + const nextInstalls = { + ...installs, + [agentId]: { + ...result.state, + ...(authMethods ? { authMethods } : {}), + }, + }; + yield* writeInstalls(nextInstalls); + + const settings = yield* readSettings; + const driverKind = ProviderDriverKind.make(acpRegistryDriverKindFor(entry.id)); + const existingInstances = settings.providerInstances ?? {}; + const hasInstance = Object.values(existingInstances).some( + (instance) => instance.driver === driverKind, + ); + const nextInstances = hasInstance + ? existingInstances + : ({ + ...existingInstances, + [pickFreeAutoInstanceId(driverKind, new Set(Object.keys(existingInstances)))]: { + driver: driverKind, + displayName: entry.name, + enabled: true, + } satisfies ProviderInstanceConfig, + } satisfies ProviderInstanceConfigMap); + + yield* writeInstances(nextInstances); + return result.state; + }), + + uninstall: (agentId) => + Effect.gen(function* () { + const entry = yield* requireEntry(agentId); + const settings = yield* readSettings; + const providerInstances = settings.providerInstances ?? {}; + const driverKind = ProviderDriverKind.make(acpRegistryDriverKindFor(agentId)); + const instancesForAgent = Object.entries(providerInstances).filter( + ([, instance]) => instance.driver === driverKind, + ); + + if (instancesForAgent.length > 0 && Option.isSome(sessionRuntimeRepoOpt)) { + const sessionRuntimeRepo = sessionRuntimeRepoOpt.value; + const allSessions = yield* sessionRuntimeRepo + .list() + .pipe(Effect.mapError(wrapSettingsError("Failed to list provider sessions"))); + const activeInstanceIds = new Set(instancesForAgent.map(([instanceId]) => instanceId)); + const activeSessions = allSessions.filter( + (session) => + session.providerInstanceId != null && + activeInstanceIds.has(session.providerInstanceId), + ); + if (activeSessions.length > 0) { + return yield* new AcpRegistryError({ + agentId, + detail: `Cannot uninstall ${entry.name}: ${activeSessions.length} active session(s) using this provider. Close them first.`, + }); + } + } + if (instancesForAgent.length > 0) { + const nextInstances = { ...providerInstances } as Record< + string, + ProviderInstanceConfig + >; + for (const [instanceId] of instancesForAgent) { + delete nextInstances[instanceId]; + } + yield* settingsService + .updateSettings({ + providerInstances: nextInstances as ProviderInstanceConfigMap, + }) + .pipe( + Effect.asVoid, + Effect.mapError(wrapSettingsError("Failed to cascade-delete provider instances")), + ); + } + + yield* Effect.tryPromise({ + try: () => uninstallAgent(entry, cacheRoot), + catch: toAcpRegistryError(agentId, "Uninstall failed"), + }); + + const installs = yield* readInstalls; + if (!(agentId in installs)) return; + const { [agentId]: _removed, ...rest } = installs; + yield* writeInstalls(rest); + }), + + authenticate: (instanceId, methodId) => + Effect.gen(function* () { + const settings = yield* readSettings; + const instance = settings.providerInstances?.[instanceId]; + if (!instance) { + return yield* new AcpRegistryError({ + agentId: instanceId, + detail: `Provider instance ${instanceId} not found`, + }); + } + const agentId = acpRegistryIdFromDriverKind(instance.driver); + if (!agentId) { + return yield* new AcpRegistryError({ + detail: `Instance ${instanceId} is not an ACP registry provider (driver=${instance.driver})`, + }); + } + const entry = yield* requireEntry(agentId); + const installs = yield* readInstalls; + const installState = installs[agentId]; + if (!installState) { + return yield* new AcpRegistryError({ + agentId, + detail: `${entry.name} is not installed`, + }); + } + const spawnTarget = resolveSpawnTarget(entry, installState); + if (!spawnTarget) { + return yield* new AcpRegistryError({ + agentId, + detail: `${entry.name} install state is missing a spawn target`, + }); + } + + const probeCwd = os.tmpdir(); + const env = mergeProviderInstanceEnvironment(instance.environment, spawnTarget.env); + const runtimeLayer = AcpSessionRuntime.layer({ + spawn: { + command: spawnTarget.command, + args: [...spawnTarget.args], + cwd: probeCwd, + env, + }, + cwd: probeCwd, + clientInfo: { name: "t3-code", version: "0.0.0" }, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + authMethodId: methodId, + }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))); + + yield* Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + }).pipe( + Effect.provide(runtimeLayer), + Effect.scoped, + Effect.mapError( + (cause) => + new AcpRegistryError({ + agentId, + detail: `Authentication failed: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + cause, + }), + ), + ); + + const now = yield* Effect.map(DateTime.now, DateTime.formatIso); + yield* settingsService + .updateSettings({ + providerInstances: { + ...settings.providerInstances, + [instanceId]: { + ...instance, + authenticatedAt: now, + }, + }, + }) + .pipe( + Effect.asVoid, + Effect.mapError(wrapSettingsError("Failed to update provider instance")), + ); + }), + } satisfies AcpRegistryServiceShape; + }), +); + +function buildEntryStatus( + entry: AcpRegistryEntry, + installed: AcpRegistryInstallState | undefined, + channels: ReadonlyArray[number]>, +): AcpRegistryEntryWithStatus { + return { + entry, + availableChannels: channels, + status: rollupStatus(entry, installed, channels), + ...(installed ? { installed } : {}), + }; +} + +function rollupStatus( + entry: AcpRegistryEntry, + installed: AcpRegistryInstallState | undefined, + channels: ReadonlyArray[number]>, +): AcpRegistryInstallStatus { + if (channels.length === 0) return "unsupported"; + if (!installed) return "not_installed"; + return installed.version === entry.version ? "installed" : "update_available"; +} + +function pickFreeAutoInstanceId( + driverKind: ProviderDriverKind, + existing: ReadonlySet, +): ProviderInstanceId { + const base = String(driverKind); + if (!existing.has(base)) return ProviderInstanceId.make(base); + for (let n = 2; n < 1000; n += 1) { + const candidate = `${base}-${n}`; + if (!existing.has(candidate)) return ProviderInstanceId.make(candidate); + } + return ProviderInstanceId.make(`${base}-${crypto.randomUUID()}`); +} diff --git a/apps/server/src/acpRegistry/installManifest.test.ts b/apps/server/src/acpRegistry/installManifest.test.ts new file mode 100644 index 00000000000..e9a34ab9e3e --- /dev/null +++ b/apps/server/src/acpRegistry/installManifest.test.ts @@ -0,0 +1,128 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import type { AcpRegistryInstallState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import { ServerConfig } from "../config.ts"; +import { ServerSettingsLive, ServerSettingsService } from "../serverSettings.ts"; + +import { + getInstallState, + InstallManifestError, + readInstalls, + setInstallState, + writeInstalls, +} from "./installManifest.ts"; + +const npxInstall = { + version: "1.0.0", + installedAt: "2026-05-17T00:00:00.000Z", + distribution: "npx", +} satisfies AcpRegistryInstallState; + +const binaryInstall = { + version: "2.0.0", + installedAt: "2026-05-17T01:00:00.000Z", + distribution: "binary", + binaryPath: "/tmp/acp-agent", + authMethods: [{ id: "oauth", name: "OAuth", description: "OAuth login" }], +} satisfies AcpRegistryInstallState; + +const makeLayer = () => + ServerSettingsLive.pipe( + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-install-manifest-test-", + }), + ), + ), + ); + +const manifestPath = Effect.gen(function* () { + const config = yield* ServerConfig; + return `${config.acpRegistryCacheDir}/installs.json`; +}); + +it.layer(NodeServices.layer)("installManifest", (it) => { + it.effect("returns an empty manifest when no manifest or legacy settings exist", () => + Effect.gen(function* () { + assert.deepEqual(yield* readInstalls, {}); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("migrates legacy settings installs into the manifest on first read", () => + Effect.gen(function* () { + const settings = yield* ServerSettingsService; + const fs = yield* FileSystem.FileSystem; + yield* settings.updateSettings({ + acpRegistryInstalls: { + "legacy-agent": npxInstall, + }, + }); + + assert.deepEqual(yield* readInstalls, { + "legacy-agent": npxInstall, + }); + assert.isTrue(yield* fs.exists(yield* manifestPath)); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("prefers the manifest over legacy settings when both exist", () => + Effect.gen(function* () { + const settings = yield* ServerSettingsService; + yield* writeInstalls({ + "manifest-agent": binaryInstall, + }); + yield* settings.updateSettings({ + acpRegistryInstalls: { + "settings-agent": npxInstall, + }, + }); + + assert.deepEqual(yield* readInstalls, { + "manifest-agent": binaryInstall, + }); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("writes install updates to the manifest without mutating settings", () => + Effect.gen(function* () { + const settings = yield* ServerSettingsService; + yield* writeInstalls({ + "manifest-agent": npxInstall, + }); + + assert.deepEqual((yield* settings.getSettings).acpRegistryInstalls, {}); + assert.deepEqual(yield* getInstallState("manifest-agent"), npxInstall); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("removes a single install from the manifest", () => + Effect.gen(function* () { + yield* setInstallState("agent-a", npxInstall); + yield* setInstallState("agent-b", binaryInstall); + yield* setInstallState("agent-a", null); + + assert.deepEqual(yield* readInstalls, { + "agent-b": binaryInstall, + }); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("fails clearly when the manifest is corrupt", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + yield* manifestPath, + `{"broken-agent":{"version":"","installedAt":"2026-05-17T00:00:00.000Z","distribution":"npx"}}`, + ); + + const error = yield* Effect.flip(readInstalls); + assert.instanceOf(error, InstallManifestError); + assert.equal(error.detail, "Invalid install manifest"); + }).pipe(Effect.provide(makeLayer())), + ); +}); diff --git a/apps/server/src/acpRegistry/installManifest.ts b/apps/server/src/acpRegistry/installManifest.ts new file mode 100644 index 00000000000..2688a7f9866 --- /dev/null +++ b/apps/server/src/acpRegistry/installManifest.ts @@ -0,0 +1,155 @@ +import { + AcpRegistryInstallState, + type AcpRegistryInstallState as AcpRegistryInstallStateType, +} from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import { ServerConfig } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { writeFileStringAtomically } from "../atomicWrite.ts"; + +const MANIFEST_FILENAME = "installs.json"; + +export const InstallManifestSchema = Schema.Record(Schema.String, AcpRegistryInstallState); +const InstallManifestJsonSchema = Schema.fromJsonString(InstallManifestSchema); +const decodeInstallManifestJson = Schema.decodeUnknownEffect(InstallManifestJsonSchema); +const encodeInstallManifestJson = Schema.encodeEffect(InstallManifestJsonSchema); + +export type InstallManifest = Readonly>; + +export class InstallManifestError extends Data.TaggedError("InstallManifestError")<{ + readonly detail: string; + readonly cause?: unknown; +}> {} + +const getManifestPath: Effect.Effect = Effect.gen(function* () { + const config = yield* ServerConfig; + return `${config.acpRegistryCacheDir}/${MANIFEST_FILENAME}`; +}); + +const readManifestFile: Effect.Effect< + InstallManifest | null, + InstallManifestError | PlatformError.PlatformError, + ServerConfig | FileSystem.FileSystem +> = Effect.gen(function* () { + const manifestPath = yield* getManifestPath; + const fs = yield* FileSystem.FileSystem; + + const exists = yield* fs.exists(manifestPath); + if (!exists) { + return null; + } + + const content = yield* fs + .readFileString(manifestPath) + .pipe( + Effect.mapError( + (cause) => new InstallManifestError({ detail: "Failed to read install manifest", cause }), + ), + ); + + return yield* decodeInstallManifestJson(content).pipe( + Effect.mapError( + (cause) => + new InstallManifestError({ + detail: "Invalid install manifest", + cause, + }), + ), + ); +}); + +export const readInstalls: Effect.Effect< + InstallManifest, + InstallManifestError | PlatformError.PlatformError, + ServerConfig | ServerSettingsService | FileSystem.FileSystem | Path.Path +> = Effect.gen(function* () { + const manifest = yield* readManifestFile; + + if (manifest !== null) { + return manifest; + } + + const settingsService = yield* ServerSettingsService; + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + (cause) => + new InstallManifestError({ detail: "Failed to read settings for migration", cause }), + ), + ); + + const settingsInstalls = settings.acpRegistryInstalls; + if (!settingsInstalls || Object.keys(settingsInstalls).length === 0) { + return {}; + } + + const migrationExit = yield* Effect.exit(writeInstalls(settingsInstalls)); + if (migrationExit._tag === "Failure") { + const error = migrationExit.cause; + const detail = error instanceof InstallManifestError ? error.detail : "Unknown migration error"; + yield* Effect.logWarning(`Failed to migrate installs to manifest: ${detail}`); + return settingsInstalls; + } + + return settingsInstalls; +}); + +export const writeInstalls: ( + installs: InstallManifest, +) => Effect.Effect = ( + installs, +) => + Effect.gen(function* () { + const manifestPath = yield* getManifestPath; + + const content = yield* encodeInstallManifestJson(installs).pipe( + Effect.mapError( + (cause) => + new InstallManifestError({ + detail: "Failed to encode install manifest", + cause, + }), + ), + ); + + yield* writeFileStringAtomically({ + filePath: manifestPath, + contents: content, + }).pipe( + Effect.mapError( + (cause) => new InstallManifestError({ detail: "Failed to write install manifest", cause }), + ), + ); + }); + +export const getInstallState: ( + agentId: string, +) => Effect.Effect< + AcpRegistryInstallStateType | undefined, + InstallManifestError | PlatformError.PlatformError, + ServerConfig | ServerSettingsService | FileSystem.FileSystem | Path.Path +> = (agentId) => Effect.map(readInstalls, (installs) => installs[agentId]); + +export const setInstallState: ( + agentId: string, + state: AcpRegistryInstallStateType | null, +) => Effect.Effect< + void, + InstallManifestError | PlatformError.PlatformError, + ServerConfig | ServerSettingsService | FileSystem.FileSystem | Path.Path +> = (agentId, state) => + Effect.gen(function* () { + const installs = yield* readInstalls; + + if (state === null) { + const { [agentId]: _, ...rest } = installs; + yield* writeInstalls(rest); + } else { + yield* writeInstalls({ ...installs, [agentId]: state }); + } + }); diff --git a/apps/server/src/acpRegistry/installer.test.ts b/apps/server/src/acpRegistry/installer.test.ts new file mode 100644 index 00000000000..fbd191a904b --- /dev/null +++ b/apps/server/src/acpRegistry/installer.test.ts @@ -0,0 +1,151 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalDate:off +import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; + +import type { AcpRegistryEntry } from "@t3tools/contracts"; + +import { + availableChannels, + installAgent, + resolveSpawnTarget, + uninstallAgent, +} from "./installer.ts"; + +function rawEntry(sha256: string, cmd = "./bin/test-agent"): AcpRegistryEntry { + return { + id: "test-agent", + name: "Test Agent", + version: "1.0.0", + description: "Test ACP agent", + distribution: { + binary: { + "linux-x86_64": { + archive: "https://example.test/agent", + sha256, + cmd, + }, + }, + }, + }; +} + +function fetchBytes(bytes: Uint8Array): typeof fetch { + return ((_input: Parameters[0], _init?: Parameters[1]) => + Promise.resolve( + new Response(bytes, { + status: 200, + }), + )) as unknown as typeof fetch; +} + +const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); + +describe("ACP registry installer", () => { + it("downloads raw binaries into path-joined cache locations and verifies sha256", async () => { + const cacheRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3-acp-install-")); + const bytes = new TextEncoder().encode("#!/bin/sh\necho ok\n"); + const entry = rawEntry(sha256(bytes)); + + const result = await installAgent(entry, { + cacheRoot, + platform: "linux-x86_64", + fetchImpl: fetchBytes(bytes), + }); + + const binaryPath = path.join(cacheRoot, entry.id, entry.version, "bin", "test-agent"); + expect(result.state.binaryPath).toBe(binaryPath); + await expect(fs.readFile(binaryPath, "utf8")).resolves.toBe("#!/bin/sh\necho ok\n"); + expect(resolveSpawnTarget(entry, result.state)?.command).toBe(binaryPath); + + await uninstallAgent(entry, cacheRoot); + await expect(fs.stat(path.join(cacheRoot, entry.id))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("rejects downloaded binaries when the manifest sha256 does not match", async () => { + const cacheRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3-acp-install-")); + const bytes = new TextEncoder().encode("not what the manifest promised"); + const entry = rawEntry("0".repeat(64)); + + await expect( + installAgent(entry, { + cacheRoot, + platform: "linux-x86_64", + fetchImpl: fetchBytes(bytes), + }), + ).rejects.toThrow("Checksum mismatch"); + }); + + it("keeps raw downloads when cmd resolves to the temporary archive path", async () => { + const cacheRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3-acp-install-")); + const bytes = new TextEncoder().encode("raw executable"); + const entry = rawEntry(sha256(bytes), "./agent.bin"); + + const result = await installAgent(entry, { + cacheRoot, + platform: "linux-x86_64", + fetchImpl: fetchBytes(bytes), + }); + + const binaryPath = path.join(cacheRoot, entry.id, entry.version, "agent.bin"); + expect(result.state.binaryPath).toBe(binaryPath); + await expect(fs.readFile(binaryPath, "utf8")).resolves.toBe("raw executable"); + }); + + it("rejects binary command paths outside the install root", async () => { + const cacheRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3-acp-install-")); + const bytes = new TextEncoder().encode("raw executable"); + + await expect( + installAgent(rawEntry(sha256(bytes), "../agent"), { + cacheRoot, + platform: "linux-x86_64", + fetchImpl: fetchBytes(bytes), + }), + ).rejects.toThrow("escapes the install root"); + + await expect( + installAgent(rawEntry(sha256(bytes), path.join(cacheRoot, "agent")), { + cacheRoot, + platform: "linux-x86_64", + fetchImpl: fetchBytes(bytes), + }), + ).rejects.toThrow("must be relative"); + }); + + it("advertises binary installs even without manifest checksums (Zed parity)", () => { + const entry = rawEntry(""); + const target = entry.distribution.binary?.["linux-x86_64"]; + if (target) { + delete (target as { sha256?: string }).sha256; + } + + expect(availableChannels(entry, "linux-x86_64")).toEqual(["binary"]); + }); + + it("installs unchecked binaries without sha256 verification", async () => { + const bytes = new Uint8Array([1, 2, 3, 4]); + const entry = rawEntry(""); + const target = entry.distribution.binary?.["linux-x86_64"]; + if (target) { + delete (target as { sha256?: string }).sha256; + } + const cacheRoot = path.join(os.tmpdir(), `acp-installer-test-${Date.now()}`); + + const result = await installAgent(entry, { + cacheRoot, + platform: "linux-x86_64", + fetchImpl: fetchBytes(bytes), + }); + + expect(result.state.distribution).toBe("binary"); + expect(result.state.binaryPath).toBeDefined(); + + await fs.rm(cacheRoot, { recursive: true, force: true }); + }); +}); diff --git a/apps/server/src/acpRegistry/installer.ts b/apps/server/src/acpRegistry/installer.ts new file mode 100644 index 00000000000..6e0f51f3dcd --- /dev/null +++ b/apps/server/src/acpRegistry/installer.ts @@ -0,0 +1,448 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalDate:off +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import * as fsPromises from "node:fs/promises"; +import * as path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +import { + AcpRegistryError, + type AcpRegistryBinaryPlatform, + type AcpRegistryBinaryTarget, + type AcpRegistryDistributionKind, + type AcpRegistryEntry, + type AcpRegistryInstallState, + type AcpRegistryPackageDistribution, +} from "@t3tools/contracts"; + +import { resolveCurrentPlatform } from "./platform.ts"; + +type FetchLike = (...args: Parameters) => Promise; + +export interface SpawnTarget { + readonly command: string; + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv | undefined; + readonly cwd: string | undefined; + readonly distribution: AcpRegistryDistributionKind; +} + +export interface InstallContext { + readonly cacheRoot: string; + readonly platform?: AcpRegistryBinaryPlatform | undefined; + readonly fetchImpl?: FetchLike; +} + +export interface InstallResult { + readonly state: AcpRegistryInstallState; +} + +type ArchiveKind = "tar-gz" | "tar-bz2" | "tar" | "zip" | "raw"; + +const ARCHIVE_FILENAME: Record = { + "tar-gz": "archive.tar.gz", + "tar-bz2": "archive.tar.bz2", + tar: "archive.tar", + zip: "archive.zip", + raw: "agent.bin", +}; + +const ARCHIVE_DETECTORS: ReadonlyArray = [ + [/\.(tar\.gz|tgz)$/, "tar-gz"], + [/\.(tar\.bz2|tbz2)$/, "tar-bz2"], + [/\.tar$/, "tar"], + [/\.zip$/, "zip"], +]; + +const WINDOWS_ABS_PATH = /^[a-zA-Z]:[/\\]/; + +export function availableChannels( + entry: AcpRegistryEntry, + platform: AcpRegistryBinaryPlatform | undefined = resolveCurrentPlatform(), +): ReadonlyArray { + const channels: AcpRegistryDistributionKind[] = []; + const binaryTarget = platform ? entry.distribution.binary?.[platform] : undefined; + if (binaryTarget) channels.push("binary"); + if (entry.distribution.npx) channels.push("npx"); + if (entry.distribution.uvx) channels.push("uvx"); + return channels; +} + +export async function installAgent( + entry: AcpRegistryEntry, + context: InstallContext, +): Promise { + const platform = context.platform ?? resolveCurrentPlatform(); + const channels = availableChannels(entry, platform); + const distribution = channels[0]; + + if (!distribution) { + throw new AcpRegistryError({ + agentId: entry.id, + detail: `No supported distribution for platform ${platform ?? "unknown"}.`, + }); + } + + if (distribution !== "binary") { + return { state: makeInstallState(entry, distribution) }; + } + + const target = platform && entry.distribution.binary?.[platform]; + if (!target) { + throw new AcpRegistryError({ + agentId: entry.id, + detail: `No binary target for platform ${platform ?? "unknown"}.`, + }); + } + + const binaryPath = await installBinary(entry, target, context); + return { state: makeInstallState(entry, "binary", binaryPath) }; +} + +export async function uninstallAgent(entry: AcpRegistryEntry, cacheRoot: string): Promise { + await fsPromises.rm(path.join(cacheRoot, safePathSegment(entry.id, "agent id", entry.id)), { + recursive: true, + force: true, + }); +} + +export function resolveSpawnTarget( + entry: AcpRegistryEntry, + installState: AcpRegistryInstallState | undefined, + options: { readonly cwd?: string } = {}, +): SpawnTarget | undefined { + if (!installState) return undefined; + + switch (installState.distribution) { + case "binary": { + if (!installState.binaryPath) return undefined; + const platform = resolveCurrentPlatform(); + const target = platform ? entry.distribution.binary?.[platform] : undefined; + return { + command: installState.binaryPath, + args: target?.args ? [...target.args] : [], + env: target?.env as NodeJS.ProcessEnv | undefined, + cwd: options.cwd, + distribution: "binary", + }; + } + case "npx": + return packageSpawn(entry.distribution.npx, "npx", options.cwd); + case "uvx": + return packageSpawn(entry.distribution.uvx, "uvx", options.cwd); + } +} + +async function installBinary( + entry: AcpRegistryEntry, + target: AcpRegistryBinaryTarget, + context: InstallContext, +): Promise { + const installRoot = path.join( + context.cacheRoot, + safePathSegment(entry.id, "agent id", entry.id), + safePathSegment(entry.version, "agent version", entry.id), + ); + const archiveKind = detectArchiveKind(target.archive); + const archivePath = path.join(installRoot, ARCHIVE_FILENAME[archiveKind]); + + await fsPromises.rm(installRoot, { recursive: true, force: true }); + await fsPromises.mkdir(installRoot, { recursive: true }); + + await downloadToFile(target.archive, archivePath, context.fetchImpl ?? globalThis.fetch); + if (target.sha256) { + await verifySha256(archivePath, target.sha256, entry.id); + } + await extractArchive(archivePath, archiveKind, installRoot, target.cmd, entry.id); + + const binaryPath = resolveCmdPath(installRoot, target.cmd, entry.id); + if (archivePath !== binaryPath) { + await fsPromises.rm(archivePath, { force: true }); + } + await fsPromises.chmod(binaryPath, 0o755).catch(() => undefined); + return binaryPath; +} + +function makeInstallState( + entry: AcpRegistryEntry, + distribution: AcpRegistryDistributionKind, + binaryPath?: string, +): AcpRegistryInstallState { + return { + version: entry.version, + installedAt: new Date().toISOString(), + distribution, + ...(binaryPath ? { binaryPath } : {}), + }; +} + +function detectArchiveKind(url: string): ArchiveKind { + const urlPath = url.toLowerCase().split("?")[0] ?? ""; + for (const [pattern, kind] of ARCHIVE_DETECTORS) { + if (pattern.test(urlPath)) return kind; + } + return "raw"; +} + +function safePathSegment(value: string, label: string, agentId: string): string { + if ( + value.length === 0 || + value === "." || + value === ".." || + value.includes("/") || + value.includes("\\") || + WINDOWS_ABS_PATH.test(value) + ) { + throw new AcpRegistryError({ + agentId, + detail: `Invalid ACP registry ${label}: ${value}`, + }); + } + return value; +} + +function assertInsideRoot(root: string, targetPath: string, agentId: string, detail: string): void { + const relative = path.relative(root, targetPath); + if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) { + return; + } + throw new AcpRegistryError({ agentId, detail }); +} + +function resolveCmdPath(installRoot: string, cmd: string, agentId: string): string { + if (path.isAbsolute(cmd) || WINDOWS_ABS_PATH.test(cmd)) { + throw new AcpRegistryError({ + agentId, + detail: `ACP registry command path must be relative to the install root: ${cmd}`, + }); + } + const targetPath = path.resolve(installRoot, cmd.replace(/^\.[/\\]/, "")); + assertInsideRoot( + installRoot, + targetPath, + agentId, + `ACP registry command path escapes the install root: ${cmd}`, + ); + return targetPath; +} + +async function verifySha256( + filePath: string, + expectedSha256: string, + agentId: string, +): Promise { + const normalizedExpected = expectedSha256.trim().toLowerCase(); + const digest = createHash("sha256"); + for await (const chunk of createReadStream(filePath)) { + digest.update(chunk); + } + const actual = digest.digest("hex"); + if (actual !== normalizedExpected) { + throw new AcpRegistryError({ + agentId, + detail: `Checksum mismatch for downloaded archive: expected ${normalizedExpected}, got ${actual}`, + }); + } +} + +async function downloadToFile(url: string, destPath: string, fetchImpl: FetchLike): Promise { + const response = await fetchImpl(url); + if (!response.ok) { + throw new AcpRegistryError({ + detail: `Download failed (${response.status} ${response.statusText}) — ${url}`, + }); + } + if (!response.body) { + throw new AcpRegistryError({ detail: `Download returned an empty body — ${url}` }); + } + const readable = Readable.fromWeb(response.body as unknown as ReadableStream); + await pipeline(readable, createWriteStream(destPath)); +} + +async function extractArchive( + archivePath: string, + archiveKind: ArchiveKind, + installRoot: string, + cmd: string, + agentId: string, +): Promise { + switch (archiveKind) { + case "tar-gz": + await assertSafeArchiveEntries(archivePath, archiveKind, installRoot, agentId); + return runProcess("tar", ["-xzf", archivePath], installRoot); + case "tar-bz2": + await assertSafeArchiveEntries(archivePath, archiveKind, installRoot, agentId); + return runProcess("tar", ["-xjf", archivePath], installRoot); + case "tar": + await assertSafeArchiveEntries(archivePath, archiveKind, installRoot, agentId); + return runProcess("tar", ["-xf", archivePath], installRoot); + case "zip": + if (process.platform !== "win32") { + await assertSafeArchiveEntries(archivePath, archiveKind, installRoot, agentId); + } + return extractZip(archivePath, installRoot); + case "raw": + { + const binaryPath = resolveCmdPath(installRoot, cmd, agentId); + await fsPromises.mkdir(path.dirname(binaryPath), { recursive: true }); + if (archivePath !== binaryPath) { + await fsPromises.copyFile(archivePath, binaryPath); + } + } + return; + } +} + +async function assertSafeArchiveEntries( + archivePath: string, + archiveKind: Exclude, + installRoot: string, + agentId: string, +): Promise { + const listing = + archiveKind === "zip" + ? await runProcessCapture("unzip", ["-Z1", archivePath], installRoot) + : await runProcessCapture("tar", ["-tf", archivePath], installRoot); + for (const entry of listing.split(/\r?\n/)) { + if (!entry) continue; + const normalized = entry.replace(/\\/g, "/"); + if ( + normalized.startsWith("/") || + normalized.includes("\0") || + WINDOWS_ABS_PATH.test(normalized) + ) { + throw new AcpRegistryError({ + agentId, + detail: `Archive entry is not relative to the install root: ${entry}`, + }); + } + assertInsideRoot( + installRoot, + path.resolve(installRoot, normalized), + agentId, + `Archive entry escapes the install root: ${entry}`, + ); + } +} + +function extractZip(archivePath: string, installRoot: string): Promise { + if (process.platform === "win32") { + return runProcess( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force", + archivePath, + installRoot, + ], + installRoot, + ); + } + return runProcess("unzip", ["-q", "-o", archivePath, "-d", installRoot], installRoot); +} + +function runProcess(command: string, args: ReadonlyArray, cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { cwd, stdio: ["ignore", "ignore", "pipe"] }); + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.once("error", (cause) => + reject( + new AcpRegistryError({ + detail: `Failed to run ${command}: ${cause.message}`, + cause, + }), + ), + ); + child.once("close", (code) => { + if (code === 0) { + resolve(); + return; + } + const trimmed = stderr.trim(); + reject( + new AcpRegistryError({ + detail: `${command} ${args.join(" ")} exited with ${code ?? "signal"}${ + trimmed ? ` — ${trimmed}` : "" + }`, + }), + ); + }); + }); +} + +function runProcessCapture( + command: string, + args: ReadonlyArray, + cwd: string, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { cwd, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.once("error", (cause) => + reject( + new AcpRegistryError({ + detail: `Failed to run ${command}: ${cause.message}`, + cause, + }), + ), + ); + child.once("close", (code) => { + if (code === 0) { + resolve(stdout); + return; + } + const trimmed = stderr.trim(); + reject( + new AcpRegistryError({ + detail: `${command} ${args.join(" ")} exited with ${code ?? "signal"}${ + trimmed ? ` — ${trimmed}` : "" + }`, + }), + ); + }); + }); +} + +let cachedBunxAvailable: boolean | undefined; + +function bunxAvailable(): boolean { + if (cachedBunxAvailable !== undefined) return cachedBunxAvailable; + cachedBunxAvailable = checkOnPath("bunx"); + return cachedBunxAvailable; +} + +function checkOnPath(command: string): boolean { + const finder = process.platform === "win32" ? "where" : "which"; + return spawnSync(finder, [command], { stdio: "ignore" }).status === 0; +} + +function packageSpawn( + pkg: AcpRegistryPackageDistribution | undefined, + channel: "npx" | "uvx", + cwd: string | undefined, +): SpawnTarget | undefined { + if (!pkg) return undefined; + const command = channel === "uvx" ? "uvx" : bunxAvailable() ? "bunx" : "npx"; + return { + command, + args: [pkg.package, ...(pkg.args ?? [])], + env: pkg.env as NodeJS.ProcessEnv | undefined, + cwd, + distribution: channel, + }; +} diff --git a/apps/server/src/acpRegistry/platform.ts b/apps/server/src/acpRegistry/platform.ts new file mode 100644 index 00000000000..39dd98bac05 --- /dev/null +++ b/apps/server/src/acpRegistry/platform.ts @@ -0,0 +1,21 @@ +import type { AcpRegistryBinaryPlatform } from "@t3tools/contracts"; + +const PLATFORMS: Record = { + darwin: "darwin", + linux: "linux", + win32: "windows", +}; + +const ARCHES: Record = { + arm64: "aarch64", + x64: "x86_64", +}; + +export function resolveCurrentPlatform( + nodePlatform: NodeJS.Platform = process.platform, + nodeArch: string = process.arch, +): AcpRegistryBinaryPlatform | undefined { + const platform = PLATFORMS[nodePlatform]; + const arch = ARCHES[nodeArch]; + return platform && arch ? (`${platform}-${arch}` as AcpRegistryBinaryPlatform) : undefined; +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b0a23cb273c..6a7aeda5619 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -31,6 +31,7 @@ export interface ServerDerivedPaths { readonly keybindingsConfigPath: string; readonly settingsPath: string; readonly providerStatusCacheDir: string; + readonly acpRegistryCacheDir: string; readonly worktreesDir: string; readonly attachmentsDir: string; readonly logsDir: string; @@ -86,12 +87,14 @@ export const deriveServerPaths = Effect.fn(function* ( const logsDir = join(stateDir, "logs"); const providerLogsDir = join(logsDir, "provider"); const providerStatusCacheDir = join(baseDir, "caches"); + const acpRegistryCacheDir = join(baseDir, "acp-agents"); return { stateDir, dbPath, keybindingsConfigPath: join(stateDir, "keybindings.json"), settingsPath: join(stateDir, "settings.json"), providerStatusCacheDir, + acpRegistryCacheDir, worktreesDir: join(baseDir, "worktrees"), attachmentsDir, logsDir, @@ -122,6 +125,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), + fs.makeDirectory(derivedPaths.acpRegistryCacheDir, { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }), ], diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts new file mode 100644 index 00000000000..d430d3c90bf --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { buildModelsFromAcpConfigOptions } from "./AcpRegistryDriver.ts"; + +describe("buildModelsFromAcpConfigOptions", () => { + it("builds provider models from ACP model select options", () => { + const configOptions = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "composer-2", + options: [ + { value: "default", name: "Auto" }, + { value: "composer-2", name: "Composer 2" }, + { value: "gpt-5.4", name: "GPT-5.4" }, + ], + }, + ] satisfies ReadonlyArray; + + expect(buildModelsFromAcpConfigOptions(configOptions)).toMatchObject([ + { slug: "default", name: "Auto", isCustom: false }, + { slug: "composer-2", name: "Composer 2", isCustom: false }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false }, + ]); + }); + + it("flattens grouped ACP model options and de-duplicates values", () => { + const configOptions = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "composer-2", + options: [ + { + group: "recommended", + name: "Recommended", + options: [ + { value: "composer-2", name: "Composer 2" }, + { value: "gpt-5.4", name: "GPT-5.4" }, + ], + }, + { + group: "legacy", + name: "Legacy", + options: [ + { value: "composer-2", name: "Composer 2 Duplicate" }, + { value: "legacy", name: "Legacy" }, + ], + }, + ], + }, + ] satisfies ReadonlyArray; + + expect(buildModelsFromAcpConfigOptions(configOptions).map((model) => model.slug)).toEqual([ + "composer-2", + "gpt-5.4", + "legacy", + ]); + }); + + it("returns an empty list when the ACP agent does not advertise a model selector", () => { + expect( + buildModelsFromAcpConfigOptions([ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: "ask", + options: [{ value: "ask", name: "Ask" }], + }, + ]), + ).toEqual([]); + }); + + it("matches by id=model when category is absent (Junie spec compliance)", () => { + // ACP spec: `category` is optional. Junie returns id="model" with no category. + const configOptions = [ + { + id: "model", + name: "Model", + type: "select", + currentValue: "gemini-3-flash-preview", + options: [ + { value: "gemini-3-flash-preview", name: "Gemini 3 Flash" }, + { value: "claude-opus-4-7", name: "Claude Opus 4.7" }, + ], + }, + ] satisfies ReadonlyArray; + + expect(buildModelsFromAcpConfigOptions(configOptions).map((m) => m.slug)).toEqual([ + "gemini-3-flash-preview", + "claude-opus-4-7", + ]); + }); +}); diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts new file mode 100644 index 00000000000..0217dc1ce2e --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -0,0 +1,303 @@ +import { + type AcpRegistryEntry, + acpRegistryDriverKindFor, + type AcpRegistrySettings, + AcpRegistrySettings as AcpRegistrySettingsSchema, + ProviderDriverKind, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Path from "effect/Path"; + +import { resolveSpawnTarget } from "../../acpRegistry/installer.ts"; +import { ServerConfig } from "../../config.ts"; +import { makeAcpRegistryTextGeneration } from "../../textGeneration/AcpRegistryTextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { getInstallState, setInstallState } from "../../acpRegistry/installManifest.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + type AcpRegistryAdapterEnv, + makeAcpRegistryAdapter, +} from "../Layers/AcpRegistryAdapterLayer.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { buildServerProvider, type ProviderProbeResult } from "../providerSnapshot.ts"; + +const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettingsSchema); + +export { buildModelsFromAcpConfigOptions } from "../acp/configOptionModels.ts"; + +export type AcpRegistryDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | ServerConfig + | ServerSettingsService + | Path.Path + | AcpRegistryAdapterEnv; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +function fallbackModel(entry: AcpRegistryEntry): ServerProviderModel { + return { + slug: entry.id, + name: entry.name, + isCustom: false, + capabilities: null, + }; +} + +export function makeAcpRegistryDriver( + entry: AcpRegistryEntry, +): ProviderDriver { + const driverKind = ProviderDriverKind.make(acpRegistryDriverKindFor(entry.id)); + + return { + driverKind, + metadata: { + displayName: entry.name, + supportsMultipleInstances: true, + }, + configSchema: AcpRegistrySettingsSchema, + defaultConfig: () => decodeAcpRegistrySettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const driverContext = yield* Effect.context< + FileSystem.FileSystem | ServerConfig | ServerSettingsService | Path.Path + >(); + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind, + instanceId, + }); + + const installState = yield* getInstallState(entry.id).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: driverKind, + instanceId, + detail: `Failed to read ACP registry install state: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + cause, + }), + ), + ); + + const spawnTarget = resolveSpawnTarget(entry, installState); + const installed = spawnTarget !== undefined; + + const cachedModels: ReadonlyArray = ( + installState?.cachedModels ?? [] + ).map((cached) => ({ + slug: cached.slug, + name: cached.name, + isCustom: false, + capabilities: null, + })); + + const discoveredModelsRef = + yield* Ref.make>(cachedModels); + const refreshSnapshotRef = yield* Ref.make>(Effect.void); + + const nowIsoEffect = Effect.map(DateTime.now, DateTime.formatIso); + + const adapter = yield* makeAcpRegistryAdapter({ + driverKind, + instanceId, + spawnTarget, + environment: processEnv, + onModelsDiscovered: (models) => + Effect.gen(function* () { + const previous = yield* Ref.get(discoveredModelsRef); + const unchanged = + previous.length === models.length && + previous.every((model, index) => model.slug === models[index]?.slug); + yield* Ref.set(discoveredModelsRef, models); + const currentInstall = yield* getInstallState(entry.id).pipe( + Effect.provide(driverContext), + Effect.catch(() => Effect.succeed(undefined as typeof installState)), + ); + if (currentInstall) { + const { discoveryFailureCount: _f, ...rest } = currentInstall; + yield* setInstallState(entry.id, { + ...rest, + cachedModels: models.map((model) => ({ + slug: model.slug, + name: model.name, + })), + lastDiscoveryAttemptAt: yield* nowIsoEffect, + }).pipe( + Effect.provide(driverContext), + Effect.catch(() => Effect.void), + ); + } + if (!unchanged) { + const refresh = yield* Ref.get(refreshSnapshotRef); + yield* refresh; + } + }), + onDiscoveryFailed: (reason) => + Effect.gen(function* () { + const currentInstall = yield* getInstallState(entry.id).pipe( + Effect.provide(driverContext), + Effect.catch(() => Effect.succeed(undefined as typeof installState)), + ); + if (!currentInstall) return; + const nextCount = (currentInstall.discoveryFailureCount ?? 0) + 1; + yield* Effect.logInfo("ACP registry discovery failure recorded", { + entryId: entry.id, + count: nextCount, + reason, + }); + yield* setInstallState(entry.id, { + ...currentInstall, + discoveryFailureCount: nextCount, + lastDiscoveryAttemptAt: yield* nowIsoEffect, + }).pipe( + Effect.provide(driverContext), + Effect.catch(() => Effect.void), + ); + }), + }); + + const stampIdentity = (snapshot: Omit) => + ({ + ...snapshot, + instanceId, + driver: driverKind, + continuation: { groupKey: continuationIdentity.continuationKey }, + ...(displayName ? { displayName } : {}), + ...(accentColor ? { accentColor } : {}), + }) satisfies ServerProvider; + + const buildSnapshot = (input: { + readonly checkedAt: string; + readonly models?: ReadonlyArray; + readonly discoveryWarning?: string; + }) => { + let probe: ProviderProbeResult; + if (installed) { + const hasAuthMethods = (installState?.authMethods?.length ?? 0) > 0; + probe = { + installed: true, + version: installState?.version ?? entry.version, + status: input.discoveryWarning ? "warning" : "ready", + auth: { + status: "unknown", + ...(hasAuthMethods ? { authMethods: installState?.authMethods ?? [] } : {}), + }, + ...(input.discoveryWarning ? { message: input.discoveryWarning } : {}), + }; + } else { + probe = { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: `${entry.name} is not installed. Install it from Settings → ACP Registry.`, + }; + } + return stampIdentity( + buildServerProvider({ + driver: driverKind, + presentation: { displayName: displayName ?? entry.name }, + enabled, + checkedAt: input.checkedAt, + models: + input.models && input.models.length > 0 ? input.models : [fallbackModel(entry)], + probe, + }), + ); + }; + + const buildSnapshotFromState = Effect.gen(function* () { + const checkedAt = yield* nowIso; + const models = yield* Ref.get(discoveredModelsRef); + return buildSnapshot({ checkedAt, models }); + }); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: driverKind, + packageName: null, + }), + getSettings: Effect.succeed(config), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: () => buildSnapshotFromState, + checkProvider: buildSnapshotFromState, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: driverKind, + instanceId, + detail: `Failed to build ${entry.name} snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + yield* Ref.set(refreshSnapshotRef, snapshot.refresh); + + // Pre-warm model discovery: if we don't have a cached list yet AND the agent is + // installed AND we haven't already failed too many times, fire a background + // session/new on boot so models appear in the UI before the user opens a chat. + // Non-blocking. Skipped after 3 consecutive failures to avoid wasting time on an + // agent that's hung / requires manual auth (Junie's auth flow, qwen-code's env vars). + const failureCount = installState?.discoveryFailureCount ?? 0; + const MAX_FAILURES = 3; + if (installed && cachedModels.length === 0 && failureCount < MAX_FAILURES) { + yield* Effect.logInfo("ACP registry driver: scheduling boot-time discovery", { + entryId: entry.id, + instanceId, + cwd: process.cwd(), + previousFailures: failureCount, + }); + yield* adapter + .discoverModels(process.cwd()) + .pipe(Effect.forkDetach, Effect.asVoid); + } else { + yield* Effect.logInfo("ACP registry driver: skipping boot-time discovery", { + entryId: entry.id, + instanceId, + installed, + cachedModelCount: cachedModels.length, + failureCount, + reason: + !installed + ? "not installed" + : cachedModels.length > 0 + ? "cache hit" + : `${failureCount} prior failures (>= ${MAX_FAILURES}); manual reload required`, + }); + } + + return { + instanceId, + driverKind, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration: makeAcpRegistryTextGeneration(), + } satisfies ProviderInstance; + }), + }; +} diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapterLayer.ts b/apps/server/src/provider/Layers/AcpRegistryAdapterLayer.ts new file mode 100644 index 00000000000..ab4a90b7db5 --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryAdapterLayer.ts @@ -0,0 +1,700 @@ +import { + ApprovalRequestId, + EventId, + type ProviderDriverKind, + type ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + type ServerProviderModel, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Random from "effect/Random"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import type { SpawnTarget } from "../../acpRegistry/installer.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { buildModelsFromSessionSetup } from "../acp/configOptionModels.ts"; +import { AcpConnection, type AcpConnectionShape } from "../acp/AcpConnection.ts"; +import { makeAcpMultiSession, type AcpMultiSessionShape } from "../acp/AcpMultiSession.ts"; +import type { AcpRegistryAdapterShape } from "../Services/AcpRegistryAdapter.ts"; +import { forkAcpEventForwarder } from "./acpRegistryAdapter/eventForwarding.ts"; +import { buildFileHandlers } from "./acpRegistryAdapter/fileHandlers.ts"; +import { resolveSelectedAcpModel } from "./acpRegistryAdapter/helpers.ts"; +import { buildPermissionHandler } from "./acpRegistryAdapter/permissionHandlers.ts"; +import type { + AcpRegistryHandlerContext, + AcpRegistrySessionContext, + PendingApproval, +} from "./acpRegistryAdapter/types.ts"; + +export interface AcpRegistryAdapterOptions { + readonly driverKind: ProviderDriverKind; + readonly instanceId: ProviderInstanceId; + readonly spawnTarget: SpawnTarget | undefined; + readonly environment?: NodeJS.ProcessEnv; + readonly onModelsDiscovered?: ( + models: ReadonlyArray, + ) => Effect.Effect; + /** Fired when a discovery attempt fails (timeout or session/new error). */ + readonly onDiscoveryFailed?: (reason: string) => Effect.Effect; +} + +export type AcpRegistryAdapterEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | ServerConfig; + +export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(function* ( + options: AcpRegistryAdapterOptions, +) { + const provider = options.driverKind; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; + + 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 makeEventStamp = () => + Effect.all({ + eventId: Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)), + createdAt: nowIso, + }); + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + const handlerContext: AcpRegistryHandlerContext = { + provider, + makeEventStamp, + offerRuntimeEvent, + }; + + 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 applySelectedAcpModel = ( + acp: AcpMultiSessionShape, + modelSelection: + | { readonly instanceId?: ProviderInstanceId; readonly model?: string } + | undefined, + threadId: ThreadId, + ) => + Effect.gen(function* () { + const selectedModel = resolveSelectedAcpModel( + yield* acp.getConfigOptions, + modelSelection, + options, + ); + if (!selectedModel) { + return; + } + yield* acp.setModel(selectedModel); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(provider, threadId, "session/set_config_option", error), + ), + ); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail(new ProviderAdapterSessionNotFoundError({ provider, threadId })); + } + return Effect.succeed(ctx); + }; + + const settlePendingApprovals = (pending: ReadonlyMap) => + Effect.forEach( + Array.from(pending.values()), + (entry) => Deferred.succeed(entry.decision, "decline").pipe(Effect.ignore), + { discard: true }, + ); + + const stopSessionInternal = (ctx: AcpRegistrySessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovals(ctx.pendingApprovals); + 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, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + // Connection pool: one child process per (cwd, spawn signature). Sessions multiplex onto these + // connections, matching Zed's pattern (zed/crates/agent_servers/src/acp.rs). + interface PooledConnection { + readonly connection: AcpConnectionShape; + readonly scope: Scope.Closeable; + refCount: number; + } + const connections = new Map(); + + const connectionKey = (spawnTarget: SpawnTarget, cwd: string): string => { + const env: NodeJS.ProcessEnv = { + ...options.environment, + ...spawnTarget.env, + }; + const envFingerprint = Object.entries(env) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); + return [ + spawnTarget.command, + spawnTarget.args.join("\0"), + cwd, + envFingerprint, + ].join(""); + }; + + const acquireConnection = (spawnTarget: SpawnTarget, cwd: string) => + Effect.gen(function* () { + const key = connectionKey(spawnTarget, cwd); + const existing = connections.get(key); + if (existing) { + existing.refCount += 1; + return existing; + } + const connectionScope = yield* Scope.make("sequential"); + const env: NodeJS.ProcessEnv = { + ...options.environment, + ...spawnTarget.env, + }; + const connectionContext = yield* Layer.build( + AcpConnection.layer({ + spawn: { + command: spawnTarget.command, + args: [...spawnTarget.args], + cwd, + env, + }, + clientInfo: { name: "t3-code", version: "0.0.0" }, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), + ).pipe(Effect.provideService(Scope.Scope, connectionScope)); + const connection = yield* Effect.service(AcpConnection).pipe( + Effect.provide(connectionContext), + ); + const pooled: PooledConnection = { + connection, + scope: connectionScope, + refCount: 1, + }; + connections.set(key, pooled); + return pooled; + }); + + const releaseConnection = (spawnTarget: SpawnTarget, cwd: string) => + Effect.gen(function* () { + const key = connectionKey(spawnTarget, cwd); + const pooled = connections.get(key); + if (!pooled) return; + pooled.refCount -= 1; + if (pooled.refCount <= 0) { + connections.delete(key); + yield* Effect.ignore(Scope.close(pooled.scope, Exit.void)); + } + }); + + const startSession: AcpRegistryAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== provider) { + return yield* new ProviderAdapterValidationError({ + provider, + operation: "startSession", + issue: `Expected provider '${provider}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + if (!options.spawnTarget) { + return yield* new ProviderAdapterValidationError({ + provider, + operation: "startSession", + issue: "Agent is not installed. Install it from Settings → ACP Registry.", + }); + } + const spawnTarget = options.spawnTarget; + const cwd = input.cwd.trim(); + + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + let ctx!: AcpRegistrySessionContext; + + const pooled = yield* acquireConnection(spawnTarget, cwd).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider, + threadId: input.threadId, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + ); + let connectionReleased = false; + const releasePooledConnection = Effect.suspend(() => { + if (connectionReleased) return Effect.void; + connectionReleased = true; + return releaseConnection(spawnTarget, cwd); + }); + + // Per-session scope: cleanup tied to this thread only; never closes the pooled connection + // unless the refcount drops to zero (handled by releasePooledConnection below). + const sessionScope = yield* Scope.make("sequential"); + // Bind connection release to the session scope: when the session closes, ref-- on the pool. + yield* Scope.addFinalizer(sessionScope, releasePooledConnection); + + let sessionScopeTransferred = false; + // If startSession fails BEFORE we transfer ownership, drop the session scope + // (which triggers release of the connection). + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const fileHandlers = buildFileHandlers({ fileSystem, cwd }); + const permissionHandler = buildPermissionHandler({ + threadId: input.threadId, + pendingApprovals, + getActiveTurnId: () => ctx?.activeTurnId, + context: handlerContext, + }); + + const acp = yield* makeAcpMultiSession({ + connection: pooled.connection, + cwd, + handlers: { + onRequestPermission: permissionHandler, + onReadTextFile: fileHandlers.onReadTextFile, + onWriteTextFile: fileHandlers.onWriteTextFile, + }, + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(provider, input.threadId, "session/start", error), + ), + ); + + // Release session handlers from connection when the session scope closes — NOT when + // startSession returns. The session lives past startSession (handlers must remain wired + // for incoming session/update notifications until the chat is closed). + const sessionIdForCleanup = acp.sessionId; + yield* Scope.addFinalizer(sessionScope, pooled.connection.releaseSession(sessionIdForCleanup)); + + yield* applySelectedAcpModel(acp, input.modelSelection, input.threadId); + + if (options.onModelsDiscovered) { + const onModelsDiscovered = options.onModelsDiscovered; + const models = buildModelsFromSessionSetup(acp.setupResult.sessionSetupResult); + yield* Effect.logInfo("ACP registry session models discovered", { + provider, + instanceId: options.instanceId, + threadId: input.threadId, + modelCount: models.length, + sessionModels: acp.setupResult.sessionSetupResult.models ?? null, + configOptionCategories: + acp.setupResult.sessionSetupResult.configOptions?.map((opt) => ({ + id: opt.id, + category: opt.category, + type: opt.type, + })) ?? null, + }); + if (models.length > 0) { + yield* onModelsDiscovered(models).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkDetach, + Effect.asVoid, + ); + } + } + + const now = yield* nowIso; + const session: ProviderSession = { + provider, + providerInstanceId: options.instanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + threadId: input.threadId, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + turns: [], + activeTurnId: undefined, + stopped: false, + }; + + const notificationFiber = yield* forkAcpEventForwarder({ + acp, + getSessionContext: () => ctx, + context: handlerContext, + }); + + ctx.notificationFiber = notificationFiber; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider, + threadId: input.threadId, + payload: { resume: undefined }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider, + threadId: input.threadId, + payload: { state: "ready", reason: "ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider, + threadId: input.threadId, + payload: { providerThreadId: acp.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: AcpRegistryAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(crypto.randomUUID()); + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider, + threadId: input.threadId, + turnId, + payload: {}, + }); + + const promptParts: Array = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + for (const attachment of input.attachments ?? []) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + yield* applySelectedAcpModel(ctx.acp, input.modelSelection, input.threadId); + + const result = yield* ctx.acp + .prompt({ prompt: promptParts }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(provider, input.threadId, "session/prompt", error), + ), + ); + + ctx.turns.push({ + id: turnId, + items: [{ prompt: promptParts, result }], + }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + + return { threadId: input.threadId, turnId }; + }); + + const interruptTurn: AcpRegistryAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovals(ctx.pendingApprovals); + yield* Effect.ignore(ctx.acp.cancel); + }); + + const respondToRequest: AcpRegistryAdapterShape["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, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: AcpRegistryAdapterShape["respondToUserInput"] = ( + _threadId, + requestId, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider, + method: "session/request_user_input", + detail: `Structured user input is not supported by ACP registry agents (request ${requestId}).`, + }), + ); + + const readThread: AcpRegistryAdapterShape["readThread"] = (threadId) => + Effect.map(requireSession(threadId), (ctx) => ({ + threadId, + turns: ctx.turns, + })); + + const rollbackThread: AcpRegistryAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + ctx.turns.splice(Math.max(0, ctx.turns.length - numTurns)); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: AcpRegistryAdapterShape["stopSession"] = (threadId) => + withThreadLock(threadId, Effect.flatMap(requireSession(threadId), stopSessionInternal)); + + const listSessions: AcpRegistryAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (ctx) => ({ ...ctx.session }))); + + const hasSession: AcpRegistryAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const ctx = sessions.get(threadId); + return ctx !== undefined && !ctx.stopped; + }); + + const stopAll: AcpRegistryAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { + discard: true, + }).pipe(Effect.tap(() => PubSub.shutdown(runtimeEventPubSub))), + ); + + /** + * Probe the agent without involving the UI: spawn (or reuse) a connection, do session/new, + * extract models, fire onModelsDiscovered, release. Used at boot to populate the model list + * before the user opens a chat. Throws never — errors are logged and swallowed. + */ + const discoverModels = (cwd: string): Effect.Effect => + Effect.gen(function* () { + yield* Effect.logInfo("ACP registry discoverModels: starting", { + provider, + instanceId: options.instanceId, + cwd, + hasSpawnTarget: options.spawnTarget !== undefined, + }); + if (!options.spawnTarget) return; + const spawnTarget = options.spawnTarget; + const pooled = yield* acquireConnection(spawnTarget, cwd).pipe( + Effect.catch((cause) => + Effect.logWarning("ACP registry discoverModels: acquire failed", { cause }).pipe( + Effect.as(null), + ), + ), + ); + if (!pooled) return; + + const sessionScope = yield* Scope.make("sequential"); + let releaseDone = false; + const releaseAll = Effect.suspend(() => { + if (releaseDone) return Effect.void; + releaseDone = true; + return Scope.close(sessionScope, Exit.void).pipe( + Effect.andThen(releaseConnection(spawnTarget, cwd)), + ); + }); + + // Hard cap discovery: some agents (Junie/JVM cold-start, or auth-required ones that wait + // silently) can hang forever on session/new. Match the old 30s budget, but bias up to 90s + // to give Junie's JVM realistic headroom. + const DISCOVERY_TIMEOUT = "90 seconds"; + const result = yield* Effect.exit( + makeAcpMultiSession({ + connection: pooled.connection, + cwd, + handlers: {}, + }).pipe(Effect.timeout(DISCOVERY_TIMEOUT)), + ); + if (Exit.isSuccess(result)) { + const acp = result.value; + yield* Scope.addFinalizer(sessionScope, pooled.connection.releaseSession(acp.sessionId)); + + const models = buildModelsFromSessionSetup(acp.setupResult.sessionSetupResult); + yield* Effect.logInfo("ACP registry boot-time model discovery", { + provider, + instanceId: options.instanceId, + modelCount: models.length, + }); + if (models.length > 0 && options.onModelsDiscovered) { + yield* options.onModelsDiscovered(models).pipe(Effect.ignoreCause({ log: true })); + } + } else { + const prettyCause = Cause.pretty(result.cause); + yield* Effect.logWarning("ACP registry discoverModels: session/new failed", { + provider, + instanceId: options.instanceId, + cause: prettyCause, + }); + if (options.onDiscoveryFailed) { + yield* options + .onDiscoveryFailed(prettyCause.split("\n", 1)[0] ?? "unknown") + .pipe(Effect.ignoreCause({ log: true })); + } + } + + yield* releaseAll; + }).pipe(Effect.scoped, Effect.ignoreCause({ log: true })); + + return { + provider, + capabilities: { sessionModelSwitch: "unsupported" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromPubSub(runtimeEventPubSub), + discoverModels, + } satisfies AcpRegistryAdapterShape & { + readonly discoverModels: (cwd: string) => Effect.Effect; + }; +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 4e43e04cb7c..f948a80ce19 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -96,6 +96,7 @@ export const deriveProviderInstanceConfigMap = ( merged[instanceId] = { driver: driver.driverKind, + enabled: true, config: legacyConfig, }; } diff --git a/apps/server/src/provider/Layers/acpRegistryAdapter/eventForwarding.ts b/apps/server/src/provider/Layers/acpRegistryAdapter/eventForwarding.ts new file mode 100644 index 00000000000..82906d83abd --- /dev/null +++ b/apps/server/src/provider/Layers/acpRegistryAdapter/eventForwarding.ts @@ -0,0 +1,83 @@ +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpToolCallEvent, +} from "../../acp/AcpCoreRuntimeEvents.ts"; +import type { AcpMultiSessionShape } from "../../acp/AcpMultiSession.ts"; + +import type { AcpRegistryHandlerContext, AcpRegistrySessionContext } from "./types.ts"; + +export function forkAcpEventForwarder(input: { + readonly acp: AcpMultiSessionShape; + readonly getSessionContext: () => AcpRegistrySessionContext; + readonly context: AcpRegistryHandlerContext; +}) { + return Stream.runDrain( + Stream.mapEffect(input.acp.getEvents(), (event) => + Effect.gen(function* () { + const ctx = input.getSessionContext(); + switch (event._tag) { + case "AssistantItemStarted": + case "AssistantItemCompleted": + yield* input.context.offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* input.context.makeEventStamp(), + provider: input.context.provider, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: + event._tag === "AssistantItemStarted" ? "item.started" : "item.completed", + }), + ); + return; + case "ContentDelta": + yield* input.context.offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* input.context.makeEventStamp(), + provider: input.context.provider, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + case "ToolCallUpdated": + yield* input.context.offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* input.context.makeEventStamp(), + provider: input.context.provider, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "PlanUpdated": + yield* input.context.offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* input.context.makeEventStamp(), + provider: input.context.provider, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: event.payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload: event.rawPayload, + }), + ); + return; + case "ModeChanged": + return; + } + }), + ), + ).pipe(Effect.forkChild); +} diff --git a/apps/server/src/provider/Layers/acpRegistryAdapter/fileHandlers.test.ts b/apps/server/src/provider/Layers/acpRegistryAdapter/fileHandlers.test.ts new file mode 100644 index 00000000000..0d87794e203 --- /dev/null +++ b/apps/server/src/provider/Layers/acpRegistryAdapter/fileHandlers.test.ts @@ -0,0 +1,21 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { resolveAcpPath } from "./fileHandlers.ts"; + +describe("resolveAcpPath", () => { + const cwd = path.resolve("/tmp/t3-acp-session"); + + it("resolves relative and in-root absolute paths inside the session cwd", () => { + expect(resolveAcpPath(cwd, "src/index.ts")).toBe(path.join(cwd, "src", "index.ts")); + expect(resolveAcpPath(cwd, path.join(cwd, "README.md"))).toBe(path.join(cwd, "README.md")); + }); + + it("rejects paths that escape the session cwd", () => { + expect(() => resolveAcpPath(cwd, "../outside.txt")).toThrow("inside the session cwd"); + expect(() => resolveAcpPath(cwd, path.resolve(cwd, "..", "outside.txt"))).toThrow( + "inside the session cwd", + ); + }); +}); diff --git a/apps/server/src/provider/Layers/acpRegistryAdapter/fileHandlers.ts b/apps/server/src/provider/Layers/acpRegistryAdapter/fileHandlers.ts new file mode 100644 index 00000000000..41bbcee2c13 --- /dev/null +++ b/apps/server/src/provider/Layers/acpRegistryAdapter/fileHandlers.ts @@ -0,0 +1,71 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as path from "node:path"; + +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +export const resolveAcpPath = (cwd: string, rawPath: string): string => { + const root = path.resolve(cwd); + const targetPath = path.resolve(root, rawPath); + const relativePath = path.relative(root, targetPath); + if (relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))) { + return targetPath; + } + throw new Error(`Path must stay inside the session cwd: ${rawPath}`); +}; + +const acpFsError = (operation: string, rawPath: string, cause: unknown) => + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Failed to ${operation} '${rawPath}': ${ + cause instanceof Error ? cause.message : String(cause) + }`, + }); + +export function buildFileHandlers(input: { + readonly fileSystem: FileSystem.FileSystem; + readonly cwd: string; +}): { + readonly onReadTextFile: ( + request: EffectAcpSchema.ReadTextFileRequest, + ) => Effect.Effect; + readonly onWriteTextFile: ( + request: EffectAcpSchema.WriteTextFileRequest, + ) => Effect.Effect; +} { + return { + onWriteTextFile: (request) => + Effect.gen(function* () { + const targetPath = yield* Effect.try({ + try: () => resolveAcpPath(input.cwd, request.path), + catch: (cause) => acpFsError("resolve file path", request.path, cause), + }); + yield* input.fileSystem.makeDirectory(path.dirname(targetPath), { + recursive: true, + }); + yield* input.fileSystem.writeFileString(targetPath, request.content); + }).pipe(Effect.mapError((cause) => acpFsError("write file", request.path, cause))), + + onReadTextFile: (request) => + Effect.gen(function* () { + const targetPath = yield* Effect.try({ + try: () => resolveAcpPath(input.cwd, request.path), + catch: (cause) => acpFsError("resolve file path", request.path, cause), + }); + const exists = yield* input.fileSystem.exists(targetPath); + if (!exists) { + return { content: "" }; + } + const content = yield* input.fileSystem.readFileString(targetPath); + if (request.line == null && request.limit == null) { + return { content }; + } + const lines = content.split("\n"); + const start = request.line != null ? Math.max(0, request.line - 1) : 0; + const end = request.limit != null ? start + request.limit : lines.length; + return { content: lines.slice(start, end).join("\n") }; + }).pipe(Effect.mapError((cause) => acpFsError("read file", request.path, cause))), + }; +} diff --git a/apps/server/src/provider/Layers/acpRegistryAdapter/helpers.ts b/apps/server/src/provider/Layers/acpRegistryAdapter/helpers.ts new file mode 100644 index 00000000000..93bc9e46962 --- /dev/null +++ b/apps/server/src/provider/Layers/acpRegistryAdapter/helpers.ts @@ -0,0 +1,28 @@ +import { type ProviderInstanceId } from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import type { AcpRegistryAdapterOptions } from "../AcpRegistryAdapterLayer.ts"; +import { collectSessionConfigOptionValues } from "../../acp/AcpRuntimeModel.ts"; + +export const resolveSelectedAcpModel = ( + configOptions: ReadonlyArray, + modelSelection: { readonly instanceId?: ProviderInstanceId; readonly model?: string } | undefined, + options: AcpRegistryAdapterOptions, +): string | undefined => { + if (modelSelection?.instanceId !== options.instanceId) { + return undefined; + } + const selectedModel = modelSelection.model?.trim(); + if (!selectedModel) { + return undefined; + } + const modelConfigOption = configOptions.find( + (option) => option.category === "model" && option.type === "select", + ); + if (!modelConfigOption) { + return undefined; + } + return collectSessionConfigOptionValues(modelConfigOption).includes(selectedModel) + ? selectedModel + : undefined; +}; diff --git a/apps/server/src/provider/Layers/acpRegistryAdapter/permissionHandlers.ts b/apps/server/src/provider/Layers/acpRegistryAdapter/permissionHandlers.ts new file mode 100644 index 00000000000..84e420c0304 --- /dev/null +++ b/apps/server/src/provider/Layers/acpRegistryAdapter/permissionHandlers.ts @@ -0,0 +1,69 @@ +import { + ApprovalRequestId, + RuntimeRequestId, + type ProviderApprovalDecision, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAcpPermissionOutcome } from "../../acp/AcpAdapterSupport.ts"; +import { + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, +} from "../../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../../acp/AcpRuntimeModel.ts"; + +import type { AcpRegistryHandlerContext, PendingApproval } from "./types.ts"; + +export function buildPermissionHandler(input: { + readonly threadId: ThreadId; + readonly pendingApprovals: Map; + readonly getActiveTurnId: () => TurnId | undefined; + readonly context: AcpRegistryHandlerContext; +}): ( + params: EffectAcpSchema.RequestPermissionRequest, +) => Effect.Effect { + return (params) => + Effect.gen(function* () { + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + input.pendingApprovals.set(requestId, { decision }); + yield* input.context.offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* input.context.makeEventStamp(), + provider: input.context.provider, + threadId: input.threadId, + turnId: input.getActiveTurnId(), + requestId: runtimeRequestId, + permissionRequest, + detail: permissionRequest.detail ?? "Permission requested", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + input.pendingApprovals.delete(requestId); + yield* input.context.offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* input.context.makeEventStamp(), + provider: input.context.provider, + threadId: input.threadId, + turnId: input.getActiveTurnId(), + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: resolveAcpPermissionOutcome(resolved, params.options), + }; + }); +} diff --git a/apps/server/src/provider/Layers/acpRegistryAdapter/types.ts b/apps/server/src/provider/Layers/acpRegistryAdapter/types.ts new file mode 100644 index 00000000000..0fee87aa974 --- /dev/null +++ b/apps/server/src/provider/Layers/acpRegistryAdapter/types.ts @@ -0,0 +1,47 @@ +import { + type ApprovalRequestId, + type EventId, + type ProviderApprovalDecision, + type ProviderDriverKind, + type ProviderRuntimeEvent, + type ProviderSession, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; +import type * as Deferred from "effect/Deferred"; +import type * as Effect from "effect/Effect"; +import type * as Fiber from "effect/Fiber"; +import type * as Scope from "effect/Scope"; + +import type { AcpMultiSessionShape } from "../../acp/AcpMultiSession.ts"; + +export interface EventStamp { + readonly eventId: EventId; + readonly createdAt: string; +} + +export type MakeEventStamp = () => Effect.Effect; + +export type OfferRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.Effect; + +export interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +export interface AcpRegistrySessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpMultiSessionShape; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + activeTurnId: TurnId | undefined; + stopped: boolean; +} + +export interface AcpRegistryHandlerContext { + readonly provider: ProviderDriverKind; + readonly makeEventStamp: MakeEventStamp; + readonly offerRuntimeEvent: OfferRuntimeEvent; +} diff --git a/apps/server/src/provider/Services/AcpRegistryAdapter.ts b/apps/server/src/provider/Services/AcpRegistryAdapter.ts new file mode 100644 index 00000000000..97e7680de7c --- /dev/null +++ b/apps/server/src/provider/Services/AcpRegistryAdapter.ts @@ -0,0 +1,4 @@ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface AcpRegistryAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts index a7fcdc4c827..1e43858d0f7 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import * as EffectAcpErrors from "effect-acp/errors"; import { ProviderDriverKind } from "@t3tools/contracts"; -import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts"; +import { + acpPermissionOutcome, + mapAcpToAdapterError, + resolveAcpPermissionOutcome, +} from "./AcpAdapterSupport.ts"; describe("AcpAdapterSupport", () => { it("maps ACP approval decisions to permission outcomes", () => { @@ -11,6 +15,33 @@ describe("AcpAdapterSupport", () => { expect(acpPermissionOutcome("decline")).toBe("reject-once"); }); + it("resolves approval decisions to the agent's advertised option ids", () => { + // Agents define their own optionId strings — the client must echo one + // back, matched by the standard `kind`, not a hardcoded value. + const options = [ + { optionId: "proceed_always", name: "Allow for this session", kind: "allow_always" }, + { optionId: "proceed_once", name: "Allow", kind: "allow_once" }, + { optionId: "cancel", name: "Reject", kind: "reject_once" }, + ] as const; + + expect(resolveAcpPermissionOutcome("accept", options)).toEqual({ + outcome: "selected", + optionId: "proceed_once", + }); + expect(resolveAcpPermissionOutcome("acceptForSession", options)).toEqual({ + outcome: "selected", + optionId: "proceed_always", + }); + expect(resolveAcpPermissionOutcome("decline", options)).toEqual({ + outcome: "selected", + optionId: "cancel", + }); + }); + + it("falls back to cancelled when the agent advertises no matching option", () => { + expect(resolveAcpPermissionOutcome("accept", [])).toEqual({ outcome: "cancelled" }); + }); + it("maps ACP request errors to provider adapter request errors", () => { const error = mapAcpToAdapterError( ProviderDriverKind.make("cursor"), diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts index cde110e6dd9..160e9cc6d57 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -5,6 +5,7 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; import { ProviderAdapterRequestError, @@ -54,3 +55,22 @@ export function acpPermissionOutcome(decision: ProviderApprovalDecision): string return "reject-once"; } } + +export function resolveAcpPermissionOutcome( + decision: ProviderApprovalDecision, + options: ReadonlyArray, +): EffectAcpSchema.RequestPermissionResponse["outcome"] { + const preferredKinds: ReadonlyArray = + decision === "acceptForSession" + ? ["allow_always", "allow_once"] + : decision === "accept" + ? ["allow_once", "allow_always"] + : ["reject_once", "reject_always"]; + for (const kind of preferredKinds) { + const match = options.find((option) => option.kind === kind); + if (match) { + return { outcome: "selected", optionId: match.optionId }; + } + } + return { outcome: "cancelled" }; +} diff --git a/apps/server/src/provider/acp/AcpConnection.ts b/apps/server/src/provider/acp/AcpConnection.ts new file mode 100644 index 00000000000..9d1d51f9ca0 --- /dev/null +++ b/apps/server/src/provider/acp/AcpConnection.ts @@ -0,0 +1,427 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as EffectAcpClient from "effect-acp/client"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpProtocol from "effect-acp/protocol"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +export interface AcpConnectionSpawnInput { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; +} + +export interface AcpConnectionRequestLogEvent { + readonly method: string; + readonly payload: unknown; + readonly status: "started" | "succeeded" | "failed"; + readonly result?: unknown; + readonly cause?: Cause.Cause; +} + +export interface AcpConnectionOptions { + readonly spawn: AcpConnectionSpawnInput; + readonly clientInfo: { readonly name: string; readonly version: string }; + readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; + readonly authMethodId?: string; + readonly requestLogger?: ( + event: AcpConnectionRequestLogEvent, + ) => Effect.Effect; + readonly protocolLogging?: { + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect; + }; +} + +/** + * Per-session callback registry. A connection routes incoming requests/notifications to the + * matching session by `sessionId`. Each callback returns an Effect using only the connection's + * runtime context (no extra services), matching how the underlying effect-acp handlers are typed. + */ +export interface AcpConnectionSessionHandlers { + readonly onSessionUpdate?: ( + notification: EffectAcpSchema.SessionNotification, + ) => Effect.Effect; + readonly onRequestPermission?: ( + request: EffectAcpSchema.RequestPermissionRequest, + ) => Effect.Effect; + readonly onElicitation?: ( + request: EffectAcpSchema.ElicitationRequest, + ) => Effect.Effect; + readonly onReadTextFile?: ( + request: EffectAcpSchema.ReadTextFileRequest, + ) => Effect.Effect; + readonly onWriteTextFile?: ( + request: EffectAcpSchema.WriteTextFileRequest, + ) => Effect.Effect; +} + +export interface AcpConnectionStartResult { + readonly initializeResult: EffectAcpSchema.InitializeResponse; + readonly authMethods: ReadonlyArray; +} + +export interface AcpConnectionNewSessionOptions { + readonly cwd: string; + readonly mcpServers?: ReadonlyArray; + readonly resumeSessionId?: string; + readonly handlers: AcpConnectionSessionHandlers; +} + +export interface AcpConnectionNewSessionResult { + readonly sessionId: string; + readonly sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; +} + +export interface AcpConnectionShape { + readonly start: () => Effect.Effect; + readonly authenticate: (methodId: string) => Effect.Effect; + readonly newSession: ( + options: AcpConnectionNewSessionOptions, + ) => Effect.Effect; + readonly releaseSession: (sessionId: string) => Effect.Effect; + readonly request: ( + method: string, + payload: unknown, + ) => Effect.Effect; + readonly notify: ( + method: string, + payload: unknown, + ) => Effect.Effect; + readonly prompt: ( + payload: EffectAcpSchema.PromptRequest, + ) => Effect.Effect; + readonly cancel: ( + payload: EffectAcpSchema.CancelNotification, + ) => Effect.Effect; + readonly setSessionConfigOption: ( + payload: EffectAcpSchema.SetSessionConfigOptionRequest, + ) => Effect.Effect< + EffectAcpSchema.SetSessionConfigOptionResponse, + EffectAcpErrors.AcpError + >; +} + +export class AcpConnection extends Context.Service()( + "t3/provider/acp/AcpConnection", +) { + static layer( + options: AcpConnectionOptions, + ): Layer.Layer { + return Layer.effect(AcpConnection, makeAcpConnection(options)); + } +} + +interface StartedState { + readonly initializeResult: EffectAcpSchema.InitializeResponse; + readonly authMethods: ReadonlyArray; +} + +type StartState = + | { readonly _tag: "NotStarted" } + | { + readonly _tag: "Starting"; + readonly deferred: Deferred.Deferred; + } + | { readonly _tag: "Started"; readonly state: StartedState }; + +const makeAcpConnection = ( + options: AcpConnectionOptions, +): Effect.Effect< + AcpConnectionShape, + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope +> => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeScope = yield* Scope.Scope; + const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); + const sessionHandlersRef = yield* Ref.make(new Map()); + + const logRequest = (event: AcpConnectionRequestLogEvent) => + options.requestLogger ? options.requestLogger(event) : Effect.void; + + const runLoggedRequest = ( + method: string, + payload: unknown, + effect: Effect.Effect, + ): Effect.Effect => + logRequest({ method, payload, status: "started" }).pipe( + Effect.flatMap(() => + effect.pipe( + Effect.tap((result) => + logRequest({ method, payload, status: "succeeded", result }), + ), + Effect.onError((cause) => + logRequest({ method, payload, status: "failed", cause }), + ), + ), + ), + ); + + const child = yield* spawner + .spawn( + ChildProcess.make(options.spawn.command, [...options.spawn.args], { + ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), + ...(options.spawn.env ? { env: { ...process.env, ...options.spawn.env } } : {}), + shell: process.platform === "win32", + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpSpawnError({ + command: options.spawn.command, + cause, + }), + ), + ); + + const acpContext = yield* Layer.build( + EffectAcpClient.layerChildProcess(child, { + ...(options.protocolLogging?.logIncoming !== undefined + ? { logIncoming: options.protocolLogging.logIncoming } + : {}), + ...(options.protocolLogging?.logOutgoing !== undefined + ? { logOutgoing: options.protocolLogging.logOutgoing } + : {}), + ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), + }), + ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); + + const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); + + const getHandlers = (sessionId: string) => + Ref.get(sessionHandlersRef).pipe(Effect.map((map) => map.get(sessionId))); + + const sessionNotFoundError = (sessionId: string) => + new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `No handlers registered for session "${sessionId}"`, + data: { sessionId }, + }); + + // Connection-level dispatch: each handler routes by params.sessionId to per-session callbacks. + yield* acp.handleSessionUpdate((notification) => + Effect.gen(function* () { + const handlers = yield* getHandlers(notification.sessionId); + if (!handlers?.onSessionUpdate) return; + yield* handlers.onSessionUpdate(notification); + }), + ); + + yield* acp.handleRequestPermission((request) => + Effect.gen(function* () { + const handlers = yield* getHandlers(request.sessionId); + if (!handlers?.onRequestPermission) { + return yield* Effect.fail(sessionNotFoundError(request.sessionId)); + } + return yield* handlers.onRequestPermission(request); + }), + ); + + yield* acp.handleElicitation((request) => + Effect.gen(function* () { + const handlers = yield* getHandlers(request.sessionId); + if (!handlers?.onElicitation) { + return yield* Effect.fail(sessionNotFoundError(request.sessionId)); + } + return yield* handlers.onElicitation(request); + }), + ); + + yield* acp.handleReadTextFile((request) => + Effect.gen(function* () { + const handlers = yield* getHandlers(request.sessionId); + if (!handlers?.onReadTextFile) { + return yield* Effect.fail(sessionNotFoundError(request.sessionId)); + } + return yield* handlers.onReadTextFile(request); + }), + ); + + yield* acp.handleWriteTextFile((request) => + Effect.gen(function* () { + const handlers = yield* getHandlers(request.sessionId); + if (!handlers?.onWriteTextFile) { + return yield* Effect.fail(sessionNotFoundError(request.sessionId)); + } + return yield* handlers.onWriteTextFile(request); + }), + ); + + const initializeClientCapabilities = { + fs: { + readTextFile: false, + writeTextFile: false, + ...options.clientCapabilities?.fs, + }, + terminal: options.clientCapabilities?.terminal ?? false, + ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}), + ...(options.clientCapabilities?.elicitation + ? { elicitation: options.clientCapabilities.elicitation } + : {}), + ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}), + } satisfies NonNullable; + + const startOnce = Effect.gen(function* () { + const initializePayload = { + protocolVersion: 1, + clientCapabilities: initializeClientCapabilities, + clientInfo: options.clientInfo, + } satisfies EffectAcpSchema.InitializeRequest; + + const initializeResult = yield* runLoggedRequest( + "initialize", + initializePayload, + acp.agent.initialize(initializePayload), + ); + + if (options.authMethodId) { + const authPayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; + yield* runLoggedRequest( + "authenticate", + authPayload, + acp.agent.authenticate(authPayload), + ); + } + + return { + initializeResult, + authMethods: initializeResult.authMethods ?? [], + } satisfies StartedState; + }); + + const start = Effect.gen(function* () { + const deferred = yield* Deferred.make(); + const effect = yield* Ref.modify(startStateRef, (state) => { + switch (state._tag) { + case "Started": + return [Effect.succeed(state.state), state] as const; + case "Starting": + return [Deferred.await(state.deferred), state] as const; + case "NotStarted": + return [ + startOnce.pipe( + Effect.tap((state) => + Ref.set(startStateRef, { _tag: "Started", state }).pipe( + Effect.andThen(Deferred.succeed(deferred, state)), + ), + ), + Effect.onError((cause) => + Deferred.failCause(deferred, cause).pipe( + Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })), + ), + ), + ), + { _tag: "Starting", deferred } satisfies StartState, + ] as const; + } + }); + return yield* effect; + }); + + const authenticate = (methodId: string) => + runLoggedRequest("authenticate", { methodId }, acp.agent.authenticate({ methodId })).pipe( + Effect.asVoid, + ); + + const registerSessionHandlers = (sessionId: string, handlers: AcpConnectionSessionHandlers) => + Ref.update(sessionHandlersRef, (map) => { + const next = new Map(map); + next.set(sessionId, handlers); + return next; + }); + + const releaseSession = (sessionId: string) => + Ref.update(sessionHandlersRef, (map) => { + if (!map.has(sessionId)) return map; + const next = new Map(map); + next.delete(sessionId); + return next; + }); + + const newSession = (input: AcpConnectionNewSessionOptions) => + Effect.gen(function* () { + yield* start; + const mcpServers = input.mcpServers ?? []; + let sessionId: string; + let sessionSetupResult: AcpConnectionNewSessionResult["sessionSetupResult"]; + if (input.resumeSessionId) { + const loadPayload = { + sessionId: input.resumeSessionId, + cwd: input.cwd, + mcpServers, + } satisfies EffectAcpSchema.LoadSessionRequest; + const loaded = yield* runLoggedRequest( + "session/load", + loadPayload, + acp.agent.loadSession(loadPayload), + ).pipe(Effect.exit); + if (Exit.isSuccess(loaded)) { + sessionId = input.resumeSessionId; + sessionSetupResult = loaded.value; + } else { + const createPayload = { + cwd: input.cwd, + mcpServers, + } satisfies EffectAcpSchema.NewSessionRequest; + const created = yield* runLoggedRequest( + "session/new", + createPayload, + acp.agent.createSession(createPayload), + ); + sessionId = created.sessionId; + sessionSetupResult = created; + } + } else { + const createPayload = { + cwd: input.cwd, + mcpServers, + } satisfies EffectAcpSchema.NewSessionRequest; + const created = yield* runLoggedRequest( + "session/new", + createPayload, + acp.agent.createSession(createPayload), + ); + sessionId = created.sessionId; + sessionSetupResult = created; + } + yield* registerSessionHandlers(sessionId, input.handlers); + return { sessionId, sessionSetupResult } satisfies AcpConnectionNewSessionResult; + }); + + return { + start: () => start, + authenticate, + newSession, + releaseSession, + request: (method, payload) => + runLoggedRequest(method, payload, acp.raw.request(method, payload)), + notify: acp.raw.notify, + prompt: (payload) => + runLoggedRequest("session/prompt", payload, acp.agent.prompt(payload)), + cancel: (payload) => acp.agent.cancel(payload), + setSessionConfigOption: (payload) => + runLoggedRequest( + "session/set_config_option", + payload, + acp.agent.setSessionConfigOption(payload), + ), + } satisfies AcpConnectionShape; + }); diff --git a/apps/server/src/provider/acp/AcpMultiSession.ts b/apps/server/src/provider/acp/AcpMultiSession.ts new file mode 100644 index 00000000000..0860d1c47e6 --- /dev/null +++ b/apps/server/src/provider/acp/AcpMultiSession.ts @@ -0,0 +1,357 @@ +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + type AcpConnectionShape, + type AcpConnectionSessionHandlers, +} from "./AcpConnection.ts"; +import { + collectSessionConfigOptionValues, + extractModelConfigId, + findSessionConfigOption, + mergeToolCallState, + parseSessionModeState, + parseSessionUpdateEvent, + type AcpParsedSessionEvent, + type AcpSessionModeState, + type AcpToolCallState, +} from "./AcpRuntimeModel.ts"; + +function formatConfigOptionValue(value: string | boolean): string { + return JSON.stringify(value); +} + +interface AssistantSegmentState { + readonly nextSegmentIndex: number; + readonly activeItemId?: string; +} + +interface EnsureActiveAssistantSegmentResult { + readonly itemId: string; + readonly startedEvent?: Extract; +} + +export interface AcpMultiSessionStartResult { + readonly sessionId: string; + readonly sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + readonly modelConfigId: string | undefined; +} + +/** Per-session handlers contributed by the adapter (file IO, permissions). */ +export interface AcpMultiSessionUserHandlers { + readonly onRequestPermission?: AcpConnectionSessionHandlers["onRequestPermission"]; + readonly onElicitation?: AcpConnectionSessionHandlers["onElicitation"]; + readonly onReadTextFile?: AcpConnectionSessionHandlers["onReadTextFile"]; + readonly onWriteTextFile?: AcpConnectionSessionHandlers["onWriteTextFile"]; +} + +export interface AcpMultiSessionOptions { + readonly connection: AcpConnectionShape; + readonly cwd: string; + readonly mcpServers?: ReadonlyArray; + readonly resumeSessionId?: string; + readonly handlers: AcpMultiSessionUserHandlers; +} + +export interface AcpMultiSessionShape { + readonly sessionId: string; + readonly setupResult: AcpMultiSessionStartResult; + readonly getEvents: () => Stream.Stream; + readonly getConfigOptions: Effect.Effect< + ReadonlyArray + >; + readonly getModeState: Effect.Effect; + readonly setModel: (model: string) => Effect.Effect; + readonly setMode: ( + modeId: string, + ) => Effect.Effect; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect< + EffectAcpSchema.SetSessionConfigOptionResponse, + EffectAcpErrors.AcpError + >; + readonly prompt: ( + payload: Omit, + ) => Effect.Effect; + readonly cancel: Effect.Effect; +} + +export const makeAcpMultiSession = ( + options: AcpMultiSessionOptions, +): Effect.Effect => + Effect.gen(function* () { + const eventQueue = yield* Queue.unbounded(); + const modeStateRef = yield* Ref.make(undefined); + const toolCallsRef = yield* Ref.make(new Map()); + const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); + const configOptionsRef = yield* Ref.make< + ReadonlyArray + >([]); + + const onSessionUpdate: AcpConnectionSessionHandlers["onSessionUpdate"] = (notification) => + Effect.gen(function* () { + const parsed = parseSessionUpdateEvent(notification); + if (parsed.modeId) { + yield* Ref.update(modeStateRef, (current) => + current === undefined ? current : updateModeState(current, parsed.modeId!), + ); + } + for (const event of parsed.events) { + if (event._tag === "ToolCallUpdated") { + yield* closeActiveAssistantSegment(); + const { previous, merged } = yield* Ref.modify(toolCallsRef, (current) => { + const previous = current.get(event.toolCall.toolCallId); + const nextToolCall = mergeToolCallState(previous, event.toolCall); + const next = new Map(current); + if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { + next.delete(nextToolCall.toolCallId); + } else { + next.set(nextToolCall.toolCallId, nextToolCall); + } + return [{ previous, merged: nextToolCall }, next] as const; + }); + if (!shouldEmitToolCallUpdate(previous, merged)) { + continue; + } + yield* Queue.offer(eventQueue, { + _tag: "ToolCallUpdated", + toolCall: merged, + rawPayload: event.rawPayload, + }); + continue; + } + if (event._tag === "ContentDelta") { + if (event.text.trim().length === 0) { + const seg = yield* Ref.get(assistantSegmentRef); + if (!seg.activeItemId) { + continue; + } + } + const itemId = yield* ensureActiveAssistantSegment(notification.sessionId); + yield* Queue.offer(eventQueue, { ...event, itemId }); + continue; + } + yield* Queue.offer(eventQueue, event); + } + }); + + const ensureActiveAssistantSegment = (sessionId: string) => + Ref.modify( + assistantSegmentRef, + (current) => { + if (current.activeItemId) { + return [{ itemId: current.activeItemId }, current] as const; + } + const itemId = `assistant:${sessionId}:segment:${current.nextSegmentIndex}`; + return [ + { + itemId, + startedEvent: { + _tag: "AssistantItemStarted", + itemId, + }, + }, + { + nextSegmentIndex: current.nextSegmentIndex + 1, + activeItemId: itemId, + }, + ] as const; + }, + ).pipe( + Effect.flatMap((result) => + result.startedEvent + ? Queue.offer(eventQueue, result.startedEvent).pipe(Effect.as(result.itemId)) + : Effect.succeed(result.itemId), + ), + ); + + const closeActiveAssistantSegment = () => + Ref.modify(assistantSegmentRef, (current) => { + if (!current.activeItemId) { + return [undefined, current] as const; + } + return [ + { + _tag: "AssistantItemCompleted", + itemId: current.activeItemId, + } satisfies AcpParsedSessionEvent, + { nextSegmentIndex: current.nextSegmentIndex } satisfies AssistantSegmentState, + ] as const; + }).pipe(Effect.flatMap((event) => (event ? Queue.offer(eventQueue, event) : Effect.void))); + + const session = yield* options.connection.newSession({ + cwd: options.cwd, + ...(options.mcpServers ? { mcpServers: options.mcpServers } : {}), + ...(options.resumeSessionId ? { resumeSessionId: options.resumeSessionId } : {}), + handlers: { + onSessionUpdate, + ...(options.handlers.onRequestPermission + ? { onRequestPermission: options.handlers.onRequestPermission } + : {}), + ...(options.handlers.onElicitation + ? { onElicitation: options.handlers.onElicitation } + : {}), + ...(options.handlers.onReadTextFile + ? { onReadTextFile: options.handlers.onReadTextFile } + : {}), + ...(options.handlers.onWriteTextFile + ? { onWriteTextFile: options.handlers.onWriteTextFile } + : {}), + }, + }); + + yield* Ref.set(modeStateRef, parseSessionModeState(session.sessionSetupResult)); + yield* Ref.set( + configOptionsRef, + session.sessionSetupResult.configOptions ?? [], + ); + + const setupResult: AcpMultiSessionStartResult = { + sessionId: session.sessionId, + sessionSetupResult: session.sessionSetupResult, + modelConfigId: extractModelConfigId(session.sessionSetupResult), + }; + + const validateConfigOptionValue = ( + configId: string, + value: string | boolean, + ): Effect.Effect => + Effect.gen(function* () { + const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId); + if (!configOption) return; + if (configOption.type === "boolean") { + if (typeof value === "boolean") return; + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected boolean`, + data: { configId: configOption.id, expectedType: "boolean", receivedValue: value }, + }); + } + if (typeof value !== "string") { + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected string`, + data: { configId: configOption.id, expectedType: "string", receivedValue: value }, + }); + } + const allowedValues = collectSessionConfigOptionValues(configOption); + if (allowedValues.includes(value)) return; + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`, + data: { configId: configOption.id, allowedValues, receivedValue: value }, + }); + }); + + const setConfigOption = ( + configId: string, + value: string | boolean, + ): Effect.Effect< + EffectAcpSchema.SetSessionConfigOptionResponse, + EffectAcpErrors.AcpError + > => + validateConfigOptionValue(configId, value).pipe( + Effect.flatMap(() => Ref.get(configOptionsRef)), + Effect.flatMap((configOptions) => { + const existing = findSessionConfigOption(configOptions, configId); + if (existing && configOptionCurrentValueMatches(existing, value)) { + return Effect.succeed({ + configOptions, + } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); + } + const payload = + typeof value === "boolean" + ? ({ + sessionId: setupResult.sessionId, + configId, + type: "boolean", + value, + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) + : ({ + sessionId: setupResult.sessionId, + configId, + value: String(value), + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); + return options.connection.setSessionConfigOption(payload).pipe( + Effect.tap((response) => + Ref.set(configOptionsRef, response.configOptions ?? configOptions), + ), + ); + }), + ); + + const updateCurrentModeId = (modeId: string) => + Ref.update(modeStateRef, (current) => + current ? { ...current, currentModeId: modeId } : current, + ); + + return { + sessionId: setupResult.sessionId, + setupResult, + getEvents: () => Stream.fromQueue(eventQueue), + getConfigOptions: Ref.get(configOptionsRef), + getModeState: Ref.get(modeStateRef), + setModel: (model) => + setConfigOption(setupResult.modelConfigId ?? "model", model).pipe(Effect.asVoid), + setMode: (modeId) => + Ref.get(modeStateRef).pipe( + Effect.flatMap((modeState) => { + if (modeState?.currentModeId === modeId) { + return Effect.succeed({} satisfies EffectAcpSchema.SetSessionModeResponse); + } + return setConfigOption("mode", modeId).pipe( + Effect.tap(() => updateCurrentModeId(modeId)), + Effect.as({} satisfies EffectAcpSchema.SetSessionModeResponse), + ); + }), + ), + setConfigOption, + prompt: (payload) => + closeActiveAssistantSegment().pipe( + Effect.andThen( + options.connection.prompt({ + sessionId: setupResult.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest), + ), + Effect.tap(() => closeActiveAssistantSegment()), + ), + cancel: options.connection.cancel({ sessionId: setupResult.sessionId }), + } satisfies AcpMultiSessionShape; + }); + +function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState { + const normalized = nextModeId.trim(); + if (!normalized) return modeState; + return modeState.availableModes.some((mode) => mode.id === normalized) + ? { ...modeState, currentModeId: normalized } + : modeState; +} + +function shouldEmitToolCallUpdate( + previous: AcpToolCallState | undefined, + next: AcpToolCallState, +): boolean { + if (next.status === "completed" || next.status === "failed") return true; + if (!next.detail) return false; + return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; +} + +function configOptionCurrentValueMatches( + configOption: EffectAcpSchema.SessionConfigOption, + value: string | boolean, +): boolean { + const currentValue = configOption.currentValue; + if (configOption.type === "boolean") return currentValue === value; + if (typeof currentValue !== "string") return false; + return currentValue.trim() === String(value).trim(); +} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 8652b2cfeaf..f725dae38e9 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -46,7 +46,12 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + /** + * ACP `authenticate` method id. Omit for agents that don't advertise an + * auth method — the `authenticate` step is skipped entirely, matching the + * ACP spec (authenticate is only required when the agent declares it). + */ + readonly authMethodId?: string; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; @@ -93,6 +98,8 @@ export interface AcpSessionRuntimeShape { readonly getEvents: () => Stream.Stream; readonly getModeState: Effect.Effect; readonly getConfigOptions: Effect.Effect>; + readonly getAuthMethods: Effect.Effect>; + readonly getAvailableModes: Effect.Effect>; readonly prompt: ( payload: Omit, ) => Effect.Effect; @@ -113,6 +120,7 @@ export interface AcpSessionRuntimeShape { method: string, payload: unknown, ) => Effect.Effect; + readonly authenticate: (methodId: string) => Effect.Effect; } interface AcpStartedState extends AcpSessionRuntimeStartResult {} @@ -164,6 +172,8 @@ const makeAcpSessionRuntime = ( const toolCallsRef = yield* Ref.make(new Map()); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); + const authMethodsRef = yield* Ref.make>([]); + const availableModesRef = yield* Ref.make>([]); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); const logRequest = (event: AcpSessionRequestLogEvent) => @@ -378,15 +388,19 @@ const makeAcpSessionRuntime = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + yield* Ref.set(authMethodsRef, initializeResult.authMethods ?? []); - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + if (options.authMethodId) { + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; + + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: @@ -436,6 +450,7 @@ const makeAcpSessionRuntime = ( yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + yield* Ref.set(availableModesRef, sessionSetupResult.modes?.availableModes ?? []); const nextState = { sessionId, @@ -549,6 +564,14 @@ const makeAcpSessionRuntime = ( request: (method, payload) => runLoggedRequest(method, payload, acp.raw.request(method, payload)), notify: acp.raw.notify, + getAuthMethods: Ref.get(authMethodsRef), + getAvailableModes: Ref.get(availableModesRef), + authenticate: (methodId) => + getStartedState.pipe( + Effect.flatMap(() => + runLoggedRequest("authenticate", { methodId }, acp.agent.authenticate({ methodId })), + ), + ), } satisfies AcpSessionRuntimeShape; }); diff --git a/apps/server/src/provider/acp/configOptionModels.ts b/apps/server/src/provider/acp/configOptionModels.ts new file mode 100644 index 00000000000..dea630286d0 --- /dev/null +++ b/apps/server/src/provider/acp/configOptionModels.ts @@ -0,0 +1,99 @@ +import type { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { createModelCapabilities } from "@t3tools/shared/model"; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +interface AcpRegistrySessionSelectOption { + readonly value: string; + readonly name: string; +} + +function flattenSessionConfigSelectOptions( + configOption: EffectAcpSchema.SessionConfigOption | undefined, +): ReadonlyArray { + if (!configOption || configOption.type !== "select") { + return []; + } + return configOption.options.flatMap((entry) => + "value" in entry + ? [ + { + value: entry.value.trim(), + name: entry.name.trim(), + } satisfies AcpRegistrySessionSelectOption, + ] + : entry.options.map( + (option) => + ({ + value: option.value.trim(), + name: option.name.trim(), + }) satisfies AcpRegistrySessionSelectOption, + ), + ); +} + +export function buildModelsFromAcpConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ReadonlyArray { + // ACP spec: `category` is OPTIONAL. Some agents (Junie) omit it and only set `id: "model"`. + // Per https://agentclientprotocol.com/protocol/schema#sessionconfigoptioncategory — clients + // MUST handle missing/unknown categories gracefully. Match either signal. + const modelOption = configOptions?.find( + (option) => option.category === "model" || option.id === "model", + ); + const modelChoices = flattenSessionConfigSelectOptions(modelOption); + const seen = new Set(); + return modelChoices.flatMap((choice) => { + if (!choice.value || seen.has(choice.value)) { + return []; + } + seen.add(choice.value); + return [ + { + slug: choice.value, + name: choice.name || choice.value, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + } satisfies ServerProviderModel, + ]; + }); +} + +export function buildModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState?.availableModels?.length) { + return []; + } + const seen = new Set(); + return modelState.availableModels.flatMap((model) => { + const slug = model.modelId.trim(); + if (!slug || seen.has(slug)) { + return []; + } + seen.add(slug); + return [ + { + slug, + name: model.name?.trim() || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + } satisfies ServerProviderModel, + ]; + }); +} + +export function buildModelsFromSessionSetup(setup: { + readonly models?: EffectAcpSchema.SessionModelState | null; + readonly configOptions?: ReadonlyArray | null; +}): ReadonlyArray { + const fromConfigOptions = buildModelsFromAcpConfigOptions(setup.configOptions); + if (fromConfigOptions.length > 0) { + return fromConfigOptions; + } + return buildModelsFromSessionModelState(setup.models); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 5af56dc6b0e..ad78a64bcab 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,6 +20,9 @@ * * @module provider/builtInDrivers */ +import { ACP_REGISTRY } from "@t3tools/contracts"; + +import { type AcpRegistryDriverEnv, makeAcpRegistryDriver } from "./Drivers/AcpRegistryDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; @@ -35,7 +38,16 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | AcpRegistryDriverEnv; + +/** + * One generic driver per bundled ACP registry entry. The driver factory is + * data-driven — adding agents to the registry snapshot grows this list + * without new code. + */ +const ACP_REGISTRY_DRIVERS: ReadonlyArray> = + ACP_REGISTRY.map(makeAcpRegistryDriver); /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -47,4 +59,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray Effect.succeed([]), + install: (agentId) => + Effect.fail( + new AcpRegistryError({ + agentId, + detail: "ACP registry install is disabled in tests.", + }), + ), + uninstall: () => Effect.void, + }), + ), Layer.provide( Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ read: Effect.succeed({ diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 7af27f0b7cf..d5bf985c1e3 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -476,6 +476,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { providerInstances: { [instanceId]: { driver: ProviderDriverKind.make("codex"), + enabled: true, environment: [ { name: "OPENROUTER_API_KEY", value: "sk-or-secret", sensitive: true }, { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, @@ -512,6 +513,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { providerInstances: { [instanceId]: { driver: ProviderDriverKind.make("codex"), + enabled: true, displayName: "Codex Personal", environment: [ { name: "OPENROUTER_API_KEY", value: "", sensitive: true, valueRedacted: true }, diff --git a/apps/server/src/textGeneration/AcpRegistryTextGeneration.ts b/apps/server/src/textGeneration/AcpRegistryTextGeneration.ts new file mode 100644 index 00000000000..37b036f7df5 --- /dev/null +++ b/apps/server/src/textGeneration/AcpRegistryTextGeneration.ts @@ -0,0 +1,22 @@ +import { TextGenerationError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import type { TextGenerationShape } from "./TextGeneration.ts"; + +// Registry agents are conversation-only in v1 — commit-message / PR / branch / +// title generation stays on the first-party providers. Every method fails +// with a clear error so callers fall back rather than hang. +const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Text generation is not supported for ACP registry agents.", + }), + ); + +export const makeAcpRegistryTextGeneration = (): TextGenerationShape => ({ + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f87bff7b975..54570f04a4e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -37,6 +37,7 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { AcpRegistryService, layer as AcpRegistryLive } from "./acpRegistry/AcpRegistryService.ts"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.ts"; import { Keybindings } from "./keybindings.ts"; @@ -193,6 +194,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const bootstrapCredentials = yield* BootstrapCredentialService; const sessions = yield* SessionCredentialService; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const acpRegistry = yield* AcpRegistryService; const serverCommandId = (tag: string) => CommandId.make(`server:${tag}:${crypto.randomUUID()}`); @@ -913,6 +915,30 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", }), + [WS_METHODS.acpRegistryList]: (_input) => + observeRpcEffect(WS_METHODS.acpRegistryList, acpRegistry.list(), { + "rpc.aggregate": "acp-registry", + }), + [WS_METHODS.acpRegistryInstall]: ({ agentId }) => + observeRpcEffect(WS_METHODS.acpRegistryInstall, acpRegistry.install(agentId), { + "rpc.aggregate": "acp-registry", + }), + [WS_METHODS.acpRegistryUninstall]: ({ agentId }) => + observeRpcEffect( + WS_METHODS.acpRegistryUninstall, + acpRegistry.uninstall(agentId).pipe(Effect.as({ agentId })), + { + "rpc.aggregate": "acp-registry", + }, + ), + [WS_METHODS.acpRegistryAuthenticate]: ({ instanceId, methodId }) => + observeRpcEffect( + WS_METHODS.acpRegistryAuthenticate, + acpRegistry.authenticate(instanceId, methodId).pipe(Effect.as({})), + { + "rpc.aggregate": "acp-registry", + }, + ), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, @@ -1245,6 +1271,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.provide( makeWsRpcLayer(session.sessionId).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(AcpRegistryLive), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( SourceControlDiscoveryLayer.layer.pipe( diff --git a/apps/web/public/acp-icons/agoragentic-acp.svg b/apps/web/public/acp-icons/agoragentic-acp.svg new file mode 100644 index 00000000000..b1372e68351 --- /dev/null +++ b/apps/web/public/acp-icons/agoragentic-acp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/acp-icons/amp-acp.svg b/apps/web/public/acp-icons/amp-acp.svg new file mode 100644 index 00000000000..314881aff83 --- /dev/null +++ b/apps/web/public/acp-icons/amp-acp.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/public/acp-icons/auggie.svg b/apps/web/public/acp-icons/auggie.svg new file mode 100644 index 00000000000..215107744a7 --- /dev/null +++ b/apps/web/public/acp-icons/auggie.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/public/acp-icons/autohand.svg b/apps/web/public/acp-icons/autohand.svg new file mode 100644 index 00000000000..f3bc983c4d9 --- /dev/null +++ b/apps/web/public/acp-icons/autohand.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/acp-icons/claude-acp.svg b/apps/web/public/acp-icons/claude-acp.svg new file mode 100644 index 00000000000..98dd82db1b3 --- /dev/null +++ b/apps/web/public/acp-icons/claude-acp.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/cline.svg b/apps/web/public/acp-icons/cline.svg new file mode 100644 index 00000000000..aeeafbc61e7 --- /dev/null +++ b/apps/web/public/acp-icons/cline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/public/acp-icons/codebuddy-code.svg b/apps/web/public/acp-icons/codebuddy-code.svg new file mode 100644 index 00000000000..735fd352aac --- /dev/null +++ b/apps/web/public/acp-icons/codebuddy-code.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/acp-icons/codex-acp.svg b/apps/web/public/acp-icons/codex-acp.svg new file mode 100644 index 00000000000..42c78a06cc4 --- /dev/null +++ b/apps/web/public/acp-icons/codex-acp.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/cortex-code.svg b/apps/web/public/acp-icons/cortex-code.svg new file mode 100644 index 00000000000..28f87a258e0 --- /dev/null +++ b/apps/web/public/acp-icons/cortex-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/corust-agent.svg b/apps/web/public/acp-icons/corust-agent.svg new file mode 100644 index 00000000000..9f30636cb00 --- /dev/null +++ b/apps/web/public/acp-icons/corust-agent.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/web/public/acp-icons/crow-cli.svg b/apps/web/public/acp-icons/crow-cli.svg new file mode 100644 index 00000000000..1169116cd9e --- /dev/null +++ b/apps/web/public/acp-icons/crow-cli.svg @@ -0,0 +1,9 @@ + + + diff --git a/apps/web/public/acp-icons/cursor.svg b/apps/web/public/acp-icons/cursor.svg new file mode 100644 index 00000000000..4ca0c2501bd --- /dev/null +++ b/apps/web/public/acp-icons/cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/deepagents.svg b/apps/web/public/acp-icons/deepagents.svg new file mode 100644 index 00000000000..abd818ec47a --- /dev/null +++ b/apps/web/public/acp-icons/deepagents.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/public/acp-icons/dimcode.svg b/apps/web/public/acp-icons/dimcode.svg new file mode 100644 index 00000000000..1fa31ce884b --- /dev/null +++ b/apps/web/public/acp-icons/dimcode.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/dirac.svg b/apps/web/public/acp-icons/dirac.svg new file mode 100644 index 00000000000..4fbb06ceeaa --- /dev/null +++ b/apps/web/public/acp-icons/dirac.svg @@ -0,0 +1,6 @@ + + δ + + + + diff --git a/apps/web/public/acp-icons/factory-droid.svg b/apps/web/public/acp-icons/factory-droid.svg new file mode 100644 index 00000000000..5c6fb8d1ff0 --- /dev/null +++ b/apps/web/public/acp-icons/factory-droid.svg @@ -0,0 +1 @@ + diff --git a/apps/web/public/acp-icons/fast-agent.svg b/apps/web/public/acp-icons/fast-agent.svg new file mode 100644 index 00000000000..a07fab2886c --- /dev/null +++ b/apps/web/public/acp-icons/fast-agent.svg @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/acp-icons/gemini.svg b/apps/web/public/acp-icons/gemini.svg new file mode 100644 index 00000000000..588d89c52ab --- /dev/null +++ b/apps/web/public/acp-icons/gemini.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/github-copilot-cli.svg b/apps/web/public/acp-icons/github-copilot-cli.svg new file mode 100644 index 00000000000..626d33badc4 --- /dev/null +++ b/apps/web/public/acp-icons/github-copilot-cli.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/acp-icons/glm-acp-agent.svg b/apps/web/public/acp-icons/glm-acp-agent.svg new file mode 100644 index 00000000000..d552d2a3d08 --- /dev/null +++ b/apps/web/public/acp-icons/glm-acp-agent.svg @@ -0,0 +1 @@ +Z.ai diff --git a/apps/web/public/acp-icons/goose.svg b/apps/web/public/acp-icons/goose.svg new file mode 100644 index 00000000000..c4928854263 --- /dev/null +++ b/apps/web/public/acp-icons/goose.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/junie.svg b/apps/web/public/acp-icons/junie.svg new file mode 100644 index 00000000000..63b60e8f3a9 --- /dev/null +++ b/apps/web/public/acp-icons/junie.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/web/public/acp-icons/kilo.svg b/apps/web/public/acp-icons/kilo.svg new file mode 100644 index 00000000000..8af6e96f34d --- /dev/null +++ b/apps/web/public/acp-icons/kilo.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/kimi.svg b/apps/web/public/acp-icons/kimi.svg new file mode 100644 index 00000000000..4f7547cf79f --- /dev/null +++ b/apps/web/public/acp-icons/kimi.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/minion-code.svg b/apps/web/public/acp-icons/minion-code.svg new file mode 100644 index 00000000000..eb3d8eb31d7 --- /dev/null +++ b/apps/web/public/acp-icons/minion-code.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/acp-icons/mistral-vibe.svg b/apps/web/public/acp-icons/mistral-vibe.svg new file mode 100644 index 00000000000..b13631b96d9 --- /dev/null +++ b/apps/web/public/acp-icons/mistral-vibe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/public/acp-icons/nova.svg b/apps/web/public/acp-icons/nova.svg new file mode 100644 index 00000000000..5e19f588792 --- /dev/null +++ b/apps/web/public/acp-icons/nova.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/web/public/acp-icons/opencode.svg b/apps/web/public/acp-icons/opencode.svg new file mode 100644 index 00000000000..a38d4cf5a96 --- /dev/null +++ b/apps/web/public/acp-icons/opencode.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/pi-acp.svg b/apps/web/public/acp-icons/pi-acp.svg new file mode 100644 index 00000000000..68ea8fd7f71 --- /dev/null +++ b/apps/web/public/acp-icons/pi-acp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/public/acp-icons/poolside.svg b/apps/web/public/acp-icons/poolside.svg new file mode 100644 index 00000000000..91de4c46d40 --- /dev/null +++ b/apps/web/public/acp-icons/poolside.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/qoder.svg b/apps/web/public/acp-icons/qoder.svg new file mode 100644 index 00000000000..417d83693dd --- /dev/null +++ b/apps/web/public/acp-icons/qoder.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/qwen-code.svg b/apps/web/public/acp-icons/qwen-code.svg new file mode 100644 index 00000000000..78f88f2831c --- /dev/null +++ b/apps/web/public/acp-icons/qwen-code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/acp-icons/sigit.svg b/apps/web/public/acp-icons/sigit.svg new file mode 100644 index 00000000000..334fc95cbab --- /dev/null +++ b/apps/web/public/acp-icons/sigit.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + diff --git a/apps/web/public/acp-icons/stakpak.svg b/apps/web/public/acp-icons/stakpak.svg new file mode 100644 index 00000000000..64425076ed1 --- /dev/null +++ b/apps/web/public/acp-icons/stakpak.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/vtcode.svg b/apps/web/public/acp-icons/vtcode.svg new file mode 100644 index 00000000000..b47c7b11923 --- /dev/null +++ b/apps/web/public/acp-icons/vtcode.svg @@ -0,0 +1,4 @@ + + + VT + \ No newline at end of file diff --git a/apps/web/src/components/AcpRegistryIcon.tsx b/apps/web/src/components/AcpRegistryIcon.tsx new file mode 100644 index 00000000000..b8ae0613215 --- /dev/null +++ b/apps/web/src/components/AcpRegistryIcon.tsx @@ -0,0 +1,65 @@ +import type { CSSProperties } from "react"; +import { useState } from "react"; + +import { cn } from "../lib/utils"; + +interface AcpRegistryIconProps { + agentId: string; + className?: string; +} + +export function AcpRegistryIcon({ agentId, className }: AcpRegistryIconProps) { + const [loadError, setLoadError] = useState(false); + + const initials = agentId + .split(/[-_]/) + .map((part) => part[0]?.toUpperCase()) + .join("") + .slice(0, 2); + + if (loadError) { + return ( + + {initials} + + ); + } + + // CSS masks let bundled monochrome SVGs inherit currentColor. + const maskUrl = `url("/acp-icons/${agentId}.svg")`; + const style: CSSProperties = { + maskImage: maskUrl, + WebkitMaskImage: maskUrl, + maskRepeat: "no-repeat", + WebkitMaskRepeat: "no-repeat", + maskPosition: "center", + WebkitMaskPosition: "center", + maskSize: "contain", + WebkitMaskSize: "contain", + }; + + return ( + <> + setLoadError(true)} + style={{ display: "none" }} + /> + + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 96fc34c1013..bcdf5ec9b71 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -61,6 +61,8 @@ import { } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ProviderModelPicker } from "./ProviderModelPicker"; +import { InlineModelPickerContent } from "./InlineModelPickerContent"; +import { AnimatedHeight } from "../AnimatedHeight"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; @@ -1945,6 +1947,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) className="mx-auto w-full min-w-0 max-w-208" data-chat-composer-form="true" > + + {isComposerModelPickerOpen ? ( +
+ setIsComposerModelPickerOpen(false)} + onInstanceModelChange={(instanceId, model) => { + onProviderModelSelect(instanceId, model); + setIsComposerModelPickerOpen(false); + }} + /> +
+ ) : null} +
{ setIsComposerModelPickerOpen(open); }} - onInstanceModelChange={onProviderModelSelect} /> {isComposerFooterCompact ? ( diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/InlineModelPickerContent.tsx similarity index 62% rename from apps/web/src/components/chat/ModelPickerContent.tsx rename to apps/web/src/components/chat/InlineModelPickerContent.tsx index c3468ef8c65..34390bfb9fa 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/InlineModelPickerContent.tsx @@ -7,7 +7,7 @@ import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { SearchIcon } from "lucide-react"; import { ModelListRow } from "./ModelListRow"; -import { ModelPickerSidebar } from "./ModelPickerSidebar"; +import { InlineProviderTileRow } from "./InlineProviderTileRow"; import { isModelPickerNewModel } from "./modelPickerModelHighlights"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox"; @@ -38,9 +38,6 @@ type ModelPickerItem = { const EMPTY_MODEL_JUMP_LABELS = new Map(); -// Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs -// can contain colons (e.g. some vendor model ids), so we only split on the -// first colon — anything after that is the slug. function splitInstanceModelKey(key: string): { instanceId: ProviderInstanceId; slug: string } { const colonIndex = key.indexOf(":"); if (colonIndex === -1) { @@ -52,32 +49,14 @@ function splitInstanceModelKey(key: string): { instanceId: ProviderInstanceId; s }; } -export const ModelPickerContent = memo(function ModelPickerContent(props: { +export const InlineModelPickerContent = memo(function InlineModelPickerContent(props: { /** The instance currently selected in the composer (combobox "value"). */ activeInstanceId: ProviderInstanceId; model: string; - /** - * When set, the picker is locked to the given driver kind — typically - * because the user is editing a previously-sent message and can't change - * which driver served the turn. Multiple instances of the same kind - * remain selectable (e.g. locked to `codex` still lets the user switch - * between the default Codex and a custom Codex Personal). - */ lockedProvider: ProviderDriverKind | null; lockedContinuationGroupKey?: string | null; - /** - * All configured provider instances in display order. Used to render - * the sidebar (one button per instance) and to resolve display names - * for the locked-mode header. - */ instanceEntries: ReadonlyArray; keybindings?: ResolvedKeybindingsConfig; - /** - * Model options per instance. Keyed by `ProviderInstanceId` so the - * default Codex instance and any custom Codex instances each have their - * own list (custom instances typically start with the same built-in - * model set but are free to diverge via customModels). - */ modelOptionsByInstance: ReadonlyMap>; terminalOpen: boolean; onRequestClose?: () => void; @@ -94,15 +73,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const listRegionRef = useRef(null); const highlightedModelKeyRef = useRef(null); const favorites = useSettings((s) => s.favorites ?? []); + // Default to the currently-active provider expanded — not favorites — so + // opening the picker shows models for the provider already in use. const [selectedInstanceId, setSelectedInstanceId] = useState( - () => { - if (props.lockedProvider !== null) { - // When locked, prime the sidebar to the currently-active instance - // so jumping into the picker keeps the focused instance visible. - return props.activeInstanceId; - } - return favorites.length > 0 ? "favorites" : props.activeInstanceId; - }, + () => props.activeInstanceId, ); const keybindings = useMemo( () => providedKeybindings ?? [], @@ -138,20 +112,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; }, [focusSearchInput]); - // Create a Set for efficient lookup. Favorites are keyed by - // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind - // to ProviderInstanceId so pre-migration favorites keyed by driver slugs - // (e.g. `"codex:gpt-5"`) still resolve — the default instance id equals - // the driver slug. const favoritesSet = useMemo(() => { return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model))); }, [favorites]); - /** - * Lookup table keyed by `instanceId`. Used for display name + driver - * kind enrichment and for `ready`/enabled filtering before flattening - * models into the search list. - */ const entryByInstanceId = useMemo( () => new Map(instanceEntries.map((entry) => [entry.instanceId, entry])), [instanceEntries], @@ -169,29 +133,19 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const readyInstanceSet = useMemo(() => { const ready = new Set(); for (const entry of instanceEntries) { - if (entry.status === "ready") { + if (entry.status === "ready" || (entry.status === "warning" && entry.models.length > 0)) { ready.add(entry.instanceId); } } return ready; }, [instanceEntries]); - // Flatten models into a searchable array. One pass over the - // instance-keyed map; each model carries its instance id + driver kind - // so the list row can render the right icon and display name without - // another lookup. const flatModels = useMemo(() => { const out: ModelPickerItem[] = []; for (const [instanceId, models] of modelOptionsByInstance) { const entry = entryByInstanceId.get(instanceId); - if (!entry) { - // Instance disappeared between renders (configuration change). Skip - // its models — stale options shouldn't appear in the picker. - continue; - } - if (!readyInstanceSet.has(instanceId)) { - continue; - } + if (!entry) continue; + if (!readyInstanceSet.has(instanceId)) continue; for (const model of models) { out.push({ slug: model.slug, @@ -218,9 +172,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { props.lockedProvider ? instanceEntries.filter((entry) => matchesLockedProvider(entry)) : [], [instanceEntries, matchesLockedProvider, props.lockedProvider], ); - const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1; - const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar); - const sidebarInstanceEntries = showLockedInstanceSidebar + const showLockedInstanceTileRow = isLocked && lockedInstanceEntries.length > 1; + const showTileRow = !isSearching && (!isLocked || showLockedInstanceTileRow); + const tileRowInstanceEntries = showLockedInstanceTileRow ? lockedInstanceEntries : instanceEntries; const instanceOrder = useMemo( @@ -228,11 +182,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [instanceEntries], ); - // Filter models based on search query and selected instance const filteredModels = useMemo(() => { let result = flatModels; - // Apply tokenized fuzzy search across the combined provider/model search fields. if (searchQuery.trim()) { const rankedMatches = result .map((model) => ({ @@ -268,20 +220,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } => rankedModel.score !== null, ); - // When searching, we only respect locked provider (by driver kind), - // ignoring sidebar selection so account-scoped searches can find a - // model before the user chooses a specific instance rail item. if (props.lockedProvider !== null) { return rankedMatches .filter((rankedModel) => matchesLockedProvider(rankedModel.model)) .toSorted((a, b) => { const scoreDelta = a.score - b.score; - if (scoreDelta !== 0) { - return scoreDelta; - } - if (a.isFavorite !== b.isFavorite) { - return a.isFavorite ? -1 : 1; - } + if (scoreDelta !== 0) return scoreDelta; + if (a.isFavorite !== b.isFavorite) return a.isFavorite ? -1 : 1; return a.tieBreaker.localeCompare(b.tieBreaker); }) .map((rankedModel) => rankedModel.model); @@ -290,12 +235,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return rankedMatches .toSorted((a, b) => { const scoreDelta = a.score - b.score; - if (scoreDelta !== 0) { - return scoreDelta; - } - if (a.isFavorite !== b.isFavorite) { - return a.isFavorite ? -1 : 1; - } + if (scoreDelta !== 0) return scoreDelta; + if (a.isFavorite !== b.isFavorite) return a.isFavorite ? -1 : 1; return a.tieBreaker.localeCompare(b.tieBreaker); }) .map((rankedModel) => rankedModel.model); @@ -303,7 +244,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (props.lockedProvider !== null) { result = result.filter((m) => matchesLockedProvider(m)); - if (showLockedInstanceSidebar) { + if (showLockedInstanceTileRow) { result = result.filter((m) => m.instanceId === selectedInstanceId); } } else if (selectedInstanceId === "favorites") { @@ -324,23 +265,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { matchesLockedProvider, props.lockedProvider, searchQuery, - showLockedInstanceSidebar, + showLockedInstanceTileRow, selectedInstanceId, ]); const handleModelSelect = useCallback( (modelSlug: string, instanceId: ProviderInstanceId) => { const options = modelOptionsByInstance.get(instanceId); - if (!options) { - return; - } + if (!options) return; const entry = entryByInstanceId.get(instanceId); - if (!entry) { - return; - } - // `resolveSelectableModel` uses the driver kind for normalization - // (slug casing etc.). Custom instances share their driver's - // normalization rules, so pass the driver kind here. + if (!entry) return; const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options); if (resolvedModel) { onInstanceModelChange(instanceId, resolvedModel); @@ -365,10 +299,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const LockedProviderIcon = isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; - // Header label for locked mode. Use the active instance's displayName - // when the lock narrows to exactly one instance (so "Codex Personal" - // shows instead of the generic driver label); fall back to the first - // matching entry otherwise. const lockedHeaderLabel = useMemo(() => { if (!isLocked || !props.lockedProvider) return null; const matches = instanceEntries.filter((entry) => matchesLockedProvider(entry)); @@ -442,31 +372,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || event.repeat) { - return; - } - + if (event.defaultPrevented || event.repeat) return; const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, context: modelJumpShortcutContext, }); const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - + if (jumpIndex === null) return; const targetModelKey = modelJumpModelKeys[jumpIndex]; - if (!targetModelKey) { - return; - } + if (!targetModelKey) return; const { instanceId, slug } = splitInstanceModelKey(targetModelKey); event.preventDefault(); event.stopPropagation(); handleModelSelect(slug, instanceId); }; - window.addEventListener("keydown", onWindowKeyDown, true); - return () => { window.removeEventListener("keydown", onWindowKeyDown, true); }; @@ -474,9 +394,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { useLayoutEffect(() => { const listRegion = listRegionRef.current; - if (!listRegion) { - return; - } + if (!listRegion) return; let cancelled = false; let frame = 0; @@ -484,18 +402,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { let timeout = 0; const measureScrollArea = () => { - if (cancelled) { - return; - } + if (cancelled) return; const viewport = listRegion.querySelector('[data-slot="scroll-area-viewport"]'); - if (!viewport || viewport.scrollHeight <= viewport.clientHeight) { - return; - } + if (!viewport || viewport.scrollHeight <= viewport.clientHeight) return; const originalScrollTop = viewport.scrollTop; const maxScrollTop = viewport.scrollHeight - viewport.clientHeight; - if (maxScrollTop <= 0) { - return; - } + if (maxScrollTop <= 0) return; viewport.scrollTop = Math.min(originalScrollTop + 1, maxScrollTop); viewport.scrollTop = originalScrollTop; }; @@ -518,31 +430,19 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
- {/* Locked provider header (only shown in locked mode) */} - {isLocked && !showLockedInstanceSidebar && LockedProviderIcon && lockedHeaderLabel && ( -
+ {/* Locked provider header (only shown in locked mode without instance tile row) */} + {isLocked && !showLockedInstanceTileRow && LockedProviderIcon && lockedHeaderLabel && ( +
- {lockedHeaderLabel} + {lockedHeaderLabel}
)} - {/* Sidebar (only in unlocked mode) */} - {showSidebar && ( - - )} - - {/* Main content area */} { - if (typeof modelKey !== "string") { - return; - } + if (typeof modelKey !== "string") return; const { instanceId, slug } = splitInstanceModelKey(modelKey); handleModelSelect(slug, instanceId); }} > -
- {/* Search bar */} -
- } - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - props.onRequestClose?.(); - return; - } - if (e.key === "Enter" && highlightedModelKeyRef.current) { - ( - e as typeof e & { preventBaseUIHandler?: () => void } - ).preventBaseUIHandler?.(); - e.preventDefault(); - e.stopPropagation(); - const { instanceId, slug } = splitInstanceModelKey( - highlightedModelKeyRef.current, - ); - handleModelSelect(slug, instanceId); - return; - } + {/* Search bar */} +
+ } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); e.stopPropagation(); - }} - onMouseDown={(e) => e.stopPropagation()} - onTouchStart={(e) => e.stopPropagation()} - size="sm" - /> -
- - {/* Model list */} -
- - {filteredModelKeys.map((modelKey, index) => { - const model = filteredModelByKey.get(modelKey); - if (!model) { - return null; - } - return ( - toggleFavorite(model.instanceId, model.slug)} - /> + props.onRequestClose?.(); + return; + } + if (e.key === "Enter" && highlightedModelKeyRef.current) { + (e as typeof e & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + e.preventDefault(); + e.stopPropagation(); + const { instanceId, slug } = splitInstanceModelKey( + highlightedModelKeyRef.current, ); - })} - -
- - No models found - + handleModelSelect(slug, instanceId); + return; + } + e.stopPropagation(); + }} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + size="sm" + /> +
+ + {/* Provider tile row (horizontal, replaces the popover sidebar) */} + {showTileRow && ( + + )} + + {/* Model list */} +
+ + {filteredModelKeys.map((modelKey, index) => { + const model = filteredModelByKey.get(modelKey); + if (!model) return null; + return ( + toggleFavorite(model.instanceId, model.slug)} + /> + ); + })} +
+ + No models found +
diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/InlineProviderTileRow.tsx similarity index 52% rename from apps/web/src/components/chat/ModelPickerSidebar.tsx rename to apps/web/src/components/chat/InlineProviderTileRow.tsx index 121b5267a3d..cfa882653be 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/InlineProviderTileRow.tsx @@ -1,18 +1,11 @@ import { type ProviderInstanceId } from "@t3tools/contracts"; import { memo, useMemo } from "react"; -import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; -import { Gemini, GithubCopilotIcon } from "../Icons"; +import { SparklesIcon, StarIcon } from "lucide-react"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; -import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; import type { ProviderInstanceEntry } from "../../providerInstances"; -/** - * Build the hover tooltip for an instance button. Mirrors the old - * kind-based copy but uses the entry's configured `displayName` so custom - * instances get their user-authored name (e.g. "Codex Personal — Unavailable."). - */ function describeUnavailableInstance(entry: ProviderInstanceEntry): string { const label = entry.displayName; if (entry.status === "ready") { @@ -30,32 +23,37 @@ function describeUnavailableInstance(entry: ProviderInstanceEntry): string { return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; } +function hasSelectableModels(entry: ProviderInstanceEntry): boolean { + return entry.status === "ready" || (entry.status === "warning" && entry.models.length > 0); +} + const SELECTED_BUTTON_CLASS = "bg-background text-foreground shadow-sm"; const SELECTED_INDICATOR_CLASS = - "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.5 -translate-y-1/2 rounded-l-full bg-primary"; + "pointer-events-none absolute -bottom-1 left-1/2 z-10 h-0.5 w-5 -translate-x-1/2 rounded-t-full bg-primary"; const BADGE_BASE_CLASS = "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm "; const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; -const SOON_BADGE_CLASS = `${BADGE_BASE_CLASS} text-muted-foreground `; -/** Opens toward the rail so the list stays readable (not over the model names). */ -const PICKER_TOOLTIP_SIDE = "left" as const; +const PICKER_TOOLTIP_SIDE = "bottom" as const; const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; +const ACP_DRIVER_PREFIX = "acp-"; -export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { +type ProviderTileRowItem = + | { readonly _tag: "entry"; readonly key: string; readonly entry: ProviderInstanceEntry } + | { readonly _tag: "separator"; readonly key: string }; + +export const InlineProviderTileRow = memo(function InlineProviderTileRow(props: { selectedInstanceId: ProviderInstanceId | "favorites"; onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void; /** - * Instance entries to render as rail buttons. Each entry becomes one icon - * keyed by `instanceId`, so the default built-in Codex and a user-authored - * `codex_personal` appear as two distinct rail items, each routing to - * their own model list. + * Instance entries to render as tiles. Each entry becomes one icon keyed + * by `instanceId`, so the default built-in Codex and a user-authored + * `codex_personal` appear as two distinct tiles, each routing to their + * own model list. */ instanceEntries: ReadonlyArray; - /** Render the favorites rail entry. Hidden for locked-provider instance switching. */ + /** Render the favorites tile. Hidden for locked-provider instance switching. */ showFavorites?: boolean; - /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */ - showComingSoon?: boolean; /** * Instance id values that should render the "new" sparkle badge. Callers * pass the subset of default built-in ids they want flagged (custom @@ -67,7 +65,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { props.onSelectInstance(instanceId); }; const showFavorites = props.showFavorites ?? true; - const showComingSoon = props.showComingSoon ?? true; const duplicateDriverCounts = useMemo(() => { const counts = new Map(); for (const entry of props.instanceEntries) { @@ -75,19 +72,48 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { } return counts; }, [props.instanceEntries]); + const tileItems = useMemo(() => { + const builtInEntries: ProviderInstanceEntry[] = []; + const acpEntries: ProviderInstanceEntry[] = []; + for (const entry of props.instanceEntries) { + if (entry.driverKind.startsWith(ACP_DRIVER_PREFIX)) { + acpEntries.push(entry); + } else { + builtInEntries.push(entry); + } + } + + const items: ProviderTileRowItem[] = builtInEntries.map((entry, index) => ({ + _tag: "entry", + key: `built-in:${entry.instanceId}:${index}`, + entry, + })); + if (builtInEntries.length > 0 && acpEntries.length > 0) { + items.push({ _tag: "separator", key: "built-in-acp-separator" }); + } + items.push( + ...acpEntries.map( + (entry, index) => + ({ + _tag: "entry", + key: `acp:${entry.instanceId}:${index}`, + entry, + }) satisfies ProviderTileRowItem, + ), + ); + return items; + }, [props.instanceEntries]); return ( - -
+
{/* Favorites section */} {showFavorites ? ( -
-
+
+
{props.selectedInstanceId === "favorites" && (
)} @@ -96,7 +122,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { render={ } /> @@ -120,9 +146,22 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
) : null} - {/* Instance buttons (one per configured instance — built-in + custom) */} - {props.instanceEntries.map((entry) => { - const isDisabled = !entry.isAvailable || entry.status !== "ready"; + {/* Instance buttons (grouped by built-in drivers, then ACP registry agents) */} + {tileItems.map((item) => { + if (item._tag === "separator") { + return ( +
+ ); + } + + const entry = item.entry; + const isDisabled = !entry.isAvailable || !hasSelectableModels(entry); const isSelected = props.selectedInstanceId === entry.instanceId; const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; const showInstanceBadge = @@ -138,9 +177,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ); - const trigger = isDisabled ? ( - {button} - ) : ( - button - ); + const trigger = isDisabled ? {button} : button; return ( -
+
{isSelected &&
} @@ -193,71 +228,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ); })} - {showComingSoon ? ( - <> - {/* Gemini button (coming soon) */} - - - - - } - /> - - Gemini — Coming soon - - - {/* Github Copilot button (coming soon) */} - - - - - } - /> - - Github Copilot — Coming soon - - - - ) : null} + {/* No "coming soon" tiles — Gemini & GitHub Copilot are now + installable via the merged ACP Registry section in Settings. */}
- +
); }); diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 154cada19aa..4f7e4be64ba 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -1,7 +1,8 @@ import { type CSSProperties, memo } from "react"; -import { type ProviderDriverKind } from "@t3tools/contracts"; +import { acpRegistryIdFromDriverKind, type ProviderDriverKind } from "@t3tools/contracts"; import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; +import { AcpRegistryIcon } from "../AcpRegistryIcon"; import { cn } from "~/lib/utils"; export function providerInstanceInitials(label: string): string { @@ -25,6 +26,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { statusDotClassName?: string; }) { const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; + const acpRegistryId = Icon ? undefined : acpRegistryIdFromDriverKind(props.driverKind); const accentStyle = props.accentColor ? ({ "--provider-accent": props.accentColor } as CSSProperties) : undefined; @@ -40,6 +42,11 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { > {Icon ? ( + ) : acpRegistryId ? ( + ) : ( {providerInstanceInitials(props.displayName)} diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index d3b168876a6..3e154638068 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { ProviderModelPicker } from "./ProviderModelPicker"; +import { InlineModelPickerContent } from "./InlineModelPickerContent"; +import { useState } from "react"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { deriveProviderInstanceEntries, @@ -268,19 +270,46 @@ async function mountPicker(props: { activeInstanceId, props.model, ); - const screen = await render( - , - { container: host }, - ); + // After the popover-to-inline refactor, the trigger (ProviderModelPicker) + // and the panel content (InlineModelPickerContent) are separate components. + // Mount a tiny wrapper that pairs them with shared open state so existing + // assertions (click trigger → list visible → data attributes present) keep + // working. + function TestHarness() { + const [open, setOpen] = useState(false); + return ( + <> + + {open ? ( + setOpen(false)} + onInstanceModelChange={(instanceId, model) => { + onInstanceModelChange(instanceId, model); + setOpen(false); + }} + /> + ) : null} + + ); + } + const screen = await render(, { container: host }); return { onInstanceModelChange, @@ -606,10 +635,13 @@ describe("ProviderModelPicker", () => { lockedProvider={null} instanceEntries={instanceEntries} modelOptionsByInstance={modelOptionsByInstance} - onInstanceModelChange={onInstanceModelChange} />, { container: host }, ); + // The trigger-only test doesn't need to receive change callbacks — it + // only asserts the visible label. Reference `onInstanceModelChange` so + // the mock import isn't flagged as unused. + void onInstanceModelChange; try { const trigger = document.querySelector( diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 4a2860ee469..aee6bc26f77 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,16 +1,10 @@ -import { - type ProviderInstanceId, - type ProviderDriverKind, - type ResolvedKeybindingsConfig, -} from "@t3tools/contracts"; +import { type ProviderInstanceId, type ProviderDriverKind } from "@t3tools/contracts"; import { memo, useEffect, useMemo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { ChevronDownIcon } from "lucide-react"; import { Button, buttonVariants } from "../ui/button"; -import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { ModelPickerContent } from "./ModelPickerContent"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ModelEsque, @@ -21,34 +15,23 @@ import { setModelPickerOpen } from "../../modelPickerOpenState"; import type { ProviderInstanceEntry } from "../../providerInstances"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { - /** - * The instance currently selected in the composer. Drives the trigger - * icon, label and the default-highlighted combobox row. - */ activeInstanceId: ProviderInstanceId; model: string; lockedProvider: ProviderDriverKind | null; lockedContinuationGroupKey?: string | null; - /** Instance entries rendered in the sidebar + used to resolve display name. */ instanceEntries: ReadonlyArray; - keybindings?: ResolvedKeybindingsConfig; modelOptionsByInstance: ReadonlyMap>; activeProviderIconClassName?: string; compact?: boolean; disabled?: boolean; - terminalOpen?: boolean; open?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; onOpenChange?: (open: boolean) => void; - onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { - const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); - const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; + const [uncontrolledIsPanelOpen, setUncontrolledIsPanelOpen] = useState(false); + const isPanelOpen = props.open ?? uncontrolledIsPanelOpen; - // Resolve the active instance entry by exact routing key. The composer - // resolves fallbacks before rendering this component; if the selected - // instance disappears, do not infer a replacement from its driver kind. const activeEntry = useMemo(() => { return ( props.instanceEntries.find((entry) => entry.instanceId === props.activeInstanceId) ?? null @@ -57,10 +40,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const activeInstanceId = props.activeInstanceId; const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? []; - // If the current slug belongs to a different instance (for example after - // a provider switch or disable), prefer the active instance's first - // option so the trigger icon and label stay in sync instead of showing - // a stale foreign slug. const selectedModel = selectedInstanceOptions.find((option) => option.slug === props.model) ?? selectedInstanceOptions[0]; @@ -72,116 +51,90 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ).length; const showInstanceBadge = Boolean(activeEntry?.accentColor) || duplicateDriverCount > 1; - const setIsMenuOpen = (open: boolean) => { + const setIsPanelOpen = (open: boolean) => { props.onOpenChange?.(open); if (props.open === undefined) { - setUncontrolledIsMenuOpen(open); + setUncontrolledIsPanelOpen(open); } }; useEffect(() => { - setModelPickerOpen(isMenuOpen); + setModelPickerOpen(isPanelOpen); return () => { setModelPickerOpen(false); }; - }, [isMenuOpen]); - - const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => { - if (props.disabled) return; - props.onInstanceModelChange(instanceId, model); - setIsMenuOpen(false); - }; + }, [isPanelOpen]); return ( - { - if (props.disabled) { - setIsMenuOpen(false); - return; - } - setIsMenuOpen(open); + ); }); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index a882942585f..f6c0324ea47 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -1,4 +1,4 @@ -import { type ServerProvider } from "@t3tools/contracts"; +import { ACP_REGISTRY_DRIVER_PREFIX, type ServerProvider } from "@t3tools/contracts"; import { memo } from "react"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { CircleAlertIcon } from "lucide-react"; @@ -14,6 +14,25 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ } const providerLabel = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); + const isAcpRegistry = status.driver.startsWith(ACP_REGISTRY_DRIVER_PREFIX); + const isUnauthenticated = status.auth.status === "unauthenticated"; + + if (isAcpRegistry && isUnauthenticated) { + return ( +
+ + + {providerLabel} requires authentication + + This provider requires authentication before it can be used. Go to{" "} + Settings → Providers and click the "Authenticate" button for this + provider. + + +
+ ); + } + const defaultMessage = status.status === "error" ? `${providerLabel} provider is unavailable.` diff --git a/apps/web/src/components/settings/AddOrInstallProviderPanel.tsx b/apps/web/src/components/settings/AddOrInstallProviderPanel.tsx new file mode 100644 index 00000000000..229de8bac7e --- /dev/null +++ b/apps/web/src/components/settings/AddOrInstallProviderPanel.tsx @@ -0,0 +1,721 @@ +"use client"; + +import { + type AcpRegistryDistributionKind, + AcpRegistrySettings, + acpRegistryDriverKindFor, + type AcpRegistryEntryWithStatus, + ProviderInstanceId, + ProviderDriverKind, + type ProviderInstanceConfig, +} from "@t3tools/contracts"; +import { + DownloadIcon, + ExternalLinkIcon, + PackageIcon, + PlusCircleIcon, + SearchIcon, + Trash2Icon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { ensureLocalApi } from "../../localApi"; +import { normalizeProviderAccentColor } from "../../providerInstances"; +import { AcpRegistryIcon } from "../AcpRegistryIcon"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import type { Icon } from "../Icons"; +import { Input } from "../ui/input"; +import { Spinner } from "../ui/spinner"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { AnimatedHeight } from "../AnimatedHeight"; +import { + ProviderSettingsForm, + deriveProviderSettingsFields, + type ProviderSettingsFieldModel, +} from "./ProviderSettingsForm"; +import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS, type DriverOption } from "./providerDriverMeta"; + +const REGISTRY_DOCS_URL = "https://agentclientprotocol.com/get-started/registry"; + +const DISTRIBUTION_LABEL: Record = { + binary: "Binary", + npx: "npx", + uvx: "uvx", +}; + +const PROVIDER_ACCENT_SWATCHES = [ + "#2563eb", + "#16a34a", + "#ea580c", + "#dc2626", + "#7c3aed", + "#0891b2", +] as const; + +const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + +function slugifyLabel(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 48); +} + +function deriveInstanceId(driver: ProviderDriverKind, label: string): string { + const slug = slugifyLabel(label); + return slug ? `${driver}_${slug}` : ""; +} + +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."; + if (!INSTANCE_ID_PATTERN.test(id)) { + return "Instance ID must start with a letter and use only letters, digits, '-', or '_'."; + } + if (existing.has(id)) return `An instance named '${id}' already exists.`; + return null; +} + +function describeError(cause: unknown, fallback: string): string { + return cause instanceof Error ? cause.message : fallback; +} + +function makeAcpRegistryIconComponent(agentId: string): Icon { + return function AcpRegistryAgentIcon({ className }) { + return ; + }; +} + +function toDriverOption(entry: AcpRegistryEntryWithStatus): DriverOption { + return { + value: ProviderDriverKind.make(acpRegistryDriverKindFor(entry.entry.id)), + label: entry.entry.name, + icon: makeAcpRegistryIconComponent(entry.entry.id), + settingsSchema: AcpRegistrySettings, + badgeLabel: "ACP", + }; +} + +type PanelMode = + | { kind: "browse" } + | { + kind: "configure"; + driverOption: DriverOption; + isFromAcpRegistry: boolean; + }; + +interface AddOrInstallProviderPanelProps { + anchorId?: string; +} + +export function AddOrInstallProviderPanel({ + anchorId = "providers-add-or-install", +}: AddOrInstallProviderPanelProps) { + const settings = useSettings(); + const { updateSettings } = useUpdateSettings(); + + const [query, setQuery] = useState(""); + const [mode, setMode] = useState({ kind: "browse" }); + + const [label, setLabel] = useState(""); + const [accentColor, setAccentColor] = useState(""); + const [instanceIdInput, setInstanceIdInput] = useState(""); + const [instanceIdDirty, setInstanceIdDirty] = useState(false); + const [configDraft, setConfigDraft] = useState>({}); + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + + const [acpEntries, setAcpEntries] = useState>([]); + const [acpLoading, setAcpLoading] = useState(true); + const [acpError, setAcpError] = useState(null); + const [busyAcpIds, setBusyAcpIds] = useState>(() => new Set()); + + const existingIds = useMemo( + () => new Set(Object.keys(settings.providerInstances ?? {})), + [settings.providerInstances], + ); + + const refreshAcp = useCallback(async () => { + try { + const list = await ensureLocalApi().acpRegistry.list(); + setAcpEntries(list); + setAcpError(null); + } catch (cause) { + setAcpError(describeError(cause, "Failed to load ACP registry.")); + } finally { + setAcpLoading(false); + } + }, []); + + useEffect(() => { + void refreshAcp(); + }, [refreshAcp]); + + useEffect(() => { + if (mode.kind !== "configure") return; + setLabel(""); + setAccentColor(""); + setInstanceIdInput(deriveInstanceId(mode.driverOption.value, "")); + setInstanceIdDirty(false); + setConfigDraft({}); + setHasAttemptedSubmit(false); + }, [mode]); + + useEffect(() => { + if (mode.kind !== "configure") return; + if (instanceIdDirty) return; + setInstanceIdInput(deriveInstanceId(mode.driverOption.value, label)); + }, [label, instanceIdDirty, mode]); + + const acpDriverOptions = useMemo( + () => + acpEntries + .filter((entry) => entry.status === "installed" || entry.status === "update_available") + .map(toDriverOption), + [acpEntries], + ); + + const acpDriverOptionByValue = useMemo( + () => new Map(acpDriverOptions.map((option) => [option.value, option] as const)), + [acpDriverOptions], + ); + + const matchesQuery = useCallback( + (text: string): boolean => { + if (!query.trim()) return true; + return text.toLowerCase().includes(query.trim().toLowerCase()); + }, + [query], + ); + + const filteredBuiltIns = useMemo( + () => DRIVER_OPTIONS.filter((option) => matchesQuery(option.label)), + [matchesQuery], + ); + + const filteredAcp = useMemo( + () => + acpEntries.filter((entry) => + matchesQuery(`${entry.entry.name} ${entry.entry.id} ${entry.entry.description}`), + ), + [acpEntries, matchesQuery], + ); + + const handleSelectBuiltIn = useCallback((option: DriverOption) => { + setMode({ kind: "configure", driverOption: option, isFromAcpRegistry: false }); + }, []); + + const handleSelectInstalledAcp = useCallback( + (entry: AcpRegistryEntryWithStatus) => { + const option = + acpDriverOptionByValue.get( + ProviderDriverKind.make(acpRegistryDriverKindFor(entry.entry.id)), + ) ?? toDriverOption(entry); + setMode({ kind: "configure", driverOption: option, isFromAcpRegistry: true }); + }, + [acpDriverOptionByValue], + ); + + const runAcpAction = useCallback( + async (agentId: string, name: string, action: "install" | "uninstall") => { + setBusyAcpIds((prev) => new Set(prev).add(agentId)); + try { + await ensureLocalApi().acpRegistry[action]({ agentId }); + toastManager.add( + stackedThreadToast({ + type: "success", + title: `${name} ${action === "install" ? "installed" : "removed"}`, + }), + ); + await refreshAcp(); + } catch (cause) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to ${action} ${name}`, + description: describeError(cause, String(cause)), + }), + ); + } finally { + setBusyAcpIds((prev) => { + if (!prev.has(agentId)) return prev; + const next = new Set(prev); + next.delete(agentId); + return next; + }); + } + }, + [refreshAcp], + ); + + const handleInstall = useCallback( + (entry: AcpRegistryEntryWithStatus) => { + void runAcpAction(entry.entry.id, entry.entry.name, "install"); + }, + [runAcpAction], + ); + + const handleUninstall = useCallback( + (entry: AcpRegistryEntryWithStatus) => { + void runAcpAction(entry.entry.id, entry.entry.name, "uninstall"); + }, + [runAcpAction], + ); + + const configuringOption = mode.kind === "configure" ? mode.driverOption : null; + const configuringFields = useMemo( + () => (configuringOption ? deriveProviderSettingsFields(configuringOption) : []), + [configuringOption], + ); + const instanceIdError = + mode.kind === "configure" ? validateInstanceId(instanceIdInput, existingIds) : null; + const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; + + const handleSave = useCallback(() => { + if (mode.kind !== "configure") return; + setHasAttemptedSubmit(true); + if (instanceIdError !== null) return; + + const hasConfig = Object.keys(configDraft).length > 0; + const normalizedAccentColor = normalizeProviderAccentColor(accentColor); + + const nextInstance: ProviderInstanceConfig = { + driver: mode.driverOption.value, + enabled: true, + ...(label.trim().length > 0 ? { displayName: label.trim() } : {}), + ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}), + ...(hasConfig ? { config: configDraft } : {}), + }; + + try { + const brandedId = ProviderInstanceId.make(instanceIdInput); + updateSettings({ + providerInstances: { + ...settings.providerInstances, + [brandedId]: nextInstance, + }, + }); + toastManager.add({ + type: "success", + title: "Provider instance added", + description: `${mode.driverOption.label} instance '${instanceIdInput}' was added.`, + }); + setMode({ kind: "browse" }); + } catch (cause) { + toastManager.add({ + type: "error", + title: "Could not add provider instance", + description: describeError(cause, "Update failed."), + }); + } + }, [ + accentColor, + configDraft, + instanceIdError, + instanceIdInput, + label, + mode, + settings.providerInstances, + updateSettings, + ]); + + return ( +
+
+ + + + {mode.kind === "browse" ? ( +
+ + {filteredBuiltIns.map((option) => ( + handleSelectBuiltIn(option)} + /> + ))} + + + + Learn more + + + } + > + {acpLoading ? ( +
+ + Loading registry… +
+ ) : acpError ? ( +
+ {acpError} +
+ ) : filteredAcp.length === 0 ? ( +
+ {query ? "No agents match your search." : "No agents available."} +
+ ) : ( + filteredAcp.map((entry) => ( + handleInstall(entry)} + onUninstall={() => handleUninstall(entry)} + onAddInstance={() => handleSelectInstalledAcp(entry)} + /> + )) + )} +
+
+ ) : ( + { + setInstanceIdDirty(true); + setInstanceIdInput(value); + }} + showInstanceIdError={showInstanceIdError} + instanceIdError={instanceIdError} + configDraft={configDraft} + setConfigDraft={(draft) => setConfigDraft(draft ?? {})} + configuringFields={configuringFields} + onCancel={() => setMode({ kind: "browse" })} + onSave={handleSave} + /> + )} +
+
+ ); +} + +function Header() { + return ( +
+

Add or install a provider

+

+ Pick a built-in driver to configure a new instance, or install an ACP-conforming agent — + installed agents register as providers automatically. +

+
+ ); +} + +function Toolbar({ query, setQuery }: { query: string; setQuery: (value: string) => void }) { + return ( +
+
+ + setQuery(event.target.value)} + placeholder="Search drivers & agents…" + className="pl-8" + /> +
+
+ ); +} + +function TileGroup({ + title, + actionRight, + children, +}: { + title: string; + actionRight?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+

+ {title} +

+ {actionRight} +
+
{children}
+
+ ); +} + +function DriverTile({ option, onClick }: { option: DriverOption; onClick: () => void }) { + const IconComponent = option.icon; + return ( + + ); +} + +function AcpRegistryTile({ + entry, + busy, + onInstall, + onUninstall, + onAddInstance, +}: { + entry: AcpRegistryEntryWithStatus; + busy: boolean; + onInstall: () => void; + onUninstall: () => void; + onAddInstance: () => void; +}) { + const { entry: meta, status, installed, availableChannels } = entry; + const isUnsupported = status === "unsupported"; + const isInstalled = status === "installed" || status === "update_available"; + + return ( +
+
+
+ +
+
+
+
{meta.name}
+ v{meta.version} + {status === "update_available" && installed && ( + + Update v{installed.version} + + )} +
+

+ {meta.description} +

+
+ {meta.id} + {!isUnsupported && availableChannels.length > 0 && ( + + + {availableChannels.map((channel) => DISTRIBUTION_LABEL[channel]).join(" · ")} + + )} +
+
+
+ +
+ {isUnsupported ? ( + + Unsupported on this platform + + ) : isInstalled ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} + +interface ConfigureFormProps { + mode: Extract; + label: string; + setLabel: (value: string) => void; + accentColor: string; + setAccentColor: (value: string) => void; + instanceIdInput: string; + setInstanceIdInput: (value: string) => void; + showInstanceIdError: boolean; + instanceIdError: string | null; + configDraft: Record; + setConfigDraft: (value: Record | undefined) => void; + configuringFields: ReadonlyArray; + onCancel: () => void; + onSave: () => void; +} + +function ConfigureForm(props: ConfigureFormProps) { + const { mode } = props; + const DriverIcon = mode.driverOption.icon; + + return ( +
+
+ + {mode.driverOption.label} + {mode.driverOption.badgeLabel ? ( + + {mode.driverOption.badgeLabel} + + ) : null} + +
+ + + + + +
+ Accent color +
+ props.setAccentColor(event.target.value)} + aria-label="Provider instance accent color" + className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" + /> +
+ {PROVIDER_ACCENT_SWATCHES.map((swatch) => { + const selected = props.accentColor.toLowerCase() === swatch; + return ( +
+ {props.accentColor ? ( + + ) : null} +
+ + Optional marker shown in the picker. + +
+ + {props.configuringFields.length > 0 ? ( +
+ +
+ ) : ( +

+ This driver has no required configuration. You can add the instance now. +

+ )} + +
+ + +
+
+ ); +} + +export { DRIVER_OPTION_BY_VALUE }; diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx deleted file mode 100644 index affa35ff260..00000000000 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ /dev/null @@ -1,491 +0,0 @@ -"use client"; - -import { CheckIcon } from "lucide-react"; -import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { - ProviderInstanceId, - ProviderDriverKind, - type ProviderInstanceConfig, -} from "@t3tools/contracts"; - -import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; -import { cn } from "../../lib/utils"; -import { normalizeProviderAccentColor } from "../../providerInstances"; -import { Button } from "../ui/button"; -import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; -import { Badge } from "../ui/badge"; -import { Input } from "../ui/input"; -import { RadioGroup } from "../ui/radio-group"; -import { toastManager } from "../ui/toast"; -import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; -import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; -import { AnimatedHeight } from "../AnimatedHeight"; - -const PROVIDER_ACCENT_SWATCHES = [ - "#2563eb", - "#16a34a", - "#ea580c", - "#dc2626", - "#7c3aed", - "#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() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, "") - .slice(0, 48); -} - -function deriveInstanceId(driver: ProviderDriverKind, label: string): string { - const slug = slugifyLabel(label); - return slug ? `${driver}_${slug}` : ""; -} - -const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; -const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); -const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; -const EMPTY_CONFIG_DRAFT: Record = {}; -interface ComingSoonDriverOption { - readonly value: ProviderDriverKind; - readonly label: string; - readonly icon: Icon; -} - -const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ - { - value: ProviderDriverKind.make("githubCopilot"), - label: "Github Copilot", - icon: GithubCopilotIcon, - }, - { - value: ProviderDriverKind.make("gemini"), - label: "Gemini", - icon: Gemini, - }, - { - value: ProviderDriverKind.make("acpRegistry"), - label: "ACP Registry", - icon: ACPRegistryIcon, - }, - { - value: ProviderDriverKind.make("piAgent"), - label: "Pi Agent", - icon: PiAgentIcon, - }, -]; - -/** - * 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. - */ -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."; - if (!INSTANCE_ID_PATTERN.test(id)) { - return "Instance ID must start with a letter and use only letters, digits, '-', or '_'."; - } - if (existing.has(id)) return `An instance named '${id}' already exists.`; - return null; -} - -interface AddProviderInstanceDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { - const settings = useSettings(); - const { updateSettings } = useUpdateSettings(); - - const [wizardStep, setWizardStep] = useState(0); - const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); - const [label, setLabel] = useState(""); - 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 [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); - - const existingIds = useMemo( - () => new Set(Object.keys(settings.providerInstances ?? {})), - [settings.providerInstances], - ); - - // 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(""); - setAccentColor(""); - setInstanceId(""); - setWizardStep(0); - setInstanceIdDirty(false); - setConfigByDriver({}); - 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]); - - const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; - const driverSettingsFields = useMemo( - () => deriveProviderSettingsFields(driverOption), - [driverOption], - ); - 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 configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; - const setConfigDraft = useCallback( - (config: Record | undefined) => { - setConfigByDriver((existing) => { - const next = { ...existing }; - if (config === undefined || Object.keys(config).length === 0) { - delete next[driver]; - } else { - next[driver] = config; - } - return next; - }); - }, - [driver], - ); - - const handleSave = useCallback(() => { - setHasAttemptedSubmit(true); - if (instanceIdError !== null) return; - - const config = configByDriver[driver] ?? {}; - const hasConfig = Object.keys(config).length > 0; - const normalizedAccentColor = normalizeProviderAccentColor(accentColor); - - const nextInstance: ProviderInstanceConfig = { - driver, - enabled: true, - ...(label.trim().length > 0 ? { displayName: label.trim() } : {}), - ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}), - ...(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, - [brandedId]: nextInstance, - }; - try { - updateSettings({ providerInstances: nextMap }); - toastManager.add({ - type: "success", - title: "Provider instance added", - description: `${driverOption.label} instance '${instanceId}' was added.`, - }); - onOpenChange(false); - } catch (error) { - toastManager.add({ - type: "error", - title: "Could not add provider instance", - description: error instanceof Error ? error.message : "Update failed.", - }); - } - }, [ - driver, - driverOption, - configByDriver, - instanceId, - instanceIdError, - label, - accentColor, - onOpenChange, - 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) => ( - - ))} -
-
- -
- -
- - 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 - - - ); - })} - -
- - - - - -
- Accent color -
- setAccentColor(event.target.value)} - aria-label="Provider instance accent color" - className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" - /> -
- {PROVIDER_ACCENT_SWATCHES.map((swatch) => { - const selected = accentColor.toLowerCase() === swatch; - return ( -
- {accentColor ? ( - - ) : null} -
- - Optional marker shown in the picker. - -
- - {driverSettingsFields.length > 0 ? ( -
- -
- ) : wizardStep === 2 ? ( -
-

- This driver has no required configuration. You can add the instance now. -

-
- ) : null} -
-
- - - - {wizardStep < wizardSteps.length - 1 ? ( - - ) : ( - - )} - -
-
-
- ); -} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 430ec3637e0..a776a67d735 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { + ACP_REGISTRY_DRIVER_PREFIX, isProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, @@ -23,6 +24,7 @@ import { import { cn } from "../../lib/utils"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { ensureLocalApi } from "../../localApi"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -30,6 +32,7 @@ import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { DraftInput } from "../ui/draft-input"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { ScrollArea } from "../ui/scroll-area"; +import { Spinner } from "../ui/spinner"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -157,6 +160,79 @@ function ProviderAuthEmail(props: { ); } +function ProviderAuthSection(props: { + readonly instanceId: ProviderInstanceId; + readonly liveProvider: ServerProvider | undefined; +}) { + const [authenticating, setAuthenticating] = useState>(() => new Set()); + const live = props.liveProvider; + const isAcpRegistry = live?.driver.startsWith(ACP_REGISTRY_DRIVER_PREFIX) ?? false; + const authMethods = live?.auth.authMethods ?? []; + const isAuthenticated = live?.auth.status === "authenticated"; + + if (!isAcpRegistry || authMethods.length === 0 || isAuthenticated) { + return null; + } + + const handleAuthenticate = async (methodId: string) => { + setAuthenticating((prev) => new Set(prev).add(methodId)); + try { + await ensureLocalApi().acpRegistry.authenticate({ + instanceId: props.instanceId, + methodId, + }); + } catch (cause) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Authentication failed`, + description: cause instanceof Error ? cause.message : String(cause), + }), + ); + } finally { + setAuthenticating((prev) => { + if (!prev.has(methodId)) return prev; + const next = new Set(prev); + next.delete(methodId); + return next; + }); + } + }; + + return ( +
+
+ Authentication +

+ This provider requires authentication. Choose a method below to authenticate. +

+
+ {authMethods.map((method) => ( + + void handleAuthenticate(method.id)} + > + {authenticating.has(method.id) ? : null} + Authenticate with {method.name} + + } + /> + {method.description ?? method.name} + + ))} +
+
+
+ ); +} + function ProviderAccentColorPicker(props: { readonly displayName: string; readonly value: string | undefined; @@ -480,7 +556,6 @@ export function ProviderInstanceCard({ const authenticatedDetail = hasAuthenticatedEmail ? (liveProvider?.auth.label ?? liveProvider?.auth.type ?? null) : null; - const summary = rawSummary; const versionLabel = getProviderVersionLabel(liveProvider?.version); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); const updateCommand = versionAdvisory?.updateCommand ?? null; @@ -658,11 +733,11 @@ export function ProviderInstanceCard({ ) : ( <> - {summary.headline} + {rawSummary.headline} )} - {summary.detail ? - {summary.detail} : null} + {rawSummary.detail ? - {rawSummary.detail} : null}

); @@ -843,6 +918,8 @@ export function ProviderInstanceCard({ /> ) : null} + + {driverOption !== undefined ? ( [0]["instanceEntries"][number] + >; + modelOptionsByInstance: Parameters[0]["modelOptionsByInstance"]; + onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; +}) { + const [open, setOpen] = useState(false); + return ( + + + } + /> + + setOpen(false)} + onInstanceModelChange={(instanceId, model) => { + props.onInstanceModelChange(instanceId, model); + setOpen(false); + }} + /> + + + ); +} + function AboutVersionTitle() { return ( @@ -834,14 +891,11 @@ export function GeneralSettingsPanel() { } control={
- { updateSettings({ textGenerationModelSelection: resolveAppModelSelectionState( @@ -919,7 +973,6 @@ export function ProviderSettingsPanel() { const { updateSettings } = useUpdateSettings(); const serverProviders = useServerProviders(); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); - const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< ReadonlySet >(() => new Set()); @@ -958,9 +1011,7 @@ export function ProviderSettingsPanel() { setIsRefreshingProviders(true); void ensureLocalApi() .server.refreshProviders() - .catch((error: unknown) => { - console.warn("Failed to refresh providers", error); - }) + .catch(() => undefined) .finally(() => { refreshingRef.current = false; setIsRefreshingProviders(false); @@ -1190,7 +1241,10 @@ export function ProviderSettingsPanel() { size="icon-xs" variant="ghost" className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" - onClick={() => setIsAddInstanceDialogOpen(true)} + onClick={() => { + const target = document.getElementById("providers-add-or-install"); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + }} aria-label="Add provider instance" > @@ -1323,10 +1377,9 @@ export function ProviderSettingsPanel() { })} - +
+ +
); } diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index cbb3427b004..0a6ae491a22 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -118,6 +118,22 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { removeBrowserSavedEnvironmentSecret(environmentId); }, }, + acpRegistry: { + list: () => + rpcClient ? rpcClient.acpRegistry.list() : Promise.reject(unavailableLocalBackendError()), + install: (input) => + rpcClient + ? rpcClient.acpRegistry.install(input) + : Promise.reject(unavailableLocalBackendError()), + uninstall: (input) => + rpcClient + ? rpcClient.acpRegistry.uninstall(input) + : Promise.reject(unavailableLocalBackendError()), + authenticate: (input) => + rpcClient + ? rpcClient.acpRegistry.authenticate(input) + : Promise.reject(unavailableLocalBackendError()), + }, server: { getConfig: () => rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()), diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 67758cac5d1..ab6834bdc90 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -44,10 +44,12 @@ function settingsWithProviderInstances(): UnifiedSettings { providerInstances: { [ProviderInstanceId.make("claudeAgent")]: { driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, config: { customModels: [] }, }, [ProviderInstanceId.make("claude_openrouter")]: { driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, config: { customModels: ["openai/gpt-5.5"] }, }, }, diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..e5ca384aa0d 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as SettingsAcpRegistryRouteImport } from './routes/settings.acp-registry' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -77,6 +78,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const SettingsAcpRegistryRoute = SettingsAcpRegistryRouteImport.update({ + id: '/acp-registry', + path: '/acp-registry', + getParentRoute: () => SettingsRoute, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -93,6 +99,7 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/acp-registry': typeof SettingsAcpRegistryRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -106,6 +113,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/acp-registry': typeof SettingsAcpRegistryRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -122,6 +130,7 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/acp-registry': typeof SettingsAcpRegistryRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -139,6 +148,7 @@ export interface FileRouteTypes { | '/' | '/pair' | '/settings' + | '/settings/acp-registry' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -152,6 +162,7 @@ export interface FileRouteTypes { to: | '/pair' | '/settings' + | '/settings/acp-registry' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -167,6 +178,7 @@ export interface FileRouteTypes { | '/_chat' | '/pair' | '/settings' + | '/settings/acp-registry' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -264,6 +276,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/settings/acp-registry': { + id: '/settings/acp-registry' + path: '/acp-registry' + fullPath: '/settings/acp-registry' + preLoaderRoute: typeof SettingsAcpRegistryRouteImport + parentRoute: typeof SettingsRoute + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -296,6 +315,7 @@ const ChatRouteChildren: ChatRouteChildren = { const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { + SettingsAcpRegistryRoute: typeof SettingsAcpRegistryRoute SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute @@ -306,6 +326,7 @@ interface SettingsRouteChildren { } const SettingsRouteChildren: SettingsRouteChildren = { + SettingsAcpRegistryRoute: SettingsAcpRegistryRoute, SettingsArchivedRoute: SettingsArchivedRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, diff --git a/apps/web/src/routes/settings.acp-registry.tsx b/apps/web/src/routes/settings.acp-registry.tsx new file mode 100644 index 00000000000..ed813c32516 --- /dev/null +++ b/apps/web/src/routes/settings.acp-registry.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +// ACP Registry was merged into the Providers page so installing an agent +// auto-registers it as a provider instance. Keep the URL working for any +// bookmarks or external links by redirecting here. +export const Route = createFileRoute("/settings/acp-registry")({ + beforeLoad: () => { + throw redirect({ to: "/settings/providers", replace: true }); + }, +}); diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 7128c909ab7..7e659b921ae 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -140,6 +140,12 @@ export interface WsRpcClient { readonly subscribeLifecycle: RpcStreamMethod; readonly subscribeAuthAccess: RpcStreamMethod; }; + readonly acpRegistry: { + readonly list: RpcUnaryNoArgMethod; + readonly install: RpcUnaryMethod; + readonly uninstall: RpcUnaryMethod; + readonly authenticate: RpcUnaryMethod; + }; readonly orchestration: { readonly dispatchCommand: RpcUnaryMethod; readonly getTurnDiff: RpcUnaryMethod; @@ -286,6 +292,15 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { tag: WS_METHODS.subscribeAuthAccess, }), }, + acpRegistry: { + list: () => transport.request((client) => client[WS_METHODS.acpRegistryList]({})), + install: (input) => + transport.request((client) => client[WS_METHODS.acpRegistryInstall](input)), + uninstall: (input) => + transport.request((client) => client[WS_METHODS.acpRegistryUninstall](input)), + authenticate: (input) => + transport.request((client) => client[WS_METHODS.acpRegistryAuthenticate](input)), + }, orchestration: { dispatchCommand: (input) => transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), diff --git a/bun.lock b/bun.lock index 476c50c005c..ffc4a5922bd 100644 --- a/bun.lock +++ b/bun.lock @@ -154,6 +154,7 @@ "name": "@t3tools/client-runtime", "dependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", "effect": "catalog:", }, "devDependencies": { diff --git a/docs/providers/acp-registry.md b/docs/providers/acp-registry.md new file mode 100644 index 00000000000..f132002b82b --- /dev/null +++ b/docs/providers/acp-registry.md @@ -0,0 +1,120 @@ +# ACP Registry + +The ACP Registry is the catalog of coding agents that speak the +[Agent Client Protocol](https://agentclientprotocol.com). T3 Code bundles a +snapshot of the upstream registry (`cdn.agentclientprotocol.com/registry/v1/latest`) +so you can browse and install any conforming agent without leaving the app. + +## What Ships In T3 Code + +| | | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Bundled entries | 31 agents — everything in the upstream registry except the four overlapping with first-party drivers (`claude-acp`, `cursor`, `opencode`, `codex-acp`). | +| Distribution channels | `binary` (downloaded, SHA256-verified, and extracted to a cache dir), `npx` (runs via `bunx`), `uvx` (runs via `uvx`). | +| Where installs live | macOS: `~/Library/Caches/t3code//acp-agents///`. Linux/Windows use the equivalent `ServerConfig.acpRegistryCacheDir`. | +| Where install state lives | `/installs.json` (migrated from `settings.json` on first read). | +| Icons | `apps/web/public/acp-icons/.svg`, mirrored from `packages/contracts/src/registry/icons/`. | + +## Browsing And Installing + +Open **Settings → Providers** and use the **Add or install a provider** panel. +The legacy `/settings/acp-registry` route redirects to this panel. You'll see +one card per registry agent with: + +- icon, display name, version +- the distribution channels available for your platform (e.g. `Binary · npx`) +- an **Install** or **Remove** button + +The search box matches against id, name, and description. + +Pressing **Install**: + +1. Picks the first supported channel (binary if a target exists for your + platform with a `sha256`, otherwise `npx`, then `uvx`). +2. For `binary`: downloads the archive (`.tar.gz`/`.tgz`/`.tar.bz2`/`.tbz2`/ + `.zip` or raw binary) to the cache dir, verifies the registry `sha256`, + extracts it with `tar` or `Expand-Archive` on Windows, and `chmod +x`'s the + declared `cmd`. +3. For `npx` / `uvx`: just records the choice — the spawn happens lazily. +4. Persists the result to `/installs.json` so the install survives restarts. + +Pressing **Remove** wipes the install state from the manifest and the agent's cache dir. + +If an agent's binary target doesn't include your platform, is missing a +`sha256`, AND has no `npx`/`uvx` fallback, the card shows "Unsupported on this +platform" instead of an Install button. + +## Authentication + +ACP agents may declare authentication methods in their `initialize` response. +T3 Code detects these and surfaces them in two places: + +1. **Settings → Providers**: When an agent requires authentication, the provider + instance card shows an **Authentication** section with one button per + advertised method (e.g. "Authenticate with OAuth"). Clicking a button + triggers the agent's auth flow and marks the instance as authenticated. +2. **Chat**: If you try to use an unauthenticated agent, the status banner + shows a friendly "This provider requires authentication" message with a link + to Settings instead of a raw stack trace. + +For agents that need API keys (Gemini, Mistral, Qwen, etc.), you can still set +variables in the per-instance **Environment variables** section after creating +the provider instance, the same way you would for any first-party provider. + +## Refreshing The Bundle + +The bundled snapshot is checked into source control for offline use. Refresh +it whenever you want to pick up new agents or version bumps: + +```bash +bun run sync:acp-registry +``` + +This script: + +1. Fetches the upstream `registry.json`. +2. Filters out the four overlapping ids (see above). +3. Sorts by id and writes `packages/contracts/src/registry/registry.json`. +4. Downloads every remaining `icon.svg` into both + `packages/contracts/src/registry/icons/` and + `apps/web/public/acp-icons/`. + +Optional flags: `--registry-url ` (point at a fork), `--skip-icons` +(skip the download pass). + +## Architecture Notes + +- **Contracts**: `packages/contracts/src/acpRegistry.ts` defines the + `AcpRegistryEntry` / install-state schemas. `packages/contracts/src/registry/index.ts` + exports the bundled `ACP_REGISTRY` array, decoded once at load. +- **Server**: `apps/server/src/acpRegistry/` + - `platform.ts` maps `os.platform()`/`os.arch()` to the registry's + `darwin-aarch64` / `linux-x86_64` / etc. literal. + - `installer.ts` is the framework-agnostic install/uninstall pipeline. + - `installManifest.ts` persists install state to + `/installs.json` (atomic writes) with one-time + migration from `ServerSettings.acpRegistryInstalls`. + - `AcpRegistryService.ts` is the Effect service consumed by the WS RPC + handlers (`acpRegistry.list` / `.install` / `.uninstall` / + `.authenticate`). +- **Web**: `apps/web/src/components/settings/AddOrInstallProviderPanel.tsx` + is the unified "Add or install a provider" panel rendered on + `Settings → Providers`. The legacy `/settings/acp-registry` route + (`apps/web/src/routes/settings.acp-registry.tsx`) redirects there. + +## Capabilities + +The adapter mirrors `CursorAdapter` / `OpenCodeAdapter` and supports: + +- Full ACP session/turn/tool protocol. +- Authentication flows (OAuth, API keys, terminal prompts) discovered from + the `initialize` handshake at install time. +- Model selection driven by the agent's `session/setup` config options. +- Auto-provisioned default provider instance per install so the agent shows + up in the chat picker without the Add Provider Instance wizard. +- Cascade-delete: uninstalling removes auto-created provider instances. +- Active-session guard: uninstall is refused while a session is live. + +Text generation (commit messages, PR descriptions, branch names, thread +titles) is intentionally **not** wired up for registry agents in v1 — those +flows stay on the first-party drivers. diff --git a/package.json b/package.json index a1aa5d0b1cd..f02d31c24bd 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", - "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" + "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs", + "sync:acp-registry": "bun run scripts/sync-acp-registry.ts" }, "devDependencies": { "@effect/language-service": "catalog:", diff --git a/packages/contracts/src/acpRegistry.ts b/packages/contracts/src/acpRegistry.ts new file mode 100644 index 00000000000..978faccd4b7 --- /dev/null +++ b/packages/contracts/src/acpRegistry.ts @@ -0,0 +1,147 @@ +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const AcpRegistryBinaryPlatform = Schema.Literals([ + "darwin-aarch64", + "darwin-x86_64", + "linux-aarch64", + "linux-x86_64", + "windows-aarch64", + "windows-x86_64", +]); +export type AcpRegistryBinaryPlatform = typeof AcpRegistryBinaryPlatform.Type; + +const EnvMap = Schema.Record(TrimmedNonEmptyString, Schema.String); +const ArgsArray = Schema.Array(Schema.String); + +export const AcpRegistryBinaryTarget = Schema.Struct({ + archive: TrimmedNonEmptyString, + sha256: Schema.optionalKey(TrimmedNonEmptyString), + cmd: TrimmedNonEmptyString, + args: Schema.optionalKey(ArgsArray), + env: Schema.optionalKey(EnvMap), +}); +export type AcpRegistryBinaryTarget = typeof AcpRegistryBinaryTarget.Type; + +export const AcpRegistryBinaryDistribution = Schema.Struct({ + "darwin-aarch64": Schema.optionalKey(AcpRegistryBinaryTarget), + "darwin-x86_64": Schema.optionalKey(AcpRegistryBinaryTarget), + "linux-aarch64": Schema.optionalKey(AcpRegistryBinaryTarget), + "linux-x86_64": Schema.optionalKey(AcpRegistryBinaryTarget), + "windows-aarch64": Schema.optionalKey(AcpRegistryBinaryTarget), + "windows-x86_64": Schema.optionalKey(AcpRegistryBinaryTarget), +}); +export type AcpRegistryBinaryDistribution = typeof AcpRegistryBinaryDistribution.Type; + +export const AcpRegistryPackageDistribution = Schema.Struct({ + package: TrimmedNonEmptyString, + args: Schema.optionalKey(ArgsArray), + env: Schema.optionalKey(EnvMap), +}); +export type AcpRegistryPackageDistribution = typeof AcpRegistryPackageDistribution.Type; + +export const AcpRegistryDistribution = Schema.Struct({ + binary: Schema.optionalKey(AcpRegistryBinaryDistribution), + npx: Schema.optionalKey(AcpRegistryPackageDistribution), + uvx: Schema.optionalKey(AcpRegistryPackageDistribution), +}); +export type AcpRegistryDistribution = typeof AcpRegistryDistribution.Type; + +export const AcpRegistryEntry = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + version: TrimmedNonEmptyString, + description: TrimmedNonEmptyString, + repository: Schema.optionalKey(TrimmedNonEmptyString), + website: Schema.optionalKey(TrimmedNonEmptyString), + authors: Schema.optionalKey(Schema.Array(TrimmedNonEmptyString)), + license: Schema.optionalKey(TrimmedNonEmptyString), + icon: Schema.optionalKey(TrimmedNonEmptyString), + distribution: AcpRegistryDistribution, +}); +export type AcpRegistryEntry = typeof AcpRegistryEntry.Type; + +export const AcpRegistryDocument = Schema.Struct({ + version: Schema.String, + agents: Schema.Array(AcpRegistryEntry), +}); +export type AcpRegistryDocument = typeof AcpRegistryDocument.Type; + +export const AcpRegistryDistributionKind = Schema.Literals(["binary", "npx", "uvx"]); +export type AcpRegistryDistributionKind = typeof AcpRegistryDistributionKind.Type; + +/** + * Auth method advertised by an ACP-conforming agent via its `initialize` + * response. Captured by the install-time probe and surfaced to clients on + * `ServerProviderAuth` so the auth UI can render the right login affordances. + */ +export const AcpAuthMethod = Schema.Struct({ + id: Schema.String, + name: Schema.String, + description: Schema.optional(Schema.String), +}); +export type AcpAuthMethod = typeof AcpAuthMethod.Type; + +export const AcpRegistryCachedModel = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +export type AcpRegistryCachedModel = typeof AcpRegistryCachedModel.Type; + +export const AcpRegistryInstallState = Schema.Struct({ + version: TrimmedNonEmptyString, + installedAt: Schema.String, + authMethods: Schema.optionalKey(Schema.Array(AcpAuthMethod)), + distribution: AcpRegistryDistributionKind, + binaryPath: Schema.optionalKey(TrimmedNonEmptyString), + cachedModels: Schema.optionalKey(Schema.Array(AcpRegistryCachedModel)), + // Count of consecutive boot-time discovery failures. When >= 3 we stop retrying on every + // boot — user can force a retry via "Reload models" or by sending a first chat message. + discoveryFailureCount: Schema.optionalKey(Schema.Number), + // ISO timestamp of the last discovery attempt — for telemetry / future manual retry UI. + lastDiscoveryAttemptAt: Schema.optionalKey(Schema.String), +}); +export type AcpRegistryInstallState = typeof AcpRegistryInstallState.Type; + +export const AcpRegistryInstallStatus = Schema.Literals([ + "installed", + "not_installed", + "unsupported", + "update_available", +]); +export type AcpRegistryInstallStatus = typeof AcpRegistryInstallStatus.Type; + +export const AcpRegistryEntryWithStatus = Schema.Struct({ + entry: AcpRegistryEntry, + status: AcpRegistryInstallStatus, + installed: Schema.optionalKey(AcpRegistryInstallState), + availableChannels: Schema.Array(AcpRegistryDistributionKind), +}); +export type AcpRegistryEntryWithStatus = typeof AcpRegistryEntryWithStatus.Type; + +export class AcpRegistryError extends Schema.TaggedErrorClass()( + "AcpRegistryError", + { + agentId: Schema.optional(Schema.String), + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + const prefix = this.agentId ? `[${this.agentId}] ` : ""; + return `${prefix}ACP registry error: ${this.detail}`; + } +} + +// A registry agent `gemini` registers as driver kind `acp-gemini`. The prefix +// namespaces it away from the four bespoke driver kinds. +export const ACP_REGISTRY_DRIVER_PREFIX = "acp-" as const; + +export const acpRegistryDriverKindFor = (id: string): string => + `${ACP_REGISTRY_DRIVER_PREFIX}${id}`; + +export const acpRegistryIdFromDriverKind = (driverKind: string): string | undefined => + driverKind.startsWith(ACP_REGISTRY_DRIVER_PREFIX) + ? driverKind.slice(ACP_REGISTRY_DRIVER_PREFIX.length) + : undefined; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8402c82647d..3fe8348d418 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -20,3 +20,5 @@ export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; export * from "./rpc.ts"; +export * from "./acpRegistry.ts"; +export * from "./registry/index.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 58894adac1a..7b56cd9aa45 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -18,6 +18,7 @@ import type { VcsStatusResult, VcsCreateRefResult, } from "./git.ts"; +import type { AcpRegistryEntryWithStatus, AcpRegistryInstallState } from "./acpRegistry.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { ProjectSearchEntriesInput, @@ -457,6 +458,15 @@ export interface LocalApi { setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; }; + acpRegistry: { + list: () => Promise>; + install: (input: { readonly agentId: string }) => Promise; + uninstall: (input: { readonly agentId: string }) => Promise<{ readonly agentId: string }>; + authenticate: (input: { + readonly instanceId: ProviderInstanceId; + readonly methodId: string; + }) => Promise>; + }; server: { getConfig: () => Promise; /** diff --git a/packages/contracts/src/providerInstance.ts b/packages/contracts/src/providerInstance.ts index 2a9fc9ed0d1..ea115e7b5ae 100644 --- a/packages/contracts/src/providerInstance.ts +++ b/packages/contracts/src/providerInstance.ts @@ -128,6 +128,7 @@ export const ProviderInstanceConfig = Schema.Struct({ environment: Schema.optionalKey(ProviderInstanceEnvironment), enabled: Schema.optionalKey(Schema.Boolean), config: Schema.optionalKey(Schema.Unknown), + authenticatedAt: Schema.optionalKey(Schema.String), }); export type ProviderInstanceConfig = typeof ProviderInstanceConfig.Type; diff --git a/packages/contracts/src/registry/icons/agoragentic-acp.svg b/packages/contracts/src/registry/icons/agoragentic-acp.svg new file mode 100644 index 00000000000..b1372e68351 --- /dev/null +++ b/packages/contracts/src/registry/icons/agoragentic-acp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/contracts/src/registry/icons/amp-acp.svg b/packages/contracts/src/registry/icons/amp-acp.svg new file mode 100644 index 00000000000..314881aff83 --- /dev/null +++ b/packages/contracts/src/registry/icons/amp-acp.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/contracts/src/registry/icons/auggie.svg b/packages/contracts/src/registry/icons/auggie.svg new file mode 100644 index 00000000000..215107744a7 --- /dev/null +++ b/packages/contracts/src/registry/icons/auggie.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/contracts/src/registry/icons/autohand.svg b/packages/contracts/src/registry/icons/autohand.svg new file mode 100644 index 00000000000..f3bc983c4d9 --- /dev/null +++ b/packages/contracts/src/registry/icons/autohand.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/contracts/src/registry/icons/claude-acp.svg b/packages/contracts/src/registry/icons/claude-acp.svg new file mode 100644 index 00000000000..98dd82db1b3 --- /dev/null +++ b/packages/contracts/src/registry/icons/claude-acp.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/cline.svg b/packages/contracts/src/registry/icons/cline.svg new file mode 100644 index 00000000000..aeeafbc61e7 --- /dev/null +++ b/packages/contracts/src/registry/icons/cline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/contracts/src/registry/icons/codebuddy-code.svg b/packages/contracts/src/registry/icons/codebuddy-code.svg new file mode 100644 index 00000000000..735fd352aac --- /dev/null +++ b/packages/contracts/src/registry/icons/codebuddy-code.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/contracts/src/registry/icons/codex-acp.svg b/packages/contracts/src/registry/icons/codex-acp.svg new file mode 100644 index 00000000000..42c78a06cc4 --- /dev/null +++ b/packages/contracts/src/registry/icons/codex-acp.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/cortex-code.svg b/packages/contracts/src/registry/icons/cortex-code.svg new file mode 100644 index 00000000000..28f87a258e0 --- /dev/null +++ b/packages/contracts/src/registry/icons/cortex-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/corust-agent.svg b/packages/contracts/src/registry/icons/corust-agent.svg new file mode 100644 index 00000000000..9f30636cb00 --- /dev/null +++ b/packages/contracts/src/registry/icons/corust-agent.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/contracts/src/registry/icons/crow-cli.svg b/packages/contracts/src/registry/icons/crow-cli.svg new file mode 100644 index 00000000000..1169116cd9e --- /dev/null +++ b/packages/contracts/src/registry/icons/crow-cli.svg @@ -0,0 +1,9 @@ + + + diff --git a/packages/contracts/src/registry/icons/cursor.svg b/packages/contracts/src/registry/icons/cursor.svg new file mode 100644 index 00000000000..4ca0c2501bd --- /dev/null +++ b/packages/contracts/src/registry/icons/cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/deepagents.svg b/packages/contracts/src/registry/icons/deepagents.svg new file mode 100644 index 00000000000..abd818ec47a --- /dev/null +++ b/packages/contracts/src/registry/icons/deepagents.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/contracts/src/registry/icons/dimcode.svg b/packages/contracts/src/registry/icons/dimcode.svg new file mode 100644 index 00000000000..1fa31ce884b --- /dev/null +++ b/packages/contracts/src/registry/icons/dimcode.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/dirac.svg b/packages/contracts/src/registry/icons/dirac.svg new file mode 100644 index 00000000000..4fbb06ceeaa --- /dev/null +++ b/packages/contracts/src/registry/icons/dirac.svg @@ -0,0 +1,6 @@ + + δ + + + + diff --git a/packages/contracts/src/registry/icons/factory-droid.svg b/packages/contracts/src/registry/icons/factory-droid.svg new file mode 100644 index 00000000000..5c6fb8d1ff0 --- /dev/null +++ b/packages/contracts/src/registry/icons/factory-droid.svg @@ -0,0 +1 @@ + diff --git a/packages/contracts/src/registry/icons/fast-agent.svg b/packages/contracts/src/registry/icons/fast-agent.svg new file mode 100644 index 00000000000..a07fab2886c --- /dev/null +++ b/packages/contracts/src/registry/icons/fast-agent.svg @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/contracts/src/registry/icons/gemini.svg b/packages/contracts/src/registry/icons/gemini.svg new file mode 100644 index 00000000000..588d89c52ab --- /dev/null +++ b/packages/contracts/src/registry/icons/gemini.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/github-copilot-cli.svg b/packages/contracts/src/registry/icons/github-copilot-cli.svg new file mode 100644 index 00000000000..626d33badc4 --- /dev/null +++ b/packages/contracts/src/registry/icons/github-copilot-cli.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/contracts/src/registry/icons/glm-acp-agent.svg b/packages/contracts/src/registry/icons/glm-acp-agent.svg new file mode 100644 index 00000000000..d552d2a3d08 --- /dev/null +++ b/packages/contracts/src/registry/icons/glm-acp-agent.svg @@ -0,0 +1 @@ +Z.ai diff --git a/packages/contracts/src/registry/icons/goose.svg b/packages/contracts/src/registry/icons/goose.svg new file mode 100644 index 00000000000..c4928854263 --- /dev/null +++ b/packages/contracts/src/registry/icons/goose.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/junie.svg b/packages/contracts/src/registry/icons/junie.svg new file mode 100644 index 00000000000..63b60e8f3a9 --- /dev/null +++ b/packages/contracts/src/registry/icons/junie.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/contracts/src/registry/icons/kilo.svg b/packages/contracts/src/registry/icons/kilo.svg new file mode 100644 index 00000000000..8af6e96f34d --- /dev/null +++ b/packages/contracts/src/registry/icons/kilo.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/kimi.svg b/packages/contracts/src/registry/icons/kimi.svg new file mode 100644 index 00000000000..4f7547cf79f --- /dev/null +++ b/packages/contracts/src/registry/icons/kimi.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/minion-code.svg b/packages/contracts/src/registry/icons/minion-code.svg new file mode 100644 index 00000000000..eb3d8eb31d7 --- /dev/null +++ b/packages/contracts/src/registry/icons/minion-code.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/contracts/src/registry/icons/mistral-vibe.svg b/packages/contracts/src/registry/icons/mistral-vibe.svg new file mode 100644 index 00000000000..b13631b96d9 --- /dev/null +++ b/packages/contracts/src/registry/icons/mistral-vibe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/contracts/src/registry/icons/nova.svg b/packages/contracts/src/registry/icons/nova.svg new file mode 100644 index 00000000000..5e19f588792 --- /dev/null +++ b/packages/contracts/src/registry/icons/nova.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/contracts/src/registry/icons/opencode.svg b/packages/contracts/src/registry/icons/opencode.svg new file mode 100644 index 00000000000..a38d4cf5a96 --- /dev/null +++ b/packages/contracts/src/registry/icons/opencode.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/pi-acp.svg b/packages/contracts/src/registry/icons/pi-acp.svg new file mode 100644 index 00000000000..68ea8fd7f71 --- /dev/null +++ b/packages/contracts/src/registry/icons/pi-acp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/contracts/src/registry/icons/poolside.svg b/packages/contracts/src/registry/icons/poolside.svg new file mode 100644 index 00000000000..91de4c46d40 --- /dev/null +++ b/packages/contracts/src/registry/icons/poolside.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/qoder.svg b/packages/contracts/src/registry/icons/qoder.svg new file mode 100644 index 00000000000..417d83693dd --- /dev/null +++ b/packages/contracts/src/registry/icons/qoder.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/qwen-code.svg b/packages/contracts/src/registry/icons/qwen-code.svg new file mode 100644 index 00000000000..78f88f2831c --- /dev/null +++ b/packages/contracts/src/registry/icons/qwen-code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/contracts/src/registry/icons/sigit.svg b/packages/contracts/src/registry/icons/sigit.svg new file mode 100644 index 00000000000..334fc95cbab --- /dev/null +++ b/packages/contracts/src/registry/icons/sigit.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + diff --git a/packages/contracts/src/registry/icons/stakpak.svg b/packages/contracts/src/registry/icons/stakpak.svg new file mode 100644 index 00000000000..64425076ed1 --- /dev/null +++ b/packages/contracts/src/registry/icons/stakpak.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/contracts/src/registry/icons/vtcode.svg b/packages/contracts/src/registry/icons/vtcode.svg new file mode 100644 index 00000000000..b47c7b11923 --- /dev/null +++ b/packages/contracts/src/registry/icons/vtcode.svg @@ -0,0 +1,4 @@ + + + VT + \ No newline at end of file diff --git a/packages/contracts/src/registry/index.ts b/packages/contracts/src/registry/index.ts new file mode 100644 index 00000000000..524ab5a60f5 --- /dev/null +++ b/packages/contracts/src/registry/index.ts @@ -0,0 +1,17 @@ +import * as Schema from "effect/Schema"; + +import { AcpRegistryDocument, type AcpRegistryEntry } from "../acpRegistry.ts"; +import registryJson from "./registry.json" with { type: "json" }; + +const document = Schema.decodeUnknownSync(AcpRegistryDocument)(registryJson); + +export const ACP_REGISTRY: ReadonlyArray = document.agents; + +export const ACP_REGISTRY_BY_ID: ReadonlyMap = new Map( + ACP_REGISTRY.map((entry) => [entry.id, entry] as const), +); + +export const ACP_REGISTRY_VERSION = document.version; + +export const acpRegistryEntryById = (id: string): AcpRegistryEntry | undefined => + ACP_REGISTRY_BY_ID.get(id); diff --git a/packages/contracts/src/registry/registry.json b/packages/contracts/src/registry/registry.json new file mode 100644 index 00000000000..1865643206c --- /dev/null +++ b/packages/contracts/src/registry/registry.json @@ -0,0 +1,1203 @@ +{ + "version": "1.0.0", + "agents": [ + { + "id": "agoragentic-acp", + "name": "Agoragentic", + "version": "1.3.0", + "description": "Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.", + "repository": "https://github.com/rhein1/agoragentic-integrations", + "website": "https://agoragentic.com", + "authors": [ + "ACRE / Agoragentic" + ], + "license": "MIT", + "distribution": { + "npx": { + "package": "agoragentic-mcp@1.3.0", + "args": [ + "--acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/agoragentic-acp.svg" + }, + { + "id": "amp-acp", + "name": "Amp", + "version": "0.7.0", + "description": "ACP wrapper for Amp - the frontier coding agent", + "repository": "https://github.com/tao12345666333/amp-acp", + "authors": [ + "tao12345666333" + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/amp-acp.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.7.0/amp-acp-darwin-aarch64.tar.gz", + "cmd": "./amp-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.7.0/amp-acp-darwin-x86_64.tar.gz", + "cmd": "./amp-acp" + }, + "linux-aarch64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.7.0/amp-acp-linux-aarch64.tar.gz", + "cmd": "./amp-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.7.0/amp-acp-linux-x86_64.tar.gz", + "cmd": "./amp-acp" + }, + "windows-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.7.0/amp-acp-windows-x86_64.zip", + "cmd": "amp-acp.exe" + } + } + } + }, + { + "id": "auggie", + "name": "Auggie CLI", + "version": "0.27.2", + "description": "Augment Code's powerful software agent, backed by industry-leading context engine", + "repository": "https://github.com/augmentcode/auggie", + "website": "https://www.augmentcode.com/", + "authors": [ + "Augment Code " + ], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/auggie.svg", + "distribution": { + "npx": { + "package": "@augmentcode/auggie@0.27.2", + "args": [ + "--acp" + ], + "env": { + "AUGMENT_DISABLE_AUTO_UPDATE": "1" + } + } + } + }, + { + "id": "autohand", + "name": "Autohand Code", + "version": "0.2.1", + "description": "Autohand Code - AI coding agent powered by Autohand AI", + "repository": "https://github.com/autohandai/autohand-acp", + "website": "https://www.autohand.ai/cli/", + "authors": [ + "Autohand AI" + ], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@autohandai/autohand-acp@0.2.1" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/autohand.svg" + }, + { + "id": "claude-acp", + "name": "Claude Agent", + "version": "0.35.0", + "description": "ACP wrapper for Anthropic's Claude", + "repository": "https://github.com/agentclientprotocol/claude-agent-acp", + "authors": [ + "Anthropic", + "Zed Industries", + "JetBrains" + ], + "license": "proprietary", + "distribution": { + "npx": { + "package": "@agentclientprotocol/claude-agent-acp@0.35.0" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg" + }, + { + "id": "cline", + "name": "Cline", + "version": "3.0.5", + "description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", + "repository": "https://github.com/cline/cline", + "website": "https://cline.bot/cli", + "authors": [ + "Cline Bot Inc." + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cline.svg", + "distribution": { + "npx": { + "package": "cline@3.0.5", + "args": [ + "--acp" + ] + } + } + }, + { + "id": "codebuddy-code", + "name": "Codebuddy Code", + "version": "2.97.2", + "description": "Tencent Cloud's official intelligent coding tool", + "website": "https://www.codebuddy.cn/cli/", + "authors": [ + "Tencent Cloud" + ], + "license": "Proprietary", + "distribution": { + "npx": { + "package": "@tencent-ai/codebuddy-code@2.97.2", + "args": [ + "--acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codebuddy-code.svg" + }, + { + "id": "codex-acp", + "name": "Codex CLI", + "version": "0.14.0", + "description": "ACP adapter for OpenAI's coding assistant", + "repository": "https://github.com/zed-industries/codex-acp", + "authors": [ + "OpenAI", + "Zed Industries" + ], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-aarch64-apple-darwin.tar.gz", + "cmd": "./codex-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-x86_64-apple-darwin.tar.gz", + "cmd": "./codex-acp" + }, + "linux-aarch64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-aarch64-unknown-linux-gnu.tar.gz", + "cmd": "./codex-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./codex-acp" + }, + "windows-aarch64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-aarch64-pc-windows-msvc.zip", + "cmd": "./codex-acp.exe" + }, + "windows-x86_64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-x86_64-pc-windows-msvc.zip", + "cmd": "./codex-acp.exe" + } + }, + "npx": { + "package": "@zed-industries/codex-acp@0.14.0" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codex-acp.svg" + }, + { + "id": "cortex-code", + "name": "Cortex Code", + "version": "1.0.73", + "description": "Snowflake's Cortex Code coding agent", + "repository": "https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code", + "authors": [ + "Snowflake" + ], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-darwin-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-darwin-arm64/cortex", + "args": [ + "acp", + "serve" + ] + }, + "darwin-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-darwin-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-darwin-amd64/cortex", + "args": [ + "acp", + "serve" + ] + }, + "linux-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-linux-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-linux-amd64/cortex", + "args": [ + "acp", + "serve" + ] + }, + "linux-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-linux-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-linux-arm64/cortex", + "args": [ + "acp", + "serve" + ] + }, + "windows-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-windows-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-windows-amd64/cortex.exe", + "args": [ + "acp", + "serve" + ] + }, + "windows-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-windows-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-windows-arm64/cortex.exe", + "args": [ + "acp", + "serve" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cortex-code.svg" + }, + { + "id": "corust-agent", + "name": "Corust Agent", + "version": "0.6.0", + "description": "Co-building with a seasoned Rust partner.", + "repository": "https://github.com/Corust-ai/corust-agent-release", + "website": "https://corust.ai/", + "authors": [ + "Corust AI " + ], + "license": "GPL-3.0-or-later", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-darwin-arm64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-darwin-x64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-linux-x64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "windows-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-windows-x64.zip", + "cmd": "./corust-agent-acp.exe" + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/corust-agent.svg" + }, + { + "id": "crow-cli", + "name": "crow-cli", + "version": "0.1.23", + "description": "Minimal ACP Native Coding Agent", + "repository": "https://github.com/crow-cli/crow-cli", + "website": "https://crow-ai.dev", + "authors": [ + "Thomas Wood" + ], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.23/crow-cli-darwin-aarch64.tar.gz", + "cmd": "./crow-cli", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.23/crow-cli-darwin-x86_64.tar.gz", + "cmd": "./crow-cli", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.23/crow-cli-linux-aarch64.tar.gz", + "cmd": "./crow-cli", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.23/crow-cli-linux-x86_64.tar.gz", + "cmd": "./crow-cli", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.23/crow-cli-windows-x86_64.zip", + "cmd": "./crow-cli.exe", + "args": [ + "acp" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/crow-cli.svg" + }, + { + "id": "cursor", + "name": "Cursor", + "version": "2026.05.09", + "description": "Cursor's coding agent", + "website": "https://cursor.com/docs/cli/acp", + "authors": [ + "Cursor" + ], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.05.09-0afadcc/darwin/arm64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.05.09-0afadcc/darwin/x64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.05.09-0afadcc/linux/arm64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.05.09-0afadcc/linux/x64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": [ + "acp" + ] + }, + "windows-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.05.09-0afadcc/windows/arm64/agent-cli-package.zip", + "cmd": "./dist-package\\cursor-agent.cmd", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.05.09-0afadcc/windows/x64/agent-cli-package.zip", + "cmd": "./dist-package\\cursor-agent.cmd", + "args": [ + "acp" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cursor.svg" + }, + { + "id": "deepagents", + "name": "DeepAgents", + "version": "0.1.7", + "description": "Batteries-included AI coding and general purpose agent powered by LangChain.", + "repository": "https://github.com/langchain-ai/deepagentsjs", + "website": "https://docs.langchain.com/oss/javascript/deepagents/overview", + "authors": [ + "LangChain" + ], + "license": "MIT", + "distribution": { + "npx": { + "package": "deepagents-acp@0.1.7", + "args": [] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/deepagents.svg" + }, + { + "id": "dimcode", + "name": "DimCode", + "version": "0.0.66", + "description": "A coding agent that puts leading models at your command.", + "website": "https://dimcode.dev/docs/acp.html", + "authors": [ + "ArcShips" + ], + "license": "proprietary", + "distribution": { + "npx": { + "package": "dimcode@0.0.66", + "args": [ + "acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dimcode.svg" + }, + { + "id": "dirac", + "name": "Dirac", + "version": "0.3.44", + "description": "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.", + "repository": "https://github.com/dirac-run/dirac", + "website": "https://dirac.run", + "authors": [ + "Dirac Delta Labs" + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dirac.svg", + "distribution": { + "npx": { + "package": "dirac-cli@0.3.44", + "args": [ + "--acp" + ] + } + } + }, + { + "id": "factory-droid", + "name": "Factory Droid", + "version": "0.128.0", + "description": "Factory Droid - AI coding agent powered by Factory AI", + "website": "https://factory.ai/product/cli", + "authors": [ + "Factory AI" + ], + "license": "proprietary", + "distribution": { + "npx": { + "package": "droid@0.128.0", + "args": [ + "exec", + "--output-format", + "acp-daemon" + ], + "env": { + "DROID_DISABLE_AUTO_UPDATE": "true", + "FACTORY_DROID_AUTO_UPDATE_ENABLED": "false" + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/factory-droid.svg" + }, + { + "id": "fast-agent", + "name": "fast-agent", + "version": "0.7.5", + "description": "Code and build agents with comprehensive multi-provider support", + "repository": "https://github.com/evalstate/fast-agent", + "website": "https://fast-agent.ai", + "authors": [ + "enquiries@fast-agent.ai" + ], + "license": "Apache 2.0", + "distribution": { + "uvx": { + "package": "fast-agent-acp==0.7.5", + "args": [ + "-x" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/fast-agent.svg" + }, + { + "id": "gemini", + "name": "Gemini CLI", + "version": "0.42.0", + "description": "Google's official CLI for Gemini", + "repository": "https://github.com/google-gemini/gemini-cli", + "website": "https://geminicli.com", + "authors": [ + "Google" + ], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@google/gemini-cli@0.42.0", + "args": [ + "--acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/gemini.svg" + }, + { + "id": "github-copilot-cli", + "name": "GitHub Copilot", + "version": "1.0.48", + "description": "GitHub's AI pair programmer", + "repository": "https://github.com/github/copilot-cli", + "website": "https://github.com/features/copilot/cli/", + "authors": [ + "GitHub" + ], + "license": "proprietary", + "distribution": { + "npx": { + "package": "@github/copilot@1.0.48", + "args": [ + "--acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/github-copilot-cli.svg" + }, + { + "id": "glm-acp-agent", + "name": "GLM Agent", + "version": "1.1.4", + "description": "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", + "repository": "https://github.com/stefandevo/glm-acp-agent", + "authors": [ + "Stefan de Vogelaere" + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/glm-acp-agent.svg", + "distribution": { + "npx": { + "package": "glm-acp-agent@1.1.4" + } + } + }, + { + "id": "goose", + "name": "goose", + "version": "1.34.1", + "description": "A local, extensible, open source AI agent that automates engineering tasks", + "repository": "https://github.com/block/goose", + "website": "https://block.github.io/goose/", + "authors": [ + "Block" + ], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/block/goose/releases/download/v1.34.1/goose-aarch64-apple-darwin.tar.bz2", + "cmd": "./goose", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.34.1/goose-x86_64-apple-darwin.tar.bz2", + "cmd": "./goose", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://github.com/block/goose/releases/download/v1.34.1/goose-aarch64-unknown-linux-gnu.tar.bz2", + "cmd": "./goose", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.34.1/goose-x86_64-unknown-linux-gnu.tar.bz2", + "cmd": "./goose", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.34.1/goose-x86_64-pc-windows-msvc.zip", + "cmd": "./goose-package\\goose.exe", + "args": [ + "acp" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/goose.svg" + }, + { + "id": "junie", + "name": "Junie", + "version": "1588.20.0", + "description": "AI Coding Agent by JetBrains", + "repository": "https://github.com/JetBrains/junie", + "website": "https://junie.jetbrains.com", + "authors": [ + "JetBrains" + ], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1588.20/junie-release-1588.20-macos-aarch64.zip", + "cmd": "./Applications/junie.app/Contents/MacOS/junie", + "args": [ + "--acp=true" + ] + }, + "darwin-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1588.20/junie-release-1588.20-macos-amd64.zip", + "cmd": "./Applications/junie.app/Contents/MacOS/junie", + "args": [ + "--acp=true" + ] + }, + "linux-aarch64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1588.20/junie-release-1588.20-linux-aarch64.zip", + "cmd": "./junie-app/bin/junie", + "args": [ + "--acp=true" + ] + }, + "linux-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1588.20/junie-release-1588.20-linux-amd64.zip", + "cmd": "./junie-app/bin/junie", + "args": [ + "--acp=true" + ] + }, + "windows-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1588.20/junie-release-1588.20-windows-amd64.zip", + "cmd": "./junie/junie.exe", + "args": [ + "--acp=true" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/junie.svg" + }, + { + "id": "kilo", + "name": "Kilo", + "version": "7.3.0", + "description": "The open source coding agent", + "repository": "https://github.com/Kilo-Org/kilocode", + "website": "https://kilo.ai/", + "authors": [ + "Kilo Code" + ], + "license": "MIT", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.0/kilo-darwin-arm64.zip", + "cmd": "./kilo", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.0/kilo-darwin-x64.zip", + "cmd": "./kilo", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.0/kilo-linux-arm64.tar.gz", + "cmd": "./kilo", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.0/kilo-linux-x64.tar.gz", + "cmd": "./kilo", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.0/kilo-windows-x64.zip", + "cmd": "./kilo.exe", + "args": [ + "acp" + ] + } + }, + "npx": { + "package": "@kilocode/cli@7.3.0", + "args": [ + "acp" + ] + } + } + }, + { + "id": "kimi", + "name": "Kimi CLI", + "version": "1.44.0", + "description": "Moonshot AI's coding assistant", + "repository": "https://github.com/MoonshotAI/kimi-cli", + "website": "https://moonshotai.github.io/kimi-cli/", + "authors": [ + "Moonshot AI" + ], + "license": "MIT", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.44.0/kimi-1.44.0-aarch64-apple-darwin.tar.gz", + "cmd": "./kimi", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.44.0/kimi-1.44.0-aarch64-unknown-linux-gnu.tar.gz", + "cmd": "./kimi", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.44.0/kimi-1.44.0-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./kimi", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.44.0/kimi-1.44.0-x86_64-pc-windows-msvc.zip", + "cmd": "./kimi.exe", + "args": [ + "acp" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/kimi.svg" + }, + { + "id": "minion-code", + "name": "Minion Code", + "version": "0.1.44", + "description": "An enhanced AI code assistant built on the Minion framework with rich development tools", + "repository": "https://github.com/femto/minion-code", + "authors": [ + "femto" + ], + "license": "AGPL-3.0", + "distribution": { + "uvx": { + "package": "minion-code@0.1.44", + "args": [ + "acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/minion-code.svg" + }, + { + "id": "mistral-vibe", + "name": "Mistral Vibe", + "version": "2.9.3", + "description": "Mistral's open-source coding assistant", + "repository": "https://github.com/mistralai/mistral-vibe", + "website": "https://mistral.ai/products/vibe", + "authors": [ + "Mistral AI" + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/mistral-vibe.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-darwin-aarch64-2.9.3.zip", + "cmd": "./vibe-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-darwin-x86_64-2.9.3.zip", + "cmd": "./vibe-acp" + }, + "linux-aarch64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-linux-aarch64-2.9.3.zip", + "cmd": "./vibe-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-linux-x86_64-2.9.3.zip", + "cmd": "./vibe-acp" + }, + "windows-aarch64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-windows-aarch64-2.9.3.zip", + "cmd": "./vibe-acp.exe" + }, + "windows-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-windows-x86_64-2.9.3.zip", + "cmd": "./vibe-acp.exe" + } + } + } + }, + { + "id": "nova", + "name": "Nova", + "version": "1.1.9", + "description": "Nova by Compass AI - a fully-fledged software engineer at your command", + "repository": "https://github.com/Compass-Agentic-Platform/nova", + "website": "https://www.compassap.ai/portfolio/nova.html", + "authors": [ + "Compass AI" + ], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/nova.svg", + "distribution": { + "npx": { + "package": "@compass-ai/nova@1.1.9", + "args": [ + "acp" + ] + } + } + }, + { + "id": "opencode", + "name": "OpenCode", + "version": "1.15.4", + "description": "The open source coding agent", + "repository": "https://github.com/anomalyco/opencode", + "website": "https://opencode.ai", + "authors": [ + "Anomaly" + ], + "license": "MIT", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/opencode.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-arm64.zip", + "cmd": "./opencode", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-x64.zip", + "cmd": "./opencode", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-arm64.tar.gz", + "cmd": "./opencode", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-x64.tar.gz", + "cmd": "./opencode", + "args": [ + "acp" + ] + }, + "windows-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-windows-arm64.zip", + "cmd": "./opencode", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-windows-x64.zip", + "cmd": "./opencode.exe", + "args": [ + "acp" + ] + } + } + } + }, + { + "id": "pi-acp", + "name": "pi ACP", + "version": "0.0.27", + "description": "ACP adapter for pi coding agent", + "repository": "https://github.com/svkozak/pi-acp", + "authors": [ + "Sergii Kozak " + ], + "license": "MIT", + "distribution": { + "npx": { + "package": "pi-acp@0.0.27" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/pi-acp.svg" + }, + { + "id": "poolside", + "name": "Poolside", + "version": "1.0.0", + "description": "Poolside's coding agent", + "website": "https://poolside.ai", + "authors": [ + "Poolside " + ], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.0/pool-darwin-arm64.tar.gz", + "cmd": "./pool-darwin-arm64", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.0/pool-darwin-amd64.tar.gz", + "cmd": "./pool-darwin-amd64", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.0/pool-linux-arm64.tar.gz", + "cmd": "./pool-linux-arm64", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.0/pool-linux-amd64.tar.gz", + "cmd": "./pool-linux-amd64", + "args": [ + "acp" + ] + }, + "windows-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.0/pool-windows-arm64.tar.gz", + "cmd": "./pool-windows-arm64.exe", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.0/pool-windows-amd64.tar.gz", + "cmd": "./pool-windows-amd64.exe", + "args": [ + "acp" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/poolside.svg" + }, + { + "id": "qoder", + "name": "Qoder CLI", + "version": "0.2.14", + "description": "AI coding assistant with agentic capabilities", + "website": "https://qoder.com", + "authors": [ + "Qoder AI" + ], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/qoder.svg", + "distribution": { + "npx": { + "package": "@qoder-ai/qodercli@0.2.14", + "args": [ + "--acp" + ] + } + } + }, + { + "id": "qwen-code", + "name": "Qwen Code", + "version": "0.15.11", + "description": "Alibaba's Qwen coding assistant", + "repository": "https://github.com/QwenLM/qwen-code", + "website": "https://qwenlm.github.io/qwen-code-docs/en/users/overview", + "authors": [ + "Alibaba Qwen Team" + ], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@qwen-code/qwen-code@0.15.11", + "args": [ + "--acp", + "--experimental-skills" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/qwen-code.svg" + }, + { + "id": "sigit", + "name": "siGit Code", + "version": "1.0.3", + "description": "Local-first coding agent. Runs entirely on your machine with optional on-device LLM inference via Onde.", + "repository": "https://github.com/getsigit/sigit", + "website": "https://github.com/getsigit/sigit", + "authors": [ + "smbCloud" + ], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.0.3/sigit-macos-arm64.tar.gz", + "cmd": "./sigit" + }, + "darwin-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.0.3/sigit-macos-amd64.tar.gz", + "cmd": "./sigit" + }, + "linux-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.0.3/sigit-linux-arm64", + "cmd": "./sigit-linux-arm64" + }, + "linux-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.0.3/sigit-linux-amd64", + "cmd": "./sigit-linux-amd64" + }, + "windows-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.0.3/sigit-win-arm64.exe", + "cmd": "./sigit-win-arm64.exe" + }, + "windows-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.0.3/sigit-win-amd64.exe", + "cmd": "./sigit-win-amd64.exe" + } + }, + "npx": { + "package": "@smbcloud/sigit@1.0.3" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/sigit.svg" + }, + { + "id": "stakpak", + "name": "Stakpak", + "version": "0.3.81", + "description": "Open-source DevOps agent in Rust with enterprise-grade security", + "repository": "https://github.com/stakpak/agent", + "website": "https://stakpak.dev", + "authors": [ + "Stakpak Team " + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/stakpak.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.81/stakpak-darwin-aarch64.tar.gz", + "cmd": "./stakpak", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.81/stakpak-darwin-x86_64.tar.gz", + "cmd": "./stakpak", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.81/stakpak-linux-aarch64.tar.gz", + "cmd": "./stakpak", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.81/stakpak-linux-x86_64.tar.gz", + "cmd": "./stakpak", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.81/stakpak-windows-x86_64.zip", + "cmd": "./stakpak.exe", + "args": [ + "acp" + ] + } + } + } + }, + { + "id": "vtcode", + "name": "VT Code", + "version": "0.96.14", + "description": "An open-source coding agent with LLM-native code understanding and robust shell safety. Supports multiple LLM providers with automatic failover and efficient context management.", + "repository": "https://github.com/vinhnx/VTCode", + "website": "https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md", + "authors": [ + "vinhnx" + ], + "license": "MIT", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-aarch64-apple-darwin.tar.gz", + "cmd": "./vtcode", + "args": [ + "acp" + ], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "darwin-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-apple-darwin.tar.gz", + "cmd": "./vtcode", + "args": [ + "acp" + ], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "linux-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./vtcode", + "args": [ + "acp" + ], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "windows-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-pc-windows-msvc.zip", + "cmd": "vtcode.exe", + "args": [ + "acp" + ], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/vtcode.svg" + } + ] +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index eb0c82bebd0..2e0235c6db3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,11 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { + AcpRegistryEntryWithStatus, + AcpRegistryError, + AcpRegistryInstallState, +} from "./acpRegistry.ts"; import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; import { AuthAccessStreamEvent } from "./auth.ts"; import { @@ -152,6 +157,12 @@ export const WS_METHODS = { sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", + // ACP registry methods + acpRegistryList: "acpRegistry.list", + acpRegistryInstall: "acpRegistry.install", + acpRegistryUninstall: "acpRegistry.uninstall", + acpRegistryAuthenticate: "acpRegistry.authenticate", + // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", @@ -276,6 +287,30 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: FilesystemBrowseError, }); +export const WsAcpRegistryListRpc = Rpc.make(WS_METHODS.acpRegistryList, { + payload: Schema.Struct({}), + success: Schema.Array(AcpRegistryEntryWithStatus), + error: AcpRegistryError, +}); + +export const WsAcpRegistryInstallRpc = Rpc.make(WS_METHODS.acpRegistryInstall, { + payload: Schema.Struct({ agentId: Schema.String }), + success: AcpRegistryInstallState, + error: AcpRegistryError, +}); + +export const WsAcpRegistryUninstallRpc = Rpc.make(WS_METHODS.acpRegistryUninstall, { + payload: Schema.Struct({ agentId: Schema.String }), + success: Schema.Struct({ agentId: Schema.String }), + error: AcpRegistryError, +}); + +export const WsAcpRegistryAuthenticateRpc = Rpc.make(WS_METHODS.acpRegistryAuthenticate, { + payload: Schema.Struct({ instanceId: ProviderInstanceId, methodId: Schema.String }), + success: Schema.Struct({}), + error: AcpRegistryError, +}); + export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -509,4 +544,8 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, + WsAcpRegistryListRpc, + WsAcpRegistryInstallRpc, + WsAcpRegistryUninstallRpc, + WsAcpRegistryAuthenticateRpc, ); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 0081c00ac7e..98ba9fd06ac 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { AcpAuthMethod } from "./acpRegistry.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { ServerAuthDescriptor } from "./auth.ts"; import { @@ -55,6 +56,12 @@ export const ServerProviderAuth = Schema.Struct({ type: Schema.optional(TrimmedNonEmptyString), label: Schema.optional(TrimmedNonEmptyString), email: Schema.optional(TrimmedNonEmptyString), + /** + * Auth methods advertised by the agent (ACP `initialize` response). + * Populated by ACP registry drivers from the install-time probe; absent + * for built-in drivers. + */ + authMethods: Schema.optionalKey(Schema.Array(AcpAuthMethod)), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..fdd5773d005 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; +import { AcpRegistryInstallState } from "./acpRegistry.ts"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; @@ -331,6 +332,28 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const AcpRegistrySettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Binary path", + description: + "Override the executable used to spawn this ACP registry agent. Leave blank to use the installed distribution.", + providerSettingsForm: { clearWhenEmpty: "omit" }, + }), + ), + }, + { + order: ["binaryPath"], + }, +); +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(""))), @@ -379,6 +402,22 @@ export const ServerSettings = Schema.Struct({ providerInstances: Schema.Record(ProviderInstanceId, ProviderInstanceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + /** + * Per-agent install state for the bundled ACP registry. Keyed by + * `AcpRegistryEntry.id`; absence means "not installed". Records the + * resolved version and the distribution channel chosen at install time + * so a subsequent server restart can re-spawn the same agent without + * re-probing the network. + * + * For `binary` installs, `binaryPath` holds the absolute path of the + * extracted executable; for `npx`/`uvx` installs the spawn target is + * resolved at runtime from the bundled registry entry. The field is a + * plain `Record` because the keys aren't `ProviderInstanceId` + * slugs — they're the upstream registry ids (e.g. `gemini`, `goose`). + */ + acpRegistryInstalls: Schema.Record(Schema.String, AcpRegistryInstallState).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -471,6 +510,10 @@ export const ServerSettingsPatch = Schema.Struct({ // patches risk leaving driver-specific config in a half-merged state. // The web UI sends a fully-formed map every time it edits this field. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), + // Whole-map replacement for ACP registry install state. The server is + // the source of truth for this field (install/uninstall RPCs mutate it); + // the patch path exists for symmetry but is rarely used by the UI. + acpRegistryInstalls: Schema.optionalKey(Schema.Record(Schema.String, AcpRegistryInstallState)), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 3052726edef..ff4a26e31e3 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -451,7 +451,13 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( Effect.forkScoped, ); - let nextRpcRequestId = 1n << 32n; + // JSON-RPC `id` MUST fit in 32-bit signed int for JVM-based agents (e.g. JetBrains Junie): + // Kotlin/Java parses `id` as Int, and IDs > 2^31 - 1 silently fail. Effect's RpcClient + // historically started at 1n << 32n to namespace away from ext-request IDs, but that + // wedges agents like Junie. Use 100_000_000 — well above the ext-request counter's range + // (also starting at 1) yet still safely below 2^31 - 1 (~2.147B), leaving ~2B headroom + // for typed RPC calls within a session. + let nextRpcRequestId = 100_000_000n; const rpc = yield* RpcClient.make(AcpRpcs.AgentRpcs, { generateRequestId: () => nextRpcRequestId++ as never, }).pipe(Effect.provideService(RpcClient.Protocol, transport.clientProtocol)); diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 3e8114f0dc2..36500217783 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -161,6 +161,40 @@ describe("serverSettings helpers", () => { }); }); + it("replaces acpRegistryInstalls so uninstalled agents disappear", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + acpRegistryInstalls: { + "claude-acp": { + version: "0.34.1", + installedAt: "2026-05-16T00:00:00.000Z", + distribution: "npx" as const, + }, + gemini: { + version: "0.42.0", + installedAt: "2026-05-14T00:00:00.000Z", + distribution: "npx" as const, + }, + }, + }; + + // Passing a smaller record must DELETE the omitted key. With deep-merge + // semantics it would silently no-op and "claude-acp" would survive. + expect( + Object.keys( + applyServerSettingsPatch(current, { + acpRegistryInstalls: { + gemini: { + version: "0.42.0", + installedAt: "2026-05-14T00:00:00.000Z", + distribution: "npx" as const, + }, + }, + }).acpRegistryInstalls, + ), + ).toEqual(["gemini"]); + }); + it("replaces providerInstances maps so omitted instance fields are cleared", () => { const codexId = ProviderInstanceId.make("codex"); const current = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 1bbf466f60b..0ad98f66f5e 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -78,11 +78,17 @@ export function applyServerSettingsPatch( const selectionPatch = patch.textGenerationModelSelection; const { automaticGitFetchInterval, ...patchForMerge } = patch; const next = deepMerge(current, patchForMerge); + // Record-shaped fields (providerInstances, acpRegistryInstalls) need + // replace-on-set semantics — `deepMerge` can't represent key deletion, so + // removing an entry by passing a smaller record would silently no-op. const nextWithReplacements = { ...next, ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + ...(patch.acpRegistryInstalls !== undefined + ? { acpRegistryInstalls: patch.acpRegistryInstalls } + : {}), ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), }; if (!selectionPatch) { diff --git a/scripts/sync-acp-registry.ts b/scripts/sync-acp-registry.ts new file mode 100644 index 00000000000..f1642a897f6 --- /dev/null +++ b/scripts/sync-acp-registry.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const REGISTRY_JSON_PATH = path.join(REPO_ROOT, "packages/contracts/src/registry/registry.json"); +const ICON_DIRS = [ + path.join(REPO_ROOT, "packages/contracts/src/registry/icons"), + path.join(REPO_ROOT, "apps/web/public/acp-icons"), +]; +const DEFAULT_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; + +const EXCLUDED_AGENT_IDS = new Set(); + +interface RegistryAgent { + id: string; + name: string; + version: string; + description: string; + icon?: string; + [key: string]: unknown; +} + +interface RegistryDocument { + version: string; + agents: RegistryAgent[]; +} + +interface CliArgs { + registryUrl: string; + skipIcons: boolean; +} + +const USAGE = "Usage: sync-acp-registry [--registry-url ] [--skip-icons]"; + +function parseArgs(argv: ReadonlyArray): CliArgs { + const args: CliArgs = { registryUrl: DEFAULT_REGISTRY_URL, skipIcons: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--registry-url") { + const value = argv[++i]; + if (!value) throw new Error("--registry-url requires a value"); + args.registryUrl = value; + } else if (arg === "--skip-icons") { + args.skipIcons = true; + } else if (arg === "--help" || arg === "-h") { + process.stdout.write(`${USAGE}\n`); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return args; +} + +async function fetchRegistry(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Fetch failed (${response.status} ${response.statusText}) — ${url}`); + } + const payload = (await response.json()) as RegistryDocument; + if (!Array.isArray(payload.agents)) { + throw new Error("Registry payload did not contain an `agents` array"); + } + return payload; +} + +async function downloadIcon(agent: RegistryAgent): Promise { + if (typeof agent.icon !== "string" || agent.icon.length === 0) return false; + const response = await fetch(agent.icon); + if (!response.ok) return false; + const text = await response.text(); + if (!text.trimStart().startsWith("<")) return false; + await Promise.all( + ICON_DIRS.map((dir) => fs.writeFile(path.join(dir, `${agent.id}.svg`), text, "utf8")), + ); + return true; +} + +async function pruneStaleIcons(wantedIds: ReadonlySet): Promise { + const wanted = new Set(Array.from(wantedIds, (id) => `${id}.svg`)); + await Promise.all( + ICON_DIRS.map(async (dir) => { + const existing = await fs.readdir(dir).catch(() => [] as string[]); + await Promise.all( + existing + .filter((entry) => !wanted.has(entry)) + .map((entry) => fs.rm(path.join(dir, entry), { force: true })), + ); + }), + ); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + await Promise.all(ICON_DIRS.map((dir) => fs.mkdir(dir, { recursive: true }))); + + process.stdout.write(`Fetching ${args.registryUrl}\n`); + const upstream = await fetchRegistry(args.registryUrl); + + const filtered = upstream.agents + .filter((agent) => !EXCLUDED_AGENT_IDS.has(agent.id)) + .sort((a, b) => a.id.localeCompare(b.id)); + const excludedIds = upstream.agents + .filter((agent) => EXCLUDED_AGENT_IDS.has(agent.id)) + .map((agent) => agent.id); + + const snapshot: RegistryDocument = { version: upstream.version, agents: filtered }; + await fs.writeFile(REGISTRY_JSON_PATH, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8"); + + await pruneStaleIcons(new Set(filtered.map((agent) => agent.id))); + + let iconOk = 0; + let iconMissing = 0; + if (!args.skipIcons) { + process.stdout.write(`Downloading ${filtered.length} icons…\n`); + const results = await Promise.all( + filtered.map((agent) => downloadIcon(agent).catch(() => false)), + ); + results.forEach((ok, index) => { + if (ok) { + iconOk += 1; + return; + } + iconMissing += 1; + process.stderr.write(` ! icon missing for ${filtered[index]!.id}\n`); + }); + } + + process.stdout.write( + [ + `Synced ACP registry v${upstream.version}`, + ` agents bundled : ${filtered.length}`, + ` agents excluded: ${excludedIds.length} (${excludedIds.join(", ") || "—"})`, + ` icons : ${args.skipIcons ? "skipped" : `${iconOk} ok, ${iconMissing} missing`}`, + ` output : ${path.relative(REPO_ROOT, REGISTRY_JSON_PATH)}`, + "", + ].join("\n"), + ); +} + +main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exit(1); +});