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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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 32392d340415bc26f5d7def3306fd2bfb873124b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:45:28 +0530 Subject: [PATCH 16/32] feat(pi): add rpc transport --- src/local-agent-pi-rpc.test.ts | 38 +++++++ src/local-agent-pi-rpc.ts | 197 +++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/local-agent-pi-rpc.test.ts create mode 100644 src/local-agent-pi-rpc.ts diff --git a/src/local-agent-pi-rpc.test.ts b/src/local-agent-pi-rpc.test.ts new file mode 100644 index 000000000..432175801 --- /dev/null +++ b/src/local-agent-pi-rpc.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { PiRpcConnection } from "./local-agent-pi-rpc.js"; + +const script = String.raw` +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (!line) continue; + const request = JSON.parse(line); + process.stdout.write("not-json\n"); + process.stdout.write(JSON.stringify({ type: "agent_start" }) + "\n"); + process.stdout.write(JSON.stringify({ + type: "response", + id: request.id, + command: request.type, + success: true, + data: { echoed: request.value }, + }) + "\n"); + } +}); +`; + +const connection = PiRpcConnection.spawn({ + command: process.execPath, + args: ["-e", script], + cwd: process.cwd(), + env: process.env, +}); +const response = await connection.request({ type: "echo", value: "hello" }); +assert.deepEqual(response, { echoed: "hello" }); +assert.deepEqual(await connection.nextEvent(1_000), { type: "agent_start" }); +connection.close(); diff --git a/src/local-agent-pi-rpc.ts b/src/local-agent-pi-rpc.ts new file mode 100644 index 000000000..25fa4ff40 --- /dev/null +++ b/src/local-agent-pi-rpc.ts @@ -0,0 +1,197 @@ +import { createRequire } from "node:module"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { terminateProcessTree } from "./process-platform.js"; + +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const MAX_RECORD_CHARS = 8 * 1024 * 1024; + +export type PiRpcRecord = Record; + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; + timer: NodeJS.Timeout; +} + +export class PiRpcConnection { + private readonly pending = new Map(); + private readonly queuedEvents: PiRpcRecord[] = []; + private readonly eventWaiters: Array<{ + resolve(value: PiRpcRecord): void; + reject(error: Error): void; + timer?: NodeJS.Timeout; + }> = []; + private nextRequestId = 0; + private buffer = ""; + private closed = false; + private failure?: Error; + + private constructor(private readonly child: ChildProcessWithoutNullStreams) { + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => this.consume(chunk)); + child.once("error", (error) => this.fail(error)); + child.once("exit", (code) => { + if (!this.closed) this.fail(new Error(`Pi RPC process exited with code ${code}.`)); + }); + } + + static spawn(input: { + command: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + }): PiRpcConnection { + const detached = process.platform !== "win32"; + const child = spawn(input.command, input.args, { + cwd: input.cwd, + env: input.env, + stdio: ["pipe", "pipe", "pipe"], + detached, + windowsHide: true, + }); + if (!child.stdin || !child.stdout || !child.stderr) { + throw new Error("Pi RPC process did not expose stdio pipes."); + } + return new PiRpcConnection(child as ChildProcessWithoutNullStreams); + } + + async request(record: PiRpcRecord, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): Promise { + this.assertOpen(); + const id = `devspace-${++this.nextRequestId}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pi RPC ${String(record.type ?? "request")} timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + timer.unref(); + this.pending.set(id, { resolve, reject, timer }); + this.write({ ...record, id }).catch((error) => { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + }); + }); + } + + async send(record: PiRpcRecord): Promise { + this.assertOpen(); + await this.write(record); + } + + async nextEvent(timeoutMs?: number): Promise { + this.assertOpen(); + const queued = this.queuedEvents.shift(); + if (queued) return queued; + return new Promise((resolve, reject) => { + const waiter: (typeof this.eventWaiters)[number] = { + resolve: (value) => { + if (waiter.timer) clearTimeout(waiter.timer); + resolve(value); + }, + reject: (error) => { + if (waiter.timer) clearTimeout(waiter.timer); + reject(error); + }, + }; + if (timeoutMs !== undefined) { + waiter.timer = setTimeout(() => { + const index = this.eventWaiters.indexOf(waiter); + if (index >= 0) this.eventWaiters.splice(index, 1); + resolve(undefined); + }, timeoutMs); + waiter.timer.unref(); + } + this.eventWaiters.push(waiter); + }); + } + + isAlive(): boolean { + return !this.closed && !this.failure && this.child.exitCode === null; + } + + close(): void { + if (this.closed) return; + this.closed = true; + const detached = process.platform !== "win32"; + terminateProcessTree(this.child, "SIGTERM", detached); + this.fail(new Error("Pi RPC process closed.")); + } + + private async write(record: PiRpcRecord): Promise { + const line = `${JSON.stringify(record)}\n`; + await new Promise((resolve, reject) => { + this.child.stdin.write(line, (error) => error ? reject(error) : resolve()); + }); + } + + private consume(chunk: string): void { + this.buffer += chunk; + if (this.buffer.length > MAX_RECORD_CHARS && !this.buffer.includes("\n")) { + this.buffer = ""; + return; + } + for (;;) { + const newline = this.buffer.indexOf("\n"); + if (newline < 0) return; + let line = this.buffer.slice(0, newline); + this.buffer = this.buffer.slice(newline + 1); + if (line.endsWith("\r")) line = line.slice(0, -1); + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue; + this.route(parsed as PiRpcRecord); + } + } + + private route(record: PiRpcRecord): void { + if (record.type === "response" && typeof record.id === "string") { + const pending = this.pending.get(record.id); + if (pending) { + this.pending.delete(record.id); + clearTimeout(pending.timer); + if (record.success === true) pending.resolve(record.data); + else pending.reject(new Error(String(record.error ?? `Pi RPC ${String(record.command)} failed.`))); + return; + } + } + const waiter = this.eventWaiters.shift(); + if (waiter) waiter.resolve(record); + else this.queuedEvents.push(record); + } + + private assertOpen(): void { + if (this.failure) throw this.failure; + if (this.closed) throw new Error("Pi RPC process is closed."); + } + + private fail(error: Error): void { + if (!this.failure) this.failure = error; + for (const [id, pending] of this.pending) { + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + } + for (const waiter of this.eventWaiters.splice(0)) waiter.reject(error); + } +} + +export function piRecordString(value: unknown, key: string): string | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const field = (value as Record)[key]; + return typeof field === "string" ? field : undefined; +} + +export function parsePiModelSlug(model: string): { provider: string; modelId: string } | undefined { + const separator = model.indexOf("/"); + if (separator <= 0 || separator === model.length - 1) return undefined; + return { provider: model.slice(0, separator), modelId: model.slice(separator + 1) }; +} 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 17/32] 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 5bc8262b5923580ef10d186fdd51703ab614d894 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:45:28 +0530 Subject: [PATCH 18/32] refactor(pi): run agent through native rpc cli --- src/local-agent-adapters.ts | 6 +- src/local-agent-availability.ts | 4 +- src/local-agent-config.test.ts | 17 +- src/local-agent-config.ts | 4 +- src/local-agent-pi.test.ts | 202 ++++++------ src/local-agent-pi.ts | 541 ++++++++++++++++---------------- 6 files changed, 403 insertions(+), 371 deletions(-) diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index e8470bd2b..765a439a7 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -28,7 +28,7 @@ import { PiLocalAgentDriver, extractPiFinalResponse, extractPiProviderError, - type PiSessionFactory, + type PiRpcFactory, } from "./local-agent-pi.js"; import type { LocalAgentDriver } from "./local-agent-runtime.js"; import { LocalAgentProviderRegistry } from "./local-agent-provider-registry.js"; @@ -40,7 +40,7 @@ export interface LocalAgentDriverOptions { subagents?: SubagentsConfig; claudeQueryFactory?: ClaudeQueryFactory; opencodeFactory?: OpencodeFactory; - piSessionFactory?: PiSessionFactory; + piRpcFactory?: PiRpcFactory; } export function createLocalAgentDrivers( @@ -63,7 +63,7 @@ export function createLocalAgentProviderRegistry( .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("pi", ({ env }) => new PiLocalAgentDriver(options.piRpcFactory, env)) .register("acp", ({ instance, env }) => new AcpLocalAgentDriver({ env, command: instance.command, diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 030002c11..ec540d6ea 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -55,7 +55,9 @@ function checkLocalAgentProviderAvailability( case "opencode": return commandAvailability(providerInstanceId, "opencode", providerEnv); case "pi": - return packageAvailability(providerInstanceId, "@earendil-works/pi-coding-agent"); + return providerEnv.PI_COMMAND + ? commandAvailability(providerInstanceId, providerEnv.PI_COMMAND, providerEnv) + : 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; diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 3ab24b133..9a4b0bc81 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -175,10 +175,15 @@ for (const id of ["opencode", "pi"] as const) { providers: [{ id, enabled: true, env: { HARNESS_ENV: id } }], }); assert.equal(localAgentProviderEnvironment(embedded, id, {}).HARNESS_ENV, id); - assert.throws( - () => subagentsConfigSchema.parse({ - enabled: true, - providers: [{ id, enabled: true, command: "/opt/bin/agent" }], - }), - ); } +assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id: "opencode", enabled: true, command: "/opt/bin/agent" }], + }), +); +const piCommand = parseSubagentsConfig({ + enabled: true, + providers: [{ id: "pi", enabled: true, command: "/opt/bin/pi" }], +}); +assert.equal(localAgentProviderEnvironment(piCommand, "pi", {}).PI_COMMAND, "/opt/bin/pi"); diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 9a61198ca..c4d85a2da 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -53,7 +53,7 @@ const providerSchema = z.object({ }); return; } - if ((driver === "opencode" || driver === "pi") && provider.command !== undefined) { + if (driver === "opencode" && provider.command !== undefined) { context.addIssue({ code: "custom", path: ["command"], @@ -181,8 +181,8 @@ export function providerCommandVariable(driver: LocalAgentDriverKind): string | switch (driver) { case "codex": return "CODEX_COMMAND"; case "claude": return "CLAUDE_COMMAND"; + case "pi": return "PI_COMMAND"; case "opencode": - case "pi": case "acp": return undefined; } diff --git a/src/local-agent-pi.test.ts b/src/local-agent-pi.test.ts index 28b945eab..e3b7cf1e5 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -1,68 +1,87 @@ import assert from "node:assert/strict"; -import type { AgentSessionEvent, AgentSessionEventListener } from "@earendil-works/pi-coding-agent"; import { PiLocalAgentDriver, - type PiSessionFactory, - type PiSessionLike, + type PiRpcFactory, + type PiRpcRuntimeConnection, } from "./local-agent-pi.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; -class FakePiSession implements PiSessionLike { - readonly sessionId = "pi_session_1"; - readonly messages: any[] = []; - readonly modelRegistry = { find: () => ({ id: "model" }) } as unknown as PiSessionLike["modelRegistry"]; - private readonly listeners = new Set(); - disposeCount = 0; - model?: unknown; - effort?: unknown; - activeTools: string[] = []; - toolHistory: string[][] = []; +class FakePiRpcConnection implements PiRpcRuntimeConnection { + readonly requests: Array> = []; + readonly events: Array> = []; + sessionFile = "/sessions/one.jsonl"; + lastPrompt = ""; + alive = true; + cleanupCount = 0; - async prompt(text: string): Promise { - const message = { - role: "assistant", - content: [{ type: "text", text: `response:${text}` }], - }; - this.messages.push(message); - for (const listener of this.listeners) listener({ type: "agent_end" } as AgentSessionEvent); + async request(record: Record): Promise { + this.requests.push(record); + switch (record.type) { + case "new_session": + this.sessionFile = `/sessions/${this.requests.length}.jsonl`; + return { cancelled: false }; + case "switch_session": + this.sessionFile = String(record.sessionPath); + return { cancelled: false }; + case "get_state": + return { + sessionFile: this.sessionFile, + isStreaming: false, + isCompacting: false, + pendingMessageCount: 0, + }; + case "get_available_models": + return { models: [{ provider: "provider", id: "model" }] }; + case "set_model": + case "set_thinking_level": + case "abort": + return undefined; + case "prompt": + this.lastPrompt = String(record.message); + this.events.push( + { type: "agent_start" }, + { type: "agent_end" }, + { type: "agent_settled" }, + ); + return undefined; + case "get_last_assistant_text": + return { text: `response:${this.lastPrompt}` }; + default: + throw new Error(`unexpected request: ${String(record.type)}`); + } } - async abort(): Promise {} + async send(_record: Record): Promise {} - subscribe(listener: AgentSessionEventListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); + async nextEvent(): Promise | undefined> { + return this.events.shift(); } - async setModel(model: any): Promise { - this.model = model; + isAlive(): boolean { + return this.alive; } - setActiveToolsByName(toolNames: string[]): void { - this.activeTools = [...toolNames]; - this.toolHistory.push([...toolNames]); - } - - setThinkingLevel(level: any): void { - this.effort = level; - } - - dispose(): void { - this.disposeCount += 1; + close(): void { + this.alive = false; } } const contexts: LocalAgentRuntimeContext[] = []; -const sessions: FakePiSession[] = []; +const connections: FakePiRpcConnection[] = []; let factoryEnv: NodeJS.ProcessEnv | undefined; -const factory: PiSessionFactory = async (context, _input, env) => { +let cleanupCount = 0; +const factory: PiRpcFactory = async (context, env) => { contexts.push(context); factoryEnv = env; - const session = new FakePiSession(); - sessions.push(session); - return session; + const connection = new FakePiRpcConnection(); + connections.push(connection); + return { + connection, + cleanup: async () => { cleanupCount += 1; }, + }; }; + const driver = new PiLocalAgentDriver(factory, { HARNESS_ENV: "pi" }); const pool = new LocalAgentRuntimePool(); const context: LocalAgentRuntimeContext = { @@ -70,6 +89,7 @@ const context: LocalAgentRuntimeContext = { providerInstanceId: "pi", provider: "pi", workspaceRoot: "/tmp/project", + writeMode: "read_only", }; const sessionIds: string[] = []; @@ -83,75 +103,73 @@ const first = await pool.run(driver, context, { onSessionId: (sessionId) => { sessionIds.push(sessionId); }, }); assert.equal(factoryEnv?.HARNESS_ENV, "pi"); +assert.equal(first.isOk(), true); +if (first.isErr()) throw first.error; +assert.match(first.value.providerSessionId ?? "", /^\/sessions\/\d+\.jsonl$/); +assert.equal(first.value.finalResponse, "response:first"); +assert.deepEqual(sessionIds, [first.value.providerSessionId]); +assert.deepEqual( + connections[0]?.requests.filter((request) => request.type === "set_model")[0], + { type: "set_model", provider: "provider", modelId: "model" }, +); +assert.deepEqual( + connections[0]?.requests.filter((request) => request.type === "set_thinking_level")[0], + { type: "set_thinking_level", level: "high" }, +); +assert.ok(first.value.items.some((event: any) => event.type === "agent_end")); +assert.ok(first.value.items.some((event: any) => event.type === "agent_settled")); + const second = await pool.run(driver, context, { prompt: "second", workspaceRoot: "/tmp/project", - writeMode: "allowed", -}); -await pool.run(driver, context, { - prompt: "third", - workspaceRoot: "/tmp/project", - writeMode: "full_access", -}); -await pool.run(driver, context, { - prompt: "fourth", - workspaceRoot: "/tmp/project", + providerSessionId: first.value.providerSessionId ?? undefined, writeMode: "read_only", }); -assert.equal(contexts.length, 1, "one warm Pi session serves successive turns"); -assert.equal(first.isOk(), true); assert.equal(second.isOk(), true); -if (first.isErr()) throw first.error; -if (second.isErr()) throw second.error; -assert.equal(first.value.providerSessionId, "pi_session_1"); -assert.equal(second.value.finalResponse, "response:second"); -assert.deepEqual(sessions[0]?.model, { id: "model" }); -assert.equal(sessions[0]?.effort, "high"); -assert.deepEqual(sessionIds, ["pi_session_1"]); -assert.deepEqual(sessions[0]?.activeTools, ["read", "grep", "find", "ls"]); -assert.deepEqual(sessions[0]?.toolHistory, [ - ["read", "grep", "find", "ls"], - ["read", "grep", "find", "ls", "edit", "write", "bash"], - ["read", "grep", "find", "ls", "edit", "write", "bash"], - ["read", "grep", "find", "ls"], -]); +assert.equal(contexts.length, 1, "same agent and write mode reuse one Pi RPC process"); -await pool.run(driver, { ...context, providerSessionId: "pi_session_1" }, { - prompt: "fifth", +await pool.run(driver, { ...context, writeMode: "allowed" }, { + prompt: "allowed", workspaceRoot: "/tmp/project", - providerSessionId: "pi_session_1", + providerSessionId: first.value.providerSessionId ?? undefined, writeMode: "allowed", }); -assert.deepEqual(sessions[0]?.activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); +assert.equal(contexts.length, 2, "changing Pi write mode creates a process with the matching fixed permission policy"); +assert.ok(connections[1]?.requests.some((request) => request.type === "switch_session")); await pool.evictIdle(Date.now() + 10 * 60_000); -assert.equal(sessions[0]?.disposeCount, 1, "idle eviction disposes the in-process session"); +assert.equal(cleanupCount, 2, "idle eviction closes external Pi processes and sandbox wrappers"); -await pool.run(driver, { ...context, providerSessionId: "pi_session_1" }, { +await pool.run(driver, { ...context, providerSessionId: first.value.providerSessionId ?? undefined }, { prompt: "resumed", workspaceRoot: "/tmp/project", - providerSessionId: "pi_session_1", + providerSessionId: first.value.providerSessionId ?? undefined, + writeMode: "read_only", }); -assert.equal(contexts.length, 2, "cold continuation creates a new AgentSession"); -assert.equal(contexts[1]?.providerSessionId, "pi_session_1"); -assert.deepEqual(sessions[1]?.activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); +assert.equal(contexts.length, 3, "cold continuation starts a new Pi RPC process"); +assert.ok(connections[2]?.requests.some((request) => request.type === "switch_session")); await pool.close(); -const missingModelSession = new FakePiSession(); -Object.defineProperty(missingModelSession, "modelRegistry", { value: { find: () => undefined } }); -const missingModelDriver = new PiLocalAgentDriver(async () => missingModelSession); -const missingModelRuntime = await missingModelDriver.createRuntime(context); -assert.equal(missingModelRuntime.isOk(), true); -if (missingModelRuntime.isErr()) throw missingModelRuntime.error; -const missingModel = await missingModelRuntime.value.run({ +const missingModel = new FakePiRpcConnection(); +missingModel.request = async function(record: Record): Promise { + this.requests.push(record); + if (record.type === "get_available_models") return { models: [] }; + if (record.type === "new_session") return { cancelled: false }; + if (record.type === "get_state") return { sessionFile: "/sessions/missing.jsonl" }; + throw new Error(`unexpected request: ${String(record.type)}`); +}; +const missingModelDriver = new PiLocalAgentDriver(async () => ({ connection: missingModel })); +const missingRuntime = await missingModelDriver.createRuntime(context); +assert.equal(missingRuntime.isOk(), true); +if (missingRuntime.isErr()) throw missingRuntime.error; +const missingResult = await missingRuntime.value.run({ prompt: "inspect", workspaceRoot: "/tmp/project", - model: "provider/missing-model", + model: "missing-model", }); -assert.equal(missingModel.isErr(), true); -if (missingModel.isErr()) { - assert.equal(missingModel.error.code, "PROVIDER_PROTOCOL_ERROR"); - assert.equal(missingModel.error.retryable, false); - assert.match(missingModel.error.message, /provider\/missing-model/); +assert.equal(missingResult.isErr(), true); +if (missingResult.isErr()) { + assert.equal(missingResult.error.code, "PROVIDER_PROTOCOL_ERROR"); + assert.match(missingResult.error.message, /missing-model/); } -await missingModelRuntime.value.close(); +await missingRuntime.value.close(); diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index 75034382a..5c5243793 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -1,5 +1,9 @@ -import { join } from "node:path"; -import type { AgentSession, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { AgentProviderExecutionError, AgentProviderProtocolError, @@ -7,6 +11,13 @@ import { captureAgentProviderResult, } from "./local-agent-errors.js"; import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import { resolveExecutableCommand } from "./local-agent-command.js"; +import { + PiRpcConnection, + parsePiModelSlug, + piRecordString, + type PiRpcRecord, +} from "./local-agent-pi-rpc.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -15,56 +26,36 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import { - createPiSandboxExtension, - createPiSandboxModeRef, - registerPiSandboxSession, - releasePiSandboxSession, - updatePiSandboxSession, -} from "./local-agent-pi-sandbox.js"; +const require = createRequire(import.meta.url); const PI_READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const; -const PI_WORKSPACE_TOOLS = ["read", "grep", "find", "ls", "edit", "write", "bash"] as const; -const PI_FULL_ACCESS_TOOLS = [...PI_WORKSPACE_TOOLS] as const; +const PI_ALLOWED_TOOLS = [...PI_READ_ONLY_TOOLS, "edit", "write", "bash"] as const; +const PI_TURN_TIMEOUT_MS = 5 * 60_000; +const PI_STATE_POLL_MS = 200; const MAX_PI_EVENTS = 10_000; -export type PiSessionLike = Pick< - AgentSession, - | "sessionId" - | "messages" - | "modelRegistry" - | "prompt" - | "subscribe" - | "setActiveToolsByName" - | "setModel" - | "setThinkingLevel" - | "dispose" - | "abort" ->; +export interface PiRpcRuntimeConnection { + request(record: PiRpcRecord, timeoutMs?: number): Promise; + send(record: PiRpcRecord): Promise; + nextEvent(timeoutMs?: number): Promise; + isAlive(): boolean; + close(): void; +} -export type PiSessionFactory = ( +export type PiRpcFactory = ( context: LocalAgentRuntimeContext, - input: LocalAgentRunInput, - env?: NodeJS.ProcessEnv, -) => Promise; + env: NodeJS.ProcessEnv, +) => Promise<{ connection: PiRpcRuntimeConnection; cleanup?: () => Promise }>; -export class PiSessionRuntime implements LocalAgentRuntime { +export class PiRpcRuntime implements LocalAgentRuntime { readonly provider = "pi" as const; - private readonly unsubscribe: () => void; - private alive = true; + private sessionFile?: string; private closed = false; - private collectingEvents = false; - private events: unknown[] = []; constructor( - private readonly session: PiSessionLike, - ) { - this.unsubscribe = session.subscribe((event) => { - if (!this.collectingEvents) return; - if (this.events.length >= MAX_PI_EVENTS) this.events.shift(); - this.events.push(event); - }); - } + private readonly connection: PiRpcRuntimeConnection, + private readonly cleanup?: () => Promise, + ) {} async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks) { return captureAgentProviderResult({ @@ -77,54 +68,53 @@ export class PiSessionRuntime implements LocalAgentRuntime { provider: this.provider, operation: "run", retryable: true, - message: "Pi runtime is not running.", + message: "Pi RPC process is not running.", }); } - const removeAbort = bindLocalAgentAbort(input.signal, () => this.session.abort()); - try { - await callbacks?.onSessionId?.(this.session.sessionId); + + const providerSessionId = await this.ensureSession(input.providerSessionId); + await callbacks?.onSessionId?.(providerSessionId); await this.applyOverrides(input); - if (input.signal?.aborted) throw localAgentCancelledError("pi", "run"); - this.events = []; - const messageStart = this.session.messages.length; - this.collectingEvents = true; + + const events: PiRpcRecord[] = []; + const removeAbort = bindLocalAgentAbort(input.signal, async () => { + await this.connection.request({ type: "abort" }, 2_000).catch(() => undefined); + }); try { - await this.session.prompt(input.prompt); + await this.connection.request({ type: "prompt", message: input.prompt }); + await this.waitForSettlement(events, input.signal); 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; - } - const currentMessages = this.session.messages.slice(messageStart); - const finalResponse = extractPiFinalResponse({ messages: currentMessages }); - if (!finalResponse) { - const providerError = extractPiProviderError(this.events) || extractPiProviderError(currentMessages); - if (providerError) { - throw new AgentProviderExecutionError({ - code: "PROVIDER_EXECUTION_ERROR", - provider: this.provider, + const response = await this.connection.request({ type: "get_last_assistant_text" }); + const finalResponse = piRecordString(response, "text")?.trim(); + if (!finalResponse) { + const providerError = extractPiProviderError(events); + if (providerError) { + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "pi", + operation: "run", + retryable: false, + cause: new Error(providerError), + message: "Pi agent turn failed.", + }); + } + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "pi", operation: "run", retryable: false, - cause: new Error(providerError), - message: "Pi agent turn failed.", + message: "Pi did not return a final assistant response.", }); } - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", + return { provider: this.provider, - operation: "run", - retryable: false, - message: "Pi did not return a final assistant response.", - }); - } - return { - provider: this.provider, - providerSessionId: this.session.sessionId, - finalResponse, - items: [...this.events, ...currentMessages], - }; + providerSessionId, + finalResponse, + items: events, + }; + } catch (cause) { + if (input.signal?.aborted) throw localAgentCancelledError("pi", "run", cause); + throw cause; } finally { removeAbort(); } @@ -133,43 +123,94 @@ export class PiSessionRuntime implements LocalAgentRuntime { } async releaseSession(_providerSessionId: string): Promise { - // The runtime is already scoped to one logical Pi session. + // Pi owns durable session files independently of this process. } isAlive(): boolean { - return this.alive && !this.closed; + return !this.closed && this.connection.isAlive(); } async close(): Promise { if (this.closed) return; this.closed = true; - this.alive = false; - this.unsubscribe(); - try { - await releasePiSandboxSession(this.session); - } finally { - this.session.dispose(); + this.connection.close(); + await this.cleanup?.(); + } + + private async ensureSession(providerSessionId?: string): Promise { + if (providerSessionId && this.sessionFile !== providerSessionId) { + const result = await this.connection.request({ type: "switch_session", sessionPath: providerSessionId }); + if (recordBoolean(result, "cancelled")) { + throw piProtocolError("resume_session", "A Pi extension cancelled the session switch."); + } + this.sessionFile = undefined; + } else if (!providerSessionId && !this.sessionFile) { + const result = await this.connection.request({ type: "new_session" }); + if (recordBoolean(result, "cancelled")) { + throw piProtocolError("create_session", "A Pi extension cancelled new session creation."); + } + } + if (this.sessionFile) return this.sessionFile; + const state = await this.connection.request({ type: "get_state" }); + const sessionFile = piRecordString(state, "sessionFile"); + if (!sessionFile) { + throw piProtocolError("resolve_session", "Pi get_state returned no persisted session file."); } + this.sessionFile = sessionFile; + return sessionFile; } private async applyOverrides(input: LocalAgentRunInput): Promise { - await updatePiSandboxSession(this.session, input.workspaceRoot, input.writeMode ?? "allowed"); - this.session.setActiveToolsByName([...piToolsForWriteMode(input.writeMode)]); if (input.model) { - const model = resolvePiModel(this.session.modelRegistry, input.model); - if (!model) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - operation: "configure_model", - retryable: false, - message: `Pi model not found: ${input.model}.`, - }); - } - await this.session.setModel(model as never); + const model = await this.resolveModel(input.model); + await this.connection.request({ type: "set_model", provider: model.provider, modelId: model.modelId }); } if (input.effort) { - this.session.setThinkingLevel(input.effort as never); + await this.connection.request({ type: "set_thinking_level", level: input.effort }); + } + } + + private async resolveModel(model: string): Promise<{ provider: string; modelId: string }> { + const parsed = parsePiModelSlug(model); + if (parsed) return parsed; + const available = await this.connection.request({ type: "get_available_models" }); + const models = recordArray(available, "models"); + const matches = models.filter((entry) => piRecordString(entry, "id") === model); + if (matches.length === 1) { + const provider = piRecordString(matches[0], "provider"); + const modelId = piRecordString(matches[0], "id"); + if (provider && modelId) return { provider, modelId }; + } + throw piProtocolError("configure_model", `Pi model not found or ambiguous: ${model}.`); + } + + private async waitForSettlement(events: PiRpcRecord[], signal?: AbortSignal): Promise { + const deadline = Date.now() + PI_TURN_TIMEOUT_MS; + let sawAgentActivity = false; + for (;;) { + if (signal?.aborted) return; + if (Date.now() >= deadline) { + throw piProtocolError("prompt", "Pi did not settle before the provider timeout.", true); + } + const event = await this.connection.nextEvent(PI_STATE_POLL_MS); + if (event) { + if (events.length >= MAX_PI_EVENTS) events.shift(); + events.push(event); + if (event.type === "agent_start" || event.type === "message_start" || event.type === "tool_execution_start") { + sawAgentActivity = true; + } + if (event.type === "extension_ui_request") { + await cancelPiUiRequest(this.connection, event); + continue; + } + if (event.type === "agent_settled") return; + continue; + } + const state = await this.connection.request({ type: "get_state" }, 2_000); + const streaming = recordBoolean(state, "isStreaming"); + const compacting = recordBoolean(state, "isCompacting"); + const pending = recordNumber(state, "pendingMessageCount") ?? 0; + if (!streaming && !compacting && pending === 0 && !sawAgentActivity) return; } } } @@ -177,7 +218,11 @@ export class PiSessionRuntime implements LocalAgentRuntime { export class PiLocalAgentDriver implements LocalAgentDriver { readonly provider = "pi" as const; readonly providerInstanceId = "pi"; - readonly runtimePolicy = { scope: "agent", idleTimeoutMs: 3 * 60_000 } as const; + readonly runtimePolicy = { + scope: "agent", + authority: "write_mode", + idleTimeoutMs: 3 * 60_000, + } as const; readonly capabilities = { sessions: { resume: true, close: false }, turns: { interrupt: true }, @@ -187,8 +232,8 @@ export class PiLocalAgentDriver implements LocalAgentDriver { } as const; constructor( - private readonly factory: PiSessionFactory = defaultPiSessionFactory, - private readonly env: NodeJS.ProcessEnv = {}, + private readonly factory: PiRpcFactory = defaultPiRpcFactory, + private readonly env: NodeJS.ProcessEnv = process.env, ) {} async createRuntime(context: LocalAgentRuntimeContext) { @@ -197,170 +242,113 @@ export class PiLocalAgentDriver implements LocalAgentDriver { agentId: context.agentId, operation: "create_runtime", run: async (): Promise => { - const input: LocalAgentRunInput = { - prompt: "", - workspaceRoot: context.workspaceRoot, - providerSessionId: context.providerSessionId, - writeMode: context.writeMode, - model: context.model, - effort: context.effort, - }; - const session = await this.factory(context, input, this.env); - return new PiSessionRuntime(session); + const { connection, cleanup } = await this.factory(context, this.env); + return new PiRpcRuntime(connection, cleanup); }, }); } } -async function defaultPiSessionFactory( +async function defaultPiRpcFactory( context: LocalAgentRuntimeContext, - input: LocalAgentRunInput, - env: NodeJS.ProcessEnv = {}, -): Promise { - const { - AuthStorage, - ModelRegistry, - SessionManager, - DefaultResourceLoader, - createAgentSession, - getAgentDir, - } = await import("@earendil-works/pi-coding-agent"); - // DevSpace's agentDir is the compatibility directory used for instructions; - // Pi keeps its own native auth, model, and session state under getAgentDir(). - const agentDir = getAgentDir(); - const authStorage = AuthStorage.create(join(agentDir, "auth.json")); - const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); - applyPiProviderEnvironment(modelRegistry, env); - const sessionManager = await resolveSessionManager(SessionManager, input.workspaceRoot, input.providerSessionId); - const model = input.model ? resolvePiModel(modelRegistry, input.model) : undefined; - if (input.model && !model) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - agentId: context.agentId, - operation: "configure_model", - retryable: false, - message: `Pi model not found: ${input.model}.`, - }); - } - const modeRef = createPiSandboxModeRef(input.writeMode ?? "allowed"); - const resourceLoader = new DefaultResourceLoader({ - cwd: input.workspaceRoot, - agentDir, - extensionFactories: [createPiSandboxExtension(input.workspaceRoot, modeRef, env)], - }); - let session: PiSessionLike | undefined; + env: NodeJS.ProcessEnv, +): Promise<{ connection: PiRpcRuntimeConnection; cleanup?: () => Promise }> { + const launch = resolvePiCommand(env); + const writeMode = context.writeMode ?? "allowed"; + const sandbox = writeMode === "full_access" + ? undefined + : await materializePiSandboxExtension(context.workspaceRoot, writeMode); + const args = [ + ...launch.args, + "--mode", + "rpc", + "--approve", + ...(writeMode === "full_access" ? [] : ["--extension", sandbox!.path]), + ...(writeMode === "read_only" + ? ["--tools", PI_READ_ONLY_TOOLS.join(",")] + : writeMode === "allowed" + ? ["--tools", PI_ALLOWED_TOOLS.join(",")] + : []), + ]; try { - const result = await createAgentSession({ - cwd: input.workspaceRoot, - agentDir, - authStorage, - modelRegistry, - sessionManager: sessionManager as never, - resourceLoader, - ...(model ? { model: model as never } : {}), - ...(input.effort ? { thinkingLevel: input.effort as never } : {}), - // Keep the full built-in registry available so warm turns can narrow or - // broaden active tools without recreating the session. - tools: [...PI_FULL_ACCESS_TOOLS], - }); - session = result.session; - await registerPiSandboxSession(session, input.workspaceRoot, modeRef, input.writeMode ?? "allowed"); - session.setActiveToolsByName([...piToolsForWriteMode(input.writeMode)]); - return session; + return { + connection: PiRpcConnection.spawn({ + command: launch.command, + args, + cwd: context.workspaceRoot, + env, + }), + ...(sandbox ? { cleanup: sandbox.cleanup } : {}), + }; } catch (error) { - if (session) { - try { - await releasePiSandboxSession(session); - } finally { - session.dispose(); - } - } + await sandbox?.cleanup(); throw error; } } -function applyPiProviderEnvironment( - modelRegistry: ModelRegistry, - env: NodeJS.ProcessEnv, -): void { - const getApiKeyAndHeaders = modelRegistry.getApiKeyAndHeaders.bind(modelRegistry); - const providerEnv = Object.fromEntries( - Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ); - modelRegistry.getApiKeyAndHeaders = async (model) => { - const auth = await getApiKeyAndHeaders(model); - if (!auth.ok) return auth; - return { - ...auth, - env: { - ...auth.env, - ...providerEnv, - }, - }; - }; -} - -export function piToolsForWriteMode(writeMode: LocalAgentRunInput["writeMode"]): readonly string[] { - switch (writeMode) { - case "read_only": return PI_READ_ONLY_TOOLS; - case "full_access": return PI_FULL_ACCESS_TOOLS; - case "allowed": - case undefined: - return PI_WORKSPACE_TOOLS; +function resolvePiCommand(env: NodeJS.ProcessEnv): { command: string; args: string[] } { + const configured = env.PI_COMMAND?.trim(); + if (configured) { + const command = resolveExecutableCommand(configured, env); + if (!command) throw new Error(`Pi executable not found: ${configured}`); + return { command, args: [] }; } + const system = resolveExecutableCommand("pi", env); + if (system) return { command: system, args: [] }; + const packageJson = require.resolve("@earendil-works/pi-coding-agent/package.json"); + const cli = join(dirname(packageJson), "dist", "cli.js"); + return { command: process.execPath, args: [cli] }; } -interface PiSessionManagerApi { - create(cwd: string): unknown; - open(path: string): unknown; - list(cwd: string): Promise>; -} - -async function resolveSessionManager( - SessionManager: PiSessionManagerApi, +async function materializePiSandboxExtension( workspaceRoot: string, - providerSessionId: string | undefined, -): Promise { - if (!providerSessionId) return SessionManager.create(workspaceRoot); - const sessions = await SessionManager.list(workspaceRoot); - const match = sessions.find((session) => session.id === providerSessionId); - if (!match) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - operation: "session", - retryable: false, - message: `Pi session not found: ${providerSessionId}.`, - }); - } - return SessionManager.open(match.path); + writeMode: "read_only" | "allowed", +): Promise<{ path: string; cleanup: () => Promise }> { + const directory = await mkdtemp(join(tmpdir(), "devspace-pi-")); + const extensionPath = join(directory, "devspace-sandbox.ts"); + const jsModule = new URL("./local-agent-pi-sandbox.js", import.meta.url); + const tsModule = new URL("./local-agent-pi-sandbox.ts", import.meta.url); + const moduleUrl = existsSync(fileURLToPath(jsModule)) ? jsModule : tsModule; + const source = [ + `import { createPiSandboxExtension, createPiSandboxModeRef } from ${JSON.stringify(moduleUrl.href)};`, + `export default createPiSandboxExtension(${JSON.stringify(resolve(workspaceRoot))}, createPiSandboxModeRef(${JSON.stringify(writeMode)}));`, + "", + ].join("\n"); + await writeFile(extensionPath, source, "utf8"); + return { + path: extensionPath, + cleanup: () => rm(directory, { recursive: true, force: true }), + }; } -function resolvePiModel(registry: { find(provider: string, modelId: string): unknown; getAll?: () => unknown[] }, reference: string): unknown { - const separator = reference.indexOf("/"); - if (separator !== -1) { - return registry.find(reference.slice(0, separator), reference.slice(separator + 1)); - } - const all = registry.getAll?.() ?? []; - return all.find((model) => asRecord(model)?.id === reference); +async function cancelPiUiRequest(connection: PiRpcRuntimeConnection, event: PiRpcRecord): Promise { + if (typeof event.id !== "string") return; + if (event.method === "notify" || event.method === "setStatus" || event.method === "setWidget" || event.method === "setTitle") return; + await connection.send({ type: "extension_ui_response", id: event.id, cancelled: true }); } export function extractPiFinalResponse(value: unknown): string { - const root = unwrapProviderPayload(value); - const messages = Array.isArray(root) ? root : readArray(root, "messages"); - if (!messages) return ""; + if (!value || typeof value !== "object") return ""; + const root = value as Record; + const data = root.data && typeof root.data === "object" && !Array.isArray(root.data) + ? root.data as Record + : root; + const messages = Array.isArray(data.messages) ? data.messages : [data]; for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = asRecord(messages[index]); - if (!message || message.role !== "assistant") continue; - const content = message.content; + const message = messages[index]; + if (!message || typeof message !== "object") continue; + if ((message as { role?: unknown }).role !== "assistant") continue; + const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content.trim(); if (!Array.isArray(content)) continue; const text = content - .map((part) => { - const record = asRecord(part); - return record?.type === "text" && typeof record.text === "string" ? record.text : ""; - }) - .filter(Boolean) + .filter((block): block is { type: string; text: string } => ( + Boolean(block) + && typeof block === "object" + && (block as { type?: unknown }).type === "text" + && typeof (block as { text?: unknown }).text === "string" + )) + .map((block) => block.text) .join("\n\n") .trim(); if (text) return text; @@ -368,35 +356,54 @@ export function extractPiFinalResponse(value: unknown): string { return ""; } -export function extractPiProviderError(value: unknown): string { - const root = unwrapProviderPayload(value); - if (Array.isArray(root)) { - for (let index = root.length - 1; index >= 0; index -= 1) { - const error = extractPiProviderError(root[index]); - if (error) return error; +export function extractPiProviderError(value: unknown): string | undefined { + const records = Array.isArray(value) ? [...value] : [value]; + if (value && typeof value === "object" && !Array.isArray(value)) { + const nested = (value as Record).messages; + if (Array.isArray(nested)) records.push(...nested); + } + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index]; + if (!record || typeof record !== "object") continue; + const event = record as Record; + if (event.type === "extension_error" && typeof event.error === "string") return event.error; + if (event.type === "message_end") { + const message = event.message; + if (message && typeof message === "object") { + const error = (message as Record).errorMessage; + if (typeof error === "string" && error) return error; + } + } + if (event.stopReason === "error" && typeof event.errorMessage === "string" && event.errorMessage) { + return event.errorMessage; } - return ""; } - const messages = readArray(root, "messages"); - if (messages) return extractPiProviderError(messages); - const record = asRecord(asRecord(root)?.message ?? root); - if (!record) return ""; - const error = record.errorMessage ?? record.error; - return typeof error === "string" ? error.trim() : ""; + return undefined; +} + +function recordBoolean(value: unknown, key: string): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return (value as Record)[key] === true; } -function unwrapProviderPayload(value: unknown): unknown { - const record = asRecord(value); - return record ? record.data ?? record.result ?? value : value; +function recordNumber(value: unknown, key: string): number | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = (value as Record)[key]; + return typeof result === "number" && Number.isFinite(result) ? result : undefined; } -function readArray(value: unknown, key: string): unknown[] | undefined { - const result = asRecord(value)?.[key]; - return Array.isArray(result) ? result : undefined; +function recordArray(value: unknown, key: string): unknown[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const result = (value as Record)[key]; + return Array.isArray(result) ? result : []; } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : undefined; +function piProtocolError(operation: string, message: string, retryable = false): AgentProviderProtocolError { + return new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "pi", + operation, + retryable, + message, + }); } 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 19/32] 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 8ae91bd061ab4a250522fa413868d8ebae12f0c6 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:45:28 +0530 Subject: [PATCH 20/32] docs(pi): describe native rpc sessions --- docs/agent-profile-schema.md | 8 +++++--- docs/configuration.md | 10 ++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 0a942b9d2..6827e582a 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -84,7 +84,8 @@ to one of these built-in driver integrations: - `claude`: Claude Code 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 +- `pi`: Pi's normal `--mode rpc` CLI process. DevSpace preserves Pi's native + auth, settings, extensions, skills, context files, and session store. - `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. @@ -94,8 +95,9 @@ 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. +started and reused by the daemon internally. Pi is launched in RPC mode and +uses its native persisted session file as DevSpace's continuation identity; +`PI_COMMAND` (or a provider `command`) can select another Pi executable. ### `model` diff --git a/docs/configuration.md b/docs/configuration.md index 2a86dcc5d..fab678772 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -192,14 +192,14 @@ does not expand `$NAME` references in these values. All subagent providers accept `env`. The daemon inherits its startup 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. +process; Pi receives it on its managed RPC process. 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, and ACP instances also accept `command`. OpenCode and Pi do not +Codex, Claude, Pi, and ACP instances also accept `command`. OpenCode does 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`: @@ -217,6 +217,12 @@ 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. +Pi runs as the normal CLI in `--mode rpc`, so user-installed extensions, +skills, context files, auth, models, and settings load normally. Restricted +DevSpace modes inject a sandbox extension and pin Pi's active built-in tools to +the matching read-only/workspace-safe set; `full_access` leaves Pi's normal +tool configuration untouched. + Existing process-level overrides remain supported: `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, `GROK_COMMAND`, and `GROK_AGENT_PROFILE`. Provider configuration takes 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 21/32] 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 dc8be67e093e6729da1d427d67669c081b184219 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:19:52 +0530 Subject: [PATCH 22/32] fix(pi): isolate cancelled turns and harden the RPC process Drain Pi's stderr and report its tail when the process dies, handle stdin errors on the connection instead of letting EPIPE crash the daemon, and drop events left over from an earlier turn. A stopped or timed-out turn aborts Pi and discards a process that does not settle, and a stop requested before the prompt never reaches Pi. Resolving a model now prefers an exact model id over provider/model parsing, and resuming a bare session id from before the RPC runtime fails explicitly instead of silently opening an empty session. Co-Authored-By: Claude Sonnet 5.5 --- src/local-agent-pi-rpc.test.ts | 29 ++++++++ src/local-agent-pi-rpc.ts | 21 +++++- src/local-agent-pi.test.ts | 117 +++++++++++++++++++++++++++++++-- src/local-agent-pi.ts | 56 ++++++++++++---- 4 files changed, 204 insertions(+), 19 deletions(-) diff --git a/src/local-agent-pi-rpc.test.ts b/src/local-agent-pi-rpc.test.ts index 432175801..4bba0628d 100644 --- a/src/local-agent-pi-rpc.test.ts +++ b/src/local-agent-pi-rpc.test.ts @@ -36,3 +36,32 @@ const response = await connection.request({ type: "echo", value: "hello" }); assert.deepEqual(response, { echoed: "hello" }); assert.deepEqual(await connection.nextEvent(1_000), { type: "agent_start" }); connection.close(); + +// A Pi that writes a lot to stderr must not block on a full pipe, and a crash reports what it said. +const noisyScript = String.raw` +process.stderr.write("x".repeat(256 * 1024)); +process.stderr.write("fatal: broken extension\n", () => process.exit(3)); +`; +const noisy = PiRpcConnection.spawn({ + command: process.execPath, + args: ["-e", noisyScript], + cwd: process.cwd(), + env: process.env, +}); +await assert.rejects( + () => noisy.nextEvent(5_000), + /exited with code 3[\s\S]*fatal: broken extension/, +); +assert.equal(noisy.isAlive(), false); + +// Events nobody consumed (for example the tail of a cancelled turn) can be dropped. +const clearable = PiRpcConnection.spawn({ + command: process.execPath, + args: ["-e", script], + cwd: process.cwd(), + env: process.env, +}); +await clearable.request({ type: "echo", value: "again" }); +clearable.clearEvents(); +assert.equal(await clearable.nextEvent(100), undefined); +clearable.close(); diff --git a/src/local-agent-pi-rpc.ts b/src/local-agent-pi-rpc.ts index 25fa4ff40..135ad1048 100644 --- a/src/local-agent-pi-rpc.ts +++ b/src/local-agent-pi-rpc.ts @@ -6,6 +6,7 @@ const require = createRequire(import.meta.url); const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const MAX_RECORD_CHARS = 8 * 1024 * 1024; +const MAX_STDERR_CHARS = 8 * 1024; export type PiRpcRecord = Record; @@ -27,13 +28,24 @@ export class PiRpcConnection { private buffer = ""; private closed = false; private failure?: Error; + private stderrTail = ""; private constructor(private readonly child: ChildProcessWithoutNullStreams) { child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => this.consume(chunk)); + // Drain stderr so a chatty Pi or extension cannot fill the pipe and block; keep the tail for diagnostics. + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + this.stderrTail = (this.stderrTail + chunk).slice(-MAX_STDERR_CHARS); + }); + // A write racing with process exit emits EPIPE here; fail this connection instead of the daemon. + child.stdin.on("error", (error) => this.fail(error)); child.once("error", (error) => this.fail(error)); - child.once("exit", (code) => { - if (!this.closed) this.fail(new Error(`Pi RPC process exited with code ${code}.`)); + // "close" (not "exit") so stderr is fully drained before it is reported. + child.once("close", (code) => { + if (this.closed) return; + const stderr = this.stderrTail.trim(); + this.fail(new Error(`Pi RPC process exited with code ${code}.${stderr ? `\n${stderr}` : ""}`)); }); } @@ -109,6 +121,11 @@ export class PiRpcConnection { }); } + /** Drops events that were not consumed, e.g. the tail of a cancelled turn. */ + clearEvents(): void { + this.queuedEvents.length = 0; + } + isAlive(): boolean { return !this.closed && !this.failure && this.child.exitCode === null; } diff --git a/src/local-agent-pi.test.ts b/src/local-agent-pi.test.ts index e3b7cf1e5..085adc7e1 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { PiLocalAgentDriver, type PiRpcFactory, @@ -7,10 +10,19 @@ import { import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; +const sessionDirectory = mkdtempSync(join(tmpdir(), "devspace-pi-test-")); +let sessionCount = 0; +// Pi session references are real files; resuming validates that. +function sessionFile(name = `session-${++sessionCount}`): string { + const path = join(sessionDirectory, `${name}.jsonl`); + writeFileSync(path, ""); + return path; +} + class FakePiRpcConnection implements PiRpcRuntimeConnection { readonly requests: Array> = []; readonly events: Array> = []; - sessionFile = "/sessions/one.jsonl"; + sessionFile = sessionFile("one"); lastPrompt = ""; alive = true; cleanupCount = 0; @@ -19,7 +31,7 @@ class FakePiRpcConnection implements PiRpcRuntimeConnection { this.requests.push(record); switch (record.type) { case "new_session": - this.sessionFile = `/sessions/${this.requests.length}.jsonl`; + this.sessionFile = sessionFile(); return { cancelled: false }; case "switch_session": this.sessionFile = String(record.sessionPath); @@ -54,8 +66,16 @@ class FakePiRpcConnection implements PiRpcRuntimeConnection { async send(_record: Record): Promise {} - async nextEvent(): Promise | undefined> { - return this.events.shift(); + async nextEvent(timeoutMs = 0): Promise | undefined> { + const event = this.events.shift(); + if (event) return event; + // Like the real connection, an empty queue waits instead of spinning on microtasks. + await new Promise((resolveWait) => setTimeout(resolveWait, Math.min(timeoutMs, 5))); + return undefined; + } + + clearEvents(): void { + this.events.length = 0; } isAlive(): boolean { @@ -105,7 +125,7 @@ const first = await pool.run(driver, context, { assert.equal(factoryEnv?.HARNESS_ENV, "pi"); assert.equal(first.isOk(), true); if (first.isErr()) throw first.error; -assert.match(first.value.providerSessionId ?? "", /^\/sessions\/\d+\.jsonl$/); +assert.match(first.value.providerSessionId ?? "", /session-\d+\.jsonl$/); assert.equal(first.value.finalResponse, "response:first"); assert.deepEqual(sessionIds, [first.value.providerSessionId]); assert.deepEqual( @@ -155,7 +175,7 @@ missingModel.request = async function(record: Record): Promise< this.requests.push(record); if (record.type === "get_available_models") return { models: [] }; if (record.type === "new_session") return { cancelled: false }; - if (record.type === "get_state") return { sessionFile: "/sessions/missing.jsonl" }; + if (record.type === "get_state") return { sessionFile: sessionFile("missing") }; throw new Error(`unexpected request: ${String(record.type)}`); }; const missingModelDriver = new PiLocalAgentDriver(async () => ({ connection: missingModel })); @@ -173,3 +193,88 @@ if (missingResult.isErr()) { assert.match(missingResult.error.message, /missing-model/); } await missingRuntime.value.close(); + +// A bare session id from before Pi ran over RPC must fail instead of opening an empty session. +const legacyConnection = new FakePiRpcConnection(); +const legacyDriver = new PiLocalAgentDriver(async () => ({ connection: legacyConnection })); +const legacyRuntime = await legacyDriver.createRuntime(context); +if (legacyRuntime.isErr()) throw legacyRuntime.error; +const legacyResult = await legacyRuntime.value.run({ + prompt: "continue", + workspaceRoot: "/tmp/project", + providerSessionId: "019f-legacy-session-id", +}); +assert.equal(legacyResult.isErr(), true); +if (legacyResult.isErr()) assert.match(legacyResult.error.message, /Start a new agent/); +assert.equal(legacyConnection.requests.some((request) => request.type === "switch_session"), false); +await legacyRuntime.value.close(); + +// An exact model id wins over provider/model parsing (gateway providers use ids containing "/"). +const gatewayConnection = new FakePiRpcConnection(); +gatewayConnection.request = async function(record: Record): Promise { + if (record.type === "get_available_models") { + this.requests.push(record); + return { models: [{ provider: "openrouter", id: "openai/gpt-4o" }] }; + } + return FakePiRpcConnection.prototype.request.call(this, record); +}; +const gatewayRuntime = await new PiLocalAgentDriver(async () => ({ connection: gatewayConnection })).createRuntime(context); +if (gatewayRuntime.isErr()) throw gatewayRuntime.error; +const gatewayResult = await gatewayRuntime.value.run({ + prompt: "inspect", + workspaceRoot: "/tmp/project", + model: "openai/gpt-4o", +}); +assert.equal(gatewayResult.isOk(), true); +assert.deepEqual( + gatewayConnection.requests.find((request) => request.type === "set_model"), + { type: "set_model", provider: "openrouter", modelId: "openai/gpt-4o" }, +); +await gatewayRuntime.value.close(); + +// Stop: Pi is aborted, a Pi that never settles is discarded, and stale events never reach the next turn. +const stalledConnection = new FakePiRpcConnection(); +stalledConnection.request = async function(record: Record): Promise { + if (record.type === "prompt") { + this.requests.push(record); + this.events.push({ type: "agent_start" }); + return undefined; + } + return FakePiRpcConnection.prototype.request.call(this, record); +}; +const stalledRuntime = await new PiLocalAgentDriver(async () => ({ connection: stalledConnection })).createRuntime(context); +if (stalledRuntime.isErr()) throw stalledRuntime.error; +const stop = new AbortController(); +setTimeout(() => stop.abort(), 20); +const stalledResult = await stalledRuntime.value.run({ + prompt: "stop me", + workspaceRoot: "/tmp/project", + signal: stop.signal, +}); +assert.equal(stalledResult.isErr(), true); +if (stalledResult.isErr()) assert.equal(stalledResult.error.code, "PROVIDER_CANCELLED"); +assert.ok(stalledConnection.requests.some((request) => request.type === "abort")); +assert.equal(stalledRuntime.value.isAlive(), false, "a Pi that does not settle after abort is discarded"); + +const preAbortConnection = new FakePiRpcConnection(); +const preAbortRuntime = await new PiLocalAgentDriver(async () => ({ connection: preAbortConnection })).createRuntime(context); +if (preAbortRuntime.isErr()) throw preAbortRuntime.error; +const alreadyStopped = new AbortController(); +alreadyStopped.abort(); +const preAbortResult = await preAbortRuntime.value.run({ + prompt: "never starts", + workspaceRoot: "/tmp/project", + signal: alreadyStopped.signal, +}); +assert.equal(preAbortResult.isErr(), true); +assert.equal(preAbortConnection.requests.some((request) => request.type === "prompt"), false); +await preAbortRuntime.value.close(); + +const staleConnection = new FakePiRpcConnection(); +staleConnection.events.push({ type: "agent_start" }, { type: "agent_settled" }); +const staleRuntime = await new PiLocalAgentDriver(async () => ({ connection: staleConnection })).createRuntime(context); +if (staleRuntime.isErr()) throw staleRuntime.error; +const staleResult = await staleRuntime.value.run({ prompt: "fresh", workspaceRoot: "/tmp/project" }); +if (staleResult.isErr()) throw staleResult.error; +assert.ok(staleResult.value.items.some((event: any) => event.type === "agent_end"), "leftover events from an earlier turn do not settle this one"); +await staleRuntime.value.close(); diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index 5c5243793..48672b645 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -2,7 +2,7 @@ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { AgentProviderExecutionError, @@ -10,7 +10,7 @@ import { AgentProviderUnavailableError, captureAgentProviderResult, } from "./local-agent-errors.js"; -import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import { localAgentCancelledError } from "./local-agent-cancellation.js"; import { resolveExecutableCommand } from "./local-agent-command.js"; import { PiRpcConnection, @@ -32,12 +32,14 @@ const PI_READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const; const PI_ALLOWED_TOOLS = [...PI_READ_ONLY_TOOLS, "edit", "write", "bash"] as const; const PI_TURN_TIMEOUT_MS = 5 * 60_000; const PI_STATE_POLL_MS = 200; +const PI_ABORT_SETTLE_MS = 2_000; const MAX_PI_EVENTS = 10_000; export interface PiRpcRuntimeConnection { request(record: PiRpcRecord, timeoutMs?: number): Promise; send(record: PiRpcRecord): Promise; nextEvent(timeoutMs?: number): Promise; + clearEvents(): void; isAlive(): boolean; close(): void; } @@ -75,15 +77,18 @@ export class PiRpcRuntime implements LocalAgentRuntime { const providerSessionId = await this.ensureSession(input.providerSessionId); await callbacks?.onSessionId?.(providerSessionId); await this.applyOverrides(input); + if (input.signal?.aborted) throw localAgentCancelledError("pi", "run"); const events: PiRpcRecord[] = []; - const removeAbort = bindLocalAgentAbort(input.signal, async () => { - await this.connection.request({ type: "abort" }, 2_000).catch(() => undefined); - }); + // Events left over from an earlier turn must not settle this one. + this.connection.clearEvents(); try { await this.connection.request({ type: "prompt", message: input.prompt }); await this.waitForSettlement(events, input.signal); - if (input.signal?.aborted) throw localAgentCancelledError("pi", "run"); + if (input.signal?.aborted) { + await this.abortTurn(); + throw localAgentCancelledError("pi", "run"); + } const response = await this.connection.request({ type: "get_last_assistant_text" }); const finalResponse = piRecordString(response, "text")?.trim(); if (!finalResponse) { @@ -115,8 +120,6 @@ export class PiRpcRuntime implements LocalAgentRuntime { } catch (cause) { if (input.signal?.aborted) throw localAgentCancelledError("pi", "run", cause); throw cause; - } finally { - removeAbort(); } }, }); @@ -139,6 +142,14 @@ export class PiRpcRuntime implements LocalAgentRuntime { private async ensureSession(providerSessionId?: string): Promise { if (providerSessionId && this.sessionFile !== providerSessionId) { + // Agents created before Pi ran over RPC stored a bare session id; switch_session would + // silently open an empty session for it instead of failing. + if (!isAbsolute(providerSessionId) || !existsSync(providerSessionId)) { + throw piProtocolError( + "resume_session", + `Pi session ${providerSessionId} is not a session file DevSpace can resume. Start a new agent.`, + ); + } const result = await this.connection.request({ type: "switch_session", sessionPath: providerSessionId }); if (recordBoolean(result, "cancelled")) { throw piProtocolError("resume_session", "A Pi extension cancelled the session switch."); @@ -171,8 +182,7 @@ export class PiRpcRuntime implements LocalAgentRuntime { } private async resolveModel(model: string): Promise<{ provider: string; modelId: string }> { - const parsed = parsePiModelSlug(model); - if (parsed) return parsed; + // Gateway providers have model ids that contain "/", so an exact id wins over provider/model. const available = await this.connection.request({ type: "get_available_models" }); const models = recordArray(available, "models"); const matches = models.filter((entry) => piRecordString(entry, "id") === model); @@ -181,15 +191,39 @@ export class PiRpcRuntime implements LocalAgentRuntime { const modelId = piRecordString(matches[0], "id"); if (provider && modelId) return { provider, modelId }; } + const parsed = parsePiModelSlug(model); + if (parsed) return parsed; throw piProtocolError("configure_model", `Pi model not found or ambiguous: ${model}.`); } + /** + * Asks Pi to stop and waits for it to settle. A Pi that does not settle is + * discarded so its late events cannot be mistaken for the next turn's. + */ + private async abortTurn(): Promise { + await this.connection.request({ type: "abort" }, PI_ABORT_SETTLE_MS).catch(() => undefined); + const deadline = Date.now() + PI_ABORT_SETTLE_MS; + try { + while (Date.now() < deadline) { + const event = await this.connection.nextEvent(PI_STATE_POLL_MS); + if (event?.type === "agent_settled") { + this.connection.clearEvents(); + return; + } + } + } catch { + // The connection already failed; closing below is idempotent. + } + this.connection.close(); + } + private async waitForSettlement(events: PiRpcRecord[], signal?: AbortSignal): Promise { const deadline = Date.now() + PI_TURN_TIMEOUT_MS; let sawAgentActivity = false; for (;;) { if (signal?.aborted) return; if (Date.now() >= deadline) { + await this.abortTurn(); throw piProtocolError("prompt", "Pi did not settle before the provider timeout.", true); } const event = await this.connection.nextEvent(PI_STATE_POLL_MS); @@ -286,7 +320,7 @@ async function defaultPiRpcFactory( } } -function resolvePiCommand(env: NodeJS.ProcessEnv): { command: string; args: string[] } { +export function resolvePiCommand(env: NodeJS.ProcessEnv): { command: string; args: string[] } { const configured = env.PI_COMMAND?.trim(); if (configured) { const command = resolveExecutableCommand(configured, env); 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 23/32] 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 254fa3d60043652b2bc25203816fd471ad054f1d Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:22:20 +0530 Subject: [PATCH 24/32] build(pi): require a Pi release that emits agent_settled The RPC runtime waits for agent_settled, which the previously pinned 0.80.3 does not emit, so turns on the bundled CLI would only end at the timeout. Co-Authored-By: Claude Sonnet 5.5 --- package.json | 2 +- pnpm-lock.yaml | 36 ++++++++++++++++++------------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 666eb193b..925bb0187 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@anthropic-ai/claude-agent-sdk": "0.3.200", "@anthropic-ai/sandbox-runtime": "0.0.71", "@clack/prompts": "^1.5.1", - "@earendil-works/pi-coding-agent": "^0.80.3", + "@earendil-works/pi-coding-agent": "^0.80.10", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db036b93e..875c1be5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^1.5.1 version: 1.5.1 '@earendil-works/pi-coding-agent': - specifier: ^0.80.3 - version: 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + specifier: ^0.80.10 + version: 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@modelcontextprotocol/ext-apps': specifier: ^1.7.2 version: 1.7.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) @@ -314,22 +314,22 @@ packages: resolution: {integrity: sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==} engines: {node: '>= 20.12.0'} - '@earendil-works/pi-agent-core@0.80.3': - resolution: {integrity: sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==} + '@earendil-works/pi-agent-core@0.80.10': + resolution: {integrity: sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.80.3': - resolution: {integrity: sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==} + '@earendil-works/pi-ai@0.80.10': + resolution: {integrity: sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.80.3': - resolution: {integrity: sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==} + '@earendil-works/pi-coding-agent@0.80.10': + resolution: {integrity: sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-tui@0.80.3': - resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} + '@earendil-works/pi-tui@0.80.10': + resolution: {integrity: sha512-c2JO29PbhKPEQ6fgHQKAl0WhwuFqzWfzspMmP+8B5tpDuP+0mvarRbKKg8gq4b+pQx/QX+6aVS4ko7deoyjQjg==} engines: {node: '>=22.19.0'} '@emnapi/core@1.10.0': @@ -2563,9 +2563,9 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@earendil-works/pi-agent-core@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -2577,7 +2577,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -2598,11 +2598,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.80.3 + '@earendil-works/pi-agent-core': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.80.10 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -2628,7 +2628,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.80.3': + '@earendil-works/pi-tui@0.80.10': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 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 25/32] 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 d9fd25be1c2eb040935d2118f5c9254ce5e8024e Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:22:20 +0530 Subject: [PATCH 26/32] fix(pi): check availability against the executable the runtime launches Preflight only looked for the bundled package, rejecting a system pi that the runtime would happily launch. Also update stale Pi docs for the RPC runtime. Co-Authored-By: Claude Sonnet 5.5 --- docs/agent-profile-schema.md | 5 +++-- docs/configuration.md | 2 +- src/local-agent-availability.test.ts | 16 ++++++++++++++++ src/local-agent-availability.ts | 15 ++++++++++++--- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 6827e582a..92e78517b 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -97,7 +97,8 @@ as executables. Run their normal login/setup flows before using them; set matching runtime implementations. OpenCode, Cursor, and Copilot runtimes are started and reused by the daemon internally. Pi is launched in RPC mode and uses its native persisted session file as DevSpace's continuation identity; -`PI_COMMAND` (or a provider `command`) can select another Pi executable. +`PI_COMMAND` selects another Pi executable (otherwise `pi` on `PATH`, then the +bundled package). Pi 0.80.5 or newer is required for the `agent_settled` event. ### `model` @@ -124,7 +125,7 @@ DevSpace passes this through to providers that expose a matching control: - `claude`: SDK effort with adaptive thinking. - `codex`: app-server model reasoning effort. -- `pi`: the AgentSession thinking-level control. +- `pi`: RPC `set_thinking_level`. - `opencode`: model variant. - ACP: standard model/thought-level config when advertised by the agent. The Grok flavor also understands xAI's typed model metadata and startup effort. diff --git a/docs/configuration.md b/docs/configuration.md index fab678772..25a7be067 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -224,7 +224,7 @@ the matching read-only/workspace-safe set; `full_access` leaves Pi's normal tool configuration untouched. Existing process-level overrides remain supported: `CODEX_COMMAND`, -`CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, +`CODEX_HOME`, `CLAUDE_COMMAND`, `PI_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, `GROK_COMMAND`, and `GROK_AGENT_PROFILE`. Provider configuration takes precedence where the same value is set in both places. diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 50751cf96..c2f3d8e7b 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -86,3 +86,19 @@ assert.equal( rmSync(directory, { recursive: true, force: true }); } } + +{ + const directory = mkdtempSync(join(tmpdir(), "devspace-pi-availability-")); + try { + const pi = join(directory, "pi"); + writeFileSync(pi, "#!/bin/sh\nexit 0\n"); + chmodSync(pi, 0o700); + const availability = (env: NodeJS.ProcessEnv) => getLocalAgentProviderAvailabilitySnapshot(env) + .find((provider) => provider.name === "pi"); + assert.equal(availability({ PATH: directory })?.available, true, "a system pi on PATH is launchable"); + assert.equal(availability({ PATH: directory, PI_COMMAND: "/definitely/missing/pi" })?.available, false); + assert.match(availability({ PATH: directory, PI_COMMAND: "/definitely/missing/pi" })?.reason ?? "", /missing\/pi/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index ec540d6ea..9ebcf4349 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,5 +1,6 @@ import { resolveExecutableCommand } from "./local-agent-command.js"; import { resolveAcpCommand } from "./local-agent-acp.js"; +import { resolvePiCommand } from "./local-agent-pi.js"; import { localAgentProviderEnvironment, subagentAcpFlavor, @@ -55,9 +56,17 @@ function checkLocalAgentProviderAvailability( case "opencode": return commandAvailability(providerInstanceId, "opencode", providerEnv); case "pi": - return providerEnv.PI_COMMAND - ? commandAvailability(providerInstanceId, providerEnv.PI_COMMAND, providerEnv) - : packageAvailability(providerInstanceId, "@earendil-works/pi-coding-agent"); + // Same resolution as the runtime: PI_COMMAND, then `pi` on PATH, then the bundled package. + try { + resolvePiCommand(providerEnv); + return { name: providerInstanceId, available: true }; + } catch (cause) { + return { + name: providerInstanceId, + available: false, + reason: cause instanceof Error ? cause.message : "Pi executable not found", + }; + } case "acp": { // Resolve exactly as the driver does, so preflight and startup agree on the executable. const configured = config ? subagentProviderConfig(config, providerInstanceId) : undefined; 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 27/32] 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 28/32] 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 29/32] 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 30/32] 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 31/32] 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 32/32] 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,