diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index d1ac7ffb..3c3a592b 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -1,6 +1,7 @@ import { api } from './client' import type { AgentTaskNotification } from '../types/chat' import type { SessionListItem, MessageEntry } from '../types/session' +import type { PermissionMode } from '../types/settings' type SessionsResponse = { sessions: SessionListItem[]; total: number } type MessagesResponse = { @@ -39,6 +40,7 @@ export type CreateSessionRepositoryOptions = { export type CreateSessionRequest = { workDir?: string repository?: CreateSessionRepositoryOptions + permissionMode?: PermissionMode } export type BranchSessionRequest = { targetMessageId: string diff --git a/desktop/src/components/controls/PermissionModeSelector.test.tsx b/desktop/src/components/controls/PermissionModeSelector.test.tsx index 03dc1efb..19fa1c54 100644 --- a/desktop/src/components/controls/PermissionModeSelector.test.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.test.tsx @@ -43,6 +43,7 @@ vi.mock('../../i18n', () => ({ })) import { PermissionModeSelector } from './PermissionModeSelector' +import { useChatStore } from '../../stores/chatStore' import { useSettingsStore } from '../../stores/settingsStore' import { useSessionStore } from '../../stores/sessionStore' import { useTabStore } from '../../stores/tabStore' @@ -55,6 +56,47 @@ describe('PermissionModeSelector', () => { useTabStore.setState({ activeTabId: null, tabs: [] }) }) + it('updates the active session without writing the global default mode', () => { + const setGlobalPermissionMode = vi.fn() + const setSessionPermissionMode = vi.fn() + useSettingsStore.setState({ + permissionMode: 'default', + setPermissionMode: setGlobalPermissionMode, + }) + useChatStore.setState({ + setSessionPermissionMode, + } as Partial>) + useSessionStore.setState({ + activeSessionId: 'current-tab', + sessions: [ + { + id: 'current-tab', + title: 'Current', + createdAt: '2026-05-24T00:00:00.000Z', + modifiedAt: '2026-05-24T00:00:00.000Z', + messageCount: 1, + projectPath: '/repo', + projectRoot: '/repo', + workDir: '/repo', + workDirExists: true, + permissionMode: 'default', + }, + ], + }) + useTabStore.setState({ + activeTabId: 'current-tab', + tabs: [{ sessionId: 'current-tab', title: 'Current', type: 'session', status: 'idle' }], + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + fireEvent.click(screen.getByRole('menuitem', { name: /Auto accept edits/ })) + + expect(setGlobalPermissionMode).not.toHaveBeenCalled() + expect(setSessionPermissionMode).toHaveBeenCalledWith('current-tab', 'acceptEdits') + }) + it('labels the compact mobile trigger and opens a phone-sized menu sheet', () => { viewportMocks.isMobile = true diff --git a/desktop/src/components/controls/PermissionModeSelector.tsx b/desktop/src/components/controls/PermissionModeSelector.tsx index 814a1d5e..f695b7d6 100644 --- a/desktop/src/components/controls/PermissionModeSelector.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.tsx @@ -31,7 +31,7 @@ type Props = { export function PermissionModeSelector({ workDir: workDirProp, compact = false, value, onChange }: Props = {}) { const t = useTranslation() const isMobile = useMobileViewport() && !isTauriRuntime() - const { permissionMode: storeMode, setPermissionMode } = useSettingsStore() + const { permissionMode: storeMode } = useSettingsStore() const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode) const activeTabId = useTabStore((s) => s.activeTabId) const sessions = useSessionStore((s) => s.sessions) @@ -41,8 +41,6 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, const menuRef = useRef(null) const isControlled = value !== undefined - const currentMode = isControlled ? value : storeMode - const PERMISSION_ITEMS: Array<{ value: PermissionMode label: string @@ -89,6 +87,9 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, const activeSession = activeTabId ? sessions.find((s) => s.id === activeTabId) : null + const currentMode = isControlled + ? value + : (activeSession?.permissionMode as PermissionMode | undefined) || storeMode const workDir = workDirProp || activeSession?.workDir || '~' const compactButtonClass = compact ? isMobile @@ -135,7 +136,6 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, if (isControlled) { onChange?.(item.value) } else { - void setPermissionMode(item.value) if (activeTabId) setSessionPermissionMode(activeTabId, item.value) } setOpen(false) @@ -267,7 +267,6 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, if (isControlled) { onChange?.('bypassPermissions') } else { - void setPermissionMode('bypassPermissions') if (activeTabId) setSessionPermissionMode(activeTabId, 'bypassPermissions') } setConfirmDialog(false) diff --git a/desktop/src/components/tasks/NewTaskModal.test.tsx b/desktop/src/components/tasks/NewTaskModal.test.tsx index 8c24de79..1eef4fff 100644 --- a/desktop/src/components/tasks/NewTaskModal.test.tsx +++ b/desktop/src/components/tasks/NewTaskModal.test.tsx @@ -86,6 +86,7 @@ describe('NewTaskModal', () => { expect(createTask).toHaveBeenCalledWith(expect.objectContaining({ model: 'provider-fast', providerId: 'provider-a', + permissionMode: 'bypassPermissions', enabled: true, recurring: true, })) diff --git a/desktop/src/components/tasks/NewTaskModal.tsx b/desktop/src/components/tasks/NewTaskModal.tsx index 5ce49dec..c4bd00b0 100644 --- a/desktop/src/components/tasks/NewTaskModal.tsx +++ b/desktop/src/components/tasks/NewTaskModal.tsx @@ -9,7 +9,6 @@ import { PromptEditor } from './PromptEditor' import { DayOfWeekPicker } from './DayOfWeekPicker' import { useTranslation } from '../../i18n' import { describeCron, isValidCron, parseCron, type FrequencyKey } from '../../lib/cronDescribe' -import type { PermissionMode } from '../../types/settings' import type { CronTask } from '../../types/task' type NotificationChannel = 'desktop' | 'telegram' | 'feishu' @@ -94,7 +93,6 @@ export function NewTaskModal({ open, onClose, editTask }: Props) { const [time, setTime] = useState(parsed?.time || '09:00') const [model, setModel] = useState(editTask?.model || '') const [providerId, setProviderId] = useState(editTask?.providerId) - const [permissionMode, setPermissionMode] = useState((editTask?.permissionMode as PermissionMode) || 'default') const [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir) const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false) const [notifyEnabled, setNotifyEnabled] = useState(editTask?.notification?.enabled || false) @@ -134,7 +132,7 @@ export function NewTaskModal({ open, onClose, editTask }: Props) { prompt: prompt.trim(), model: model || undefined, providerId, - permissionMode: permissionMode !== 'default' ? permissionMode : undefined, + permissionMode: 'bypassPermissions', folderPath: folderPath.trim() || undefined, useWorktree: useWorktree || undefined, notification: notifyEnabled && notifyChannels.length > 0 @@ -200,8 +198,6 @@ export function NewTaskModal({ open, onClose, editTask }: Props) { value={prompt} onChange={setPrompt} placeholder={t('newTask.promptPlaceholder')} - permissionMode={permissionMode} - onPermissionModeChange={setPermissionMode} modelId={model} onModelChange={setModel} providerId={providerId} diff --git a/desktop/src/components/tasks/PromptEditor.tsx b/desktop/src/components/tasks/PromptEditor.tsx index b406751c..f70fe2b9 100644 --- a/desktop/src/components/tasks/PromptEditor.tsx +++ b/desktop/src/components/tasks/PromptEditor.tsx @@ -1,17 +1,12 @@ -import { PermissionModeSelector } from '../controls/PermissionModeSelector' import { ModelSelector } from '../controls/ModelSelector' import { DirectoryPicker } from '../shared/DirectoryPicker' import { useTranslation } from '../../i18n' -import type { PermissionMode } from '../../types/settings' type Props = { value: string onChange: (value: string) => void placeholder?: string - permissionMode: PermissionMode - onPermissionModeChange: (mode: PermissionMode) => void - modelId: string onModelChange: (modelId: string) => void providerId?: string | null @@ -28,8 +23,6 @@ export function PromptEditor({ value, onChange, placeholder, - permissionMode, - onPermissionModeChange, modelId, onModelChange, providerId, @@ -56,7 +49,10 @@ export function PromptEditor({
{/* Row 1: Permission + Model selectors */}
- +
+ gavel + {t('newTask.fullPermissions')} +
{ @@ -71,13 +67,10 @@ export function PromptEditor({
- {/* Bypass + no folder warning */} - {permissionMode === 'bypassPermissions' && ( -
- warning - {t('promptEditor.bypassWarning')}{folderPath ? ` ${t('promptEditor.within')} ${folderPath}` : ` ${t('promptEditor.selectFolder')}`}. -
- )} +
+ warning + {t('promptEditor.bypassWarning')}{folderPath ? ` ${t('promptEditor.within')} ${folderPath}` : ` ${t('promptEditor.selectFolder')}`}. +
) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index bbda80d4..5c12a29c 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1309,6 +1309,7 @@ export const en = { // ─── New Task Modal ────────────────────────────────────── 'newTask.title': 'New scheduled task', 'newTask.localWarning': 'Local tasks only run while your computer is awake.', + 'newTask.fullPermissions': 'Full permissions', 'newTask.name': 'Name', 'newTask.namePlaceholder': 'daily-code-review', 'newTask.description': 'Description', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 4d589fa9..41479fad 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1311,6 +1311,7 @@ export const zh: Record = { // ─── New Task Modal ────────────────────────────────────── 'newTask.title': '新建定时任务', 'newTask.localWarning': '本地任务仅在电脑唤醒时运行。', + 'newTask.fullPermissions': '所有权限', 'newTask.name': '名称', 'newTask.namePlaceholder': 'daily-code-review', 'newTask.description': '描述', diff --git a/desktop/src/mocks/data.ts b/desktop/src/mocks/data.ts index 4c09cbb8..28295704 100644 --- a/desktop/src/mocks/data.ts +++ b/desktop/src/mocks/data.ts @@ -185,7 +185,6 @@ export const mockToolInspection = { // ─── New Task Modal ─────────────────────────────────────────────── export const mockNewTaskDefaults = { - permissionModes: ['Restricted', 'Standard', 'Full Access'], models: ['Claude 3.5 Sonnet', 'Claude 3.5 Haiku', 'Claude 3.5 Opus'], frequencies: ['Hourly', 'Daily at 9:00 AM', 'Weekly', 'Monthly', 'Custom cron'], } diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 67bb8904..f2f565c9 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -174,7 +174,7 @@ describe('EmptySession', () => { mocks.webviewDragHandlers.length = 0 mocks.isMobile = false mocks.isTauriRuntime = false - useSettingsStore.setState({ locale: 'en', activeProviderName: null }) + useSettingsStore.setState({ locale: 'en', activeProviderName: null, permissionMode: 'default' }) useSessionStore.setState(initialSessionState, true) useChatStore.setState(initialChatState, true) useTabStore.setState(initialTabState, true) @@ -354,6 +354,7 @@ describe('EmptySession', () => { expect(mocks.createSession).toHaveBeenCalledWith({ workDir: '/workspace/project', repository: { branch: 'main', worktree: false }, + permissionMode: 'default', }) }) @@ -394,7 +395,7 @@ describe('EmptySession', () => { fireEvent.click(screen.getByRole('button', { name: /Run/i })) await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith({}) + expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' }) }) expect(useSessionRuntimeStore.getState().selections['draft-session']).toEqual({ @@ -436,7 +437,7 @@ describe('EmptySession', () => { fireEvent.click(screen.getByRole('button', { name: /Run/i })) await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith({}) + expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' }) }) expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', { type: 'user_message', @@ -488,7 +489,7 @@ describe('EmptySession', () => { fireEvent.click(screen.getByRole('button', { name: /Run/i })) await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith({}) + expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' }) }) expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', { type: 'user_message', @@ -563,6 +564,7 @@ describe('EmptySession', () => { await waitFor(() => { expect(mocks.createSession).toHaveBeenCalledWith({ workDir: '/workspace/project', + permissionMode: 'default', }) }) }) @@ -668,6 +670,7 @@ describe('EmptySession', () => { expect(mocks.createSession).toHaveBeenCalledWith({ workDir: '/workspace/project', repository: { branch: 'main', worktree: false }, + permissionMode: 'default', }) }) }) @@ -759,6 +762,7 @@ describe('EmptySession', () => { expect(mocks.createSession).toHaveBeenCalledWith({ workDir: '/workspace/project', repository: { branch: 'main', worktree: false }, + permissionMode: 'default', }) }) }) diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 410cb43b..ffb94de0 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -36,6 +36,7 @@ import { resolveSlashUiAction, } from '../components/chat/composerUtils' import type { AttachmentRef } from '../types/chat' +import type { PermissionMode } from '../types/settings' import type { SlashCommandOption } from '../components/chat/composerUtils' type Attachment = ComposerAttachment @@ -110,6 +111,8 @@ export function EmptySession() { const addToast = useUIStore((state) => state.addToast) const currentModel = useSettingsStore((state) => state.currentModel) const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior) + const defaultPermissionMode = useSettingsStore((state) => state.permissionMode) + const [draftPermissionMode, setDraftPermissionMode] = useState(defaultPermissionMode) const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary) const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY]) const draftRuntimeSelectionKey = draftRuntimeSelection @@ -275,9 +278,12 @@ export function EmptySession() { const explicitDraftSelection = useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY] const sessionId = await createSession( workDir || undefined, - selectedBranch - ? { repository: { branch: selectedBranch, worktree: useWorktree } } - : undefined, + { + ...(selectedBranch + ? { repository: { branch: selectedBranch, worktree: useWorktree } } + : {}), + permissionMode: draftPermissionMode, + }, ) if (explicitDraftSelection) { useSessionRuntimeStore.getState().setSelection(sessionId, explicitDraftSelection) @@ -731,7 +737,12 @@ export function EmptySession() { )} - +
diff --git a/desktop/src/pages/NewTaskModal.tsx b/desktop/src/pages/NewTaskModal.tsx index af580be7..6ec0b653 100644 --- a/desktop/src/pages/NewTaskModal.tsx +++ b/desktop/src/pages/NewTaskModal.tsx @@ -13,7 +13,6 @@ export default function NewTaskModal() { const [taskName, setTaskName] = useState('') const [description, setDescription] = useState('') const [systemPrompt, setSystemPrompt] = useState('') - const [permissionMode, setPermissionMode] = useState(mockNewTaskDefaults.permissionModes[0]) const [model, setModel] = useState(mockNewTaskDefaults.models[0]) const [rootFolder, setRootFolder] = useState('') const [frequency, setFrequency] = useState(mockNewTaskDefaults.frequencies[1]) @@ -189,21 +188,11 @@ export default function NewTaskModal() { -
- - - unfold_more +
+ + gavel + Full Access
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 2ef29416..52de6b24 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' -import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior } from '../types/settings' +import type { EffortLevel, ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior } from '../types/settings' import type { Locale } from '../i18n' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' @@ -152,7 +152,6 @@ export function Settings() {
setActiveTab('providers')} /> - setActiveTab('permissions')} /> setActiveTab('general')} /> setActiveTab('h5Access')} /> setActiveTab('adapters')} /> @@ -174,7 +173,6 @@ export function Settings() { {/* Tab content */}
{activeTab === 'providers' && } - {activeTab === 'permissions' && } {activeTab === 'activity' && } {activeTab === 'general' && } {activeTab === 'h5Access' && } @@ -1428,55 +1426,6 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF } -// ─── Permission Settings ────────────────────────────────────── - -function PermissionSettings() { - const { permissionMode, setPermissionMode } = useSettingsStore() - const t = useTranslation() - - const MODES: Array<{ mode: PermissionMode; icon: string; label: string; desc: string }> = [ - { mode: 'default', icon: 'verified_user', label: t('settings.permissions.default'), desc: t('settings.permissions.defaultDesc') }, - { mode: 'acceptEdits', icon: 'edit_note', label: t('settings.permissions.acceptEdits'), desc: t('settings.permissions.acceptEditsDesc') }, - { mode: 'plan', icon: 'architecture', label: t('settings.permissions.plan'), desc: t('settings.permissions.planDesc') }, - { mode: 'bypassPermissions', icon: 'bolt', label: t('settings.permissions.bypass'), desc: t('settings.permissions.bypassDesc') }, - ] - - return ( -
-

{t('settings.permissions.title')}

-

{t('settings.permissions.description')}

- -
- {MODES.map(({ mode, icon, label, desc }) => { - const isSelected = permissionMode === mode - return ( - - ) - })} -
-
- ) -} - // ─── General Settings ────────────────────────────────────── function GeneralSettings() { diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 2474343a..749aed03 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -19,6 +19,7 @@ const { updateTabTitleMock, updateTabStatusMock, updateSessionTitleMock, + updateSessionPermissionModeMock, sessionStoreSnapshot, cliTaskStoreSnapshot, } = vi.hoisted(() => ({ @@ -38,6 +39,7 @@ const { updateTabTitleMock: vi.fn(), updateTabStatusMock: vi.fn(), updateSessionTitleMock: vi.fn(), + updateSessionPermissionModeMock: vi.fn(), sessionStoreSnapshot: { sessions: [] as Array<{ id: string @@ -103,6 +105,7 @@ vi.mock('./sessionStore', () => ({ getState: () => ({ sessions: sessionStoreSnapshot.sessions, updateSessionTitle: updateSessionTitleMock, + updateSessionPermissionMode: updateSessionPermissionModeMock, }), }, })) @@ -1787,6 +1790,7 @@ describe('chatStore history mapping', () => { type: 'set_permission_mode', mode: 'acceptEdits', }) + expect(updateSessionPermissionModeMock).toHaveBeenCalledWith('session-1', 'acceptEdits') }) it('stores terminal task notifications for agent tool cards', () => { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index cfcc3a40..2c845807 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -1010,6 +1010,7 @@ export const useChatStore = create((set, get) => ({ setSessionPermissionMode: (sessionId, mode) => { if (!get().sessions[sessionId]) return + useSessionStore.getState().updateSessionPermissionMode(sessionId, mode) wsManager.send(sessionId, { type: 'set_permission_mode', mode }) }, diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index e987bb04..33fcef47 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -8,10 +8,12 @@ import { import { useSessionRuntimeStore } from './sessionRuntimeStore' import { useTabStore } from './tabStore' import type { SessionListItem } from '../types/session' +import type { PermissionMode } from '../types/settings' import { isPlaceholderSessionTitle } from '../lib/sessionTitle' type CreateSessionOptions = { repository?: CreateSessionRepositoryOptions + permissionMode?: PermissionMode } type BranchSessionResult = Pick @@ -41,6 +43,7 @@ type SessionStore = { clearSessionSelection: () => void renameSession: (id: string, title: string) => Promise updateSessionTitle: (id: string, title: string) => void + updateSessionPermissionMode: (id: string, mode: PermissionMode) => void setActiveSession: (id: string | null) => void } @@ -83,6 +86,7 @@ export const useSessionStore = create((set, get) => ({ const { sessionId: id, workDir: resolvedWorkDir } = await sessionsApi.create({ ...(workDir ? { workDir } : {}), ...(options?.repository ? { repository: options.repository } : {}), + ...(options?.permissionMode ? { permissionMode: options.permissionMode } : {}), }) const now = new Date().toISOString() const optimisticSession: SessionListItem = { @@ -95,6 +99,7 @@ export const useSessionStore = create((set, get) => ({ workDir: resolvedWorkDir ?? workDir ?? null, projectRoot: resolvedWorkDir ?? workDir ?? null, workDirExists: true, + permissionMode: options?.permissionMode, } set((state) => ({ @@ -209,6 +214,14 @@ export const useSessionStore = create((set, get) => ({ })) }, + updateSessionPermissionMode: (id, mode) => { + set((s) => ({ + sessions: s.sessions.map((session) => + session.id === id ? { ...session, permissionMode: mode } : session, + ), + })) + }, + setActiveSession: (id) => set({ activeSessionId: id }), })) diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index 686f0898..c40f885a 100644 --- a/desktop/src/stores/uiStore.ts +++ b/desktop/src/stores/uiStore.ts @@ -30,7 +30,6 @@ export type Toast = { export type SettingsTab = | 'providers' - | 'permissions' | 'activity' | 'general' | 'h5Access' diff --git a/desktop/src/types/session.ts b/desktop/src/types/session.ts index d094ae6e..6c83366e 100644 --- a/desktop/src/types/session.ts +++ b/desktop/src/types/session.ts @@ -10,6 +10,7 @@ export type SessionListItem = { projectRoot?: string | null workDir: string | null workDirExists: boolean + permissionMode?: string } export type MessageEntry = { diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 4965d061..501ff292 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -2365,6 +2365,202 @@ describe('WebSocket Chat Integration', () => { } }, 20_000) + it('should persist permission changes made before the CLI starts', async () => { + await fetch(`${baseUrl}/api/permissions/mode`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'default' }), + }) + + 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 startCalls: Array<{ + sessionId: string + options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined + }> = [] + + 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 }, + ) { + startCalls.push({ sessionId: sid, options }) + return originalStartSession(sid, workDir, sdkUrl, options) + }) as typeof conversationService.startSession + + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for inactive permission switch connection for session ${sessionId}`)) + }, 5000) + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + if (msg.type === 'connected') { + clearTimeout(timeout) + ws.send(JSON.stringify({ + type: 'set_permission_mode', + mode: 'acceptEdits', + })) + resolve() + } + if (msg.type === 'error') { + clearTimeout(timeout) + reject(new Error(msg.message)) + } + } + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error(`WebSocket error for inactive permission switch session ${sessionId}`)) + } + }) + + await waitUntil(async () => { + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`) + if (!res.ok) return false + const body = await res.json() as { status?: { permissionMode?: string } } + return body.status?.permissionMode === 'acceptEdits' + }, `persisted inactive permission switch for ${sessionId}`) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for first turn after inactive permission switch for session ${sessionId}`)) + }, 10_000) + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + if (msg.type === 'message_complete') { + clearTimeout(timeout) + resolve() + } + if (msg.type === 'error') { + clearTimeout(timeout) + reject(new Error(msg.message)) + } + } + ws.send(JSON.stringify({ type: 'user_message', content: 'first turn after permission switch' })) + }) + + expect(startCalls).toHaveLength(1) + expect(startCalls[0]).toMatchObject({ + sessionId, + options: { + permissionMode: 'acceptEdits', + }, + }) + } finally { + ws.close() + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) + await fetch(`${baseUrl}/api/permissions/mode`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'default' }), + }) + } + }, 20_000) + + it('should restart when switching from bypass permissions back to default', async () => { + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'bypassPermissions' }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + + const originalStartSession = conversationService.startSession.bind(conversationService) + const startCalls: Array<{ + sessionId: string + options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined + }> = [] + + 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 }, + ) { + startCalls.push({ sessionId: sid, options }) + 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 waiting for bypass-to-default permission switch connection for session ${sessionId}`)) + }, 5000) + 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() + } + if (msg.type === 'error') { + clearTimeout(timeout) + reject(new Error(msg.message)) + } + } + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error(`WebSocket error for bypass-to-default permission switch session ${sessionId}`)) + } + }) + + await waitUntil( + () => startCalls.length === 1 && conversationService.hasSession(sessionId), + `prewarmed CLI process for bypass-to-default permission switch ${sessionId}`, + ) + expect(startCalls[0]).toMatchObject({ + sessionId, + options: { + permissionMode: 'bypassPermissions', + }, + }) + + const switchStartIndex = messages.length + ws.send(JSON.stringify({ + type: 'set_permission_mode', + mode: 'default', + })) + + await waitUntil( + async () => messages.slice(switchStartIndex).some((msg) => msg.type === 'status' && msg.state === 'idle'), + `bypass-to-default permission switch completion for ${sessionId}`, + ) + + expect(startCalls).toHaveLength(2) + expect(startCalls[1]).toMatchObject({ + sessionId, + options: { + permissionMode: 'default', + }, + }) + await waitUntil(async () => { + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`) + if (!res.ok) return false + const body = await res.json() as { status?: { permissionMode?: string } } + return body.status?.permissionMode === 'default' + }, `persisted bypass-to-default permission switch for ${sessionId}`) + expect(messages.slice(switchStartIndex).some((msg) => msg.type === 'error')).toBe(false) + } finally { + ws.close() + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) + } + }, 20_000) + it('should ignore stale persisted runtime provider ids when resuming old sessions', async () => { const providerService = new ProviderService() const activeProvider = await providerService.addProvider({ diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index af227d4d..364b0495 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -330,6 +330,74 @@ describe('cron scheduler launcher resolution', () => { expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli') }) + unixOnly('executeTask launches scheduled tasks with full permissions', async () => { + const appRoot = path.join(tmpDir, 'app-root') + const sidecarPath = path.join(tmpDir, 'claude-sidecar') + const sidecarArgsPath = path.join(tmpDir, 'sidecar.args') + + await fs.mkdir(appRoot, { recursive: true }) + await fs.writeFile( + sidecarPath, + [ + '#!/bin/sh', + `printf '%s\\n' "$@" > "${sidecarArgsPath}"`, + '/bin/cat >/dev/null', + 'printf \'%s\\n\' \'{"type":"result","result":"permissions ok"}\'', + 'exit 0', + '', + ].join('\n'), + 'utf-8', + ) + await fs.chmod(sidecarPath, 0o755) + + process.env.CLAUDE_CLI_PATH = sidecarPath + process.env.CLAUDE_APP_ROOT = appRoot + + const cronService = new CronService() + const scheduler = new CronScheduler(cronService) + const createSessionCalls: unknown[][] = [] + const appendSessionMetadataCalls: unknown[][] = [] + ;(scheduler as unknown as { + sessionService: { + createSession: (...args: unknown[]) => Promise<{ sessionId: string }> + deleteSessionFile: (sessionId: string) => Promise + appendSessionMetadata: (...args: unknown[]) => Promise + } + }).sessionService = { + createSession: async (...args: unknown[]) => { + createSessionCalls.push(args) + return { sessionId: 'scheduled-session' } + }, + deleteSessionFile: async () => {}, + appendSessionMetadata: async (...args: unknown[]) => { + appendSessionMetadataCalls.push(args) + }, + } + const task = await cronService.createTask({ + cron: '* * * * *', + prompt: 'cron permission test', + name: 'Permission Task', + recurring: true, + folderPath: tmpDir, + permissionMode: 'default', + }) + + const run = await scheduler.executeTask(task, { createSession: true }) + const canonicalTmpDir = await fs.realpath(tmpDir) + + expect(run.status).toBe('completed') + expect(run.sessionId).toBe('scheduled-session') + expect(createSessionCalls).toEqual([[canonicalTmpDir, undefined, 'bypassPermissions']]) + expect(appendSessionMetadataCalls).toEqual([ + ['scheduled-session', { workDir: canonicalTmpDir, permissionMode: 'bypassPermissions' }], + ]) + const sidecarArgs = (await fs.readFile(sidecarArgsPath, 'utf-8')) + .trim() + .split('\n') + expect(sidecarArgs).toContain('--dangerously-skip-permissions') + expect(sidecarArgs).not.toContain('--permission-mode') + }) + unixOnly('executeTask inherits exported terminal shell variables', async () => { const appRoot = path.join(tmpDir, 'app-root') const sidecarPath = path.join(tmpDir, 'claude-sidecar') diff --git a/src/server/__tests__/e2e/business-flow.test.ts b/src/server/__tests__/e2e/business-flow.test.ts index 1f22455a..d894aee1 100644 --- a/src/server/__tests__/e2e/business-flow.test.ts +++ b/src/server/__tests__/e2e/business-flow.test.ts @@ -74,7 +74,7 @@ describe('Business Flow: Scheduled Tasks', () => { expect(data.task.cron).toBe('0 9 * * 1-5') expect(data.task.prompt).toContain('git log') expect(data.task.recurring).toBe(true) - expect(data.task.permissionMode).toBe('default') + expect(data.task.permissionMode).toBe('bypassPermissions') expect(data.task.model).toBe('claude-sonnet-4-6') expect(data.task.createdAt).toBeGreaterThan(0) }) diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index dc259673..dab1a973 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -1142,6 +1142,30 @@ describe('SessionService', () => { }) }) + it('should persist session permission mode in launch metadata', async () => { + const workDir = path.join(tmpDir, 'permission-workdir') + await fs.mkdir(workDir, { recursive: true }) + const { sessionId } = await (service.createSession as unknown as ( + workDir?: string, + repositoryOptions?: unknown, + permissionMode?: string, + ) => Promise<{ sessionId: string; workDir: string }>)(workDir, undefined, 'acceptEdits') + + let launchInfo = await service.getSessionLaunchInfo(sessionId) + expect(launchInfo?.permissionMode).toBe('acceptEdits') + + await (service.appendSessionMetadata as unknown as ( + sessionId: string, + metadata: { workDir: string; permissionMode?: string }, + ) => Promise)(sessionId, { + workDir, + permissionMode: 'plan', + }) + + launchInfo = await service.getSessionLaunchInfo(sessionId) + expect(launchInfo?.permissionMode).toBe('plan') + }) + it('should remove stale placeholder files after native CLI worktree startup', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const sourceFile = await writeSessionFile('-tmp-source', sessionId, [ @@ -1877,6 +1901,27 @@ describe('Sessions API', () => { ) }) + it('GET /api/sessions/:id/inspection should report persisted permission mode for inactive sessions', async () => { + const workDir = await fs.mkdtemp(path.join(tmpDir, 'api-session-permission-')) + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir, permissionMode: 'bypassPermissions' }), + }) + expect(createRes.status).toBe(201) + + const { sessionId } = (await createRes.json()) as { sessionId: string } + const inspectionRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`) + expect(inspectionRes.status).toBe(200) + + const inspection = (await inspectionRes.json()) as { + active: boolean + status: { permissionMode?: string } + } + expect(inspection.active).toBe(false) + expect(inspection.status.permissionMode).toBe('bypassPermissions') + }) + it('GET /api/sessions/repository-context should return branch launch metadata', async () => { const workDir = await createCleanGitRepo(tmpDir) const res = await fetch( diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index b71acc4e..8f78f3f9 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -281,9 +281,9 @@ async function handleSessionWorkspaceRoute( } async function createSession(req: Request): Promise { - let body: { workDir?: string; repository?: CreateSessionRepositoryOptions } + let body: { workDir?: string; repository?: CreateSessionRepositoryOptions; permissionMode?: string } try { - body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions } + body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions; permissionMode?: string } } catch { throw ApiError.badRequest('Invalid JSON body') } @@ -292,6 +292,10 @@ async function createSession(req: Request): Promise { throw ApiError.badRequest('workDir must be a string') } + if (body.permissionMode !== undefined && typeof body.permissionMode !== 'string') { + throw ApiError.badRequest('permissionMode must be a string') + } + if (body.repository !== undefined) { if (!body.repository || typeof body.repository !== 'object' || Array.isArray(body.repository)) { throw ApiError.badRequest('repository must be an object') @@ -304,7 +308,7 @@ async function createSession(req: Request): Promise { } } - const result = await sessionService.createSession(body.workDir, body.repository) + const result = await sessionService.createSession(body.workDir, body.repository, body.permissionMode) recentProjectsCache = null return Response.json(result, { status: 201 }) } @@ -499,6 +503,10 @@ async function getSessionInspection(sessionId: string, url: URL): Promise null) + const permissionMode = active + ? conversationService.getSessionPermissionMode(sessionId) + : launchInfo?.permissionMode ?? 'default' const initMessage = conversationService.getSessionInitMessage(sessionId) ?? [...conversationService.getRecentSdkMessages(sessionId)] .reverse() @@ -518,7 +526,7 @@ async function getSessionInspection(sessionId: string, url: URL): Promise { + if (!sessionId) return + await this.sessionService.appendSessionMetadata(sessionId, { + workDir, + permissionMode: 'bypassPermissions', + }).catch(() => { + // The task result is still valid even if session metadata refresh fails. + }) + } + + private resolveCanonicalWorkDir(workDir: string): string { + try { + return realpathSync(workDir) + } catch { + return workDir + } + } + private getRuntimeArgs(task: CronTask): string[] { const model = task.model?.trim() - return model ? ['--model', model] : [] + return [ + ...(model ? ['--model', model] : []), + '--dangerously-skip-permissions', + ] } private async buildTaskChildEnv( diff --git a/src/server/services/cronService.ts b/src/server/services/cronService.ts index 16767c51..2700c439 100644 --- a/src/server/services/cronService.ts +++ b/src/server/services/cronService.ts @@ -56,7 +56,10 @@ export class CronService { /** 获取所有任务 */ async listTasks(): Promise { const data = await this.readTasksFile() - return data.tasks + return data.tasks.map((task) => ({ + ...task, + permissionMode: 'bypassPermissions', + })) } /** 创建新任务 */ @@ -70,6 +73,7 @@ export class CronService { const data = await this.readTasksFile() const newTask: CronTask = { ...task, + permissionMode: 'bypassPermissions', id: crypto.randomBytes(4).toString('hex'), createdAt: Date.now(), } @@ -88,7 +92,11 @@ export class CronService { // 不允许修改 id 和 createdAt const { id: _id, createdAt: _ca, ...safeUpdates } = updates - data.tasks[index] = { ...data.tasks[index], ...safeUpdates } + data.tasks[index] = { + ...data.tasks[index], + ...safeUpdates, + permissionMode: 'bypassPermissions', + } await this.writeTasksFile(data) return data.tasks[index] } diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 50dfdd97..0ddd36f4 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -43,6 +43,7 @@ export type SessionListItem = { projectRoot: string | null workDir: string | null workDirExists: boolean + permissionMode?: string } export type DeleteSessionFailure = { @@ -68,6 +69,7 @@ export type SessionLaunchInfo = { worktreeSession?: PersistedWorktreeSession | null transcriptMessageCount: number customTitle: string | null + permissionMode?: string } export type TrimSessionResult = { @@ -200,6 +202,7 @@ type RawEntry = { timestamp?: string } customTitle?: string + permissionMode?: string worktreeSession?: PersistedWorktreeSession | null title?: string [key: string]: unknown @@ -217,6 +220,14 @@ type PersistedWorktreeSession = { hookBased?: boolean } +const VALID_SESSION_PERMISSION_MODES = new Set([ + 'default', + 'acceptEdits', + 'plan', + 'bypassPermissions', + 'dontAsk', +]) + type ContentBlock = Record const USER_INTERRUPTION_TEXTS = new Set([ @@ -317,6 +328,21 @@ export class SessionService { return undefined } + private resolvePermissionModeFromEntries(entries: RawEntry[]): string | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i] + if (entry?.type !== 'session-meta') continue + const permissionMode = entry.permissionMode + if ( + typeof permissionMode === 'string' && + VALID_SESSION_PERMISSION_MODES.has(permissionMode) + ) { + return permissionMode + } + } + return undefined + } + private resolveWorktreeSessionFromEntries(entries: RawEntry[]): PersistedWorktreeSession | null | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i] @@ -1308,6 +1334,7 @@ export class SessionService { try { const entries = await this.readJsonlFile(filePath) const workDir = this.resolveWorkDirFromEntries(entries, projectDir) + const permissionMode = this.resolvePermissionModeFromEntries(entries) const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir) const workDirExists = await this.pathExists(workDir) @@ -1337,6 +1364,7 @@ export class SessionService { projectRoot, workDir, workDirExists, + permissionMode, } } catch { // Skip unreadable files @@ -1365,6 +1393,7 @@ export class SessionService { ) const title = this.extractTitle(entries) const workDir = this.resolveWorkDirFromEntries(entries, projectDir) + const permissionMode = this.resolvePermissionModeFromEntries(entries) const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir) const workDirExists = await this.pathExists(workDir) @@ -1386,6 +1415,7 @@ export class SessionService { projectRoot, workDir, workDirExists, + permissionMode, messages, } } @@ -1413,6 +1443,7 @@ export class SessionService { async createSession( workDir?: string, repositoryOptions?: CreateSessionRepositoryOptions, + permissionMode?: string, ): Promise<{ sessionId: string; workDir: string }> { // Default to user home directory when no workDir specified const resolvedWorkDir = workDir || os.homedir() @@ -1464,6 +1495,9 @@ export class SessionService { isMeta: true, workDir: absWorkDir, repository: preparedWorkspace.repository, + ...(permissionMode && VALID_SESSION_PERMISSION_MODES.has(permissionMode) + ? { permissionMode } + : {}), timestamp: now, } @@ -1603,6 +1637,7 @@ export class SessionService { const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd() const repository = this.resolveRepositoryFromEntries(entries) const worktreeSession = this.resolveWorktreeSessionFromEntries(entries) + const permissionMode = this.resolvePermissionModeFromEntries(entries) let customTitle: string | null = null for (const entry of entries) { @@ -1620,6 +1655,7 @@ export class SessionService { worktreeSession, transcriptMessageCount, customTitle, + permissionMode, } } @@ -1682,6 +1718,7 @@ export class SessionService { workDir: string customTitle?: string | null repository?: PreparedSessionWorkspace['repository'] + permissionMode?: string } ): Promise { const matches = await this.findSessionFiles(sessionId) @@ -1708,6 +1745,9 @@ export class SessionService { isMeta: true, workDir: normalizedWorkDir, repository, + ...(metadata.permissionMode && VALID_SESSION_PERMISSION_MODES.has(metadata.permissionMode) + ? { permissionMode: metadata.permissionMode } + : {}), timestamp: new Date().toISOString(), }) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index f77d36df..465abb41 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -192,7 +192,7 @@ export const handleWebSocket = { break case 'set_permission_mode': - handleSetPermissionMode(ws, message) + void handleSetPermissionMode(ws, message) break case 'set_runtime_config': @@ -479,11 +479,38 @@ function handleComputerUsePermissionResponse( } } -function handleSetPermissionMode( +async function handleSetPermissionMode( ws: ServerWebSocket, message: Extract -) { +): Promise { const { sessionId } = ws.data + const pendingStartup = sessionStartupPromises.get(sessionId) + + if (pendingStartup) { + await persistSessionPermissionMode(sessionId, message.mode) + await enqueueRuntimeTransition(sessionId, async () => { + await pendingStartup.catch(() => undefined) + if (!conversationService.hasSession(sessionId)) return + await applyPermissionModeToActiveSession(ws, sessionId, message.mode) + }) + return + } + + if (!conversationService.hasSession(sessionId)) { + await persistSessionPermissionMode(sessionId, message.mode) + return + } + + await applyPermissionModeToActiveSession(ws, sessionId, message.mode) +} + +async function applyPermissionModeToActiveSession( + ws: ServerWebSocket, + sessionId: string, + mode: string, +): Promise { + const currentMode = conversationService.getSessionPermissionMode(sessionId) + if (currentMode === mode) return // Switching to/from bypassPermissions requires the CLI to be (re)started with // --dangerously-skip-permissions. The CLI rejects a runtime set_permission_mode @@ -491,20 +518,21 @@ function handleSetPermissionMode( // sending the SDK message (which would silently fail), restart the CLI subprocess // with the correct arguments so the new permission mode takes effect. const needsRestart = - conversationService.hasSession(sessionId) && - (message.mode === 'bypassPermissions' || conversationService.getSessionPermissionMode(sessionId) === 'bypassPermissions') + mode === 'bypassPermissions' || currentMode === 'bypassPermissions' if (needsRestart) { void enqueueRuntimeTransition(sessionId, () => - restartSessionWithPermissionMode(ws, sessionId, message.mode), + restartSessionWithPermissionMode(ws, sessionId, mode), ) return } - const ok = conversationService.setPermissionMode(sessionId, message.mode) + const ok = conversationService.setPermissionMode(sessionId, mode) if (!ok) { console.warn(`[WS] Ignored permission mode update for inactive session ${sessionId}`) + return } + await persistSessionPermissionMode(sessionId, mode) } async function handleSetRuntimeConfig( @@ -567,13 +595,11 @@ async function restartSessionWithPermissionMode( mode: string, ): Promise { try { - // Persist the new mode first so it's read on restart - await settingsService.setPermissionMode(mode) - const workDir = conversationService.getSessionWorkDir(sessionId) + await persistSessionPermissionMode(sessionId, mode, workDir) conversationService.stopSession(sessionId) - // Rebuild runtime settings (will pick up the persisted mode) + // Rebuild runtime settings (will pick up the session-scoped mode) const runtimeSettings = await getRuntimeSettings(sessionId) const sdkUrl = `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + @@ -604,6 +630,24 @@ async function restartSessionWithPermissionMode( } } +async function persistSessionPermissionMode( + sessionId: string, + mode: string, + knownWorkDir?: string | null, +): Promise { + const workDir = + knownWorkDir || + conversationService.getSessionWorkDir(sessionId) || + await sessionService.getSessionWorkDir(sessionId).catch(() => null) + + if (!workDir) return + + await sessionService.appendSessionMetadata(sessionId, { + workDir, + permissionMode: mode, + }) +} + async function restartSessionWithRuntimeConfig( ws: ServerWebSocket, sessionId: string, @@ -1815,6 +1859,9 @@ function isKnownRuntimeProviderId( } async function getRuntimeSettings(sessionId?: string): Promise { + const sessionPermissionMode = sessionId + ? await getSessionPermissionMode(sessionId) + : undefined const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined if (runtimeOverride) { if (typeof runtimeOverride.providerId === 'string') { @@ -1825,7 +1872,11 @@ async function getRuntimeSettings(sessionId?: string): Promise `[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`, ) runtimeOverrides.delete(sessionId!) - return getDefaultRuntimeSettings() + const defaults = await getDefaultRuntimeSettings() + return { + ...defaults, + permissionMode: sessionPermissionMode ?? defaults.permissionMode, + } } } @@ -1837,7 +1888,7 @@ async function getRuntimeSettings(sessionId?: string): Promise const thinking = resolveDesktopThinkingMode(userSettings) return { - permissionMode: await settingsService.getPermissionMode().catch(() => undefined), + permissionMode: sessionPermissionMode ?? await settingsService.getPermissionMode().catch(() => undefined), model: runtimeOverride.modelId, effort, thinking, @@ -1845,7 +1896,16 @@ async function getRuntimeSettings(sessionId?: string): Promise } } - return getDefaultRuntimeSettings() + const defaults = await getDefaultRuntimeSettings() + return { + ...defaults, + permissionMode: sessionPermissionMode ?? defaults.permissionMode, + } +} + +async function getSessionPermissionMode(sessionId: string): Promise { + const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null) + return launchInfo?.permissionMode } async function getDefaultRuntimeSettings(): Promise {