diff --git a/AGENTS.md b/AGENTS.md index cea5090cce0..5d9960d4630 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,16 @@ Docs: - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server +## ACP Registry + +In addition to the four bespoke providers (Codex, Claude, Cursor, OpenCode), +T3 Code bundles a snapshot of the +[Agent Client Protocol registry](https://agentclientprotocol.com/get-started/registry) +and exposes a Zed-style installer at **Settings → ACP Registry**. 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.ts b/apps/server/src/acpRegistry/AcpRegistryService.ts new file mode 100644 index 00000000000..3c3b0fd5df3 --- /dev/null +++ b/apps/server/src/acpRegistry/AcpRegistryService.ts @@ -0,0 +1,134 @@ +import { + ACP_REGISTRY, + acpRegistryEntryById, + AcpRegistryError, + type AcpRegistryEntry, + type AcpRegistryEntryWithStatus, + type AcpRegistryInstallState, + type AcpRegistryInstallStatus, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { ServerConfig } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; + +import { availableChannels, installAgent, uninstallAgent } from "./installer.ts"; +import { resolveCurrentPlatform } from "./platform.ts"; + +export interface AcpRegistryServiceShape { + readonly list: () => Effect.Effect, AcpRegistryError>; + readonly install: (agentId: string) => Effect.Effect; + readonly uninstall: (agentId: string) => Effect.Effect; +} + +export class AcpRegistryService extends Context.Service< + AcpRegistryService, + AcpRegistryServiceShape +>()("t3/acpRegistry/AcpRegistryService") {} + +export const layer: Layer.Layer = + Layer.effect( + AcpRegistryService, + Effect.gen(function* () { + const config = yield* ServerConfig; + const settingsService = yield* ServerSettingsService; + const platform = resolveCurrentPlatform(); + const cacheRoot = config.acpRegistryCacheDir; + + const wrapSettingsError = (detail: string) => (cause: unknown) => + new AcpRegistryError({ detail, cause }); + + const readInstalls = settingsService.getSettings.pipe( + Effect.map((s) => s.acpRegistryInstalls), + Effect.mapError(wrapSettingsError("Failed to read server settings")), + ); + + const writeInstalls = (next: Readonly>) => + settingsService + .updateSettings({ acpRegistryInstalls: next }) + .pipe( + Effect.asVoid, + Effect.mapError(wrapSettingsError("Failed to persist server settings")), + ); + + const requireEntry = (agentId: string): Effect.Effect => { + const entry = acpRegistryEntryById(agentId); + return entry + ? Effect.succeed(entry) + : Effect.fail(new AcpRegistryError({ agentId, detail: "Unknown ACP registry agent." })); + }; + + const isAcpRegistryError = Schema.is(AcpRegistryError); + const toAcpRegistryError = + (agentId: string, fallback: string) => + (cause: unknown): AcpRegistryError => { + if (isAcpRegistryError(cause)) return cause; + return new AcpRegistryError({ + agentId, + detail: cause instanceof Error ? cause.message : fallback, + cause, + }); + }; + + return { + list: () => + Effect.gen(function* () { + const installs = yield* readInstalls; + return ACP_REGISTRY.map((entry) => + buildEntryStatus(entry, installs[entry.id], availableChannels(entry, platform)), + ); + }), + + install: (agentId) => + Effect.gen(function* () { + const entry = yield* requireEntry(agentId); + const result = yield* Effect.tryPromise({ + try: () => installAgent(entry, { cacheRoot }), + catch: toAcpRegistryError(agentId, "Install failed"), + }); + const installs = yield* readInstalls; + yield* writeInstalls({ ...installs, [agentId]: result.state }); + return result.state; + }), + + uninstall: (agentId) => + Effect.gen(function* () { + const entry = yield* requireEntry(agentId); + yield* Effect.tryPromise({ + try: () => uninstallAgent(entry, cacheRoot), + catch: toAcpRegistryError(agentId, "Uninstall failed"), + }); + const installs = yield* readInstalls; + if (!(agentId in installs)) return; + const { [agentId]: _removed, ...rest } = installs; + yield* writeInstalls(rest); + }), + } satisfies AcpRegistryServiceShape; + }), + ); + +function buildEntryStatus( + entry: AcpRegistryEntry, + installed: AcpRegistryInstallState | undefined, + channels: ReadonlyArray[number]>, +): AcpRegistryEntryWithStatus { + return { + entry, + availableChannels: channels, + status: rollupStatus(entry, installed, channels), + ...(installed ? { installed } : {}), + }; +} + +function rollupStatus( + entry: AcpRegistryEntry, + installed: AcpRegistryInstallState | undefined, + channels: ReadonlyArray[number]>, +): AcpRegistryInstallStatus { + if (channels.length === 0) return "unsupported"; + if (!installed) return "not_installed"; + return installed.version === entry.version ? "installed" : "update_available"; +} diff --git a/apps/server/src/acpRegistry/installer.ts b/apps/server/src/acpRegistry/installer.ts new file mode 100644 index 00000000000..aeab4695602 --- /dev/null +++ b/apps/server/src/acpRegistry/installer.ts @@ -0,0 +1,279 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalDate:off +import { spawn, spawnSync } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import * as fsPromises from "node:fs/promises"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +import { + AcpRegistryError, + type AcpRegistryBinaryPlatform, + type AcpRegistryBinaryTarget, + type AcpRegistryDistributionKind, + type AcpRegistryEntry, + type AcpRegistryInstallState, + type AcpRegistryPackageDistribution, +} from "@t3tools/contracts"; + +import { resolveCurrentPlatform } from "./platform.ts"; + +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?: typeof fetch; +} + +export interface InstallResult { + readonly state: AcpRegistryInstallState; +} + +type ArchiveKind = "tar-gz" | "tar-bz2" | "tar" | "zip" | "raw"; + +const ARCHIVE_FILENAME: Record = { + "tar-gz": "archive.tar.gz", + "tar-bz2": "archive.tar.bz2", + tar: "archive.tar", + zip: "archive.zip", + raw: "agent.bin", +}; + +const ARCHIVE_DETECTORS: ReadonlyArray = [ + [/\.(tar\.gz|tgz)$/, "tar-gz"], + [/\.(tar\.bz2|tbz2)$/, "tar-bz2"], + [/\.tar$/, "tar"], + [/\.zip$/, "zip"], +]; + +const WINDOWS_ABS_PATH = /^[a-zA-Z]:[/\\]/; + +export function availableChannels( + entry: AcpRegistryEntry, + platform: AcpRegistryBinaryPlatform | undefined = resolveCurrentPlatform(), +): ReadonlyArray { + const channels: AcpRegistryDistributionKind[] = []; + if (platform && entry.distribution.binary?.[platform]) channels.push("binary"); + if (entry.distribution.npx) channels.push("npx"); + if (entry.distribution.uvx) channels.push("uvx"); + return channels; +} + +export async function installAgent( + entry: AcpRegistryEntry, + context: InstallContext, +): Promise { + const platform = context.platform ?? resolveCurrentPlatform(); + const channels = availableChannels(entry, platform); + const distribution = channels[0]; + + if (!distribution) { + throw new AcpRegistryError({ + agentId: entry.id, + detail: `No supported distribution for platform ${platform ?? "unknown"}.`, + }); + } + + if (distribution !== "binary") { + return { state: makeInstallState(entry, distribution) }; + } + + const target = platform && entry.distribution.binary?.[platform]; + if (!target) { + throw new AcpRegistryError({ + agentId: entry.id, + detail: `No binary target for platform ${platform ?? "unknown"}.`, + }); + } + + const binaryPath = await installBinary(entry, target, context); + return { state: makeInstallState(entry, "binary", binaryPath) }; +} + +export async function uninstallAgent(entry: AcpRegistryEntry, cacheRoot: string): Promise { + await fsPromises.rm(`${cacheRoot}/${entry.id}`, { recursive: true, force: true }); +} + +export function resolveSpawnTarget( + entry: AcpRegistryEntry, + installState: AcpRegistryInstallState | undefined, + options: { readonly cwd?: string } = {}, +): SpawnTarget | undefined { + if (!installState) return undefined; + + switch (installState.distribution) { + case "binary": { + if (!installState.binaryPath) return undefined; + const platform = resolveCurrentPlatform(); + const target = platform ? entry.distribution.binary?.[platform] : undefined; + return { + command: installState.binaryPath, + args: target?.args ? [...target.args] : [], + env: target?.env as NodeJS.ProcessEnv | undefined, + cwd: options.cwd, + distribution: "binary", + }; + } + case "npx": + return packageSpawn(entry.distribution.npx, "npx", options.cwd); + case "uvx": + return packageSpawn(entry.distribution.uvx, "uvx", options.cwd); + } +} + +async function installBinary( + entry: AcpRegistryEntry, + target: AcpRegistryBinaryTarget, + context: InstallContext, +): Promise { + const installRoot = `${context.cacheRoot}/${entry.id}/${entry.version}`; + const archiveKind = detectArchiveKind(target.archive); + const archivePath = `${installRoot}/${ARCHIVE_FILENAME[archiveKind]}`; + + await fsPromises.rm(installRoot, { recursive: true, force: true }); + await fsPromises.mkdir(installRoot, { recursive: true }); + + await downloadToFile(target.archive, archivePath, context.fetchImpl ?? globalThis.fetch); + await extractArchive(archivePath, archiveKind, installRoot, target.cmd); + + if (archiveKind !== "raw") { + await fsPromises.rm(archivePath, { force: true }); + } + + const binaryPath = resolveCmdPath(installRoot, target.cmd); + await fsPromises.chmod(binaryPath, 0o755).catch(() => undefined); + return binaryPath; +} + +function makeInstallState( + entry: AcpRegistryEntry, + distribution: AcpRegistryDistributionKind, + binaryPath?: string, +): AcpRegistryInstallState { + return { + version: entry.version, + installedAt: new Date().toISOString(), + distribution, + ...(binaryPath ? { binaryPath } : {}), + }; +} + +function detectArchiveKind(url: string): ArchiveKind { + const path = url.toLowerCase().split("?")[0] ?? ""; + for (const [pattern, kind] of ARCHIVE_DETECTORS) { + if (pattern.test(path)) return kind; + } + return "raw"; +} + +function resolveCmdPath(installRoot: string, cmd: string): string { + if (cmd.startsWith("/") || WINDOWS_ABS_PATH.test(cmd)) return cmd; + return `${installRoot}/${cmd.replace(/^\.\//, "")}`; +} + +async function downloadToFile( + url: string, + destPath: string, + fetchImpl: typeof fetch, +): Promise { + const response = await fetchImpl(url); + if (!response.ok) { + throw new AcpRegistryError({ + detail: `Download failed (${response.status} ${response.statusText}) — ${url}`, + }); + } + if (!response.body) { + throw new AcpRegistryError({ detail: `Download returned an empty body — ${url}` }); + } + const readable = Readable.fromWeb(response.body as unknown as ReadableStream); + await pipeline(readable, createWriteStream(destPath)); +} + +async function extractArchive( + archivePath: string, + archiveKind: ArchiveKind, + installRoot: string, + cmd: string, +): Promise { + switch (archiveKind) { + case "tar-gz": + return runProcess("tar", ["-xzf", archivePath], installRoot); + case "tar-bz2": + return runProcess("tar", ["-xjf", archivePath], installRoot); + case "tar": + return runProcess("tar", ["-xf", archivePath], installRoot); + case "zip": + return runProcess("unzip", ["-q", "-o", archivePath, "-d", installRoot], installRoot); + case "raw": + await fsPromises.copyFile(archivePath, resolveCmdPath(installRoot, cmd)); + return; + } +} + +function runProcess(command: string, args: ReadonlyArray, cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { cwd, stdio: ["ignore", "ignore", "pipe"] }); + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.once("error", (cause) => + reject( + new AcpRegistryError({ + detail: `Failed to run ${command}: ${cause.message}`, + cause, + }), + ), + ); + child.once("close", (code) => { + if (code === 0) { + resolve(); + return; + } + const trimmed = stderr.trim(); + reject( + new AcpRegistryError({ + detail: `${command} ${args.join(" ")} exited with ${code ?? "signal"}${ + trimmed ? ` — ${trimmed}` : "" + }`, + }), + ); + }); + }); +} + +let cachedBunxAvailable: boolean | undefined; + +function bunxAvailable(): boolean { + if (cachedBunxAvailable !== undefined) return cachedBunxAvailable; + cachedBunxAvailable = checkOnPath("bunx"); + return cachedBunxAvailable; +} + +function checkOnPath(command: string): boolean { + const finder = process.platform === "win32" ? "where" : "which"; + return spawnSync(finder, [command], { stdio: "ignore" }).status === 0; +} + +function packageSpawn( + pkg: AcpRegistryPackageDistribution | undefined, + channel: "npx" | "uvx", + cwd: string | undefined, +): SpawnTarget | undefined { + if (!pkg) return undefined; + const command = channel === "uvx" ? "uvx" : bunxAvailable() ? "bunx" : "npx"; + return { + command, + args: [pkg.package, ...(pkg.args ?? [])], + env: pkg.env as NodeJS.ProcessEnv | undefined, + cwd, + distribution: channel, + }; +} diff --git a/apps/server/src/acpRegistry/platform.ts b/apps/server/src/acpRegistry/platform.ts new file mode 100644 index 00000000000..39dd98bac05 --- /dev/null +++ b/apps/server/src/acpRegistry/platform.ts @@ -0,0 +1,21 @@ +import type { AcpRegistryBinaryPlatform } from "@t3tools/contracts"; + +const PLATFORMS: Record = { + darwin: "darwin", + linux: "linux", + win32: "windows", +}; + +const ARCHES: Record = { + arm64: "aarch64", + x64: "x86_64", +}; + +export function resolveCurrentPlatform( + nodePlatform: NodeJS.Platform = process.platform, + nodeArch: string = process.arch, +): AcpRegistryBinaryPlatform | undefined { + const platform = PLATFORMS[nodePlatform]; + const arch = ARCHES[nodeArch]; + return platform && arch ? (`${platform}-${arch}` as AcpRegistryBinaryPlatform) : undefined; +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b0a23cb273c..6a7aeda5619 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -31,6 +31,7 @@ export interface ServerDerivedPaths { readonly keybindingsConfigPath: string; readonly settingsPath: string; readonly providerStatusCacheDir: string; + readonly acpRegistryCacheDir: string; readonly worktreesDir: string; readonly attachmentsDir: string; readonly logsDir: string; @@ -86,12 +87,14 @@ export const deriveServerPaths = Effect.fn(function* ( const logsDir = join(stateDir, "logs"); const providerLogsDir = join(logsDir, "provider"); const providerStatusCacheDir = join(baseDir, "caches"); + const acpRegistryCacheDir = join(baseDir, "acp-agents"); return { stateDir, dbPath, keybindingsConfigPath: join(stateDir, "keybindings.json"), settingsPath: join(stateDir, "settings.json"), providerStatusCacheDir, + acpRegistryCacheDir, worktreesDir: join(baseDir, "worktrees"), attachmentsDir, logsDir, @@ -122,6 +125,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), + fs.makeDirectory(derivedPaths.acpRegistryCacheDir, { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }), ], diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts new file mode 100644 index 00000000000..e3c8a610f0a --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -0,0 +1,148 @@ +import { + type AcpRegistryEntry, + acpRegistryDriverKindFor, + type AcpRegistrySettings, + AcpRegistrySettings as AcpRegistrySettingsSchema, + ProviderDriverKind, + type ServerProvider, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { resolveSpawnTarget } from "../../acpRegistry/installer.ts"; +import { ServerConfig } from "../../config.ts"; +import { makeAcpRegistryTextGeneration } from "../../textGeneration/AcpRegistryTextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + type AcpRegistryAdapterEnv, + makeAcpRegistryAdapter, +} from "../Layers/AcpRegistryAdapter.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"; +import type { ServerProviderShape } from "../Services/ServerProvider.ts"; + +const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettingsSchema); + +export type AcpRegistryDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | ServerConfig + | ServerSettingsService + | AcpRegistryAdapterEnv; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +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 settingsService = yield* ServerSettingsService; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind, + instanceId, + }); + + const installState = yield* settingsService.getSettings.pipe( + Effect.map((settings) => settings.acpRegistryInstalls[entry.id]), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: driverKind, + instanceId, + detail: `Failed to read ACP registry install state: ${cause.message}`, + cause, + }), + ), + ); + + const spawnTarget = resolveSpawnTarget(entry, installState); + const installed = spawnTarget !== undefined; + + const adapter = yield* makeAcpRegistryAdapter({ + driverKind, + instanceId, + spawnTarget, + environment: processEnv, + }); + + const checkedAt = yield* nowIso; + let probe: ProviderProbeResult; + if (installed) { + probe = { + installed: true, + version: installState?.version ?? entry.version, + status: "ready", + auth: { status: "unknown" }, + }; + } else { + probe = { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: `${entry.name} is not installed. Install it from Settings → ACP Registry.`, + }; + } + const snapshotValue: ServerProvider = { + ...buildServerProvider({ + driver: driverKind, + presentation: { displayName: displayName ?? entry.name }, + enabled, + checkedAt, + models: [], + probe, + }), + instanceId, + driver: driverKind, + continuation: { groupKey: continuationIdentity.continuationKey }, + ...(displayName ? { displayName } : {}), + ...(accentColor ? { accentColor } : {}), + }; + + const snapshot: ServerProviderShape = { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: driverKind, + packageName: null, + }), + getSnapshot: Effect.succeed(snapshotValue), + refresh: Effect.succeed(snapshotValue), + streamChanges: Stream.empty, + }; + + return { + instanceId, + driverKind, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration: makeAcpRegistryTextGeneration(), + } satisfies ProviderInstance; + }), + }; +} diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts new file mode 100644 index 00000000000..5f2fd4e410f --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -0,0 +1,579 @@ +import { + ApprovalRequestId, + EventId, + type ProviderApprovalDecision, + type ProviderDriverKind, + type ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Random from "effect/Random"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import type { SpawnTarget } from "../../acpRegistry/installer.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, + makeAcpAssistantItemEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { AcpSessionRuntime, type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import type { AcpRegistryAdapterShape } from "../Services/AcpRegistryAdapter.ts"; + +export interface AcpRegistryAdapterOptions { + readonly driverKind: ProviderDriverKind; + readonly instanceId: ProviderInstanceId; + /** `undefined` when the agent is not installed — `startSession` then fails fast. */ + readonly spawnTarget: SpawnTarget | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +interface AcpRegistrySessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntimeShape; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + activeTurnId: TurnId | undefined; + stopped: boolean; +} + +export type AcpRegistryAdapterEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | ServerConfig; + +export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(function* ( + options: AcpRegistryAdapterOptions, +) { + const provider = options.driverKind; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const makeEventStamp = () => + Effect.all({ + eventId: Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)), + createdAt: nowIso, + }); + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing = current.get(threadId); + if (existing) return Effect.succeed([existing, current] as const); + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail(new ProviderAdapterSessionNotFoundError({ provider, 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" }, + }); + }); + + const buildAcpRuntime = (spawnTarget: SpawnTarget, cwd: string) => + Effect.gen(function* () { + const sessionScope = yield* Scope.make("sequential"); + const env: NodeJS.ProcessEnv = { + ...options.environment, + ...spawnTarget.env, + }; + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + spawn: { + command: spawnTarget.command, + args: [...spawnTarget.args], + cwd, + env, + }, + cwd, + clientInfo: { name: "t3-code", version: "0.0.0" }, + }).pipe( + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ), + ).pipe(Effect.provideService(Scope.Scope, sessionScope)); + const acp = yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + return { acp, sessionScope }; + }); + + 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 { acp, sessionScope } = yield* buildAcpRuntime(spawnTarget, cwd).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider, + threadId: input.threadId, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + ); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + Effect.gen(function* () { + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + 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); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: + resolved === "decline" + ? ({ outcome: "cancelled" } as const) + : { outcome: "selected" as const, optionId: acpPermissionOutcome(resolved) }, + }; + }), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(provider, input.threadId, "session/start", error), + ), + ); + + 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* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "AssistantItemStarted": + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: + event._tag === "AssistantItemStarted" + ? "item.started" + : "item.completed", + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "PlanUpdated": + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + 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); + + ctx.notificationFiber = notificationFiber; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + 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: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: AcpRegistryAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(crypto.randomUUID()); + ctx.activeTurnId = turnId; + ctx.session = { ...ctx.session, activeTurnId: turnId, updatedAt: yield* nowIso }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider, + threadId: input.threadId, + turnId, + payload: {}, + }); + + const promptParts: Array = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + for (const attachment of input.attachments ?? []) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + 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)), + ), + ); + + return { + provider, + capabilities: { sessionModelSwitch: "unsupported" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromPubSub(runtimeEventPubSub), + } satisfies AcpRegistryAdapterShape; +}); 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/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 8652b2cfeaf..4608d8b1a97 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -46,7 +46,12 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + /** + * ACP `authenticate` method id. Omit for agents that don't advertise an + * auth method — the `authenticate` step is skipped entirely, matching the + * ACP spec (authenticate is only required when the agent declares it). + */ + readonly authMethodId?: string; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; @@ -378,15 +383,17 @@ const makeAcpSessionRuntime = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + if (options.authMethodId) { + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 5af56dc6b0e..88caadbb1d0 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,6 +20,12 @@ * * @module provider/builtInDrivers */ +import { ACP_REGISTRY } from "@t3tools/contracts"; + +import { + type AcpRegistryDriverEnv, + makeAcpRegistryDriver, +} from "./Drivers/AcpRegistryDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; @@ -35,7 +41,16 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | AcpRegistryDriverEnv; + +/** + * One generic driver per bundled ACP registry entry. The driver factory is + * data-driven — adding agents to the registry snapshot grows this list + * without new code. + */ +const ACP_REGISTRY_DRIVERS: ReadonlyArray> = + ACP_REGISTRY.map(makeAcpRegistryDriver); /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -47,4 +62,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray Effect.succeed([]), + install: (agentId) => + Effect.fail( + new AcpRegistryError({ + agentId, + detail: "ACP registry install is disabled in tests.", + }), + ), + uninstall: () => Effect.void, + }), + ), Layer.provide( Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ read: Effect.succeed({ diff --git a/apps/server/src/textGeneration/AcpRegistryTextGeneration.ts b/apps/server/src/textGeneration/AcpRegistryTextGeneration.ts new file mode 100644 index 00000000000..37b036f7df5 --- /dev/null +++ b/apps/server/src/textGeneration/AcpRegistryTextGeneration.ts @@ -0,0 +1,22 @@ +import { TextGenerationError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import type { TextGenerationShape } from "./TextGeneration.ts"; + +// Registry agents are conversation-only in v1 — commit-message / PR / branch / +// title generation stays on the first-party providers. Every method fails +// with a clear error so callers fall back rather than hang. +const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Text generation is not supported for ACP registry agents.", + }), + ); + +export const makeAcpRegistryTextGeneration = (): TextGenerationShape => ({ + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f87bff7b975..112f08bdb49 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -37,6 +37,7 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { AcpRegistryService, layer as AcpRegistryLive } from "./acpRegistry/AcpRegistryService.ts"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.ts"; import { Keybindings } from "./keybindings.ts"; @@ -193,6 +194,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const bootstrapCredentials = yield* BootstrapCredentialService; const sessions = yield* SessionCredentialService; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const acpRegistry = yield* AcpRegistryService; const serverCommandId = (tag: string) => CommandId.make(`server:${tag}:${crypto.randomUUID()}`); @@ -913,6 +915,22 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", }), + [WS_METHODS.acpRegistryList]: (_input) => + observeRpcEffect(WS_METHODS.acpRegistryList, acpRegistry.list(), { + "rpc.aggregate": "acp-registry", + }), + [WS_METHODS.acpRegistryInstall]: ({ agentId }) => + observeRpcEffect(WS_METHODS.acpRegistryInstall, acpRegistry.install(agentId), { + "rpc.aggregate": "acp-registry", + }), + [WS_METHODS.acpRegistryUninstall]: ({ agentId }) => + observeRpcEffect( + WS_METHODS.acpRegistryUninstall, + acpRegistry.uninstall(agentId).pipe(Effect.as({ agentId })), + { + "rpc.aggregate": "acp-registry", + }, + ), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, @@ -1245,6 +1263,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.provide( makeWsRpcLayer(session.sessionId).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(AcpRegistryLive), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( SourceControlDiscoveryLayer.layer.pipe( diff --git a/apps/web/public/acp-icons/agoragentic-acp.svg b/apps/web/public/acp-icons/agoragentic-acp.svg new file mode 100644 index 00000000000..b1372e68351 --- /dev/null +++ b/apps/web/public/acp-icons/agoragentic-acp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/acp-icons/amp-acp.svg b/apps/web/public/acp-icons/amp-acp.svg new file mode 100644 index 00000000000..314881aff83 --- /dev/null +++ b/apps/web/public/acp-icons/amp-acp.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/public/acp-icons/auggie.svg b/apps/web/public/acp-icons/auggie.svg new file mode 100644 index 00000000000..215107744a7 --- /dev/null +++ b/apps/web/public/acp-icons/auggie.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/public/acp-icons/autohand.svg b/apps/web/public/acp-icons/autohand.svg new file mode 100644 index 00000000000..f3bc983c4d9 --- /dev/null +++ b/apps/web/public/acp-icons/autohand.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/acp-icons/cline.svg b/apps/web/public/acp-icons/cline.svg new file mode 100644 index 00000000000..aeeafbc61e7 --- /dev/null +++ b/apps/web/public/acp-icons/cline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/public/acp-icons/codebuddy-code.svg b/apps/web/public/acp-icons/codebuddy-code.svg new file mode 100644 index 00000000000..735fd352aac --- /dev/null +++ b/apps/web/public/acp-icons/codebuddy-code.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/acp-icons/cortex-code.svg b/apps/web/public/acp-icons/cortex-code.svg new file mode 100644 index 00000000000..28f87a258e0 --- /dev/null +++ b/apps/web/public/acp-icons/cortex-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/corust-agent.svg b/apps/web/public/acp-icons/corust-agent.svg new file mode 100644 index 00000000000..9f30636cb00 --- /dev/null +++ b/apps/web/public/acp-icons/corust-agent.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/web/public/acp-icons/crow-cli.svg b/apps/web/public/acp-icons/crow-cli.svg new file mode 100644 index 00000000000..1169116cd9e --- /dev/null +++ b/apps/web/public/acp-icons/crow-cli.svg @@ -0,0 +1,9 @@ + + + diff --git a/apps/web/public/acp-icons/deepagents.svg b/apps/web/public/acp-icons/deepagents.svg new file mode 100644 index 00000000000..abd818ec47a --- /dev/null +++ b/apps/web/public/acp-icons/deepagents.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/public/acp-icons/dimcode.svg b/apps/web/public/acp-icons/dimcode.svg new file mode 100644 index 00000000000..1fa31ce884b --- /dev/null +++ b/apps/web/public/acp-icons/dimcode.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/dirac.svg b/apps/web/public/acp-icons/dirac.svg new file mode 100644 index 00000000000..4fbb06ceeaa --- /dev/null +++ b/apps/web/public/acp-icons/dirac.svg @@ -0,0 +1,6 @@ + + δ + + + + diff --git a/apps/web/public/acp-icons/factory-droid.svg b/apps/web/public/acp-icons/factory-droid.svg new file mode 100644 index 00000000000..5c6fb8d1ff0 --- /dev/null +++ b/apps/web/public/acp-icons/factory-droid.svg @@ -0,0 +1 @@ + diff --git a/apps/web/public/acp-icons/fast-agent.svg b/apps/web/public/acp-icons/fast-agent.svg new file mode 100644 index 00000000000..a07fab2886c --- /dev/null +++ b/apps/web/public/acp-icons/fast-agent.svg @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/acp-icons/gemini.svg b/apps/web/public/acp-icons/gemini.svg new file mode 100644 index 00000000000..588d89c52ab --- /dev/null +++ b/apps/web/public/acp-icons/gemini.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/github-copilot-cli.svg b/apps/web/public/acp-icons/github-copilot-cli.svg new file mode 100644 index 00000000000..626d33badc4 --- /dev/null +++ b/apps/web/public/acp-icons/github-copilot-cli.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/acp-icons/glm-acp-agent.svg b/apps/web/public/acp-icons/glm-acp-agent.svg new file mode 100644 index 00000000000..d552d2a3d08 --- /dev/null +++ b/apps/web/public/acp-icons/glm-acp-agent.svg @@ -0,0 +1 @@ +Z.ai diff --git a/apps/web/public/acp-icons/goose.svg b/apps/web/public/acp-icons/goose.svg new file mode 100644 index 00000000000..c4928854263 --- /dev/null +++ b/apps/web/public/acp-icons/goose.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/junie.svg b/apps/web/public/acp-icons/junie.svg new file mode 100644 index 00000000000..63b60e8f3a9 --- /dev/null +++ b/apps/web/public/acp-icons/junie.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/web/public/acp-icons/kilo.svg b/apps/web/public/acp-icons/kilo.svg new file mode 100644 index 00000000000..8af6e96f34d --- /dev/null +++ b/apps/web/public/acp-icons/kilo.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/kimi.svg b/apps/web/public/acp-icons/kimi.svg new file mode 100644 index 00000000000..4f7547cf79f --- /dev/null +++ b/apps/web/public/acp-icons/kimi.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/minion-code.svg b/apps/web/public/acp-icons/minion-code.svg new file mode 100644 index 00000000000..eb3d8eb31d7 --- /dev/null +++ b/apps/web/public/acp-icons/minion-code.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/acp-icons/mistral-vibe.svg b/apps/web/public/acp-icons/mistral-vibe.svg new file mode 100644 index 00000000000..b13631b96d9 --- /dev/null +++ b/apps/web/public/acp-icons/mistral-vibe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/public/acp-icons/nova.svg b/apps/web/public/acp-icons/nova.svg new file mode 100644 index 00000000000..5e19f588792 --- /dev/null +++ b/apps/web/public/acp-icons/nova.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/web/public/acp-icons/pi-acp.svg b/apps/web/public/acp-icons/pi-acp.svg new file mode 100644 index 00000000000..68ea8fd7f71 --- /dev/null +++ b/apps/web/public/acp-icons/pi-acp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/public/acp-icons/poolside.svg b/apps/web/public/acp-icons/poolside.svg new file mode 100644 index 00000000000..91de4c46d40 --- /dev/null +++ b/apps/web/public/acp-icons/poolside.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/qoder.svg b/apps/web/public/acp-icons/qoder.svg new file mode 100644 index 00000000000..417d83693dd --- /dev/null +++ b/apps/web/public/acp-icons/qoder.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/qwen-code.svg b/apps/web/public/acp-icons/qwen-code.svg new file mode 100644 index 00000000000..78f88f2831c --- /dev/null +++ b/apps/web/public/acp-icons/qwen-code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/acp-icons/sigit.svg b/apps/web/public/acp-icons/sigit.svg new file mode 100644 index 00000000000..334fc95cbab --- /dev/null +++ b/apps/web/public/acp-icons/sigit.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + diff --git a/apps/web/public/acp-icons/stakpak.svg b/apps/web/public/acp-icons/stakpak.svg new file mode 100644 index 00000000000..64425076ed1 --- /dev/null +++ b/apps/web/public/acp-icons/stakpak.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/acp-icons/vtcode.svg b/apps/web/public/acp-icons/vtcode.svg new file mode 100644 index 00000000000..b47c7b11923 --- /dev/null +++ b/apps/web/public/acp-icons/vtcode.svg @@ -0,0 +1,4 @@ + + + VT + \ No newline at end of file diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 154cada19aa..04659630fec 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -1,5 +1,5 @@ import { type CSSProperties, memo } from "react"; -import { type ProviderDriverKind } from "@t3tools/contracts"; +import { acpRegistryIdFromDriverKind, type ProviderDriverKind } from "@t3tools/contracts"; import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; import { cn } from "~/lib/utils"; @@ -25,6 +25,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { statusDotClassName?: string; }) { const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; + const acpRegistryId = Icon ? undefined : acpRegistryIdFromDriverKind(props.driverKind); const accentStyle = props.accentColor ? ({ "--provider-accent": props.accentColor } as CSSProperties) : undefined; @@ -40,6 +41,13 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { > {Icon ? ( + ) : acpRegistryId ? ( + ) : ( {providerInstanceInitials(props.displayName)} diff --git a/apps/web/src/components/settings/AcpRegistryPanel.tsx b/apps/web/src/components/settings/AcpRegistryPanel.tsx new file mode 100644 index 00000000000..84e9567cfc1 --- /dev/null +++ b/apps/web/src/components/settings/AcpRegistryPanel.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { + type AcpRegistryDistributionKind, + type AcpRegistryEntryWithStatus, +} from "@t3tools/contracts"; +import { DownloadIcon, ExternalLinkIcon, PackageIcon, SearchIcon, Trash2Icon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { cn } from "../../lib/utils"; +import { ensureLocalApi } from "../../localApi"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { ScrollArea } from "../ui/scroll-area"; +import { Spinner } from "../ui/spinner"; +import { stackedThreadToast, toastManager } from "../ui/toast"; + +const REGISTRY_DOCS_URL = "https://agentclientprotocol.com/get-started/registry"; + +const DISTRIBUTION_LABEL: Record = { + binary: "Binary", + npx: "npx", + uvx: "uvx", +}; + +type FilterTab = "all" | "installed" | "not_installed"; + +const TABS: ReadonlyArray<{ id: FilterTab; label: string }> = [ + { id: "all", label: "All" }, + { id: "installed", label: "Installed" }, + { id: "not_installed", label: "Not Installed" }, +]; + +const INSTALLED_STATUSES = new Set(["installed", "update_available"] as const); +const NOT_INSTALLED_STATUSES = new Set(["not_installed", "unsupported"] as const); + +function passesTab(entry: AcpRegistryEntryWithStatus, tab: FilterTab): boolean { + if (tab === "all") return true; + return (tab === "installed" ? INSTALLED_STATUSES : NOT_INSTALLED_STATUSES).has( + entry.status as never, + ); +} + +function matchesQuery(entry: AcpRegistryEntryWithStatus, query: string): boolean { + if (!query) return true; + const needle = query.toLowerCase(); + return ( + entry.entry.id.toLowerCase().includes(needle) || + entry.entry.name.toLowerCase().includes(needle) || + entry.entry.description.toLowerCase().includes(needle) + ); +} + +function describeError(cause: unknown, fallback: string): string { + return cause instanceof Error ? cause.message : fallback; +} + +export function AcpRegistryPanel() { + const [entries, setEntries] = useState>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + const [tab, setTab] = useState("all"); + const [busy, setBusy] = useState>(() => new Set()); + + const refresh = useCallback(async () => { + try { + setEntries(await ensureLocalApi().acpRegistry.list()); + setError(null); + } catch (cause) { + setError(describeError(cause, "Failed to load ACP registry.")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const runAction = useCallback( + async (agentId: string, name: string, action: "install" | "uninstall"): Promise => { + setBusy((prev) => new Set(prev).add(agentId)); + try { + await ensureLocalApi().acpRegistry[action]({ agentId }); + toastManager.add( + stackedThreadToast({ + type: "success", + title: `${name} ${action === "install" ? "installed" : "removed"}`, + }), + ); + await refresh(); + } catch (cause) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to ${action} ${name}`, + description: describeError(cause, String(cause)), + }), + ); + } finally { + setBusy((prev) => { + if (!prev.has(agentId)) return prev; + const next = new Set(prev); + next.delete(agentId); + return next; + }); + } + }, + [refresh], + ); + + const filtered = useMemo( + () => entries.filter((entry) => passesTab(entry, tab) && matchesQuery(entry, query)), + [entries, tab, query], + ); + + const tabCounts = useMemo>(() => { + const counts: Record = { + all: entries.length, + installed: 0, + not_installed: 0, + }; + for (const entry of entries) { + if (INSTALLED_STATUSES.has(entry.status as never)) counts.installed += 1; + if (NOT_INSTALLED_STATUSES.has(entry.status as never)) counts.not_installed += 1; + } + return counts; + }, [entries]); + + return ( +
+
+ + +
+ {loading ? ( + + + Loading registry… + + ) : error ? ( +
+ {error} +
+ ) : filtered.length === 0 ? ( + No agents match your filter. + ) : ( + filtered.map((entry) => ( + void runAction(entry.entry.id, entry.entry.name, "install")} + onUninstall={() => void runAction(entry.entry.id, entry.entry.name, "uninstall")} + /> + )) + )} +
+
+
+ ); +} + +function Header() { + return ( +
+
+

ACP Registry

+

+ Browse, install, and remove ACP-conforming coding agents. Installed agents become + selectable as providers. +

+
+ + Learn more + + +
+ ); +} + +interface ToolbarProps { + query: string; + setQuery: (value: string) => void; + tab: FilterTab; + setTab: (value: FilterTab) => void; + tabCounts: Record; +} + +function Toolbar({ query, setQuery, tab, setTab, tabCounts }: ToolbarProps) { + return ( +
+
+ + setQuery(event.target.value)} + placeholder="Search agents…" + className="pl-8" + /> +
+
+ {TABS.map((option) => { + const active = tab === option.id; + return ( + + ); + })} +
+
+ ); +} + +interface AcpRegistryAgentCardProps { + entry: AcpRegistryEntryWithStatus; + busy: boolean; + onInstall: () => void; + onUninstall: () => void; +} + +function AcpRegistryAgentCard({ entry, busy, onInstall, onUninstall }: AcpRegistryAgentCardProps) { + const { entry: meta, status, installed, availableChannels } = entry; + const isUnsupported = status === "unsupported"; + const isInstalled = INSTALLED_STATUSES.has(status as never); + + return ( +
+
+ { + event.currentTarget.style.display = "none"; + }} + /> +
+ +
+
+

{meta.name}

+ v{meta.version} + {status === "update_available" && installed && ( + + Update from v{installed.version} + + )} +
+

{meta.description}

+
+ ID: {meta.id} + {meta.repository && ( + + + Repo + + )} + {!isUnsupported && availableChannels.length > 0 && ( + + + {availableChannels.map((channel) => DISTRIBUTION_LABEL[channel]).join(" · ")} + + )} +
+
+ +
+ {isUnsupported ? ( + + Unsupported on this platform + + ) : ( + + )} +
+
+ ); +} + +interface ActionButtonProps { + kind: "install" | "remove"; + busy: boolean; + onClick: () => void; +} + +function ActionButton({ kind, busy, onClick }: ActionButtonProps) { + const isInstall = kind === "install"; + const Icon = isInstall ? DownloadIcon : Trash2Icon; + return ( + + ); +} + +function StatusRow({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index affa35ff260..ff04045a977 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -4,6 +4,9 @@ import { CheckIcon } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { useCallback, useEffect, useMemo, useState } from "react"; import { + AcpRegistrySettings, + acpRegistryDriverKindFor, + type AcpRegistryEntryWithStatus, ProviderInstanceId, ProviderDriverKind, type ProviderInstanceConfig, @@ -11,9 +14,11 @@ import { import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; +import { ensureLocalApi } from "../../localApi"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; -import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; +import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; +import type { DriverOption } from "./providerDriverMeta"; import { Dialog, DialogDescription, @@ -81,11 +86,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ label: "Gemini", icon: Gemini, }, - { - value: ProviderDriverKind.make("acpRegistry"), - label: "ACP Registry", - icon: ACPRegistryIcon, - }, { value: ProviderDriverKind.make("piAgent"), label: "Pi Agent", @@ -93,6 +93,24 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ }, ]; +/** Per-agent icon backed by the bundled `/acp-icons/.svg` asset. */ +function makeAcpRegistryIcon(agentId: string): Icon { + return function AcpRegistryAgentIcon({ className }) { + return ; + }; +} + +/** Build a `DriverOption` for an installed ACP registry agent. */ +function toDriverOption(entry: AcpRegistryEntryWithStatus): DriverOption { + return { + value: ProviderDriverKind.make(acpRegistryDriverKindFor(entry.entry.id)), + label: entry.entry.name, + icon: makeAcpRegistryIcon(entry.entry.id), + settingsSchema: AcpRegistrySettings, + badgeLabel: "ACP", + }; +} + /** * Validate an instance id against the same slug rules the server applies in * `ProviderInstanceId` (see `packages/contracts/src/providerInstance.ts`). @@ -129,12 +147,42 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns // Errors are suppressed until the user has tried to submit once. After that // they update live so fixing the problem clears the message in place. const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + const [acpRegistryOptions, setAcpRegistryOptions] = useState>([]); const existingIds = useMemo( () => new Set(Object.keys(settings.providerInstances ?? {})), [settings.providerInstances], ); + // Installed ACP registry agents become first-class selectable drivers. + useEffect(() => { + if (!open) return; + let cancelled = false; + void ensureLocalApi() + .acpRegistry.list() + .then((entries) => { + if (cancelled) return; + setAcpRegistryOptions( + entries + .filter( + (entry) => entry.status === "installed" || entry.status === "update_available", + ) + .map(toDriverOption), + ); + }) + .catch(() => { + if (!cancelled) setAcpRegistryOptions([]); + }); + return () => { + cancelled = true; + }; + }, [open]); + + const acpRegistryOptionByValue = useMemo( + () => new Map(acpRegistryOptions.map((option) => [option.value, option] as const)), + [acpRegistryOptions], + ); + // Reset the form every time the dialog opens so each creation starts // from a clean slate. useEffect(() => { @@ -156,7 +204,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns setInstanceId(deriveInstanceId(driver, label)); }, [driver, label, instanceIdDirty]); - const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; + const driverOption = + DRIVER_OPTION_BY_VALUE[driver] ?? acpRegistryOptionByValue.get(driver) ?? DEFAULT_DRIVER_OPTION; const driverSettingsFields = useMemo( () => deriveProviderSettingsFields(driverOption), [driverOption], @@ -332,6 +381,33 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns ); })} + {acpRegistryOptions.map((option) => { + const IconComponent = option.icon; + const isSelected = option.value === driver; + return ( + + + + {option.label} + + {option.badgeLabel ? ( + + {option.badgeLabel} + + ) : null} + + ); + })} {COMING_SOON_DRIVER_OPTIONS.map((option) => { const IconComponent = option.icon; return ( diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index bec96063868..1ce565ac064 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -6,6 +6,7 @@ import { GitBranchIcon, KeyboardIcon, Link2Icon, + PackageIcon, Settings2Icon, } from "lucide-react"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; @@ -25,6 +26,7 @@ export type SettingsSectionPath = | "/settings/general" | "/settings/keybindings" | "/settings/providers" + | "/settings/acp-registry" | "/settings/source-control" | "/settings/connections" | "/settings/archived"; @@ -37,6 +39,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "General", to: "/settings/general", icon: Settings2Icon }, { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, { label: "Providers", to: "/settings/providers", icon: BotIcon }, + { label: "ACP Registry", to: "/settings/acp-registry", icon: PackageIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index cbb3427b004..e55d0194cae 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -118,6 +118,18 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { removeBrowserSavedEnvironmentSecret(environmentId); }, }, + acpRegistry: { + list: () => + rpcClient ? rpcClient.acpRegistry.list() : Promise.reject(unavailableLocalBackendError()), + install: (input) => + rpcClient + ? rpcClient.acpRegistry.install(input) + : Promise.reject(unavailableLocalBackendError()), + uninstall: (input) => + rpcClient + ? rpcClient.acpRegistry.uninstall(input) + : Promise.reject(unavailableLocalBackendError()), + }, server: { getConfig: () => rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()), 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..40eac45ee31 --- /dev/null +++ b/apps/web/src/routes/settings.acp-registry.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { AcpRegistryPanel } from "../components/settings/AcpRegistryPanel"; + +function SettingsAcpRegistryRoute() { + return ; +} + +export const Route = createFileRoute("/settings/acp-registry")({ + component: SettingsAcpRegistryRoute, +}); diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 7128c909ab7..33146024a77 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -140,6 +140,11 @@ export interface WsRpcClient { readonly subscribeLifecycle: RpcStreamMethod; readonly subscribeAuthAccess: RpcStreamMethod; }; + readonly acpRegistry: { + readonly list: RpcUnaryNoArgMethod; + readonly install: RpcUnaryMethod; + readonly uninstall: RpcUnaryMethod; + }; readonly orchestration: { readonly dispatchCommand: RpcUnaryMethod; readonly getTurnDiff: RpcUnaryMethod; @@ -286,6 +291,13 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { tag: WS_METHODS.subscribeAuthAccess, }), }, + acpRegistry: { + list: () => transport.request((client) => client[WS_METHODS.acpRegistryList]({})), + install: (input) => + transport.request((client) => client[WS_METHODS.acpRegistryInstall](input)), + uninstall: (input) => + transport.request((client) => client[WS_METHODS.acpRegistryUninstall](input)), + }, orchestration: { dispatchCommand: (input) => transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), diff --git a/bun.lock b/bun.lock index 476c50c005c..ffc4a5922bd 100644 --- a/bun.lock +++ b/bun.lock @@ -154,6 +154,7 @@ "name": "@t3tools/client-runtime", "dependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", "effect": "catalog:", }, "devDependencies": { diff --git a/docs/providers/acp-registry.md b/docs/providers/acp-registry.md new file mode 100644 index 00000000000..a4a1f3d8d6f --- /dev/null +++ b/docs/providers/acp-registry.md @@ -0,0 +1,101 @@ +# ACP Registry + +The ACP Registry is the catalog of coding agents that speak the +[X.com DMs](https://x.com/i/chat/116227293-786375418685165568). 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 + 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 | `acpRegistryInstalls` map in `settings.json`. | +| Icons | `apps/web/public/acp-icons/.svg`, mirrored from `packages/contracts/src/registry/icons/`. | + +## Browsing And Installing + +Open **Settings → ACP Registry**. You'll see one card per agent with: + +- icon, display name, version +- short description and upstream repo link +- 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. The tab filter +(`All / Installed / Not Installed`) narrows the list. Counts in each tab badge +update as you install. + +Pressing **Install**: + +1. Picks the first supported channel (binary if a target exists for your + platform, otherwise `npx`, then `uvx`). +2. For `binary`: downloads the archive (`.tar.gz`/`.tgz`/`.tar.bz2`/`.tbz2`/ + `.zip` or raw binary) to the cache dir, extracts it with `tar`/`unzip`, and + `chmod +x`'s the declared `cmd`. +3. For `npx` / `uvx`: just records the choice — the spawn happens lazily. +4. Persists the result to `settings.json` so the install survives restarts. + +Pressing **Remove** wipes both the settings entry and the agent's cache dir. + +If an agent's binary target doesn't include your platform AND it has no +`npx`/`uvx` fallback, the card shows "Unsupported on this platform" instead +of an Install button. + +## Authentication + +The registry intentionally doesn't capture auth requirements — agents handle +their own OAuth/API-key flows over the ACP handshake. For agents that need +API keys (Gemini, Mistral, Qwen, etc.), set the variable on 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. + - `AcpRegistryService.ts` is the Effect service consumed by the WS RPC + handlers (`acpRegistry.list` / `.install` / `.uninstall`). +- **Web**: `apps/web/src/components/settings/AcpRegistryPanel.tsx` is the + Zed-style page; the route is `apps/web/src/routes/settings.acp-registry.tsx`. + +## What This Doesn't Do Yet + +Installing an agent records its state and downloads its binary. Wiring an +installed agent into the chat provider picker requires a generic +`AcpRegistryAdapter` that mirrors the existing `CursorAdapter` / +`OpenCodeAdapter` (≈1000+ lines of ACP session/turn/tool plumbing). That +adapter is a follow-up — the installer and UI scaffolding are in place so it +can be added without further protocol/UI changes. + +In the meantime, treat the ACP Registry page as a managed installer for +agents you'll run manually outside T3 Code, with the bonus that everything +becomes ready to plug into chat once the adapter lands. diff --git a/package.json b/package.json index a1aa5d0b1cd..f02d31c24bd 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", - "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" + "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs", + "sync:acp-registry": "bun run scripts/sync-acp-registry.ts" }, "devDependencies": { "@effect/language-service": "catalog:", diff --git a/packages/contracts/src/acpRegistry.ts b/packages/contracts/src/acpRegistry.ts new file mode 100644 index 00000000000..be1ca016486 --- /dev/null +++ b/packages/contracts/src/acpRegistry.ts @@ -0,0 +1,121 @@ +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, + 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; + +export const AcpRegistryInstallState = Schema.Struct({ + version: TrimmedNonEmptyString, + installedAt: Schema.String, + distribution: AcpRegistryDistributionKind, + binaryPath: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type AcpRegistryInstallState = typeof AcpRegistryInstallState.Type; + +export const AcpRegistryInstallStatus = Schema.Literals([ + "installed", + "not_installed", + "unsupported", + "update_available", +]); +export type AcpRegistryInstallStatus = typeof AcpRegistryInstallStatus.Type; + +export const AcpRegistryEntryWithStatus = Schema.Struct({ + entry: AcpRegistryEntry, + status: AcpRegistryInstallStatus, + installed: Schema.optionalKey(AcpRegistryInstallState), + availableChannels: Schema.Array(AcpRegistryDistributionKind), +}); +export type AcpRegistryEntryWithStatus = typeof AcpRegistryEntryWithStatus.Type; + +export class AcpRegistryError extends Schema.TaggedErrorClass()( + "AcpRegistryError", + { + agentId: Schema.optional(Schema.String), + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + const prefix = this.agentId ? `[${this.agentId}] ` : ""; + return `${prefix}ACP registry error: ${this.detail}`; + } +} + +// A registry agent `gemini` registers as driver kind `acp-gemini`. The prefix +// namespaces it away from the four bespoke driver kinds. +export const ACP_REGISTRY_DRIVER_PREFIX = "acp-" as const; + +export const acpRegistryDriverKindFor = (id: string): string => + `${ACP_REGISTRY_DRIVER_PREFIX}${id}`; + +export const acpRegistryIdFromDriverKind = (driverKind: string): string | undefined => + driverKind.startsWith(ACP_REGISTRY_DRIVER_PREFIX) + ? driverKind.slice(ACP_REGISTRY_DRIVER_PREFIX.length) + : undefined; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8402c82647d..3fe8348d418 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -20,3 +20,5 @@ export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; export * from "./rpc.ts"; +export * from "./acpRegistry.ts"; +export * from "./registry/index.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 58894adac1a..14e683860c6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -18,6 +18,7 @@ import type { VcsStatusResult, VcsCreateRefResult, } from "./git.ts"; +import type { AcpRegistryEntryWithStatus, AcpRegistryInstallState } from "./acpRegistry.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { ProjectSearchEntriesInput, @@ -457,6 +458,11 @@ export interface LocalApi { setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; }; + acpRegistry: { + list: () => Promise>; + install: (input: { readonly agentId: string }) => Promise; + uninstall: (input: { readonly agentId: string }) => Promise<{ readonly agentId: string }>; + }; server: { getConfig: () => Promise; /** 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/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/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/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/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..706e648d416 --- /dev/null +++ b/packages/contracts/src/registry/registry.json @@ -0,0 +1,832 @@ +{ + "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.26.0", + "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.26.0", + "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": "cline", + "name": "Cline", + "version": "3.0.2", + "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.2", + "args": ["--acp"] + } + } + }, + { + "id": "codebuddy-code", + "name": "Codebuddy Code", + "version": "2.97.0", + "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.0", + "args": ["--acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codebuddy-code.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": "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.41", + "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.41", + "args": ["--acp"] + } + } + }, + { + "id": "factory-droid", + "name": "Factory Droid", + "version": "0.124.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.124.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.3", + "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.3", + "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.47", + "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.47", + "args": ["--acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/github-copilot-cli.svg" + }, + { + "id": "glm-acp-agent", + "name": "GLM Agent", + "version": "1.1.3", + "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.3" + } + } + }, + { + "id": "goose", + "name": "goose", + "version": "1.34.0", + "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.0/goose-aarch64-apple-darwin.tar.bz2", + "cmd": "./goose", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.34.0/goose-x86_64-apple-darwin.tar.bz2", + "cmd": "./goose", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/block/goose/releases/download/v1.34.0/goose-aarch64-unknown-linux-gnu.tar.bz2", + "cmd": "./goose", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.34.0/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.0/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.2.52", + "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.2.52/kilo-darwin-arm64.zip", + "cmd": "./kilo", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.52/kilo-darwin-x64.zip", + "cmd": "./kilo", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.52/kilo-linux-arm64.tar.gz", + "cmd": "./kilo", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.52/kilo-linux-x64.tar.gz", + "cmd": "./kilo", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.52/kilo-windows-x64.zip", + "cmd": "./kilo.exe", + "args": ["acp"] + } + }, + "npx": { + "package": "@kilocode/cli@7.2.52", + "args": ["acp"] + } + } + }, + { + "id": "kimi", + "name": "Kimi CLI", + "version": "1.43.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.43.0/kimi-1.43.0-aarch64-apple-darwin.tar.gz", + "cmd": "./kimi", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.43.0/kimi-1.43.0-aarch64-unknown-linux-gnu.tar.gz", + "cmd": "./kimi", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.43.0/kimi-1.43.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.43.0/kimi-1.43.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.8", + "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.8", + "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.80", + "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.80/stakpak-darwin-aarch64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.80/stakpak-darwin-x86_64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.80/stakpak-linux-aarch64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.80/stakpak-linux-x86_64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.80/stakpak-windows-x86_64.zip", + "cmd": "./stakpak.exe", + "args": ["acp"] + } + } + } + }, + { + "id": "vtcode", + "name": "VT Code", + "version": "0.96.14", + "description": "An open-source coding agent with LLM-native code understanding and robust shell safety. Supports multiple LLM providers with automatic failover and efficient context management.", + "repository": "https://github.com/vinhnx/VTCode", + "website": "https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md", + "authors": ["vinhnx"], + "license": "MIT", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-aarch64-apple-darwin.tar.gz", + "cmd": "./vtcode", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "darwin-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-apple-darwin.tar.gz", + "cmd": "./vtcode", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "linux-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./vtcode", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "windows-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-pc-windows-msvc.zip", + "cmd": "vtcode.exe", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/vtcode.svg" + } + ] +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index eb0c82bebd0..a0a52b4e863 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,11 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { + AcpRegistryEntryWithStatus, + AcpRegistryError, + AcpRegistryInstallState, +} from "./acpRegistry.ts"; import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; import { AuthAccessStreamEvent } from "./auth.ts"; import { @@ -152,6 +157,11 @@ export const WS_METHODS = { sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", + // ACP registry methods + acpRegistryList: "acpRegistry.list", + acpRegistryInstall: "acpRegistry.install", + acpRegistryUninstall: "acpRegistry.uninstall", + // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", @@ -276,6 +286,24 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: FilesystemBrowseError, }); +export const WsAcpRegistryListRpc = Rpc.make(WS_METHODS.acpRegistryList, { + payload: Schema.Struct({}), + success: Schema.Array(AcpRegistryEntryWithStatus), + error: AcpRegistryError, +}); + +export const WsAcpRegistryInstallRpc = Rpc.make(WS_METHODS.acpRegistryInstall, { + payload: Schema.Struct({ agentId: Schema.String }), + success: AcpRegistryInstallState, + error: AcpRegistryError, +}); + +export const WsAcpRegistryUninstallRpc = Rpc.make(WS_METHODS.acpRegistryUninstall, { + payload: Schema.Struct({ agentId: Schema.String }), + success: Schema.Struct({ agentId: Schema.String }), + error: AcpRegistryError, +}); + export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -509,4 +537,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, + WsAcpRegistryListRpc, + WsAcpRegistryInstallRpc, + WsAcpRegistryUninstallRpc, ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..fdd5773d005 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; +import { AcpRegistryInstallState } from "./acpRegistry.ts"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; @@ -331,6 +332,28 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const AcpRegistrySettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Binary path", + description: + "Override the executable used to spawn this ACP registry agent. Leave blank to use the installed distribution.", + providerSettingsForm: { clearWhenEmpty: "omit" }, + }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -379,6 +402,22 @@ export const ServerSettings = Schema.Struct({ providerInstances: Schema.Record(ProviderInstanceId, ProviderInstanceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + /** + * Per-agent install state for the bundled ACP registry. Keyed by + * `AcpRegistryEntry.id`; absence means "not installed". Records the + * resolved version and the distribution channel chosen at install time + * so a subsequent server restart can re-spawn the same agent without + * re-probing the network. + * + * For `binary` installs, `binaryPath` holds the absolute path of the + * extracted executable; for `npx`/`uvx` installs the spawn target is + * resolved at runtime from the bundled registry entry. The field is a + * plain `Record` because the keys aren't `ProviderInstanceId` + * slugs — they're the upstream registry ids (e.g. `gemini`, `goose`). + */ + acpRegistryInstalls: Schema.Record(Schema.String, AcpRegistryInstallState).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -471,6 +510,10 @@ export const ServerSettingsPatch = Schema.Struct({ // patches risk leaving driver-specific config in a half-merged state. // The web UI sends a fully-formed map every time it edits this field. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), + // Whole-map replacement for ACP registry install state. The server is + // the source of truth for this field (install/uninstall RPCs mutate it); + // the patch path exists for symmetry but is rarely used by the UI. + acpRegistryInstalls: Schema.optionalKey(Schema.Record(Schema.String, AcpRegistryInstallState)), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/scripts/sync-acp-registry.ts b/scripts/sync-acp-registry.ts new file mode 100644 index 00000000000..16468cd26f5 --- /dev/null +++ b/scripts/sync-acp-registry.ts @@ -0,0 +1,151 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const REGISTRY_JSON_PATH = path.join(REPO_ROOT, "packages/contracts/src/registry/registry.json"); +const ICON_DIRS = [ + path.join(REPO_ROOT, "packages/contracts/src/registry/icons"), + path.join(REPO_ROOT, "apps/web/public/acp-icons"), +]; +const DEFAULT_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; + +// First-party drivers have bespoke implementations; skip the registry copy. +const EXCLUDED_AGENT_IDS = new Set(["claude-acp", "cursor", "opencode", "codex-acp"]); + +interface RegistryAgent { + id: string; + name: string; + version: string; + description: string; + icon?: string; + [key: string]: unknown; +} + +interface RegistryDocument { + version: string; + agents: RegistryAgent[]; +} + +interface CliArgs { + registryUrl: string; + skipIcons: boolean; +} + +const USAGE = "Usage: sync-acp-registry [--registry-url ] [--skip-icons]"; + +function parseArgs(argv: ReadonlyArray): CliArgs { + const args: CliArgs = { registryUrl: DEFAULT_REGISTRY_URL, skipIcons: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--registry-url") { + const value = argv[++i]; + if (!value) throw new Error("--registry-url requires a value"); + args.registryUrl = value; + } else if (arg === "--skip-icons") { + args.skipIcons = true; + } else if (arg === "--help" || arg === "-h") { + process.stdout.write(`${USAGE}\n`); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return args; +} + +async function fetchRegistry(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Fetch failed (${response.status} ${response.statusText}) — ${url}`); + } + const payload = (await response.json()) as RegistryDocument; + if (!Array.isArray(payload.agents)) { + throw new Error("Registry payload did not contain an `agents` array"); + } + return payload; +} + +async function downloadIcon(agent: RegistryAgent): Promise { + if (typeof agent.icon !== "string" || agent.icon.length === 0) return false; + const response = await fetch(agent.icon); + if (!response.ok) return false; + const text = await response.text(); + if (!text.trimStart().startsWith("<")) return false; + await Promise.all( + ICON_DIRS.map((dir) => fs.writeFile(path.join(dir, `${agent.id}.svg`), text, "utf8")), + ); + return true; +} + +async function pruneStaleIcons(wantedIds: ReadonlySet): Promise { + const wanted = new Set(Array.from(wantedIds, (id) => `${id}.svg`)); + await Promise.all( + ICON_DIRS.map(async (dir) => { + const existing = await fs.readdir(dir).catch(() => [] as string[]); + await Promise.all( + existing + .filter((entry) => !wanted.has(entry)) + .map((entry) => fs.rm(path.join(dir, entry), { force: true })), + ); + }), + ); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + await Promise.all(ICON_DIRS.map((dir) => fs.mkdir(dir, { recursive: true }))); + + process.stdout.write(`Fetching ${args.registryUrl}\n`); + const upstream = await fetchRegistry(args.registryUrl); + + const filtered = upstream.agents + .filter((agent) => !EXCLUDED_AGENT_IDS.has(agent.id)) + .sort((a, b) => a.id.localeCompare(b.id)); + const excludedIds = upstream.agents + .filter((agent) => EXCLUDED_AGENT_IDS.has(agent.id)) + .map((agent) => agent.id); + + const snapshot: RegistryDocument = { version: upstream.version, agents: filtered }; + await fs.writeFile(REGISTRY_JSON_PATH, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8"); + + await pruneStaleIcons(new Set(filtered.map((agent) => agent.id))); + + let iconOk = 0; + let iconMissing = 0; + if (!args.skipIcons) { + process.stdout.write(`Downloading ${filtered.length} icons…\n`); + const results = await Promise.all( + filtered.map((agent) => downloadIcon(agent).catch(() => false)), + ); + results.forEach((ok, index) => { + if (ok) { + iconOk += 1; + return; + } + iconMissing += 1; + process.stderr.write(` ! icon missing for ${filtered[index]!.id}\n`); + }); + } + + process.stdout.write( + [ + `Synced ACP registry v${upstream.version}`, + ` agents bundled : ${filtered.length}`, + ` agents excluded: ${excludedIds.length} (${excludedIds.join(", ") || "—"})`, + ` icons : ${args.skipIcons ? "skipped" : `${iconOk} ok, ${iconMissing} missing`}`, + ` output : ${path.relative(REPO_ROOT, REGISTRY_JSON_PATH)}`, + "", + ].join("\n"), + ); +} + +main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exit(1); +});