From 2ba4a347db1d6f741c9f824fd0b14883ec6d3d93 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:44:51 +0530 Subject: [PATCH 01/26] refactor(subagents): separate provider instances from drivers --- schema/v1/devspace.schema.json | 128 +++++++++--------------- src/cli.test.ts | 6 +- src/cli.ts | 6 +- src/config-migration.ts | 4 +- src/config.ts | 4 +- src/db/migrations.ts | 21 ++++ src/db/schema.ts | 3 +- src/local-agent-acp.test.ts | 10 +- src/local-agent-acp.ts | 2 + src/local-agent-adapters.test.ts | 28 ++++++ src/local-agent-adapters.ts | 77 +++++++++++--- src/local-agent-availability.test.ts | 1 + src/local-agent-availability.ts | 77 ++++++++------ src/local-agent-catalog.test.ts | 8 +- src/local-agent-catalog.ts | 19 ++-- src/local-agent-claude.test.ts | 5 +- src/local-agent-claude.ts | 5 +- src/local-agent-codex.test.ts | 7 +- src/local-agent-codex.ts | 1 + src/local-agent-config.test.ts | 25 +++-- src/local-agent-config.ts | 95 ++++++++++++------ src/local-agent-daemon-lifecycle.ts | 2 +- src/local-agent-daemon-protocol.test.ts | 6 +- src/local-agent-daemon-protocol.ts | 8 +- src/local-agent-daemon.test.ts | 3 +- src/local-agent-errors.ts | 22 ++-- src/local-agent-manager.test.ts | 30 ++++-- src/local-agent-manager.ts | 80 +++++++++------ src/local-agent-opencode.test.ts | 9 ++ src/local-agent-opencode.ts | 1 + src/local-agent-pi.test.ts | 1 + src/local-agent-pi.ts | 1 + src/local-agent-presentation.test.ts | 6 +- src/local-agent-profiles.test.ts | 9 +- src/local-agent-profiles.ts | 31 +----- src/local-agent-provider.ts | 20 ++++ src/local-agent-runtime-pool.ts | 4 +- src/local-agent-runtime.test.ts | 10 ++ src/local-agent-runtime.ts | 15 ++- src/local-agent-store.test.ts | 10 +- src/local-agent-store.ts | 30 ++++-- src/local-agent-targets.test.ts | 20 +++- src/local-agent-targets.ts | 18 ++-- src/oauth-store.test.ts | 1 + src/onboarding.test.ts | 14 ++- src/onboarding.ts | 18 +++- src/server.test.ts | 17 ++-- 47 files changed, 594 insertions(+), 324 deletions(-) create mode 100644 src/local-agent-provider.ts diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index cf9ce8f84..de7a66041 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -177,92 +177,56 @@ "providers": { "type": "array", "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "enum": [ - "codex", - "claude", - "cursor", - "copilot", - "grok" - ] - }, - "enabled": { - "type": "boolean" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "effort": { - "type": "string", - "minLength": 1 - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - }, - "additionalProperties": { - "type": "string" - } - }, - "command": { - "type": "string", - "minLength": 1, - "pattern": "\\S" - } - }, - "required": [ - "id", - "enabled" - ], - "additionalProperties": false + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "driver": { + "type": "string", + "enum": [ + "codex", + "claude", + "opencode", + "pi", + "cursor", + "copilot", + "grok" + ] + }, + "enabled": { + "type": "boolean" }, - { + "model": { + "type": "string", + "minLength": 1 + }, + "effort": { + "type": "string", + "minLength": 1 + }, + "env": { "type": "object", - "properties": { - "id": { - "type": "string", - "enum": [ - "opencode", - "pi" - ] - }, - "enabled": { - "type": "boolean" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "effort": { - "type": "string", - "minLength": 1 - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - }, - "additionalProperties": { - "type": "string" - } - } + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, - "required": [ - "id", - "enabled" - ], - "additionalProperties": false + "additionalProperties": { + "type": "string" + } + }, + "command": { + "type": "string", + "minLength": 1, + "pattern": "\\S" } - ] + }, + "required": [ + "id", + "enabled" + ], + "additionalProperties": false } } }, diff --git a/src/cli.test.ts b/src/cli.test.ts index 3a622eb19..1cc6ba5e6 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -68,7 +68,8 @@ try { workspaceId: "ws_current", workspaceRoot: projectRoot, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", model: "gpt-5.4", effort: "high", }).id, @@ -79,7 +80,8 @@ try { workspaceId: "ws_other", workspaceRoot: projectRoot, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }).id, { status: "running" }, ); diff --git a/src/cli.ts b/src/cli.ts index 3037f65e8..e02d4f5b1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ import { formatLocalAgentProviderStatusSummary, } from "./local-agent-catalog.js"; import { loadLocalAgentProfiles } from "./local-agent-profiles.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { resolveSubagentsConfig } from "./local-agent-config.js"; import { parseLocalAgentContinueArgs, parseLocalAgentRunArgs, @@ -218,7 +218,7 @@ async function runInit({ force }: { force: boolean }): Promise { })); } - const currentSubagents = files.config.subagents; + const currentSubagents = resolveSubagentsConfig(files.config.subagents); const availability = getLocalAgentProviderAvailabilitySnapshot( process.env, currentSubagents, @@ -247,7 +247,7 @@ async function runInit({ force }: { force: boolean }): Promise { required: true, }); if (prompts.isCancel(providerAnswer)) throw new SetupCancelledError(); - const selectedProviders = providerAnswer as LocalAgentProvider[]; + const selectedProviders = providerAnswer as string[]; const subagents = updateOnboardingSubagentsConfig( currentSubagents, selectedProviders, diff --git a/src/config-migration.ts b/src/config-migration.ts index 837d46f32..7614edf35 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_PROVIDERS } from "./local-agent-profiles.js"; +import { LOCAL_AGENT_DRIVER_KINDS } 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_PROVIDERS.map((id) => ({ id, enabled: true })) + ? LOCAL_AGENT_DRIVER_KINDS.map((id) => ({ id, enabled: true })) : [], }; } diff --git a/src/config.ts b/src/config.ts index 34fcdfc25..b9cd6c7fb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import { expandHomePath } from "./roots.js"; import type { LoggingConfig } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; -import type { SubagentsConfig } from "./local-agent-config.js"; +import { resolveSubagentsConfig, type SubagentsConfig } from "./local-agent-config.js"; export type { ToolMode } from "./config-schema.js"; @@ -75,7 +75,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { skillPaths: stored.skills.paths, devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), - subagents: stored.subagents, + subagents: resolveSubagentsConfig(stored.subagents), agentDir: normalizePath(stored.skills.agentDir), logging: { ...stored.logging, diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 8bb1f7e4f..9ce071df4 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -47,6 +47,11 @@ const migrations: Migration[] = [ name: "local-agent-turns", up: migrateLocalAgentTurns, }, + { + version: 9, + name: "local-agent-provider-instances", + up: migrateLocalAgentProviderInstances, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -289,6 +294,22 @@ function migrateLocalAgentTurns(sqlite: Database.Database): void { `); } +function migrateLocalAgentProviderInstances(sqlite: Database.Database): void { + const columns = sqlite.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ + name: string; + }>; + const names = new Set(columns.map((column) => column.name)); + if (names.has("provider") && !names.has("provider_instance_id")) { + sqlite.exec("alter table local_agent_sessions rename column provider to provider_instance_id"); + } + addColumnIfMissing(sqlite, "local_agent_sessions", "driver", "text"); + sqlite.exec(` + update local_agent_sessions + set driver = provider_instance_id + where driver is null or driver = '' + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 77b234368..a80f9c71f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -98,7 +98,8 @@ export const localAgentSessions = sqliteTable( workspaceId: text("workspace_id"), workspaceRoot: text("workspace_root").notNull(), profileName: text("profile_name").notNull(), - provider: text("provider").notNull(), + providerInstanceId: text("provider_instance_id").notNull(), + driver: text("driver").notNull(), model: text("model"), effort: text("effort"), providerSessionId: text("provider_session_id"), diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 7a5222261..bb9998d15 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -256,6 +256,7 @@ await overlapRuntime.close(); const cachedContext = { agentId: "agt_acp", + providerInstanceId: "cursor", provider: "cursor" as const, workspaceRoot: "/tmp/project", writeMode: "allowed" as const, @@ -266,11 +267,13 @@ assert.deepEqual(acpCommandArgs("cursor", cachedContext), [ ]); assert.deepEqual(acpCommandArgs("grok", { ...cachedContext, + providerInstanceId: "grok", provider: "grok", effort: "low", }), ["agent", "--reasoning-effort", "low", "stdio"]); assert.deepEqual(acpCommandArgs("grok", { ...cachedContext, + providerInstanceId: "grok", provider: "grok", effort: "low", }, { GROK_AGENT_PROFILE: " /tmp/grok-coding-only.md " }), [ @@ -314,7 +317,12 @@ if (process.platform === "win32") { ); await writeFile(command, `@ECHO OFF\r\n"${process.execPath}" "${recorder}" %*\r\n`); const shimDriver = new AcpLocalAgentDriver("copilot", process.env, () => command); - const shimStartup = await shimDriver.createRuntime({ ...cachedContext, provider: "copilot", workspaceRoot }); + const shimStartup = await shimDriver.createRuntime({ + ...cachedContext, + providerInstanceId: "copilot", + provider: "copilot", + workspaceRoot, + }); assert.equal(shimStartup.isErr(), true); if (shimStartup.isErr()) assert.equal(shimStartup.error.code, "PROVIDER_PROTOCOL_ERROR"); const forwarded = JSON.parse(await readFile(marker, "utf8")) as string[]; diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 078391ea9..bbbabb260 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -410,6 +410,7 @@ export class AcpRuntime implements LocalAgentRuntime { export class AcpLocalAgentDriver implements LocalAgentDriver { readonly provider: AcpProvider; + readonly providerInstanceId: string; // Keep ACP warm briefly, then let the generic pool close the process so the // daemon can reach its own idle shutdown state. readonly idleTimeoutMs = 5 * 60_000; @@ -422,6 +423,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { private readonly commandResolver: AcpCommandResolver = resolveAcpCommand, ) { this.provider = provider; + this.providerInstanceId = provider; } runtimeKey(context: LocalAgentRuntimeContext): string { diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 395072a1d..cbaf2e190 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -2,12 +2,14 @@ import assert from "node:assert/strict"; import { delimiter } from "node:path"; import { claudeCommandEnvironment, + createLocalAgentDrivers, extractOpenCodeFinalResponse, extractPiFinalResponse, extractPiProviderError, resolveAcpModelConfigUpdate, resolveAcpEffortConfigUpdate, } from "./local-agent-adapters.js"; +import { parseSubagentsConfig } from "./local-agent-config.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; assert.deepEqual( resolveAcpModelConfigUpdate({ @@ -330,5 +332,31 @@ assert.equal( removeDevspaceNodeModulesBinFromPath([devspaceBin, userBin].join(delimiter)), userBin, ); +} +{ + const subagents = parseSubagentsConfig({ + enabled: true, + providers: [ + { id: "claude-work", driver: "claude", enabled: true }, + { id: "claude-personal", driver: "claude", enabled: true }, + ], + }); + const drivers = createLocalAgentDrivers({ subagents }); + assert.deepEqual( + drivers.map((driver) => [driver.providerInstanceId, driver.provider]), + [["claude-work", "claude"], ["claude-personal", "claude"]], + ); + const context = { + agentId: "agt_test", + providerInstanceId: "claude-work", + provider: "claude" as const, + workspaceRoot: "/tmp/project", + writeMode: "allowed" as const, + }; + assert.notEqual( + drivers[0]?.runtimeKey(context), + drivers[1]?.runtimeKey({ ...context, providerInstanceId: "claude-personal" }), + "provider instances sharing a driver must never share one runtime pool key", + ); } diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index d83d2fc8e..86076fbb0 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,9 +1,14 @@ import { localAgentProviderEnvironment, localAgentProviderEnvironmentOverrides, + type SubagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { + LOCAL_AGENT_DRIVER_KINDS, + type LocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; import { AcpLocalAgentDriver, resolveAcpCommand, @@ -43,21 +48,63 @@ export function createLocalAgentDrivers( options: LocalAgentDriverOptions = {}, ): LocalAgentDriver[] { const env = options.env ?? process.env; - const providerEnv = (provider: LocalAgentProvider) => options.subagents - ? localAgentProviderEnvironment(options.subagents, provider, env) - : env; - const providerEnvOverrides = (provider: LocalAgentProvider) => options.subagents - ? localAgentProviderEnvironmentOverrides(options.subagents, provider) + const instances = options.subagents?.providers ?? LOCAL_AGENT_DRIVER_KINDS.map((driver) => ({ + id: driver, + driver, + enabled: true, + } satisfies SubagentProviderConfig)); + return instances.map((instance) => new ProviderInstanceDriver( + instance.id, + createDriver(instance, options, env), + )); +} + +function createDriver( + instance: SubagentProviderConfig, + options: LocalAgentDriverOptions, + inheritedEnv: NodeJS.ProcessEnv, +): LocalAgentDriver { + const env = options.subagents + ? localAgentProviderEnvironment(options.subagents, instance.id, inheritedEnv) + : inheritedEnv; + const envOverrides = options.subagents + ? localAgentProviderEnvironmentOverrides(options.subagents, instance.id) : {}; - return [ - new CodexLocalAgentDriver(providerEnv("codex")), - new ClaudeLocalAgentDriver(options.claudeQueryFactory, providerEnv("claude")), - new OpencodeLocalAgentDriver(options.opencodeFactory, providerEnv("opencode")), - new PiLocalAgentDriver(options.piSessionFactory, providerEnvOverrides("pi")), - new AcpLocalAgentDriver("cursor", providerEnv("cursor")), - new AcpLocalAgentDriver("copilot", providerEnv("copilot")), - new AcpLocalAgentDriver("grok", providerEnv("grok")), - ]; + switch (instance.driver) { + case "codex": + return new CodexLocalAgentDriver(env); + case "claude": + return new ClaudeLocalAgentDriver(options.claudeQueryFactory, env); + case "opencode": + return new OpencodeLocalAgentDriver(options.opencodeFactory, env); + case "pi": + return new PiLocalAgentDriver(options.piSessionFactory, envOverrides); + case "cursor": + case "copilot": + case "grok": + return new AcpLocalAgentDriver(instance.driver, env); + } +} + +class ProviderInstanceDriver implements LocalAgentDriver { + readonly provider: LocalAgentDriverKind; + readonly idleTimeoutMs?: number; + + constructor( + readonly providerInstanceId: LocalAgentProviderInstanceId, + private readonly driver: LocalAgentDriver, + ) { + this.provider = driver.provider; + this.idleTimeoutMs = driver.idleTimeoutMs; + } + + runtimeKey(context: Parameters[0]): string { + return JSON.stringify([this.providerInstanceId, this.driver.runtimeKey(context)]); + } + + createRuntime(context: Parameters[0]) { + return this.driver.createRuntime(context); + } } export function extractLocalAgentResponseText(value: unknown): string { diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index a5592b42a..6088a0a7e 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -43,6 +43,7 @@ assert.equal( instructions: "on-demand", providers: [{ id: "codex", + driver: "codex", enabled: true, command: executable, env: { OPENAI_API_KEY: "configured-secret", EMPTY_VALUE: "" }, diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 14764488c..e6916c007 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,15 +1,18 @@ -import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; import { resolveExecutableCommand } from "./local-agent-command.js"; import { localAgentProviderEnvironment, + subagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; +import { + isLocalAgentDriverKind, + LOCAL_AGENT_DRIVER_KINDS, + type LocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export interface LocalAgentProviderAvailability { - name: LocalAgentProvider; + name: LocalAgentProviderInstanceId; available: boolean; reason?: string; note?: string; @@ -19,67 +22,83 @@ export function getLocalAgentProviderAvailabilitySnapshot( env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): LocalAgentProviderAvailability[] { - return LOCAL_AGENT_PROVIDERS.map((provider) => ( - checkLocalAgentProviderAvailability(provider, env, config) + 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 }), + ...(config?.providers.filter((provider) => !isLocalAgentDriverKind(provider.id)) ?? []), + ]; + return instances.map((instance) => checkLocalAgentProviderAvailability( + instance.id, + instance.driver, + env, + config, )); } function checkLocalAgentProviderAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, + driver: LocalAgentDriverKind, env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): LocalAgentProviderAvailability { - const providerEnv = config ? localAgentProviderEnvironment(config, provider, env) : env; - switch (provider) { + const providerEnv = config ? localAgentProviderEnvironment(config, providerInstanceId, env) : env; + switch (driver) { case "codex": - return codexAvailability(providerEnv); + return codexAvailability(providerInstanceId, providerEnv); case "claude": return providerEnv.CLAUDE_COMMAND - ? commandAvailability(provider, providerEnv.CLAUDE_COMMAND, providerEnv) - : packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); + ? commandAvailability(providerInstanceId, providerEnv.CLAUDE_COMMAND, providerEnv) + : packageAvailability(providerInstanceId, "@anthropic-ai/claude-agent-sdk"); case "opencode": - return packageAvailability(provider, "@opencode-ai/sdk/v2"); + return packageAvailability(providerInstanceId, "@opencode-ai/sdk/v2"); case "pi": - return packageAvailability(provider, "@earendil-works/pi-coding-agent"); + return packageAvailability(providerInstanceId, "@earendil-works/pi-coding-agent"); case "cursor": - return commandAvailability(provider, providerEnv.CURSOR_COMMAND ?? "cursor-agent", providerEnv); + return commandAvailability(providerInstanceId, providerEnv.CURSOR_COMMAND ?? "cursor-agent", providerEnv); case "copilot": - return commandAvailability(provider, providerEnv.COPILOT_COMMAND ?? "copilot", providerEnv); + return commandAvailability(providerInstanceId, providerEnv.COPILOT_COMMAND ?? "copilot", providerEnv); case "grok": - return commandAvailability(provider, providerEnv.GROK_COMMAND ?? "grok", providerEnv); + return commandAvailability(providerInstanceId, providerEnv.GROK_COMMAND ?? "grok", providerEnv); } } export function assertLocalAgentProviderAvailable( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): void { - const availability = checkLocalAgentProviderAvailability(provider, env, config); + const provider = config ? subagentProviderConfig(config, providerInstanceId) : undefined; + const driver = provider?.driver + ?? (isLocalAgentDriverKind(providerInstanceId) ? providerInstanceId : undefined); + if (!driver) throw new Error(`${providerInstanceId} provider is not configured.`); + const availability = checkLocalAgentProviderAvailability(providerInstanceId, driver, env, config); if (availability.available) return; throw new Error( - `${provider} provider is not available: ${availability.reason ?? "provider preflight failed"}`, + `${providerInstanceId} provider is not available: ${availability.reason ?? "provider preflight failed"}`, ); } function packageAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, packageName: string, ): LocalAgentProviderAvailability { try { import.meta.resolve(packageName); - return { name: provider, available: true }; + return { name: providerInstanceId, available: true }; } catch { return { - name: provider, + name: providerInstanceId, available: false, reason: `${packageName} package not found`, }; } } -function codexAvailability(env: NodeJS.ProcessEnv): LocalAgentProviderAvailability { - const availability = commandAvailability("codex", env.CODEX_COMMAND ?? "codex", env); +function codexAvailability( + providerInstanceId: LocalAgentProviderInstanceId, + env: NodeJS.ProcessEnv, +): LocalAgentProviderAvailability { + const availability = commandAvailability(providerInstanceId, env.CODEX_COMMAND ?? "codex", env); return availability.available ? { ...availability, @@ -89,13 +108,13 @@ function codexAvailability(env: NodeJS.ProcessEnv): LocalAgentProviderAvailabili } function commandAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, command: string, env: NodeJS.ProcessEnv, ): LocalAgentProviderAvailability { - if (resolveExecutableCommand(command, env)) return { name: provider, available: true }; + if (resolveExecutableCommand(command, env)) return { name: providerInstanceId, available: true }; return { - name: provider, + name: providerInstanceId, available: false, reason: `${command} executable not found`, }; diff --git a/src/local-agent-catalog.test.ts b/src/local-agent-catalog.test.ts index cc156251d..42acea196 100644 --- a/src/local-agent-catalog.test.ts +++ b/src/local-agent-catalog.test.ts @@ -10,9 +10,9 @@ const config: SubagentsConfig = { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true, model: "gpt-default", effort: "medium" }, - { id: "claude", enabled: true, model: "sonnet" }, - { id: "pi", enabled: false }, + { id: "codex", driver: "codex", enabled: true, model: "gpt-default", effort: "medium" }, + { id: "claude", driver: "claude", enabled: true, model: "sonnet" }, + { id: "pi", driver: "pi", enabled: false }, ], }; const statuses = buildLocalAgentProviderStatuses(config, [ @@ -23,7 +23,7 @@ const statuses = buildLocalAgentProviderStatuses(config, [ assert.equal(statuses.find((provider) => provider.id === "codex")?.usable, true); assert.equal(statuses.find((provider) => provider.id === "claude")?.usable, false); assert.equal(statuses.find((provider) => provider.id === "pi")?.usable, false); -assert.equal(statuses.find((provider) => provider.id === "opencode")?.enabled, false); +assert.equal(statuses.find((provider) => provider.id === "opencode"), undefined); const profiles: LocalAgentProfile[] = [ { diff --git a/src/local-agent-catalog.ts b/src/local-agent-catalog.ts index c19904ba2..b446adab0 100644 --- a/src/local-agent-catalog.ts +++ b/src/local-agent-catalog.ts @@ -1,14 +1,14 @@ import type { SubagentsConfig } from "./local-agent-config.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; -import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProfile, - type LocalAgentProfileSummary, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; +import type { LocalAgentProfile, LocalAgentProfileSummary } from "./local-agent-profiles.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export interface LocalAgentProviderStatus { - id: LocalAgentProvider; + id: LocalAgentProviderInstanceId; + driver: LocalAgentDriverKind; enabled: boolean; available: boolean; usable: boolean; @@ -28,13 +28,14 @@ export function buildLocalAgentProviderStatuses( config: SubagentsConfig, availability: readonly LocalAgentProviderAvailability[], ): LocalAgentProviderStatus[] { - return LOCAL_AGENT_PROVIDERS.map((id) => { - const configured = config.providers.find((entry) => entry.id === id); + return config.providers.map((configured) => { + const id = configured.id; const live = availability.find((entry) => entry.name === id); const enabled = configured?.enabled === true; const available = live?.available === true; return { id, + driver: configured.driver, enabled, available, usable: config.enabled && enabled && available, diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index 14e364005..e6c0a2a0e 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -6,7 +6,7 @@ import { type ClaudeUserMessage, } from "./local-agent-claude.js"; import { createLocalAgentDrivers } from "./local-agent-adapters.js"; -import { subagentsConfigSchema } from "./local-agent-config.js"; +import { parseSubagentsConfig } from "./local-agent-config.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { @@ -56,6 +56,7 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { const context: LocalAgentRuntimeContext = { agentId: "agt_claude", + providerInstanceId: "claude", provider: "claude", workspaceRoot: "/tmp/project", model: "sonnet", @@ -241,7 +242,7 @@ const configuredDriver = createLocalAgentDrivers({ ANTHROPIC_API_KEY: "inherited", INHERITED: "yes", }, - subagents: subagentsConfigSchema.parse({ + subagents: parseSubagentsConfig({ enabled: true, providers: [{ id: "claude", diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index a639f2f46..7c8b0d9da 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -5,7 +5,7 @@ import { captureAgentProviderResult, isProgrammerDefect, } from "./local-agent-errors.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -77,7 +77,7 @@ class AsyncInputQueue implements AsyncIterable { } export class ClaudeQueryRuntime implements LocalAgentRuntime { - readonly provider: LocalAgentProvider = "claude"; + readonly provider: LocalAgentDriverKind = "claude"; private readonly iterator: AsyncIterator; private alive = true; private closed = false; @@ -213,6 +213,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { export class ClaudeLocalAgentDriver implements LocalAgentDriver { readonly provider = "claude" as const; + readonly providerInstanceId = "claude"; readonly idleTimeoutMs = 3 * 60_000; constructor( diff --git a/src/local-agent-codex.test.ts b/src/local-agent-codex.test.ts index 1dcebc043..74383e882 100644 --- a/src/local-agent-codex.test.ts +++ b/src/local-agent-codex.test.ts @@ -12,7 +12,12 @@ import { } from "./local-agent-codex.js"; import { toAgentErrorPayload } from "./local-agent-errors.js"; -const cachedContext = { agentId: "agt_test", provider: "codex" as const, workspaceRoot: "/tmp/project" }; +const cachedContext = { + agentId: "agt_test", + providerInstanceId: "codex", + provider: "codex" as const, + workspaceRoot: "/tmp/project", +}; assert.equal(parseCodexVersion("codex-cli 0.9.1"), "0.9.1"); assert.equal(sandboxFor("read_only"), "read-only"); diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index 937e44481..46c4c007b 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -230,6 +230,7 @@ async function waitForProcessExit( export class CodexLocalAgentDriver implements LocalAgentDriver { readonly provider = "codex" as const; + readonly providerInstanceId = "codex"; readonly idleTimeoutMs = 5 * 60_000; private commandResolved = false; diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 6cf19515a..acb9a8b8c 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -3,11 +3,12 @@ import { isSubagentProviderEnabled, localAgentProviderConfigRevision, localAgentProviderEnvironment, + parseSubagentsConfig, subagentProviderConfig, subagentsConfigSchema, } from "./local-agent-config.js"; -const config = subagentsConfigSchema.parse({ +const config = parseSubagentsConfig({ enabled: true, providers: [ { @@ -27,13 +28,14 @@ assert.deepEqual(config, { providers: [ { id: "codex", + driver: "codex", enabled: true, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude", enabled: false, model: "sonnet" }, + { id: "claude", driver: "claude", enabled: false, model: "sonnet" }, ], }); assert.equal(isSubagentProviderEnabled(config, "codex"), true); @@ -63,7 +65,7 @@ assert.deepEqual(inherited, { }); assert.equal( localAgentProviderConfigRevision(config), - localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + localAgentProviderConfigRevision(parseSubagentsConfig({ enabled: true, providers: [ { id: "claude", enabled: false, model: "sonnet" }, @@ -81,7 +83,7 @@ assert.equal( ); assert.notEqual( localAgentProviderConfigRevision(config), - localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + localAgentProviderConfigRevision(parseSubagentsConfig({ ...config, providers: config.providers.map((provider) => provider.id === "codex" ? { ...provider, command: "/opt/bin/another-wrapper" } @@ -100,8 +102,19 @@ assert.throws( enabled: true, providers: [{ id: "unknown", enabled: true }], }), - /Invalid discriminator value/, + /must declare a driver/, ); +const namedInstance = parseSubagentsConfig({ + enabled: true, + providers: [{ id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }], +}); +assert.deepEqual(namedInstance.providers[0], { + id: "codex-work", + driver: "codex", + enabled: true, + model: "gpt-work", +}); +assert.equal(subagentProviderConfig(namedInstance, "codex-work")?.driver, "codex"); assert.throws( () => subagentsConfigSchema.parse({ enabled: true, @@ -124,7 +137,7 @@ assert.throws( /Invalid environment variable name/, ); for (const id of ["opencode", "pi"] as const) { - const embedded = subagentsConfigSchema.parse({ + const embedded = parseSubagentsConfig({ enabled: true, providers: [{ id, enabled: true, env: { HARNESS_ENV: id } }], }); diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 39d42f09d..e279bd184 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -1,8 +1,11 @@ import { createHash } from "node:crypto"; import * as z from "zod/v4"; import { - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + isLocalAgentDriverKind, + LOCAL_AGENT_DRIVER_KINDS, + type LocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; const environmentSchema = z.record( z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Invalid environment variable name"), @@ -22,24 +25,36 @@ const commandSchema = z.string() .min(1) .optional(); -const providerSchema = z.discriminatedUnion("id", [ - z.object({ - id: z.enum(["codex", "claude", "cursor", "copilot", "grok"]), - ...providerShape, - command: commandSchema, - }).strict(), - z.object({ - id: z.enum(["opencode", "pi"]), - ...providerShape, - }).strict(), -]); +const providerSchema = z.object({ + id: z.string().trim().min(1), + driver: z.enum(LOCAL_AGENT_DRIVER_KINDS).optional(), + ...providerShape, + command: commandSchema, +}).strict().superRefine((provider, context) => { + const driver = provider.driver ?? (isLocalAgentDriverKind(provider.id) ? provider.id : undefined); + if (!driver) { + context.addIssue({ + code: "custom", + path: ["driver"], + message: `Subagent provider instance ${provider.id} must declare a driver.`, + }); + return; + } + if ((driver === "opencode" || driver === "pi") && provider.command !== undefined) { + context.addIssue({ + code: "custom", + path: ["command"], + message: `${driver} does not support a command override.`, + }); + } +}); export const subagentsConfigSchema = z.object({ enabled: z.boolean(), instructions: z.enum(["on-demand", "preload"]).default("on-demand"), providers: z.array(providerSchema), }).strict().superRefine((value, context) => { - const seen = new Set(); + const seen = new Set(); for (const [index, provider] of value.providers.entries()) { if (seen.has(provider.id)) { context.addIssue({ @@ -57,46 +72,69 @@ export const storedSubagentsConfigSchema = z.union([ subagentsConfigSchema, ]); -export type SubagentProviderConfig = z.infer; -export type SubagentsConfig = z.infer; +type ParsedSubagentProviderConfig = z.infer; +type ParsedSubagentsConfig = z.infer; + +export interface SubagentProviderConfig extends Omit { + driver: LocalAgentDriverKind; +} + +export interface SubagentsConfig extends Omit { + providers: SubagentProviderConfig[]; +} + export type StoredSubagentsConfig = z.infer; +export function resolveSubagentsConfig(config: ParsedSubagentsConfig): SubagentsConfig { + return { + ...config, + providers: config.providers.map((provider) => ({ + ...provider, + driver: provider.driver ?? provider.id as LocalAgentDriverKind, + })), + }; +} + +export function parseSubagentsConfig(value: unknown): SubagentsConfig { + return resolveSubagentsConfig(subagentsConfigSchema.parse(value)); +} + export function subagentProviderConfig( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): SubagentProviderConfig | undefined { - return config.providers.find((entry) => entry.id === provider); + return config.providers.find((entry) => entry.id === providerInstanceId); } export function isSubagentProviderEnabled( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): boolean { - return config.enabled && subagentProviderConfig(config, provider)?.enabled === true; + return config.enabled && subagentProviderConfig(config, providerInstanceId)?.enabled === true; } export function localAgentProviderEnvironment( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, inherited: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - const providerConfig = subagentProviderConfig(config, provider); + const providerConfig = subagentProviderConfig(config, providerInstanceId); const env = { ...inherited, ...providerConfig?.env }; - const commandVariable = providerCommandVariable(provider); - const command = providerConfig && "command" in providerConfig ? providerConfig.command : undefined; + const commandVariable = providerConfig ? providerCommandVariable(providerConfig.driver) : undefined; + const command = providerConfig?.command; if (commandVariable && command) env[commandVariable] = command; return env; } export function localAgentProviderEnvironmentOverrides( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): Record { - return { ...subagentProviderConfig(config, provider)?.env }; + return { ...subagentProviderConfig(config, providerInstanceId)?.env }; } -export function providerCommandVariable(provider: LocalAgentProvider): string | undefined { - switch (provider) { +export function providerCommandVariable(driver: LocalAgentDriverKind): string | undefined { + switch (driver) { case "codex": return "CODEX_COMMAND"; case "claude": return "CLAUDE_COMMAND"; case "cursor": return "CURSOR_COMMAND"; @@ -113,6 +151,7 @@ export function localAgentProviderConfigRevision(config: SubagentsConfig): strin .sort((left, right) => left.id.localeCompare(right.id)) .map((provider) => ({ id: provider.id, + driver: provider.driver, enabled: provider.enabled, ...(provider.model ? { model: provider.model } : {}), ...(provider.effort ? { effort: provider.effort } : {}), diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 5ac32b93b..82573c779 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { join, resolve } from "node:path"; -export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 5; +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 6; export const LOCAL_AGENT_DAEMON_SOCKET_NAME = "agentd.sock"; export const LOCAL_AGENT_DAEMON_PID_NAME = "agentd.pid"; export const LOCAL_AGENT_DAEMON_LOCK_NAME = "agentd.lock"; diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index bd340d5b5..7b7a92488 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -119,7 +119,8 @@ const record = decodeAgentRecord({ workspaceId: "ws_test", workspaceRoot: "/tmp/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", status: "idle", latestResponse: " response whitespace \n", createdAt: "now", @@ -168,7 +169,8 @@ const failedRecord = decodeAgentRecord({ workspaceId: "ws_error", workspaceRoot: "/tmp/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", status: "error", error: "Timed out waiting for the local agent daemon.", errorCode: "DAEMON_TIMEOUT", diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index 21aeb6806..a9f2b2fc1 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -10,6 +10,7 @@ import type { } from "./local-agent-manager.js"; import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecycle.js"; +import { isLocalAgentDriverKind } from "./local-agent-provider.js"; export type LocalAgentDaemonMethod = | "hello" @@ -213,12 +214,17 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { const record = asRecord(value); const status = requiredString(record?.status, "status"); if (!isLocalAgentStatus(status)) throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent status."); + const driver = requiredString(record?.driver, "driver"); + if (!isLocalAgentDriverKind(driver)) { + throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent driver."); + } return { id: requiredString(record?.id, "id"), workspaceId: optionalString(record?.workspaceId), workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), profileName: requiredString(record?.profileName, "profileName"), - provider: requiredString(record?.provider, "provider"), + providerInstanceId: requiredString(record?.providerInstanceId, "providerInstanceId"), + driver, model: optionalString(record?.model), effort: optionalString(record?.effort), providerSessionId: optionalString(record?.providerSessionId), diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 88722dafa..8985bdc70 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -30,7 +30,8 @@ const record: LocalAgentRecord = { workspaceId: "ws_test", workspaceRoot: join(root, "project"), profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", status: "running", createdAt: "now", updatedAt: "now", diff --git a/src/local-agent-errors.ts b/src/local-agent-errors.ts index 32c33cd77..21c92402e 100644 --- a/src/local-agent-errors.ts +++ b/src/local-agent-errors.ts @@ -5,9 +5,9 @@ import { type Result as BetterResult, } from "better-result"; import { - isLocalAgentProvider, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + isLocalAgentDriverKind, + type LocalAgentDriverKind, +} from "./local-agent-provider.js"; export type AgentTargetErrorCode = | "UNKNOWN_TARGET" @@ -19,7 +19,7 @@ export type AgentTargetErrorCode = export class AgentTargetError extends TaggedError("AgentTargetError")<{ code: AgentTargetErrorCode; target: string; - provider?: LocalAgentProvider; + provider?: string; operation?: string; retryable: boolean; cause?: unknown; @@ -50,7 +50,7 @@ export class AgentScopeError extends TaggedError("AgentScopeError")<{ }>() {} interface AgentProviderErrorFields extends Record { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; retryable: boolean; @@ -165,7 +165,7 @@ export interface AgentErrorPayload { code: LocalAgentError["code"]; message: string; retryable?: boolean; - provider?: LocalAgentProvider; + provider?: string; agentId?: string; workspaceId?: string; operation?: string; @@ -233,7 +233,7 @@ export function agentErrorFromPayload(payload: { target?: string; }): LocalAgentError | undefined { const retryable = payload.retryable ?? false; - const provider = payload.provider && isLocalAgentProvider(payload.provider) + const provider = payload.provider && isLocalAgentDriverKind(payload.provider) ? payload.provider : undefined; switch (payload.code) { @@ -245,7 +245,7 @@ export function agentErrorFromPayload(payload: { return new AgentTargetError({ code: payload.code, target: payload.target ?? payload.agentId ?? payload.provider ?? "unknown", - provider, + provider: payload.provider, operation: payload.operation, retryable, message: payload.message, @@ -363,7 +363,7 @@ export function agentErrorFromPayload(payload: { } export function providerErrorFromCause(input: { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; cause: unknown; @@ -406,7 +406,7 @@ export function providerErrorFromCause(input: { } export async function captureAgentProviderResult(input: { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; run: () => T | Promise; @@ -456,7 +456,7 @@ function unavailableCauseKind(error: unknown): "permanent" | "transient" | undef return undefined; } -function displayProvider(provider: LocalAgentProvider): string { +function displayProvider(provider: LocalAgentDriverKind): string { switch (provider) { case "codex": return "Codex"; case "claude": return "Claude"; diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 0df247006..3f11deff8 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -42,8 +42,9 @@ const subagents: SubagentsConfig = { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true, model: "gpt-default", effort: "medium" }, - { id: "claude", enabled: true }, + { id: "codex", driver: "codex", enabled: true, model: "gpt-default", effort: "medium" }, + { id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }, + { id: "claude", driver: "claude", enabled: true }, ], }; @@ -96,6 +97,7 @@ class FakeRuntime implements LocalAgentRuntime { const runtimes = new Map(); const driver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: (context: LocalAgentRuntimeContext) => context.agentId, createRuntime: async (context) => { @@ -104,6 +106,7 @@ const driver: LocalAgentDriver = { return Result.ok(runtime); }, }; +const workDriver: LocalAgentDriver = { ...driver, providerInstanceId: "codex-work" }; function providerFailure(message: string): AgentProviderExecutionError { return new AgentProviderExecutionError({ @@ -120,14 +123,15 @@ const stale = store.create({ workspaceId: scope.workspaceId, workspaceRoot: root, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }); const staleTurn = store.beginTurn(stale.id, { prompt: "interrupted turn" }); store.update(stale.id, { latestResponse: "previous response" }); const manager = new LocalAgentManager({ store, - drivers: [driver], + drivers: [driver, workDriver], pool: new LocalAgentRuntimePool(), loadProfiles: async () => [profile, disabledProfile], allowedRoots: [root], @@ -174,6 +178,17 @@ const unknown = await manager.start({ assert.equal(unknown.isErr(), true); if (unknown.isErr()) assert.equal(unknown.error.code, "UNKNOWN_TARGET"); +const namedInstance = unwrap(await manager.start({ + target: "codex-work", + prompt: "inspect named instance", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +assert.equal(namedInstance.providerInstanceId, "codex-work"); +assert.equal(namedInstance.driver, "codex"); +assert.equal(namedInstance.model, "gpt-work"); +await waitFor(() => getRecord(namedInstance.id).status === "idle"); + const disabled = await manager.start({ target: "disabled-reviewer", prompt: "inspect", @@ -199,13 +214,14 @@ const disabledProvider = await manager.start({ workspaceRoot: root, }); assert.equal(disabledProvider.isErr(), true); -if (disabledProvider.isErr()) assert.equal(disabledProvider.error.code, "PROVIDER_DISABLED"); +if (disabledProvider.isErr()) assert.equal(disabledProvider.error.code, "UNKNOWN_TARGET"); const previouslyCreatedDisabled = store.create({ workspaceId: scope.workspaceId, workspaceRoot: root, profileName: disabledProfile.name, - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }); store.update(previouslyCreatedDisabled.id, { status: "idle" }); const disabledContinuation = await manager.continue(previouslyCreatedDisabled.id, "inspect", {}, scope); @@ -277,7 +293,7 @@ const second = unwrap(await manager.start({ })); await waitFor(() => getRecord(second.id).status === "idle"); assert.notEqual(first.id, second.id); -assert.equal(runtimes.size, 2, "different agents receive independent logical runtimes"); +assert.equal(runtimes.size, 3, "different agents receive independent logical runtimes"); const failed = unwrap(await manager.start({ target: "reviewer", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 30fe1e1f4..f5a17986e 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -11,9 +11,8 @@ import { } from "./local-agent-errors.js"; import { type LocalAgentProfile, - type LocalAgentProvider, - isLocalAgentProvider, } from "./local-agent-profiles.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; import { resolveLocalAgentTarget, } from "./local-agent-targets.js"; @@ -34,6 +33,7 @@ import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { assertAllowedPath } from "./roots.js"; import { isSubagentProviderEnabled, + subagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; @@ -92,7 +92,7 @@ interface ActiveLocalAgentTurn { */ export class LocalAgentManager { private readonly store: LocalAgentStore; - private readonly drivers = new Map(); + private readonly drivers = new Map(); private readonly pool: LocalAgentRuntimePool; private readonly loadProfiles: (workspaceRoot: string) => Promise; private readonly agentDir?: string; @@ -105,7 +105,7 @@ export class LocalAgentManager { constructor(options: LocalAgentManagerOptions) { this.store = options.store; - for (const driver of options.drivers) this.drivers.set(driver.provider, driver); + for (const driver of options.drivers) this.drivers.set(driver.providerInstanceId, driver); this.pool = options.pool; this.loadProfiles = options.loadProfiles; this.agentDir = options.agentDir; @@ -153,12 +153,13 @@ export class LocalAgentManager { })); } yield* manager.providerEnabledResult(target.provider, target.name, "start"); - yield* manager.driverResult(target.provider, "start"); + const driver = yield* manager.driverResult(target.provider, "start"); const record = yield* manager.store.createResult({ workspaceId: input.workspaceId, workspaceRoot, profileName: target.name, - provider: target.provider, + providerInstanceId: target.provider, + driver: driver.provider, model: target.model, effort: target.effort, }); @@ -184,8 +185,8 @@ export class LocalAgentManager { yield* manager.agentWorkspaceResult(record, scope, "continue"); const profiles = yield* Result.await(manager.loadProfilesResult(record.workspaceRoot, record.profileName)); yield* manager.profileForRecordResult(record, profiles); - yield* manager.providerEnabledResult(record.provider, record.profileName, "continue"); - yield* manager.driverResult(record.provider, "continue", agentId); + yield* manager.providerEnabledResult(record.providerInstanceId, record.profileName, "continue"); + yield* manager.driverResult(record.providerInstanceId, "continue", agentId, record.driver); return manager.begin(record, prompt, overrides, scope.workspaceId); }); } @@ -331,7 +332,8 @@ export class LocalAgentManager { ): Promise { const startedAt = Date.now(); this.log("info", "agent_run_started", { - provider: record.provider, + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), }); @@ -360,13 +362,14 @@ export class LocalAgentManager { this.persistRunError(record, turnId, input.error, startedAt); return; } - const driver = this.driverResult(record.provider, "run", record.id); + const driver = this.driverResult(record.providerInstanceId, "run", record.id, record.driver); if (driver.isErr()) { this.persistRunError(record, turnId, driver.error, startedAt); return; } const context: LocalAgentRuntimeContext = { agentId: record.id, + providerInstanceId: record.providerInstanceId, provider: driver.value.provider, workspaceRoot, providerSessionId: record.providerSessionId, @@ -400,7 +403,8 @@ export class LocalAgentManager { }); if (updated.isErr()) throw updated.error; this.log("info", "agent_run_completed", { - provider: updated.value.provider, + providerInstanceId: updated.value.providerInstanceId, + driver: updated.value.driver, agentId: updated.value.id, providerSessionIdPrefix: updated.value.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), @@ -417,7 +421,8 @@ export class LocalAgentManager { errorRetryable: false, }); this.log("error", "agent_run_failed", { - provider: record.provider, + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), @@ -444,7 +449,8 @@ export class LocalAgentManager { errorRetryable: error.retryable, }); this.log("error", "agent_run_failed", { - provider: record.provider, + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), @@ -461,12 +467,12 @@ export class LocalAgentManager { prompt: string, overrides: RunOverrides, ): BetterResult { - const isRawProvider = record.profileName === record.provider; + const isRawProvider = record.profileName === record.providerInstanceId; if (!profile && !isRawProvider) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", target: record.profileName, - provider: isLocalAgentProvider(record.provider) ? record.provider : undefined, + provider: record.providerInstanceId, retryable: false, message: `Subagent profile not found: ${record.profileName}.`, })); @@ -489,13 +495,13 @@ export class LocalAgentManager { record: LocalAgentRecord, profiles: readonly LocalAgentProfile[], ): BetterResult { - if (record.profileName === record.provider) return Result.ok(undefined); + if (record.profileName === record.providerInstanceId) return Result.ok(undefined); const profile = profiles.find((candidate) => candidate.name === record.profileName); if (!profile) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", target: record.profileName, - provider: isLocalAgentProvider(record.provider) ? record.provider : undefined, + provider: record.providerInstanceId, retryable: false, message: `Subagent profile not found: ${record.profileName}.`, })); @@ -513,47 +519,59 @@ export class LocalAgentManager { } private driverResult( - provider: string, + providerInstanceId: string, operation: string, agentId?: string, + expectedDriver?: LocalAgentDriverKind, ): BetterResult { - if (!isLocalAgentProvider(provider)) { + const driver = this.drivers.get(providerInstanceId); + if (!driver) { return Result.err(new AgentTargetError({ code: "PROVIDER_NOT_CONFIGURED", - target: provider, + target: agentId ?? providerInstanceId, + provider: providerInstanceId, operation, retryable: false, - message: `No local agent driver is configured for provider: ${provider}.`, + message: `No local agent driver is configured for provider instance: ${providerInstanceId}.`, })); } - const driver = this.drivers.get(provider); - if (!driver) { + if (expectedDriver && driver.provider !== expectedDriver) { return Result.err(new AgentTargetError({ code: "PROVIDER_NOT_CONFIGURED", - target: agentId ?? provider, - provider, + target: agentId ?? providerInstanceId, + provider: providerInstanceId, operation, retryable: false, - message: `No local agent driver is configured for provider: ${provider}.`, + message: `Provider instance ${providerInstanceId} now uses ${driver.provider}; this agent was created with ${expectedDriver}.`, })); } return Result.ok(driver); } private providerEnabledResult( - provider: string, + providerInstanceId: string, target: string, operation: string, ): BetterResult { - if (!isLocalAgentProvider(provider)) return Result.ok(undefined); - if (isSubagentProviderEnabled(this.subagents, provider)) return Result.ok(undefined); + const configured = subagentProviderConfig(this.subagents, providerInstanceId); + if (!configured) { + return Result.err(new AgentTargetError({ + code: "PROVIDER_NOT_CONFIGURED", + target, + provider: providerInstanceId, + operation, + retryable: false, + message: `Subagent provider instance is not configured: ${providerInstanceId}.`, + })); + } + if (isSubagentProviderEnabled(this.subagents, providerInstanceId)) return Result.ok(undefined); return Result.err(new AgentTargetError({ code: "PROVIDER_DISABLED", target, - provider, + provider: providerInstanceId, operation, retryable: false, - message: `Subagent provider is disabled: ${provider}.`, + message: `Subagent provider is disabled: ${providerInstanceId}.`, })); } diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 69c358004..6b12a1f76 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -58,6 +58,7 @@ const pool = new LocalAgentRuntimePool(); const first = await pool.run(driver, { agentId: "agt_one", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -68,6 +69,7 @@ const first = await pool.run(driver, { }); const second = await pool.run(driver, { agentId: "agt_two", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -142,6 +144,7 @@ if (process.platform !== "win32") { }); const created = await envDriver.createRuntime({ agentId: "agt_env", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }); @@ -179,6 +182,7 @@ assert.deepEqual(promptInputs[0], { let callbackSessionId: string | undefined; await pool.run(driver, { agentId: "agt_one", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -283,6 +287,7 @@ const applicationErrorDriver = new OpencodeLocalAgentDriver(async () => ({ })); const applicationFailure = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { prompt: "bad input", workspaceRoot: "/tmp/project" }); @@ -295,6 +300,7 @@ if (applicationFailure.isErr()) { assert.equal(applicationErrorPool.size, 1, "ordinary provider errors must not evict a healthy server runtime"); const recoveredApplicationTurn = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { prompt: "valid input", workspaceRoot: "/tmp/project" }); @@ -312,6 +318,7 @@ const recoveringDriver = new OpencodeLocalAgentDriver(async () => { const recoveringPool = new LocalAgentRuntimePool(); await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -321,6 +328,7 @@ await recoveringPool.run(recoveringDriver, { healthAvailable = false; const deadRuntime = await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -335,6 +343,7 @@ if (deadRuntime.isErr()) { assert.equal(recoveringPool.size, 0, "a failed health check removes the dead runtime immediately"); await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index d9980aba2..3a7a3eba2 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -149,6 +149,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { export class OpencodeLocalAgentDriver implements LocalAgentDriver { readonly provider = "opencode" as const; + readonly providerInstanceId = "opencode"; readonly idleTimeoutMs = 5 * 60_000; constructor( diff --git a/src/local-agent-pi.test.ts b/src/local-agent-pi.test.ts index df111666c..6c1471cbb 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -65,6 +65,7 @@ const driver = new PiLocalAgentDriver(factory, { HARNESS_ENV: "pi" }); const pool = new LocalAgentRuntimePool(); const context: LocalAgentRuntimeContext = { agentId: "agt_pi", + providerInstanceId: "pi", provider: "pi", workspaceRoot: "/tmp/project", }; diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index bfb85cb3a..2b3780455 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -164,6 +164,7 @@ export class PiSessionRuntime implements LocalAgentRuntime { export class PiLocalAgentDriver implements LocalAgentDriver { readonly provider = "pi" as const; + readonly providerInstanceId = "pi"; readonly idleTimeoutMs = 3 * 60_000; constructor( diff --git a/src/local-agent-presentation.test.ts b/src/local-agent-presentation.test.ts index ea1a4d6e6..fca1e3eae 100644 --- a/src/local-agent-presentation.test.ts +++ b/src/local-agent-presentation.test.ts @@ -13,7 +13,8 @@ const record: LocalAgentRecord = { workspaceId: "ws_private", workspaceRoot: "/private/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", model: "gpt-5.4", effort: "high", providerSessionId: "provider_private", @@ -65,9 +66,10 @@ assert.deepEqual(failed, { const catalog: LocalAgentCatalog = { enabled: true, providers: [ - { id: "codex", enabled: true, available: true, usable: true, model: "gpt-5.4", effort: "high" }, + { id: "codex", driver: "codex", enabled: true, available: true, usable: true, model: "gpt-5.4", effort: "high" }, { id: "claude", + driver: "claude", enabled: true, available: false, usable: false, diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 8a7d57db1..f22258b13 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -76,16 +76,17 @@ try { [ "---", "name: custom", - "description: Unsupported custom agent.", - "provider: custom", + "description: Named provider instance.", + "provider: codex-work", "---", "", "Custom body.", "", ].join("\n"), ); - const profilesWithInvalid = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); - assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["reviewer"]); + const profilesWithNamedInstance = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); + assert.deepEqual(profilesWithNamedInstance.map((profile) => profile.name), ["custom", "reviewer"]); + assert.equal(profilesWithNamedInstance[0]?.provider, "codex-work"); const disabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { workspaces: { allowedRoots: [workspaceRoot] }, diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index ad99a225c..2c55f824a 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -3,23 +3,12 @@ import { readdir, readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import type { ServerConfig } from "./config.js"; - -export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot" | "grok"; - -export const LOCAL_AGENT_PROVIDERS: readonly LocalAgentProvider[] = [ - "codex", - "claude", - "opencode", - "pi", - "cursor", - "copilot", - "grok", -]; +import type { LocalAgentProviderInstanceId } from "./local-agent-provider.js"; export interface LocalAgentProfile { name: string; description: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; filePath: string; @@ -30,7 +19,7 @@ export interface LocalAgentProfile { export interface LocalAgentProfileSummary { name: string; description: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; } @@ -41,7 +30,6 @@ interface ParsedFrontmatter { } const FRONTMATTER_DELIMITER = "---"; -const PROVIDERS = new Set(LOCAL_AGENT_PROVIDERS); export async function loadLocalAgentProfiles( config: ServerConfig, @@ -154,21 +142,12 @@ function profileFromFrontmatter( }; } -function readProvider(frontmatter: Record, filePath: string): LocalAgentProvider { +function readProvider(frontmatter: Record, filePath: string): LocalAgentProviderInstanceId { const provider = readString(frontmatter, "provider"); if (!provider) { throw new Error(`Subagent profile is missing provider: ${filePath}`); } - if (!PROVIDERS.has(provider as LocalAgentProvider)) { - throw new Error( - `Subagent profile provider must be codex, claude, opencode, pi, cursor, copilot, or grok: ${filePath}`, - ); - } - return provider as LocalAgentProvider; -} - -export function isLocalAgentProvider(value: string): value is LocalAgentProvider { - return PROVIDERS.has(value as LocalAgentProvider); + return provider; } function readString(frontmatter: Record, key: string): string | undefined { diff --git a/src/local-agent-provider.ts b/src/local-agent-provider.ts new file mode 100644 index 000000000..8506463c3 --- /dev/null +++ b/src/local-agent-provider.ts @@ -0,0 +1,20 @@ +export const LOCAL_AGENT_DRIVER_KINDS = [ + "codex", + "claude", + "opencode", + "pi", + "cursor", + "copilot", + "grok", +] as const; + +export type LocalAgentDriverKind = typeof LOCAL_AGENT_DRIVER_KINDS[number]; + +/** User-configured routing identity. Multiple instances may share one driver. */ +export type LocalAgentProviderInstanceId = string; + +const DRIVER_KINDS = new Set(LOCAL_AGENT_DRIVER_KINDS); + +export function isLocalAgentDriverKind(value: string): value is LocalAgentDriverKind { + return DRIVER_KINDS.has(value); +} diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index a03150240..213223555 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -12,7 +12,7 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000; const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 60_000; @@ -209,7 +209,7 @@ export class LocalAgentRuntimePool { private async discardRuntime( entry: RuntimeEntry, - provider: LocalAgentProvider, + provider: LocalAgentDriverKind, reason: string, ): Promise { try { diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index da2364005..a21513b9b 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -17,6 +17,7 @@ import type { const context: LocalAgentRuntimeContext = { agentId: "agt_test", + providerInstanceId: "codex", provider: "codex", workspaceRoot: "/tmp/project", }; @@ -92,6 +93,7 @@ class FakeRuntime implements LocalAgentRuntime { const runtime = new FakeRuntime(); let createCount = 0; const driver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", idleTimeoutMs: Number.POSITIVE_INFINITY, runtimeKey: () => "shared", @@ -132,6 +134,7 @@ const sessionPool = new LocalAgentRuntimePool({ sessionIdleTimeoutMs: 10, }); const sessionDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", idleTimeoutMs: Number.POSITIVE_INFINITY, runtimeKey: () => "session-runtime", @@ -160,6 +163,7 @@ const shutdownReleasePool = new LocalAgentRuntimePool({ sessionIdleTimeoutMs: 10, }); const shutdownReleaseDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", idleTimeoutMs: Number.POSITIVE_INFINITY, runtimeKey: () => "shutdown-release-runtime", @@ -209,6 +213,7 @@ class CleanupFailureRuntime extends FakeRuntime { const cleanupPool = new LocalAgentRuntimePool(); const cleanupRuntime = new CleanupFailureRuntime(); const cleanupDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: () => "cleanup-runtime", createRuntime: async () => Result.ok(cleanupRuntime), @@ -225,6 +230,7 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider let attempts = 0; const recoveryPool = new LocalAgentRuntimePool(); const recoveryDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: () => "dead-runtime-recovery", createRuntime: async () => Result.ok(attempts++ === 0 ? deadRuntime : replacementRuntime), @@ -249,6 +255,7 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider const completedTurnRuntime = new ClosingAfterTurnRuntime(); completedTurnPool = new LocalAgentRuntimePool(); const completedTurnDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: () => "completed-turn-during-close", createRuntime: async () => Result.ok(completedTurnRuntime), @@ -265,6 +272,7 @@ const creating = new Promise const raceRuntime = new FakeRuntime(); const racePool = new LocalAgentRuntimePool(); const raceDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: () => "creation-race", createRuntime: () => creating, @@ -296,6 +304,7 @@ if (afterClose.isErr()) { const recoveryRuntime = new FakeRuntime(); const creationPool = new LocalAgentRuntimePool(); const creationDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: () => "creation-failure", async createRuntime() { @@ -349,6 +358,7 @@ if (afterClose.isErr()) { sessionIdleTimeoutMs: 10, }); const releaseDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", idleTimeoutMs: Number.POSITIVE_INFINITY, runtimeKey: () => "release-failure", diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index ecc40f545..bd1b0982b 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -1,6 +1,9 @@ import type { Result } from "better-result"; import type { AgentProviderError } from "./local-agent-errors.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; @@ -16,7 +19,7 @@ export interface LocalAgentRunInput { } export interface LocalAgentRunResult { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; providerSessionId: string | null; finalResponse: string; items: unknown[]; @@ -33,7 +36,8 @@ export interface LocalAgentRunCallbacks { export interface LocalAgentRuntimeContext { agentId: string; - provider: LocalAgentProvider; + providerInstanceId: LocalAgentProviderInstanceId; + provider: LocalAgentDriverKind; workspaceRoot: string; providerSessionId?: string; writeMode?: LocalAgentWriteMode; @@ -48,7 +52,7 @@ export interface LocalAgentRuntimeContext { * identity used when a later runtime is created. */ export interface LocalAgentRuntime { - readonly provider: LocalAgentProvider; + readonly provider: LocalAgentDriverKind; run( input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks, @@ -59,7 +63,8 @@ export interface LocalAgentRuntime { } export interface LocalAgentDriver { - readonly provider: LocalAgentProvider; + readonly providerInstanceId: LocalAgentProviderInstanceId; + readonly provider: LocalAgentDriverKind; runtimeKey(context: LocalAgentRuntimeContext): string; createRuntime(context: LocalAgentRuntimeContext): Promise>; readonly idleTimeoutMs?: number; diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index e29bd319b..04e9d2a9a 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -16,7 +16,8 @@ try { workspaceId: "ws_1", workspaceRoot: join(root, "project"), profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex-work", + driver: "codex", model: "gpt-5.4", effort: "high", }); @@ -25,6 +26,8 @@ try { assert.equal(created.status, "starting"); assert.equal(store.getById(created.id)?.effort, "high"); assert.equal(store.getById(created.id)?.profileName, "reviewer"); + assert.equal(store.getById(created.id)?.providerInstanceId, "codex-work"); + assert.equal(store.getById(created.id)?.driver, "codex"); assert.equal(store.getById(created.id.slice(0, 7)), undefined); const updated = store.update(created.id, { @@ -130,7 +133,8 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot workspaceId: "ws_1", workspaceRoot: join(root, "project"), profileName: "explorer", - provider: "claude", + providerInstanceId: "claude", + driver: "claude", }); assert.deepEqual( @@ -194,6 +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?.errorCode, undefined); assert.equal(legacyRecord?.errorRetryable, undefined); const upgradedRecord = upgradedStore.update("agt_legacy", { diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 3aab64da6..83336aa19 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { Result, type Result as BetterResult } from "better-result"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { AgentStoreError, isProgrammerDefect } from "./local-agent-errors.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; export type LocalAgentTurnStatus = "running" | "completed" | "failed" | "stopped"; @@ -12,7 +13,8 @@ export interface LocalAgentRecord { workspaceId?: string; workspaceRoot: string; profileName: string; - provider: string; + providerInstanceId: string; + driver: LocalAgentDriverKind; model?: string; effort?: string; providerSessionId?: string; @@ -29,7 +31,8 @@ export interface CreateLocalAgentRecordInput { workspaceId?: string; workspaceRoot: string; profileName: string; - provider: string; + providerInstanceId: string; + driver: LocalAgentDriverKind; model?: string; effort?: string; } @@ -78,7 +81,8 @@ interface LocalAgentRow { workspace_id: string | null; workspace_root: string; profile_name: string; - provider: string; + provider_instance_id: string; + driver: LocalAgentDriverKind; model: string | null; effort: string | null; provider_session_id: string | null; @@ -157,7 +161,8 @@ export class LocalAgentStore { workspaceId: input.workspaceId, workspaceRoot: resolve(input.workspaceRoot), profileName: input.profileName, - provider: input.provider, + providerInstanceId: input.providerInstanceId, + driver: input.driver, model: input.model, effort: input.effort, status: "starting", @@ -172,20 +177,22 @@ export class LocalAgentStore { workspace_id, workspace_root, profile_name, - provider, + provider_instance_id, + driver, model, effort, status, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( record.id, record.workspaceId ?? null, record.workspaceRoot, record.profileName, - record.provider, + record.providerInstanceId, + record.driver, record.model ?? null, record.effort ?? null, record.status, @@ -239,7 +246,8 @@ export class LocalAgentStore { workspace_id = ?, workspace_root = ?, profile_name = ?, - provider = ?, + provider_instance_id = ?, + driver = ?, model = ?, effort = ?, provider_session_id = ?, @@ -255,7 +263,8 @@ export class LocalAgentStore { updated.workspaceId ?? null, resolve(updated.workspaceRoot), updated.profileName, - updated.provider, + updated.providerInstanceId, + updated.driver, updated.model ?? null, updated.effort ?? null, updated.providerSessionId ?? null, @@ -463,7 +472,8 @@ function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { workspaceId: row.workspace_id ?? undefined, workspaceRoot: row.workspace_root, profileName: row.profile_name, - provider: row.provider, + providerInstanceId: row.provider_instance_id, + driver: row.driver, model: row.model ?? undefined, effort: row.effort ?? undefined, providerSessionId: row.provider_session_id ?? undefined, diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index fde0c000e..ff7a197cb 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -106,7 +106,8 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { } { - const target = resolveLocalAgentTarget("opencode", profiles); + const providers = [{ id: "opencode", driver: "opencode", enabled: true }] as const; + const target = resolveLocalAgentTarget("opencode", profiles, undefined, undefined, providers); assert.equal(target?.kind, "provider"); assert.equal(target?.name, "opencode"); assert.equal(target?.provider, "opencode"); @@ -115,7 +116,8 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { } { - const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2", "deep"); + const providers = [{ id: "opencode", driver: "opencode", enabled: true }] as const; + const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2", "deep", providers); assert.equal(target?.kind, "provider"); assert.equal(target?.model, "kimi-k2"); assert.equal(target?.effort, "deep"); @@ -124,6 +126,7 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { { const providerDefaults = [{ id: "codex", + driver: "codex", enabled: true, model: "gpt-default", effort: "medium", @@ -139,6 +142,19 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { assert.equal(overridden?.effort, "xhigh"); } +{ + const providerInstances = [{ + id: "codex-work", + driver: "codex", + enabled: true, + model: "gpt-work", + }] as const; + const target = resolveLocalAgentTarget("codex-work", profiles, undefined, undefined, providerInstances); + assert.equal(target?.kind, "provider"); + assert.equal(target?.provider, "codex-work"); + assert.equal(target?.model, "gpt-work"); +} + { const target = resolveLocalAgentTarget("claude", profiles); assert.equal(target?.kind, "profile"); diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts index 367b04761..d3794810c 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -1,9 +1,5 @@ -import { - isLocalAgentProvider, - LOCAL_AGENT_PROVIDERS, - type LocalAgentProfile, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; +import type { LocalAgentProviderInstanceId } from "./local-agent-provider.js"; import type { SubagentProviderConfig } from "./local-agent-config.js"; export interface ParsedLocalAgentRunArgs { @@ -24,15 +20,15 @@ export type LocalAgentTarget = | { kind: "profile"; name: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; profile: LocalAgentProfile; } | { kind: "provider"; - name: LocalAgentProvider; - provider: LocalAgentProvider; + name: LocalAgentProviderInstanceId; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; }; @@ -143,8 +139,8 @@ export function resolveLocalAgentTarget( }; } - if (isLocalAgentProvider(target)) { - const providerConfig = providerConfigs.find((entry) => entry.id === target); + const providerConfig = providerConfigs.find((entry) => entry.id === target); + if (providerConfig) { return { kind: "provider", name: target, diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 90a8ade25..0f7c7aedc 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -52,6 +52,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 6, name: "local-agent-effort-rename" }, { version: 7, name: "workspace-recovery-state" }, { version: 8, name: "local-agent-turns" }, + { version: 9, name: "local-agent-provider-instances" }, ]); } finally { database.close(); diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts index 9a5d02e8c..48049943e 100644 --- a/src/onboarding.test.ts +++ b/src/onboarding.test.ts @@ -22,8 +22,8 @@ assert.deepEqual( enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true }, - { id: "claude", enabled: true }, + { id: "codex", driver: "codex", enabled: true }, + { id: "claude", driver: "claude", enabled: true }, ], }, ); @@ -34,30 +34,34 @@ const configured = { providers: [ { id: "codex" as const, + driver: "codex" as const, enabled: true, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude" as const, enabled: true, model: "sonnet" }, + { id: "claude" as const, driver: "claude" as const, enabled: true, model: "sonnet" }, + { id: "codex-work", driver: "codex" as const, enabled: true, model: "gpt-work" }, ], }; assert.deepEqual( - updateOnboardingSubagentsConfig(configured, ["claude"]), + updateOnboardingSubagentsConfig(configured, ["claude", "codex-work"]), { enabled: true, instructions: "preload", providers: [ { id: "codex", + driver: "codex", enabled: false, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude", enabled: true, model: "sonnet" }, + { id: "claude", driver: "claude", enabled: true, model: "sonnet" }, + { id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }, ], }, ); diff --git a/src/onboarding.ts b/src/onboarding.ts index 193dd57dd..fcf26931a 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -1,8 +1,9 @@ import type { SubagentsConfig } from "./local-agent-config.js"; import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + isLocalAgentDriverKind, + LOCAL_AGENT_DRIVER_KINDS, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export const SUBAGENT_SKILL_INSTALL_COMMAND = "npx skills add Waishnav/devspace --skill subagents --global"; @@ -31,21 +32,28 @@ export function usesCodingAgents(usage: OnboardingUsage): boolean { export function updateOnboardingSubagentsConfig( current: SubagentsConfig, - selectedProviders: readonly LocalAgentProvider[], + selectedProviders: readonly LocalAgentProviderInstanceId[], ): SubagentsConfig { const selected = new Set(selectedProviders); + const customInstances = current.providers + .filter((provider) => !isLocalAgentDriverKind(provider.id)) + .map((provider) => ({ ...provider, enabled: selected.has(provider.id) })); return { enabled: true, instructions: current.instructions, - providers: LOCAL_AGENT_PROVIDERS + providers: [ + ...LOCAL_AGENT_DRIVER_KINDS .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, enabled: selected.has(id), }; }), + ...customInstances, + ], }; } diff --git a/src/server.test.ts b/src/server.test.ts index 59bcc5d37..d93515a61 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -12,6 +12,7 @@ import { loadConfig, type ServerConfig, type ToolMode } from "./config.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; import { buildLocalAgentProviderStatuses } from "./local-agent-catalog.js"; import type { SubagentsConfig } from "./local-agent-config.js"; +import { isLocalAgentDriverKind } from "./local-agent-provider.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; import { createMcpServer, createServer } from "./server.js"; @@ -415,8 +416,8 @@ test("open_workspace omits providers disabled by configuration", async (t) => { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true }, - { id: "claude", enabled: false }, + { id: "codex", driver: "codex", enabled: true }, + { id: "claude", driver: "claude", enabled: false }, ], }, }); @@ -445,7 +446,7 @@ test("open_workspace preloads subagent instructions when configured", async (t) subagents: { enabled: true, instructions: "preload", - providers: [{ id: "codex", enabled: true }], + providers: [{ id: "codex", driver: "codex", enabled: true }], }, }); @@ -775,10 +776,12 @@ async function fixture( subagents: options.subagents ?? { enabled: true, instructions: "on-demand", - providers: initialProviderAvailability.map((provider) => ({ - id: provider.name, - enabled: true, - })), + providers: initialProviderAvailability.map((provider) => { + if (!isLocalAgentDriverKind(provider.name)) { + throw new Error(`Test provider instance requires explicit config: ${provider.name}`); + } + return { id: provider.name, driver: provider.name, enabled: true }; + }), }, } : modeConfig; From 55cd9ae59216d2586f7e8bfd31ccf82758249026 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:44:57 +0530 Subject: [PATCH 02/26] docs(subagents): document provider instances --- docs/agent-profile-schema.md | 7 ++++--- docs/configuration.md | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 94186ce18..2c12319be 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -64,7 +64,7 @@ model can choose the right profile. ### `provider` -Required built-in provider id: +Required provider instance id. The default instance ids are: ```yaml provider: codex @@ -76,8 +76,9 @@ provider: copilot provider: grok ``` -Unsupported or custom providers are rejected. DevSpace maps providers to their -native integration: +Profiles may also reference named provider instances configured under +`subagents.providers`, such as `provider: codex-work`. Provider instances map +to one of these built-in driver integrations: - `codex`: the host-installed `codex app-server` command - `claude`: Claude Code SDK diff --git a/docs/configuration.md b/docs/configuration.md index 408018c7f..d7b3675f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -149,6 +149,26 @@ Subagent providers are explicit. Omitted providers are disabled: } ``` +`id` is the provider instance id used by profiles and `devspace agents run`. +For the default built-in instance ids, `driver` is optional and defaults to the +same value. Named instances declare the driver explicitly, which allows +multiple independently configured instances of one integration: + +```jsonc +{ + "id": "codex-work", + "driver": "codex", + "enabled": true, + "env": { + "CODEX_HOME": "/home/alice/.codex-work", + }, +} +``` + +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. + `subagents.instructions` controls when ChatGPT receives the managed workflow: | Value | Behavior | From b246d4e9f51d9ab59c8c1198496a474c5b761345 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:05:47 +0530 Subject: [PATCH 03/26] fix(deps): pin koffi for package installs --- package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index fa39fb46c..57d36c7be 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "drizzle-orm": "^0.45.2", "express": "^5.2.1", "jsonc-parser": "^3.3.1", - "koffi": "^3.1.2", + "koffi": "3.2.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92ca7f368..611ce519d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^3.3.1 version: 3.3.1 koffi: - specifier: ^3.1.2 + specifier: 3.2.1 version: 3.2.1 lucide: specifier: ^1.24.0 From 6944735c6186f7fe1ca72ace1a096bb95bd6031b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:08:19 +0530 Subject: [PATCH 04/26] fix(subagents): keep profile instructions when a profile shares its provider instance name A profile named like its provider instance won target resolution on start but was treated as a raw provider run on continue, dropping its instructions and its disabled check. Resolve the profile first on continue too. Also make the migrated local_agent_sessions.driver column NOT NULL so migrated databases enforce the same invariant as fresh ones. Co-Authored-By: Claude Sonnet 5.5 --- src/db/migrations.ts | 2 +- src/local-agent-manager.test.ts | 31 ++++++++++++++++++++++++++++--- src/local-agent-manager.ts | 3 ++- src/local-agent-store.test.ts | 5 +++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 9ce071df4..40db50d5f 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -302,7 +302,7 @@ function migrateLocalAgentProviderInstances(sqlite: Database.Database): void { if (names.has("provider") && !names.has("provider_instance_id")) { sqlite.exec("alter table local_agent_sessions rename column provider to provider_instance_id"); } - addColumnIfMissing(sqlite, "local_agent_sessions", "driver", "text"); + addColumnIfMissing(sqlite, "local_agent_sessions", "driver", "text not null default ''"); sqlite.exec(` update local_agent_sessions set driver = provider_instance_id diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 3f11deff8..b3a39cd0b 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -38,12 +38,20 @@ const disabledProfile: LocalAgentProfile = { filePath: join(root, "disabled-reviewer.md"), disabled: true, }; +const shadowProfile: LocalAgentProfile = { + ...profile, + name: "codex-shadow", + provider: "codex-shadow", + filePath: join(root, "codex-shadow.md"), + body: "Shadow profile body.", +}; const subagents: SubagentsConfig = { enabled: true, instructions: "on-demand", providers: [ { id: "codex", driver: "codex", enabled: true, model: "gpt-default", effort: "medium" }, { id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }, + { id: "codex-shadow", driver: "codex", enabled: true }, { id: "claude", driver: "claude", enabled: true }, ], }; @@ -107,6 +115,7 @@ const driver: LocalAgentDriver = { }, }; const workDriver: LocalAgentDriver = { ...driver, providerInstanceId: "codex-work" }; +const shadowDriver: LocalAgentDriver = { ...driver, providerInstanceId: "codex-shadow" }; function providerFailure(message: string): AgentProviderExecutionError { return new AgentProviderExecutionError({ @@ -131,9 +140,9 @@ store.update(stale.id, { latestResponse: "previous response" }); const manager = new LocalAgentManager({ store, - drivers: [driver, workDriver], + drivers: [driver, workDriver, shadowDriver], pool: new LocalAgentRuntimePool(), - loadProfiles: async () => [profile, disabledProfile], + loadProfiles: async () => [profile, disabledProfile, shadowProfile], allowedRoots: [root], subagents, }); @@ -189,6 +198,22 @@ assert.equal(namedInstance.driver, "codex"); assert.equal(namedInstance.model, "gpt-work"); await waitFor(() => getRecord(namedInstance.id).status === "idle"); +// A profile shadows a provider instance with the same name, on start and on every continuation. +const shadowed = unwrap(await manager.start({ + target: "codex-shadow", + prompt: "first", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => getRecord(shadowed.id).status === "idle"); +unwrap(await manager.continue(shadowed.id, "second", {}, scope)); +await waitFor(() => runtimes.get(shadowed.id)?.inputs.length === 2); +assert.deepEqual( + runtimes.get(shadowed.id)?.inputs.map((input) => input.prompt), + ["Shadow profile body.\n\nTask:\nfirst", "Shadow profile body.\n\nTask:\nsecond"], +); +await waitFor(() => getRecord(shadowed.id).status === "idle"); + const disabled = await manager.start({ target: "disabled-reviewer", prompt: "inspect", @@ -293,7 +318,7 @@ const second = unwrap(await manager.start({ })); await waitFor(() => getRecord(second.id).status === "idle"); assert.notEqual(first.id, second.id); -assert.equal(runtimes.size, 3, "different agents receive independent logical runtimes"); +assert.equal(runtimes.size, 4, "different agents receive independent logical runtimes"); const failed = unwrap(await manager.start({ target: "reviewer", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index f5a17986e..8bc8456ff 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -495,8 +495,9 @@ export class LocalAgentManager { record: LocalAgentRecord, profiles: readonly LocalAgentProfile[], ): BetterResult { - if (record.profileName === record.providerInstanceId) return Result.ok(undefined); + // Profiles win over provider instances of the same name, matching start-time target resolution. const profile = profiles.find((candidate) => candidate.name === record.profileName); + if (!profile && record.profileName === record.providerInstanceId) return Result.ok(undefined); if (!profile) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index 04e9d2a9a..ecfd6ad71 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -200,6 +200,11 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot assert.equal(legacyRecord?.effort, "high"); assert.equal(legacyRecord?.providerInstanceId, "codex"); assert.equal(legacyRecord?.driver, "codex"); + const inspect = new Database(databasePath(legacyStateDir), { readonly: true }); + const driverColumn = (inspect.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ name: string; notnull: number }>) + .find((column) => column.name === "driver"); + inspect.close(); + assert.equal(driverColumn?.notnull, 1, "migrated driver column rejects NULL like a fresh schema"); assert.equal(legacyRecord?.errorCode, undefined); assert.equal(legacyRecord?.errorRetryable, undefined); const upgradedRecord = upgradedStore.update("agt_legacy", { From 3a15397e3b5cc0f49305d56f28b6e339589158bd Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:26:50 +0530 Subject: [PATCH 05/26] build(opencode): add v2 client --- package.json | 1 + pnpm-lock.yaml | 138 ++++++++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 1 + 3 files changed, 140 insertions(+) diff --git a/package.json b/package.json index 57d36c7be..666eb193b 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/server": "^2.0.0", "@opencode-ai/sdk": "1.17.13", + "@opencode/client": "2.0.18", "@pierre/diffs": "^1.3.6", "better-result": "^2.10.0", "better-sqlite3": "^12.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 611ce519d..db036b93e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@opencode-ai/sdk': specifier: 1.17.13 version: 1.17.13 + '@opencode/client': + specifier: 2.0.18 + version: 2.0.18(effect@4.0.0-rc.112) '@pierre/diffs': specifier: ^1.3.6 version: 1.3.6(@shikijs/themes@3.23.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -717,6 +720,36 @@ packages: resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} engines: {node: '>=20'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -729,6 +762,23 @@ packages: '@opencode-ai/sdk@1.17.13': resolution: {integrity: sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ==} + '@opencode/client@2.0.18': + resolution: {integrity: sha512-N74FNdXkTTC1Re5T+PNxH6fsVf1tZiV3kESu3UWL/545oAkBRXoiikMmDszuuHZ4AxGwtr9NiQjKrHiMdnLVYQ==} + peerDependencies: + effect: 4.0.0-rc.112 + solid-js: '>=1.9.0' + peerDependenciesMeta: + effect: + optional: true + solid-js: + optional: true + + '@opencode/protocol@2.0.18': + resolution: {integrity: sha512-IhzzlCz/hAlFvBLWQ+kDceX0/CkYOrjVyTPwYJYvWwKze5AHzVxxDYxkhCqLJj0hn/wtdNL10nhjVothuHcaBQ==} + + '@opencode/schema@2.0.18': + resolution: {integrity: sha512-f+lp6f+kxhygdeSxjqkiu6vk9t0onH/6OVBghSNyo9ZUUFP9BU/2JKthMgy9kHGyFu2vp0lg01H9umXn76u52A==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1317,6 +1367,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1373,6 +1426,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1737,6 +1794,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1769,6 +1833,10 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-pty@1.1.0: resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} @@ -1869,6 +1937,9 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -2830,6 +2901,24 @@ snapshots: '@modelcontextprotocol/core': 2.0.0 zod: 4.4.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2843,6 +2932,23 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@opencode/client@2.0.18(effect@4.0.0-rc.112)': + dependencies: + '@opencode/protocol': 2.0.18 + '@opencode/schema': 2.0.18 + optionalDependencies: + effect: 4.0.0-rc.112 + + '@opencode/protocol@2.0.18': + dependencies: + '@opencode/schema': 2.0.18 + effect: 4.0.0-rc.112 + + '@opencode/schema@2.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + effect: 4.0.0-rc.112 + '@opentelemetry/api@1.9.0': {} '@opentelemetry/semantic-conventions@1.41.1': {} @@ -3280,6 +3386,11 @@ snapshots: ee-first@1.1.1: {} + effect@4.0.0-rc.112: + dependencies: + fast-check: 4.10.2 + msgpackr: 2.1.0 + encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -3375,6 +3486,10 @@ snapshots: extend@3.0.2: {} + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} fast-sha256@1.3.0: {} @@ -3742,6 +3857,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.1.0: + optionalDependencies: + msgpackr-extract: 3.0.4 + nanoid@3.3.12: {} napi-build-utils@2.0.0: {} @@ -3765,6 +3896,11 @@ snapshots: node-forge@1.4.0: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-pty@1.1.0: dependencies: node-addon-api: 7.1.1 @@ -3874,6 +4010,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + pure-rand@8.4.2: {} + qs@6.15.2: dependencies: side-channel: 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b10c94d08..f7842d097 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,6 @@ allowBuilds: better-sqlite3: true esbuild: true koffi: true + msgpackr-extract: false node-pty: true protobufjs: false From a84c48c8876950650547f0b3f301efe52f086d99 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:27:18 +0530 Subject: [PATCH 06/26] refactor(opencode): add runtime generation foundation --- src/local-agent-opencode-server.ts | 116 +++++++++++++++++ src/local-agent-opencode-version.test.ts | 53 ++++++++ src/local-agent-opencode-version.ts | 155 +++++++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 src/local-agent-opencode-server.ts create mode 100644 src/local-agent-opencode-version.test.ts create mode 100644 src/local-agent-opencode-version.ts diff --git a/src/local-agent-opencode-server.ts b/src/local-agent-opencode-server.ts new file mode 100644 index 000000000..d3de5c440 --- /dev/null +++ b/src/local-agent-opencode-server.ts @@ -0,0 +1,116 @@ +import { createRequire } from "node:module"; +import { createServer as createNetServer } from "node:net"; +import { terminateProcessTree } from "./process-platform.js"; + +const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; +const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; +const OPENCODE_SERVER_START_ATTEMPTS = 3; +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; + +export interface OpencodeServerLike { + close(): void; +} + +export interface StartedOpencodeServer extends OpencodeServerLike { + url: string; +} + +export async function startOpencodeServer(env: NodeJS.ProcessEnv): Promise { + for (let attempt = 1; attempt <= OPENCODE_SERVER_START_ATTEMPTS; attempt += 1) { + const port = await allocateOpencodePort(); + try { + return await launchOpencodeServer(env, port); + } catch (error) { + if (attempt === OPENCODE_SERVER_START_ATTEMPTS || !await isOpencodePortInUse(port)) throw error; + } + } + throw new Error("OpenCode server failed to start."); +} + +async function launchOpencodeServer(env: NodeJS.ProcessEnv, port: number): Promise { + const detached = process.platform !== "win32"; + const child = spawn("opencode", [ + "serve", + `--hostname=${OPENCODE_SERVER_HOSTNAME}`, + `--port=${port}`, + ], { + detached, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let closed = false; + const close = () => { + if (closed) return; + closed = true; + terminateProcessTree(child, "SIGTERM", detached); + }; + const url = await new Promise((resolve, reject) => { + let output = ""; + let ready = false; + const timer = setTimeout(() => { + if (ready) return; + close(); + reject(new Error(`Timeout waiting for OpenCode server after ${OPENCODE_SERVER_START_TIMEOUT_MS}ms`)); + }, OPENCODE_SERVER_START_TIMEOUT_MS); + timer.unref(); + const inspect = (chunk: Buffer | string) => { + if (ready) return; + output += chunk.toString(); + for (const line of output.split("\n")) { + const match = line.match(/(?:opencode )?server listening on\s+(https?:\/\/[^\s]+)/); + if (!match?.[1]) continue; + ready = true; + clearTimeout(timer); + resolve(match[1]); + return; + } + }; + child.stdout?.on("data", inspect); + child.stderr?.on("data", inspect); + child.once("error", (error) => { + if (ready) return; + clearTimeout(timer); + close(); + reject(error); + }); + child.once("exit", (code) => { + if (ready) return; + clearTimeout(timer); + close(); + reject(new Error(`OpenCode server exited with code ${code}${output.trim() ? `\n${output.trim()}` : ""}`)); + }); + }); + return { url, close }; +} + +async function allocateOpencodePort(): Promise { + const server = createNetServer(); + server.unref(); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen({ host: OPENCODE_SERVER_HOSTNAME, port: 0, exclusive: true }, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to allocate an OpenCode server port.")); + return; + } + server.close((error) => error ? reject(error) : resolve(address.port)); + }); + }); +} + +async function isOpencodePortInUse(port: number): Promise { + const server = createNetServer(); + server.unref(); + return new Promise((resolve, reject) => { + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") resolve(true); + else reject(error); + }); + server.listen({ host: OPENCODE_SERVER_HOSTNAME, port, exclusive: true }, () => { + server.close((error) => error ? reject(error) : resolve(false)); + }); + }); +} diff --git a/src/local-agent-opencode-version.test.ts b/src/local-agent-opencode-version.test.ts new file mode 100644 index 000000000..b8cf06035 --- /dev/null +++ b/src/local-agent-opencode-version.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { + classifyOpenCodeCliVersion, + encodeOpenCodeV2SessionId, + OpenCodeRuntimeProbe, + requireOpenCodeV1NativeSessionId, + requireOpenCodeV2NativeSessionId, +} from "./local-agent-opencode-version.js"; + +assert.deepEqual(classifyOpenCodeCliVersion("1.18.32\n"), { + generation: "v1", + version: "1.18.32", +}); +assert.deepEqual(classifyOpenCodeCliVersion("opencode v2.0.20\n"), { + generation: "v2", + version: "2.0.20", +}); +assert.deepEqual(classifyOpenCodeCliVersion("opencode v2.1.0-beta.2\n"), { + generation: "v2", + version: "2.1.0-beta.2", +}); +assert.equal(classifyOpenCodeCliVersion("opencode unknown"), undefined); + +assert.equal(encodeOpenCodeV2SessionId("ses_v2"), "opencode:v2:ses_v2"); +assert.equal(requireOpenCodeV2NativeSessionId("opencode:v2:ses_v2"), "ses_v2"); +assert.equal(requireOpenCodeV1NativeSessionId("ses_v1"), "ses_v1"); +assert.throws( + () => requireOpenCodeV2NativeSessionId("ses_v1"), + /different OpenCode protocol generation/, +); +assert.throws( + () => requireOpenCodeV1NativeSessionId("opencode:v2:ses_v2"), + /different OpenCode protocol generation/, +); + +let successfulCalls = 0; +const cached = new OpenCodeRuntimeProbe(async () => { + successfulCalls += 1; + return { generation: "v2", version: "2.0.20" }; +}); +assert.deepEqual(await cached.get(), { generation: "v2", version: "2.0.20" }); +assert.deepEqual(await cached.get(), { generation: "v2", version: "2.0.20" }); +assert.equal(successfulCalls, 1, "successful probes are cached"); + +let attempts = 0; +const retriesFailures = new OpenCodeRuntimeProbe(async () => { + attempts += 1; + if (attempts === 1) throw new Error("not ready"); + return { generation: "v1", version: "1.18.32" }; +}); +await assert.rejects(() => retriesFailures.get(), /not ready/); +assert.deepEqual(await retriesFailures.get(), { generation: "v1", version: "1.18.32" }); +assert.equal(attempts, 2, "failed probes are not cached"); diff --git a/src/local-agent-opencode-version.ts b/src/local-agent-opencode-version.ts new file mode 100644 index 000000000..62150e7b5 --- /dev/null +++ b/src/local-agent-opencode-version.ts @@ -0,0 +1,155 @@ +import { createRequire } from "node:module"; +import { + AgentProviderProtocolError, + AgentProviderUnavailableError, +} from "./local-agent-errors.js"; + +const OPENCODE_VERSION_PROBE_TIMEOUT_MS = 4_000; +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; + +export type OpenCodeGeneration = "v1" | "v2"; + +export interface ProbedOpenCodeRuntime { + generation: OpenCodeGeneration; + version: string; +} + +export type OpenCodeRuntimeProbeFunction = () => Promise; + +const OPENCODE_V2_SESSION_PREFIX = "opencode:v2:"; + +export class OpenCodeRuntimeProbe { + private lastSuccessfulProbe?: ProbedOpenCodeRuntime; + + constructor(private readonly probe: OpenCodeRuntimeProbeFunction) {} + + async get(): Promise { + if (this.lastSuccessfulProbe) return this.lastSuccessfulProbe; + return this.refresh(); + } + + async refresh(): Promise { + const result = await this.probe(); + this.lastSuccessfulProbe = result; + return result; + } + + lastSuccess(): ProbedOpenCodeRuntime | undefined { + return this.lastSuccessfulProbe; + } +} + +export function createOpenCodeRuntimeProbe( + env: NodeJS.ProcessEnv = process.env, +): OpenCodeRuntimeProbe { + return new OpenCodeRuntimeProbe(() => probeOpenCodeBinary(env)); +} + +export function classifyOpenCodeCliVersion(output: string): ProbedOpenCodeRuntime | undefined { + const match = output.trim().match(/(?:^|\s)v?((\d+)\.\d+\.\d+(?:[-+][^\s]+)?)(?:$|\s)/); + if (!match?.[1] || !match[2]) return undefined; + const major = Number(match[2]); + const version = match[1]; + return { + generation: major >= 2 ? "v2" : "v1", + version, + }; +} + +export function encodeOpenCodeV2SessionId(nativeSessionId: string): string { + return `${OPENCODE_V2_SESSION_PREFIX}${nativeSessionId}`; +} + +export function requireOpenCodeV2NativeSessionId(providerSessionId: string): string { + if (providerSessionId.startsWith(OPENCODE_V2_SESSION_PREFIX)) { + const nativeSessionId = providerSessionId.slice(OPENCODE_V2_SESSION_PREFIX.length); + if (nativeSessionId) return nativeSessionId; + } + throw incompatibleOpenCodeSession("v2", providerSessionId); +} + +export function requireOpenCodeV1NativeSessionId(providerSessionId: string): string { + if (!providerSessionId.startsWith(OPENCODE_V2_SESSION_PREFIX)) return providerSessionId; + throw incompatibleOpenCodeSession("v1", providerSessionId); +} + +function incompatibleOpenCodeSession( + generation: OpenCodeGeneration, + providerSessionId: string, +): AgentProviderProtocolError { + return new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "resume_session", + retryable: false, + message: `OpenCode ${generation} cannot resume session ${providerSessionId}; the session belongs to a different OpenCode protocol generation. Start a new agent after changing OpenCode major versions.`, + }); +} + +export async function probeOpenCodeBinary( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const output = await runOpenCodeVersion(env); + const result = classifyOpenCodeCliVersion(output); + if (result) return result; + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "probe_version", + retryable: false, + message: "Unable to determine the installed OpenCode version from `opencode --version`.", + }); +} + +function runOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + const child = spawn("opencode", ["--version"], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGTERM"); + reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + message: `OpenCode version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT_MS}ms.`, + })); + }, OPENCODE_VERSION_PROBE_TIMEOUT_MS); + timer.unref(); + child.stdout?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); + child.stderr?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); + child.once("error", (cause) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + cause, + message: "OpenCode executable is unavailable.", + })); + }); + child.once("exit", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) resolve(output); + else reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + message: `OpenCode version probe exited with code ${code}${output.trim() ? `: ${output.trim()}` : "."}`, + })); + }); + }); +} From 11aff467832ba81822cd90986d5523acc062fb54 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:27:18 +0530 Subject: [PATCH 07/26] feat(opencode): support v2 runtime --- src/local-agent-adapters.ts | 2 +- src/local-agent-availability.ts | 2 +- src/local-agent-opencode-v2.test.ts | 218 ++++++++++++++++++ src/local-agent-opencode-v2.ts | 342 ++++++++++++++++++++++++++++ src/local-agent-opencode.test.ts | 56 +++-- src/local-agent-opencode.ts | 169 ++++---------- 6 files changed, 638 insertions(+), 151 deletions(-) create mode 100644 src/local-agent-opencode-v2.test.ts create mode 100644 src/local-agent-opencode-v2.ts diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 86076fbb0..2059cbc9d 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -76,7 +76,7 @@ function createDriver( case "claude": return new ClaudeLocalAgentDriver(options.claudeQueryFactory, env); case "opencode": - return new OpencodeLocalAgentDriver(options.opencodeFactory, env); + return new OpencodeLocalAgentDriver({ factory: options.opencodeFactory, env }); case "pi": return new PiLocalAgentDriver(options.piSessionFactory, envOverrides); case "cursor": diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index e6916c007..103abd68a 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -50,7 +50,7 @@ function checkLocalAgentProviderAvailability( ? commandAvailability(providerInstanceId, providerEnv.CLAUDE_COMMAND, providerEnv) : packageAvailability(providerInstanceId, "@anthropic-ai/claude-agent-sdk"); case "opencode": - return packageAvailability(providerInstanceId, "@opencode-ai/sdk/v2"); + return commandAvailability(providerInstanceId, "opencode", providerEnv); case "pi": return packageAvailability(providerInstanceId, "@earendil-works/pi-coding-agent"); case "cursor": diff --git a/src/local-agent-opencode-v2.test.ts b/src/local-agent-opencode-v2.test.ts new file mode 100644 index 000000000..636ecce74 --- /dev/null +++ b/src/local-agent-opencode-v2.test.ts @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { + extractOpenCodeV2FinalResponse, + OpencodeV2Runtime, + opencodeV2Permissions, + opencodeV2ServerEnvironment, + parseOpenCodeV2Model, + type OpencodeV2ClientLike, +} from "./local-agent-opencode-v2.js"; +import type { SessionMessageAssistant } from "@opencode/client"; +import { OpencodeLocalAgentDriver } from "./local-agent-opencode.js"; +import { OpenCodeRuntimeProbe } from "./local-agent-opencode-version.js"; +import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; + +const createInputs: unknown[] = []; +const updateInputs: unknown[] = []; +const switchModelInputs: unknown[] = []; +const promptInputs: unknown[] = []; +const waitInputs: unknown[] = []; +const messageInputs: unknown[] = []; +let closeCalls = 0; + +const assistant: SessionMessageAssistant = { + id: "msg_assistant", + time: { created: 1, completed: 2 }, + type: "assistant", + agent: "build", + model: { id: "sonnet", providerID: "anthropic", variant: "high" }, + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "hello" }, + { type: "text", text: " world" }, + ], + finish: "stop", +}; + +const client = { + server: { + async info() { return { version: "2.0.20", pid: 1, urls: [], paths: { tmp: "/tmp" } }; }, + }, + session: { + async create(input: unknown) { + createInputs.push(input); + return { + id: "ses_v2", + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: "/tmp/project" }, + }; + }, + async update(input: unknown) { updateInputs.push(input); }, + async get() { + return { + id: "ses_v2", + projectID: "project", + model: { id: "sonnet", providerID: "anthropic" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: "/tmp/project" }, + }; + }, + async switchModel(input: unknown) { switchModelInputs.push(input); }, + async prompt(input: unknown) { + promptInputs.push(input); + return { id: "inbox", sessionID: "ses_v2", time: { created: 1 }, type: "user", payload: { text: "x" }, delivery: "queue" }; + }, + async wait(input: unknown) { waitInputs.push(input); }, + }, + message: { + async list(input: unknown) { + messageInputs.push(input); + return { data: [assistant], cursor: { previous: null, next: null } }; + }, + }, + model: { + async default() { + return { + location: { directory: "/tmp/project" }, + data: { + id: "default-model", + modelID: "default-model", + providerID: "openrouter", + name: "Default", + capabilities: {}, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 1, output: 1 }, + }, + }; + }, + }, +} as unknown as OpencodeV2ClientLike; + +const runtime = new OpencodeV2Runtime(client, { + close: () => { closeCalls += 1; }, +}); +let sessionId: string | undefined; +const first = await runtime.run({ + prompt: "first", + workspaceRoot: "/tmp/project", + writeMode: "read_only", + model: "anthropic/sonnet", + effort: "high", +}, { + onSessionId: (id) => { sessionId = id; }, +}); +assert.equal(first.isOk(), true); +if (first.isErr()) throw first.error; +assert.equal(sessionId, "opencode:v2:ses_v2"); +assert.equal(first.value.providerSessionId, "opencode:v2:ses_v2"); +assert.equal(first.value.finalResponse, "hello world"); +assert.deepEqual(createInputs[0], { + location: { directory: "/tmp/project" }, + permissions: opencodeV2Permissions("read_only"), + model: { providerID: "anthropic", id: "sonnet", variant: "high" }, +}); +assert.equal(updateInputs.length, 0, "new sessions are configured at creation"); +assert.equal(switchModelInputs.length, 0, "new sessions select their model at creation"); +assert.deepEqual(promptInputs[0], { sessionID: "ses_v2", text: "first" }); +assert.deepEqual(waitInputs[0], { sessionID: "ses_v2" }); +assert.deepEqual(messageInputs[0], { sessionID: "ses_v2", limit: 1, order: "desc", type: "assistant" }); + +const continued = await runtime.run({ + prompt: "continued", + workspaceRoot: "/tmp/project", + providerSessionId: "opencode:v2:ses_v2", + writeMode: "allowed", + effort: "low", +}); +assert.equal(continued.isOk(), true); +if (continued.isErr()) throw continued.error; +assert.deepEqual(updateInputs[0], { + sessionID: "ses_v2", + permissions: opencodeV2Permissions("allowed"), +}); +assert.deepEqual(switchModelInputs[0], { + sessionID: "ses_v2", + model: { id: "sonnet", providerID: "anthropic", variant: "low" }, +}); + +const wrongGeneration = await runtime.run({ + prompt: "legacy continuation", + workspaceRoot: "/tmp/project", + providerSessionId: "ses_v1_legacy", +}); +assert.equal(wrongGeneration.isErr(), true); +if (wrongGeneration.isErr()) { + assert.equal(wrongGeneration.error.code, "PROVIDER_PROTOCOL_ERROR"); + assert.match(wrongGeneration.error.message, /different OpenCode protocol generation/); +} + +assert.deepEqual(parseOpenCodeV2Model("anthropic/sonnet", "high"), { + providerID: "anthropic", + id: "sonnet", + variant: "high", +}); +assert.deepEqual(parseOpenCodeV2Model("custom-model"), { + providerID: "opencode", + id: "custom-model", +}); +assert.equal(extractOpenCodeV2FinalResponse(assistant), "hello world"); + +assert.deepEqual(opencodeV2Permissions("full_access"), [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + { action: "question", resource: "*", effect: "deny" }, +]); +assert.deepEqual(opencodeV2Permissions("read_only").slice(-3), [ + { action: "external_directory", resource: "*", effect: "deny" }, + { action: "edit", resource: "*", effect: "deny" }, + { action: "shell", resource: "*", effect: "deny" }, +]); +assert.deepEqual(opencodeV2ServerEnvironment({ + PATH: "/bin", + OPENCODE_PASSWORD: "ambient-v2", + OPENCODE_SERVER_PASSWORD: "ambient-v1", +}, "generated"), { + PATH: "/bin", + OPENCODE_PASSWORD: "generated", +}); + +await runtime.close(); +await runtime.close(); +assert.equal(closeCalls, 1); + +let v1FactoryCalls = 0; +let v2FactoryCalls = 0; +const routedDriver = new OpencodeLocalAgentDriver({ + factory: async () => { + v1FactoryCalls += 1; + throw new Error("v1 factory must not be selected for OpenCode 2"); + }, + v2Factory: async () => { + v2FactoryCalls += 1; + return { client, server: { close: () => undefined } }; + }, + runtimeProbe: new OpenCodeRuntimeProbe(async () => ({ generation: "v2", version: "2.0.20" })), +}); +const routedPool = new LocalAgentRuntimePool(); +const routed = await routedPool.run(routedDriver, { + agentId: "agt_v2", + providerInstanceId: "opencode", + provider: "opencode", + workspaceRoot: "/tmp/project", +}, { + prompt: "route through v2", + workspaceRoot: "/tmp/project", +}); +assert.equal(routed.isOk(), true); +assert.equal(v1FactoryCalls, 0); +assert.equal(v2FactoryCalls, 1); +await routedPool.close(); diff --git a/src/local-agent-opencode-v2.ts b/src/local-agent-opencode-v2.ts new file mode 100644 index 000000000..5c0fc1ff3 --- /dev/null +++ b/src/local-agent-opencode-v2.ts @@ -0,0 +1,342 @@ +import { randomBytes } from "node:crypto"; +import { + OpenCode, + type ModelRef, + type OpenCodeClient, + type PermissionRule, + type SessionMessageAssistant, +} from "@opencode/client"; +import { + AgentProviderExecutionError, + AgentProviderProtocolError, + AgentProviderUnavailableError, + captureAgentProviderResult, +} from "./local-agent-errors.js"; +import type { + LocalAgentRunCallbacks, + LocalAgentRunInput, + LocalAgentRunResult, + LocalAgentRuntime, + LocalAgentRuntimeContext, +} from "./local-agent-runtime.js"; +import { startOpencodeServer, type OpencodeServerLike } from "./local-agent-opencode-server.js"; +import { + encodeOpenCodeV2SessionId, + requireOpenCodeV2NativeSessionId, +} from "./local-agent-opencode-version.js"; + +const OPENCODE_PROMPT_TIMEOUT_MS = 5 * 60_000; + +export type OpencodeV2ClientLike = Pick; + +export type OpencodeV2Factory = ( + context?: LocalAgentRuntimeContext, + env?: NodeJS.ProcessEnv, +) => Promise<{ + client: OpencodeV2ClientLike; + server: OpencodeServerLike; +}>; + +export class OpencodeV2Runtime implements LocalAgentRuntime { + readonly provider = "opencode" as const; + private alive = true; + private closed = false; + private readonly promptControllers = new Set(); + + constructor( + private readonly client: OpencodeV2ClientLike, + private readonly server: OpencodeServerLike, + private readonly promptTimeoutMs = OPENCODE_PROMPT_TIMEOUT_MS, + ) {} + + async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks) { + return captureAgentProviderResult({ + provider: this.provider, + operation: "run", + run: async (): Promise => { + if (!this.alive) { + throw new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: this.provider, + operation: "run", + retryable: true, + message: "OpenCode runtime is not running.", + }); + } + try { + await this.client.server.info(); + const continuing = input.providerSessionId !== undefined; + const nativeSessionId = input.providerSessionId + ? requireOpenCodeV2NativeSessionId(input.providerSessionId) + : await this.createSession(input); + const providerSessionId = encodeOpenCodeV2SessionId(nativeSessionId); + await callbacks?.onSessionId?.(providerSessionId); + if (continuing) await this.configureSession(nativeSessionId, input); + const messages = await this.prompt(nativeSessionId, input); + const assistant = requireLatestAssistant(messages.data); + assertOpenCodeV2AssistantSucceeded(assistant); + return { + provider: this.provider, + providerSessionId, + finalResponse: requireOpenCodeV2FinalResponse(assistant), + items: messages.data, + }; + } catch (error) { + if (isOpenCodeTransportFailure(error)) { + this.alive = false; + throw new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: this.provider, + operation: "run", + retryable: true, + cause: error, + message: "OpenCode provider is unavailable.", + }); + } + throw error; + } + }, + }); + } + + async releaseSession(_providerSessionId: string): Promise { + // OpenCode keeps durable sessions independently of this process. + } + + isAlive(): boolean { + return this.alive && !this.closed; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.alive = false; + for (const controller of this.promptControllers) controller.abort(); + this.promptControllers.clear(); + this.server.close(); + } + + private async createSession(input: LocalAgentRunInput): Promise { + const model = await resolveOpenCodeV2Model(this.client, input); + const session = await this.client.session.create({ + location: { directory: input.workspaceRoot }, + permissions: opencodeV2Permissions(input.writeMode), + ...(model ? { model } : {}), + }); + if (!session.id) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "create_session", + retryable: false, + message: "OpenCode did not return a session id.", + }); + } + return session.id; + } + + private async configureSession(sessionId: string, input: LocalAgentRunInput): Promise { + await this.client.session.update({ + sessionID: sessionId, + permissions: opencodeV2Permissions(input.writeMode), + }); + if (!input.model && !input.effort) return; + const current = await this.client.session.get({ sessionID: sessionId }); + const model = await resolveOpenCodeV2Model(this.client, input, current.model); + if (!model) return; + await this.client.session.switchModel({ sessionID: sessionId, model }); + } + + private async prompt(sessionId: string, input: LocalAgentRunInput) { + const controller = new AbortController(); + this.promptControllers.add(controller); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, this.promptTimeoutMs); + try { + await this.client.session.prompt({ + sessionID: sessionId, + text: input.prompt, + }, { signal: controller.signal }); + await this.client.session.wait({ sessionID: sessionId }, { signal: controller.signal }); + return await this.client.message.list({ + sessionID: sessionId, + limit: 1, + order: "desc", + type: "assistant", + }, { signal: controller.signal }); + } catch (error) { + if (!timedOut) throw error; + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "prompt", + retryable: true, + cause: error, + message: "OpenCode did not finish the prompt before the provider timeout.", + }); + } finally { + clearTimeout(timer); + this.promptControllers.delete(controller); + } + } +} + +export async function defaultOpencodeV2Factory( + _context?: LocalAgentRuntimeContext, + env: NodeJS.ProcessEnv = process.env, +): Promise<{ client: OpencodeV2ClientLike; server: OpencodeServerLike }> { + const password = randomBytes(32).toString("base64url"); + const server = await startOpencodeServer(opencodeV2ServerEnvironment(env, password)); + const authorization = `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; + return { + client: OpenCode.make({ + baseUrl: server.url, + headers: { authorization }, + }), + server, + }; +} + +export function opencodeV2ServerEnvironment( + env: NodeJS.ProcessEnv, + password: string, +): NodeJS.ProcessEnv { + const { + OPENCODE_PASSWORD: _opencodePassword, + OPENCODE_SERVER_PASSWORD: _legacyPassword, + ...rest + } = env; + return { ...rest, OPENCODE_PASSWORD: password }; +} + +export function opencodeV2Permissions( + writeMode: LocalAgentRunInput["writeMode"], +): PermissionRule[] { + const rules: PermissionRule[] = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + { action: "question", resource: "*", effect: "deny" }, + ]; + if (writeMode !== "full_access") { + rules.push({ action: "external_directory", resource: "*", effect: "deny" }); + } + if (writeMode === "read_only") { + rules.push( + { action: "edit", resource: "*", effect: "deny" }, + { action: "shell", resource: "*", effect: "deny" }, + ); + } + return rules; +} + +async function resolveOpenCodeV2Model( + client: OpencodeV2ClientLike, + input: LocalAgentRunInput, + current?: ModelRef, +): Promise { + if (input.model) return parseOpenCodeV2Model(input.model, input.effort); + if (!input.effort) return undefined; + if (current) return { ...current, variant: input.effort }; + const fallback = await client.model.default({ + location: { directory: input.workspaceRoot }, + }); + if (!fallback.data) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "select_model", + retryable: false, + message: "OpenCode has no default model to apply the requested effort variant to.", + }); + } + return { + id: fallback.data.id, + providerID: fallback.data.providerID, + variant: input.effort, + }; +} + +export function parseOpenCodeV2Model(model: string, effort?: string): ModelRef { + const separator = model.indexOf("/"); + return { + providerID: separator === -1 ? "opencode" : model.slice(0, separator), + id: separator === -1 ? model : model.slice(separator + 1), + ...(effort ? { variant: effort } : {}), + }; +} + +export function extractOpenCodeV2FinalResponse(message: SessionMessageAssistant): string { + return message.content + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join("") + .trim(); +} + +function requireOpenCodeV2FinalResponse(message: SessionMessageAssistant): string { + const response = extractOpenCodeV2FinalResponse(message); + if (!response) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "run", + retryable: false, + message: "OpenCode did not return a final assistant response.", + }); + } + return response; +} + +function requireLatestAssistant(messages: unknown[]): SessionMessageAssistant { + const assistant = messages.find((message) => ( + message !== null + && typeof message === "object" + && "type" in message + && message.type === "assistant" + )); + if (!assistant) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "run", + retryable: false, + message: "OpenCode did not return an assistant message after the prompt completed.", + }); + } + return assistant as SessionMessageAssistant; +} + +function assertOpenCodeV2AssistantSucceeded(message: SessionMessageAssistant): void { + if (!message.error) return; + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "opencode", + operation: "prompt", + retryable: message.error.status === 429 || (message.error.status ?? 0) >= 500, + cause: message.error, + message: message.error.message || `OpenCode returned ${message.error.type}.`, + }); +} + +function isOpenCodeTransportFailure(error: unknown): boolean { + const code = transportErrorCode(error); + return code === "ECONNREFUSED" + || code === "ECONNRESET" + || code === "EPIPE" + || code === "ENETDOWN" + || code === "ENETUNREACH" + || code === "ETIMEDOUT"; +} + +function transportErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const code = (error as NodeJS.ErrnoException).code; + if (typeof code === "string") return code; + const cause = (error as Error & { cause?: unknown }).cause; + return cause && typeof cause === "object" && typeof (cause as NodeJS.ErrnoException).code === "string" + ? (cause as NodeJS.ErrnoException).code + : undefined; +} diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 6b12a1f76..9bc2d271d 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -11,8 +11,13 @@ import { type OpencodeClientLike, type OpencodeFactory, } from "./local-agent-opencode.js"; +import { OpenCodeRuntimeProbe } from "./local-agent-opencode-version.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; +function v1Probe(): OpenCodeRuntimeProbe { + return new OpenCodeRuntimeProbe(async () => ({ generation: "v1", version: "1.17.13" })); +} + let sessionNumber = 0; const createInputs: unknown[] = []; const promptInputs: unknown[] = []; @@ -53,7 +58,11 @@ const factory: OpencodeFactory = async (_context, env) => { server: { close: () => { closeCalls += 1; } }, }; }; -const driver = new OpencodeLocalAgentDriver(factory, { HARNESS_ENV: "opencode" }); +const driver = new OpencodeLocalAgentDriver({ + factory, + env: { HARNESS_ENV: "opencode" }, + runtimeProbe: v1Probe(), +}); const pool = new LocalAgentRuntimePool(); const first = await pool.run(driver, { @@ -131,16 +140,19 @@ if (process.platform !== "win32") { "", ].join("\n")); await chmod(command, 0o700); - const envDriver = new OpencodeLocalAgentDriver(undefined, { - PATH: commandRoot, - HARNESS_ENV: "opencode-child", - MARKER: marker, - ARGS_MARKER: argsMarker, - COLLISION_MARKER: collisionMarker, - HOLDER_READY: holderReady, - HOLDER_PROCESS: holderProcess, - HOLDER_LAUNCHER: holderLauncher, - NODE_EXECUTABLE: process.execPath, + const envDriver = new OpencodeLocalAgentDriver({ + env: { + PATH: commandRoot, + HARNESS_ENV: "opencode-child", + MARKER: marker, + ARGS_MARKER: argsMarker, + COLLISION_MARKER: collisionMarker, + HOLDER_READY: holderReady, + HOLDER_PROCESS: holderProcess, + HOLDER_LAUNCHER: holderLauncher, + NODE_EXECUTABLE: process.execPath, + }, + runtimeProbe: v1Probe(), }); const created = await envDriver.createRuntime({ agentId: "agt_env", @@ -281,10 +293,13 @@ const applicationErrorClient = { }, } as unknown as OpencodeClientLike; const applicationErrorPool = new LocalAgentRuntimePool(); -const applicationErrorDriver = new OpencodeLocalAgentDriver(async () => ({ - client: applicationErrorClient, - server: { close: () => undefined }, -})); +const applicationErrorDriver = new OpencodeLocalAgentDriver({ + factory: async () => ({ + client: applicationErrorClient, + server: { close: () => undefined }, + }), + runtimeProbe: v1Probe(), +}); const applicationFailure = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", providerInstanceId: "opencode", @@ -310,10 +325,13 @@ assert.equal(recoveredApplicationTurn.value.finalResponse, "ok"); await applicationErrorPool.close(); let recoveringFactoryCalls = 0; -const recoveringDriver = new OpencodeLocalAgentDriver(async () => { - recoveringFactoryCalls += 1; - healthAvailable = true; - return { client, server: { close: () => undefined } }; +const recoveringDriver = new OpencodeLocalAgentDriver({ + factory: async () => { + recoveringFactoryCalls += 1; + healthAvailable = true; + return { client, server: { close: () => undefined } }; + }, + runtimeProbe: v1Probe(), }); const recoveringPool = new LocalAgentRuntimePool(); await recoveringPool.run(recoveringDriver, { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 3a7a3eba2..5f3f5fdd1 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -1,5 +1,3 @@ -import { createRequire } from "node:module"; -import { createServer as createNetServer } from "node:net"; import type { OpencodeClient, PermissionConfig, @@ -18,14 +16,19 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import { terminateProcessTree } from "./process-platform.js"; +import { startOpencodeServer, type OpencodeServerLike } from "./local-agent-opencode-server.js"; +import { + defaultOpencodeV2Factory, + OpencodeV2Runtime, + type OpencodeV2Factory, +} from "./local-agent-opencode-v2.js"; +import { + createOpenCodeRuntimeProbe, + requireOpenCodeV1NativeSessionId, + type OpenCodeRuntimeProbe, +} from "./local-agent-opencode-version.js"; -const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; -const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; -const OPENCODE_SERVER_START_ATTEMPTS = 3; const OPENCODE_PROMPT_TIMEOUT_MS = 5 * 60_000; -const require = createRequire(import.meta.url); -const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; interface OpencodeModelRef { providerID: string; @@ -34,10 +37,6 @@ interface OpencodeModelRef { export type OpencodeClientLike = Pick; -export interface OpencodeServerLike { - close(): void; -} - export type OpencodeFactory = ( context?: LocalAgentRuntimeContext, env?: NodeJS.ProcessEnv, @@ -74,7 +73,9 @@ export class OpencodeRuntime implements LocalAgentRuntime { } try { await assertOpencodeHealthy(this.client); - const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input); + const sessionId = input.providerSessionId + ? requireOpenCodeV1NativeSessionId(input.providerSessionId) + : await createOpencodeSession(this.client, input); await callbacks?.onSessionId?.(sessionId); const promptResult = await this.prompt(sessionId, input); assertOpenCodePromptSucceeded(promptResult); @@ -151,11 +152,22 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { readonly provider = "opencode" as const; readonly providerInstanceId = "opencode"; readonly idleTimeoutMs = 5 * 60_000; - - constructor( - private readonly factory: OpencodeFactory = defaultOpencodeFactory, - private readonly env: NodeJS.ProcessEnv = process.env, - ) {} + private readonly factory: OpencodeFactory; + private readonly v2Factory: OpencodeV2Factory; + private readonly env: NodeJS.ProcessEnv; + private readonly runtimeProbe: OpenCodeRuntimeProbe; + + constructor(options: { + factory?: OpencodeFactory; + v2Factory?: OpencodeV2Factory; + env?: NodeJS.ProcessEnv; + runtimeProbe?: OpenCodeRuntimeProbe; + } = {}) { + this.factory = options.factory ?? defaultOpencodeFactory; + this.v2Factory = options.v2Factory ?? defaultOpencodeV2Factory; + this.env = options.env ?? process.env; + this.runtimeProbe = options.runtimeProbe ?? createOpenCodeRuntimeProbe(this.env); + } runtimeKey(_context: LocalAgentRuntimeContext): string { return "opencode:default"; @@ -167,6 +179,11 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { agentId: context.agentId, operation: "create_runtime", run: async (): Promise => { + const runtime = await this.runtimeProbe.get(); + if (runtime.generation === "v2") { + const { client, server } = await this.v2Factory(context, this.env); + return new OpencodeV2Runtime(client, server); + } const { client, server } = await this.factory(context, this.env); return new OpencodeRuntime(client, server); }, @@ -186,124 +203,16 @@ async function defaultOpencodeFactory( devspace_full_access: opencodeAgentConfig("full_access"), }, }; - const server = await startOpencodeServer(env, config); + const server = await startOpencodeServer({ + ...env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(config), + }); return { client: createOpencodeClient({ baseUrl: server.url }), server, }; } -async function startOpencodeServer( - env: NodeJS.ProcessEnv, - config: Record, -): Promise { - for (let attempt = 1; attempt <= OPENCODE_SERVER_START_ATTEMPTS; attempt += 1) { - const port = await allocateOpencodePort(); - try { - return await launchOpencodeServer(env, config, port); - } catch (error) { - if (attempt === OPENCODE_SERVER_START_ATTEMPTS || !await isOpencodePortInUse(port)) throw error; - } - } - throw new Error("OpenCode server failed to start."); -} - -async function launchOpencodeServer( - env: NodeJS.ProcessEnv, - config: Record, - port: number, -): Promise { - const detached = process.platform !== "win32"; - const child = spawn("opencode", [ - "serve", - `--hostname=${OPENCODE_SERVER_HOSTNAME}`, - `--port=${port}`, - ], { - detached, - env: { - ...env, - OPENCODE_CONFIG_CONTENT: JSON.stringify(config), - }, - stdio: ["ignore", "pipe", "pipe"], - }); - let closed = false; - const close = () => { - if (closed) return; - closed = true; - terminateProcessTree(child, "SIGTERM", detached); - }; - const url = await new Promise((resolve, reject) => { - let output = ""; - let ready = false; - const timer = setTimeout(() => { - if (ready) return; - close(); - reject(new Error(`Timeout waiting for OpenCode server after ${OPENCODE_SERVER_START_TIMEOUT_MS}ms`)); - }, OPENCODE_SERVER_START_TIMEOUT_MS); - timer.unref(); - child.stdout?.on("data", (chunk: Buffer | string) => { - if (ready) return; - output += chunk.toString(); - for (const line of output.split("\n")) { - if (!line.startsWith("opencode server listening")) continue; - const match = line.match(/on\s+(https?:\/\/[^\s]+)/); - if (!match?.[1]) continue; - ready = true; - clearTimeout(timer); - resolve(match[1]); - return; - } - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - if (!ready) output += chunk.toString(); - }); - child.once("error", (error) => { - if (ready) return; - clearTimeout(timer); - close(); - reject(error); - }); - child.once("exit", (code) => { - if (ready) return; - clearTimeout(timer); - close(); - reject(new Error(`OpenCode server exited with code ${code}${output.trim() ? `\n${output.trim()}` : ""}`)); - }); - }); - return { url, close }; -} - -async function allocateOpencodePort(): Promise { - const server = createNetServer(); - server.unref(); - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen({ host: OPENCODE_SERVER_HOSTNAME, port: 0, exclusive: true }, () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Failed to allocate an OpenCode server port.")); - return; - } - server.close((error) => error ? reject(error) : resolve(address.port)); - }); - }); -} - -async function isOpencodePortInUse(port: number): Promise { - const server = createNetServer(); - server.unref(); - return new Promise((resolve, reject) => { - server.once("error", (error: NodeJS.ErrnoException) => { - if (error.code === "EADDRINUSE") resolve(true); - else reject(error); - }); - server.listen({ host: OPENCODE_SERVER_HOSTNAME, port, exclusive: true }, () => { - server.close((error) => error ? reject(error) : resolve(false)); - }); - }); -} - export function opencodeAgentConfig(writeMode: LocalAgentRunInput["writeMode"]): { mode: "primary"; permission: PermissionConfig; From 33b0f329ee4299df0562c48e5d80f0b9d4e02e78 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:27:18 +0530 Subject: [PATCH 08/26] docs(opencode): document runtime version routing --- docs/agent-profile-schema.md | 15 +++++++++------ docs/configuration.md | 5 +++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 2c12319be..55fa5debd 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -82,17 +82,20 @@ to one of these built-in driver integrations: - `codex`: the host-installed `codex app-server` command - `claude`: Claude Code SDK -- `opencode`: OpenCode SDK +- `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`) -Codex is resolved from the user's environment rather than bundled with -DevSpace. Run `codex login` normally before using it; set `CODEX_COMMAND` when -the executable is not on the normal PATH. OpenCode, Cursor, and Copilot -runtimes are started and reused by the daemon internally, while Pi is embedded -through its Node SDK. +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 +`CODEX_COMMAND` when Codex is not on the normal PATH. OpenCode is discovered as +`opencode`, and DevSpace automatically routes OpenCode 1.x and 2.x to their +matching runtime implementations. OpenCode, Cursor, and Copilot runtimes are +started and reused by the daemon internally, while Pi is embedded through its +Node SDK. ### `model` diff --git a/docs/configuration.md b/docs/configuration.md index d7b3675f2..271f1b6fc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -193,6 +193,11 @@ environment, then overlays the provider's `env` without mutating the daemon's process environment. OpenCode receives that environment on its managed server process; embedded Pi scopes it to its provider requests and command execution. +The `opencode` driver runs `opencode --version` before creating its first +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 From be77f88933fb97ef2ddb78f536ebebff98f38a8c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:09:30 +0530 Subject: [PATCH 09/26] fix(opencode): probe the executable version on every runtime creation A cached probe could keep routing new runtimes to the previous protocol generation after OpenCode was upgraded while the daemon stayed up. Runtime creation already spawns a server, so one extra --version call is negligible. Also report spawn failures other than ENOENT with their cause. Co-Authored-By: Claude Sonnet 5.5 --- src/local-agent-opencode-v2.test.ts | 28 +++++++++++++++++++-- src/local-agent-opencode-version.test.ts | 20 --------------- src/local-agent-opencode-version.ts | 31 +++--------------------- src/local-agent-opencode.test.ts | 13 ++++------ src/local-agent-opencode.ts | 13 +++++----- 5 files changed, 41 insertions(+), 64 deletions(-) diff --git a/src/local-agent-opencode-v2.test.ts b/src/local-agent-opencode-v2.test.ts index 636ecce74..dae455653 100644 --- a/src/local-agent-opencode-v2.test.ts +++ b/src/local-agent-opencode-v2.test.ts @@ -9,7 +9,6 @@ import { } from "./local-agent-opencode-v2.js"; import type { SessionMessageAssistant } from "@opencode/client"; import { OpencodeLocalAgentDriver } from "./local-agent-opencode.js"; -import { OpenCodeRuntimeProbe } from "./local-agent-opencode-version.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; const createInputs: unknown[] = []; @@ -200,7 +199,7 @@ const routedDriver = new OpencodeLocalAgentDriver({ v2FactoryCalls += 1; return { client, server: { close: () => undefined } }; }, - runtimeProbe: new OpenCodeRuntimeProbe(async () => ({ generation: "v2", version: "2.0.20" })), + probeRuntime: async () => ({ generation: "v2", version: "2.0.20" }), }); const routedPool = new LocalAgentRuntimePool(); const routed = await routedPool.run(routedDriver, { @@ -216,3 +215,28 @@ assert.equal(routed.isOk(), true); assert.equal(v1FactoryCalls, 0); assert.equal(v2FactoryCalls, 1); await routedPool.close(); + +// A runtime created after OpenCode is upgraded must follow the installed executable, not a stale probe. +let installedGeneration: "v1" | "v2" = "v1"; +const calls = { v1: 0, v2: 0 }; +const upgradingDriver = new OpencodeLocalAgentDriver({ + factory: async () => { + calls.v1 += 1; + throw new Error("stop after routing"); + }, + v2Factory: async () => { + calls.v2 += 1; + throw new Error("stop after routing"); + }, + probeRuntime: async () => ({ generation: installedGeneration, version: "x.y.z" }), +}); +const upgradeContext = { + agentId: "agt_upgrade", + providerInstanceId: "opencode", + provider: "opencode", + workspaceRoot: "/tmp/project", +} as const; +await upgradingDriver.createRuntime(upgradeContext); +installedGeneration = "v2"; +await upgradingDriver.createRuntime(upgradeContext); +assert.deepEqual(calls, { v1: 1, v2: 1 }); diff --git a/src/local-agent-opencode-version.test.ts b/src/local-agent-opencode-version.test.ts index b8cf06035..8ef59810d 100644 --- a/src/local-agent-opencode-version.test.ts +++ b/src/local-agent-opencode-version.test.ts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import { classifyOpenCodeCliVersion, encodeOpenCodeV2SessionId, - OpenCodeRuntimeProbe, requireOpenCodeV1NativeSessionId, requireOpenCodeV2NativeSessionId, } from "./local-agent-opencode-version.js"; @@ -32,22 +31,3 @@ assert.throws( () => requireOpenCodeV1NativeSessionId("opencode:v2:ses_v2"), /different OpenCode protocol generation/, ); - -let successfulCalls = 0; -const cached = new OpenCodeRuntimeProbe(async () => { - successfulCalls += 1; - return { generation: "v2", version: "2.0.20" }; -}); -assert.deepEqual(await cached.get(), { generation: "v2", version: "2.0.20" }); -assert.deepEqual(await cached.get(), { generation: "v2", version: "2.0.20" }); -assert.equal(successfulCalls, 1, "successful probes are cached"); - -let attempts = 0; -const retriesFailures = new OpenCodeRuntimeProbe(async () => { - attempts += 1; - if (attempts === 1) throw new Error("not ready"); - return { generation: "v1", version: "1.18.32" }; -}); -await assert.rejects(() => retriesFailures.get(), /not ready/); -assert.deepEqual(await retriesFailures.get(), { generation: "v1", version: "1.18.32" }); -assert.equal(attempts, 2, "failed probes are not cached"); diff --git a/src/local-agent-opencode-version.ts b/src/local-agent-opencode-version.ts index 62150e7b5..98984102b 100644 --- a/src/local-agent-opencode-version.ts +++ b/src/local-agent-opencode-version.ts @@ -19,33 +19,6 @@ export type OpenCodeRuntimeProbeFunction = () => Promise; const OPENCODE_V2_SESSION_PREFIX = "opencode:v2:"; -export class OpenCodeRuntimeProbe { - private lastSuccessfulProbe?: ProbedOpenCodeRuntime; - - constructor(private readonly probe: OpenCodeRuntimeProbeFunction) {} - - async get(): Promise { - if (this.lastSuccessfulProbe) return this.lastSuccessfulProbe; - return this.refresh(); - } - - async refresh(): Promise { - const result = await this.probe(); - this.lastSuccessfulProbe = result; - return result; - } - - lastSuccess(): ProbedOpenCodeRuntime | undefined { - return this.lastSuccessfulProbe; - } -} - -export function createOpenCodeRuntimeProbe( - env: NodeJS.ProcessEnv = process.env, -): OpenCodeRuntimeProbe { - return new OpenCodeRuntimeProbe(() => probeOpenCodeBinary(env)); -} - export function classifyOpenCodeCliVersion(output: string): ProbedOpenCodeRuntime | undefined { const match = output.trim().match(/(?:^|\s)v?((\d+)\.\d+\.\d+(?:[-+][^\s]+)?)(?:$|\s)/); if (!match?.[1] || !match[2]) return undefined; @@ -135,7 +108,9 @@ function runOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { operation: "probe_version", retryable: true, cause, - message: "OpenCode executable is unavailable.", + message: (cause as NodeJS.ErrnoException).code === "ENOENT" + ? "OpenCode executable is unavailable." + : `OpenCode version probe failed to start: ${cause.message}`, })); }); child.once("exit", (code) => { diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 9bc2d271d..1d0e1915d 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -11,12 +11,9 @@ import { type OpencodeClientLike, type OpencodeFactory, } from "./local-agent-opencode.js"; -import { OpenCodeRuntimeProbe } from "./local-agent-opencode-version.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; -function v1Probe(): OpenCodeRuntimeProbe { - return new OpenCodeRuntimeProbe(async () => ({ generation: "v1", version: "1.17.13" })); -} +const probeV1 = async () => ({ generation: "v1", version: "1.17.13" }) as const; let sessionNumber = 0; const createInputs: unknown[] = []; @@ -61,7 +58,7 @@ const factory: OpencodeFactory = async (_context, env) => { const driver = new OpencodeLocalAgentDriver({ factory, env: { HARNESS_ENV: "opencode" }, - runtimeProbe: v1Probe(), + probeRuntime: probeV1, }); const pool = new LocalAgentRuntimePool(); @@ -152,7 +149,7 @@ if (process.platform !== "win32") { HOLDER_LAUNCHER: holderLauncher, NODE_EXECUTABLE: process.execPath, }, - runtimeProbe: v1Probe(), + probeRuntime: probeV1, }); const created = await envDriver.createRuntime({ agentId: "agt_env", @@ -298,7 +295,7 @@ const applicationErrorDriver = new OpencodeLocalAgentDriver({ client: applicationErrorClient, server: { close: () => undefined }, }), - runtimeProbe: v1Probe(), + probeRuntime: probeV1, }); const applicationFailure = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", @@ -331,7 +328,7 @@ const recoveringDriver = new OpencodeLocalAgentDriver({ healthAvailable = true; return { client, server: { close: () => undefined } }; }, - runtimeProbe: v1Probe(), + probeRuntime: probeV1, }); const recoveringPool = new LocalAgentRuntimePool(); await recoveringPool.run(recoveringDriver, { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 5f3f5fdd1..fe6f04e93 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -23,9 +23,9 @@ import { type OpencodeV2Factory, } from "./local-agent-opencode-v2.js"; import { - createOpenCodeRuntimeProbe, + probeOpenCodeBinary, requireOpenCodeV1NativeSessionId, - type OpenCodeRuntimeProbe, + type OpenCodeRuntimeProbeFunction, } from "./local-agent-opencode-version.js"; const OPENCODE_PROMPT_TIMEOUT_MS = 5 * 60_000; @@ -155,18 +155,18 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { private readonly factory: OpencodeFactory; private readonly v2Factory: OpencodeV2Factory; private readonly env: NodeJS.ProcessEnv; - private readonly runtimeProbe: OpenCodeRuntimeProbe; + private readonly probeRuntime: OpenCodeRuntimeProbeFunction; constructor(options: { factory?: OpencodeFactory; v2Factory?: OpencodeV2Factory; env?: NodeJS.ProcessEnv; - runtimeProbe?: OpenCodeRuntimeProbe; + probeRuntime?: OpenCodeRuntimeProbeFunction; } = {}) { this.factory = options.factory ?? defaultOpencodeFactory; this.v2Factory = options.v2Factory ?? defaultOpencodeV2Factory; this.env = options.env ?? process.env; - this.runtimeProbe = options.runtimeProbe ?? createOpenCodeRuntimeProbe(this.env); + this.probeRuntime = options.probeRuntime ?? (() => probeOpenCodeBinary(this.env)); } runtimeKey(_context: LocalAgentRuntimeContext): string { @@ -179,7 +179,8 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { agentId: context.agentId, operation: "create_runtime", run: async (): Promise => { - const runtime = await this.runtimeProbe.get(); + // Probe on every runtime creation so an upgraded executable is routed to the matching protocol generation. + const runtime = await this.probeRuntime(); if (runtime.generation === "v2") { const { client, server } = await this.v2Factory(context, this.env); return new OpencodeV2Runtime(client, server); From 28679a79ecfd761afc0c3e630aed5970cea62f05 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:36:46 +0530 Subject: [PATCH 10/26] fix(opencode): report a failed v2 turn instead of a missing assistant message OpenCode 2 records a turn that fails before any assistant output (for example an expired provider login) as a failed idle message. The assistant-only listing hid it and the run ended with a misleading protocol error. Read the failed idle message and surface its error. Co-Authored-By: Claude Sonnet 5.5 --- src/local-agent-opencode-v2.test.ts | 25 +++++++++++++++++++++++++ src/local-agent-opencode-v2.ts | 24 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/local-agent-opencode-v2.test.ts b/src/local-agent-opencode-v2.test.ts index dae455653..805d8d6ef 100644 --- a/src/local-agent-opencode-v2.test.ts +++ b/src/local-agent-opencode-v2.test.ts @@ -240,3 +240,28 @@ await upgradingDriver.createRuntime(upgradeContext); installedGeneration = "v2"; await upgradingDriver.createRuntime(upgradeContext); assert.deepEqual(calls, { v1: 1, v2: 1 }); + +// A turn that fails before any assistant output surfaces the provider's error, not a protocol error. +const failedIdle = { + id: "msg_idle", + time: { created: 3 }, + type: "idle", + outcome: "failed", + error: { type: "provider.auth", message: "xAI request failed (400): invalid_grant" }, +}; +const failedClient = { + ...client, + message: { + async list(input: { type?: string }) { + return { data: input.type === "assistant" ? [] : [failedIdle], cursor: { previous: null, next: null } }; + }, + }, +} as unknown as OpencodeV2ClientLike; +const failedRuntime = new OpencodeV2Runtime(failedClient, { close: () => undefined }); +const failedRun = await failedRuntime.run({ prompt: "auth fails", workspaceRoot: "/tmp/project" }); +assert.equal(failedRun.isErr(), true); +if (failedRun.isErr()) { + assert.equal(failedRun.error.code, "PROVIDER_EXECUTION_ERROR"); + assert.match(failedRun.error.message, /invalid_grant/); +} +await failedRuntime.close(); diff --git a/src/local-agent-opencode-v2.ts b/src/local-agent-opencode-v2.ts index 5c0fc1ff3..388572bd8 100644 --- a/src/local-agent-opencode-v2.ts +++ b/src/local-agent-opencode-v2.ts @@ -73,6 +73,7 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { await callbacks?.onSessionId?.(providerSessionId); if (continuing) await this.configureSession(nativeSessionId, input); const messages = await this.prompt(nativeSessionId, input); + if (messages.data.length === 0) await this.assertTurnNotFailed(nativeSessionId); const assistant = requireLatestAssistant(messages.data); assertOpenCodeV2AssistantSucceeded(assistant); return { @@ -116,6 +117,29 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { this.server.close(); } + /** + * A turn that fails before any assistant output (for example provider auth) + * is recorded as a failed `idle` message, which the assistant-only listing hides. + */ + private async assertTurnNotFailed(sessionId: string): Promise { + const recent = await this.client.message.list({ sessionID: sessionId, limit: 5, order: "desc" }); + const idle = recent.data.find((message) => ( + message !== null + && typeof message === "object" + && "type" in message + && message.type === "idle" + )) as { outcome?: string; error?: { type?: string; message?: string } } | undefined; + if (idle?.outcome !== "failed") return; + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "opencode", + operation: "prompt", + retryable: false, + cause: idle.error, + message: idle.error?.message || "OpenCode turn failed before producing a response.", + }); + } + private async createSession(input: LocalAgentRunInput): Promise { const model = await resolveOpenCodeV2Model(this.client, input); const session = await this.client.session.create({ From 3df63b07f610262b8b5422cdfc45dfffed9ca0e4 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:57:37 +0530 Subject: [PATCH 11/26] feat(subagents): add capabilities and turn cancellation --- src/local-agent-acp.test.ts | 4 ++ src/local-agent-acp.ts | 26 ++++++++++-- src/local-agent-cancellation.ts | 28 +++++++++++++ src/local-agent-claude.test.ts | 3 ++ src/local-agent-claude.ts | 16 ++++++++ src/local-agent-codex.ts | 27 ++++++++++++- src/local-agent-manager.test.ts | 36 +++++++++++++++++ src/local-agent-manager.ts | 59 +++++++++++++++++++++++++--- src/local-agent-opencode-v2.ts | 7 ++++ src/local-agent-opencode.ts | 17 ++++++++ src/local-agent-pi.test.ts | 2 + src/local-agent-pi.ts | 18 +++++++++ src/local-agent-provider-registry.ts | 2 + src/local-agent-runtime.test.ts | 18 +++++++++ src/local-agent-runtime.ts | 10 +++++ 15 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 src/local-agent-cancellation.ts diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index bb9998d15..2d6fa3fe5 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -16,6 +16,7 @@ const requests: Array<{ method: string; params?: unknown }> = []; const queues = new Map(); const connection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { requests.push({ method, params }); const input = params as { sessionId?: string } | undefined; @@ -203,6 +204,7 @@ const overlappingPrompt = new Promise((resolvePrompt) => { releaseOverlapp const promptEntered = new Promise((resolveEntered) => { markPromptEntered = resolveEntered; }); const overlapConnection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { const input = params as { sessionId?: string } | undefined; if (method === "session/new") { @@ -356,6 +358,7 @@ const grokQueues = new Map(); const grokCompletionRegistry = new GrokPromptCompletionRegistry(); const grokConnection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { grokRequests.push({ method, params }); const input = params as { sessionId?: string; _meta?: { promptId?: string } } | undefined; @@ -421,6 +424,7 @@ await grokRuntime.close(); const grokConfigurationConnection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string): Promise { if (method === "session/new") { return { diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 88ab8c0ec..3736a6c58 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -8,6 +8,7 @@ import { captureAgentProviderResult, isProgrammerDefect, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; import { terminateProcessTree } from "./process-platform.js"; import { DEVSPACE_VERSION } from "./version.js"; import { @@ -49,6 +50,7 @@ const ACP_COMMANDS: Record = { interface AcpConnectionLike { agent: { request(method: string, params?: unknown): Promise; + notify(method: string, params?: unknown): Promise; }; close(error?: unknown): void; closed: Promise; @@ -142,6 +144,9 @@ export class AcpRuntime implements LocalAgentRuntime { throw new TypeError(`${this.provider} ACP session ${sessionId} already has an active turn.`); } this.activeSessions.add(sessionId); + const removeAbort = bindLocalAgentAbort(input.signal, () => ( + this.connection.agent.notify("session/cancel", { sessionId }) + )); const queue = this.queues.get(sessionId) ?? { values: [] }; this.queues.set(sessionId, queue); const promptId = this.provider === "grok" ? this.nextPromptId() : undefined; @@ -166,9 +171,16 @@ export class AcpRuntime implements LocalAgentRuntime { prompt: [{ type: "text", text: input.prompt }], ...(promptId ? { _meta: { promptId, requestId: promptId } } : {}), }); - const response = completion - ? await Promise.race([standardResponse, completion]) - : await standardResponse; + let response: unknown; + try { + response = completion + ? await Promise.race([standardResponse, completion]) + : await standardResponse; + } catch (cause) { + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run", cause); + throw cause; + } + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); if (completion && isGrokPromptCompletion(response)) { await yieldToAcpQueue(); } else if (promptId) { @@ -193,6 +205,7 @@ export class AcpRuntime implements LocalAgentRuntime { items: updates, }; } finally { + removeAbort(); if (promptId) this.grokCompletionRegistry?.remove(sessionId, promptId); this.activeSessions.delete(sessionId); } @@ -416,6 +429,13 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { authority: "write_mode", idleTimeoutMs: 5 * 60_000, } as const; + readonly capabilities = { + sessions: { resume: true, close: true }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; private commandResolved = false; private resolvedCommand?: string; diff --git a/src/local-agent-cancellation.ts b/src/local-agent-cancellation.ts new file mode 100644 index 000000000..e8d47a9d7 --- /dev/null +++ b/src/local-agent-cancellation.ts @@ -0,0 +1,28 @@ +import { AgentProviderCancelledError } from "./local-agent-errors.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; + +export function localAgentCancelledError( + provider: LocalAgentDriverKind, + operation: string, + cause?: unknown, +): AgentProviderCancelledError { + return new AgentProviderCancelledError({ + code: "PROVIDER_CANCELLED", + provider, + operation, + retryable: false, + ...(cause === undefined ? {} : { cause }), + message: `${provider} agent turn was stopped.`, + }); +} + +export function bindLocalAgentAbort( + signal: AbortSignal | undefined, + interrupt: () => void | Promise, +): () => void { + if (!signal) return () => undefined; + const onAbort = () => { void Promise.resolve(interrupt()).catch(() => undefined); }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + return () => signal.removeEventListener("abort", onAbort); +} diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index bd19fbc6d..2a470b8c4 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -41,6 +41,8 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { this.closeCount += 1; } + async interrupt(): Promise {} + async setPermissionMode(mode: string): Promise { this.permissionModes.push(mode); } @@ -207,6 +209,7 @@ const brokenStreamQuery: ClaudeQueryLike = { }; }, close() {}, + async interrupt() {}, async setPermissionMode() {}, async applyFlagSettings() {}, }; diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index 65a85df1d..d7e46ad90 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -5,6 +5,7 @@ import { captureAgentProviderResult, isProgrammerDefect, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; import type { LocalAgentDriverKind } from "./local-agent-provider.js"; import type { LocalAgentDriver, @@ -28,6 +29,7 @@ const CLAUDE_WORKSPACE_ALLOWED_TOOLS = [ export interface ClaudeQueryLike extends AsyncIterable { close(): void; + interrupt(): Promise; setPermissionMode(mode: ClaudePermissionMode): Promise; applyFlagSettings(settings: Record): Promise; setModel?(model?: string): Promise; @@ -106,6 +108,8 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { message: "Claude runtime is not running.", }); } + const removeAbort = bindLocalAgentAbort(input.signal, () => this.query.interrupt()); + try { if (this.providerSessionId) await callbacks?.onSessionId?.(this.providerSessionId); const flagSettings = claudeAuthoritySettings(input.workspaceRoot, input.writeMode); if (input.effort) { @@ -129,6 +133,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { try { next = await this.iterator.next(); } catch (error) { + if (input.signal?.aborted) throw localAgentCancelledError("claude", "run", error); this.alive = false; if (isProgrammerDefect(error)) throw error; throw new AgentProviderUnavailableError({ @@ -161,6 +166,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { } } if (record?.type !== "result") continue; + if (input.signal?.aborted) throw localAgentCancelledError("claude", "run"); const resultError = claudeResultError(record); if (resultError) { @@ -190,6 +196,9 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { items, }; } + } finally { + removeAbort(); + } }, }); } @@ -219,6 +228,13 @@ export class ClaudeLocalAgentDriver implements LocalAgentDriver { authority: "full_access_boundary", idleTimeoutMs: 3 * 60_000, } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; constructor( private readonly factory: ClaudeQueryFactory = defaultClaudeQueryFactory, diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index e619a1635..134385bca 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -8,6 +8,7 @@ import { AgentProviderUnavailableError, captureAgentProviderResult, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { terminateProcessTree } from "./process-platform.js"; import { DEVSPACE_VERSION } from "./version.js"; @@ -147,7 +148,14 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { } await callbacks?.onSessionId?.(threadId); - const completed = await this.rpc.runTurn(threadId, turnParams(input, threadId)); + let completed: CodexTurnResult; + try { + completed = await this.rpc.runTurn(threadId, turnParams(input, threadId), input.signal); + } catch (cause) { + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run", cause); + throw cause; + } + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); const parsed = parseCompletedTurn(completed.event.params, completed.items); if (parsed.failure) { throw new AgentProviderExecutionError({ @@ -232,6 +240,13 @@ export class CodexLocalAgentDriver implements LocalAgentDriver { readonly provider = "codex" as const; readonly providerInstanceId = "codex"; readonly runtimePolicy = { scope: "instance", idleTimeoutMs: 5 * 60_000 } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; private commandResolved = false; private resolvedCommand?: ResolvedCodexCommand; @@ -354,7 +369,7 @@ class CodexAppServerRpc { this.write({ method, ...(params === undefined ? {} : { params }) }); } - async runTurn(threadId: string, params: unknown): Promise { + async runTurn(threadId: string, params: unknown, signal?: AbortSignal): Promise { if (this.fatalError) throw this.fatalError; if (this.turns.has(threadId)) throw new Error(`Codex thread ${threadId} already has an active turn.`); let resolveTurn!: (result: CodexTurnResult) => void; @@ -370,12 +385,20 @@ class CodexAppServerRpc { reject: rejectTurn, }; this.turns.set(threadId, turn); + const removeAbort = bindLocalAgentAbort(signal, async () => { + if (!turn.turnId) return; + await this.request("turn/interrupt", { threadId, turnId: turn.turnId }); + }); try { const response = await this.request("turn/start", params); turn.turnId = readString(asRecord(response)?.turn, "id"); + if (signal?.aborted && turn.turnId) { + await this.request("turn/interrupt", { threadId, turnId: turn.turnId }); + } if (turn.completed) return { event: turn.completed, items: turn.items }; return await completion; } finally { + removeAbort(); if (this.turns.get(threadId) === turn) this.turns.delete(threadId); } } diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index c6a2adb64..6c5cca458 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { LocalAgentManager } from "./local-agent-manager.js"; import { + AgentProviderCancelledError, AgentProviderExecutionError, type AgentProviderError, } from "./local-agent-errors.js"; @@ -16,6 +17,14 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; + +const TEST_CAPABILITIES = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: false }, +} as const; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { LocalAgentStore } from "./local-agent-store.js"; import type { SubagentsConfig } from "./local-agent-config.js"; @@ -76,6 +85,20 @@ class FakeRuntime implements LocalAgentRuntime { if (input.prompt.includes("hold")) { await new Promise((resolve) => { this.releaseHold = resolve; }); } + if (input.prompt.includes("cancel-me")) { + await new Promise((resolve) => { + const done = () => resolve(); + if (input.signal?.aborted) done(); + else input.signal?.addEventListener("abort", done, { once: true }); + }); + return Result.err(new AgentProviderCancelledError({ + code: "PROVIDER_CANCELLED", + provider: "codex", + operation: "run", + retryable: false, + message: "codex agent turn was stopped.", + })); + } return Result.ok({ provider: this.provider, providerSessionId: "thread_test", @@ -108,6 +131,7 @@ const driver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", runtimePolicy: { scope: "agent" }, + capabilities: TEST_CAPABILITIES, createRuntime: async (context) => { const runtime = new FakeRuntime(); runtimes.set(context.agentId, runtime); @@ -187,6 +211,18 @@ const unknown = await manager.start({ assert.equal(unknown.isErr(), true); if (unknown.isErr()) assert.equal(unknown.error.code, "UNKNOWN_TARGET"); +const cancellable = unwrap(await manager.start({ + target: "codex", + prompt: "cancel-me", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => getRecord(cancellable.id).status === "running"); +const stopped = unwrap(await manager.stop(cancellable.id, scope)); +assert.equal(stopped.status, "stopped"); +assert.equal(store.getLatestTurn(cancellable.id)?.status, "stopped"); +assert.equal(store.getLatestTurn(cancellable.id)?.errorCode, "PROVIDER_CANCELLED"); + const namedInstance = unwrap(await manager.start({ target: "codex-work", prompt: "inspect named instance", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 8bc8456ff..c6ccaaeff 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -2,6 +2,7 @@ import { resolve } from "node:path"; import { Result, type Result as BetterResult } from "better-result"; import { AgentConflictError, + AgentProviderCancelledError, AgentScopeError, AgentStoreError, AgentTargetError, @@ -73,6 +74,7 @@ export type AgentContinueError = AgentStartError; export type AgentLookupError = AgentTargetError | AgentScopeError | AgentStoreError; export type AgentListError = AgentScopeError | AgentStoreError; export type AgentWaitError = AgentLookupError; +export type AgentStopError = AgentLookupError | AgentConflictError; export type LocalAgentWaitResult = | { id: string; status: "running"; wait?: "timeout" } @@ -83,6 +85,7 @@ export type LocalAgentWaitResult = interface ActiveLocalAgentTurn { turnId: number; completion: Promise; + abortController: AbortController; } /** @@ -260,6 +263,38 @@ export class LocalAgentManager { return Result.ok(results); } + async stop( + agentId: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const record = this.get(agentId, scope); + if (record.isErr()) return record; + if (record.value.status === "stopped") return Result.ok(record.value); + const active = this.activeTurns.get(agentId); + if (!active) { + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "stop", + retryable: false, + message: `Agent ${agentId} does not have a running turn.`, + })); + } + const driver = this.drivers.get(record.value.providerInstanceId); + if (!driver?.capabilities.turns.interrupt) { + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "stop", + retryable: false, + message: `Agent ${agentId} provider does not support stopping an active turn.`, + })); + } + active.abortController.abort(); + await active.completion; + return this.get(agentId, scope); + } + async close(): Promise { if (this.closePromise) return this.closePromise; this.accepting = false; @@ -315,10 +350,22 @@ export class LocalAgentManager { if (begun.isErr()) return begun; // Defer invocation until after the tracking entry is visible. This keeps // cleanup correct even if runTurn later gains a synchronous completion path. + const abortController = new AbortController(); const turn = Promise.resolve().then(() => ( - this.runTurn(begun.value.agent, begun.value.turn.id, prompt, overrides, workspaceId) + this.runTurn( + begun.value.agent, + begun.value.turn.id, + prompt, + overrides, + workspaceId, + abortController.signal, + ) )); - this.activeTurns.set(record.id, { turnId: begun.value.turn.id, completion: turn }); + this.activeTurns.set(record.id, { + turnId: begun.value.turn.id, + completion: turn, + abortController, + }); void turn.catch(() => undefined); return Result.ok(begun.value.agent); } @@ -329,6 +376,7 @@ export class LocalAgentManager { prompt: string, overrides: RunOverrides, workspaceId?: string, + signal?: AbortSignal, ): Promise { const startedAt = Date.now(); this.log("info", "agent_run_started", { @@ -387,7 +435,7 @@ export class LocalAgentManager { if (updated.isErr()) throw updated.error; }, }; - const result = await this.pool.run(driver.value, context, input.value, callbacks); + const result = await this.pool.run(driver.value, context, { ...input.value, signal }, callbacks); if (result.isErr()) { this.persistRunError(record, turnId, result.error, startedAt); return; @@ -442,13 +490,14 @@ export class LocalAgentManager { error: LocalAgentError, startedAt: number, ): void { + const stopped = AgentProviderCancelledError.is(error); const persisted = this.store.finishTurnResult(record.id, turnId, { - status: "failed", + status: stopped ? "stopped" : "failed", error: error.message, errorCode: error.code, errorRetryable: error.retryable, }); - this.log("error", "agent_run_failed", { + this.log(stopped ? "info" : "error", stopped ? "agent_run_stopped" : "agent_run_failed", { providerInstanceId: record.providerInstanceId, driver: record.driver, agentId: record.id, diff --git a/src/local-agent-opencode-v2.ts b/src/local-agent-opencode-v2.ts index 388572bd8..8558b03cd 100644 --- a/src/local-agent-opencode-v2.ts +++ b/src/local-agent-opencode-v2.ts @@ -12,6 +12,7 @@ import { AgentProviderUnavailableError, captureAgentProviderResult, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; import type { LocalAgentRunCallbacks, LocalAgentRunInput, @@ -175,6 +176,10 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { const controller = new AbortController(); this.promptControllers.add(controller); let timedOut = false; + const removeAbort = bindLocalAgentAbort(input.signal, async () => { + await this.client.session.interrupt({ sessionID: sessionId }); + controller.abort(); + }); const timer = setTimeout(() => { timedOut = true; controller.abort(); @@ -192,6 +197,7 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { type: "assistant", }, { signal: controller.signal }); } catch (error) { + if (input.signal?.aborted) throw localAgentCancelledError("opencode", "prompt", error); if (!timedOut) throw error; throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -203,6 +209,7 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { }); } finally { clearTimeout(timer); + removeAbort(); this.promptControllers.delete(controller); } } diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index ce62b1db1..7ef0c1464 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -8,6 +8,7 @@ import { AgentProviderUnavailableError, captureAgentProviderResult, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -125,6 +126,13 @@ export class OpencodeRuntime implements LocalAgentRuntime { const controller = new AbortController(); this.promptControllers.add(controller); let timedOut = false; + const removeAbort = bindLocalAgentAbort(input.signal, async () => { + await this.client.session.abort({ + sessionID: sessionId, + directory: input.workspaceRoot, + }, { throwOnError: false }); + controller.abort(); + }); const timer = setTimeout(() => { timedOut = true; controller.abort(); @@ -132,6 +140,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { try { return await promptOpencodeSession(this.client, sessionId, input, controller.signal); } catch (error) { + if (input.signal?.aborted) throw localAgentCancelledError("opencode", "prompt", error); if (!timedOut) throw error; throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -143,6 +152,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { }); } finally { clearTimeout(timer); + removeAbort(); this.promptControllers.delete(controller); } } @@ -152,6 +162,13 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { readonly provider = "opencode" as const; readonly providerInstanceId = "opencode"; readonly runtimePolicy = { scope: "instance", idleTimeoutMs: 5 * 60_000 } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; private readonly factory: OpencodeFactory; private readonly v2Factory: OpencodeV2Factory; private readonly env: NodeJS.ProcessEnv; diff --git a/src/local-agent-pi.test.ts b/src/local-agent-pi.test.ts index 6c1471cbb..28b945eab 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -28,6 +28,8 @@ class FakePiSession implements PiSessionLike { for (const listener of this.listeners) listener({ type: "agent_end" } as AgentSessionEvent); } + async abort(): Promise {} + subscribe(listener: AgentSessionEventListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index 453168e7d..4d2a5fd0a 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -6,6 +6,7 @@ import { AgentProviderUnavailableError, captureAgentProviderResult, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -38,6 +39,7 @@ export type PiSessionLike = Pick< | "setModel" | "setThinkingLevel" | "dispose" + | "abort" >; export type PiSessionFactory = ( @@ -78,6 +80,8 @@ export class PiSessionRuntime implements LocalAgentRuntime { message: "Pi runtime is not running.", }); } + const removeAbort = bindLocalAgentAbort(input.signal, () => this.session.abort()); + try { await callbacks?.onSessionId?.(this.session.sessionId); await this.applyOverrides(input); this.events = []; @@ -85,6 +89,10 @@ export class PiSessionRuntime implements LocalAgentRuntime { this.collectingEvents = true; try { await this.session.prompt(input.prompt); + if (input.signal?.aborted) throw localAgentCancelledError("pi", "run"); + } catch (cause) { + if (input.signal?.aborted) throw localAgentCancelledError("pi", "run", cause); + throw cause; } finally { this.collectingEvents = false; } @@ -116,6 +124,9 @@ export class PiSessionRuntime implements LocalAgentRuntime { finalResponse, items: [...this.events, ...currentMessages], }; + } finally { + removeAbort(); + } }, }); } @@ -166,6 +177,13 @@ export class PiLocalAgentDriver implements LocalAgentDriver { readonly provider = "pi" as const; readonly providerInstanceId = "pi"; readonly runtimePolicy = { scope: "agent", idleTimeoutMs: 3 * 60_000 } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "client-boundary" }, + mcp: { supported: false }, + } as const; constructor( private readonly factory: PiSessionFactory = defaultPiSessionFactory, diff --git a/src/local-agent-provider-registry.ts b/src/local-agent-provider-registry.ts index de944d72f..60c70ffbb 100644 --- a/src/local-agent-provider-registry.ts +++ b/src/local-agent-provider-registry.ts @@ -51,6 +51,7 @@ export class LocalAgentProviderRegistry { class ProviderInstanceDriver implements LocalAgentDriver { readonly provider: LocalAgentDriverKind; readonly runtimePolicy: LocalAgentDriver["runtimePolicy"]; + readonly capabilities: LocalAgentDriver["capabilities"]; constructor( readonly providerInstanceId: LocalAgentProviderInstanceId, @@ -58,6 +59,7 @@ class ProviderInstanceDriver implements LocalAgentDriver { ) { this.provider = driver.provider; this.runtimePolicy = driver.runtimePolicy; + this.capabilities = driver.capabilities; } createRuntime(context: Parameters[0]) { diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index 1aaead71f..23d8393da 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -15,6 +15,14 @@ import type { LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; +const TEST_CAPABILITIES = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: false }, +} as const; + const context: LocalAgentRuntimeContext = { agentId: "agt_test", providerInstanceId: "codex", @@ -96,6 +104,7 @@ const driver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => { createCount += 1; await Promise.resolve(); @@ -136,6 +145,7 @@ const sessionDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(sessionRuntime), }; await sessionPool.run(sessionDriver, context, input); @@ -164,6 +174,7 @@ const shutdownReleaseDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(shutdownReleaseRuntime), }; await shutdownReleasePool.run(shutdownReleaseDriver, context, input); @@ -213,6 +224,7 @@ const cleanupDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(cleanupRuntime), }; const cleanupFailure = await cleanupPool.run(cleanupDriver, context, input); @@ -230,6 +242,7 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider providerInstanceId: "codex", provider: "codex", runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(attempts++ === 0 ? deadRuntime : replacementRuntime), }; @@ -255,6 +268,7 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider providerInstanceId: "codex", provider: "codex", runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(completedTurnRuntime), }; @@ -272,6 +286,7 @@ const raceDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: () => creating, }; const pendingRun = racePool.run(raceDriver, context, input); @@ -304,6 +319,7 @@ if (afterClose.isErr()) { providerInstanceId: "codex", provider: "codex", runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, async createRuntime() { createAttempts += 1; if (createAttempts === 1) { @@ -358,6 +374,7 @@ if (afterClose.isErr()) { providerInstanceId: "codex", provider: "codex", runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(releaseRuntime), }; @@ -384,6 +401,7 @@ if (afterClose.isErr()) { providerInstanceId, provider: "codex", runtimePolicy, + capabilities: TEST_CAPABILITIES, createRuntime: async () => { creates += 1; return Result.ok(new FakeRuntime()); diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index eb3335189..e73e3e7d6 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -16,6 +16,7 @@ export interface LocalAgentRunInput { effort?: string; modelOverrideRequested?: boolean; effortOverrideRequested?: boolean; + signal?: AbortSignal; } export interface LocalAgentRunResult { @@ -56,6 +57,14 @@ export interface LocalAgentRuntimePolicy { sessionIdleTimeoutMs?: number; } +export interface LocalAgentCapabilities { + sessions: { resume: boolean; close: boolean }; + turns: { interrupt: boolean }; + configuration: { modelOverride: boolean; effortOverride: boolean }; + permissions: { enforcement: "native" | "client-boundary" | "unsupported" }; + mcp: { supported: boolean }; +} + /** * A runtime is deliberately disposable. Nothing from this interface is * persisted; the provider session ID in LocalAgentStore is the continuation @@ -76,5 +85,6 @@ export interface LocalAgentDriver { readonly providerInstanceId: LocalAgentProviderInstanceId; readonly provider: LocalAgentDriverKind; readonly runtimePolicy: LocalAgentRuntimePolicy; + readonly capabilities: LocalAgentCapabilities; createRuntime(context: LocalAgentRuntimeContext): Promise>; } From b0b2d3a9ff22cfc257a8b2f05ad02ef48503e692 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:57:37 +0530 Subject: [PATCH 12/26] feat(subagents): expose agent stop command --- skills/subagents/SKILL.md | 8 ++++++++ src/cli.ts | 18 ++++++++++++++++++ src/local-agent-client.ts | 14 ++++++++++++++ src/local-agent-daemon-lifecycle.ts | 2 +- src/local-agent-daemon-protocol.test.ts | 14 ++++++++++++++ src/local-agent-daemon-protocol.ts | 13 +++++++++++++ src/local-agent-daemon.test.ts | 6 ++++++ src/local-agent-daemon.ts | 7 +++++++ 8 files changed, 81 insertions(+), 1 deletion(-) diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index 5ef4f5bb7..e486bd60b 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -60,6 +60,14 @@ devspace agents continue "" devspace agents wait ``` +Stop an active turn when its work is no longer needed: + +```bash +devspace agents stop +``` + +Stopping is scoped to the current project and preserves the durable agent record as `stopped`. + ## Good uses - Review a change for correctness, security, or missing tests. diff --git a/src/cli.ts b/src/cli.ts index e02d4f5b1..47c398264 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -497,6 +497,7 @@ function printHelp(): void { " devspace agents continue [--model ] [--effort ] ", " devspace agents show [--json]", " devspace agents wait ... [--timeout ] [--json]", + " devspace agents stop [--json]", " devspace agents daemon ", " devspace -v, --version Print the installed version", "", @@ -544,6 +545,9 @@ async function runAgentsCommand(args: string[]): Promise { case "wait": await runAgentWorkflowCommand(json, () => runAgentsWait(commandArgs, json)); return; + case "stop": + await runAgentWorkflowCommand(json, () => runAgentsStop(commandArgs, json)); + return; case "targets": await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); return; @@ -665,6 +669,19 @@ async function runAgentsWait(args: string[], json: boolean): Promise { printAgentXml(results.map(formatAgentObservation).join("\n")); } +async function runAgentsStop(args: string[], json: boolean): Promise { + const [id, ...extra] = args; + if (!id || extra.length > 0) throw new Error("Usage: devspace agents stop [--json]"); + const config = loadConfig(); + const client = createLocalAgentClient(config); + const scope = resolveCliWorkspaceContext(config.allowedRoots); + const record = presentAgentWorkflowResult(await client.stopAgent(id, scope), json); + if (!record) return; + const observation = presentAgentObservation(record); + if (json) printJson(observation); + else printAgentXml(formatAgentObservation(observation)); +} + function parseAgentsWaitArgs(args: string[]): { ids: string[]; timeoutMs?: number } { const ids: string[] = []; let timeoutMs: number | undefined; @@ -808,6 +825,7 @@ function printAgentsHelp(): void { " devspace agents continue [--model ] [--effort ] [--json] ", " devspace agents show [--json]", " devspace agents wait ... [--timeout ] [--json]", + " devspace agents stop [--json]", " devspace agents targets [--json]", " devspace agents daemon [--json]", ].join("\n"), diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 5aa5bce35..74d735d6b 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -49,6 +49,7 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentStopError, AgentWaitError, LocalAgentWaitResult, RunOverrides, @@ -160,6 +161,14 @@ export class LocalAgentClient { return decodeRequestResult(result, "agent.wait", decodeAgentWaitResults); } + async stopAgent( + agentId: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("agent.stop", { id: agentId, scope }); + return decodeRequestResult(result, "agent.stop", decodeAgentRecord); + } + async status(): Promise> { const result = await this.requestExisting("daemon.status", {}); return decodeRequestResult(result, "daemon.status", decodeDaemonStatus); @@ -695,6 +704,11 @@ function isRequestError( case "agent.get": case "agent.wait": return category === "target" || category === "scope" || category === "store"; + case "agent.stop": + return category === "target" + || category === "scope" + || category === "conflict" + || category === "store"; case "agent.list": return category === "scope" || category === "store"; case "hello": diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 82573c779..fd0405e50 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { join, resolve } from "node:path"; -export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 6; +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 7; export const LOCAL_AGENT_DAEMON_SOCKET_NAME = "agentd.sock"; export const LOCAL_AGENT_DAEMON_PID_NAME = "agentd.pid"; export const LOCAL_AGENT_DAEMON_LOCK_NAME = "agentd.lock"; diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index 7b7a92488..50641ed9b 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -194,6 +194,20 @@ const waitRequest = decodeLocalAgentDaemonRequest({ }); assert.equal(waitRequest.method, "agent.wait"); if (waitRequest.method !== "agent.wait") throw new Error("expected agent.wait request"); + +const stopRequest = decodeLocalAgentDaemonRequest({ + requestId: "stop", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "secret", + method: "agent.stop", + params: { + id: "agt_stop", + scope: { workspaceId: "ws_test", workspaceRoot: "/tmp/project" }, + }, +}); +assert.equal(stopRequest.method, "agent.stop"); +if (stopRequest.method !== "agent.stop") throw new Error("expected agent.stop request"); +assert.equal(stopRequest.params.id, "agt_stop"); assert.deepEqual(waitRequest.params.ids, ["agt_one", "agt_two"]); assert.equal(waitRequest.params.timeoutMs, 5_000); diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index a9f2b2fc1..37f72b613 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -19,6 +19,7 @@ export type LocalAgentDaemonMethod = | "agent.get" | "agent.list" | "agent.wait" + | "agent.stop" | "daemon.status" | "daemon.stop" | "daemon.logs"; @@ -34,6 +35,7 @@ export type LocalAgentDaemonRequest = scope: LocalAgentWorkspaceScope; timeoutMs?: number; }> + | AgentDaemonRequestBase<"agent.stop", { id: string; scope: LocalAgentWorkspaceScope }> | AgentDaemonRequestBase<"daemon.status", Record> | AgentDaemonRequestBase<"daemon.stop", { ifIdle?: boolean }> | AgentDaemonRequestBase<"daemon.logs", { lines?: number }>; @@ -169,6 +171,17 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR method, params: decodeWaitParams(params), } as LocalAgentDaemonRequest; + case "agent.stop": + return { + requestId, + protocolVersion, + authToken, + method, + params: { + id: requiredString(asRecord(params)?.id, "id"), + scope: decodeWorkspaceScope(asRecord(params)?.scope), + }, + } as LocalAgentDaemonRequest; case "daemon.logs": return { requestId, diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 8985bdc70..5cf7d4dcd 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -96,6 +96,11 @@ class FakeManager implements LocalAgentDaemonManager { return Result.ok(agentIds.map((id) => ({ id, status: "running" as const }))); } + async stop(_agentId: string, _scope: unknown) { + this.activeTurnCount = 0; + return Result.ok({ ...record, status: "stopped" } as LocalAgentRecord); + } + async evictIdle(): Promise {} async close(): Promise { @@ -174,6 +179,7 @@ try { assert.deepEqual(unwrap(await client.wait([record.id], recordScope, 0)), [ { id: record.id, status: "running" }, ]); + assert.equal(unwrap(await client.stopAgent(record.id, recordScope)).status, "stopped"); assert.equal(unwrap(await client.status()).state, "ready"); unwrap(await client.stop()); diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index aac96e9d9..3f929e25a 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -37,6 +37,7 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentStopError, AgentWaitError, LocalAgentWaitResult, RunOverrides, @@ -61,6 +62,7 @@ export interface LocalAgentDaemonManager { timeoutMs?: number, signal?: AbortSignal, ): Promise>; + stop(agentId: string, scope: LocalAgentWorkspaceScope): Promise>; evictIdle(now?: number): Promise; close(): Promise; readonly activeTurnCount: number; @@ -339,6 +341,11 @@ export class LocalAgentDaemon { request.params.timeoutMs, signal, )); + case "agent.stop": + return unwrapManagerResult(await this.manager.stop( + request.params.id, + request.params.scope, + )); case "daemon.status": return this.status(); case "daemon.stop": From 2889608d31d12c6386dddbf2b415146d4b16b54b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:13:15 +0530 Subject: [PATCH 13/26] fix(subagents): make stop bounded and never start work after a stop Abort the local OpenCode request before awaiting the provider's abort so a stalled provider call cannot keep the turn pending. Do not submit a prompt to Pi, Claude, Codex, or ACP once stop was requested during setup. Give ACP providers a grace period to answer session/cancel before dropping the process. A failed Codex interrupt no longer reports the turn as stopped. The manager bounds how long stop waits (below the client deadline) and reports a conflict instead of success when the turn kept running or finished normally. Co-Authored-By: Claude Sonnet 5.5 --- src/local-agent-acp.test.ts | 61 ++++++++++++++++++++++++++++++++ src/local-agent-acp.ts | 33 +++++++++++++++-- src/local-agent-claude.ts | 1 + src/local-agent-codex.ts | 5 ++- src/local-agent-manager.test.ts | 39 +++++++++++++++++++- src/local-agent-manager.ts | 30 ++++++++++++++-- src/local-agent-opencode-v2.ts | 4 ++- src/local-agent-opencode.test.ts | 28 +++++++++++++++ src/local-agent-opencode.ts | 4 ++- src/local-agent-pi.ts | 1 + 10 files changed, 197 insertions(+), 9 deletions(-) diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 2d6fa3fe5..c4b926eb6 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -477,3 +477,64 @@ await grokConfigurationRuntime.close(); await resumedRuntime.close(); await resumedRuntime.close(); assert.equal(resumedRuntime.isAlive(), false); + +// Stop: a provider that never answers session/prompt after session/cancel is given up on. +const stuckRequests: string[] = []; +const stuckNotifications: string[] = []; +const stuckRuntime = new AcpRuntime({ + provider: "cursor", + command: "cursor-agent", + args: ["acp"], + env: {}, + cancelGraceMs: 50, +}, { + agent: { + async notify(method: string): Promise { stuckNotifications.push(method); }, + async request(method: string): Promise { + stuckRequests.push(method); + if (method === "session/new") return { sessionId: "stuck_session" }; + return new Promise(() => undefined); + }, + }, + close() {}, + closed: new Promise(() => undefined), +}); +const stuckAbort = new AbortController(); +const stuckRun = stuckRuntime.run({ prompt: "never answers", workspaceRoot: "/tmp/project", signal: stuckAbort.signal }); +await new Promise((resolveTick) => setTimeout(resolveTick, 10)); +stuckAbort.abort(); +const stuckResult = await stuckRun; +assert.equal(stuckResult.isErr(), true); +if (stuckResult.isErr()) assert.equal(stuckResult.error.code, "PROVIDER_CANCELLED"); +assert.deepEqual(stuckNotifications, ["session/cancel"]); +assert.equal(stuckRuntime.isAlive(), false, "an unresponsive provider is dropped from the pool"); + +// Stop requested before the prompt is sent must not start provider work. +const preAbortRequests: string[] = []; +const preAbortRuntime = new AcpRuntime({ + provider: "cursor", + command: "cursor-agent", + args: ["acp"], + env: {}, +}, { + agent: { + async notify(): Promise {}, + async request(method: string): Promise { + preAbortRequests.push(method); + return method === "session/new" ? { sessionId: "pre_abort_session" } : { stopReason: "end_turn" }; + }, + }, + close() {}, + closed: new Promise(() => undefined), +}); +const preAborted = new AbortController(); +preAborted.abort(); +const preAbortResult = await preAbortRuntime.run({ + prompt: "should not run", + workspaceRoot: "/tmp/project", + signal: preAborted.signal, +}); +assert.equal(preAbortResult.isErr(), true); +if (preAbortResult.isErr()) assert.equal(preAbortResult.error.code, "PROVIDER_CANCELLED"); +assert.equal(preAbortRequests.includes("session/prompt"), false); +await preAbortRuntime.close(); diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 3736a6c58..9edea84c6 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -36,6 +36,8 @@ const MAX_ACP_QUEUE_ITEMS = 10_000; const MAX_ACP_STDERR_BYTES = 32 * 1024; const ACP_INITIALIZE_TIMEOUT_MS = 10_000; const ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS = 10 * 60_000; +// How long a provider gets to answer session/prompt after session/cancel before its process is dropped. +const ACP_CANCEL_GRACE_MS = 10_000; const require = createRequire(import.meta.url); const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; @@ -79,6 +81,7 @@ export interface AcpRuntimeOptions { sessionMetadata?: Map; grokCompletionRegistry?: GrokPromptCompletionRegistry; promptCompletionTimeoutMs?: number; + cancelGraceMs?: number; } export class AcpRuntime implements LocalAgentRuntime { @@ -92,6 +95,7 @@ export class AcpRuntime implements LocalAgentRuntime { private readonly sessionMetadata: Map; private readonly grokCompletionRegistry?: GrokPromptCompletionRegistry; private readonly promptCompletionTimeoutMs: number; + private readonly cancelGraceMs: number; private readonly activeSessions = new Set(); private promptSequence = 0; private alive = true; @@ -108,6 +112,7 @@ export class AcpRuntime implements LocalAgentRuntime { this.sessionMetadata = options.sessionMetadata ?? new Map(); this.grokCompletionRegistry = options.grokCompletionRegistry; this.promptCompletionTimeoutMs = options.promptCompletionTimeoutMs ?? ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS; + this.cancelGraceMs = options.cancelGraceMs ?? ACP_CANCEL_GRACE_MS; void this.connection.closed.then(() => { if (!this.closed) this.alive = false; this.grokCompletionRegistry?.rejectAll(new Error(`${this.provider} ACP connection closed.`)); @@ -140,6 +145,7 @@ export class AcpRuntime implements LocalAgentRuntime { }); } const sessionId = await this.openSession(input, callbacks); + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); if (this.activeSessions.has(sessionId)) { throw new TypeError(`${this.provider} ACP session ${sessionId} already has an active turn.`); } @@ -147,6 +153,23 @@ export class AcpRuntime implements LocalAgentRuntime { const removeAbort = bindLocalAgentAbort(input.signal, () => ( this.connection.agent.notify("session/cancel", { sessionId }) )); + // A provider that ignores session/cancel would leave the turn running forever: + // give up on it after a grace period and drop the process so the next turn starts clean. + let cancelTimer: NodeJS.Timeout | undefined; + let onAbort: (() => void) | undefined; + const cancelDeadline = input.signal && new Promise((_, reject) => { + onAbort = () => { + cancelTimer = setTimeout(() => { + void this.close(); + reject(localAgentCancelledError(this.provider, "run", new Error( + `${this.provider} did not acknowledge session/cancel within ${this.cancelGraceMs}ms.`, + ))); + }, this.cancelGraceMs); + }; + if (input.signal?.aborted) onAbort(); + else input.signal?.addEventListener("abort", onAbort, { once: true }); + }); + cancelDeadline?.catch(() => undefined); const queue = this.queues.get(sessionId) ?? { values: [] }; this.queues.set(sessionId, queue); const promptId = this.provider === "grok" ? this.nextPromptId() : undefined; @@ -173,9 +196,11 @@ export class AcpRuntime implements LocalAgentRuntime { }); let response: unknown; try { - response = completion - ? await Promise.race([standardResponse, completion]) - : await standardResponse; + response = await Promise.race([ + standardResponse, + ...(completion ? [completion] : []), + ...(cancelDeadline ? [cancelDeadline] : []), + ]); } catch (cause) { if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run", cause); throw cause; @@ -206,6 +231,8 @@ export class AcpRuntime implements LocalAgentRuntime { }; } finally { removeAbort(); + clearTimeout(cancelTimer); + if (onAbort) input.signal?.removeEventListener("abort", onAbort); if (promptId) this.grokCompletionRegistry?.remove(sessionId, promptId); this.activeSessions.delete(sessionId); } diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index d7e46ad90..d33a848a1 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -121,6 +121,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { await this.query.applyFlagSettings(flagSettings); await this.query.setPermissionMode(claudePermissionMode(input.writeMode)); if (input.model && this.query.setModel) await this.query.setModel(input.model); + if (input.signal?.aborted) throw localAgentCancelledError("claude", "run"); this.inputQueue.push({ type: "user", message: { role: "user", content: input.prompt }, diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index 134385bca..3a978875e 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -148,6 +148,7 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { } await callbacks?.onSessionId?.(threadId); + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); let completed: CodexTurnResult; try { completed = await this.rpc.runTurn(threadId, turnParams(input, threadId), input.signal); @@ -393,7 +394,9 @@ class CodexAppServerRpc { const response = await this.request("turn/start", params); turn.turnId = readString(asRecord(response)?.turn, "id"); if (signal?.aborted && turn.turnId) { - await this.request("turn/interrupt", { threadId, turnId: turn.turnId }); + // A failed interrupt must not report the turn as stopped: keep waiting for the + // provider's terminal event; the manager bounds how long stop waits for it. + await this.request("turn/interrupt", { threadId, turnId: turn.turnId }).catch(() => undefined); } if (turn.completed) return { event: turn.completed, items: turn.items }; return await completion; diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 6c5cca458..f3a648d4a 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -85,6 +85,9 @@ class FakeRuntime implements LocalAgentRuntime { if (input.prompt.includes("hold")) { await new Promise((resolve) => { this.releaseHold = resolve; }); } + if (input.prompt.includes("finish-despite-stop")) { + await new Promise((resolve) => input.signal?.addEventListener("abort", () => resolve(), { once: true })); + } if (input.prompt.includes("cancel-me")) { await new Promise((resolve) => { const done = () => resolve(); @@ -169,6 +172,7 @@ const manager = new LocalAgentManager({ loadProfiles: async () => [profile, disabledProfile, shadowProfile], allowedRoots: [root], subagents, + stopTimeoutMs: 200, }); const defectStore = new LocalAgentStore(join(root, "defect-state")); @@ -223,6 +227,39 @@ assert.equal(stopped.status, "stopped"); assert.equal(store.getLatestTurn(cancellable.id)?.status, "stopped"); assert.equal(store.getLatestTurn(cancellable.id)?.errorCode, "PROVIDER_CANCELLED"); +// A provider that ignores stop leaves the turn running; stop reports that instead of hanging. +const stubborn = unwrap(await manager.start({ + target: "codex", + prompt: "hold", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(stubborn.id)?.inputs.length === 1); +const stubbornStop = await manager.stop(stubborn.id, scope); +assert.equal(stubbornStop.isErr(), true); +if (stubbornStop.isErr()) { + assert.equal(stubbornStop.error.code, "AGENT_CONFLICT"); + assert.equal(stubbornStop.error.retryable, true); +} +assert.equal(getRecord(stubborn.id).status, "running"); +runtimes.get(stubborn.id)?.release(); +await waitFor(() => getRecord(stubborn.id).status === "idle"); + +// A turn that completes normally despite the stop request is not reported as stopped. +const finishing = unwrap(await manager.start({ + target: "codex", + prompt: "finish-despite-stop", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(finishing.id)?.inputs.length === 1); +const finishingStop = await manager.stop(finishing.id, scope); +assert.equal(finishingStop.isErr(), true); +if (finishingStop.isErr()) { + assert.equal(finishingStop.error.code, "AGENT_CONFLICT"); + assert.equal(finishingStop.error.retryable, false); +} + const namedInstance = unwrap(await manager.start({ target: "codex-work", prompt: "inspect named instance", @@ -354,7 +391,7 @@ const second = unwrap(await manager.start({ })); await waitFor(() => getRecord(second.id).status === "idle"); assert.notEqual(first.id, second.id); -assert.equal(runtimes.size, 4, "different agents receive independent logical runtimes"); +assert.equal(runtimes.size, 6, "different agents receive independent logical runtimes"); const failed = unwrap(await manager.start({ target: "reviewer", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index c6ccaaeff..7e3f7098c 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -58,6 +58,9 @@ export interface LocalAgentManagerLogger { (level: "info" | "warn" | "error", event: string, fields: Record): void; } +// Kept below the client's default request deadline so a slow stop is reported as a conflict, not a daemon timeout. +const DEFAULT_STOP_TIMEOUT_MS = 20_000; + export interface LocalAgentManagerOptions { store: LocalAgentStore; drivers: readonly LocalAgentDriver[]; @@ -67,6 +70,7 @@ export interface LocalAgentManagerOptions { allowedRoots?: readonly string[]; logger?: LocalAgentManagerLogger; subagents: SubagentsConfig; + stopTimeoutMs?: number; } export type AgentStartError = AgentTargetError | AgentScopeError | AgentConflictError | AgentStoreError; @@ -102,6 +106,7 @@ export class LocalAgentManager { private readonly allowedRoots?: readonly string[]; private readonly logger?: LocalAgentManagerLogger; private readonly subagents: SubagentsConfig; + private readonly stopTimeoutMs: number; private readonly activeTurns = new Map(); private accepting = true; private closePromise?: Promise; @@ -115,6 +120,7 @@ export class LocalAgentManager { this.allowedRoots = options.allowedRoots; this.logger = options.logger; this.subagents = options.subagents; + this.stopTimeoutMs = options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS; } reconcileActiveRuns(message?: string): BetterResult { @@ -291,8 +297,28 @@ export class LocalAgentManager { })); } active.abortController.abort(); - await active.completion; - return this.get(agentId, scope); + const settled = await this.settledWithin(active.completion, this.stopTimeoutMs); + const refreshed = this.get(agentId, scope); + if (refreshed.isErr() || refreshed.value.status === "stopped") return refreshed; + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "stop", + retryable: !settled, + message: settled + ? `Agent ${agentId} finished before it could be stopped.` + : `Agent ${agentId} did not stop within ${this.stopTimeoutMs}ms; its turn is still running.`, + })); + } + + private async settledWithin(completion: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }); + try { + return await Promise.race([completion.then(() => true as const, () => true as const), timeout]); + } finally { + clearTimeout(timer); + } } async close(): Promise { diff --git a/src/local-agent-opencode-v2.ts b/src/local-agent-opencode-v2.ts index 8558b03cd..074d62da4 100644 --- a/src/local-agent-opencode-v2.ts +++ b/src/local-agent-opencode-v2.ts @@ -176,9 +176,11 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { const controller = new AbortController(); this.promptControllers.add(controller); let timedOut = false; + // Abort the local request first so a stalled provider interrupt cannot keep the turn pending. const removeAbort = bindLocalAgentAbort(input.signal, async () => { - await this.client.session.interrupt({ sessionID: sessionId }); + const interrupting = this.client.session.interrupt({ sessionID: sessionId }); controller.abort(); + await interrupting; }); const timer = setTimeout(() => { timedOut = true; diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 1d0e1915d..8839c3f44 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -237,6 +237,34 @@ if (timedOutPrompt.isErr()) { } await timeoutRuntime.close(); +// Stop cancels the local request even when the provider's abort call never returns. +const stalledAbortClient = { + global: { + async health() { return { data: { healthy: true } }; }, + }, + session: { + async create() { return { data: { id: "session_stalled_abort" } }; }, + abort() { return new Promise(() => undefined); }, + async prompt(_input: unknown, options?: { signal?: AbortSignal }) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }); + }, + }, +} as unknown as OpencodeClientLike; +const stalledAbortRuntime = new OpencodeRuntime(stalledAbortClient, { close: () => undefined }); +const stalledAbort = new AbortController(); +const stalledAbortRun = stalledAbortRuntime.run({ + prompt: "stop me", + workspaceRoot: "/tmp/project", + signal: stalledAbort.signal, +}); +setTimeout(() => stalledAbort.abort(), 10); +const stalledAbortResult = await stalledAbortRun; +assert.equal(stalledAbortResult.isErr(), true); +if (stalledAbortResult.isErr()) assert.equal(stalledAbortResult.error.code, "PROVIDER_CANCELLED"); +await stalledAbortRuntime.close(); + assert.equal(opencodeAgentFor("read_only"), "devspace_read_only"); assert.equal(opencodeAgentFor("full_access"), "devspace_full_access"); assert.deepEqual(opencodePermissionFor("allowed"), { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 7ef0c1464..cdde0a0c1 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -126,12 +126,14 @@ export class OpencodeRuntime implements LocalAgentRuntime { const controller = new AbortController(); this.promptControllers.add(controller); let timedOut = false; + // Abort the local request first so a stalled provider abort cannot keep the turn pending. const removeAbort = bindLocalAgentAbort(input.signal, async () => { - await this.client.session.abort({ + const aborting = this.client.session.abort({ sessionID: sessionId, directory: input.workspaceRoot, }, { throwOnError: false }); controller.abort(); + await aborting; }); const timer = setTimeout(() => { timedOut = true; diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index 4d2a5fd0a..75034382a 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -84,6 +84,7 @@ export class PiSessionRuntime implements LocalAgentRuntime { try { await callbacks?.onSessionId?.(this.session.sessionId); await this.applyOverrides(input); + if (input.signal?.aborted) throw localAgentCancelledError("pi", "run"); this.events = []; const messageStart = this.session.messages.length; this.collectingEvents = true; From bafce8bd0fdae227a30466f3a67ea0b7c903a06b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:43:43 +0530 Subject: [PATCH 14/26] refactor(subagents): make driver runtime policy declarative --- src/local-agent-acp.ts | 14 ++--- src/local-agent-adapters.test.ts | 13 +---- src/local-agent-adapters.ts | 68 +++++-------------------- src/local-agent-claude.test.ts | 16 ++---- src/local-agent-claude.ts | 11 ++-- src/local-agent-codex.ts | 9 +--- src/local-agent-manager.test.ts | 2 +- src/local-agent-opencode.ts | 6 +-- src/local-agent-pi.ts | 6 +-- src/local-agent-provider-registry.ts | 66 ++++++++++++++++++++++++ src/local-agent-runtime-pool.ts | 35 +++++++++++-- src/local-agent-runtime.test.ts | 76 +++++++++++++++++++++++----- src/local-agent-runtime.ts | 13 ++++- 13 files changed, 206 insertions(+), 129 deletions(-) create mode 100644 src/local-agent-provider-registry.ts diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index bbbabb260..88ab8c0ec 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -411,9 +411,11 @@ export class AcpRuntime implements LocalAgentRuntime { export class AcpLocalAgentDriver implements LocalAgentDriver { readonly provider: AcpProvider; readonly providerInstanceId: string; - // Keep ACP warm briefly, then let the generic pool close the process so the - // daemon can reach its own idle shutdown state. - readonly idleTimeoutMs = 5 * 60_000; + readonly runtimePolicy = { + scope: "workspace", + authority: "write_mode", + idleTimeoutMs: 5 * 60_000, + } as const; private commandResolved = false; private resolvedCommand?: string; @@ -426,12 +428,6 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { this.providerInstanceId = provider; } - runtimeKey(context: LocalAgentRuntimeContext): string { - const command = this.resolveCommand() ?? ACP_COMMANDS[this.provider][0]; - const writeMode = context.writeMode ?? "allowed"; - return `acp:${this.provider}:${command}:${writeMode}:${resolve(context.workspaceRoot)}`; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index cbaf2e190..29315ce83 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -347,16 +347,5 @@ assert.equal( drivers.map((driver) => [driver.providerInstanceId, driver.provider]), [["claude-work", "claude"], ["claude-personal", "claude"]], ); - const context = { - agentId: "agt_test", - providerInstanceId: "claude-work", - provider: "claude" as const, - workspaceRoot: "/tmp/project", - writeMode: "allowed" as const, - }; - assert.notEqual( - drivers[0]?.runtimeKey(context), - drivers[1]?.runtimeKey({ ...context, providerInstanceId: "claude-personal" }), - "provider instances sharing a driver must never share one runtime pool key", - ); + assert.deepEqual(drivers.map((driver) => driver.runtimePolicy.scope), ["agent", "agent"]); } diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 2059cbc9d..7cdb09037 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,13 +1,9 @@ import { - localAgentProviderEnvironment, - localAgentProviderEnvironmentOverrides, type SubagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; import { LOCAL_AGENT_DRIVER_KINDS, - type LocalAgentDriverKind, - type LocalAgentProviderInstanceId, } from "./local-agent-provider.js"; import { AcpLocalAgentDriver, @@ -33,6 +29,7 @@ import { type PiSessionFactory, } from "./local-agent-pi.js"; import type { LocalAgentDriver } from "./local-agent-runtime.js"; +import { LocalAgentProviderRegistry } from "./local-agent-provider-registry.js"; export type LocalAgentAdapter = LocalAgentDriver; @@ -53,58 +50,21 @@ export function createLocalAgentDrivers( driver, enabled: true, } satisfies SubagentProviderConfig)); - return instances.map((instance) => new ProviderInstanceDriver( - instance.id, - createDriver(instance, options, env), - )); + const registry = createLocalAgentProviderRegistry(options); + return instances.map((instance) => registry.create(instance, options.subagents, env)); } -function createDriver( - instance: SubagentProviderConfig, - options: LocalAgentDriverOptions, - inheritedEnv: NodeJS.ProcessEnv, -): LocalAgentDriver { - const env = options.subagents - ? localAgentProviderEnvironment(options.subagents, instance.id, inheritedEnv) - : inheritedEnv; - const envOverrides = options.subagents - ? localAgentProviderEnvironmentOverrides(options.subagents, instance.id) - : {}; - switch (instance.driver) { - case "codex": - return new CodexLocalAgentDriver(env); - case "claude": - return new ClaudeLocalAgentDriver(options.claudeQueryFactory, env); - case "opencode": - return new OpencodeLocalAgentDriver({ factory: options.opencodeFactory, env }); - case "pi": - return new PiLocalAgentDriver(options.piSessionFactory, envOverrides); - case "cursor": - case "copilot": - case "grok": - return new AcpLocalAgentDriver(instance.driver, env); - } -} - -class ProviderInstanceDriver implements LocalAgentDriver { - readonly provider: LocalAgentDriverKind; - readonly idleTimeoutMs?: number; - - constructor( - readonly providerInstanceId: LocalAgentProviderInstanceId, - private readonly driver: LocalAgentDriver, - ) { - this.provider = driver.provider; - this.idleTimeoutMs = driver.idleTimeoutMs; - } - - runtimeKey(context: Parameters[0]): string { - return JSON.stringify([this.providerInstanceId, this.driver.runtimeKey(context)]); - } - - createRuntime(context: Parameters[0]) { - return this.driver.createRuntime(context); - } +export function createLocalAgentProviderRegistry( + options: LocalAgentDriverOptions = {}, +): LocalAgentProviderRegistry { + return new LocalAgentProviderRegistry() + .register("codex", ({ env }) => new CodexLocalAgentDriver(env)) + .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)); } export function extractLocalAgentResponseText(value: unknown): string { diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index e6c0a2a0e..bd19fbc6d 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -72,17 +72,11 @@ const driver = new ClaudeLocalAgentDriver(({ prompt, options }) => { query = new FakeClaudeQuery(prompt); return query; }, { PATH: "/usr/bin" }); -assert.equal(driver.runtimeKey(context), "claude:agt_claude:restricted"); -assert.equal( - driver.runtimeKey({ ...context, writeMode: "allowed" }), - "claude:agt_claude:restricted", - "restricted Claude modes can share one query because per-turn settings are dynamic", -); -assert.equal( - driver.runtimeKey({ ...context, writeMode: "full_access" }), - "claude:agt_claude:full_access", - "full access uses a query initialized with the explicit dangerous-permission opt-in", -); +assert.deepEqual(driver.runtimePolicy, { + scope: "agent", + authority: "full_access_boundary", + idleTimeoutMs: 3 * 60_000, +}); const runtimeResult = await driver.createRuntime(context); assert.equal(runtimeResult.isOk(), true); diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index 7c8b0d9da..65a85df1d 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -214,18 +214,17 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { export class ClaudeLocalAgentDriver implements LocalAgentDriver { readonly provider = "claude" as const; readonly providerInstanceId = "claude"; - readonly idleTimeoutMs = 3 * 60_000; + readonly runtimePolicy = { + scope: "agent", + authority: "full_access_boundary", + idleTimeoutMs: 3 * 60_000, + } as const; constructor( private readonly factory: ClaudeQueryFactory = defaultClaudeQueryFactory, private readonly env: NodeJS.ProcessEnv = process.env, ) {} - runtimeKey(context: LocalAgentRuntimeContext): string { - const authority = context.writeMode === "full_access" ? "full_access" : "restricted"; - return `claude:${context.agentId}:${authority}`; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index 46c4c007b..e619a1635 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -231,7 +231,7 @@ async function waitForProcessExit( export class CodexLocalAgentDriver implements LocalAgentDriver { readonly provider = "codex" as const; readonly providerInstanceId = "codex"; - readonly idleTimeoutMs = 5 * 60_000; + readonly runtimePolicy = { scope: "instance", idleTimeoutMs: 5 * 60_000 } as const; private commandResolved = false; private resolvedCommand?: ResolvedCodexCommand; @@ -241,13 +241,6 @@ export class CodexLocalAgentDriver implements LocalAgentDriver { private readonly commandResolver: CodexCommandResolver = resolveCodexCommand, ) {} - runtimeKey(_context: LocalAgentRuntimeContext): string { - const command = this.resolveCommand(); - const executable = command?.executable ?? this.env.CODEX_COMMAND ?? "codex"; - const codexHome = resolve(this.env.CODEX_HOME ?? join(homedir(), ".codex")); - return `codex:${executable}:${codexHome}`; - } - async createRuntime(_context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index b3a39cd0b..c6a2adb64 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -107,7 +107,7 @@ const runtimes = new Map(); const driver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: (context: LocalAgentRuntimeContext) => context.agentId, + runtimePolicy: { scope: "agent" }, createRuntime: async (context) => { const runtime = new FakeRuntime(); runtimes.set(context.agentId, runtime); diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index fe6f04e93..ce62b1db1 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -151,7 +151,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { export class OpencodeLocalAgentDriver implements LocalAgentDriver { readonly provider = "opencode" as const; readonly providerInstanceId = "opencode"; - readonly idleTimeoutMs = 5 * 60_000; + readonly runtimePolicy = { scope: "instance", idleTimeoutMs: 5 * 60_000 } as const; private readonly factory: OpencodeFactory; private readonly v2Factory: OpencodeV2Factory; private readonly env: NodeJS.ProcessEnv; @@ -169,10 +169,6 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { this.probeRuntime = options.probeRuntime ?? (() => probeOpenCodeBinary(this.env)); } - runtimeKey(_context: LocalAgentRuntimeContext): string { - return "opencode:default"; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index 2b3780455..453168e7d 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -165,17 +165,13 @@ export class PiSessionRuntime implements LocalAgentRuntime { export class PiLocalAgentDriver implements LocalAgentDriver { readonly provider = "pi" as const; readonly providerInstanceId = "pi"; - readonly idleTimeoutMs = 3 * 60_000; + readonly runtimePolicy = { scope: "agent", idleTimeoutMs: 3 * 60_000 } as const; constructor( private readonly factory: PiSessionFactory = defaultPiSessionFactory, private readonly env: NodeJS.ProcessEnv = {}, ) {} - runtimeKey(context: LocalAgentRuntimeContext): string { - return `pi:${context.agentId}`; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, diff --git a/src/local-agent-provider-registry.ts b/src/local-agent-provider-registry.ts new file mode 100644 index 000000000..de944d72f --- /dev/null +++ b/src/local-agent-provider-registry.ts @@ -0,0 +1,66 @@ +import type { + SubagentProviderConfig, + SubagentsConfig, +} from "./local-agent-config.js"; +import { + localAgentProviderEnvironment, + localAgentProviderEnvironmentOverrides, +} from "./local-agent-config.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; +import type { LocalAgentDriver } from "./local-agent-runtime.js"; + +export interface LocalAgentDriverFactoryContext { + instance: SubagentProviderConfig; + env: NodeJS.ProcessEnv; + envOverrides: Record; +} + +export type LocalAgentDriverFactory = ( + context: LocalAgentDriverFactoryContext, +) => LocalAgentDriver; + +export class LocalAgentProviderRegistry { + private readonly factories = new Map(); + + register(kind: LocalAgentDriverKind, factory: LocalAgentDriverFactory): this { + if (this.factories.has(kind)) throw new Error(`Local agent driver already registered: ${kind}`); + this.factories.set(kind, factory); + return this; + } + + create( + instance: SubagentProviderConfig, + subagents: SubagentsConfig | undefined, + inheritedEnv: NodeJS.ProcessEnv, + ): LocalAgentDriver { + const factory = this.factories.get(instance.driver); + if (!factory) throw new Error(`No local agent driver registered for: ${instance.driver}`); + const env = subagents + ? localAgentProviderEnvironment(subagents, instance.id, inheritedEnv) + : inheritedEnv; + const envOverrides = subagents + ? localAgentProviderEnvironmentOverrides(subagents, instance.id) + : {}; + return new ProviderInstanceDriver(instance.id, factory({ instance, env, envOverrides })); + } +} + +class ProviderInstanceDriver implements LocalAgentDriver { + readonly provider: LocalAgentDriverKind; + readonly runtimePolicy: LocalAgentDriver["runtimePolicy"]; + + constructor( + readonly providerInstanceId: LocalAgentProviderInstanceId, + private readonly driver: LocalAgentDriver, + ) { + this.provider = driver.provider; + this.runtimePolicy = driver.runtimePolicy; + } + + createRuntime(context: Parameters[0]) { + return this.driver.createRuntime(context); + } +} diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index 213223555..399c1833a 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -255,7 +255,7 @@ export class LocalAgentRuntimePool { driver: LocalAgentDriver, context: LocalAgentRuntimeContext, ): Promise> { - const key = driver.runtimeKey(context); + const key = runtimePoolKey(driver, context); while (true) { const existing = this.entries.get(key); if (existing && !existing.closing) { @@ -311,8 +311,8 @@ export class LocalAgentRuntimePool { entry = { key, driver, - idleTimeoutMs: driver.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, - sessionIdleTimeoutMs: this.sessionIdleTimeoutMs, + idleTimeoutMs: driver.runtimePolicy.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, + sessionIdleTimeoutMs: driver.runtimePolicy.sessionIdleTimeoutMs ?? this.sessionIdleTimeoutMs, createPromise, activeRuns: 0, lastUsedAt: this.now(), @@ -496,6 +496,35 @@ export class LocalAgentRuntimePool { } } +function runtimePoolKey(driver: LocalAgentDriver, context: LocalAgentRuntimeContext): string { + const policy = driver.runtimePolicy; + const parts: Array = [ + context.providerInstanceId, + policy.scope, + ]; + switch (policy.scope) { + case "instance": + break; + case "workspace": + parts.push(context.workspaceRoot); + break; + case "agent": + parts.push(context.agentId); + break; + } + switch (policy.authority ?? "none") { + case "none": + break; + case "write_mode": + parts.push(context.writeMode ?? "allowed"); + break; + case "full_access_boundary": + parts.push(context.writeMode === "full_access" ? "full_access" : "restricted"); + break; + } + return JSON.stringify(parts); +} + function poolClosedError( driver: LocalAgentDriver, context: LocalAgentRuntimeContext, diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index a21513b9b..1aaead71f 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -95,8 +95,7 @@ let createCount = 0; const driver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "shared", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, createRuntime: async () => { createCount += 1; await Promise.resolve(); @@ -136,8 +135,7 @@ const sessionPool = new LocalAgentRuntimePool({ const sessionDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "session-runtime", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, createRuntime: async () => Result.ok(sessionRuntime), }; await sessionPool.run(sessionDriver, context, input); @@ -165,8 +163,7 @@ const shutdownReleasePool = new LocalAgentRuntimePool({ const shutdownReleaseDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "shutdown-release-runtime", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, createRuntime: async () => Result.ok(shutdownReleaseRuntime), }; await shutdownReleasePool.run(shutdownReleaseDriver, context, input); @@ -215,7 +212,7 @@ const cleanupRuntime = new CleanupFailureRuntime(); const cleanupDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "cleanup-runtime", + runtimePolicy: { scope: "instance" }, createRuntime: async () => Result.ok(cleanupRuntime), }; const cleanupFailure = await cleanupPool.run(cleanupDriver, context, input); @@ -232,7 +229,7 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider const recoveryDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "dead-runtime-recovery", + runtimePolicy: { scope: "instance" }, createRuntime: async () => Result.ok(attempts++ === 0 ? deadRuntime : replacementRuntime), }; @@ -257,7 +254,7 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider const completedTurnDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "completed-turn-during-close", + runtimePolicy: { scope: "instance" }, createRuntime: async () => Result.ok(completedTurnRuntime), }; @@ -274,7 +271,7 @@ const racePool = new LocalAgentRuntimePool(); const raceDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "creation-race", + runtimePolicy: { scope: "instance" }, createRuntime: () => creating, }; const pendingRun = racePool.run(raceDriver, context, input); @@ -306,7 +303,7 @@ if (afterClose.isErr()) { const creationDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "creation-failure", + runtimePolicy: { scope: "instance" }, async createRuntime() { createAttempts += 1; if (createAttempts === 1) { @@ -360,8 +357,7 @@ if (afterClose.isErr()) { const releaseDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "release-failure", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, createRuntime: async () => Result.ok(releaseRuntime), }; @@ -381,6 +377,60 @@ if (afterClose.isErr()) { await releasePool.close(); } +{ + let creates = 0; + const policyPool = new LocalAgentRuntimePool(); + const policyDriver = (providerInstanceId: string, runtimePolicy: LocalAgentDriver["runtimePolicy"]): LocalAgentDriver => ({ + providerInstanceId, + provider: "codex", + runtimePolicy, + createRuntime: async () => { + creates += 1; + return Result.ok(new FakeRuntime()); + }, + }); + + const instanceDriver = policyDriver("codex-work", { scope: "instance" }); + await policyPool.run(instanceDriver, { ...context, providerInstanceId: "codex-work" }, input); + await policyPool.run(instanceDriver, { + ...context, + agentId: "agt_other", + providerInstanceId: "codex-work", + }, input); + assert.equal(creates, 1, "instance-scoped runtimes are shared across agents in one provider instance"); + + const personalDriver = policyDriver("codex-personal", { scope: "instance" }); + await policyPool.run(personalDriver, { + ...context, + providerInstanceId: "codex-personal", + }, input); + assert.equal(creates, 2, "provider instances never share a runtime even when they use the same driver"); + + const claudeDriver = policyDriver("claude-work", { + scope: "agent", + authority: "full_access_boundary", + }); + const claudeContext = { + ...context, + agentId: "agt_claude", + providerInstanceId: "claude-work", + }; + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "read_only" }, input); + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "allowed" }, input); + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "full_access" }, input); + assert.equal(creates, 4, "full-access authority is isolated while restricted modes share one agent runtime"); + + const acpDriver = policyDriver("cursor", { scope: "workspace", authority: "write_mode" }); + const acpContext = { ...context, providerInstanceId: "cursor", workspaceRoot: "/tmp/a" }; + await policyPool.run(acpDriver, { ...acpContext, writeMode: "allowed" }, input); + await policyPool.run(acpDriver, { ...acpContext, agentId: "agt_acp_2", writeMode: "allowed" }, input); + await policyPool.run(acpDriver, { ...acpContext, writeMode: "read_only" }, input); + await policyPool.run(acpDriver, { ...acpContext, workspaceRoot: "/tmp/b", writeMode: "allowed" }, input); + assert.equal(creates, 7, "workspace and write mode are explicit ACP runtime isolation dimensions"); + + await policyPool.close(); +} + function unwrap(result: BetterResult): T { if (result.isErr()) throw result.error; return result.value; diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index bd1b0982b..eb3335189 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -46,6 +46,16 @@ export interface LocalAgentRuntimeContext { agentDir?: string; } +export type LocalAgentRuntimeScope = "instance" | "workspace" | "agent"; +export type LocalAgentRuntimeAuthority = "none" | "write_mode" | "full_access_boundary"; + +export interface LocalAgentRuntimePolicy { + scope: LocalAgentRuntimeScope; + authority?: LocalAgentRuntimeAuthority; + idleTimeoutMs?: number; + sessionIdleTimeoutMs?: number; +} + /** * A runtime is deliberately disposable. Nothing from this interface is * persisted; the provider session ID in LocalAgentStore is the continuation @@ -65,7 +75,6 @@ export interface LocalAgentRuntime { export interface LocalAgentDriver { readonly providerInstanceId: LocalAgentProviderInstanceId; readonly provider: LocalAgentDriverKind; - runtimeKey(context: LocalAgentRuntimeContext): string; + readonly runtimePolicy: LocalAgentRuntimePolicy; createRuntime(context: LocalAgentRuntimeContext): Promise>; - readonly idleTimeoutMs?: number; } From 801d0ae83aa01042caf1d5323119d7da70d6fc6b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:26:43 +0530 Subject: [PATCH 15/26] test(subagents): assert runtime independence without counting runtimes Co-Authored-By: Claude Sonnet 5.5 --- src/local-agent-manager.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index f3a648d4a..e47c1faf6 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -391,7 +391,7 @@ const second = unwrap(await manager.start({ })); await waitFor(() => getRecord(second.id).status === "idle"); assert.notEqual(first.id, second.id); -assert.equal(runtimes.size, 6, "different agents receive independent logical runtimes"); +assert.notEqual(runtimes.get(first.id), runtimes.get(second.id), "different agents receive independent logical runtimes"); const failed = unwrap(await manager.start({ target: "reviewer", From 9e75976535f003542c587cb26ab3fa8ae9154314 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:05:04 +0530 Subject: [PATCH 16/26] refactor(subagents): normalize ACP provider identity --- src/config-migration.ts | 4 +-- src/db/migrations.ts | 13 ++++++++++ src/local-agent-availability.ts | 24 +++++++++++------- src/local-agent-config.test.ts | 23 ++++++++++++++++++ src/local-agent-config.ts | 43 ++++++++++++++++++++++++++++----- src/local-agent-provider.ts | 24 +++++++++++++++--- src/local-agent-store.test.ts | 6 ++--- src/oauth-store.test.ts | 1 + src/onboarding.ts | 10 +++++--- 9 files changed, 121 insertions(+), 27 deletions(-) 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 40db50d5f..a047306f7 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-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-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 ecfd6ad71..aa5d48456 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"); const inspect = new Database(databasePath(legacyStateDir), { readonly: true }); const driverColumn = (inspect.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ name: string; notnull: number }>) .find((column) => column.name === "driver"); 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), }; }), From 143eb3af6c25435647ef4185282568a33c796cff Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:05:04 +0530 Subject: [PATCH 17/26] refactor(acp): support generic provider instances --- src/local-agent-acp.test.ts | 32 +++++++------- src/local-agent-acp.ts | 85 +++++++++++++++++++++++-------------- src/local-agent-adapters.ts | 16 ++++--- src/local-agent-errors.ts | 4 +- src/local-agent-grok.ts | 2 +- 5 files changed, 81 insertions(+), 58 deletions(-) diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index c4b926eb6..dd0077064 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 9edea84c6..84838146f 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; @@ -43,7 +44,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"], @@ -69,7 +70,7 @@ interface AcpSessionQueue { } export interface AcpRuntimeOptions { - provider: AcpProvider; + flavor: AcpFlavor; command: string; args: string[]; env: NodeJS.ProcessEnv; @@ -85,7 +86,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; @@ -102,7 +104,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 }; @@ -172,7 +174,7 @@ export class AcpRuntime implements LocalAgentRuntime { cancelDeadline?.catch(() => undefined); 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, @@ -336,7 +338,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); } } @@ -348,7 +350,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; } @@ -375,11 +377,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); } } @@ -449,8 +451,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", @@ -465,14 +467,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) { @@ -492,7 +504,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, @@ -534,13 +546,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" } }; @@ -580,7 +592,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, @@ -626,7 +638,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; @@ -653,22 +665,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[] { @@ -694,6 +714,7 @@ export function acpCommandArgs( "stdio", ]; } + if (provider === "generic") return []; const sandboxArgs = writeMode === "full_access" ? ["--no-sandbox"] : ["--experimental", "--sandbox"]; @@ -794,7 +815,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-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, From eb7dadf665c20bd0549dae21b2e07a8d6fb00ac4 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:05:04 +0530 Subject: [PATCH 18/26] docs(acp): document generic driver configuration --- docs/agent-profile-schema.md | 10 +++++----- docs/configuration.md | 26 ++++++++++++++++++++------ schema/v1/devspace.schema.json | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 11 deletions(-) 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": [ From 7e558b91bf10829dab6c55a89e874183be3bbb11 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:15:26 +0530 Subject: [PATCH 19/26] fix(acp): keep flavor for named ACP instances and align preflight with startup A named instance using a legacy ACP driver (driver: "cursor") lost its flavor and started without the provider's required arguments, while a built-in id paired with another driver was wrongly given ACP flavor config. Availability now resolves the ACP executable through the same function the driver uses, and configured built-in ACP ids are no longer listed twice or dropped. Co-Authored-By: Claude Sonnet 5.5 --- src/local-agent-adapters.ts | 3 ++- src/local-agent-availability.test.ts | 26 ++++++++++++++++++++++++++ src/local-agent-availability.ts | 28 ++++++++++++++++++---------- src/local-agent-config.test.ts | 10 ++++++++++ src/local-agent-config.ts | 28 ++++++++++++++++++++-------- 5 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 106597e6b..e8470bd2b 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,4 +1,5 @@ import { + subagentAcpFlavor, type SubagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; @@ -67,7 +68,7 @@ export function createLocalAgentProviderRegistry( env, command: instance.command, args: instance.config?.args, - flavor: instance.config?.flavor, + flavor: subagentAcpFlavor(instance), })); } diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 6088a0a7e..50751cf96 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -60,3 +60,29 @@ assert.equal( rmSync(directory, { recursive: true, force: true }); } } + +{ + const directory = mkdtempSync(join(tmpdir(), "devspace-acp-availability-")); + try { + const grok = join(directory, "grok"); + writeFileSync(grok, "#!/bin/sh\nexit 0\n"); + chmodSync(grok, 0o700); + const env = { PATH: directory }; + const config = { + enabled: true, + instructions: "on-demand" as const, + providers: [ + { id: "cursor", driver: "acp" as const, enabled: true, config: { flavor: "cursor" as const } }, + { id: "cursor-work", driver: "acp" as const, enabled: true, config: { flavor: "grok" as const } }, + { id: "kiro", driver: "acp" as const, enabled: true, command: "kiro" }, + ], + }; + const snapshot = getLocalAgentProviderAvailabilitySnapshot(env, config); + assert.deepEqual(snapshot.filter((provider) => provider.name === "cursor").length, 1, "configured built-in ACP ids are listed once"); + assert.equal(snapshot.find((provider) => provider.name === "cursor-work")?.available, true, "preflight checks the flavor's executable"); + assert.equal(snapshot.find((provider) => provider.name === "kiro")?.reason, "kiro executable not found"); + assert.equal(snapshot.find((provider) => provider.name === "cursor")?.available, false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index a18eb8989..030002c11 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,12 +1,13 @@ import { resolveExecutableCommand } from "./local-agent-command.js"; +import { resolveAcpCommand } from "./local-agent-acp.js"; import { localAgentProviderEnvironment, + subagentAcpFlavor, subagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; import { defaultDriverForProviderId, - isLocalAgentDriverKind, LOCAL_AGENT_DEFAULT_PROVIDER_IDS, type LocalAgentDriverKind, type LocalAgentProviderInstanceId, @@ -24,9 +25,10 @@ export function getLocalAgentProviderAvailabilitySnapshot( config?: SubagentsConfig, ): LocalAgentProviderAvailability[] { const configured = new Map(config?.providers.map((provider) => [provider.id, provider]) ?? []); + const defaultIds = new Set(LOCAL_AGENT_DEFAULT_PROVIDER_IDS); const instances = [ ...LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => configured.get(id) ?? { id, driver: defaultDriverForProviderId(id)! }), - ...(config?.providers.filter((provider) => !isLocalAgentDriverKind(provider.id)) ?? []), + ...(config?.providers.filter((provider) => !defaultIds.has(provider.id)) ?? []), ]; return instances.map((instance) => checkLocalAgentProviderAvailability( instance.id, @@ -55,15 +57,21 @@ function checkLocalAgentProviderAvailability( case "pi": return packageAvailability(providerInstanceId, "@earendil-works/pi-coding-agent"); case "acp": { + // Resolve exactly as the driver does, so preflight and startup agree on the executable. 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" }; + const flavor = subagentAcpFlavor({ id: providerInstanceId, config: configured?.config }); + if (resolveAcpCommand(flavor ?? "generic", providerEnv, configured?.command)) { + return { name: providerInstanceId, available: true }; + } + return { + name: providerInstanceId, + available: false, + reason: configured?.command + ? `${configured.command} executable not found` + : flavor + ? `${flavor} executable not found` + : "ACP command is not configured", + }; } } } diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 410516313..3ab24b133 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -138,6 +138,16 @@ const legacyAcp = parseSubagentsConfig({ }); assert.equal(legacyAcp.providers[0]?.driver, "acp"); assert.deepEqual(legacyAcp.providers[0]?.config, { flavor: "cursor" }); +const namedLegacyAcp = parseSubagentsConfig({ + enabled: true, + providers: [ + { id: "cursor-work", driver: "cursor", enabled: true, command: "cursor-agent" }, + { id: "grok", driver: "codex", enabled: true }, + ], +}); +assert.equal(namedLegacyAcp.providers[0]?.driver, "acp"); +assert.deepEqual(namedLegacyAcp.providers[0]?.config, { flavor: "cursor" }, "a named legacy-driver instance keeps its flavor"); +assert.equal(namedLegacyAcp.providers[1]?.config, undefined, "a built-in id with another driver is not an ACP provider"); assert.throws( () => subagentsConfigSchema.parse({ enabled: true, diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index ba2869493..9a61198ca 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -115,18 +115,30 @@ export type StoredSubagentsConfig = z.infer; export function resolveSubagentsConfig(config: ParsedSubagentsConfig): SubagentsConfig { return { ...config, - providers: config.providers.map((provider) => ({ - ...provider, - driver: provider.driver + providers: config.providers.map((provider) => { + const 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 } } - : {}), - })), + : defaultDriverForProviderId(provider.id)!; + // `driver: "cursor"` (or a built-in id) predates generic ACP; keep it as the ACP flavor. + const flavor = provider.config?.flavor + ?? (provider.driver && isLegacyAcpDriverKind(provider.driver) ? provider.driver : undefined) + ?? (isLegacyAcpDriverKind(provider.id) ? provider.id : undefined); + return { + ...provider, + driver, + ...(driver === "acp" && flavor ? { config: { ...provider.config, flavor } } : {}), + }; + }), }; } +/** Built-in ids (`cursor`, `copilot`, `grok`) imply their flavor even when a config object omits it. */ +export function subagentAcpFlavor( + provider: Pick, +): typeof LEGACY_ACP_DRIVER_KINDS[number] | undefined { + return provider.config?.flavor ?? (isLegacyAcpDriverKind(provider.id) ? provider.id : undefined); +} + export function parseSubagentsConfig(value: unknown): SubagentsConfig { return resolveSubagentsConfig(subagentsConfigSchema.parse(value)); } From 175fce51d56bb0f81b1f495e01c48dfa458686f2 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:21:18 +0530 Subject: [PATCH 20/26] test(acp): describe cancel tests with flavor options Co-Authored-By: Claude Sonnet 5.5 --- src/local-agent-acp.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index dd0077064..fb91e3acf 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -482,7 +482,7 @@ assert.equal(resumedRuntime.isAlive(), false); const stuckRequests: string[] = []; const stuckNotifications: string[] = []; const stuckRuntime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -512,7 +512,7 @@ assert.equal(stuckRuntime.isAlive(), false, "an unresponsive provider is dropped // Stop requested before the prompt is sent must not start provider work. const preAbortRequests: string[] = []; const preAbortRuntime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, From 2eb50b7f4937fab1f3ceb0755e4f2db0243c0727 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:51:57 +0530 Subject: [PATCH 21/26] fix(subagents): apply a continuation profile only for its provider instance Co-Authored-By: Claude Opus 5.5 --- src/local-agent-manager.test.ts | 12 +++++++++++- src/local-agent-manager.ts | 5 ++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index b3a39cd0b..7b84ebf7a 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -127,6 +127,8 @@ function providerFailure(message: string): AgentProviderExecutionError { }); } +// Profiles added after an agent started, to exercise continuation lookups. +const lateProfiles: LocalAgentProfile[] = []; const store = new LocalAgentStore(stateDir); const stale = store.create({ workspaceId: scope.workspaceId, @@ -142,7 +144,7 @@ const manager = new LocalAgentManager({ store, drivers: [driver, workDriver, shadowDriver], pool: new LocalAgentRuntimePool(), - loadProfiles: async () => [profile, disabledProfile, shadowProfile], + loadProfiles: async () => [profile, disabledProfile, shadowProfile, ...lateProfiles], allowedRoots: [root], subagents, }); @@ -198,6 +200,14 @@ assert.equal(namedInstance.driver, "codex"); assert.equal(namedInstance.model, "gpt-work"); await waitFor(() => getRecord(namedInstance.id).status === "idle"); +// A later same-name profile for another provider instance does not apply to the stored instance. +lateProfiles.push({ ...profile, name: "codex-work", provider: "claude", body: "Claude profile body." }); +unwrap(await manager.continue(namedInstance.id, "again", {}, scope)); +await waitFor(() => runtimes.get(namedInstance.id)?.inputs.length === 2); +assert.equal(runtimes.get(namedInstance.id)?.inputs[1]?.prompt, "again"); +await waitFor(() => getRecord(namedInstance.id).status === "idle"); +lateProfiles.length = 0; + // A profile shadows a provider instance with the same name, on start and on every continuation. const shadowed = unwrap(await manager.start({ target: "codex-shadow", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 8bc8456ff..2f259d137 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -496,7 +496,10 @@ export class LocalAgentManager { profiles: readonly LocalAgentProfile[], ): BetterResult { // Profiles win over provider instances of the same name, matching start-time target resolution. - const profile = profiles.find((candidate) => candidate.name === record.profileName); + // A same-name profile for another provider instance would not match the runtime that executes the turn. + const profile = profiles.find( + (candidate) => candidate.name === record.profileName && candidate.provider === record.providerInstanceId, + ); if (!profile && record.profileName === record.providerInstanceId) return Result.ok(undefined); if (!profile) { return Result.err(new AgentTargetError({ From a6b66ff290af6d83f076757698844a3252b74fec Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:20:36 +0530 Subject: [PATCH 22/26] fix(opencode): ignore stale assistant messages and drain probe output A continued v2 turn that fails before output no longer returns the previous turn's assistant message, and the version probe reads output after stdio closes rather than on exit. Co-Authored-By: Claude Opus 5.5 --- src/local-agent-opencode-v2.test.ts | 25 +++++++++++++++++++++++++ src/local-agent-opencode-v2.ts | 16 ++++++++++------ src/local-agent-opencode-version.ts | 3 ++- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/local-agent-opencode-v2.test.ts b/src/local-agent-opencode-v2.test.ts index 805d8d6ef..ccc04577a 100644 --- a/src/local-agent-opencode-v2.test.ts +++ b/src/local-agent-opencode-v2.test.ts @@ -265,3 +265,28 @@ if (failedRun.isErr()) { assert.match(failedRun.error.message, /invalid_grant/); } await failedRuntime.close(); + +// A continued turn that fails before output must not return the previous turn's response. +const staleClient = { + ...client, + session: { + ...client.session, + async prompt() { + return { id: "inbox", sessionID: "ses_v2", time: { created: 5 }, type: "user", payload: { text: "x" }, delivery: "queue" }; + }, + }, + message: { + async list(input: { type?: string }) { + return { data: input.type === "assistant" ? [assistant] : [failedIdle], cursor: { previous: null, next: null } }; + }, + }, +} as unknown as OpencodeV2ClientLike; +const staleRuntime = new OpencodeV2Runtime(staleClient, { close: () => undefined }); +const staleRun = await staleRuntime.run({ + prompt: "continued but failing", + workspaceRoot: "/tmp/project", + providerSessionId: "opencode:v2:ses_v2", +}); +assert.equal(staleRun.isErr(), true); +if (staleRun.isErr()) assert.match(staleRun.error.message, /invalid_grant/); +await staleRuntime.close(); diff --git a/src/local-agent-opencode-v2.ts b/src/local-agent-opencode-v2.ts index 388572bd8..3bd41ee58 100644 --- a/src/local-agent-opencode-v2.ts +++ b/src/local-agent-opencode-v2.ts @@ -72,15 +72,18 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { const providerSessionId = encodeOpenCodeV2SessionId(nativeSessionId); await callbacks?.onSessionId?.(providerSessionId); if (continuing) await this.configureSession(nativeSessionId, input); - const messages = await this.prompt(nativeSessionId, input); - if (messages.data.length === 0) await this.assertTurnNotFailed(nativeSessionId); - const assistant = requireLatestAssistant(messages.data); + const { promptedAt, messages } = await this.prompt(nativeSessionId, input); + // On a continued session the latest assistant message may predate this + // prompt when the new turn failed before producing output. + const fresh = messages.data.filter((message) => message.time.created >= promptedAt); + if (fresh.length === 0) await this.assertTurnNotFailed(nativeSessionId); + const assistant = requireLatestAssistant(fresh); assertOpenCodeV2AssistantSucceeded(assistant); return { provider: this.provider, providerSessionId, finalResponse: requireOpenCodeV2FinalResponse(assistant), - items: messages.data, + items: fresh, }; } catch (error) { if (isOpenCodeTransportFailure(error)) { @@ -180,17 +183,18 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { controller.abort(); }, this.promptTimeoutMs); try { - await this.client.session.prompt({ + const prompted = await this.client.session.prompt({ sessionID: sessionId, text: input.prompt, }, { signal: controller.signal }); await this.client.session.wait({ sessionID: sessionId }, { signal: controller.signal }); - return await this.client.message.list({ + const messages = await this.client.message.list({ sessionID: sessionId, limit: 1, order: "desc", type: "assistant", }, { signal: controller.signal }); + return { promptedAt: prompted.time.created, messages }; } catch (error) { if (!timedOut) throw error; throw new AgentProviderProtocolError({ diff --git a/src/local-agent-opencode-version.ts b/src/local-agent-opencode-version.ts index 98984102b..d4e8e452d 100644 --- a/src/local-agent-opencode-version.ts +++ b/src/local-agent-opencode-version.ts @@ -113,7 +113,8 @@ function runOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { : `OpenCode version probe failed to start: ${cause.message}`, })); }); - child.once("exit", (code) => { + // `close` waits for stdout/stderr to drain; `exit` can fire before the version is read. + child.once("close", (code) => { if (settled) return; settled = true; clearTimeout(timer); From ddbcad7d913088f079b56f92a2d34e0f9e43526b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:23:37 +0530 Subject: [PATCH 23/26] fix(acp): scope ACP runtimes to one agent A stop that outlives the cancel grace period closes the runtime, which must not end other agents' turns sharing the same workspace. Co-Authored-By: Claude Opus 5.5 --- src/local-agent-acp.test.ts | 2 ++ src/local-agent-acp.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index c4b926eb6..1f4433fd3 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -296,6 +296,8 @@ const missingCommandDriver = new AcpLocalAgentDriver( process.env, () => join(tmpdir(), "devspace-definitely-missing-acp-command"), ); +// A stop that outlives the cancel grace period closes the runtime, so it must not be shared. +assert.equal(missingCommandDriver.runtimePolicy.scope, "agent"); const missingCommand = await missingCommandDriver.createRuntime(cachedContext); assert.equal(missingCommand.isErr(), true); if (missingCommand.isErr()) { diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 9edea84c6..5210b2633 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -451,8 +451,10 @@ export class AcpRuntime implements LocalAgentRuntime { export class AcpLocalAgentDriver implements LocalAgentDriver { readonly provider: AcpProvider; readonly providerInstanceId: string; + // Agent-scoped: a stop that outlives the cancel grace period closes the + // runtime, which must not take other agents' turns down with it. readonly runtimePolicy = { - scope: "workspace", + scope: "agent", authority: "write_mode", idleTimeoutMs: 5 * 60_000, } as const; From a0767cd3c69f0e4b96f6613b102e373ba4e5a11f Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:23:37 +0530 Subject: [PATCH 24/26] fix(opencode): report stopped only after OpenCode confirms the interrupt The local request now ends only once the native abort (v1) or interrupt (v2) succeeds. A failed or stalled one keeps the turn running, so the manager reports a stop conflict instead of a false stopped state. Co-Authored-By: Claude Opus 5.5 --- src/local-agent-opencode-v2.test.ts | 25 +++++++++++++++++++++ src/local-agent-opencode-v2.ts | 10 +++++---- src/local-agent-opencode.test.ts | 34 ++++++++++++++--------------- src/local-agent-opencode.ts | 11 +++++----- 4 files changed, 54 insertions(+), 26 deletions(-) diff --git a/src/local-agent-opencode-v2.test.ts b/src/local-agent-opencode-v2.test.ts index ccc04577a..5485462aa 100644 --- a/src/local-agent-opencode-v2.test.ts +++ b/src/local-agent-opencode-v2.test.ts @@ -290,3 +290,28 @@ const staleRun = await staleRuntime.run({ assert.equal(staleRun.isErr(), true); if (staleRun.isErr()) assert.match(staleRun.error.message, /invalid_grant/); await staleRuntime.close(); + +// Stop ends the turn only once OpenCode confirms the interrupt; a failed interrupt leaves it running. +const interruptRun = async (interrupt: () => Promise) => { + const interruptClient = { + ...client, + session: { + ...client.session, + interrupt, + async wait(_input: unknown, options?: { signal?: AbortSignal }) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }); + }, + }, + } as unknown as OpencodeV2ClientLike; + const interruptRuntime = new OpencodeV2Runtime(interruptClient, { close: () => undefined }, 100); + const stop = new AbortController(); + const run = interruptRuntime.run({ prompt: "stop me", workspaceRoot: "/tmp/project", signal: stop.signal }); + setTimeout(() => stop.abort(), 10); + const result = await run; + await interruptRuntime.close(); + return result.isErr() ? result.error.code : "ok"; +}; +assert.equal(await interruptRun(async () => ({ interrupted: true })), "PROVIDER_CANCELLED"); +assert.equal(await interruptRun(async () => { throw new Error("interrupt failed"); }), "PROVIDER_PROTOCOL_ERROR"); diff --git a/src/local-agent-opencode-v2.ts b/src/local-agent-opencode-v2.ts index df80d97cf..2f5ce3da4 100644 --- a/src/local-agent-opencode-v2.ts +++ b/src/local-agent-opencode-v2.ts @@ -179,11 +179,12 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { const controller = new AbortController(); this.promptControllers.add(controller); let timedOut = false; - // Abort the local request first so a stalled provider interrupt cannot keep the turn pending. + // End the local request only once OpenCode confirms the interrupt (`interrupted: false` + // means the session was already idle). A failed or stalled interrupt keeps the turn + // running, and the manager reports the stop as a conflict. const removeAbort = bindLocalAgentAbort(input.signal, async () => { - const interrupting = this.client.session.interrupt({ sessionID: sessionId }); + await this.client.session.interrupt({ sessionID: sessionId }); controller.abort(); - await interrupting; }); const timer = setTimeout(() => { timedOut = true; @@ -203,7 +204,8 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { }, { signal: controller.signal }); return { promptedAt: prompted.time.created, messages }; } catch (error) { - if (input.signal?.aborted) throw localAgentCancelledError("opencode", "prompt", error); + // A provider timeout after a stop request is still a timeout: the interrupt was never confirmed. + if (input.signal?.aborted && !timedOut) throw localAgentCancelledError("opencode", "prompt", error); if (!timedOut) throw error; throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 8839c3f44..648d041d2 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -237,33 +237,33 @@ if (timedOutPrompt.isErr()) { } await timeoutRuntime.close(); -// Stop cancels the local request even when the provider's abort call never returns. -const stalledAbortClient = { +// Stop ends the turn only once OpenCode confirms the abort; a stalled abort leaves it running. +const abortClient = (abort: () => Promise) => ({ global: { async health() { return { data: { healthy: true } }; }, }, session: { - async create() { return { data: { id: "session_stalled_abort" } }; }, - abort() { return new Promise(() => undefined); }, + async create() { return { data: { id: "session_abort" } }; }, + abort, async prompt(_input: unknown, options?: { signal?: AbortSignal }) { return new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); }); }, }, -} as unknown as OpencodeClientLike; -const stalledAbortRuntime = new OpencodeRuntime(stalledAbortClient, { close: () => undefined }); -const stalledAbort = new AbortController(); -const stalledAbortRun = stalledAbortRuntime.run({ - prompt: "stop me", - workspaceRoot: "/tmp/project", - signal: stalledAbort.signal, -}); -setTimeout(() => stalledAbort.abort(), 10); -const stalledAbortResult = await stalledAbortRun; -assert.equal(stalledAbortResult.isErr(), true); -if (stalledAbortResult.isErr()) assert.equal(stalledAbortResult.error.code, "PROVIDER_CANCELLED"); -await stalledAbortRuntime.close(); +}) as unknown as OpencodeClientLike; +const stopRun = async (abort: () => Promise) => { + const stopRuntime = new OpencodeRuntime(abortClient(abort), { close: () => undefined }, 100); + const stop = new AbortController(); + const run = stopRuntime.run({ prompt: "stop me", workspaceRoot: "/tmp/project", signal: stop.signal }); + setTimeout(() => stop.abort(), 10); + const result = await run; + await stopRuntime.close(); + return result.isErr() ? result.error.code : "ok"; +}; +assert.equal(await stopRun(async () => ({ data: true })), "PROVIDER_CANCELLED"); +assert.equal(await stopRun(async () => ({ error: { name: "UnknownError" } })), "PROVIDER_PROTOCOL_ERROR"); +assert.equal(await stopRun(() => new Promise(() => undefined)), "PROVIDER_PROTOCOL_ERROR"); assert.equal(opencodeAgentFor("read_only"), "devspace_read_only"); assert.equal(opencodeAgentFor("full_access"), "devspace_full_access"); diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index cdde0a0c1..c8ecdc4ba 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -126,14 +126,14 @@ export class OpencodeRuntime implements LocalAgentRuntime { const controller = new AbortController(); this.promptControllers.add(controller); let timedOut = false; - // Abort the local request first so a stalled provider abort cannot keep the turn pending. + // End the local request only once OpenCode confirms the abort. A failed or + // stalled abort keeps the turn running, and the manager reports the stop as a conflict. const removeAbort = bindLocalAgentAbort(input.signal, async () => { - const aborting = this.client.session.abort({ + const aborted = await this.client.session.abort({ sessionID: sessionId, directory: input.workspaceRoot, }, { throwOnError: false }); - controller.abort(); - await aborting; + if (!aborted.error) controller.abort(); }); const timer = setTimeout(() => { timedOut = true; @@ -142,7 +142,8 @@ export class OpencodeRuntime implements LocalAgentRuntime { try { return await promptOpencodeSession(this.client, sessionId, input, controller.signal); } catch (error) { - if (input.signal?.aborted) throw localAgentCancelledError("opencode", "prompt", error); + // A provider timeout after a stop request is still a timeout: the abort was never confirmed. + if (input.signal?.aborted && !timedOut) throw localAgentCancelledError("opencode", "prompt", error); if (!timedOut) throw error; throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", From 06faf8fada5c68cd606779f54e5236e1488de296 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:27:49 +0530 Subject: [PATCH 25/26] fix(acp): reject config.args for built-in ACP flavors Cursor, Copilot and Grok derive their sandbox flags from the write mode; replacing their arguments would drop those restrictions. Co-Authored-By: Claude Opus 5.5 --- src/local-agent-config.test.ts | 7 +++++++ src/local-agent-config.ts | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 3ab24b133..f7088358c 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -132,6 +132,13 @@ assert.deepEqual(genericAcp.providers[0], { command: "kiro", config: { args: ["acp"] }, }); +// Built-in ACP flavors derive sandbox flags from the write mode; custom args would drop them. +for (const provider of [ + { id: "cursor", driver: "cursor", enabled: true, config: { args: ["acp"] } }, + { id: "copilot-work", driver: "acp", enabled: true, command: "copilot", config: { flavor: "copilot", args: ["--acp"] } }, +]) { + assert.throws(() => parseSubagentsConfig({ enabled: true, providers: [provider] }), /config\.args applies only to generic/); +} const legacyAcp = parseSubagentsConfig({ enabled: true, providers: [{ id: "cursor", driver: "cursor", enabled: true }], diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 9a61198ca..16ed58db6 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -67,6 +67,16 @@ const providerSchema = z.object({ message: `${driver} does not support ACP config.`, }); } + const flavor = provider.config?.flavor + ?? (provider.driver && isLegacyAcpDriverKind(provider.driver) ? provider.driver : undefined) + ?? (isLegacyAcpDriverKind(provider.id) ? provider.id : undefined); + if (driver === "acp" && flavor && provider.config?.args !== undefined) { + context.addIssue({ + code: "custom", + path: ["config", "args"], + message: `${flavor} sets its own ACP arguments for each write mode; config.args applies only to generic ACP providers.`, + }); + } if (driver === "acp" && !provider.command && !isLegacyAcpDriverKind(provider.id)) { context.addIssue({ code: "custom", From a4770bd36a500383e3bf78739480f624f0fc44aa Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:27:49 +0530 Subject: [PATCH 26/26] fix(acp): run generic ACP providers only with full_access DevSpace cannot bound what an arbitrary ACP agent writes, since it may act without a permission request. Restricted write modes are refused instead of being silently unenforced. Co-Authored-By: Claude Opus 5.5 --- docs/configuration.md | 3 +++ src/local-agent-acp.test.ts | 7 +++++++ src/local-agent-acp.ts | 19 ++++++++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2a86dcc5d..4272ee121 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -216,6 +216,9 @@ executable and may provide fixed startup arguments under `config.args`: `config.flavor` is reserved for the small built-in protocol quirks currently needed by `cursor`, `copilot`, and `grok`; generic ACP agents normally omit it. +Those built-in flavors derive their sandbox arguments from the write mode, so +they reject `config.args`. DevSpace cannot enforce `read_only` or `allowed` on an +arbitrary ACP agent, so generic ACP instances run only with `full_access`. Existing process-level overrides remain supported: `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 69f8417c5..df0ca63c3 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -298,6 +298,13 @@ const missingCommandDriver = new AcpLocalAgentDriver({ }); // A stop that outlives the cancel grace period closes the runtime, so it must not be shared. assert.equal(missingCommandDriver.runtimePolicy.scope, "agent"); +// Generic ACP agents cannot be held to a restricted write mode, so they run only with full_access. +const genericDriver = new AcpLocalAgentDriver({ command: process.execPath, args: ["-e", ""] }); +for (const writeMode of ["read_only", "allowed"] as const) { + const restricted = await genericDriver.createRuntime({ ...cachedContext, writeMode }); + assert.equal(restricted.isErr(), true); + if (restricted.isErr()) assert.match(restricted.error.message, /full_access/); +} const missingCommand = await missingCommandDriver.createRuntime(cachedContext); assert.equal(missingCommand.isErr(), true); if (missingCommand.isErr()) { diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index c2812e531..d12bf473f 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -3,6 +3,7 @@ import { createRequire } from "node:module"; import { resolve } from "node:path"; import { Readable, Writable } from "node:stream"; import { + AgentProviderExecutionError, AgentProviderProtocolError, AgentProviderUnavailableError, captureAgentProviderResult, @@ -506,7 +507,23 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { message: `${this.provider} executable was not found.`, }); } - const args = this.configuredArgs ?? acpCommandArgs(this.flavor, context, this.env); + if (this.flavor === "generic" && context.writeMode !== "full_access") { + // DevSpace cannot bound what an arbitrary ACP agent writes: it may act + // without asking for permission, so only an explicit full_access turn may run it. + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: this.provider, + agentId: context.agentId, + operation: "create_runtime", + retryable: false, + message: `Generic ACP providers cannot enforce the ${context.writeMode ?? "allowed"} write mode; run them with full_access.`, + }); + } + // Built-in flavors derive their sandbox flags from the write mode, so only + // generic providers take configured arguments. + const args = this.flavor === "generic" + ? this.configuredArgs ?? [] + : acpCommandArgs(this.flavor, context, this.env); const child = spawn(command, args, { cwd: resolve(context.workspaceRoot), env: this.env,