/ state / onChangeAppState.ts
onChangeAppState.ts
  1  import { setMainLoopModelOverride } from '../bootstrap/state.js'
  2  import {
  3    clearApiKeyHelperCache,
  4    clearAwsCredentialsCache,
  5    clearGcpCredentialsCache,
  6  } from '../utils/auth.js'
  7  import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
  8  import { toError } from '../utils/errors.js'
  9  import { logError } from '../utils/log.js'
 10  import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js'
 11  import {
 12    permissionModeFromString,
 13    toExternalPermissionMode,
 14  } from '../utils/permissions/PermissionMode.js'
 15  import {
 16    notifyPermissionModeChanged,
 17    notifySessionMetadataChanged,
 18    type SessionExternalMetadata,
 19  } from '../utils/sessionState.js'
 20  import { updateSettingsForSource } from '../utils/settings/settings.js'
 21  import type { AppState } from './AppStateStore.js'
 22  
 23  // Inverse of the push below — restore on worker restart.
 24  export function externalMetadataToAppState(
 25    metadata: SessionExternalMetadata,
 26  ): (prev: AppState) => AppState {
 27    return prev => ({
 28      ...prev,
 29      ...(typeof metadata.permission_mode === 'string'
 30        ? {
 31            toolPermissionContext: {
 32              ...prev.toolPermissionContext,
 33              mode: permissionModeFromString(metadata.permission_mode),
 34            },
 35          }
 36        : {}),
 37      ...(typeof metadata.is_ultraplan_mode === 'boolean'
 38        ? { isUltraplanMode: metadata.is_ultraplan_mode }
 39        : {}),
 40    })
 41  }
 42  
 43  export function onChangeAppState({
 44    newState,
 45    oldState,
 46  }: {
 47    newState: AppState
 48    oldState: AppState
 49  }) {
 50    // toolPermissionContext.mode — single choke point for CCR/SDK mode sync.
 51    //
 52    // Prior to this block, mode changes were relayed to CCR by only 2 of 8+
 53    // mutation paths: a bespoke setAppState wrapper in print.ts (headless/SDK
 54    // mode only) and a manual notify in the set_permission_mode handler.
 55    // Every other path — Shift+Tab cycling, ExitPlanModePermissionRequest
 56    // dialog options, the /plan slash command, rewind, the REPL bridge's
 57    // onSetPermissionMode — mutated AppState without telling
 58    // CCR, leaving external_metadata.permission_mode stale and the web UI out
 59    // of sync with the CLI's actual mode.
 60    //
 61    // Hooking the diff here means ANY setAppState call that changes the mode
 62    // notifies CCR (via notifySessionMetadataChanged → ccrClient.reportMetadata)
 63    // and the SDK status stream (via notifyPermissionModeChanged → registered
 64    // in print.ts). The scattered callsites above need zero changes.
 65    const prevMode = oldState.toolPermissionContext.mode
 66    const newMode = newState.toolPermissionContext.mode
 67    if (prevMode !== newMode) {
 68      // CCR external_metadata must not receive internal-only mode names
 69      // (bubble, ungated auto). Externalize first — and skip
 70      // the CCR notify if the EXTERNAL mode didn't change (e.g.,
 71      // default→bubble→default is noise from CCR's POV since both
 72      // externalize to 'default'). The SDK channel (notifyPermissionModeChanged)
 73      // passes raw mode; its listener in print.ts applies its own filter.
 74      const prevExternal = toExternalPermissionMode(prevMode)
 75      const newExternal = toExternalPermissionMode(newMode)
 76      if (prevExternal !== newExternal) {
 77        // Ultraplan = first plan cycle only. The initial control_request
 78        // sets mode and isUltraplanMode atomically, so the flag's
 79        // transition gates it. null per RFC 7396 (removes the key).
 80        const isUltraplan =
 81          newExternal === 'plan' &&
 82          newState.isUltraplanMode &&
 83          !oldState.isUltraplanMode
 84            ? true
 85            : null
 86        notifySessionMetadataChanged({
 87          permission_mode: newExternal,
 88          is_ultraplan_mode: isUltraplan,
 89        })
 90      }
 91      notifyPermissionModeChanged(newMode)
 92    }
 93  
 94    // mainLoopModel: remove it from settings?
 95    if (
 96      newState.mainLoopModel !== oldState.mainLoopModel &&
 97      newState.mainLoopModel === null
 98    ) {
 99      // Remove from settings
100      updateSettingsForSource('userSettings', { model: undefined })
101      setMainLoopModelOverride(null)
102    }
103  
104    // mainLoopModel: add it to settings?
105    if (
106      newState.mainLoopModel !== oldState.mainLoopModel &&
107      newState.mainLoopModel !== null
108    ) {
109      // Save to settings
110      updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
111      setMainLoopModelOverride(newState.mainLoopModel)
112    }
113  
114    // expandedView → persist as showExpandedTodos + showSpinnerTree for backwards compat
115    if (newState.expandedView !== oldState.expandedView) {
116      const showExpandedTodos = newState.expandedView === 'tasks'
117      const showSpinnerTree = newState.expandedView === 'teammates'
118      if (
119        getGlobalConfig().showExpandedTodos !== showExpandedTodos ||
120        getGlobalConfig().showSpinnerTree !== showSpinnerTree
121      ) {
122        saveGlobalConfig(current => ({
123          ...current,
124          showExpandedTodos,
125          showSpinnerTree,
126        }))
127      }
128    }
129  
130    // verbose
131    if (
132      newState.verbose !== oldState.verbose &&
133      getGlobalConfig().verbose !== newState.verbose
134    ) {
135      const verbose = newState.verbose
136      saveGlobalConfig(current => ({
137        ...current,
138        verbose,
139      }))
140    }
141  
142    // tungstenPanelVisible (ant-only tmux panel sticky toggle)
143    if (process.env.USER_TYPE === 'ant') {
144      if (
145        newState.tungstenPanelVisible !== oldState.tungstenPanelVisible &&
146        newState.tungstenPanelVisible !== undefined &&
147        getGlobalConfig().tungstenPanelVisible !== newState.tungstenPanelVisible
148      ) {
149        const tungstenPanelVisible = newState.tungstenPanelVisible
150        saveGlobalConfig(current => ({ ...current, tungstenPanelVisible }))
151      }
152    }
153  
154    // settings: clear auth-related caches when settings change
155    // This ensures apiKeyHelper and AWS/GCP credential changes take effect immediately
156    if (newState.settings !== oldState.settings) {
157      try {
158        clearApiKeyHelperCache()
159        clearAwsCredentialsCache()
160        clearGcpCredentialsCache()
161  
162        // Re-apply environment variables when settings.env changes
163        // This is additive-only: new vars are added, existing may be overwritten, nothing is deleted
164        if (newState.settings.env !== oldState.settings.env) {
165          applyConfigEnvironmentVariables()
166        }
167      } catch (error) {
168        logError(toError(error))
169      }
170    }
171  }