diff --git a/packages/app/e2e/helpers/rewind-flow.ts b/packages/app/e2e/helpers/rewind-flow.ts index 8b8ea7c3a..14cd857da 100644 --- a/packages/app/e2e/helpers/rewind-flow.ts +++ b/packages/app/e2e/helpers/rewind-flow.ts @@ -27,6 +27,7 @@ interface ProviderLaunchConfig { model?: string; thinkingOptionId?: string; modeId?: string; + featureValues?: Record; } const SEND_TIMEOUT_MS = 240_000; @@ -52,7 +53,12 @@ function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig { modeId: "full-access", }; case "opencode": - return { provider, model: "opencode/big-pickle", modeId: "full-access" }; + return { + provider, + model: "opencode/big-pickle", + modeId: "build", + featureValues: { auto_accept: true }, + }; case "pi": return { provider, diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 1d3be1e25..46849eb5f 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -19,7 +19,7 @@ import { } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; -import { Brain, ChevronDown, ListTodo, Settings2, Zap } from "lucide-react-native"; +import { Brain, ChevronDown, ListTodo, Settings2, ShieldCheck, Zap } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { @@ -139,6 +139,7 @@ function findOptionLabel( const FEATURE_ICONS: Record = { "list-todo": ListTodo, + "shield-check": ShieldCheck, zap: Zap, }; @@ -151,6 +152,7 @@ function getFeatureIconColor( enabled: boolean, palette: { blue: { 400: string }; + green: { 400: string }; yellow: { 400: string }; }, foregroundMuted: string, @@ -162,6 +164,8 @@ function getFeatureIconColor( switch (getFeatureHighlightColor(featureId)) { case "blue": return palette.blue[400]; + case "green": + return palette.green[400]; case "yellow": return palette.yellow[400]; default: diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 25d64459e..d9ffbb642 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -4,6 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { + Bot, ChevronDown, ShieldAlert, ShieldCheck, @@ -33,6 +34,7 @@ function shouldRenderForPlacement(placement: AgentModeControlPlacement, isCompac } const MODE_ICONS = { + Bot, ShieldCheck, ShieldAlert, ShieldOff, diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index f81293567..2d0df9d59 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -1,7 +1,7 @@ import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; export type ExplainedAgentControl = "mode" | "model" | "thinking"; -export type FeatureHighlightColor = "blue" | "default" | "yellow"; +export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow"; export function getAgentControlHint(selector: ExplainedAgentControl): string { switch (selector) { @@ -32,6 +32,8 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol switch (featureId) { case "fast_mode": return "yellow"; + case "auto_accept": + return "green"; case "plan_mode": return "blue"; default: diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index ab4d98dda..5d3db6fd4 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1156,11 +1156,12 @@ export class AgentManager { async setAgentMode(agentId: string, modeId: string): Promise { const agent = this.requireSessionAgent(agentId); await agent.session.setMode(modeId); - agent.config.modeId = modeId; - agent.currentModeId = modeId; + const currentMode = await agent.session.getCurrentMode(); + agent.config.modeId = currentMode ?? undefined; + agent.currentModeId = currentMode; // Update runtimeInfo to reflect the new mode if (agent.runtimeInfo) { - agent.runtimeInfo = { ...agent.runtimeInfo, modeId }; + agent.runtimeInfo = { ...agent.runtimeInfo, modeId: currentMode }; } this.touchUpdatedAt(agent); this.emitState(agent); diff --git a/packages/server/src/server/agent/create-agent-mode.test.ts b/packages/server/src/server/agent/create-agent-mode.test.ts index 24f39137c..3cb2c95a9 100644 --- a/packages/server/src/server/agent/create-agent-mode.test.ts +++ b/packages/server/src/server/agent/create-agent-mode.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.js"; const CLAUDE_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"]; -const OPENCODE_MODES = ["build", "full-access", "plan"]; +const OPENCODE_MODES = ["build", "plan"]; const CODEX_MODES = ["auto", "full-access"]; describe("resolveAndValidateCreateAgentMode", () => { @@ -25,7 +25,7 @@ describe("resolveAndValidateCreateAgentMode", () => { availableModes: OPENCODE_MODES, }), ).toThrow( - "Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, full-access, plan", + "Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, plan", ); }); @@ -68,7 +68,7 @@ describe("resolveAndValidateCreateAgentMode", () => { availableModes: OPENCODE_MODES, }), ).toThrow( - "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, full-access, plan", + "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan", ); }); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 5eefbfabb..6a7beb68b 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1447,11 +1447,39 @@ describe("create_agent MCP tool", () => { initialPrompt: "Do work", }), ).rejects.toThrow( - "Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, full-access, plan", + "Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, plan", ); expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); }); + it("accepts legacy OpenCode full-access as build plus auto accept", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.createAgent.mockResolvedValue({ + id: "child-agent", + cwd: existingCwd, + lifecycle: "idle", + currentModeId: "build", + availableModes: [], + config: { title: "Child", featureValues: { auto_accept: true } }, + } as ManagedAgent); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = registeredTool(server, "create_agent"); + + await tool.handler({ + cwd: existingCwd, + title: "Legacy mode", + provider: "opencode/gpt-5.4", + settings: { modeId: "full-access" }, + initialPrompt: "Do work", + }); + + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }), + undefined, + undefined, + ); + }); + it("inherits the caller mode when the new agent uses the same provider", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ @@ -1513,12 +1541,12 @@ describe("create_agent MCP tool", () => { initialPrompt: "Do work", }), ).rejects.toThrow( - "cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, full-access, plan", + "cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan", ); expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); }); - it("inherits the target provider's unattended mode when caller is unattended cross-provider", async () => { + it("maps unattended callers to OpenCode auto accept", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", @@ -1530,9 +1558,9 @@ describe("create_agent MCP tool", () => { id: "child-agent", cwd: existingCwd, lifecycle: "idle", - currentModeId: "full-access", + currentModeId: "build", availableModes: [], - config: { title: "Child" }, + config: { title: "Child", featureValues: { auto_accept: true } }, } as ManagedAgent); const server = await createAgentMcpServer({ @@ -1549,7 +1577,7 @@ describe("create_agent MCP tool", () => { }); expect(spies.agentManager.createAgent).toHaveBeenCalledWith( - expect.objectContaining({ modeId: "full-access" }), + expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }), undefined, expect.any(Object), ); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 8d24b6139..59c037b0c 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -10,7 +10,7 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import type { AgentProvider } from "./agent-sdk-types.js"; -import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; +import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js"; import { AgentFeatureSchema, AgentPermissionRequestPayloadSchema, @@ -134,6 +134,42 @@ const CODEX_TO_CLAUDE_MODE: Record = { "full-access": "bypassPermissions", }; +const OPENCODE_PROVIDER_ID = "opencode"; +const OPENCODE_BUILD_MODE_ID = "build"; +const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access"; +const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept"; + +function isOpenCodeLegacyFullAccessMode( + provider: AgentProvider, + modeId: string | undefined, +): boolean { + return provider === OPENCODE_PROVIDER_ID && modeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID; +} + +function withOpenCodeAutoAcceptFeature( + features: Record | undefined, + enabled: boolean, +): Record { + return { + ...features, + [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled, + }; +} + +function hasOpenCodeAutoAcceptFeature(agent: ManagedAgent): boolean { + if (agent.provider !== OPENCODE_PROVIDER_ID) { + return false; + } + return ( + agent.features?.some( + (feature) => + feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID && + feature.type === "toggle" && + feature.value === true, + ) === true || agent.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true + ); +} + function mapModeAcrossProviders( sourceMode: string, sourceProvider: AgentProvider, @@ -583,6 +619,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom : {}), ...(callerAgent.config.title ? { title: callerAgent.config.title } : {}), ...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}), + ...(callerAgent.config.featureValues + ? { featureValues: callerAgent.config.featureValues } + : {}), ...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}), ...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}), }; @@ -910,6 +949,48 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom return modes.some((mode) => mode.id === modeId && mode.isUnattended === true); }; + const isAgentInUnattendedState = (agent: ManagedAgent): boolean => { + return ( + isParentInUnattendedMode(agent.provider, agent.currentModeId) || + hasOpenCodeAutoAcceptFeature(agent) + ); + }; + + const resolveCreateModeAndFeatures = (input: { + provider: AgentProvider; + requestedMode: string | undefined; + parent: { provider: AgentProvider; modeId: string | null; isUnattended: boolean } | null; + features: Record | undefined; + }): { mode: string | undefined; features: Record | undefined } => { + const legacyOpenCodeFullAccess = isOpenCodeLegacyFullAccessMode( + input.provider, + input.requestedMode, + ); + const inheritsOpenCodeUnattended = + input.provider === OPENCODE_PROVIDER_ID && + input.requestedMode === undefined && + input.parent?.isUnattended === true; + const inheritsOpenCodeAutoAccept = + inheritsOpenCodeUnattended && input.features?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined; + const requestedMode = legacyOpenCodeFullAccess ? OPENCODE_BUILD_MODE_ID : input.requestedMode; + const features = + legacyOpenCodeFullAccess || inheritsOpenCodeAutoAccept + ? withOpenCodeAutoAcceptFeature(input.features, true) + : input.features; + const mode = + inheritsOpenCodeUnattended && requestedMode === undefined + ? OPENCODE_BUILD_MODE_ID + : resolveAndValidateCreateAgentMode({ + requestedMode, + targetProvider: input.provider, + parent: input.parent, + availableModes: getAvailableModeIds(input.provider), + targetUnattendedMode: getUnattendedModeId(input.provider), + }); + + return { mode, features }; + }; + const resolveCallerCreateAgentArgs = ( args: unknown, parentAgentId: string, @@ -928,16 +1009,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom lockedCwd: callerContext?.lockedCwd, allowCustomCwd: callerContext?.allowCustomCwd ?? true, }); - const resolvedMode = resolveAndValidateCreateAgentMode({ + const resolvedRuntime = resolveCreateModeAndFeatures({ + provider, requestedMode: settings?.modeId, - targetProvider: provider, parent: { provider: parentAgent.provider, modeId: parentAgent.currentModeId, - isUnattended: isParentInUnattendedMode(parentAgent.provider, parentAgent.currentModeId), + isUnattended: isAgentInUnattendedState(parentAgent), }, - availableModes: getAvailableModeIds(provider), - targetUnattendedMode: getUnattendedModeId(provider), + features: settings?.features, }); return { provider, @@ -946,11 +1026,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom normalizedTitle: callerArgs.title.trim(), model: resolvedProviderModel.model, thinkingOptionId: settings?.thinkingOptionId, - features: settings?.features, + features: resolvedRuntime.features, labels: callerArgs.labels, notifyOnFinish: callerArgs.notifyOnFinish ?? false, resolvedCwd, - resolvedMode, + resolvedMode: resolvedRuntime.mode, setupContinuation: undefined, }; }; @@ -962,12 +1042,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider); const { cwd, settings, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs; - const resolvedMode = resolveAndValidateCreateAgentMode({ + const resolvedRuntime = resolveCreateModeAndFeatures({ + provider: resolvedProviderModel.provider, requestedMode: settings?.modeId, - targetProvider: resolvedProviderModel.provider, parent: null, - availableModes: getAvailableModeIds(resolvedProviderModel.provider), - targetUnattendedMode: getUnattendedModeId(resolvedProviderModel.provider), + features: settings?.features, }); let resolvedCwd = expandUserPath(cwd); let setupContinuation: AgentWorktreeSetupContinuation | undefined; @@ -1021,11 +1100,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom normalizedTitle: topLevelArgs.title.trim(), model: resolvedProviderModel.model, thinkingOptionId: settings?.thinkingOptionId, - features: settings?.features, + features: resolvedRuntime.features, labels: topLevelArgs.labels, notifyOnFinish: topLevelArgs.notifyOnFinish ?? false, resolvedCwd, - resolvedMode, + resolvedMode: resolvedRuntime.mode, setupContinuation, }; }; diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 2477037f0..e4b1161aa 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -2,7 +2,12 @@ import { z } from "zod"; import type { AgentMode } from "./agent-sdk-types.js"; export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning" | `#${string}`; -export type AgentModeIcon = "ShieldCheck" | "ShieldAlert" | "ShieldOff" | "ShieldQuestionMark"; +export type AgentModeIcon = + | "Bot" + | "ShieldCheck" + | "ShieldAlert" + | "ShieldOff" + | "ShieldQuestionMark"; export interface AgentModeVisuals { icon: AgentModeIcon; @@ -126,22 +131,14 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [ id: "build", label: "Build", description: "Allows edits and tool execution for implementation work", - icon: "ShieldCheck", + icon: "Bot", colorTier: "moderate", }, - { - id: "full-access", - label: "Full Access", - description: "Automatically approves all tool permission prompts for the session", - icon: "ShieldAlert", - colorTier: "dangerous", - isUnattended: true, - }, { id: "plan", label: "Plan", description: "Read-only planning mode that avoids file edits", - icon: "ShieldCheck", + icon: "Bot", colorTier: "planning", }, ]; diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 75ea88441..849b7b72a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -61,8 +61,8 @@ function questionEvent(): unknown { }; } -describe("OpenCode full-access mode", () => { - test("includes virtual full-access mode with dynamic OpenCode agents", async () => { +describe("OpenCode auto_accept feature", () => { + test("lists OpenCode modes without the legacy virtual full-access mode", async () => { const { runtime } = mockOpenCodeClient({ agents: [ { name: "build", mode: "primary", hidden: false, description: "Build agent" }, @@ -73,14 +73,36 @@ describe("OpenCode full-access mode", () => { const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); const modes = await client.listModes({ cwd: "/tmp/project", force: false }); - expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "full-access", "paseo-custom"]); - expect(modes.find((mode) => mode.id === "full-access")).toMatchObject({ - label: "Full Access", - description: "Automatically approves all tool permission prompts for the session", - }); + expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "paseo-custom"]); }); - test("reports full-access but sends prompts through OpenCode build agent", async () => { + test("lists auto accept as a provider feature", async () => { + const { runtime } = mockOpenCodeClient(); + + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); + const enabledFeatures = await client.listFeatures({ + provider: "opencode", + cwd: "/tmp/project", + featureValues: { auto_accept: true }, + }); + const legacyFeatures = await client.listFeatures({ + provider: "opencode", + cwd: "/tmp/project", + modeId: "full-access", + }); + + expect(enabledFeatures).toEqual([ + expect.objectContaining({ + type: "toggle", + id: "auto_accept", + label: "Auto Accept", + value: true, + }), + ]); + expect(legacyFeatures).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]); + }); + + test("keeps legacy full-access as an alias for build plus auto accept", async () => { const { openCodeClient, runtime } = mockOpenCodeClient(); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); @@ -90,7 +112,8 @@ describe("OpenCode full-access mode", () => { modeId: "full-access", }); - expect(await session.getCurrentMode()).toBe("full-access"); + expect(await session.getCurrentMode()).toBe("build"); + expect(session.features).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]); await session.run("Implement the change"); @@ -102,7 +125,7 @@ describe("OpenCode full-access mode", () => { await session.close(); }); - test("auto-approves tool permissions in full-access without surfacing them", async () => { + test("auto-approves tool permissions when auto accept is enabled", async () => { const { openCodeClient, runtime } = mockOpenCodeClient({ events: [toolPermissionEvent(), idleEvent()], }); @@ -112,7 +135,7 @@ describe("OpenCode full-access mode", () => { const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project", - modeId: "full-access", + featureValues: { auto_accept: true }, }); session.subscribe((event) => receivedEvents.push(event)); @@ -130,7 +153,7 @@ describe("OpenCode full-access mode", () => { await session.close(); }); - test("keeps questions separate from full-access tool auto-approval", async () => { + test("keeps questions separate from auto accept tool approval", async () => { const { openCodeClient, runtime } = mockOpenCodeClient({ events: [questionEvent(), idleEvent()], }); @@ -140,7 +163,7 @@ describe("OpenCode full-access mode", () => { const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project", - modeId: "full-access", + featureValues: { auto_accept: true }, }); session.subscribe((event) => receivedEvents.push(event)); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 8e868c394..51005a803 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -83,7 +83,8 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { }; const OPENCODE_BUILD_MODE_ID = "build"; -const OPENCODE_FULL_ACCESS_MODE_ID = "full-access"; +const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access"; +const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept"; const OPENCODE_PERSISTED_SESSION_LIMIT = 200; const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000; const OPENCODE_PERMISSION_ACTION_ALLOW_ONCE = "allow_once"; @@ -100,13 +101,24 @@ const DEFAULT_MODES: AgentMode[] = [ label: "Plan", description: "Read-only planning mode that avoids file edits", }, - { - id: OPENCODE_FULL_ACCESS_MODE_ID, - label: "Full Access", - description: "Automatically approves all tool permission prompts for the session", - }, ]; +function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean { + return config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true; +} + +function buildOpenCodeAutoAcceptFeature(config: AgentSessionConfig): AgentFeature { + return { + type: "toggle", + id: OPENCODE_AUTO_ACCEPT_FEATURE_ID, + label: "Auto Accept", + description: "Automatically approves OpenCode tool permission prompts.", + tooltip: "Auto accept permission prompts", + icon: "shield-check", + value: isOpenCodeAutoAcceptEnabled(config), + }; +} + function buildOpenCodePermissionActions(): AgentPermissionAction[] { return [ { @@ -436,11 +448,26 @@ function normalizeOpenCodeModeId(modeId: string | null | undefined): string { function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string { const normalizedModeId = normalizeOpenCodeModeId(modeId); - return normalizedModeId === OPENCODE_FULL_ACCESS_MODE_ID + return normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID ? OPENCODE_BUILD_MODE_ID : normalizedModeId; } +function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConfig { + if (normalizeOpenCodeModeId(config.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) { + return { ...config }; + } + + return { + ...config, + modeId: OPENCODE_BUILD_MODE_ID, + featureValues: { + ...config.featureValues, + [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true, + }, + }; +} + function isSelectableOpenCodeAgent(agent: { mode?: string; hidden?: boolean }): boolean { return (agent.mode === "primary" || agent.mode === "all") && agent.hidden !== true; } @@ -462,6 +489,7 @@ function mapOpenCodeAgentToMode(agent: { return { id: agent.name, label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1), + icon: "Bot", description: typeof agent.description === "string" && agent.description.trim().length > 0 ? agent.description.trim() @@ -473,6 +501,9 @@ function mapOpenCodeAgentToMode(agent: { function mergeOpenCodeModes(discoveredModes: AgentMode[]): AgentMode[] { const modesById = new Map(DEFAULT_MODES.map((mode) => [mode.id, mode])); for (const mode of discoveredModes) { + if (mode.id === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) { + continue; + } modesById.set(mode.id, mode); } return sortOpenCodeModes(Array.from(modesById.values())); @@ -1329,8 +1360,8 @@ export class OpenCodeAgentClient implements AgentClient { } } - async listFeatures(_config: AgentSessionConfig): Promise { - return []; + async listFeatures(config: AgentSessionConfig): Promise { + return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))]; } async listPersistedAgents( @@ -1437,7 +1468,7 @@ export class OpenCodeAgentClient implements AgentClient { if (config.provider !== "opencode") { throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`); } - return { ...config, provider: "opencode" }; + return normalizeOpenCodeConfig({ ...config, provider: "opencode" }); } private async populateModelContextWindowCache( @@ -2541,6 +2572,7 @@ class OpenCodeAgentSession implements AgentSession { private readonly logger: Logger; private readonly modelContextWindowsByModelKey: ReadonlyMap; private currentMode: string = "default"; + private autoAcceptEnabled = false; private pendingPermissions = new Map(); private abortController: AbortController | null = null; private pendingAbortPromise: Promise | null = null; @@ -2590,6 +2622,7 @@ class OpenCodeAgentSession implements AgentSession { this.logger = logger.child({ agentId: this.agentId }); this.modelContextWindowsByModelKey = modelContextWindowsByModelKey; this.currentMode = normalizeOpenCodeModeId(config.modeId); + this.autoAcceptEnabled = isOpenCodeAutoAcceptEnabled(config); this.releaseServer = releaseServer ?? null; this.persistSession = persistSession; this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens( @@ -2602,6 +2635,10 @@ class OpenCodeAgentSession implements AgentSession { return this.sessionId; } + get features(): AgentFeature[] { + return [buildOpenCodeAutoAcceptFeature(this.config)]; + } + async getRuntimeInfo(): Promise { return { provider: "opencode", @@ -3238,7 +3275,28 @@ class OpenCodeAgentSession implements AgentSession { } async setMode(modeId: string): Promise { - this.currentMode = normalizeOpenCodeModeId(modeId); + const normalizedModeId = normalizeOpenCodeModeId(modeId); + if (normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) { + this.currentMode = OPENCODE_BUILD_MODE_ID; + await this.setFeature(OPENCODE_AUTO_ACCEPT_FEATURE_ID, true); + return; + } + + this.currentMode = normalizedModeId; + this.config.modeId = normalizedModeId; + } + + async setFeature(featureId: string, value: unknown): Promise { + if (featureId !== OPENCODE_AUTO_ACCEPT_FEATURE_ID) { + throw new Error(`Unsupported OpenCode feature '${featureId}'`); + } + + const enabled = value === true; + this.autoAcceptEnabled = enabled; + this.config.featureValues = { + ...this.config.featureValues, + [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled, + }; } getPendingPermissions(): AgentPermissionRequest[] { @@ -3521,7 +3579,7 @@ class OpenCodeAgentSession implements AgentSession { } private async tryAutoApproveToolPermission(request: AgentPermissionRequest): Promise { - if (this.currentMode !== OPENCODE_FULL_ACCESS_MODE_ID || request.kind !== "tool") { + if (!this.autoAcceptEnabled || request.kind !== "tool") { return false; }