/ utils / settings / applySettingsChange.ts
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  }