diff --git a/desktop/src/components/controls/PermissionModeSelector.test.tsx b/desktop/src/components/controls/PermissionModeSelector.test.tsx
index f1561696..d4642715 100644
--- a/desktop/src/components/controls/PermissionModeSelector.test.tsx
+++ b/desktop/src/components/controls/PermissionModeSelector.test.tsx
@@ -59,6 +59,7 @@ import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
const initialSetSessionPermissionMode = useChatStore.getState().setSessionPermissionMode
+const initialAcceptAutoModeOptIn = useSettingsStore.getState().acceptAutoModeOptIn
function makeChatSession(chatState: PerSessionState['chatState']): PerSessionState {
return {
@@ -85,7 +86,11 @@ function makeChatSession(chatState: PerSessionState['chatState']): PerSessionSta
describe('PermissionModeSelector', () => {
beforeEach(() => {
viewportMocks.isMobile = false
- useSettingsStore.setState({ permissionMode: 'default' })
+ useSettingsStore.setState({
+ permissionMode: 'default',
+ autoModeOptInAccepted: false,
+ acceptAutoModeOptIn: initialAcceptAutoModeOptIn,
+ })
useChatStore.setState({
sessions: {},
setSessionPermissionMode: initialSetSessionPermissionMode,
@@ -486,6 +491,28 @@ describe('PermissionModeSelector', () => {
})
})
+ it('confirms every entry into Auto without rewriting prior consent', async () => {
+ const onChange = vi.fn()
+ const acceptAutoModeOptIn = vi.fn().mockResolvedValue(undefined)
+ useSettingsStore.setState({
+ autoModeOptInAccepted: true,
+ acceptAutoModeOptIn,
+ } as never)
+
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' }))
+ fireEvent.click(screen.getByRole('menuitem', { name: /Auto mode/ }))
+
+ expect(onChange).not.toHaveBeenCalled()
+ expect(screen.getByRole('dialog', { name: 'Enable Auto mode?' })).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Enable Auto mode' }))
+
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith('auto'))
+ expect(acceptAutoModeOptIn).not.toHaveBeenCalled()
+ })
+
it('applies first-use Auto consent to the active session', async () => {
const setSessionPermissionMode = vi.fn()
const acceptAutoModeOptIn = vi.fn().mockResolvedValue(undefined)
diff --git a/desktop/src/components/controls/PermissionModeSelector.tsx b/desktop/src/components/controls/PermissionModeSelector.tsx
index 0cacfb82..d3c5d004 100644
--- a/desktop/src/components/controls/PermissionModeSelector.tsx
+++ b/desktop/src/components/controls/PermissionModeSelector.tsx
@@ -190,7 +190,7 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
interactionTabIdRef.current = null
return
}
- if (item.value === 'auto' && !autoModeOptInAccepted) {
+ if (item.value === 'auto' && item.value !== currentMode) {
setOpen(false)
setAutoDialog(true)
return
@@ -384,7 +384,9 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
setAutoConsentPending(true)
try {
- await acceptAutoModeOptIn()
+ if (!autoModeOptInAccepted) {
+ await acceptAutoModeOptIn()
+ }
const confirmedTabId = useTabStore.getState().activeTabId
if (
confirmedTabId !== interactionTabIdRef.current ||
diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts
index 2587f693..1abd1605 100644
--- a/src/server/__tests__/conversations.test.ts
+++ b/src/server/__tests__/conversations.test.ts
@@ -429,11 +429,22 @@ describe('ConversationService', () => {
it('should not inject a desktop-specific ask override in default permission mode', () => {
const svc = new ConversationService()
expect((svc as any).getPermissionArgs('default', false)).toEqual([
+ '--allow-dangerously-skip-permissions',
'--permission-mode',
'default',
])
})
+ it('should keep an initially requested bypass session explicitly dangerous', () => {
+ const svc = new ConversationService()
+ expect((svc as any).getPermissionArgs('bypassPermissions', false)).toEqual([
+ '--dangerously-skip-permissions',
+ ])
+ expect((svc as any).getPermissionArgs('default', true)).toEqual([
+ '--dangerously-skip-permissions',
+ ])
+ })
+
it('should pass disabled thinking to the CLI runtime args', () => {
const svc = new ConversationService()
expect((svc as any).getRuntimeArgs({
@@ -1394,7 +1405,7 @@ describe('WebSocket Chat Integration', () => {
}
async function withMockPermissionModeBehavior(
- behavior: 'confirm' | 'reject' | 'acknowledge' | 'status-before-reject',
+ behavior: 'confirm' | 'reject' | 'acknowledge' | 'status-before-reject' | 'unavailable',
callback: () => Promise,
): Promise {
const previousBehavior = process.env.MOCK_SDK_PERMISSION_MODE_BEHAVIOR
@@ -3668,7 +3679,7 @@ describe('WebSocket Chat Integration', () => {
})
}, 20_000)
- it('should defer bypass permission restarts until the active turn completes', async () => {
+ it('should defer bypass permission changes until the active turn completes without restarting', async () => {
await withMockStreamDelay(350, async () => {
await fetch(`${baseUrl}/api/permissions/mode`, {
method: 'PUT',
@@ -3703,7 +3714,7 @@ describe('WebSocket Chat Integration', () => {
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
let switchTriggered = false
let turnComplete = false
- let modeConfirmedBeforeTurnComplete = false
+ let modeConfirmed = false
let deferredInspectionChecked = false
try {
await new Promise((resolve, reject) => {
@@ -3757,34 +3768,24 @@ describe('WebSocket Chat Integration', () => {
return
}
- if (msg.type === 'permission_mode_changed' && !turnComplete) {
- modeConfirmedBeforeTurnComplete = true
- }
-
- if (
- msg.type === 'status' &&
- msg.state === 'idle' &&
- switchTriggered &&
- !turnComplete &&
- startCalls.length > 1
- ) {
- clearTimeout(timeout)
- ws.close()
- reject(new Error('Permission restart ran before the active turn completed'))
+ if (msg.type === 'permission_mode_changed' && msg.mode === 'bypassPermissions') {
+ modeConfirmed = true
+ if (turnComplete) {
+ clearTimeout(timeout)
+ resolve()
+ }
return
}
if (msg.type === 'message_complete' && switchTriggered && !turnComplete) {
turnComplete = true
expect(startCalls).toHaveLength(1)
+ if (modeConfirmed) {
+ clearTimeout(timeout)
+ resolve()
+ }
return
}
-
- if (msg.type === 'status' && msg.state === 'idle' && turnComplete && startCalls.length > 1) {
- clearTimeout(timeout)
- ws.close()
- resolve()
- }
}
ws.onerror = () => {
@@ -3795,21 +3796,16 @@ describe('WebSocket Chat Integration', () => {
expect(switchTriggered).toBe(true)
expect(turnComplete).toBe(true)
+ expect(modeConfirmed).toBe(true)
expect(deferredInspectionChecked).toBe(true)
- expect(modeConfirmedBeforeTurnComplete).toBe(false)
- expect(startCalls).toHaveLength(2)
+ expect(startCalls).toHaveLength(1)
expect(startCalls[0]).toMatchObject({
sessionId,
options: {
permissionMode: 'default',
},
})
- expect(startCalls[1]).toMatchObject({
- sessionId,
- options: {
- permissionMode: 'bypassPermissions',
- },
- })
+ expect(conversationService.getSessionPermissionMode(sessionId)).toBe('bypassPermissions')
} finally {
ws.close()
conversationService.startSession = originalStartSession
@@ -3823,7 +3819,7 @@ describe('WebSocket Chat Integration', () => {
})
}, 20_000)
- it('should keep the session idle in the UI while restarting for a bypass permission switch', async () => {
+ it('should enter bypass permissions without restarting the CLI', async () => {
await fetch(`${baseUrl}/api/permissions/mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -3892,29 +3888,21 @@ describe('WebSocket Chat Integration', () => {
}, `prewarmed slash commands for idle permission switch ${sessionId}`)
const switchStartIndex = messages.length
+ const switchStartedAt = performance.now()
ws.send(JSON.stringify({
type: 'set_permission_mode',
mode: 'bypassPermissions',
}))
await waitUntil(
- async () => messages.slice(switchStartIndex).some((msg) => msg.type === 'status' && msg.state === 'idle'),
- `idle permission switch completion for ${sessionId}`,
+ () => messages.slice(switchStartIndex).some((msg) =>
+ msg.type === 'permission_mode_changed' && msg.mode === 'bypassPermissions'
+ ),
+ `in-process bypass permission switch completion for ${sessionId}`,
)
- expect(startCalls).toHaveLength(2)
- expect(startCalls[1]).toMatchObject({
- sessionId,
- options: {
- permissionMode: 'bypassPermissions',
- },
- })
- expect(
- messages
- .slice(switchStartIndex)
- .filter((msg) => msg.type === 'status')
- .map((msg) => msg.state),
- ).toEqual(['idle'])
+ expect(performance.now() - switchStartedAt).toBeLessThan(1_000)
+ expect(startCalls).toHaveLength(1)
expect(
messages
.slice(switchStartIndex)
@@ -3933,6 +3921,71 @@ describe('WebSocket Chat Integration', () => {
}
}, 20_000)
+ it('should restart an already-running legacy session only when bypass capability is unavailable', async () => {
+ await withMockPermissionModeBehavior('unavailable', async () => {
+ const createRes = await fetch(`${baseUrl}/api/sessions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'default' }),
+ })
+ expect(createRes.status).toBe(201)
+ const { sessionId } = await createRes.json() as { sessionId: string }
+
+ const originalStartSession = conversationService.startSession.bind(conversationService)
+ const startModes: Array = []
+ conversationService.startSession = (async function patchedStartSession(
+ sid: string,
+ workDir: string,
+ sdkUrl: string,
+ options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
+ ) {
+ startModes.push(options?.permissionMode)
+ return originalStartSession(sid, workDir, sdkUrl, options)
+ }) as typeof conversationService.startSession
+
+ const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
+ const messages: any[] = []
+ try {
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error(`Timed out prewarming ${sessionId}`)), 5_000)
+ ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data as string)
+ messages.push(msg)
+ if (msg.type === 'connected') {
+ clearTimeout(timeout)
+ ws.send(JSON.stringify({ type: 'prewarm_session' }))
+ resolve()
+ }
+ }
+ ws.onerror = () => {
+ clearTimeout(timeout)
+ reject(new Error(`WebSocket error prewarming ${sessionId}`))
+ }
+ })
+ await waitUntil(
+ () => Boolean((conversationService as any).sessions.get(sessionId)?.sdkSocket),
+ `SDK control channel for legacy bypass fallback ${sessionId}`,
+ )
+
+ ws.send(JSON.stringify({ type: 'set_permission_mode', mode: 'bypassPermissions' }))
+ await waitUntil(
+ () => messages.some((msg) =>
+ msg.type === 'permission_mode_changed' && msg.mode === 'bypassPermissions'
+ ),
+ `legacy bypass restart confirmation ${sessionId}`,
+ )
+
+ expect(startModes).toEqual(['default', 'bypassPermissions'])
+ expect(messages.some((msg) => msg.type === 'error')).toBe(false)
+ expect(conversationService.getSessionPermissionMode(sessionId)).toBe('bypassPermissions')
+ } finally {
+ ws.close()
+ conversationService.startSession = originalStartSession
+ conversationService.stopSession(sessionId)
+ }
+ })
+ }, 20_000)
+
it('should persist permission changes made before the CLI starts', async () => {
await fetch(`${baseUrl}/api/permissions/mode`, {
method: 'PUT',
@@ -4312,62 +4365,61 @@ describe('WebSocket Chat Integration', () => {
}
}, 20_000)
- it('should preserve safe permission metadata when a bypass restart fails', async () => {
- const createRes = await fetch(`${baseUrl}/api/sessions`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'default' }),
- })
- expect(createRes.status).toBe(201)
- const { sessionId } = await createRes.json() as { sessionId: string }
- const originalStartSession = conversationService.startSession.bind(conversationService)
- let startCount = 0
- conversationService.startSession = (async (...args: Parameters) => {
- startCount += 1
- if (startCount === 2) throw new Error('mock bypass restart failure')
- return originalStartSession(...args)
- }) as typeof conversationService.startSession
- const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
- const messages: any[] = []
-
- try {
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => reject(new Error(`Timed out prewarming ${sessionId}`)), 5_000)
- ws.onmessage = (event) => {
- const msg = JSON.parse(event.data as string)
- messages.push(msg)
- if (msg.type === 'connected') {
- clearTimeout(timeout)
- ws.send(JSON.stringify({ type: 'prewarm_session' }))
- resolve()
- }
- }
- ws.onerror = () => {
- clearTimeout(timeout)
- reject(new Error(`WebSocket error prewarming ${sessionId}`))
- }
+ it('should preserve safe permission metadata when a bypass change is rejected', async () => {
+ await withMockPermissionModeBehavior('reject', async () => {
+ const createRes = await fetch(`${baseUrl}/api/sessions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'default' }),
})
- await waitUntil(() => startCount === 1, `initial prewarm start for ${sessionId}`)
+ expect(createRes.status).toBe(201)
+ const { sessionId } = await createRes.json() as { sessionId: string }
+ const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
+ const messages: any[] = []
- ws.send(JSON.stringify({ type: 'set_permission_mode', mode: 'bypassPermissions' }))
- await waitUntil(
- () => messages.some((msg) => msg.type === 'error' && msg.code === 'CLI_RESTART_FAILED'),
- `failed bypass restart for ${sessionId}`,
- )
+ try {
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error(`Timed out prewarming ${sessionId}`)), 5_000)
+ ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data as string)
+ messages.push(msg)
+ if (msg.type === 'connected') {
+ clearTimeout(timeout)
+ ws.send(JSON.stringify({ type: 'prewarm_session' }))
+ resolve()
+ }
+ }
+ ws.onerror = () => {
+ clearTimeout(timeout)
+ reject(new Error(`WebSocket error prewarming ${sessionId}`))
+ }
+ })
+ await waitUntil(
+ () => Boolean((conversationService as any).sessions.get(sessionId)?.sdkSocket),
+ `SDK control channel for rejected bypass change ${sessionId}`,
+ )
- const inspectionRes = await fetch(
- `${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`,
- )
- const inspection = await inspectionRes.json() as { status?: { permissionMode?: string } }
- expect(inspection.status?.permissionMode).toBe('default')
- expect(messages.some((msg) =>
- msg.type === 'permission_mode_changed' && msg.mode === 'bypassPermissions'
- )).toBe(false)
- } finally {
- ws.close()
- conversationService.startSession = originalStartSession
- conversationService.stopSession(sessionId)
- }
+ ws.send(JSON.stringify({ type: 'set_permission_mode', mode: 'bypassPermissions' }))
+ await waitUntil(
+ () => messages.some((msg) =>
+ msg.type === 'error' && msg.code === 'PERMISSION_MODE_CHANGE_FAILED'
+ ),
+ `rejected bypass change for ${sessionId}`,
+ )
+
+ const inspectionRes = await fetch(
+ `${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`,
+ )
+ const inspection = await inspectionRes.json() as { status?: { permissionMode?: string } }
+ expect(inspection.status?.permissionMode).toBe('default')
+ expect(messages.some((msg) =>
+ msg.type === 'permission_mode_changed' && msg.mode === 'bypassPermissions'
+ )).toBe(false)
+ } finally {
+ ws.close()
+ conversationService.stopSession(sessionId)
+ }
+ })
}, 20_000)
it('should persist CLI-originated permission-mode broadcasts', async () => {
@@ -5205,7 +5257,7 @@ describe('WebSocket Chat Integration', () => {
}
}, 20_000)
- it('should wait for an in-flight permission restart before sending the next user turn', async () => {
+ it('should wait for an in-flight permission change before sending the next user turn', async () => {
await fetch(`${baseUrl}/api/permissions/mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -5304,19 +5356,13 @@ describe('WebSocket Chat Integration', () => {
}
})
- expect(startCalls).toHaveLength(2)
+ expect(startCalls).toHaveLength(1)
expect(startCalls[0]).toMatchObject({
sessionId,
options: {
permissionMode: 'default',
},
})
- expect(startCalls[1]).toMatchObject({
- sessionId,
- options: {
- permissionMode: 'bypassPermissions',
- },
- })
expect(sendCalls).toMatchObject([
{
content: 'first turn before permission switch',
@@ -5325,7 +5371,7 @@ describe('WebSocket Chat Integration', () => {
},
{
content: 'second turn immediately after permission switch',
- startCallCount: 2,
+ startCallCount: 1,
permissionMode: 'bypassPermissions',
},
])
diff --git a/src/server/__tests__/fixtures/mock-sdk-cli.ts b/src/server/__tests__/fixtures/mock-sdk-cli.ts
index db1d243e..7d2c70de 100644
--- a/src/server/__tests__/fixtures/mock-sdk-cli.ts
+++ b/src/server/__tests__/fixtures/mock-sdk-cli.ts
@@ -203,6 +203,19 @@ ws.addEventListener('message', (event) => {
}
if (parsed.type === 'control_request' && parsed.request?.subtype === 'set_permission_mode') {
+ if (permissionModeBehavior === 'unavailable') {
+ emit(ws, {
+ type: 'control_response',
+ response: {
+ subtype: 'error',
+ request_id: parsed.request_id,
+ error:
+ 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions',
+ },
+ session_id: sessionId,
+ })
+ continue
+ }
if (permissionModeBehavior === 'status-before-reject') {
emit(ws, {
type: 'system',
diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts
index 1638e8b7..2aaaad9d 100644
--- a/src/server/__tests__/ws-memory-events.test.ts
+++ b/src/server/__tests__/ws-memory-events.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'bun:test'
import {
createCurrentTurnLocalCommandForwarder,
- shouldRestartForPermissionMode,
+ shouldFallbackToPermissionRestart,
translateCliMessage,
} from '../ws/handler.js'
import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
@@ -318,29 +318,29 @@ describe('WebSocket compact events', () => {
})
})
-describe('WebSocket permission mode restart policy', () => {
- it('restarts the CLI only when entering bypassPermissions', () => {
- // 进入 bypass 需要带 --dangerously-skip-permissions 重启子进程。
- expect(shouldRestartForPermissionMode('default', 'bypassPermissions')).toBe(true)
- expect(shouldRestartForPermissionMode('plan', 'bypassPermissions')).toBe(true)
- expect(shouldRestartForPermissionMode('acceptEdits', 'bypassPermissions')).toBe(true)
+describe('WebSocket permission mode compatibility fallback', () => {
+ it('restarts only when an old CLI session lacks the bypass launch capability', () => {
+ expect(shouldFallbackToPermissionRestart(
+ 'bypassPermissions',
+ new Error(
+ 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions',
+ ),
+ )).toBe(true)
})
- it('does NOT restart when leaving bypassPermissions for a stricter mode', () => {
- // 从 bypass 切出不重启——否则会冲掉进程内 prePlanMode,导致 ExitPlanMode 后
- // 恢复成 default 而非进入 plan 前的 bypassPermissions。这正是桌面端退出 plan
- // 权限回不到 bypass 的根因。
- expect(shouldRestartForPermissionMode('bypassPermissions', 'plan')).toBe(false)
- expect(shouldRestartForPermissionMode('bypassPermissions', 'default')).toBe(false)
- expect(shouldRestartForPermissionMode('bypassPermissions', 'acceptEdits')).toBe(false)
+ it('does not restart when bypass is disabled by user settings', () => {
+ expect(shouldFallbackToPermissionRestart(
+ 'bypassPermissions',
+ new Error(
+ 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration',
+ ),
+ )).toBe(false)
})
- it('does not restart for non-bypass transitions or no-op changes', () => {
- expect(shouldRestartForPermissionMode('default', 'plan')).toBe(false)
- expect(shouldRestartForPermissionMode('plan', 'acceptEdits')).toBe(false)
- // 同模式(含 bypass→bypass)是 no-op,不重启。
- expect(shouldRestartForPermissionMode('bypassPermissions', 'bypassPermissions')).toBe(false)
- expect(shouldRestartForPermissionMode('plan', 'plan')).toBe(false)
+ it('does not restart other permission failures', () => {
+ expect(shouldFallbackToPermissionRestart('auto', new Error('classifier unavailable'))).toBe(false)
+ expect(shouldFallbackToPermissionRestart('default', new Error('mock rejection'))).toBe(false)
+ expect(shouldFallbackToPermissionRestart('bypassPermissions', new Error('mock rejection'))).toBe(false)
})
})
diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts
index 91dc989d..b8dc4580 100644
--- a/src/server/services/conversationService.ts
+++ b/src/server/services/conversationService.ts
@@ -1109,7 +1109,11 @@ export class ConversationService {
return ['--dangerously-skip-permissions']
}
- const args = ['--permission-mode', resolvedMode]
+ const args = [
+ '--allow-dangerously-skip-permissions',
+ '--permission-mode',
+ resolvedMode,
+ ]
return args
}
diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts
index 2d892f9f..7c622f16 100644
--- a/src/server/ws/handler.ts
+++ b/src/server/ws/handler.ts
@@ -758,28 +758,26 @@ async function handleSetPermissionMode(
return
}
- await applyPermissionModeToActiveSession(ws, sessionId, message.mode)
+ await enqueueRuntimeTransition(sessionId, () =>
+ applyPermissionModeToActiveSession(ws, sessionId, message.mode),
+ )
}
+const BYPASS_CAPABILITY_UNAVAILABLE =
+ 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions'
+
/**
- * 决定一次权限模式切换是否需要重启 CLI 子进程。
- *
- * 只有"进入 bypassPermissions"才需要重启:CLI 必须带 --dangerously-skip-permissions
- * 启动,否则运行时的 set_permission_mode → bypassPermissions 会被拒绝,所以重启子进程
- * 带上该 flag。
- *
- * 反过来"从 bypassPermissions 切到更严格的模式"**不要**重启:此时进程已带 flag,运行时
- * 降级即可。更关键的是——重启会把进程内的 prePlanMode 记忆冲掉:若 bypass→plan 走重启,
- * 新 CLI 直接以 plan 启动、prePlanMode 为空,ExitPlanMode 只能恢复成 default 而非进入前的
- * bypassPermissions。保持进程不变、走 setPermissionMode 做进程内 transition,CLI 才会像 TUI
- * 一样栈存 prePlanMode='bypassPermissions',退出 plan 时正确恢复 bypass。
+ * Sessions launched by this desktop build can switch into bypass in-process.
+ * A session that was already running before an app update may lack that launch
+ * capability, so retain the old restart path only for that exact CLI error.
*/
-export function shouldRestartForPermissionMode(
- currentMode: string,
- mode: string,
+export function shouldFallbackToPermissionRestart(
+ mode: PermissionMode,
+ error: unknown,
): boolean {
- if (currentMode === mode) return false
- return mode === 'bypassPermissions'
+ if (mode !== 'bypassPermissions') return false
+ const message = error instanceof Error ? error.message : String(error)
+ return message.includes(BYPASS_CAPABILITY_UNAVAILABLE)
}
async function applyPermissionModeToActiveSession(
@@ -797,15 +795,6 @@ async function applyPermissionModeToActiveSession(
sendToSession(sessionId, { type: 'permission_mode_changed', mode })
return
}
- const needsRestart = shouldRestartForPermissionMode(currentMode, mode)
-
- if (needsRestart) {
- void enqueueRuntimeTransition(sessionId, () =>
- restartSessionWithPermissionMode(ws, sessionId, mode),
- )
- return
- }
-
try {
const ok = await conversationService.setPermissionMode(sessionId, mode)
if (!ok) {
@@ -814,6 +803,10 @@ async function applyPermissionModeToActiveSession(
}
await commitConfirmedPermissionMode(sessionId, mode)
} catch (err) {
+ if (shouldFallbackToPermissionRestart(mode, err)) {
+ await restartSessionWithPermissionMode(ws, sessionId, mode)
+ return
+ }
const errMsg = err instanceof Error ? err.message : String(err)
console.warn(`[WS] Failed to set permission mode for ${sessionId}: ${errMsg}`)
sendMessage(ws, {