import { setMainLoopModelOverride } from '../bootstrap/state.js' import { clearApiKeyHelperCache, clearAwsCredentialsCache, clearGcpCredentialsCache, } from '../utils/auth.js' import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' import { toError } from '../utils/errors.js' import { logError } from '../utils/log.js' import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js' import { permissionModeFromString, toExternalPermissionMode, } from '../utils/permissions/PermissionMode.js' import { notifyPermissionModeChanged, notifySessionMetadataChanged, type SessionExternalMetadata, } from '../utils/sessionState.js' import { updateSettingsForSource } from '../utils/settings/settings.js' import type { AppState } from './AppStateStore.js' // Inverse of the push below — restore on worker restart. export function externalMetadataToAppState( metadata: SessionExternalMetadata, ): (prev: AppState) => AppState { return prev => ({ ...prev, ...(typeof metadata.permission_mode === 'string' ? { toolPermissionContext: { ...prev.toolPermissionContext, mode: permissionModeFromString(metadata.permission_mode), }, } : {}), ...(typeof metadata.is_ultraplan_mode === 'boolean' ? { isUltraplanMode: metadata.is_ultraplan_mode } : {}), }) } export function onChangeAppState({ newState, oldState, }: { newState: AppState oldState: AppState }) { // toolPermissionContext.mode — single choke point for CCR/SDK mode sync. // // Prior to this block, mode changes were relayed to CCR by only 2 of 8+ // mutation paths: a bespoke setAppState wrapper in print.ts (headless/SDK // mode only) and a manual notify in the set_permission_mode handler. // Every other path — Shift+Tab cycling, ExitPlanModePermissionRequest // dialog options, the /plan slash command, rewind, the REPL bridge's // onSetPermissionMode — mutated AppState without telling // CCR, leaving external_metadata.permission_mode stale and the web UI out // of sync with the CLI's actual mode. // // Hooking the diff here means ANY setAppState call that changes the mode // notifies CCR (via notifySessionMetadataChanged → ccrClient.reportMetadata) // and the SDK status stream (via notifyPermissionModeChanged → registered // in print.ts). The scattered callsites above need zero changes. const prevMode = oldState.toolPermissionContext.mode const newMode = newState.toolPermissionContext.mode if (prevMode !== newMode) { // CCR external_metadata must not receive internal-only mode names // (bubble, ungated auto). Externalize first — and skip // the CCR notify if the EXTERNAL mode didn't change (e.g., // default→bubble→default is noise from CCR's POV since both // externalize to 'default'). The SDK channel (notifyPermissionModeChanged) // passes raw mode; its listener in print.ts applies its own filter. const prevExternal = toExternalPermissionMode(prevMode) const newExternal = toExternalPermissionMode(newMode) if (prevExternal !== newExternal) { // Ultraplan = first plan cycle only. The initial control_request // sets mode and isUltraplanMode atomically, so the flag's // transition gates it. null per RFC 7396 (removes the key). const isUltraplan = newExternal === 'plan' && newState.isUltraplanMode && !oldState.isUltraplanMode ? true : null notifySessionMetadataChanged({ permission_mode: newExternal, is_ultraplan_mode: isUltraplan, }) } notifyPermissionModeChanged(newMode) } // mainLoopModel: remove it from settings? if ( newState.mainLoopModel !== oldState.mainLoopModel && newState.mainLoopModel === null ) { // Remove from settings updateSettingsForSource('userSettings', { model: undefined }) setMainLoopModelOverride(null) } // mainLoopModel: add it to settings? if ( newState.mainLoopModel !== oldState.mainLoopModel && newState.mainLoopModel !== null ) { // Save to settings updateSettingsForSource('userSettings', { model: newState.mainLoopModel }) setMainLoopModelOverride(newState.mainLoopModel) } // expandedView → persist as showExpandedTodos + showSpinnerTree for backwards compat if (newState.expandedView !== oldState.expandedView) { const showExpandedTodos = newState.expandedView === 'tasks' const showSpinnerTree = newState.expandedView === 'teammates' if ( getGlobalConfig().showExpandedTodos !== showExpandedTodos || getGlobalConfig().showSpinnerTree !== showSpinnerTree ) { saveGlobalConfig(current => ({ ...current, showExpandedTodos, showSpinnerTree, })) } } // verbose if ( newState.verbose !== oldState.verbose && getGlobalConfig().verbose !== newState.verbose ) { const verbose = newState.verbose saveGlobalConfig(current => ({ ...current, verbose, })) } // tungstenPanelVisible (ant-only tmux panel sticky toggle) if (process.env.USER_TYPE === 'ant') { if ( newState.tungstenPanelVisible !== oldState.tungstenPanelVisible && newState.tungstenPanelVisible !== undefined && getGlobalConfig().tungstenPanelVisible !== newState.tungstenPanelVisible ) { const tungstenPanelVisible = newState.tungstenPanelVisible saveGlobalConfig(current => ({ ...current, tungstenPanelVisible })) } } // settings: clear auth-related caches when settings change // This ensures apiKeyHelper and AWS/GCP credential changes take effect immediately if (newState.settings !== oldState.settings) { try { clearApiKeyHelperCache() clearAwsCredentialsCache() clearGcpCredentialsCache() // Re-apply environment variables when settings.env changes // This is additive-only: new vars are added, existing may be overwritten, nothing is deleted if (newState.settings.env !== oldState.settings.env) { applyConfigEnvironmentVariables() } } catch (error) { logError(toError(error)) } } }