diff --git a/AGENTS.md b/AGENTS.md index 380a9202683..074549c440d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,16 @@ Long term maintainability is a core priority. If you add new functionality, firs - `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. - `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile. +## 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..35b0d851ee3 --- /dev/null +++ b/apps/server/src/acpRegistry/AcpRegistryService.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "@effect/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..e22d97beca8 --- /dev/null +++ b/apps/server/src/acpRegistry/AcpRegistryService.ts @@ -0,0 +1,522 @@ +import * as NodeOS from "node:os"; + +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 { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +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 * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import * as ServerConfig from "../config.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import * as AcpSessionRuntime from "../provider/acp/AcpSessionRuntime.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as InstallManifest from "./installManifest.ts"; +import * as Installer from "./installer.ts"; +import { resolveCurrentPlatform } from "./platform.ts"; + +export class AcpRegistryService extends Context.Service< + AcpRegistryService, + { + readonly list: () => Effect.Effect, AcpRegistryError>; + readonly install: (agentId: string) => Effect.Effect; + readonly uninstall: (agentId: string) => Effect.Effect; + readonly authenticate: ( + instanceId: ProviderInstanceId, + methodId: string, + ) => Effect.Effect; + } +>()("t3/acpRegistry/AcpRegistryService") {} + +export function authProbeTimeoutForDistribution( + distribution: AcpRegistryDistributionKind, +): Duration.Input { + // Binary distributions are already cached locally and start immediately, so a short timeout is sufficient. + // npx and uvx require package download and dependency resolution, which can take significantly longer, + // especially on slow networks or for packages with many dependencies. The 25s timeout accommodates + // these operations while still providing reasonable feedback to the user. + switch (distribution) { + case "binary": + return "4 seconds"; + case "npx": + case "uvx": + return "25 seconds"; + } +} + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + const sessionRuntimeRepoOpt = yield* Effect.serviceOption( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + ); + const platform = resolveCurrentPlatform(hostPlatform, hostArchitecture); + const cacheRoot = config.acpRegistryCacheDir; + + const probeAuthMethods = ( + spawnTarget: Installer.SpawnTarget, + ): Effect.Effect< + | ReadonlyArray<{ + readonly id: string; + readonly name: string; + readonly description?: string; + }> + | undefined, + never + > => { + const probeCwd = NodeOS.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.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.orElseSucceed(() => undefined), + ); + }; + + const readSettings = settingsService.getSettings.pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + operation: "read-settings", + detail: "Failed to read server settings", + cause, + }), + ), + ); + + const readInstalls = InstallManifest.readInstalls.pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.provideService(ServerSettings.ServerSettingsService, settingsService), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new AcpRegistryError({ + operation: "read-install-manifest", + detail: "Failed to read install manifest", + cause, + }), + ), + ); + + const writeInstalls = (next: Readonly>) => + InstallManifest.writeInstalls(next).pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new AcpRegistryError({ + operation: "write-install-manifest", + detail: "Failed to write install manifest", + cause, + }), + ), + ); + + const writeInstances = (nextInstances: ProviderInstanceConfigMap) => + settingsService.updateSettings({ providerInstances: nextInstances }).pipe( + Effect.asVoid, + Effect.mapError( + (cause) => + new AcpRegistryError({ + operation: "write-settings", + detail: "Failed to persist server settings", + cause, + }), + ), + ); + + const requireEntry = (agentId: string): Effect.Effect => { + const entry = acpRegistryEntryById(agentId); + return entry + ? Effect.succeed(entry) + : Effect.fail( + new AcpRegistryError({ + operation: "resolve-agent", + 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.catchTags({ + AcpRegistryError: (error) => + Effect.logWarning("acp.registry backfill skipped", { + operation: error.operation, + agentId: error.agentId, + detail: error.detail, + cause: error.cause, + }), + }), + ); + + const isAcpRegistryError = Schema.is(AcpRegistryError); + + return AcpRegistryService.of({ + list: () => + Effect.gen(function* () { + const installs = yield* readInstalls; + return ACP_REGISTRY.map((entry) => + buildEntryStatus(entry, installs[entry.id], Installer.availableChannels(entry, platform)), + ); + }), + + install: (agentId) => + Effect.gen(function* () { + const entry = yield* requireEntry(agentId); + const result = yield* Effect.tryPromise({ + try: () => Installer.installAgent(entry, { cacheRoot, platform }), + catch: (cause) => + isAcpRegistryError(cause) + ? cause + : new AcpRegistryError({ + operation: "install", + agentId, + detail: "Install failed", + cause, + }), + }); + + const spawnTargetForProbe = Installer.resolveSpawnTarget(entry, result.state, { platform }); + const authMethods = spawnTargetForProbe + ? yield* probeAuthMethods(spawnTargetForProbe) + : undefined; + + const installs = yield* readInstalls; + const installedState = { + ...result.state, + ...(authMethods ? { authMethods } : {}), + }; + const nextInstalls = { + ...installs, + [agentId]: installedState, + }; + 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 installedState; + }), + + 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( + (cause) => + new AcpRegistryError({ + operation: "list-sessions", + agentId, + detail: "Failed to list provider sessions", + cause, + }), + ), + ); + 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({ + operation: "uninstall", + 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; + for (const [instanceId] of instancesForAgent) { + delete nextInstances[instanceId]; + } + yield* settingsService + .updateSettings({ + providerInstances: nextInstances as ProviderInstanceConfigMap, + }) + .pipe( + Effect.asVoid, + Effect.mapError( + (cause) => + new AcpRegistryError({ + operation: "delete-provider-instances", + agentId, + detail: "Failed to cascade-delete provider instances", + cause, + }), + ), + ); + } + + yield* Effect.tryPromise({ + try: () => Installer.uninstallAgent(entry, cacheRoot), + catch: (cause) => + isAcpRegistryError(cause) + ? cause + : new AcpRegistryError({ + operation: "uninstall", + agentId, + detail: "Uninstall failed", + cause, + }), + }); + + 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({ + operation: "authenticate", + agentId: instanceId, + detail: `Provider instance ${instanceId} not found`, + }); + } + const agentId = acpRegistryIdFromDriverKind(instance.driver); + if (!agentId) { + return yield* new AcpRegistryError({ + operation: "authenticate", + 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({ + operation: "authenticate", + agentId, + detail: `${entry.name} is not installed`, + }); + } + const spawnTarget = Installer.resolveSpawnTarget(entry, installState, { platform }); + if (!spawnTarget) { + return yield* new AcpRegistryError({ + operation: "authenticate", + agentId, + detail: `${entry.name} install state is missing a spawn target`, + }); + } + + const probeCwd = NodeOS.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.AcpSessionRuntime; + yield* runtime.start(); + }).pipe( + Effect.provide(runtimeLayer), + Effect.scoped, + Effect.mapError( + (cause) => + new AcpRegistryError({ + operation: "authenticate", + agentId, + detail: `Authentication failed for method '${methodId}'`, + 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( + (cause) => + new AcpRegistryError({ + operation: "write-settings", + agentId, + detail: "Failed to update provider instance", + cause, + }), + ), + ); + }), + } satisfies AcpRegistryService["Service"]); +}); + +export const layer = Layer.effect(AcpRegistryService, make); + +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 += 1) { + const candidate = `${base}-${n}`; + if (!existing.has(candidate)) return ProviderInstanceId.make(candidate); + } +} diff --git a/apps/server/src/acpRegistry/childProcessRegistry.ts b/apps/server/src/acpRegistry/childProcessRegistry.ts new file mode 100644 index 00000000000..c8b43edf72c --- /dev/null +++ b/apps/server/src/acpRegistry/childProcessRegistry.ts @@ -0,0 +1,79 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Layer-2 defense against orphaned ACP child processes. + * + * Effect's `Scope.close` finalizers run asynchronously. When Node receives SIGKILL (e.g. the + * dev runner force-kills our process), finalizers don't get a chance to run — children with + * detached process groups stay alive. + * + * This module: + * - Tracks every ACP child PID we spawn in a process-wide Set + * - On SIGINT / SIGTERM / 'exit', synchronously sends SIGTERM then SIGKILL to each tracked + * process group, so children can't outlive us regardless of how we're shut down. + * + * Layer 1 (`detached: true` + group-kill on scope close) handles graceful shutdown. + * Layer 3 (boot reaper) cleans up what survived. This is the middle layer. + */ +const trackedPids = new Set(); +let installed = false; + +function killGroupSafely(pid: number, signal: "SIGTERM" | "SIGKILL"): void { + try { + // Negative pid → signal the whole process group, including any forks the child created. + process.kill(-pid, signal); + } catch { + // Group may already be gone, or pid wasn't a group leader. Fall back to single-pid kill. + try { + process.kill(pid, signal); + } catch { + // Already dead — ignore. + } + } +} + +function reapAll(): void { + if (trackedPids.size === 0) return; + for (const pid of trackedPids) { + killGroupSafely(pid, "SIGTERM"); + } + // No async sleep available in synchronous exit handlers — best-effort SIGKILL immediately. + for (const pid of trackedPids) { + killGroupSafely(pid, "SIGKILL"); + } + trackedPids.clear(); +} + +function ensureShutdownHandlers(platform: NodeJS.Platform): void { + if (installed || platform === "win32") return; + installed = true; + // Order matters: register BEFORE other handlers so we run early in the shutdown sequence. + process.on("exit", reapAll); + process.on("SIGINT", () => { + reapAll(); + process.exit(130); + }); + process.on("SIGTERM", () => { + reapAll(); + process.exit(143); + }); + process.on("SIGHUP", () => { + reapAll(); + process.exit(129); + }); +} + +export function trackChildProcess(pid: number | undefined, platform: NodeJS.Platform): void { + if (pid === undefined || pid <= 0 || platform === "win32") return; + ensureShutdownHandlers(platform); + trackedPids.add(pid); +} + +export function untrackChildProcess(pid: number | undefined): void { + if (pid === undefined) return; + trackedPids.delete(pid); +} + +/** For diagnostics — current count of tracked children. */ +export function trackedChildProcessCount(): number { + return trackedPids.size; +} diff --git a/apps/server/src/acpRegistry/installManifest.test.ts b/apps/server/src/acpRegistry/installManifest.test.ts new file mode 100644 index 00000000000..b3ecc548bf0 --- /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 * as ServerConfig from "../config.ts"; +import * as ServerSettings 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 = () => + ServerSettings.layerTest().pipe( + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-install-manifest-test-", + }), + ), + ), + ); + +const manifestPath = Effect.gen(function* () { + const config = yield* ServerConfig.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* ServerSettings.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* ServerSettings.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* ServerSettings.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..10da4b52014 --- /dev/null +++ b/apps/server/src/acpRegistry/installManifest.ts @@ -0,0 +1,171 @@ +import { + AcpRegistryInstallState, + type AcpRegistryInstallState as AcpRegistryInstallStateType, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +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 = Cause.squash(migrationExit.cause); + const detail = error instanceof InstallManifestError ? error.detail : "Unknown migration error"; + yield* Effect.logWarning(`Failed to migrate installs to manifest: ${detail}`); + return settingsInstalls; + } + + // Cleanup: clear stale settings.json entries after successful migration + yield* settingsService.updateSettings({ acpRegistryInstalls: {} }).pipe( + Effect.asVoid, + Effect.mapError( + (cause) => + new InstallManifestError({ + detail: "Failed to cleanup stale settings after migration", + cause, + }), + ), + ); + yield* Effect.logInfo( + "Migrated ACP registry installs from settings.json to manifest and cleaned up stale entries", + ); + + 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..88d2809dc1a --- /dev/null +++ b/apps/server/src/acpRegistry/installer.test.ts @@ -0,0 +1,157 @@ +// @effect-diagnostics globalDate:off nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +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) => NodeCrypto.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 NodeFSP.mkdtemp(NodePath.join(NodeOS.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 = NodePath.join(cacheRoot, entry.id, entry.version, "bin", "test-agent"); + expect(result.state.binaryPath).toBe(binaryPath); + await expect(NodeFSP.readFile(binaryPath, "utf8")).resolves.toBe("#!/bin/sh\necho ok\n"); + expect(resolveSpawnTarget(entry, result.state, { platform: "linux-x86_64" })?.command).toBe( + binaryPath, + ); + + await uninstallAgent(entry, cacheRoot); + await expect(NodeFSP.stat(NodePath.join(cacheRoot, entry.id))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("rejects downloaded binaries when the manifest sha256 does not match", async () => { + const cacheRoot = await NodeFSP.mkdtemp(NodePath.join(NodeOS.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.toMatchObject({ + operation: "verify-download", + agentId: entry.id, + expectedChecksum: "0".repeat(64), + actualChecksum: sha256(bytes), + }); + }); + + it("keeps raw downloads when cmd resolves to the temporary archive path", async () => { + const cacheRoot = await NodeFSP.mkdtemp(NodePath.join(NodeOS.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 = NodePath.join(cacheRoot, entry.id, entry.version, "agent.bin"); + expect(result.state.binaryPath).toBe(binaryPath); + await expect(NodeFSP.readFile(binaryPath, "utf8")).resolves.toBe("raw executable"); + }); + + it("rejects binary command paths outside the install root", async () => { + const cacheRoot = await NodeFSP.mkdtemp(NodePath.join(NodeOS.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), NodePath.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 = NodePath.join(NodeOS.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 NodeFSP.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..175d4d5eea6 --- /dev/null +++ b/apps/server/src/acpRegistry/installer.ts @@ -0,0 +1,522 @@ +// @effect-diagnostics globalDate:off nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeStream from "node:stream"; +import * as NodeStreamPromises from "node:stream/promises"; + +import { + AcpRegistryError, + type AcpRegistryBinaryPlatform, + type AcpRegistryBinaryTarget, + type AcpRegistryDistributionKind, + type AcpRegistryEntry, + type AcpRegistryInstallState, + type AcpRegistryPackageDistribution, +} from "@t3tools/contracts"; + +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, +): 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; + const channels = availableChannels(entry, platform); + const distribution = channels[0]; + + if (!distribution) { + throw new AcpRegistryError({ + operation: "resolve-distribution", + agentId: entry.id, + detail: "No supported distribution is available for this platform.", + platform: platform ?? "unknown", + }); + } + + if (distribution !== "binary") { + return { state: makeInstallState(entry, distribution) }; + } + + const target = platform && entry.distribution.binary?.[platform]; + if (!target) { + throw new AcpRegistryError({ + operation: "resolve-binary-target", + agentId: entry.id, + detail: "No binary target is available for this platform.", + 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 NodeFSP.rm(NodePath.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; + readonly platform?: AcpRegistryBinaryPlatform | undefined; + } = {}, +): SpawnTarget | undefined { + if (!installState) return undefined; + + switch (installState.distribution) { + case "binary": { + if (!installState.binaryPath) return undefined; + const platform = options.platform; + 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, options.platform); + case "uvx": + return packageSpawn(entry.distribution.uvx, "uvx", options.cwd, options.platform); + } +} + +async function installBinary( + entry: AcpRegistryEntry, + target: AcpRegistryBinaryTarget, + context: InstallContext, +): Promise { + const installRoot = NodePath.join( + context.cacheRoot, + safePathSegment(entry.id, "agent id", entry.id), + safePathSegment(entry.version, "agent version", entry.id), + ); + const archiveKind = detectArchiveKind(target.archive); + const archivePath = NodePath.join(installRoot, ARCHIVE_FILENAME[archiveKind]); + + await NodeFSP.rm(installRoot, { recursive: true, force: true }); + await NodeFSP.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, + context.platform, + ); + + const binaryPath = resolveCmdPath(installRoot, target.cmd, entry.id); + if (archivePath !== binaryPath) { + await NodeFSP.rm(archivePath, { force: true }); + } + await NodeFSP.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({ + operation: "validate-install-path", + agentId, + detail: `Invalid ACP registry ${label}: ${value}`, + }); + } + return value; +} + +function assertInsideRoot(root: string, targetPath: string, agentId: string, detail: string): void { + const relative = NodePath.relative(root, targetPath); + if (relative === "" || (!relative.startsWith("..") && !NodePath.isAbsolute(relative))) { + return; + } + throw new AcpRegistryError({ operation: "validate-install-path", agentId, detail }); +} + +function resolveCmdPath(installRoot: string, cmd: string, agentId: string): string { + if (NodePath.isAbsolute(cmd) || WINDOWS_ABS_PATH.test(cmd)) { + throw new AcpRegistryError({ + operation: "validate-install-path", + agentId, + detail: `ACP registry command path must be relative to the install root: ${cmd}`, + }); + } + const targetPath = NodePath.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 = NodeCrypto.createHash("sha256"); + for await (const chunk of NodeFS.createReadStream(filePath)) { + digest.update(chunk); + } + const actual = digest.digest("hex"); + if (actual !== normalizedExpected) { + throw new AcpRegistryError({ + operation: "verify-download", + agentId, + detail: "The downloaded archive checksum did not match.", + expectedChecksum: normalizedExpected, + actualChecksum: actual, + }); + } +} + +async function downloadToFile(url: string, destPath: string, fetchImpl: FetchLike): Promise { + const response = await fetchImpl(url); + if (!response.ok) { + throw new AcpRegistryError({ + operation: "download", + detail: "The registry archive download failed.", + url, + status: response.status, + statusText: response.statusText, + }); + } + if (!response.body) { + throw new AcpRegistryError({ + operation: "download", + detail: "The registry archive download returned an empty body.", + url, + }); + } + const readable = NodeStream.Readable.fromWeb( + response.body as unknown as ReadableStream, + ); + await NodeStreamPromises.pipeline(readable, NodeFS.createWriteStream(destPath)); +} + +async function extractArchive( + archivePath: string, + archiveKind: ArchiveKind, + installRoot: string, + cmd: string, + agentId: string, + platform: AcpRegistryBinaryPlatform | undefined, +): Promise { + switch (archiveKind) { + case "tar-gz": + await assertSafeArchiveEntries(archivePath, archiveKind, installRoot, agentId, platform); + return runProcess("tar", ["-xzf", archivePath], installRoot); + case "tar-bz2": + await assertSafeArchiveEntries(archivePath, archiveKind, installRoot, agentId, platform); + return runProcess("tar", ["-xjf", archivePath], installRoot); + case "tar": + await assertSafeArchiveEntries(archivePath, archiveKind, installRoot, agentId, platform); + return runProcess("tar", ["-xf", archivePath], installRoot); + case "zip": + await assertSafeArchiveEntries(archivePath, archiveKind, installRoot, agentId, platform); + return extractZip(archivePath, installRoot, platform); + case "raw": + { + const binaryPath = resolveCmdPath(installRoot, cmd, agentId); + await NodeFSP.mkdir(NodePath.dirname(binaryPath), { recursive: true }); + if (archivePath !== binaryPath) { + await NodeFSP.copyFile(archivePath, binaryPath); + } + } + return; + } +} + +async function assertSafeArchiveEntries( + archivePath: string, + archiveKind: Exclude, + installRoot: string, + agentId: string, + platform: AcpRegistryBinaryPlatform | undefined, +): Promise { + const listing = + archiveKind === "zip" + ? await listZipEntries(archivePath, installRoot, platform) + : 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({ + operation: "validate-archive", + agentId, + detail: "An archive entry is not relative to the install root.", + path: entry, + }); + } + assertInsideRoot( + installRoot, + NodePath.resolve(installRoot, normalized), + agentId, + `Archive entry escapes the install root: ${entry}`, + ); + } +} + +function listZipEntries( + archivePath: string, + cwd: string, + platform: AcpRegistryBinaryPlatform | undefined, +): Promise { + if (platform?.startsWith("windows-") === true) { + return runProcessCapture( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "Add-Type -AssemblyName System.IO.Compression.FileSystem; " + + "$zip = [System.IO.Compression.ZipFile]::OpenRead($args[0]); " + + "try { $zip.Entries | ForEach-Object { $_.FullName } } finally { $zip.Dispose() }", + archivePath, + ], + cwd, + ); + } + return runProcessCapture("unzip", ["-Z1", archivePath], cwd); +} + +function extractZip( + archivePath: string, + installRoot: string, + platform: AcpRegistryBinaryPlatform | undefined, +): Promise { + if (platform?.startsWith("windows-") === true) { + 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 = NodeChildProcess.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({ + operation: "run-process", + detail: "Failed to start an installer command.", + command, + args: [...args], + cause, + }), + ), + ); + child.once("close", (code) => { + if (code === 0) { + resolve(); + return; + } + const trimmed = stderr.trim(); + reject( + new AcpRegistryError({ + operation: "run-process", + detail: "An installer command exited unsuccessfully.", + command, + args: [...args], + exitCode: code, + ...(trimmed ? { stderr: trimmed } : {}), + }), + ); + }); + }); +} + +function runProcessCapture( + command: string, + args: ReadonlyArray, + cwd: string, +): Promise { + return new Promise((resolve, reject) => { + const child = NodeChildProcess.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({ + operation: "run-process", + detail: "Failed to start an installer command.", + command, + args: [...args], + cause, + }), + ), + ); + child.once("close", (code) => { + if (code === 0) { + resolve(stdout); + return; + } + const trimmed = stderr.trim(); + reject( + new AcpRegistryError({ + operation: "run-process", + detail: "An installer command exited unsuccessfully.", + command, + args: [...args], + exitCode: code, + ...(trimmed ? { stderr: trimmed } : {}), + }), + ); + }); + }); +} + +let cachedBunxAvailable: boolean | undefined; + +function bunxAvailable(platform: AcpRegistryBinaryPlatform | undefined): boolean { + if (cachedBunxAvailable !== undefined) return cachedBunxAvailable; + cachedBunxAvailable = checkOnPath("bunx", platform); + return cachedBunxAvailable; +} + +function checkOnPath(command: string, platform: AcpRegistryBinaryPlatform | undefined): boolean { + const finder = platform?.startsWith("windows-") === true ? "where" : "which"; + return NodeChildProcess.spawnSync(finder, [command], { stdio: "ignore" }).status === 0; +} + +function packageSpawn( + pkg: AcpRegistryPackageDistribution | undefined, + channel: "npx" | "uvx", + cwd: string | undefined, + platform: AcpRegistryBinaryPlatform | undefined, +): SpawnTarget | undefined { + if (!pkg) return undefined; + const command = channel === "uvx" ? "uvx" : bunxAvailable(platform) ? "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/orphanReaper.ts b/apps/server/src/acpRegistry/orphanReaper.ts new file mode 100644 index 00000000000..340601b025f --- /dev/null +++ b/apps/server/src/acpRegistry/orphanReaper.ts @@ -0,0 +1,50 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; + +/** + * Some ACP agents (notably Junie via its `.app` launcher) detach from the parent process + * on macOS — when t3code exits, the launcher PID dies but the actual agent process orphans + * and survives across server restarts, accumulating zombie JVMs that consume RAM and CPU. + * + * On boot, before spawning a new instance, scan `ps` for any process whose command line + * starts with our managed binary path and kill it (SIGKILL). Safe because we only target + * binaries under our own cache directory (`~/.t3/acp-agents/...`). + */ +export const reapOrphanProcesses = (binaryPath: string): Effect.Effect => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + if (!binaryPath || platform === "win32") return 0; + return yield* Effect.sync(() => { + try { + // ps -A -o pid=,command= → newline-separated "PID COMMAND" pairs + const output = NodeChildProcess.execFileSync("/bin/ps", ["-A", "-o", "pid=,command="], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const myPid = process.pid; + const victims: number[] = []; + for (const line of output.split("\n")) { + const match = line.trim().match(/^(\d+)\s+(.*)$/); + if (!match) continue; + const pid = Number(match[1]); + const cmd = match[2]; + if (pid === myPid) continue; + if (!cmd || !cmd.startsWith(binaryPath)) continue; + victims.push(pid); + } + if (victims.length === 0) return 0; + for (const pid of victims) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Process already dead or permission denied — ignore. + } + } + return victims.length; + } catch { + return 0; + } + }); + }); diff --git a/apps/server/src/acpRegistry/platform.ts b/apps/server/src/acpRegistry/platform.ts new file mode 100644 index 00000000000..9ea5116dccf --- /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, + nodeArch: string, +): 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 2608ccc16ae..a42b2d3ff44 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; @@ -99,12 +100,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, @@ -135,6 +138,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..aac35440d04 --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vite-plus/test"; +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..402afb95cf8 --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -0,0 +1,318 @@ +import { + type AcpRegistryEntry, + acpRegistryDriverKindFor, + type AcpRegistrySettings, + AcpRegistrySettings as AcpRegistrySettingsSchema, + ProviderDriverKind, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +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 * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as Path from "effect/Path"; + +import { resolveSpawnTarget } from "../../acpRegistry/installer.ts"; +import { reapOrphanProcesses } from "../../acpRegistry/orphanReaper.ts"; +import { resolveCurrentPlatform } from "../../acpRegistry/platform.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 hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + const platform = resolveCurrentPlatform(hostPlatform, hostArchitecture); + 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, + }), + ), + ); + + const spawnTarget = resolveSpawnTarget(entry, installState, { platform }); + const installed = spawnTarget !== undefined; + + // Reap orphan child processes from previous server runs that didn't shut down cleanly. + // Critical for Junie (its `.app` launcher detaches → JVM survives parent SIGKILL, + // accumulating across restarts at ~150MB/30%CPU each). + if (installed && spawnTarget?.command) { + const reaped = yield* reapOrphanProcesses(spawnTarget.command); + if (reaped > 0) { + yield* Effect.logInfo("ACP registry reaped orphan agent processes", { + entryId: entry.id, + command: spawnTarget.command, + count: reaped, + }); + } + } + + 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.orElseSucceed(() => 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.orElseSucceed(() => undefined), + ); + } + 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.orElseSucceed(() => 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.orElseSucceed(() => undefined), + ); + }), + }); + + 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 the ${entry.name} provider snapshot.`, + 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..b4bc33d66d1 --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryAdapterLayer.ts @@ -0,0 +1,711 @@ +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 Crypto from "effect/Crypto"; +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 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 * as AcpConnection 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 + | Crypto.Crypto + | 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 crypto = yield* Crypto.Crypto; + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe(Effect.orDie); + const makeEventStamp = () => + Effect.all({ + eventId: Effect.map(randomUUIDv4, EventId.make), + createdAt: nowIso, + }); + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + const handlerContext: AcpRegistryHandlerContext = { + provider, + makeEventStamp, + makeApprovalRequestId: () => Effect.map(randomUUIDv4, ApprovalRequestId.make), + 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: AcpConnection.AcpConnection["Service"]; + 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.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: "Failed to acquire the ACP connection.", + 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(yield* randomUUIDv4); + 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: `Failed to read attachment '${attachment.id}'.`, + 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 sessionClosed = false; + let connectionReleased = false; + // Close the *session* immediately (we don't need to keep handlers wired); keep the + // *connection* warm for a few minutes so the user's first chat in this cwd reuses + // the already-spawned process instead of paying cold-start again. + const WARM_KEEP_ALIVE = "5 minutes"; + const closeSessionOnly = Effect.suspend(() => { + if (sessionClosed) return Effect.void; + sessionClosed = true; + return Scope.close(sessionScope, Exit.void); + }); + const releaseConnectionLater = Effect.suspend(() => { + if (connectionReleased) return Effect.void; + connectionReleased = true; + return releaseConnection(spawnTarget, cwd); + }).pipe(Effect.delay(WARM_KEEP_ALIVE)); + + // 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 })); + } + } + + // Close session NOW (releases handlers, decrements nothing); keep connection warm. + // The delayed release runs in a detached fiber so the discovery effect can return. + yield* closeSessionOnly; + yield* releaseConnectionLater.pipe(Effect.forkDetach, Effect.asVoid); + }).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 0fd88b4262a..ac5d2f462e4 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..a76e0433584 --- /dev/null +++ b/apps/server/src/provider/Layers/acpRegistryAdapter/fileHandlers.test.ts @@ -0,0 +1,42 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { buildFileHandlers, resolveAcpPath } from "./fileHandlers.ts"; + +describe("resolveAcpPath", () => { + const cwd = NodePath.resolve("/tmp/t3-acp-session"); + + it("resolves relative and in-root absolute paths inside the session cwd", () => { + expect(resolveAcpPath(cwd, "src/index.ts")).toBe(NodePath.join(cwd, "src", "index.ts")); + expect(resolveAcpPath(cwd, NodePath.join(cwd, "README.md"))).toBe( + NodePath.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, NodePath.resolve(cwd, "..", "outside.txt"))).toThrow( + "inside the session cwd", + ); + }); + + it.effect("returns a stable protocol error for a path traversal request", () => { + const handlers = buildFileHandlers({ fileSystem: {} as never, cwd }); + return Effect.gen(function* () { + const error = yield* handlers + .onReadTextFile({ sessionId: "session-1", path: "../outside.txt" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "File path must stay inside the session cwd: ../outside.txt", + data: { operation: "resolve-file-path", path: "../outside.txt" }, + method: "fs/read_text_file", + operation: "handle-request", + }); + }); + }); +}); 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..743673522a4 --- /dev/null +++ b/apps/server/src/provider/Layers/acpRegistryAdapter/fileHandlers.ts @@ -0,0 +1,130 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath 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 = NodePath.resolve(cwd); + const targetPath = NodePath.resolve(root, rawPath); + const relativePath = NodePath.relative(root, targetPath); + if ( + relativePath === "" || + (!relativePath.startsWith("..") && !NodePath.isAbsolute(relativePath)) + ) { + return targetPath; + } + throw new Error(`Path must stay inside the session cwd: ${rawPath}`); +}; + +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) => + new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `File path must stay inside the session cwd: ${request.path}`, + data: { operation: "resolve-file-path", path: request.path }, + method: "fs/write_text_file", + operation: "handle-request", + cause, + }), + }); + yield* input.fileSystem + .makeDirectory(NodePath.dirname(targetPath), { recursive: true }) + .pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Failed to prepare the destination for '${request.path}'.`, + data: { operation: "create-parent-directory", path: request.path }, + method: "fs/write_text_file", + operation: "handle-request", + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(targetPath, request.content).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Failed to write text file '${request.path}'.`, + data: { operation: "write-text-file", path: request.path }, + method: "fs/write_text_file", + operation: "handle-request", + cause, + }), + ), + ); + }), + + onReadTextFile: (request) => + Effect.gen(function* () { + const targetPath = yield* Effect.try({ + try: () => resolveAcpPath(input.cwd, request.path), + catch: (cause) => + new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `File path must stay inside the session cwd: ${request.path}`, + data: { operation: "resolve-file-path", path: request.path }, + method: "fs/read_text_file", + operation: "handle-request", + cause, + }), + }); + const exists = yield* input.fileSystem.exists(targetPath).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Failed to inspect text file '${request.path}'.`, + data: { operation: "inspect-text-file", path: request.path }, + method: "fs/read_text_file", + operation: "handle-request", + cause, + }), + ), + ); + if (!exists) { + return { content: "" }; + } + const content = yield* input.fileSystem.readFileString(targetPath).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Failed to read text file '${request.path}'.`, + data: { operation: "read-text-file", path: request.path }, + method: "fs/read_text_file", + operation: "handle-request", + cause, + }), + ), + ); + 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") }; + }), + }; +} 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..e2c28603f0a --- /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.id === "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..bd6c7660114 --- /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 = yield* input.context.makeApprovalRequestId(); + 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..af179d79af1 --- /dev/null +++ b/apps/server/src/provider/Layers/acpRegistryAdapter/types.ts @@ -0,0 +1,48 @@ +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 makeApprovalRequestId: () => Effect.Effect; + 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 0aebe0ca6d8..7bf1fb8346c 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 "vite-plus/test"; 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"), @@ -25,4 +56,22 @@ describe("AcpAdapterSupport", () => { expect(error._tag).toBe("ProviderAdapterRequestError"); expect(error.message).toContain("Invalid params"); }); + + it("does not expose transport cause text in the adapter message", () => { + const error = mapAcpToAdapterError( + ProviderDriverKind.make("cursor"), + "thread-1" as never, + "session/prompt", + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/prompt", + cause: new Error("authorization=secret-token"), + }), + ); + + expect(error.message).toBe( + "Provider adapter request failed (cursor) for session/prompt: ACP transport operation 'call-rpc' failed.", + ); + expect(error.message).not.toContain("secret-token"); + }); }); diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts index cde110e6dd9..f58ee99cca9 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -3,44 +3,66 @@ import { type ProviderDriverKind, type ThreadId, } 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, ProviderAdapterSessionClosedError, type ProviderAdapterError, } from "../Errors.ts"; -const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError); -const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); - export function mapAcpToAdapterError( provider: ProviderDriverKind, threadId: ThreadId, method: string, error: EffectAcpErrors.AcpError, ): ProviderAdapterError { - if (isAcpProcessExitedError(error)) { - return new ProviderAdapterSessionClosedError({ - provider, - threadId, - cause: error, - }); - } - if (isAcpRequestError(error)) { - return new ProviderAdapterRequestError({ - provider, - method, - detail: error.message, - cause: error, - }); + switch (error._tag) { + case "AcpProcessExitedError": + return new ProviderAdapterSessionClosedError({ + provider, + threadId, + cause: error, + }); + case "AcpRequestError": + return new ProviderAdapterRequestError({ + provider, + method, + // ACP request messages are intentional JSON-RPC protocol payloads, not arbitrary causes. + detail: error.errorMessage, + cause: error, + }); + case "AcpSpawnError": + return new ProviderAdapterRequestError({ + provider, + method, + detail: "ACP process could not be started.", + cause: error, + }); + case "AcpProtocolParseError": + return new ProviderAdapterRequestError({ + provider, + method, + detail: `ACP protocol operation '${error.operation}' failed.`, + cause: error, + }); + case "AcpTransportError": + return new ProviderAdapterRequestError({ + provider, + method, + detail: error.operation + ? `ACP transport operation '${error.operation}' failed.` + : "ACP transport operation failed.", + cause: error, + }); + case "AcpInputStreamEndedError": + return new ProviderAdapterRequestError({ + provider, + method, + detail: "ACP input stream ended.", + cause: error, + }); } - return new ProviderAdapterRequestError({ - provider, - method, - detail: error.message, - cause: error, - }); } export function acpPermissionOutcome(decision: ProviderApprovalDecision): string { @@ -54,3 +76,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..f99c1a56630 --- /dev/null +++ b/apps/server/src/provider/acp/AcpConnection.ts @@ -0,0 +1,448 @@ +import * as Cause from "effect/Cause"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +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 * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +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"; + +import { trackChildProcess, untrackChildProcess } from "../../acpRegistry/childProcessRegistry.ts"; + +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 class AcpConnection extends Context.Service< + AcpConnection, + { + 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; + } +>()("t3/provider/acp/AcpConnection") {} + +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 }; + +export const make = ( + options: AcpConnectionOptions, +): Effect.Effect< + AcpConnection["Service"], + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope +> => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeScope = yield* Scope.Scope; + const hostPlatform = yield* HostProcessPlatform; + 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: hostPlatform === "win32", + // LAYER 1: spawn as process-group leader on Unix so we can SIGTERM the entire group + // (including any forks/JVMs the child spawns) when the scope closes. Critical for + // macOS .app launchers like Junie that fork into a long-lived JVM. + detached: hostPlatform !== "win32", + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpSpawnError({ + command: options.spawn.command, + cause, + }), + ), + ); + + // LAYER 1 (continued) + LAYER 2 registration: track this PID and ensure the whole group + // is killed when the connection scope closes (graceful) OR when the server itself exits + // (handled by the registry's process-level shutdown hooks). + const childPid = child.pid as unknown as number | undefined; + if (typeof childPid === "number" && childPid > 0) { + trackChildProcess(childPid, hostPlatform); + yield* Scope.addFinalizer( + runtimeScope, + Effect.sync(() => { + untrackChildProcess(childPid); + if (hostPlatform === "win32") return; + try { + // Negative pid → kill the process group, catches forked JVMs. + process.kill(-childPid, "SIGTERM"); + } catch { + // Fall back to single-pid kill if group signal fails (e.g. not a group leader). + try { + process.kill(childPid, "SIGTERM"); + } catch { + // Already gone. + } + } + }), + ); + } + + 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))); + + // 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* EffectAcpErrors.AcpRequestError.invalidParams( + `No handlers registered for session "${request.sessionId}"`, + { sessionId: 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* EffectAcpErrors.AcpRequestError.invalidParams( + `No handlers registered for session "${request.sessionId}"`, + { sessionId: 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* EffectAcpErrors.AcpRequestError.invalidParams( + `No handlers registered for session "${request.sessionId}"`, + { sessionId: 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* EffectAcpErrors.AcpRequestError.invalidParams( + `No handlers registered for session "${request.sessionId}"`, + { sessionId: 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 AcpConnection["Service"]; + }); + +export const layer = (options: AcpConnectionOptions) => Layer.effect(AcpConnection, make(options)); diff --git a/apps/server/src/provider/acp/AcpMultiSession.ts b/apps/server/src/provider/acp/AcpMultiSession.ts new file mode 100644 index 00000000000..9f40ec5675d --- /dev/null +++ b/apps/server/src/provider/acp/AcpMultiSession.ts @@ -0,0 +1,352 @@ +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 * as AcpConnection 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?: AcpConnection.AcpConnectionSessionHandlers["onRequestPermission"]; + readonly onElicitation?: AcpConnection.AcpConnectionSessionHandlers["onElicitation"]; + readonly onReadTextFile?: AcpConnection.AcpConnectionSessionHandlers["onReadTextFile"]; + readonly onWriteTextFile?: AcpConnection.AcpConnectionSessionHandlers["onWriteTextFile"]; +} + +export interface AcpMultiSessionOptions { + readonly connection: AcpConnection.AcpConnection["Service"]; + 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>; + 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; + 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>( + [], + ); + + const onSessionUpdate: AcpConnection.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 => + 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 || + previous.status !== next.status + ); +} + +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 4fc2c443e11..55c3458e5c6 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -48,7 +48,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 mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { @@ -165,6 +170,10 @@ export class AcpSessionRuntime extends Context.Service< readonly getModeState: Effect.Effect; /** Latest configuration options observed from session setup and configuration writes. */ readonly getConfigOptions: Effect.Effect>; + /** Authentication methods advertised by the agent during initialization. */ + readonly getAuthMethods: Effect.Effect>; + /** Modes advertised by the active session. */ + readonly getAvailableModes: Effect.Effect>; /** * Sends a prompt turn to the active session. * @see https://agentclientprotocol.com/protocol/schema#session/prompt @@ -221,6 +230,8 @@ export class AcpSessionRuntime extends Context.Service< method: string, payload: unknown, ) => Effect.Effect; + /** Authenticates the initialized agent with the selected method. */ + readonly authenticate: (methodId: string) => Effect.Effect; } >()("t3/provider/acp/AcpSessionRuntime") {} @@ -259,6 +270,8 @@ export const make = ( 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) => @@ -478,15 +491,19 @@ export const make = ( 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: @@ -536,6 +553,7 @@ export const make = ( yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + yield* Ref.set(availableModesRef, sessionSetupResult.modes?.availableModes ?? []); const nextState = { sessionId, @@ -663,6 +681,14 @@ export const make = ( 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 AcpSessionRuntime["Service"]; }); @@ -790,7 +816,12 @@ function shouldEmitToolCallUpdate( if (!next.detail) { return false; } - return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; + return ( + previous === undefined || + previous.title !== next.title || + previous.detail !== next.detail || + previous.status !== next.status + ); } const assistantItemId = (sessionId: string, segmentIndex: number) => 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 791a96e1da3..0c7dc7e2d85 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"; @@ -37,7 +40,16 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | 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 @@ -50,4 +62,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray Effect.succeed([]), - ...options?.layers?.externalLauncher, - }), + Layer.mergeAll( + Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), + ...options?.layers?.externalLauncher, + }), + Layer.mock(AcpRegistryService.AcpRegistryService)({ + list: () => Effect.succeed([]), + install: (agentId) => + Effect.fail( + new AcpRegistryError({ + operation: "install", + agentId, + detail: "ACP registry install is disabled in tests.", + }), + ), + uninstall: () => Effect.void, + authenticate: () => Effect.void, + }), + ), ), Layer.provide( Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 87feee669ec..cf16490874f 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -480,6 +480,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 }, @@ -516,6 +517,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 7c45d0b58b8..6529a0db1bc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -60,6 +60,7 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import * as AcpRegistryService from "./acpRegistry/AcpRegistryService.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; @@ -289,6 +290,10 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], + [WS_METHODS.acpRegistryList, AuthOrchestrationReadScope], + [WS_METHODS.acpRegistryInstall, AuthOrchestrationOperateScope], + [WS_METHODS.acpRegistryUninstall, AuthOrchestrationOperateScope], + [WS_METHODS.acpRegistryAuthenticate, AuthOrchestrationOperateScope], [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], @@ -425,6 +430,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const relayClient = yield* RelayClient.RelayClient; + const acpRegistry = yield* AcpRegistryService.AcpRegistryService; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -1287,6 +1293,30 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ), { "rpc.aggregate": "cloud" }, ), + [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, @@ -1807,6 +1837,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(PreviewAutomationBroker.layer), + Layer.provide(AcpRegistryService.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( SourceControlDiscovery.layer.pipe( 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/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index a58e29fe115..79da6c4a91d 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -1,6 +1,7 @@ import { type CSSProperties, memo } from "react"; -import { type ProviderDriverKind } from "@t3tools/contracts"; +import { acpRegistryIdFromDriverKind, type ProviderDriverKind } from "@t3tools/contracts"; +import { AcpRegistryIcon } from "../AcpRegistryIcon"; import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; import { cn } from "~/lib/utils"; @@ -27,6 +28,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { indicatorBackground?: string; }) { const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; + const acpRegistryId = Icon ? undefined : acpRegistryIdFromDriverKind(props.driverKind); const indicatorBackground = props.indicatorBackground ?? "var(--card)"; const accentStyle = props.accentColor ? ({ "--provider-accent": props.accentColor } as CSSProperties) @@ -44,6 +46,11 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { > {Icon ? ( + ) : acpRegistryId ? ( + ) : ( {providerInstanceInitials(props.displayName)} diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index c725b3e275a..04890d833be 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 { InfoIcon } from "lucide-react"; import { cn } from "~/lib/utils"; @@ -16,11 +16,14 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; + const isAcpRegistry = status.driver.startsWith(ACP_REGISTRY_DRIVER_PREFIX); const title = isUnauthenticated ? `${providerName} is unauthenticated` : `${providerName} provider status`; const message = isUnauthenticated - ? "Sign in via the CLI to authenticate again." + ? isAcpRegistry + ? "Authenticate this provider from Settings → Providers." + : "Sign in via the CLI to authenticate again." : (status.message ?? (status.status === "error" ? `${providerName} 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..56fe71ff4c9 --- /dev/null +++ b/apps/web/src/components/settings/AddOrInstallProviderPanel.tsx @@ -0,0 +1,797 @@ +"use client"; + +import { + type AcpRegistryDistributionKind, + AcpRegistrySettings, + acpRegistryDriverKindFor, + type AcpRegistryEntryWithStatus, + ProviderInstanceId, + ProviderDriverKind, + type ProviderInstanceConfig, +} from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + DownloadIcon, + ExternalLinkIcon, + PackageIcon, + PlusCircleIcon, + SearchIcon, + Trash2Icon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { normalizeProviderAccentColor } from "../../providerInstances"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +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 { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +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 = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const primaryEnvironment = usePrimaryEnvironment(); + const listAcpRegistry = useAtomCommand(serverEnvironment.listAcpRegistry, { + reportFailure: false, + }); + const installAcpRegistryAgent = useAtomCommand(serverEnvironment.installAcpRegistryAgent, { + reportFailure: false, + }); + const uninstallAcpRegistryAgent = useAtomCommand(serverEnvironment.uninstallAcpRegistryAgent, { + reportFailure: false, + }); + + 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 [uninstallConfirmEntry, setUninstallConfirmEntry] = + useState(null); + + const existingIds = useMemo( + () => new Set(Object.keys(settings.providerInstances ?? {})), + [settings.providerInstances], + ); + + const refreshAcp = useCallback(async () => { + if (!primaryEnvironment) { + setAcpError("Connect to a backend to load the ACP registry."); + setAcpLoading(false); + return; + } + try { + const result = await listAcpRegistry({ + environmentId: primaryEnvironment.environmentId, + input: {}, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + setAcpEntries(result.value); + setAcpError(null); + } catch (cause) { + setAcpError(describeError(cause, "Failed to load ACP registry.")); + } finally { + setAcpLoading(false); + } + }, [listAcpRegistry, primaryEnvironment]); + + 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 { + if (!primaryEnvironment) { + throw new Error("Connect to a backend before changing ACP registry agents."); + } + const runAction = + action === "install" ? installAcpRegistryAgent : uninstallAcpRegistryAgent; + const result = await runAction({ + environmentId: primaryEnvironment.environmentId, + input: { agentId }, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + 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; + }); + } + }, + [installAcpRegistryAgent, primaryEnvironment, refreshAcp, uninstallAcpRegistryAgent], + ); + + const handleInstall = useCallback( + (entry: AcpRegistryEntryWithStatus) => { + void runAcpAction(entry.entry.id, entry.entry.name, "install"); + }, + [runAcpAction], + ); + + const handleUninstall = useCallback((entry: AcpRegistryEntryWithStatus) => { + setUninstallConfirmEntry(entry); + }, []); + + const confirmUninstall = useCallback(() => { + if (!uninstallConfirmEntry) return; + void runAcpAction( + uninstallConfirmEntry.entry.id, + uninstallConfirmEntry.entry.name, + "uninstall", + ); + setUninstallConfirmEntry(null); + }, [uninstallConfirmEntry, 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} + /> + )} + + + {uninstallConfirmEntry && ( + !open && setUninstallConfirmEntry(null)} + > + + + Remove {uninstallConfirmEntry.entry.name}? + + This will remove the agent and delete all associated provider instances. This action + cannot be undone. + + + + }>Cancel + + + + + )} +
+ ); +} + +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/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index ac2f7be81e8..43a1159fe1c 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -10,10 +10,12 @@ import { Trash2Icon, XIcon, } from "lucide-react"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { useState, type ReactNode } from "react"; import { + ACP_REGISTRY_DRIVER_PREFIX, isProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, @@ -26,6 +28,9 @@ import { import { cn } from "../../lib/utils"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { normalizeProviderAccentColor } from "../../providerInstances"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; @@ -33,6 +38,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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -153,6 +159,94 @@ function ProviderAuthEmail(props: { ); } +function ProviderAuthSection(props: { + readonly instanceId: ProviderInstanceId; + readonly liveProvider: ServerProvider | undefined; +}) { + const [authenticating, setAuthenticating] = useState>(() => new Set()); + const primaryEnvironment = usePrimaryEnvironment(); + const authenticateAcpRegistryAgent = useAtomCommand( + serverEnvironment.authenticateAcpRegistryAgent, + { reportFailure: false }, + ); + 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((current) => new Set(current).add(methodId)); + try { + if (!primaryEnvironment) { + throw new Error("Connect to a backend before authenticating this provider."); + } + const result = await authenticateAcpRegistryAgent({ + environmentId: primaryEnvironment.environmentId, + input: { + instanceId: props.instanceId, + methodId, + }, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + } catch (cause) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Authentication failed", + description: + cause instanceof Error ? cause.message : "The provider could not authenticate.", + }), + ); + } finally { + setAuthenticating((current) => { + if (!current.has(methodId)) return current; + const next = new Set(current); + 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 ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; @@ -764,6 +858,8 @@ export function ProviderInstanceCard({ /> + + {driverOption ? ( >(() => new Set()); @@ -1282,7 +1281,11 @@ 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={() => { + document + .getElementById("providers-add-or-install") + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + }} aria-label="Add provider instance" > @@ -1415,9 +1418,9 @@ export function ProviderSettingsPanel() { })} - {isAddInstanceDialogOpen ? ( - - ) : null} +
+ +
); } diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 3d973ccca74..c1713b435da 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/vite.config.ts b/apps/web/vite.config.ts index 8f984c850dc..739c8703435 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + import tailwindcss from "@tailwindcss/vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; @@ -81,6 +85,36 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); +function copyAcpIcons() { + return { + name: "copy-acp-icons", + async buildStart() { + const contractsIconsDir = NodePath.resolve( + import.meta.dirname, + "../../packages/contracts/src/registry/icons", + ); + const webIconsDir = NodePath.resolve(import.meta.dirname, "public/acp-icons"); + + try { + await NodeFSP.mkdir(webIconsDir, { recursive: true }); + const entries = await NodeFSP.readdir(contractsIconsDir); + await Promise.all( + entries + .filter((entry) => entry.endsWith(".svg")) + .map((entry) => + NodeFSP.copyFile( + NodePath.join(contractsIconsDir, entry), + NodePath.join(webIconsDir, entry), + ), + ), + ); + } catch { + // Registry assets are optional when consumers build only the web package. + } + }, + }; +} + export default defineConfig(() => { return { plugins: [ @@ -95,6 +129,7 @@ export default defineConfig(() => { presets: [reactCompilerPreset()], }), tailwindcss(), + copyAcpIcons(), ], optimizeDeps: { include: [ 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 f97275e60bb..a18fe1e4d47 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,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 .vite-plus apps/*/.vite-plus packages/*/.vite-plus", - "sync:repos": "node scripts/sync-reference-repos.ts" + "sync:repos": "node scripts/sync-reference-repos.ts", + "sync:acp-registry": "node scripts/sync-acp-registry.ts" }, "devDependencies": { "@babel/plugin-transform-react-jsx": "7.28.6", diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index eb784183793..5dd60ca41b4 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -162,6 +162,38 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + listAcpRegistry: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:list-acp-registry", + tag: WS_METHODS.acpRegistryList, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + }), + installAcpRegistryAgent: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:install-acp-registry-agent", + tag: WS_METHODS.acpRegistryInstall, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => `${environmentId}:${input.agentId}`, + }, + }), + uninstallAcpRegistryAgent: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:uninstall-acp-registry-agent", + tag: WS_METHODS.acpRegistryUninstall, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => `${environmentId}:${input.agentId}`, + }, + }), + authenticateAcpRegistryAgent: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:authenticate-acp-registry-agent", + tag: WS_METHODS.acpRegistryAuthenticate, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => `${environmentId}:${input.instanceId}:${input.methodId}`, + }, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/contracts/src/acpRegistry.ts b/packages/contracts/src/acpRegistry.ts new file mode 100644 index 00000000000..75cb5ae497d --- /dev/null +++ b/packages/contracts/src/acpRegistry.ts @@ -0,0 +1,159 @@ +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", + { + operation: Schema.String, + agentId: Schema.optional(Schema.String), + detail: Schema.String, + platform: Schema.optional(Schema.String), + path: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + status: Schema.optional(Schema.Number), + statusText: Schema.optional(Schema.String), + expectedChecksum: Schema.optional(Schema.String), + actualChecksum: Schema.optional(Schema.String), + command: Schema.optional(Schema.String), + args: Schema.optional(Schema.Array(Schema.String)), + exitCode: Schema.optional(Schema.NullOr(Schema.Number)), + stderr: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const prefix = this.agentId ? `[${this.agentId}] ` : ""; + return `${prefix}ACP registry ${this.operation} failed: ${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 43270efdec7..f10e9a41e0a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -26,3 +26,5 @@ export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./rpc.ts"; +export * from "./acpRegistry.ts"; +export * from "./registry/index.ts"; 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..8dc4a38517e --- /dev/null +++ b/packages/contracts/src/registry/registry.json @@ -0,0 +1,977 @@ +{ + "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 a2a8e9106aa..d95ea4a4529 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 { AuthAccessStreamError, @@ -222,6 +227,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", @@ -394,6 +405,30 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), }); +export const WsAcpRegistryListRpc = Rpc.make(WS_METHODS.acpRegistryList, { + payload: Schema.Struct({}), + success: Schema.Array(AcpRegistryEntryWithStatus), + error: Schema.Union([AcpRegistryError, EnvironmentAuthorizationError]), +}); + +export const WsAcpRegistryInstallRpc = Rpc.make(WS_METHODS.acpRegistryInstall, { + payload: Schema.Struct({ agentId: Schema.String }), + success: AcpRegistryInstallState, + error: Schema.Union([AcpRegistryError, EnvironmentAuthorizationError]), +}); + +export const WsAcpRegistryUninstallRpc = Rpc.make(WS_METHODS.acpRegistryUninstall, { + payload: Schema.Struct({ agentId: Schema.String }), + success: Schema.Struct({ agentId: Schema.String }), + error: Schema.Union([AcpRegistryError, EnvironmentAuthorizationError]), +}); + +export const WsAcpRegistryAuthenticateRpc = Rpc.make(WS_METHODS.acpRegistryAuthenticate, { + payload: Schema.Struct({ instanceId: ProviderInstanceId, methodId: Schema.String }), + success: Schema.Struct({}), + error: Schema.Union([AcpRegistryError, EnvironmentAuthorizationError]), +}); + export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -748,4 +783,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 b76ea965afe..ebb7bddb8f1 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 1cb57a98254..5ad34982f9b 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"; @@ -355,6 +356,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(""))), @@ -408,6 +431,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; @@ -509,6 +548,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 61b3d71b49d..e5f8f0ceb43 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -450,7 +450,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 5bec7d386b6..65ad8ce735a 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..8ad9394608c --- /dev/null +++ b/scripts/sync-acp-registry.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env node +// @effect-diagnostics globalFetch:off nodeBuiltinImport:off - Standalone registry sync script runs directly in Node. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); +const REGISTRY_JSON_PATH = NodePath.join( + REPO_ROOT, + "packages/contracts/src/registry/registry.json", +); +const ICON_DIR = NodePath.join(REPO_ROOT, "packages/contracts/src/registry/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; +} + +const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9._-]*$/i; + +function safeIconPath(agentId: string): string { + if (!SAFE_AGENT_ID.test(agentId)) { + throw new Error(`Unsafe agent id: ${agentId}`); + } + const target = NodePath.join(ICON_DIR, `${agentId}.svg`); + const resolved = NodePath.resolve(target); + const root = NodePath.resolve(ICON_DIR); + if ( + resolved !== NodePath.join(root, `${agentId}.svg`) || + !resolved.startsWith(`${root}${NodePath.sep}`) + ) { + throw new Error(`Icon path escapes ICON_DIR: ${agentId}`); + } + return resolved; +} + +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 NodeFSP.writeFile(safeIconPath(agent.id), text, "utf8"); + return true; +} + +async function pruneStaleIcons(wantedIds: ReadonlySet): Promise { + const wanted = new Set(Array.from(wantedIds, (id) => `${id}.svg`)); + const existing = await NodeFSP.readdir(ICON_DIR).catch(() => [] as string[]); + await Promise.all( + existing + .filter((entry) => !wanted.has(entry)) + .map((entry) => NodeFSP.rm(NodePath.join(ICON_DIR, entry), { force: true })), + ); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + await NodeFSP.mkdir(ICON_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 NodeFSP.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 : ${NodePath.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); +});