diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 3145038647..bea28168a6 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the `; function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { - readonly approvalPolicy: "on-request" | "never"; - readonly sandbox: "workspace-write" | "danger-full-access"; + readonly approvalPolicy: "untrusted" | "on-request" | "never"; + readonly sandbox: "read-only" | "workspace-write" | "danger-full-access"; } { - if (runtimeMode === "approval-required") { - return { - approvalPolicy: "on-request", - sandbox: "workspace-write", - }; + switch (runtimeMode) { + case "approval-required": + return { + approvalPolicy: "untrusted", + sandbox: "read-only", + }; + case "auto-accept-edits": + return { + approvalPolicy: "on-request", + sandbox: "workspace-write", + }; + case "full-access": + return { + approvalPolicy: "never", + sandbox: "danger-full-access", + }; } - - return { - approvalPolicy: "never", - sandbox: "danger-full-access", - }; } /** diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d064a8239f..65eeef9797 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2493,57 +2493,62 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("restores base permission mode on sendTurn when interactionMode is default", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; + for (const [runtimeMode, expectedBase] of [ + ["full-access", "bypassPermissions"], + ["approval-required", "default"], + ["auto-accept-edits", "acceptEdits"], + ] as const) { + it.effect(`restores ${expectedBase} permission mode after plan turn (${runtimeMode})`, () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeAgent", - runtimeMode: "full-access", - }); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode, + }); - // First turn in plan mode - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "plan this", - interactionMode: "plan", - attachments: [], - }); + // First turn in plan mode + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); - // Complete the turn so we can send another - const turnCompletedFiber = yield* Stream.filter( - adapter.streamEvents, - (event) => event.type === "turn.completed", - ).pipe(Stream.runHead, Effect.forkChild); + // Complete the turn so we can send another + const turnCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-plan-restore", - uuid: "result-plan", - } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: `sdk-session-${runtimeMode}`, + uuid: `result-${runtimeMode}`, + } as unknown as SDKMessage); - yield* Fiber.join(turnCompletedFiber); + yield* Fiber.join(turnCompletedFiber); - // Second turn back to default - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "now do it", - interactionMode: "default", - attachments: [], - }); + // Second turn back to default + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "now do it", + interactionMode: "default", + attachments: [], + }); - // First call sets "plan", second call restores "bypassPermissions" (the base for full-access) - assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", "bypassPermissions"]); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); + assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", expectedBase]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + } it.effect("does not call setPermissionMode when interactionMode is absent", () => { const harness = makeHarness(); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6b50bd4fbb..6cbf090d32 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2693,7 +2693,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ? modelSelection.options.thinking : undefined; const effectiveEffort = getEffectiveClaudeCodeEffort(effort); - const permissionMode = input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; + const runtimeModeToPermission: Record = { + "auto-accept-edits": "acceptEdits", + "full-access": "bypassPermissions", + }; + const permissionMode = runtimeModeToPermission[input.runtimeMode]; const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), @@ -2881,8 +2885,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } else if (input.interactionMode === "default") { yield* Effect.tryPromise({ - try: () => - context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"), + try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7562f845e2..4cdb615b37 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -102,6 +102,7 @@ import { ListTodoIcon, LockIcon, LockOpenIcon, + PenLineIcon, XIcon, } from "lucide-react"; import { Button } from "./ui/button"; @@ -332,6 +333,30 @@ interface PendingPullRequestSetupRequest { scriptId: string; } +const runtimeModeConfig: Record< + RuntimeMode, + { label: string; title: string; icon: React.ReactNode; next: RuntimeMode } +> = { + "approval-required": { + label: "Supervised", + title: "Supervised - click for auto-accept edits", + icon: , + next: "auto-accept-edits", + }, + "auto-accept-edits": { + label: "Auto-accept edits", + title: "Auto-accept edits - click for full access", + icon: , + next: "full-access", + }, + "full-access": { + label: "Full access", + title: "Full access - click for supervised", + icon: , + next: "approval-required", + }, +}; + function useLocalDispatchState(input: { activeThread: Thread | undefined; activeLatestTurn: Thread["latestTurn"] | null; @@ -1811,10 +1836,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); - const toggleRuntimeMode = useCallback(() => { - void handleRuntimeModeChange( - runtimeMode === "full-access" ? "approval-required" : "full-access", - ); + const cycleRuntimeMode = useCallback(() => { + void handleRuntimeModeChange(runtimeModeConfig[runtimeMode].next); }, [handleRuntimeModeChange, runtimeMode]); const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { @@ -4015,7 +4038,7 @@ export default function ChatView({ threadId }: ChatViewProps) { traitsMenuContent={providerTraitsMenuContent} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} - onToggleRuntimeMode={toggleRuntimeMode} + onRuntimeModeChange={handleRuntimeModeChange} /> ) : ( <> @@ -4062,22 +4085,12 @@ export default function ChatView({ threadId }: ChatViewProps) { className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" size="sm" type="button" - onClick={() => - void handleRuntimeModeChange( - runtimeMode === "full-access" - ? "approval-required" - : "full-access", - ) - } - title={ - runtimeMode === "full-access" - ? "Full access — click to require approvals" - : "Approval required — click for full access" - } + onClick={cycleRuntimeMode} + title={runtimeModeConfig[runtimeMode].title} > - {runtimeMode === "full-access" ? : } + {runtimeModeConfig[runtimeMode].icon} - {runtimeMode === "full-access" ? "Full access" : "Supervised"} + {runtimeModeConfig[runtimeMode].label} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index eee6f885e9..a17651e657 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -130,7 +130,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str } onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} - onToggleRuntimeMode={vi.fn()} + onRuntimeModeChange={vi.fn()} />, { container: host }, ); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index db38ed8c1e..84e0953440 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -20,7 +20,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; - onToggleRuntimeMode: () => void; + onRuntimeModeChange: (mode: RuntimeMode) => void; }) { return ( @@ -60,10 +60,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls value={props.runtimeMode} onValueChange={(value) => { if (!value || value === props.runtimeMode) return; - props.onToggleRuntimeMode(); + props.onRuntimeModeChange(value as RuntimeMode); }} > Supervised + Auto-accept edits Full access {props.activePlan ? ( diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8a93b7b0da..2dd64adf7a 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1801,8 +1801,7 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } - const nextRuntimeMode = - runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null; + const nextRuntimeMode = runtimeMode ?? null; set((state) => { const existing = state.draftsByThreadId[threadId]; if (!existing && nextRuntimeMode === null) { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a780a55c78..a954d1e2dc 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -62,7 +62,11 @@ export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); export type ModelSelection = typeof ModelSelection.Type; -export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); +export const RuntimeMode = Schema.Literals([ + "approval-required", + "auto-accept-edits", + "full-access", +]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const ProviderInteractionMode = Schema.Literals(["default", "plan"]);