applySettingsChange.ts
1 import type { AppState } from '../../state/AppState.js' 2 import { logForDebugging } from '../debug.js' 3 import { updateHooksConfigSnapshot } from '../hooks/hooksConfigSnapshot.js' 4 import { 5 createDisabledBypassPermissionsContext, 6 findOverlyBroadBashPermissions, 7 isBypassPermissionsModeDisabled, 8 removeDangerousPermissions, 9 transitionPlanAutoMode, 10 } from '../permissions/permissionSetup.js' 11 import { syncPermissionRulesFromDisk } from '../permissions/permissions.js' 12 import { loadAllPermissionRulesFromDisk } from '../permissions/permissionsLoader.js' 13 import type { SettingSource } from './constants.js' 14 import { getInitialSettings } from './settings.js' 15 16 /** 17 * Apply a settings change to app state. Re-reads settings from disk, 18 * reloads permissions and hooks, and pushes the new state. 19 * 20 * Used by both the interactive path (AppState.tsx via useSettingsChange) and 21 * the headless/SDK path (print.ts direct subscribe) so that managed-settings 22 * / policy changes are fully applied in both modes. 23 * 24 * The settings cache is reset by the notifier (changeDetector.fanOut) before 25 * listeners are iterated, so getInitialSettings() here reads fresh disk 26 * state. Previously this function reset the cache itself, which — combined 27 * with useSettingsChange's own reset — caused N disk reloads per notification 28 * for N subscribers. 29 * 30 * Side-effects like clearing auth caches and applying env vars are handled by 31 * `onChangeAppState` which fires when `settings` changes in state. 32 */ 33 export function applySettingsChange( 34 source: SettingSource, 35 setAppState: (f: (prev: AppState) => AppState) => void, 36 ): void { 37 const newSettings = getInitialSettings() 38 39 logForDebugging(`Settings changed from ${source}, updating app state`) 40 41 const updatedRules = loadAllPermissionRulesFromDisk() 42 updateHooksConfigSnapshot() 43 44 setAppState(prev => { 45 let newContext = syncPermissionRulesFromDisk( 46 prev.toolPermissionContext, 47 updatedRules, 48 ) 49 50 // Ant-only: re-strip overly broad Bash allow rules after settings sync 51 if ( 52 process.env.USER_TYPE === 'ant' && 53 process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent' 54 ) { 55 const overlyBroad = findOverlyBroadBashPermissions(updatedRules, []) 56 if (overlyBroad.length > 0) { 57 newContext = removeDangerousPermissions(newContext, overlyBroad) 58 } 59 } 60 61 if ( 62 newContext.isBypassPermissionsModeAvailable && 63 isBypassPermissionsModeDisabled() 64 ) { 65 newContext = createDisabledBypassPermissionsContext(newContext) 66 } 67 68 newContext = transitionPlanAutoMode(newContext) 69 70 // Sync effortLevel from settings to top-level AppState when it changes 71 // (e.g. via applyFlagSettings from IDE). Only propagate if the setting 72 // itself changed — otherwise unrelated settings churn (e.g. tips dismissal 73 // on startup) would clobber a --effort CLI flag value held in AppState. 74 const prevEffort = prev.settings.effortLevel 75 const newEffort = newSettings.effortLevel 76 const effortChanged = prevEffort !== newEffort 77 78 return { 79 ...prev, 80 settings: newSettings, 81 toolPermissionContext: newContext, 82 // Only propagate a defined new value — when the disk key is absent 83 // (e.g. /effort max for non-ants writes undefined; --effort CLI flag), 84 // prev.settings.effortLevel can be stale (internal writes suppress the 85 // watcher that would resync AppState.settings), so effortChanged would 86 // be true and we'd wipe a session-scoped value held in effortValue. 87 ...(effortChanged && newEffort !== undefined 88 ? { effortValue: newEffort } 89 : {}), 90 } 91 }) 92 }