From 7bd97cb4296c86f68d1515637d75b8f83e3811c0 Mon Sep 17 00:00:00 2001 From: m4tta <427218+m4tta@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:06:02 -0700 Subject: [PATCH 1/2] Add Claude Code auto permission mode as a 4th runtime mode Claude Code's TypeScript Agent SDK exposes an "auto" PermissionMode (default | acceptEdits | bypassPermissions | plan | dontAsk | auto) that runs tool calls through a background safety classifier instead of skipping permission checks entirely. It's distinct from bypassPermissions and never requires allowDangerouslySkipPermissions, so it works in environments where an org policy blocks --dangerously-skip-permissions but still allows auto mode. - Add "auto" to the RuntimeMode contract and map it to the SDK's permissionMode in ClaudeAdapter. - Add a supportsAutoRuntimeMode provider capability flag so only Claude advertises the option; Codex falls back to its workspace-write policy defensively instead of full bypass. - Surface the option in the web composer menus and mobile runtime option lists, gated on that capability flag. --- .../features/threads/NewTaskDraftScreen.tsx | 14 +++++++++-- .../src/features/threads/ThreadComposer.tsx | 14 +++++++++-- .../src/provider/Layers/ClaudeAdapter.ts | 1 + .../src/provider/Layers/ClaudeProvider.ts | 1 + .../provider/Layers/CodexSessionRuntime.ts | 2 ++ apps/server/src/provider/providerSnapshot.ts | 4 +++ apps/web/src/components/chat/ChatComposer.tsx | 25 +++++++++++++++++-- .../chat/CompactComposerControlsMenu.tsx | 16 +++++++++--- apps/web/src/providerModels.ts | 7 ++++++ packages/contracts/src/orchestration.ts | 1 + packages/contracts/src/server.ts | 1 + 11 files changed, 77 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ce24198f5e2..40031b1000a 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -198,10 +198,15 @@ export function NewTaskDraftScreen(props: { ? "Approve actions" : flow.runtimeMode === "auto-accept-edits" ? "Auto-accept edits" - : "Full access", + : flow.runtimeMode === "auto" + ? "Auto" + : "Full access", subactions: [ { id: "options:runtime:approval-required", title: "Approve actions" }, { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, + ...(flow.selectedModelOption?.providerDriver === "claudeAgent" + ? [{ id: "options:runtime:auto", title: "Auto" }] + : []), { id: "options:runtime:full-access", title: "Full access" }, ].map((option) => { const value = option.id.replace("options:runtime:", ""); @@ -229,7 +234,12 @@ export function NewTaskDraftScreen(props: { }), }, ], - [flow.interactionMode, flow.runtimeMode, providerOptionDescriptors], + [ + flow.interactionMode, + flow.runtimeMode, + flow.selectedModelOption?.providerDriver, + providerOptionDescriptors, + ], ); const workspaceMenuActions = useMemo(() => { diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 75991cae885..e956d82a234 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -552,10 +552,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? "Approve actions" : currentRuntimeMode === "auto-accept-edits" ? "Auto-accept edits" - : "Full access", + : currentRuntimeMode === "auto" + ? "Auto" + : "Full access", subactions: [ { id: "options:runtime:approval-required", title: "Approve actions" }, { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, + ...(currentModelOption?.providerDriver === "claudeAgent" + ? [{ id: "options:runtime:auto", title: "Auto" }] + : []), { id: "options:runtime:full-access", title: "Full access" }, ].map((option) => { const value = option.id.replace("options:runtime:", ""); @@ -583,7 +588,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), }, ], - [currentInteractionMode, currentRuntimeMode, providerOptionDescriptors], + [ + currentInteractionMode, + currentModelOption?.providerDriver, + currentRuntimeMode, + providerOptionDescriptors, + ], ); // ── Menu handlers ──────────────────────────────────────── diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 97a93f85829..10e741d0672 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3431,6 +3431,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", + auto: "auto", "full-access": "bypassPermissions", }; const permissionMode = runtimeModeToPermission[input.runtimeMode]; diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 59b7d0464de..edd628e5600 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -47,6 +47,7 @@ const PROVIDER = ProviderDriverKind.make("claudeAgent"); const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, + supportsAutoRuntimeMode: true, } as const; const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 99ac498f0c3..8ca5a9ebe89 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -273,6 +273,7 @@ function runtimeModeToThreadConfig(input: RuntimeMode): { sandbox: "read-only", }; case "auto-accept-edits": + case "auto": return { approvalPolicy: "on-request", sandbox: "workspace-write", @@ -311,6 +312,7 @@ function runtimeModeToTurnSandboxPolicy( type: "readOnly", }; case "auto-accept-edits": + case "auto": return { type: "workspaceWrite", }; diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index dfe31ffdc44..2f8a900a4ef 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -57,6 +57,7 @@ export interface ServerProviderPresentation { readonly badgeLabel?: string; readonly showInteractionModeToggle?: boolean; readonly requiresNewThreadForModelChange?: boolean; + readonly supportsAutoRuntimeMode?: boolean; } export type ServerProviderDraft = Omit; @@ -234,6 +235,9 @@ export function buildServerProvider(input: { ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } : {}), + ...(typeof input.presentation.supportsAutoRuntimeMode === "boolean" + ? { supportsAutoRuntimeMode: input.presentation.supportsAutoRuntimeMode } + : {}), enabled: input.enabled, installed: input.probe.installed, version: input.probe.version, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..f88fddd2b76 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -101,10 +101,15 @@ import { LockIcon, LockOpenIcon, PenLineIcon, + SparklesIcon, XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; +import { + getProviderAutoRuntimeModeSupport, + getProviderDisplayName, + getProviderInteractionModeToggle, +} from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -142,6 +147,11 @@ const runtimeModeConfig: Record< description: "Auto-approve edits, ask before other actions.", icon: PenLineIcon, }, + auto: { + label: "Auto", + description: "Run without prompts; a safety classifier reviews actions in the background.", + icon: SparklesIcon, + }, "full-access": { label: "Full access", description: "Allow commands and edits without prompts.", @@ -194,6 +204,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; + runtimeModeOptions: ReadonlyArray; showPlanToggle: boolean; planSidebarLabel: string; planSidebarOpen: boolean; @@ -269,7 +280,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {runtimeModeOption.label} - {runtimeModeOptions.map((mode) => { + {props.runtimeModeOptions.map((mode) => { const option = runtimeModeConfig[mode]; const OptionIcon = option.icon; return ( @@ -817,9 +828,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerStatuses, selectedProvider, ), + supportsAutoRuntimeMode: getProviderAutoRuntimeModeSupport(providerStatuses, selectedProvider), }), [providerStatuses, selectedProvider], ); + const runtimeModeOptionsForProvider = useMemo( + () => + composerProviderControls.supportsAutoRuntimeMode + ? runtimeModeOptions + : runtimeModeOptions.filter((mode) => mode !== "auto"), + [composerProviderControls.supportsAutoRuntimeMode], + ); const selectedModelSelection = useMemo( () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], @@ -2500,6 +2519,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} + runtimeModeOptions={runtimeModeOptionsForProvider} showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} traitsMenuContent={providerTraitsMenuContent} onToggleInteractionMode={toggleInteractionMode} @@ -2518,6 +2538,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} interactionMode={interactionMode} runtimeMode={runtimeMode} + runtimeModeOptions={runtimeModeOptionsForProvider} showPlanToggle={showPlanSidebarToggle} planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index f1fbd193a63..fb7cf6fb331 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -12,12 +12,20 @@ import { MenuTrigger, } from "../ui/menu"; +const RUNTIME_MODE_LABELS: Record = { + "approval-required": "Supervised", + "auto-accept-edits": "Auto-accept edits", + auto: "Auto", + "full-access": "Full access", +}; + export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { activePlan: boolean; interactionMode: ProviderInteractionMode; planSidebarLabel: string; planSidebarOpen: boolean; runtimeMode: RuntimeMode; + runtimeModeOptions: ReadonlyArray; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; @@ -69,9 +77,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls props.onRuntimeModeChange(value as RuntimeMode); }} > - Supervised - Auto-accept edits - Full access + {props.runtimeModeOptions.map((mode) => ( + + {RUNTIME_MODE_LABELS[mode]} + + ))} {props.activePlan ? ( <> diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index aa9afcf31e1..c39e4fa311b 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -53,6 +53,13 @@ export function getProviderInteractionModeToggle( return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true; } +export function getProviderAutoRuntimeModeSupport( + providers: ReadonlyArray, + provider: ProviderDriverKind, +): boolean { + return getProviderSnapshot(providers, provider)?.supportsAutoRuntimeMode ?? false; +} + export function isProviderEnabled( providers: ReadonlyArray, provider: ProviderDriverKind, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 623fed0917b..16e4e53a757 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -117,6 +117,7 @@ export type ModelSelection = typeof ModelSelection.Type; export const RuntimeMode = Schema.Literals([ "approval-required", "auto-accept-edits", + "auto", "full-access", ]); export type RuntimeMode = typeof RuntimeMode.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b76ea965afe..d681570a935 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -166,6 +166,7 @@ export const ServerProvider = Schema.Struct({ continuation: Schema.optional(ServerProviderContinuation), showInteractionModeToggle: Schema.optional(Schema.Boolean), requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), + supportsAutoRuntimeMode: Schema.optional(Schema.Boolean), enabled: Schema.Boolean, installed: Schema.Boolean, version: Schema.NullOr(TrimmedNonEmptyString), From 45eddfd23d7ffa1ecf691a4e85dbd226268a7966 Mon Sep 17 00:00:00 2001 From: m4tta <427218+m4tta@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:20:06 -0700 Subject: [PATCH 2/2] Gate mobile auto runtime mode on the capability flag, not driver kind Mobile checked providerDriver === "claudeAgent" directly, while the web composer checks the supportsAutoRuntimeMode flag reported by the provider snapshot. Those happen to agree today since Claude always sets the flag to true, but they could silently diverge if the flag is ever made conditional. Add supportsAutoRuntimeMode to ModelOption and gate on that instead, matching the web behavior. --- apps/mobile/src/features/threads/NewTaskDraftScreen.tsx | 4 ++-- apps/mobile/src/features/threads/ThreadComposer.tsx | 4 ++-- apps/mobile/src/lib/modelOptions.ts | 3 +++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 40031b1000a..dd146948e50 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -204,7 +204,7 @@ export function NewTaskDraftScreen(props: { subactions: [ { id: "options:runtime:approval-required", title: "Approve actions" }, { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, - ...(flow.selectedModelOption?.providerDriver === "claudeAgent" + ...(flow.selectedModelOption?.supportsAutoRuntimeMode ? [{ id: "options:runtime:auto", title: "Auto" }] : []), { id: "options:runtime:full-access", title: "Full access" }, @@ -237,7 +237,7 @@ export function NewTaskDraftScreen(props: { [ flow.interactionMode, flow.runtimeMode, - flow.selectedModelOption?.providerDriver, + flow.selectedModelOption?.supportsAutoRuntimeMode, providerOptionDescriptors, ], ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e956d82a234..fc29a6d7e69 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -558,7 +558,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer subactions: [ { id: "options:runtime:approval-required", title: "Approve actions" }, { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, - ...(currentModelOption?.providerDriver === "claudeAgent" + ...(currentModelOption?.supportsAutoRuntimeMode ? [{ id: "options:runtime:auto", title: "Auto" }] : []), { id: "options:runtime:full-access", title: "Full access" }, @@ -590,7 +590,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ], [ currentInteractionMode, - currentModelOption?.providerDriver, + currentModelOption?.supportsAutoRuntimeMode, currentRuntimeMode, providerOptionDescriptors, ], diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index e21682414d7..f0e44f6bdf9 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -15,6 +15,7 @@ export type ModelOption = { readonly providerKey: string; readonly providerLabel: string; readonly providerDriver: string; + readonly supportsAutoRuntimeMode: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; }; @@ -78,6 +79,7 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, + supportsAutoRuntimeMode: provider.supportsAutoRuntimeMode ?? false, capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -107,6 +109,7 @@ export function buildModelOptions( providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver: fallbackModelSelection.instanceId, + supportsAutoRuntimeMode: false, capabilities: null, selection: fallbackModelSelection, });