diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 55fa5debd..0a942b9d2 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -85,9 +85,9 @@ to one of these built-in driver integrations: - `opencode`: the host-installed OpenCode CLI; DevSpace probes its major version and uses the matching 1.x or 2.x protocol internally - `pi`: the installed Pi coding-agent SDK, one in-process session per DevSpace agent -- `cursor`: ACP -- `copilot`: ACP -- `grok`: Grok Build ACP (`grok agent stdio`) +- `acp`: generic Agent Client Protocol integration. Cursor, Copilot, and Grok + are default provider instances using this driver; other ACP agents can be + configured without adding a DevSpace driver. Codex and OpenCode are resolved from the user's environment rather than bundled as executables. Run their normal login/setup flows before using them; set @@ -124,8 +124,8 @@ DevSpace passes this through to providers that expose a matching control: - `codex`: app-server model reasoning effort. - `pi`: the AgentSession thinking-level control. - `opencode`: model variant. -- `cursor` and `copilot`: ACP thought-level config when supported. -- `grok`: `--reasoning-effort` on startup and xAI's ACP model metadata for resumed sessions. +- ACP: standard model/thought-level config when advertised by the agent. The + Grok flavor also understands xAI's typed model metadata and startup effort. ### `disabled` diff --git a/docs/configuration.md b/docs/configuration.md index 271f1b6fc..2a86dcc5d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -166,8 +166,9 @@ multiple independently configured instances of one integration: ``` Profiles can then select that instance with `provider: codex-work`. The driver -is the implementation (`codex`, `claude`, `opencode`, `pi`, `cursor`, -`copilot`, or `grok`); provider-native session ids remain internal to DevSpace. +is the implementation (`codex`, `claude`, `opencode`, `pi`, or `acp`); +provider-native session ids remain internal to DevSpace. Cursor, Copilot, and +Grok are default ACP provider instances rather than separate drivers. `subagents.instructions` controls when ChatGPT receives the managed workflow: @@ -198,10 +199,23 @@ runtime and automatically selects the OpenCode 1.x or 2.x protocol. Both major versions use the same provider instance and profile configuration; `opencode2` is not a separate driver. -Codex, Claude, Cursor, Copilot, and Grok also accept `command`. OpenCode and Pi -do not expose a command override. For providers that support it, an explicit -`command` wins over both the inherited command override and a command override -placed in `env`. +Codex, Claude, and ACP instances also accept `command`. OpenCode and Pi do not +expose a command override. Cursor, Copilot, and Grok retain their built-in ACP +commands when `command` is omitted. An arbitrary ACP instance declares its +executable and may provide fixed startup arguments under `config.args`: + +```jsonc +{ + "id": "kiro", + "driver": "acp", + "enabled": true, + "command": "kiro", + "config": { "args": ["acp"] } +} +``` + +`config.flavor` is reserved for the small built-in protocol quirks currently +needed by `cursor`, `copilot`, and `grok`; generic ACP agents normally omit it. Existing process-level overrides remain supported: `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index de7a66041..ecd6270c9 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -190,6 +190,7 @@ "claude", "opencode", "pi", + "acp", "cursor", "copilot", "grok" @@ -220,6 +221,26 @@ "type": "string", "minLength": 1, "pattern": "\\S" + }, + "config": { + "type": "object", + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "flavor": { + "type": "string", + "enum": [ + "cursor", + "copilot", + "grok" + ] + } + }, + "additionalProperties": false } }, "required": [ diff --git a/src/config-migration.ts b/src/config-migration.ts index 7614edf35..e7ec9665b 100644 --- a/src/config-migration.ts +++ b/src/config-migration.ts @@ -5,7 +5,7 @@ import { type DevspaceConfig, } from "./config-schema.js"; import { storedSubagentsConfigSchema } from "./local-agent-config.js"; -import { LOCAL_AGENT_DRIVER_KINDS } from "./local-agent-provider.js"; +import { LOCAL_AGENT_DEFAULT_PROVIDER_IDS } from "./local-agent-provider.js"; const legacyConfigSchema = z.object({ host: z.string().optional(), @@ -92,7 +92,7 @@ function migrateLegacySubagents( return { enabled: value, providers: value - ? LOCAL_AGENT_DRIVER_KINDS.map((id) => ({ id, enabled: true })) + ? LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => ({ id, enabled: true })) : [], }; } diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 9ce071df4..55c3f7b36 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -52,6 +52,11 @@ const migrations: Migration[] = [ name: "local-agent-provider-instances", up: migrateLocalAgentProviderInstances, }, + { + version: 10, + name: "local-agent-generic-acp-driver", + up: migrateLocalAgentGenericAcpDriver, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -310,6 +315,14 @@ function migrateLocalAgentProviderInstances(sqlite: Database.Database): void { `); } +function migrateLocalAgentGenericAcpDriver(sqlite: Database.Database): void { + sqlite.exec(` + update local_agent_sessions + set driver = 'acp' + where driver in ('cursor', 'copilot', 'grok') + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 2d6fa3fe5..e84423e68 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -55,7 +55,7 @@ const connection = { const sessionIds: string[] = []; const runtime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -106,7 +106,7 @@ assert.equal(requests.filter(({ method }) => method === "session/close").length, assert.equal(runtime.isAlive(), true); const resumedRuntime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -145,7 +145,7 @@ assert.equal(queues.has("cursor_session_1"), false); assert.equal(requests.filter(({ method }) => method === "session/close").length, 1); const closeOnlyRuntime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -229,7 +229,7 @@ const overlapConnection = { closed: new Promise(() => undefined), }; const overlapRuntime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -259,7 +259,7 @@ await overlapRuntime.close(); const cachedContext = { agentId: "agt_acp", providerInstanceId: "cursor", - provider: "cursor" as const, + provider: "acp" as const, workspaceRoot: "/tmp/project", writeMode: "allowed" as const, }; @@ -270,13 +270,13 @@ assert.deepEqual(acpCommandArgs("cursor", cachedContext), [ assert.deepEqual(acpCommandArgs("grok", { ...cachedContext, providerInstanceId: "grok", - provider: "grok", + provider: "acp", effort: "low", }), ["agent", "--reasoning-effort", "low", "stdio"]); assert.deepEqual(acpCommandArgs("grok", { ...cachedContext, providerInstanceId: "grok", - provider: "grok", + provider: "acp", effort: "low", }, { GROK_AGENT_PROFILE: " /tmp/grok-coding-only.md " }), [ "agent", "--agent-profile", "/tmp/grok-coding-only.md", "--reasoning-effort", "low", "stdio", @@ -291,11 +291,11 @@ assert.deepEqual(acpCommandArgs("copilot", { ...cachedContext, writeMode: "full_ "--acp", "--no-sandbox", "--allow-all", "-C", resolvedProject, ]); -const missingCommandDriver = new AcpLocalAgentDriver( - "cursor", - process.env, - () => join(tmpdir(), "devspace-definitely-missing-acp-command"), -); +const missingCommandDriver = new AcpLocalAgentDriver({ + flavor: "cursor", + env: process.env, + commandResolver: () => join(tmpdir(), "devspace-definitely-missing-acp-command"), +}); const missingCommand = await missingCommandDriver.createRuntime(cachedContext); assert.equal(missingCommand.isErr(), true); if (missingCommand.isErr()) { @@ -318,11 +318,11 @@ if (process.platform === "win32") { `require("node:fs").writeFileSync(${JSON.stringify(marker)}, JSON.stringify(process.argv.slice(2)));\n`, ); await writeFile(command, `@ECHO OFF\r\n"${process.execPath}" "${recorder}" %*\r\n`); - const shimDriver = new AcpLocalAgentDriver("copilot", process.env, () => command); + const shimDriver = new AcpLocalAgentDriver({ flavor: "copilot", env: process.env, commandResolver: () => command }); const shimStartup = await shimDriver.createRuntime({ ...cachedContext, providerInstanceId: "copilot", - provider: "copilot", + provider: "acp", workspaceRoot, }); assert.equal(shimStartup.isErr(), true); @@ -397,7 +397,7 @@ const grokConnection = { closed: new Promise(() => undefined), }; const grokRuntime = new AcpRuntime({ - provider: "grok", + flavor: "grok", command: "grok", args: ["agent", "--reasoning-effort", "low", "stdio"], env: {}, @@ -445,7 +445,7 @@ const grokConfigurationConnection = { closed: new Promise(() => undefined), }; const grokConfigurationRuntime = new AcpRuntime({ - provider: "grok", + flavor: "grok", command: "grok", args: ["agent", "stdio"], env: {}, diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 3736a6c58..4f482e9a2 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -30,7 +30,8 @@ import type { } from "./local-agent-runtime.js"; import { resolveExecutableCommand } from "./local-agent-command.js"; -export type AcpProvider = "cursor" | "copilot" | "grok"; +export type AcpFlavor = "cursor" | "copilot" | "grok" | "generic"; +export type AcpProvider = AcpFlavor; const MAX_ACP_QUEUE_ITEMS = 10_000; const MAX_ACP_STDERR_BYTES = 32 * 1024; @@ -41,7 +42,7 @@ const spawn = require("cross-spawn") as typeof import("node:child_process").spaw const observeChildError = (): void => {}; -const ACP_COMMANDS: Record = { +const ACP_COMMANDS: Partial> = { cursor: ["cursor-agent", "acp"], copilot: ["copilot", "--acp"], grok: ["grok", "agent", "stdio"], @@ -67,7 +68,7 @@ interface AcpSessionQueue { } export interface AcpRuntimeOptions { - provider: AcpProvider; + flavor: AcpFlavor; command: string; args: string[]; env: NodeJS.ProcessEnv; @@ -82,7 +83,8 @@ export interface AcpRuntimeOptions { } export class AcpRuntime implements LocalAgentRuntime { - readonly provider: AcpProvider; + readonly provider = "acp" as const; + private readonly flavor: AcpFlavor; private readonly child?: ChildProcessWithoutNullStreams; private readonly connection: AcpConnectionLike; private readonly capabilities: AcpCapabilities; @@ -98,7 +100,7 @@ export class AcpRuntime implements LocalAgentRuntime { private closed = false; constructor(options: AcpRuntimeOptions, connection: AcpConnectionLike) { - this.provider = options.provider; + this.flavor = options.flavor; this.child = options.child; this.connection = connection; this.capabilities = options.capabilities ?? { resume: false, close: false }; @@ -149,7 +151,7 @@ export class AcpRuntime implements LocalAgentRuntime { )); const queue = this.queues.get(sessionId) ?? { values: [] }; this.queues.set(sessionId, queue); - const promptId = this.provider === "grok" ? this.nextPromptId() : undefined; + const promptId = this.flavor === "grok" ? this.nextPromptId() : undefined; const completion = promptId && this.grokCompletionRegistry ? this.grokCompletionRegistry.wait( sessionId, @@ -309,7 +311,7 @@ export class AcpRuntime implements LocalAgentRuntime { } private cacheSessionMetadata(sessionId: string, response: unknown): void { - if (hasAcpConfigOptions(response) || (this.provider === "grok" && readGrokSessionState(response))) { + if (hasAcpConfigOptions(response) || (this.flavor === "grok" && readGrokSessionState(response))) { this.sessionMetadata.set(sessionId, response); } } @@ -321,7 +323,7 @@ export class AcpRuntime implements LocalAgentRuntime { isNewSession = false, ): Promise { const metadata = response ?? this.sessionMetadata.get(sessionId); - if (this.provider === "grok") { + if (this.flavor === "grok") { await this.configureGrokSession(sessionId, input, metadata, isNewSession); return; } @@ -348,11 +350,11 @@ export class AcpRuntime implements LocalAgentRuntime { return; } if (input.model) { - const config = resolveAcpModelConfigUpdate(metadata, input.model, this.provider, sessionId); + const config = resolveAcpModelConfigUpdate(metadata, input.model, this.flavor, sessionId); await this.connection.agent.request("session/set_config_option", config); } if (input.effort) { - const config = resolveAcpEffortConfigUpdate(metadata, input.effort, this.provider, sessionId); + const config = resolveAcpEffortConfigUpdate(metadata, input.effort, this.flavor, sessionId); await this.connection.agent.request("session/set_config_option", config); } } @@ -422,8 +424,8 @@ export class AcpRuntime implements LocalAgentRuntime { } export class AcpLocalAgentDriver implements LocalAgentDriver { - readonly provider: AcpProvider; - readonly providerInstanceId: string; + readonly provider = "acp" as const; + readonly providerInstanceId = "acp"; readonly runtimePolicy = { scope: "workspace", authority: "write_mode", @@ -438,14 +440,24 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { } as const; private commandResolved = false; private resolvedCommand?: string; - - constructor( - provider: AcpProvider, - private readonly env: NodeJS.ProcessEnv = process.env, - private readonly commandResolver: AcpCommandResolver = resolveAcpCommand, - ) { - this.provider = provider; - this.providerInstanceId = provider; + private readonly flavor: AcpFlavor; + private readonly env: NodeJS.ProcessEnv; + private readonly configuredCommand?: string; + private readonly configuredArgs?: string[]; + private readonly commandResolver: AcpCommandResolver; + + constructor(options: { + flavor?: Exclude; + command?: string; + args?: string[]; + env?: NodeJS.ProcessEnv; + commandResolver?: AcpCommandResolver; + } = {}) { + this.flavor = options.flavor ?? "generic"; + this.env = options.env ?? process.env; + this.configuredCommand = options.command; + this.configuredArgs = options.args; + this.commandResolver = options.commandResolver ?? resolveAcpCommand; } async createRuntime(context: LocalAgentRuntimeContext) { @@ -465,7 +477,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { message: `${this.provider} executable was not found.`, }); } - const args = acpCommandArgs(this.provider, context, this.env); + const args = this.configuredArgs ?? acpCommandArgs(this.flavor, context, this.env); const child = spawn(command, args, { cwd: resolve(context.workspaceRoot), env: this.env, @@ -507,13 +519,13 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { const { client, methods, ndJsonStream } = await import("@agentclientprotocol/sdk"); const queues = new Map(); const sessionWriteModes = new Map(); - const grokCompletionRegistry = this.provider === "grok" + const grokCompletionRegistry = this.flavor === "grok" ? new GrokPromptCompletionRegistry() : undefined; const app = client({ name: "DevSpace" }) .onRequest(methods.client.session.requestPermission, (context) => { const writeMode = sessionWriteModes.get(context.params.sessionId); - const selected = selectAcpPermissionOption(context.params.options, writeMode, this.provider); + const selected = selectAcpPermissionOption(context.params.options, writeMode, this.flavor); return selected ? { outcome: { outcome: "selected", optionId: selected.optionId } } : { outcome: { outcome: "cancelled" } }; @@ -553,7 +565,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { ); const capabilities = readAcpCapabilities(init); const runtime = new AcpRuntime({ - provider: this.provider, + flavor: this.flavor, command, args, env: this.env, @@ -599,7 +611,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { private resolveCommand(): string | undefined { if (!this.commandResolved) { - this.resolvedCommand = this.commandResolver(this.provider, this.env); + this.resolvedCommand = this.commandResolver(this.flavor, this.env, this.configuredCommand); this.commandResolved = true; } return this.resolvedCommand; @@ -626,22 +638,30 @@ async function waitForProcessExit( } export function resolveAcpCommand( - provider: AcpProvider, + provider: AcpFlavor, env: NodeJS.ProcessEnv = process.env, + explicitCommand?: string, ): string | undefined { - const configured = provider === "cursor" + const configured = explicitCommand ?? (provider === "cursor" ? env.CURSOR_COMMAND : provider === "copilot" ? env.COPILOT_COMMAND - : env.GROK_COMMAND; - const command = configured ?? ACP_COMMANDS[provider][0]; + : provider === "grok" + ? env.GROK_COMMAND + : undefined); + const command = configured ?? ACP_COMMANDS[provider]?.[0]; + if (!command) return undefined; return resolveExecutableCommand(command, env); } -export type AcpCommandResolver = (provider: AcpProvider, env: NodeJS.ProcessEnv) => string | undefined; +export type AcpCommandResolver = ( + provider: AcpFlavor, + env: NodeJS.ProcessEnv, + explicitCommand?: string, +) => string | undefined; export function acpCommandArgs( - provider: AcpProvider, + provider: AcpFlavor, context: LocalAgentRuntimeContext, env: NodeJS.ProcessEnv = process.env, ): string[] { @@ -667,6 +687,7 @@ export function acpCommandArgs( "stdio", ]; } + if (provider === "generic") return []; const sandboxArgs = writeMode === "full_access" ? ["--no-sandbox"] : ["--experimental", "--sandbox"]; @@ -767,7 +788,7 @@ export function selectAcpAllowPermissionOption( export function selectAcpPermissionOption( options: Array<{ optionId: string; kind: string }>, writeMode: LocalAgentWriteMode | undefined, - provider?: AcpProvider, + provider?: AcpFlavor, ): { optionId: string } | undefined { if (!writeMode) return undefined; // Copilot's native sandbox has a per-command escape hatch enabled by diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 7cdb09037..106597e6b 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -3,6 +3,7 @@ import { type SubagentsConfig, } from "./local-agent-config.js"; import { + LOCAL_AGENT_DEFAULT_PROVIDER_IDS, LOCAL_AGENT_DRIVER_KINDS, } from "./local-agent-provider.js"; import { @@ -45,9 +46,9 @@ export function createLocalAgentDrivers( options: LocalAgentDriverOptions = {}, ): LocalAgentDriver[] { const env = options.env ?? process.env; - const instances = options.subagents?.providers ?? LOCAL_AGENT_DRIVER_KINDS.map((driver) => ({ - id: driver, - driver, + const instances = options.subagents?.providers ?? LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => ({ + id, + driver: id === "cursor" || id === "copilot" || id === "grok" ? "acp" : id, enabled: true, } satisfies SubagentProviderConfig)); const registry = createLocalAgentProviderRegistry(options); @@ -62,9 +63,12 @@ export function createLocalAgentProviderRegistry( .register("claude", ({ env }) => new ClaudeLocalAgentDriver(options.claudeQueryFactory, env)) .register("opencode", ({ env }) => new OpencodeLocalAgentDriver({ factory: options.opencodeFactory, env })) .register("pi", ({ envOverrides }) => new PiLocalAgentDriver(options.piSessionFactory, envOverrides)) - .register("cursor", ({ env }) => new AcpLocalAgentDriver("cursor", env)) - .register("copilot", ({ env }) => new AcpLocalAgentDriver("copilot", env)) - .register("grok", ({ env }) => new AcpLocalAgentDriver("grok", env)); + .register("acp", ({ instance, env }) => new AcpLocalAgentDriver({ + env, + command: instance.command, + args: instance.config?.args, + flavor: instance.config?.flavor, + })); } export function extractLocalAgentResponseText(value: unknown): string { diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 103abd68a..a18eb8989 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -5,8 +5,9 @@ import { type SubagentsConfig, } from "./local-agent-config.js"; import { + defaultDriverForProviderId, isLocalAgentDriverKind, - LOCAL_AGENT_DRIVER_KINDS, + LOCAL_AGENT_DEFAULT_PROVIDER_IDS, type LocalAgentDriverKind, type LocalAgentProviderInstanceId, } from "./local-agent-provider.js"; @@ -24,7 +25,7 @@ export function getLocalAgentProviderAvailabilitySnapshot( ): LocalAgentProviderAvailability[] { const configured = new Map(config?.providers.map((provider) => [provider.id, provider]) ?? []); const instances = [ - ...LOCAL_AGENT_DRIVER_KINDS.map((driver) => configured.get(driver) ?? { id: driver, driver }), + ...LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => configured.get(id) ?? { id, driver: defaultDriverForProviderId(id)! }), ...(config?.providers.filter((provider) => !isLocalAgentDriverKind(provider.id)) ?? []), ]; return instances.map((instance) => checkLocalAgentProviderAvailability( @@ -53,12 +54,17 @@ function checkLocalAgentProviderAvailability( return commandAvailability(providerInstanceId, "opencode", providerEnv); case "pi": return packageAvailability(providerInstanceId, "@earendil-works/pi-coding-agent"); - case "cursor": - return commandAvailability(providerInstanceId, providerEnv.CURSOR_COMMAND ?? "cursor-agent", providerEnv); - case "copilot": - return commandAvailability(providerInstanceId, providerEnv.COPILOT_COMMAND ?? "copilot", providerEnv); - case "grok": - return commandAvailability(providerInstanceId, providerEnv.GROK_COMMAND ?? "grok", providerEnv); + case "acp": { + const configured = config ? subagentProviderConfig(config, providerInstanceId) : undefined; + const command = configured?.command + ?? (providerInstanceId === "cursor" ? providerEnv.CURSOR_COMMAND ?? "cursor-agent" + : providerInstanceId === "copilot" ? providerEnv.COPILOT_COMMAND ?? "copilot" + : providerInstanceId === "grok" ? providerEnv.GROK_COMMAND ?? "grok" + : undefined); + return command + ? commandAvailability(providerInstanceId, command, providerEnv) + : { name: providerInstanceId, available: false, reason: "ACP command is not configured" }; + } } } @@ -69,7 +75,7 @@ export function assertLocalAgentProviderAvailable( ): void { const provider = config ? subagentProviderConfig(config, providerInstanceId) : undefined; const driver = provider?.driver - ?? (isLocalAgentDriverKind(providerInstanceId) ? providerInstanceId : undefined); + ?? defaultDriverForProviderId(providerInstanceId); if (!driver) throw new Error(`${providerInstanceId} provider is not configured.`); const availability = checkLocalAgentProviderAvailability(providerInstanceId, driver, env, config); if (availability.available) return; diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index acb9a8b8c..410516313 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -115,6 +115,29 @@ assert.deepEqual(namedInstance.providers[0], { model: "gpt-work", }); assert.equal(subagentProviderConfig(namedInstance, "codex-work")?.driver, "codex"); +const genericAcp = parseSubagentsConfig({ + enabled: true, + providers: [{ + id: "kiro", + driver: "acp", + enabled: true, + command: "kiro", + config: { args: ["acp"] }, + }], +}); +assert.deepEqual(genericAcp.providers[0], { + id: "kiro", + driver: "acp", + enabled: true, + command: "kiro", + config: { args: ["acp"] }, +}); +const legacyAcp = parseSubagentsConfig({ + enabled: true, + providers: [{ id: "cursor", driver: "cursor", enabled: true }], +}); +assert.equal(legacyAcp.providers[0]?.driver, "acp"); +assert.deepEqual(legacyAcp.providers[0]?.config, { flavor: "cursor" }); assert.throws( () => subagentsConfigSchema.parse({ enabled: true, diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index e279bd184..ba2869493 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -1,7 +1,10 @@ import { createHash } from "node:crypto"; import * as z from "zod/v4"; import { + defaultDriverForProviderId, isLocalAgentDriverKind, + isLegacyAcpDriverKind, + LEGACY_ACP_DRIVER_KINDS, LOCAL_AGENT_DRIVER_KINDS, type LocalAgentDriverKind, type LocalAgentProviderInstanceId, @@ -25,13 +28,23 @@ const commandSchema = z.string() .min(1) .optional(); +const acpConfigSchema = z.object({ + args: z.array(z.string()).optional(), + flavor: z.enum(LEGACY_ACP_DRIVER_KINDS).optional(), +}).strict(); + +const inputDriverKinds = [...LOCAL_AGENT_DRIVER_KINDS, ...LEGACY_ACP_DRIVER_KINDS] as const; + const providerSchema = z.object({ id: z.string().trim().min(1), - driver: z.enum(LOCAL_AGENT_DRIVER_KINDS).optional(), + driver: z.enum(inputDriverKinds).optional(), ...providerShape, command: commandSchema, + config: acpConfigSchema.optional(), }).strict().superRefine((provider, context) => { - const driver = provider.driver ?? (isLocalAgentDriverKind(provider.id) ? provider.id : undefined); + const driver = provider.driver + ? (isLegacyAcpDriverKind(provider.driver) ? "acp" : provider.driver) + : defaultDriverForProviderId(provider.id); if (!driver) { context.addIssue({ code: "custom", @@ -47,6 +60,20 @@ const providerSchema = z.object({ message: `${driver} does not support a command override.`, }); } + if (driver !== "acp" && provider.config !== undefined) { + context.addIssue({ + code: "custom", + path: ["config"], + message: `${driver} does not support ACP config.`, + }); + } + if (driver === "acp" && !provider.command && !isLegacyAcpDriverKind(provider.id)) { + context.addIssue({ + code: "custom", + path: ["command"], + message: `ACP provider instance ${provider.id} must declare a command.`, + }); + } }); export const subagentsConfigSchema = z.object({ @@ -90,7 +117,12 @@ export function resolveSubagentsConfig(config: ParsedSubagentsConfig): Subagents ...config, providers: config.providers.map((provider) => ({ ...provider, - driver: provider.driver ?? provider.id as LocalAgentDriverKind, + driver: provider.driver + ? (isLegacyAcpDriverKind(provider.driver) ? "acp" : provider.driver) + : defaultDriverForProviderId(provider.id)!, + ...(isLegacyAcpDriverKind(provider.id) && provider.config?.flavor === undefined + ? { config: { ...provider.config, flavor: provider.id } } + : {}), })), }; } @@ -137,11 +169,9 @@ export function providerCommandVariable(driver: LocalAgentDriverKind): string | switch (driver) { case "codex": return "CODEX_COMMAND"; case "claude": return "CLAUDE_COMMAND"; - case "cursor": return "CURSOR_COMMAND"; - case "copilot": return "COPILOT_COMMAND"; - case "grok": return "GROK_COMMAND"; case "opencode": case "pi": + case "acp": return undefined; } } @@ -163,6 +193,7 @@ export function localAgentProviderConfigRevision(config: SubagentsConfig): strin ), } : {}), + ...(provider.config ? { config: provider.config } : {}), })); return createHash("sha256") .update(JSON.stringify({ enabled: config.enabled, providers })) diff --git a/src/local-agent-errors.ts b/src/local-agent-errors.ts index 21c92402e..424aa2cb4 100644 --- a/src/local-agent-errors.ts +++ b/src/local-agent-errors.ts @@ -462,9 +462,7 @@ function displayProvider(provider: LocalAgentDriverKind): string { case "claude": return "Claude"; case "opencode": return "OpenCode"; case "pi": return "Pi"; - case "cursor": return "Cursor"; - case "copilot": return "Copilot"; - case "grok": return "Grok"; + case "acp": return "ACP"; } } diff --git a/src/local-agent-grok.ts b/src/local-agent-grok.ts index d4df607d9..09c851bf1 100644 --- a/src/local-agent-grok.ts +++ b/src/local-agent-grok.ts @@ -208,7 +208,7 @@ export function resolveGrokEffort( function grokConfigurationError(message: string): AgentProviderProtocolError { return new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", - provider: "grok", + provider: "acp", operation: "configure_session", retryable: false, message, diff --git a/src/local-agent-provider.ts b/src/local-agent-provider.ts index 8506463c3..671ad11c6 100644 --- a/src/local-agent-provider.ts +++ b/src/local-agent-provider.ts @@ -3,9 +3,16 @@ export const LOCAL_AGENT_DRIVER_KINDS = [ "claude", "opencode", "pi", - "cursor", - "copilot", - "grok", + "acp", +] as const; + +export const LEGACY_ACP_DRIVER_KINDS = ["cursor", "copilot", "grok"] as const; +export const LOCAL_AGENT_DEFAULT_PROVIDER_IDS = [ + "codex", + "claude", + "opencode", + "pi", + ...LEGACY_ACP_DRIVER_KINDS, ] as const; export type LocalAgentDriverKind = typeof LOCAL_AGENT_DRIVER_KINDS[number]; @@ -14,7 +21,18 @@ export type LocalAgentDriverKind = typeof LOCAL_AGENT_DRIVER_KINDS[number]; export type LocalAgentProviderInstanceId = string; const DRIVER_KINDS = new Set(LOCAL_AGENT_DRIVER_KINDS); +const LEGACY_ACP_DRIVERS = new Set(LEGACY_ACP_DRIVER_KINDS); export function isLocalAgentDriverKind(value: string): value is LocalAgentDriverKind { return DRIVER_KINDS.has(value); } + +export function isLegacyAcpDriverKind(value: string): value is typeof LEGACY_ACP_DRIVER_KINDS[number] { + return LEGACY_ACP_DRIVERS.has(value); +} + +export function defaultDriverForProviderId(value: string): LocalAgentDriverKind | undefined { + if (isLocalAgentDriverKind(value)) return value; + if (isLegacyAcpDriverKind(value)) return "acp"; + return undefined; +} diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index 04e9d2a9a..6b13173e7 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -184,7 +184,7 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot "agt_legacy", join(root, "legacy-project"), "reviewer", - "codex", + "cursor", "high", "error", "old error", @@ -198,8 +198,8 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot const legacyRecord = upgradedStore.getById("agt_legacy"); assert.equal(legacyRecord?.error, "old error"); assert.equal(legacyRecord?.effort, "high"); - assert.equal(legacyRecord?.providerInstanceId, "codex"); - assert.equal(legacyRecord?.driver, "codex"); + assert.equal(legacyRecord?.providerInstanceId, "cursor"); + assert.equal(legacyRecord?.driver, "acp"); assert.equal(legacyRecord?.errorCode, undefined); assert.equal(legacyRecord?.errorRetryable, undefined); const upgradedRecord = upgradedStore.update("agt_legacy", { diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 0f7c7aedc..20f71310e 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -53,6 +53,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 7, name: "workspace-recovery-state" }, { version: 8, name: "local-agent-turns" }, { version: 9, name: "local-agent-provider-instances" }, + { version: 10, name: "local-agent-generic-acp-driver" }, ]); } finally { database.close(); diff --git a/src/onboarding.ts b/src/onboarding.ts index fcf26931a..fb2f0c2ec 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -1,7 +1,8 @@ import type { SubagentsConfig } from "./local-agent-config.js"; import { + defaultDriverForProviderId, + LOCAL_AGENT_DEFAULT_PROVIDER_IDS, isLocalAgentDriverKind, - LOCAL_AGENT_DRIVER_KINDS, type LocalAgentProviderInstanceId, } from "./local-agent-provider.js"; @@ -35,21 +36,22 @@ export function updateOnboardingSubagentsConfig( selectedProviders: readonly LocalAgentProviderInstanceId[], ): SubagentsConfig { const selected = new Set(selectedProviders); + const defaultIds = new Set(LOCAL_AGENT_DEFAULT_PROVIDER_IDS); const customInstances = current.providers - .filter((provider) => !isLocalAgentDriverKind(provider.id)) + .filter((provider) => !defaultIds.has(provider.id)) .map((provider) => ({ ...provider, enabled: selected.has(provider.id) })); return { enabled: true, instructions: current.instructions, providers: [ - ...LOCAL_AGENT_DRIVER_KINDS + ...LOCAL_AGENT_DEFAULT_PROVIDER_IDS .filter((id) => selected.has(id) || current.providers.some((provider) => provider.id === id)) .map((id) => { const existing = current.providers.find((provider) => provider.id === id); return { ...existing, id, - driver: existing?.driver ?? id, + driver: existing?.driver ?? defaultDriverForProviderId(id)!, enabled: selected.has(id), }; }),