/ utils / config.ts
config.ts
   1  import { feature } from 'bun:bundle'
   2  import { randomBytes } from 'crypto'
   3  import { unwatchFile, watchFile } from 'fs'
   4  import memoize from 'lodash-es/memoize.js'
   5  import pickBy from 'lodash-es/pickBy.js'
   6  import { basename, dirname, join, resolve } from 'path'
   7  import { getOriginalCwd, getSessionTrustAccepted } from '../bootstrap/state.js'
   8  import { getAutoMemEntrypoint } from '../memdir/paths.js'
   9  import { logEvent } from '../services/analytics/index.js'
  10  import type { McpServerConfig } from '../services/mcp/types.js'
  11  import type {
  12    BillingType,
  13    ReferralEligibilityResponse,
  14  } from '../services/oauth/types.js'
  15  import { getCwd } from '../utils/cwd.js'
  16  import { registerCleanup } from './cleanupRegistry.js'
  17  import { logForDebugging } from './debug.js'
  18  import { logForDiagnosticsNoPII } from './diagLogs.js'
  19  import { getGlobalClaudeFile } from './env.js'
  20  import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
  21  import { ConfigParseError, getErrnoCode } from './errors.js'
  22  import { writeFileSyncAndFlush_DEPRECATED } from './file.js'
  23  import { getFsImplementation } from './fsOperations.js'
  24  import { findCanonicalGitRoot } from './git.js'
  25  import { safeParseJSON } from './json.js'
  26  import { stripBOM } from './jsonRead.js'
  27  import * as lockfile from './lockfile.js'
  28  import { logError } from './log.js'
  29  import type { MemoryType } from './memory/types.js'
  30  import { normalizePathForConfigKey } from './path.js'
  31  import { getEssentialTrafficOnlyReason } from './privacyLevel.js'
  32  import { getManagedFilePath } from './settings/managedPath.js'
  33  import type { ThemeSetting } from './theme.js'
  34  
  35  /* eslint-disable @typescript-eslint/no-require-imports */
  36  const teamMemPaths = feature('TEAMMEM')
  37    ? (require('../memdir/teamMemPaths.js') as typeof import('../memdir/teamMemPaths.js'))
  38    : null
  39  const ccrAutoConnect = feature('CCR_AUTO_CONNECT')
  40    ? (require('../bridge/bridgeEnabled.js') as typeof import('../bridge/bridgeEnabled.js'))
  41    : null
  42  
  43  /* eslint-enable @typescript-eslint/no-require-imports */
  44  import type { ImageDimensions } from './imageResizer.js'
  45  import type { ModelOption } from './model/modelOptions.js'
  46  import { jsonParse, jsonStringify } from './slowOperations.js'
  47  
  48  // Re-entrancy guard: prevents getConfig → logEvent → getGlobalConfig → getConfig
  49  // infinite recursion when the config file is corrupted. logEvent's sampling check
  50  // reads GrowthBook features from the global config, which calls getConfig again.
  51  let insideGetConfig = false
  52  
  53  // Image dimension info for coordinate mapping (only set when image was resized)
  54  export type PastedContent = {
  55    id: number // Sequential numeric ID
  56    type: 'text' | 'image'
  57    content: string
  58    mediaType?: string // e.g., 'image/png', 'image/jpeg'
  59    filename?: string // Display name for images in attachment slot
  60    dimensions?: ImageDimensions
  61    sourcePath?: string // Original file path for images dragged onto the terminal
  62  }
  63  
  64  export interface SerializedStructuredHistoryEntry {
  65    display: string
  66    pastedContents?: Record<number, PastedContent>
  67    pastedText?: string
  68  }
  69  export interface HistoryEntry {
  70    display: string
  71    pastedContents: Record<number, PastedContent>
  72  }
  73  
  74  export type ReleaseChannel = 'stable' | 'latest'
  75  
  76  export type ProjectConfig = {
  77    allowedTools: string[]
  78    mcpContextUris: string[]
  79    mcpServers?: Record<string, McpServerConfig>
  80    lastAPIDuration?: number
  81    lastAPIDurationWithoutRetries?: number
  82    lastToolDuration?: number
  83    lastCost?: number
  84    lastDuration?: number
  85    lastLinesAdded?: number
  86    lastLinesRemoved?: number
  87    lastTotalInputTokens?: number
  88    lastTotalOutputTokens?: number
  89    lastTotalCacheCreationInputTokens?: number
  90    lastTotalCacheReadInputTokens?: number
  91    lastTotalWebSearchRequests?: number
  92    lastFpsAverage?: number
  93    lastFpsLow1Pct?: number
  94    lastSessionId?: string
  95    lastModelUsage?: Record<
  96      string,
  97      {
  98        inputTokens: number
  99        outputTokens: number
 100        cacheReadInputTokens: number
 101        cacheCreationInputTokens: number
 102        webSearchRequests: number
 103        costUSD: number
 104      }
 105    >
 106    lastSessionMetrics?: Record<string, number>
 107    exampleFiles?: string[]
 108    exampleFilesGeneratedAt?: number
 109  
 110    // Trust dialog settings
 111    hasTrustDialogAccepted?: boolean
 112  
 113    hasCompletedProjectOnboarding?: boolean
 114    projectOnboardingSeenCount: number
 115    hasClaudeMdExternalIncludesApproved?: boolean
 116    hasClaudeMdExternalIncludesWarningShown?: boolean
 117    // MCP server approval fields - migrated to settings but kept for backward compatibility
 118    enabledMcpjsonServers?: string[]
 119    disabledMcpjsonServers?: string[]
 120    enableAllProjectMcpServers?: boolean
 121    // List of disabled MCP servers (all scopes) - used for enable/disable toggle
 122    disabledMcpServers?: string[]
 123    // Opt-in list for built-in MCP servers that default to disabled
 124    enabledMcpServers?: string[]
 125    // Worktree session management
 126    activeWorktreeSession?: {
 127      originalCwd: string
 128      worktreePath: string
 129      worktreeName: string
 130      originalBranch?: string
 131      sessionId: string
 132      hookBased?: boolean
 133    }
 134    /** Spawn mode for `claude remote-control` multi-session. Set by first-run dialog or `w` toggle. */
 135    remoteControlSpawnMode?: 'same-dir' | 'worktree'
 136  }
 137  
 138  const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
 139    allowedTools: [],
 140    mcpContextUris: [],
 141    mcpServers: {},
 142    enabledMcpjsonServers: [],
 143    disabledMcpjsonServers: [],
 144    hasTrustDialogAccepted: false,
 145    projectOnboardingSeenCount: 0,
 146    hasClaudeMdExternalIncludesApproved: false,
 147    hasClaudeMdExternalIncludesWarningShown: false,
 148  }
 149  
 150  export type InstallMethod = 'local' | 'native' | 'global' | 'unknown'
 151  
 152  export {
 153    EDITOR_MODES,
 154    NOTIFICATION_CHANNELS,
 155  } from './configConstants.js'
 156  
 157  import type { EDITOR_MODES, NOTIFICATION_CHANNELS } from './configConstants.js'
 158  
 159  export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number]
 160  
 161  export type AccountInfo = {
 162    accountUuid: string
 163    emailAddress: string
 164    organizationUuid?: string
 165    organizationName?: string | null // added 4/23/2025, not populated for existing users
 166    organizationRole?: string | null
 167    workspaceRole?: string | null
 168    // Populated by /api/oauth/profile
 169    displayName?: string
 170    hasExtraUsageEnabled?: boolean
 171    billingType?: BillingType | null
 172    accountCreatedAt?: string
 173    subscriptionCreatedAt?: string
 174  }
 175  
 176  // TODO: 'emacs' is kept for backward compatibility - remove after a few releases
 177  export type EditorMode = 'emacs' | (typeof EDITOR_MODES)[number]
 178  
 179  export type DiffTool = 'terminal' | 'auto'
 180  
 181  export type OutputStyle = string
 182  
 183  export type GlobalConfig = {
 184    /**
 185     * @deprecated Use settings.apiKeyHelper instead.
 186     */
 187    apiKeyHelper?: string
 188    projects?: Record<string, ProjectConfig>
 189    numStartups: number
 190    installMethod?: InstallMethod
 191    autoUpdates?: boolean
 192    // Flag to distinguish protection-based disabling from user preference
 193    autoUpdatesProtectedForNative?: boolean
 194    // Session count when Doctor was last shown
 195    doctorShownAtSession?: number
 196    userID?: string
 197    theme: ThemeSetting
 198    hasCompletedOnboarding?: boolean
 199    // Tracks the last version that reset onboarding, used with MIN_VERSION_REQUIRING_ONBOARDING_RESET
 200    lastOnboardingVersion?: string
 201    // Tracks the last version for which release notes were seen, used for managing release notes
 202    lastReleaseNotesSeen?: string
 203    // Timestamp when changelog was last fetched (content stored in ~/.claude/cache/changelog.md)
 204    changelogLastFetched?: number
 205    // @deprecated - Migrated to ~/.claude/cache/changelog.md. Keep for migration support.
 206    cachedChangelog?: string
 207    mcpServers?: Record<string, McpServerConfig>
 208    // claude.ai MCP connectors that have successfully connected at least once.
 209    // Used to gate "connector unavailable" / "needs auth" startup notifications:
 210    // a connector the user has actually used is worth flagging when it breaks,
 211    // but an org-configured connector that's been needs-auth since day one is
 212    // something the user has demonstrably ignored and shouldn't nag about.
 213    claudeAiMcpEverConnected?: string[]
 214    preferredNotifChannel: NotificationChannel
 215    /**
 216     * @deprecated. Use the Notification hook instead (docs/hooks.md).
 217     */
 218    customNotifyCommand?: string
 219    verbose: boolean
 220    customApiKeyResponses?: {
 221      approved?: string[]
 222      rejected?: string[]
 223    }
 224    primaryApiKey?: string // Primary API key for the user when no environment variable is set, set via oauth (TODO: rename)
 225    hasAcknowledgedCostThreshold?: boolean
 226    hasSeenUndercoverAutoNotice?: boolean // ant-only: whether the one-time auto-undercover explainer has been shown
 227    hasSeenUltraplanTerms?: boolean // ant-only: whether the one-time CCR terms notice has been shown in the ultraplan launch dialog
 228    hasResetAutoModeOptInForDefaultOffer?: boolean // ant-only: one-shot migration guard, re-prompts churned auto-mode users
 229    oauthAccount?: AccountInfo
 230    iterm2KeyBindingInstalled?: boolean // Legacy - keeping for backward compatibility
 231    editorMode?: EditorMode
 232    bypassPermissionsModeAccepted?: boolean
 233    hasUsedBackslashReturn?: boolean
 234    autoCompactEnabled: boolean // Controls whether auto-compact is enabled
 235    showTurnDuration: boolean // Controls whether to show turn duration message (e.g., "Cooked for 1m 6s")
 236    /**
 237     * @deprecated Use settings.env instead.
 238     */
 239    env: { [key: string]: string } // Environment variables to set for the CLI
 240    hasSeenTasksHint?: boolean // Whether the user has seen the tasks hint
 241    hasUsedStash?: boolean // Whether the user has used the stash feature (Ctrl+S)
 242    hasUsedBackgroundTask?: boolean // Whether the user has backgrounded a task (Ctrl+B)
 243    queuedCommandUpHintCount?: number // Counter for how many times the user has seen the queued command up hint
 244    diffTool?: DiffTool // Which tool to use for displaying diffs (terminal or vscode)
 245  
 246    // Terminal setup state tracking
 247    iterm2SetupInProgress?: boolean
 248    iterm2BackupPath?: string // Path to the backup file for iTerm2 preferences
 249    appleTerminalBackupPath?: string // Path to the backup file for Terminal.app preferences
 250    appleTerminalSetupInProgress?: boolean // Whether Terminal.app setup is currently in progress
 251  
 252    // Key binding setup tracking
 253    shiftEnterKeyBindingInstalled?: boolean // Whether Shift+Enter key binding is installed (for iTerm2 or VSCode)
 254    optionAsMetaKeyInstalled?: boolean // Whether Option as Meta key is installed (for Terminal.app)
 255  
 256    // IDE configurations
 257    autoConnectIde?: boolean // Whether to automatically connect to IDE on startup if exactly one valid IDE is available
 258    autoInstallIdeExtension?: boolean // Whether to automatically install IDE extensions when running from within an IDE
 259  
 260    // IDE dialogs
 261    hasIdeOnboardingBeenShown?: Record<string, boolean> // Map of terminal name to whether IDE onboarding has been shown
 262    ideHintShownCount?: number // Number of times the /ide command hint has been shown
 263    hasIdeAutoConnectDialogBeenShown?: boolean // Whether the auto-connect IDE dialog has been shown
 264  
 265    tipsHistory: {
 266      [tipId: string]: number // Key is tipId, value is the numStartups when tip was last shown
 267    }
 268  
 269    // /buddy companion soul — bones regenerated from userId on read. See src/buddy/.
 270    companion?: import('../buddy/types.js').StoredCompanion
 271    companionMuted?: boolean
 272  
 273    // Feedback survey tracking
 274    feedbackSurveyState?: {
 275      lastShownTime?: number
 276    }
 277  
 278    // Transcript share prompt tracking ("Don't ask again")
 279    transcriptShareDismissed?: boolean
 280  
 281    // Memory usage tracking
 282    memoryUsageCount: number // Number of times user has added to memory
 283  
 284    // Sonnet-1M configs
 285    hasShownS1MWelcomeV2?: Record<string, boolean> // Whether the Sonnet-1M v2 welcome message has been shown per org
 286    // Cache of Sonnet-1M subscriber access per org - key is org ID
 287    // hasAccess means "hasAccessAsDefault" but the old name is kept for backward
 288    // compatibility.
 289    s1mAccessCache?: Record<
 290      string,
 291      { hasAccess: boolean; hasAccessNotAsDefault?: boolean; timestamp: number }
 292    >
 293    // Cache of Sonnet-1M PayG access per org - key is org ID
 294    // hasAccess means "hasAccessAsDefault" but the old name is kept for backward
 295    // compatibility.
 296    s1mNonSubscriberAccessCache?: Record<
 297      string,
 298      { hasAccess: boolean; hasAccessNotAsDefault?: boolean; timestamp: number }
 299    >
 300  
 301    // Guest passes eligibility cache per org - key is org ID
 302    passesEligibilityCache?: Record<
 303      string,
 304      ReferralEligibilityResponse & { timestamp: number }
 305    >
 306  
 307    // Grove config cache per account - key is account UUID
 308    groveConfigCache?: Record<
 309      string,
 310      { grove_enabled: boolean; timestamp: number }
 311    >
 312  
 313    // Guest passes upsell tracking
 314    passesUpsellSeenCount?: number // Number of times the guest passes upsell has been shown
 315    hasVisitedPasses?: boolean // Whether the user has visited /passes command
 316    passesLastSeenRemaining?: number // Last seen remaining_passes count — reset upsell when it increases
 317  
 318    // Overage credit grant upsell tracking (keyed by org UUID — multi-org users).
 319    // Inlined shape (not import()) because config.ts is in the SDK build surface
 320    // and the SDK bundler can't resolve CLI service modules.
 321    overageCreditGrantCache?: Record<
 322      string,
 323      {
 324        info: {
 325          available: boolean
 326          eligible: boolean
 327          granted: boolean
 328          amount_minor_units: number | null
 329          currency: string | null
 330        }
 331        timestamp: number
 332      }
 333    >
 334    overageCreditUpsellSeenCount?: number // Number of times the overage credit upsell has been shown
 335    hasVisitedExtraUsage?: boolean // Whether the user has visited /extra-usage — hides credit upsells
 336  
 337    // Voice mode notice tracking
 338    voiceNoticeSeenCount?: number // Number of times the voice-mode-available notice has been shown
 339    voiceLangHintShownCount?: number // Number of times the /voice dictation-language hint has been shown
 340    voiceLangHintLastLanguage?: string // Resolved STT language code when the hint was last shown — reset count when it changes
 341    voiceFooterHintSeenCount?: number // Number of sessions the "hold X to speak" footer hint has been shown
 342  
 343    // Opus 1M merge notice tracking
 344    opus1mMergeNoticeSeenCount?: number // Number of times the opus-1m-merge notice has been shown
 345  
 346    // Experiment enrollment notice tracking (keyed by experiment id)
 347    experimentNoticesSeenCount?: Record<string, number>
 348  
 349    // OpusPlan experiment config
 350    hasShownOpusPlanWelcome?: Record<string, boolean> // Whether the OpusPlan welcome message has been shown per org
 351  
 352    // Queue usage tracking
 353    promptQueueUseCount: number // Number of times use has used the prompt queue
 354  
 355    // Btw usage tracking
 356    btwUseCount: number // Number of times user has used /btw
 357  
 358    // Plan mode usage tracking
 359    lastPlanModeUse?: number // Timestamp of last plan mode usage
 360  
 361    // Subscription notice tracking
 362    subscriptionNoticeCount?: number // Number of times the subscription notice has been shown
 363    hasAvailableSubscription?: boolean // Cached result of whether user has a subscription available
 364    subscriptionUpsellShownCount?: number // Number of times the subscription upsell has been shown (deprecated)
 365    recommendedSubscription?: string // Cached config value from Statsig (deprecated)
 366  
 367    // Todo feature configuration
 368    todoFeatureEnabled: boolean // Whether the todo feature is enabled
 369    showExpandedTodos?: boolean // Whether to show todos expanded, even when empty
 370    showSpinnerTree?: boolean // Whether to show the teammate spinner tree instead of pills
 371  
 372    // First start time tracking
 373    firstStartTime?: string // ISO timestamp when Claude Code was first started on this machine
 374  
 375    messageIdleNotifThresholdMs: number // How long the user has to have been idle to get a notification that Claude is done generating
 376  
 377    githubActionSetupCount?: number // Number of times the user has set up the GitHub Action
 378    slackAppInstallCount?: number // Number of times the user has clicked to install the Slack app
 379  
 380    // File checkpointing configuration
 381    fileCheckpointingEnabled: boolean
 382  
 383    // Terminal progress bar configuration (OSC 9;4)
 384    terminalProgressBarEnabled: boolean
 385  
 386    // Terminal tab status indicator (OSC 21337). When on, emits a colored
 387    // dot + status text to the tab sidebar and drops the spinner prefix
 388    // from the title (the dot makes it redundant).
 389    showStatusInTerminalTab?: boolean
 390  
 391    // Push-notification toggles (set via /config). Default off — explicit opt-in required.
 392    taskCompleteNotifEnabled?: boolean
 393    inputNeededNotifEnabled?: boolean
 394    agentPushNotifEnabled?: boolean
 395  
 396    // Claude Code usage tracking
 397    claudeCodeFirstTokenDate?: string // ISO timestamp of the user's first Claude Code OAuth token
 398  
 399    // Model switch callout tracking (ant-only)
 400    modelSwitchCalloutDismissed?: boolean // Whether user chose "Don't show again"
 401    modelSwitchCalloutLastShown?: number // Timestamp of last shown (don't show for 24h)
 402    modelSwitchCalloutVersion?: string
 403  
 404    // Effort callout tracking - shown once for Opus 4.6 users
 405    effortCalloutDismissed?: boolean // v1 - legacy, read to suppress v2 for Pro users who already saw it
 406    effortCalloutV2Dismissed?: boolean
 407  
 408    // Remote callout tracking - shown once before first bridge enable
 409    remoteDialogSeen?: boolean
 410  
 411    // Cross-process backoff for initReplBridge's oauth_expired_unrefreshable skip.
 412    // `expiresAt` is the dedup key — content-addressed, self-clears when /login
 413    // replaces the token. `failCount` caps false positives: transient refresh
 414    // failures (auth server 5xx, lock errors) get 3 retries before backoff kicks
 415    // in, mirroring useReplBridge's MAX_CONSECUTIVE_INIT_FAILURES. Dead-token
 416    // accounts cap at 3 config writes; healthy+transient-blip self-heals in ~210s.
 417    bridgeOauthDeadExpiresAt?: number
 418    bridgeOauthDeadFailCount?: number
 419  
 420    // Desktop upsell startup dialog tracking
 421    desktopUpsellSeenCount?: number // Total showings (max 3)
 422    desktopUpsellDismissed?: boolean // "Don't ask again" picked
 423  
 424    // Idle-return dialog tracking
 425    idleReturnDismissed?: boolean // "Don't ask again" picked
 426  
 427    // Opus 4.5 Pro migration tracking
 428    opusProMigrationComplete?: boolean
 429    opusProMigrationTimestamp?: number
 430  
 431    // Sonnet 4.5 1m migration tracking
 432    sonnet1m45MigrationComplete?: boolean
 433  
 434    // Opus 4.0/4.1 → current Opus migration (shows one-time notif)
 435    legacyOpusMigrationTimestamp?: number
 436  
 437    // Sonnet 4.5 → 4.6 migration (pro/max/team premium)
 438    sonnet45To46MigrationTimestamp?: number
 439  
 440    // Cached statsig gate values
 441    cachedStatsigGates: {
 442      [gateName: string]: boolean
 443    }
 444  
 445    // Cached statsig dynamic configs
 446    cachedDynamicConfigs?: { [configName: string]: unknown }
 447  
 448    // Cached GrowthBook feature values
 449    cachedGrowthBookFeatures?: { [featureName: string]: unknown }
 450  
 451    // Local GrowthBook overrides (ant-only, set via /config Gates tab).
 452    // Checked after env-var overrides but before the real resolved value.
 453    growthBookOverrides?: { [featureName: string]: unknown }
 454  
 455    // Emergency tip tracking - stores the last shown tip to prevent re-showing
 456    lastShownEmergencyTip?: string
 457  
 458    // File picker gitignore behavior
 459    respectGitignore: boolean // Whether file picker should respect .gitignore files (default: true). Note: .ignore files are always respected
 460  
 461    // Copy command behavior
 462    copyFullResponse: boolean // Whether /copy always copies the full response instead of showing the picker
 463  
 464    // Fullscreen in-app text selection behavior
 465    copyOnSelect?: boolean // Auto-copy to clipboard on mouse-up (undefined → true; lets cmd+c "work" via no-op)
 466  
 467    // GitHub repo path mapping for teleport directory switching
 468    // Key: "owner/repo" (lowercase), Value: array of absolute paths where repo is cloned
 469    githubRepoPaths?: Record<string, string[]>
 470  
 471    // Terminal emulator to launch for claude-cli:// deep links. Captured from
 472    // TERM_PROGRAM during interactive sessions since the deep link handler runs
 473    // headless (LaunchServices/xdg) with no TERM_PROGRAM set.
 474    deepLinkTerminal?: string
 475  
 476    // iTerm2 it2 CLI setup
 477    iterm2It2SetupComplete?: boolean // Whether it2 setup has been verified
 478    preferTmuxOverIterm2?: boolean // User preference to always use tmux over iTerm2 split panes
 479  
 480    // Skill usage tracking for autocomplete ranking
 481    skillUsage?: Record<string, { usageCount: number; lastUsedAt: number }>
 482    // Official marketplace auto-install tracking
 483    officialMarketplaceAutoInstallAttempted?: boolean // Whether auto-install was attempted
 484    officialMarketplaceAutoInstalled?: boolean // Whether auto-install succeeded
 485    officialMarketplaceAutoInstallFailReason?:
 486      | 'policy_blocked'
 487      | 'git_unavailable'
 488      | 'gcs_unavailable'
 489      | 'unknown' // Reason for failure if applicable
 490    officialMarketplaceAutoInstallRetryCount?: number // Number of retry attempts
 491    officialMarketplaceAutoInstallLastAttemptTime?: number // Timestamp of last attempt
 492    officialMarketplaceAutoInstallNextRetryTime?: number // Earliest time to retry again
 493  
 494    // Claude in Chrome settings
 495    hasCompletedClaudeInChromeOnboarding?: boolean // Whether Claude in Chrome onboarding has been shown
 496    claudeInChromeDefaultEnabled?: boolean // Whether Claude in Chrome is enabled by default (undefined means platform default)
 497    cachedChromeExtensionInstalled?: boolean // Cached result of whether Chrome extension is installed
 498  
 499    // Chrome extension pairing state (persisted across sessions)
 500    chromeExtension?: {
 501      pairedDeviceId?: string
 502      pairedDeviceName?: string
 503    }
 504  
 505    // LSP plugin recommendation preferences
 506    lspRecommendationDisabled?: boolean // Disable all LSP plugin recommendations
 507    lspRecommendationNeverPlugins?: string[] // Plugin IDs to never suggest
 508    lspRecommendationIgnoredCount?: number // Track ignored recommendations (stops after 5)
 509  
 510    // Claude Code hint protocol state (<claude-code-hint /> tags from CLIs/SDKs).
 511    // Nested by hint type so future types (docs, mcp, ...) slot in without new
 512    // top-level keys.
 513    claudeCodeHints?: {
 514      // Plugin IDs the user has already been prompted for. Show-once semantics:
 515      // recorded regardless of yes/no response, never re-prompted. Capped at
 516      // 100 entries to bound config growth — past that, hints stop entirely.
 517      plugin?: string[]
 518      // User chose "don't show plugin installation hints again" from the dialog.
 519      disabled?: boolean
 520    }
 521  
 522    // Permission explainer configuration
 523    permissionExplainerEnabled?: boolean // Enable Haiku-generated explanations for permission requests (default: true)
 524  
 525    // Teammate spawn mode: 'auto' | 'tmux' | 'in-process'
 526    teammateMode?: 'auto' | 'tmux' | 'in-process' // How to spawn teammates (default: 'auto')
 527    // Model for new teammates when the tool call doesn't pass one.
 528    // undefined = hardcoded Opus (backward-compat); null = leader's model; string = model alias/ID.
 529    teammateDefaultModel?: string | null
 530  
 531    // PR status footer configuration (feature-flagged via GrowthBook)
 532    prStatusFooterEnabled?: boolean // Show PR review status in footer (default: true)
 533  
 534    // Tmux live panel visibility (ant-only, toggled via Enter on tmux pill)
 535    tungstenPanelVisible?: boolean
 536  
 537    // Cached org-level fast mode status from the API.
 538    // Used to detect cross-session changes and notify users.
 539    penguinModeOrgEnabled?: boolean
 540  
 541    // Epoch ms when background refreshes last ran (fast mode, quota, passes, client data).
 542    // Used with tengu_cicada_nap_ms to throttle API calls
 543    startupPrefetchedAt?: number
 544  
 545    // Run Remote Control at startup (requires BRIDGE_MODE)
 546    // undefined = use default (see getRemoteControlAtStartup() for precedence)
 547    remoteControlAtStartup?: boolean
 548  
 549    // Cached extra usage disabled reason from the last API response
 550    // undefined = no cache, null = extra usage enabled, string = disabled reason.
 551    cachedExtraUsageDisabledReason?: string | null
 552  
 553    // Auto permissions notification tracking (ant-only)
 554    autoPermissionsNotificationCount?: number // Number of times the auto permissions notification has been shown
 555  
 556    // Speculation configuration (ant-only)
 557    speculationEnabled?: boolean // Whether speculation is enabled (default: true)
 558  
 559  
 560    // Client data for server-side experiments (fetched during bootstrap).
 561    clientDataCache?: Record<string, unknown> | null
 562  
 563    // Additional model options for the model picker (fetched during bootstrap).
 564    additionalModelOptionsCache?: ModelOption[]
 565  
 566    // Disk cache for /api/claude_code/organizations/metrics_enabled.
 567    // Org-level settings change rarely; persisting across processes avoids a
 568    // cold API call on every `claude -p` invocation.
 569    metricsStatusCache?: {
 570      enabled: boolean
 571      timestamp: number
 572    }
 573  
 574    // Version of the last-applied migration set. When equal to
 575    // CURRENT_MIGRATION_VERSION, runMigrations() skips all sync migrations
 576    // (avoiding 11× saveGlobalConfig lock+re-read on every startup).
 577    migrationVersion?: number
 578  }
 579  
 580  /**
 581   * Factory for a fresh default GlobalConfig. Used instead of deep-cloning a
 582   * shared constant — the nested containers (arrays, records) are all empty, so
 583   * a factory gives fresh refs at zero clone cost.
 584   */
 585  function createDefaultGlobalConfig(): GlobalConfig {
 586    return {
 587      numStartups: 0,
 588      installMethod: undefined,
 589      autoUpdates: undefined,
 590      theme: 'dark',
 591      preferredNotifChannel: 'auto',
 592      verbose: false,
 593      editorMode: 'normal',
 594      autoCompactEnabled: true,
 595      showTurnDuration: true,
 596      hasSeenTasksHint: false,
 597      hasUsedStash: false,
 598      hasUsedBackgroundTask: false,
 599      queuedCommandUpHintCount: 0,
 600      diffTool: 'auto',
 601      customApiKeyResponses: {
 602        approved: [],
 603        rejected: [],
 604      },
 605      env: {},
 606      tipsHistory: {},
 607      memoryUsageCount: 0,
 608      promptQueueUseCount: 0,
 609      btwUseCount: 0,
 610      todoFeatureEnabled: true,
 611      showExpandedTodos: false,
 612      messageIdleNotifThresholdMs: 60000,
 613      autoConnectIde: false,
 614      autoInstallIdeExtension: true,
 615      fileCheckpointingEnabled: true,
 616      terminalProgressBarEnabled: true,
 617      cachedStatsigGates: {},
 618      cachedDynamicConfigs: {},
 619      cachedGrowthBookFeatures: {},
 620      respectGitignore: true,
 621      copyFullResponse: false,
 622    }
 623  }
 624  
 625  export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = createDefaultGlobalConfig()
 626  
 627  export const GLOBAL_CONFIG_KEYS = [
 628    'apiKeyHelper',
 629    'installMethod',
 630    'autoUpdates',
 631    'autoUpdatesProtectedForNative',
 632    'theme',
 633    'verbose',
 634    'preferredNotifChannel',
 635    'shiftEnterKeyBindingInstalled',
 636    'editorMode',
 637    'hasUsedBackslashReturn',
 638    'autoCompactEnabled',
 639    'showTurnDuration',
 640    'diffTool',
 641    'env',
 642    'tipsHistory',
 643    'todoFeatureEnabled',
 644    'showExpandedTodos',
 645    'messageIdleNotifThresholdMs',
 646    'autoConnectIde',
 647    'autoInstallIdeExtension',
 648    'fileCheckpointingEnabled',
 649    'terminalProgressBarEnabled',
 650    'showStatusInTerminalTab',
 651    'taskCompleteNotifEnabled',
 652    'inputNeededNotifEnabled',
 653    'agentPushNotifEnabled',
 654    'respectGitignore',
 655    'claudeInChromeDefaultEnabled',
 656    'hasCompletedClaudeInChromeOnboarding',
 657    'lspRecommendationDisabled',
 658    'lspRecommendationNeverPlugins',
 659    'lspRecommendationIgnoredCount',
 660    'copyFullResponse',
 661    'copyOnSelect',
 662    'permissionExplainerEnabled',
 663    'prStatusFooterEnabled',
 664    'remoteControlAtStartup',
 665    'remoteDialogSeen',
 666  ] as const
 667  
 668  export type GlobalConfigKey = (typeof GLOBAL_CONFIG_KEYS)[number]
 669  
 670  export function isGlobalConfigKey(key: string): key is GlobalConfigKey {
 671    return GLOBAL_CONFIG_KEYS.includes(key as GlobalConfigKey)
 672  }
 673  
 674  export const PROJECT_CONFIG_KEYS = [
 675    'allowedTools',
 676    'hasTrustDialogAccepted',
 677    'hasCompletedProjectOnboarding',
 678  ] as const
 679  
 680  export type ProjectConfigKey = (typeof PROJECT_CONFIG_KEYS)[number]
 681  
 682  /**
 683   * Check if the user has already accepted the trust dialog for the cwd.
 684   *
 685   * This function traverses parent directories to check if a parent directory
 686   * had approval. Accepting trust for a directory implies trust for child
 687   * directories.
 688   *
 689   * @returns Whether the trust dialog has been accepted (i.e. "should not be shown")
 690   */
 691  let _trustAccepted = false
 692  
 693  export function resetTrustDialogAcceptedCacheForTesting(): void {
 694    _trustAccepted = false
 695  }
 696  
 697  export function checkHasTrustDialogAccepted(): boolean {
 698    // Trust only transitions false→true during a session (never the reverse),
 699    // so once true we can latch it. false is not cached — it gets re-checked
 700    // on every call so that trust dialog acceptance is picked up mid-session.
 701    // (lodash memoize doesn't fit here because it would also cache false.)
 702    return (_trustAccepted ||= computeTrustDialogAccepted())
 703  }
 704  
 705  function computeTrustDialogAccepted(): boolean {
 706    // Check session-level trust (for home directory case where trust is not persisted)
 707    // When running from home dir, trust dialog is shown but acceptance is stored
 708    // in memory only. This allows hooks and other features to work during the session.
 709    if (getSessionTrustAccepted()) {
 710      return true
 711    }
 712  
 713    const config = getGlobalConfig()
 714  
 715    // Always check where trust would be saved (git root or original cwd)
 716    // This is the primary location where trust is persisted by saveCurrentProjectConfig
 717    const projectPath = getProjectPathForConfig()
 718    const projectConfig = config.projects?.[projectPath]
 719    if (projectConfig?.hasTrustDialogAccepted) {
 720      return true
 721    }
 722  
 723    // Now check from current working directory and its parents
 724    // Normalize paths for consistent JSON key lookup
 725    let currentPath = normalizePathForConfigKey(getCwd())
 726  
 727    // Traverse all parent directories
 728    while (true) {
 729      const pathConfig = config.projects?.[currentPath]
 730      if (pathConfig?.hasTrustDialogAccepted) {
 731        return true
 732      }
 733  
 734      const parentPath = normalizePathForConfigKey(resolve(currentPath, '..'))
 735      // Stop if we've reached the root (when parent is same as current)
 736      if (parentPath === currentPath) {
 737        break
 738      }
 739      currentPath = parentPath
 740    }
 741  
 742    return false
 743  }
 744  
 745  /**
 746   * Check trust for an arbitrary directory (not the session cwd).
 747   * Walks up from `dir`, returning true if any ancestor has trust persisted.
 748   * Unlike checkHasTrustDialogAccepted, this does NOT consult session trust or
 749   * the memoized project path — use when the target dir differs from cwd (e.g.
 750   * /assistant installing into a user-typed path).
 751   */
 752  export function isPathTrusted(dir: string): boolean {
 753    const config = getGlobalConfig()
 754    let currentPath = normalizePathForConfigKey(resolve(dir))
 755    while (true) {
 756      if (config.projects?.[currentPath]?.hasTrustDialogAccepted) return true
 757      const parentPath = normalizePathForConfigKey(resolve(currentPath, '..'))
 758      if (parentPath === currentPath) return false
 759      currentPath = parentPath
 760    }
 761  }
 762  
 763  // We have to put this test code here because Jest doesn't support mocking ES modules :O
 764  const TEST_GLOBAL_CONFIG_FOR_TESTING: GlobalConfig = {
 765    ...DEFAULT_GLOBAL_CONFIG,
 766    autoUpdates: false,
 767  }
 768  const TEST_PROJECT_CONFIG_FOR_TESTING: ProjectConfig = {
 769    ...DEFAULT_PROJECT_CONFIG,
 770  }
 771  
 772  export function isProjectConfigKey(key: string): key is ProjectConfigKey {
 773    return PROJECT_CONFIG_KEYS.includes(key as ProjectConfigKey)
 774  }
 775  
 776  /**
 777   * Detect whether writing `fresh` would lose auth/onboarding state that the
 778   * in-memory cache still has. This happens when `getConfig` hits a corrupted
 779   * or truncated file mid-write (from another process or a non-atomic fallback)
 780   * and returns DEFAULT_GLOBAL_CONFIG. Writing that back would permanently
 781   * wipe auth. See GH #3117.
 782   */
 783  function wouldLoseAuthState(fresh: {
 784    oauthAccount?: unknown
 785    hasCompletedOnboarding?: boolean
 786  }): boolean {
 787    const cached = globalConfigCache.config
 788    if (!cached) return false
 789    const lostOauth =
 790      cached.oauthAccount !== undefined && fresh.oauthAccount === undefined
 791    const lostOnboarding =
 792      cached.hasCompletedOnboarding === true &&
 793      fresh.hasCompletedOnboarding !== true
 794    return lostOauth || lostOnboarding
 795  }
 796  
 797  export function saveGlobalConfig(
 798    updater: (currentConfig: GlobalConfig) => GlobalConfig,
 799  ): void {
 800    if (process.env.NODE_ENV === 'test') {
 801      const config = updater(TEST_GLOBAL_CONFIG_FOR_TESTING)
 802      // Skip if no changes (same reference returned)
 803      if (config === TEST_GLOBAL_CONFIG_FOR_TESTING) {
 804        return
 805      }
 806      Object.assign(TEST_GLOBAL_CONFIG_FOR_TESTING, config)
 807      return
 808    }
 809  
 810    let written: GlobalConfig | null = null
 811    try {
 812      const didWrite = saveConfigWithLock(
 813        getGlobalClaudeFile(),
 814        createDefaultGlobalConfig,
 815        current => {
 816          const config = updater(current)
 817          // Skip if no changes (same reference returned)
 818          if (config === current) {
 819            return current
 820          }
 821          written = {
 822            ...config,
 823            projects: removeProjectHistory(current.projects),
 824          }
 825          return written
 826        },
 827      )
 828      // Only write-through if we actually wrote. If the auth-loss guard
 829      // tripped (or the updater made no changes), the file is untouched and
 830      // the cache is still valid -- touching it would corrupt the guard.
 831      if (didWrite && written) {
 832        writeThroughGlobalConfigCache(written)
 833      }
 834    } catch (error) {
 835      logForDebugging(`Failed to save config with lock: ${error}`, {
 836        level: 'error',
 837      })
 838      // Fall back to non-locked version on error. This fallback is a race
 839      // window: if another process is mid-write (or the file got truncated),
 840      // getConfig returns defaults. Refuse to write those over a good cached
 841      // config to avoid wiping auth. See GH #3117.
 842      const currentConfig = getConfig(
 843        getGlobalClaudeFile(),
 844        createDefaultGlobalConfig,
 845      )
 846      if (wouldLoseAuthState(currentConfig)) {
 847        logForDebugging(
 848          'saveGlobalConfig fallback: re-read config is missing auth that cache has; refusing to write. See GH #3117.',
 849          { level: 'error' },
 850        )
 851        logEvent('tengu_config_auth_loss_prevented', {})
 852        return
 853      }
 854      const config = updater(currentConfig)
 855      // Skip if no changes (same reference returned)
 856      if (config === currentConfig) {
 857        return
 858      }
 859      written = {
 860        ...config,
 861        projects: removeProjectHistory(currentConfig.projects),
 862      }
 863      saveConfig(getGlobalClaudeFile(), written, DEFAULT_GLOBAL_CONFIG)
 864      writeThroughGlobalConfigCache(written)
 865    }
 866  }
 867  
 868  // Cache for global config
 869  let globalConfigCache: { config: GlobalConfig | null; mtime: number } = {
 870    config: null,
 871    mtime: 0,
 872  }
 873  
 874  // Tracking for config file operations (telemetry)
 875  let lastReadFileStats: { mtime: number; size: number } | null = null
 876  let configCacheHits = 0
 877  let configCacheMisses = 0
 878  // Session-total count of actual disk writes to the global config file.
 879  // Exposed for ant-only dev diagnostics (see inc-4552) so anomalous write
 880  // rates surface in the UI before they corrupt ~/.claude.json.
 881  let globalConfigWriteCount = 0
 882  
 883  export function getGlobalConfigWriteCount(): number {
 884    return globalConfigWriteCount
 885  }
 886  
 887  export const CONFIG_WRITE_DISPLAY_THRESHOLD = 20
 888  
 889  function reportConfigCacheStats(): void {
 890    const total = configCacheHits + configCacheMisses
 891    if (total > 0) {
 892      logEvent('tengu_config_cache_stats', {
 893        cache_hits: configCacheHits,
 894        cache_misses: configCacheMisses,
 895        hit_rate: configCacheHits / total,
 896      })
 897    }
 898    configCacheHits = 0
 899    configCacheMisses = 0
 900  }
 901  
 902  // Register cleanup to report cache stats at session end
 903  // eslint-disable-next-line custom-rules/no-top-level-side-effects
 904  registerCleanup(async () => {
 905    reportConfigCacheStats()
 906  })
 907  
 908  /**
 909   * Migrates old autoUpdaterStatus to new installMethod and autoUpdates fields
 910   * @internal
 911   */
 912  function migrateConfigFields(config: GlobalConfig): GlobalConfig {
 913    // Already migrated
 914    if (config.installMethod !== undefined) {
 915      return config
 916    }
 917  
 918    // autoUpdaterStatus is removed from the type but may exist in old configs
 919    const legacy = config as GlobalConfig & {
 920      autoUpdaterStatus?:
 921        | 'migrated'
 922        | 'installed'
 923        | 'disabled'
 924        | 'enabled'
 925        | 'no_permissions'
 926        | 'not_configured'
 927    }
 928  
 929    // Determine install method and auto-update preference from old field
 930    let installMethod: InstallMethod = 'unknown'
 931    let autoUpdates = config.autoUpdates ?? true // Default to enabled unless explicitly disabled
 932  
 933    switch (legacy.autoUpdaterStatus) {
 934      case 'migrated':
 935        installMethod = 'local'
 936        break
 937      case 'installed':
 938        installMethod = 'native'
 939        break
 940      case 'disabled':
 941        // When disabled, we don't know the install method
 942        autoUpdates = false
 943        break
 944      case 'enabled':
 945      case 'no_permissions':
 946      case 'not_configured':
 947        // These imply global installation
 948        installMethod = 'global'
 949        break
 950      case undefined:
 951        // No old status, keep defaults
 952        break
 953    }
 954  
 955    return {
 956      ...config,
 957      installMethod,
 958      autoUpdates,
 959    }
 960  }
 961  
 962  /**
 963   * Removes history field from projects (migrated to history.jsonl)
 964   * @internal
 965   */
 966  function removeProjectHistory(
 967    projects: Record<string, ProjectConfig> | undefined,
 968  ): Record<string, ProjectConfig> | undefined {
 969    if (!projects) {
 970      return projects
 971    }
 972  
 973    const cleanedProjects: Record<string, ProjectConfig> = {}
 974    let needsCleaning = false
 975  
 976    for (const [path, projectConfig] of Object.entries(projects)) {
 977      // history is removed from the type but may exist in old configs
 978      const legacy = projectConfig as ProjectConfig & { history?: unknown }
 979      if (legacy.history !== undefined) {
 980        needsCleaning = true
 981        const { history, ...cleanedConfig } = legacy
 982        cleanedProjects[path] = cleanedConfig
 983      } else {
 984        cleanedProjects[path] = projectConfig
 985      }
 986    }
 987  
 988    return needsCleaning ? cleanedProjects : projects
 989  }
 990  
 991  // fs.watchFile poll interval for detecting writes from other instances (ms)
 992  const CONFIG_FRESHNESS_POLL_MS = 1000
 993  let freshnessWatcherStarted = false
 994  
 995  // fs.watchFile polls stat on the libuv threadpool and only calls us when mtime
 996  // changed — a stalled stat never blocks the main thread.
 997  function startGlobalConfigFreshnessWatcher(): void {
 998    if (freshnessWatcherStarted || process.env.NODE_ENV === 'test') return
 999    freshnessWatcherStarted = true
1000    const file = getGlobalClaudeFile()
1001    watchFile(
1002      file,
1003      { interval: CONFIG_FRESHNESS_POLL_MS, persistent: false },
1004      curr => {
1005        // Our own writes fire this too — the write-through's Date.now()
1006        // overshoot makes cache.mtime > file mtime, so we skip the re-read.
1007        // Bun/Node also fire with curr.mtimeMs=0 when the file doesn't exist
1008        // (initial callback or deletion) — the <= handles that too.
1009        if (curr.mtimeMs <= globalConfigCache.mtime) return
1010        void getFsImplementation()
1011          .readFile(file, { encoding: 'utf-8' })
1012          .then(content => {
1013            // A write-through may have advanced the cache while we were reading;
1014            // don't regress to the stale snapshot watchFile stat'd.
1015            if (curr.mtimeMs <= globalConfigCache.mtime) return
1016            const parsed = safeParseJSON(stripBOM(content))
1017            if (parsed === null || typeof parsed !== 'object') return
1018            globalConfigCache = {
1019              config: migrateConfigFields({
1020                ...createDefaultGlobalConfig(),
1021                ...(parsed as Partial<GlobalConfig>),
1022              }),
1023              mtime: curr.mtimeMs,
1024            }
1025            lastReadFileStats = { mtime: curr.mtimeMs, size: curr.size }
1026          })
1027          .catch(() => {})
1028      },
1029    )
1030    registerCleanup(async () => {
1031      unwatchFile(file)
1032      freshnessWatcherStarted = false
1033    })
1034  }
1035  
1036  // Write-through: what we just wrote IS the new config. cache.mtime overshoots
1037  // the file's real mtime (Date.now() is recorded after the write) so the
1038  // freshness watcher skips re-reading our own write on its next tick.
1039  function writeThroughGlobalConfigCache(config: GlobalConfig): void {
1040    globalConfigCache = { config, mtime: Date.now() }
1041    lastReadFileStats = null
1042  }
1043  
1044  export function getGlobalConfig(): GlobalConfig {
1045    if (process.env.NODE_ENV === 'test') {
1046      return TEST_GLOBAL_CONFIG_FOR_TESTING
1047    }
1048  
1049    // Fast path: pure memory read. After startup, this always hits — our own
1050    // writes go write-through and other instances' writes are picked up by the
1051    // background freshness watcher (never blocks this path).
1052    if (globalConfigCache.config) {
1053      configCacheHits++
1054      return globalConfigCache.config
1055    }
1056  
1057    // Slow path: startup load. Sync I/O here is acceptable because it runs
1058    // exactly once, before any UI is rendered. Stat before read so any race
1059    // self-corrects (old mtime + new content → watcher re-reads next tick).
1060    configCacheMisses++
1061    try {
1062      let stats: { mtimeMs: number; size: number } | null = null
1063      try {
1064        stats = getFsImplementation().statSync(getGlobalClaudeFile())
1065      } catch {
1066        // File doesn't exist
1067      }
1068      const config = migrateConfigFields(
1069        getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig),
1070      )
1071      globalConfigCache = {
1072        config,
1073        mtime: stats?.mtimeMs ?? Date.now(),
1074      }
1075      lastReadFileStats = stats
1076        ? { mtime: stats.mtimeMs, size: stats.size }
1077        : null
1078      startGlobalConfigFreshnessWatcher()
1079      return config
1080    } catch {
1081      // If anything goes wrong, fall back to uncached behavior
1082      return migrateConfigFields(
1083        getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig),
1084      )
1085    }
1086  }
1087  
1088  /**
1089   * Returns the effective value of remoteControlAtStartup. Precedence:
1090   *   1. User's explicit config value (always wins — honors opt-out)
1091   *   2. CCR auto-connect default (ant-only build, GrowthBook-gated)
1092   *   3. false (Remote Control must be explicitly opted into)
1093   */
1094  export function getRemoteControlAtStartup(): boolean {
1095    const explicit = getGlobalConfig().remoteControlAtStartup
1096    if (explicit !== undefined) return explicit
1097    if (feature('CCR_AUTO_CONNECT')) {
1098      if (ccrAutoConnect?.getCcrAutoConnectDefault()) return true
1099    }
1100    return false
1101  }
1102  
1103  export function getCustomApiKeyStatus(
1104    truncatedApiKey: string,
1105  ): 'approved' | 'rejected' | 'new' {
1106    const config = getGlobalConfig()
1107    if (config.customApiKeyResponses?.approved?.includes(truncatedApiKey)) {
1108      return 'approved'
1109    }
1110    if (config.customApiKeyResponses?.rejected?.includes(truncatedApiKey)) {
1111      return 'rejected'
1112    }
1113    return 'new'
1114  }
1115  
1116  function saveConfig<A extends object>(
1117    file: string,
1118    config: A,
1119    defaultConfig: A,
1120  ): void {
1121    // Ensure the directory exists before writing the config file
1122    const dir = dirname(file)
1123    const fs = getFsImplementation()
1124    // mkdirSync is already recursive in FsOperations implementation
1125    fs.mkdirSync(dir)
1126  
1127    // Filter out any values that match the defaults
1128    const filteredConfig = pickBy(
1129      config,
1130      (value, key) =>
1131        jsonStringify(value) !== jsonStringify(defaultConfig[key as keyof A]),
1132    )
1133    // Write config file with secure permissions - mode only applies to new files
1134    writeFileSyncAndFlush_DEPRECATED(
1135      file,
1136      jsonStringify(filteredConfig, null, 2),
1137      {
1138        encoding: 'utf-8',
1139        mode: 0o600,
1140      },
1141    )
1142    if (file === getGlobalClaudeFile()) {
1143      globalConfigWriteCount++
1144    }
1145  }
1146  
1147  /**
1148   * Returns true if a write was performed; false if the write was skipped
1149   * (no changes, or auth-loss guard tripped). Callers use this to decide
1150   * whether to invalidate the cache -- invalidating after a skipped write
1151   * destroys the good cached state the auth-loss guard depends on.
1152   */
1153  function saveConfigWithLock<A extends object>(
1154    file: string,
1155    createDefault: () => A,
1156    mergeFn: (current: A) => A,
1157  ): boolean {
1158    const defaultConfig = createDefault()
1159    const dir = dirname(file)
1160    const fs = getFsImplementation()
1161  
1162    // Ensure directory exists (mkdirSync is already recursive in FsOperations)
1163    fs.mkdirSync(dir)
1164  
1165    let release
1166    try {
1167      const lockFilePath = `${file}.lock`
1168      const startTime = Date.now()
1169      release = lockfile.lockSync(file, {
1170        lockfilePath: lockFilePath,
1171        onCompromised: err => {
1172          // Default onCompromised throws from a setTimeout callback, which
1173          // becomes an unhandled exception. Log instead -- the lock being
1174          // stolen (e.g. after a 10s event-loop stall) is recoverable.
1175          logForDebugging(`Config lock compromised: ${err}`, { level: 'error' })
1176        },
1177      })
1178      const lockTime = Date.now() - startTime
1179      if (lockTime > 100) {
1180        logForDebugging(
1181          'Lock acquisition took longer than expected - another Claude instance may be running',
1182        )
1183        logEvent('tengu_config_lock_contention', {
1184          lock_time_ms: lockTime,
1185        })
1186      }
1187  
1188      // Check for stale write - file changed since we last read it
1189      // Only check for global config file since lastReadFileStats tracks that specific file
1190      if (lastReadFileStats && file === getGlobalClaudeFile()) {
1191        try {
1192          const currentStats = fs.statSync(file)
1193          if (
1194            currentStats.mtimeMs !== lastReadFileStats.mtime ||
1195            currentStats.size !== lastReadFileStats.size
1196          ) {
1197            logEvent('tengu_config_stale_write', {
1198              read_mtime: lastReadFileStats.mtime,
1199              write_mtime: currentStats.mtimeMs,
1200              read_size: lastReadFileStats.size,
1201              write_size: currentStats.size,
1202            })
1203          }
1204        } catch (e) {
1205          const code = getErrnoCode(e)
1206          if (code !== 'ENOENT') {
1207            throw e
1208          }
1209          // File doesn't exist yet, no stale check needed
1210        }
1211      }
1212  
1213      // Re-read the current config to get latest state. If the file is
1214      // momentarily corrupted (concurrent writes, kill-during-write), this
1215      // returns defaults -- we must not write those back over good config.
1216      const currentConfig = getConfig(file, createDefault)
1217      if (file === getGlobalClaudeFile() && wouldLoseAuthState(currentConfig)) {
1218        logForDebugging(
1219          'saveConfigWithLock: re-read config is missing auth that cache has; refusing to write to avoid wiping ~/.claude.json. See GH #3117.',
1220          { level: 'error' },
1221        )
1222        logEvent('tengu_config_auth_loss_prevented', {})
1223        return false
1224      }
1225  
1226      // Apply the merge function to get the updated config
1227      const mergedConfig = mergeFn(currentConfig)
1228  
1229      // Skip write if no changes (same reference returned)
1230      if (mergedConfig === currentConfig) {
1231        return false
1232      }
1233  
1234      // Filter out any values that match the defaults
1235      const filteredConfig = pickBy(
1236        mergedConfig,
1237        (value, key) =>
1238          jsonStringify(value) !== jsonStringify(defaultConfig[key as keyof A]),
1239      )
1240  
1241      // Create timestamped backup of existing config before writing
1242      // We keep multiple backups to prevent data loss if a reset/corrupted config
1243      // overwrites a good backup. Backups are stored in ~/.claude/backups/ to
1244      // keep the home directory clean.
1245      try {
1246        const fileBase = basename(file)
1247        const backupDir = getConfigBackupDir()
1248  
1249        // Ensure backup directory exists
1250        try {
1251          fs.mkdirSync(backupDir)
1252        } catch (mkdirErr) {
1253          const mkdirCode = getErrnoCode(mkdirErr)
1254          if (mkdirCode !== 'EEXIST') {
1255            throw mkdirErr
1256          }
1257        }
1258  
1259        // Check existing backups first -- skip creating a new one if a recent
1260        // backup already exists. During startup, many saveGlobalConfig calls fire
1261        // within milliseconds of each other; without this check, each call
1262        // creates a new backup file that accumulates on disk.
1263        const MIN_BACKUP_INTERVAL_MS = 60_000
1264        const existingBackups = fs
1265          .readdirStringSync(backupDir)
1266          .filter(f => f.startsWith(`${fileBase}.backup.`))
1267          .sort()
1268          .reverse() // Most recent first (timestamps sort lexicographically)
1269  
1270        const mostRecentBackup = existingBackups[0]
1271        const mostRecentTimestamp = mostRecentBackup
1272          ? Number(mostRecentBackup.split('.backup.').pop())
1273          : 0
1274        const shouldCreateBackup =
1275          Number.isNaN(mostRecentTimestamp) ||
1276          Date.now() - mostRecentTimestamp >= MIN_BACKUP_INTERVAL_MS
1277  
1278        if (shouldCreateBackup) {
1279          const backupPath = join(backupDir, `${fileBase}.backup.${Date.now()}`)
1280          fs.copyFileSync(file, backupPath)
1281        }
1282  
1283        // Clean up old backups, keeping only the 5 most recent
1284        const MAX_BACKUPS = 5
1285        // Re-read if we just created one; otherwise reuse the list
1286        const backupsForCleanup = shouldCreateBackup
1287          ? fs
1288              .readdirStringSync(backupDir)
1289              .filter(f => f.startsWith(`${fileBase}.backup.`))
1290              .sort()
1291              .reverse()
1292          : existingBackups
1293  
1294        for (const oldBackup of backupsForCleanup.slice(MAX_BACKUPS)) {
1295          try {
1296            fs.unlinkSync(join(backupDir, oldBackup))
1297          } catch {
1298            // Ignore cleanup errors
1299          }
1300        }
1301      } catch (e) {
1302        const code = getErrnoCode(e)
1303        if (code !== 'ENOENT') {
1304          logForDebugging(`Failed to backup config: ${e}`, {
1305            level: 'error',
1306          })
1307        }
1308        // No file to backup or backup failed, continue with write
1309      }
1310  
1311      // Write config file with secure permissions - mode only applies to new files
1312      writeFileSyncAndFlush_DEPRECATED(
1313        file,
1314        jsonStringify(filteredConfig, null, 2),
1315        {
1316          encoding: 'utf-8',
1317          mode: 0o600,
1318        },
1319      )
1320      if (file === getGlobalClaudeFile()) {
1321        globalConfigWriteCount++
1322      }
1323      return true
1324    } finally {
1325      if (release) {
1326        release()
1327      }
1328    }
1329  }
1330  
1331  // Flag to track if config reading is allowed
1332  let configReadingAllowed = false
1333  
1334  export function enableConfigs(): void {
1335    if (configReadingAllowed) {
1336      // Ensure this is idempotent
1337      return
1338    }
1339  
1340    const startTime = Date.now()
1341    logForDiagnosticsNoPII('info', 'enable_configs_started')
1342  
1343    // Any reads to configuration before this flag is set show an console warning
1344    // to prevent us from adding config reading during module initialization
1345    configReadingAllowed = true
1346    // We only check the global config because currently all the configs share a file
1347    getConfig(
1348      getGlobalClaudeFile(),
1349      createDefaultGlobalConfig,
1350      true /* throw on invalid */,
1351    )
1352  
1353    logForDiagnosticsNoPII('info', 'enable_configs_completed', {
1354      duration_ms: Date.now() - startTime,
1355    })
1356  }
1357  
1358  /**
1359   * Returns the directory where config backup files are stored.
1360   * Uses ~/.claude/backups/ to keep the home directory clean.
1361   */
1362  function getConfigBackupDir(): string {
1363    return join(getClaudeConfigHomeDir(), 'backups')
1364  }
1365  
1366  /**
1367   * Find the most recent backup file for a given config file.
1368   * Checks ~/.claude/backups/ first, then falls back to the legacy location
1369   * (next to the config file) for backwards compatibility.
1370   * Returns the full path to the most recent backup, or null if none exist.
1371   */
1372  function findMostRecentBackup(file: string): string | null {
1373    const fs = getFsImplementation()
1374    const fileBase = basename(file)
1375    const backupDir = getConfigBackupDir()
1376  
1377    // Check the new backup directory first
1378    try {
1379      const backups = fs
1380        .readdirStringSync(backupDir)
1381        .filter(f => f.startsWith(`${fileBase}.backup.`))
1382        .sort()
1383  
1384      const mostRecent = backups.at(-1) // Timestamps sort lexicographically
1385      if (mostRecent) {
1386        return join(backupDir, mostRecent)
1387      }
1388    } catch {
1389      // Backup dir doesn't exist yet
1390    }
1391  
1392    // Fall back to legacy location (next to the config file)
1393    const fileDir = dirname(file)
1394  
1395    try {
1396      const backups = fs
1397        .readdirStringSync(fileDir)
1398        .filter(f => f.startsWith(`${fileBase}.backup.`))
1399        .sort()
1400  
1401      const mostRecent = backups.at(-1) // Timestamps sort lexicographically
1402      if (mostRecent) {
1403        return join(fileDir, mostRecent)
1404      }
1405  
1406      // Check for legacy backup file (no timestamp)
1407      const legacyBackup = `${file}.backup`
1408      try {
1409        fs.statSync(legacyBackup)
1410        return legacyBackup
1411      } catch {
1412        // Legacy backup doesn't exist
1413      }
1414    } catch {
1415      // Ignore errors reading directory
1416    }
1417  
1418    return null
1419  }
1420  
1421  function getConfig<A>(
1422    file: string,
1423    createDefault: () => A,
1424    throwOnInvalid?: boolean,
1425  ): A {
1426    // Log a warning if config is accessed before it's allowed
1427    if (!configReadingAllowed && process.env.NODE_ENV !== 'test') {
1428      throw new Error('Config accessed before allowed.')
1429    }
1430  
1431    const fs = getFsImplementation()
1432  
1433    try {
1434      const fileContent = fs.readFileSync(file, {
1435        encoding: 'utf-8',
1436      })
1437      try {
1438        // Strip BOM before parsing - PowerShell 5.x adds BOM to UTF-8 files
1439        const parsedConfig = jsonParse(stripBOM(fileContent))
1440        return {
1441          ...createDefault(),
1442          ...parsedConfig,
1443        }
1444      } catch (error) {
1445        // Throw a ConfigParseError with the file path and default config
1446        const errorMessage =
1447          error instanceof Error ? error.message : String(error)
1448        throw new ConfigParseError(errorMessage, file, createDefault())
1449      }
1450    } catch (error) {
1451      // Handle file not found - check for backup and return default
1452      const errCode = getErrnoCode(error)
1453      if (errCode === 'ENOENT') {
1454        const backupPath = findMostRecentBackup(file)
1455        if (backupPath) {
1456          process.stderr.write(
1457            `\nClaude configuration file not found at: ${file}\n` +
1458              `A backup file exists at: ${backupPath}\n` +
1459              `You can manually restore it by running: cp "${backupPath}" "${file}"\n\n`,
1460          )
1461        }
1462        return createDefault()
1463      }
1464  
1465      // Re-throw ConfigParseError if throwOnInvalid is true
1466      if (error instanceof ConfigParseError && throwOnInvalid) {
1467        throw error
1468      }
1469  
1470      // Log config parse errors so users know what happened
1471      if (error instanceof ConfigParseError) {
1472        logForDebugging(
1473          `Config file corrupted, resetting to defaults: ${error.message}`,
1474          { level: 'error' },
1475        )
1476  
1477        // Guard: logEvent → shouldSampleEvent → getGlobalConfig → getConfig
1478        // causes infinite recursion when the config file is corrupted, because
1479        // the sampling check reads a GrowthBook feature from global config.
1480        // Only log analytics on the outermost call.
1481        if (!insideGetConfig) {
1482          insideGetConfig = true
1483          try {
1484            // Log the error for monitoring
1485            logError(error)
1486  
1487            // Log analytics event for config corruption
1488            let hasBackup = false
1489            try {
1490              fs.statSync(`${file}.backup`)
1491              hasBackup = true
1492            } catch {
1493              // No backup
1494            }
1495            logEvent('tengu_config_parse_error', {
1496              has_backup: hasBackup,
1497            })
1498          } finally {
1499            insideGetConfig = false
1500          }
1501        }
1502  
1503        process.stderr.write(
1504          `\nClaude configuration file at ${file} is corrupted: ${error.message}\n`,
1505        )
1506  
1507        // Try to backup the corrupted config file (only if not already backed up)
1508        const fileBase = basename(file)
1509        const corruptedBackupDir = getConfigBackupDir()
1510  
1511        // Ensure backup directory exists
1512        try {
1513          fs.mkdirSync(corruptedBackupDir)
1514        } catch (mkdirErr) {
1515          const mkdirCode = getErrnoCode(mkdirErr)
1516          if (mkdirCode !== 'EEXIST') {
1517            throw mkdirErr
1518          }
1519        }
1520  
1521        const existingCorruptedBackups = fs
1522          .readdirStringSync(corruptedBackupDir)
1523          .filter(f => f.startsWith(`${fileBase}.corrupted.`))
1524  
1525        let corruptedBackupPath: string | undefined
1526        let alreadyBackedUp = false
1527  
1528        // Check if current corrupted content matches any existing backup
1529        const currentContent = fs.readFileSync(file, { encoding: 'utf-8' })
1530        for (const backup of existingCorruptedBackups) {
1531          try {
1532            const backupContent = fs.readFileSync(
1533              join(corruptedBackupDir, backup),
1534              { encoding: 'utf-8' },
1535            )
1536            if (currentContent === backupContent) {
1537              alreadyBackedUp = true
1538              break
1539            }
1540          } catch {
1541            // Ignore read errors on backups
1542          }
1543        }
1544  
1545        if (!alreadyBackedUp) {
1546          corruptedBackupPath = join(
1547            corruptedBackupDir,
1548            `${fileBase}.corrupted.${Date.now()}`,
1549          )
1550          try {
1551            fs.copyFileSync(file, corruptedBackupPath)
1552            logForDebugging(
1553              `Corrupted config backed up to: ${corruptedBackupPath}`,
1554              {
1555                level: 'error',
1556              },
1557            )
1558          } catch {
1559            // Ignore backup errors
1560          }
1561        }
1562  
1563        // Notify user about corrupted config and available backup
1564        const backupPath = findMostRecentBackup(file)
1565        if (corruptedBackupPath) {
1566          process.stderr.write(
1567            `The corrupted file has been backed up to: ${corruptedBackupPath}\n`,
1568          )
1569        } else if (alreadyBackedUp) {
1570          process.stderr.write(`The corrupted file has already been backed up.\n`)
1571        }
1572  
1573        if (backupPath) {
1574          process.stderr.write(
1575            `A backup file exists at: ${backupPath}\n` +
1576              `You can manually restore it by running: cp "${backupPath}" "${file}"\n\n`,
1577          )
1578        } else {
1579          process.stderr.write(`\n`)
1580        }
1581      }
1582  
1583      return createDefault()
1584    }
1585  }
1586  
1587  // Memoized function to get the project path for config lookup
1588  export const getProjectPathForConfig = memoize((): string => {
1589    const originalCwd = getOriginalCwd()
1590    const gitRoot = findCanonicalGitRoot(originalCwd)
1591  
1592    if (gitRoot) {
1593      // Normalize for consistent JSON keys (forward slashes on all platforms)
1594      // This ensures paths like C:\Users\... and C:/Users/... map to the same key
1595      return normalizePathForConfigKey(gitRoot)
1596    }
1597  
1598    // Not in a git repo
1599    return normalizePathForConfigKey(resolve(originalCwd))
1600  })
1601  
1602  export function getCurrentProjectConfig(): ProjectConfig {
1603    if (process.env.NODE_ENV === 'test') {
1604      return TEST_PROJECT_CONFIG_FOR_TESTING
1605    }
1606  
1607    const absolutePath = getProjectPathForConfig()
1608    const config = getGlobalConfig()
1609  
1610    if (!config.projects) {
1611      return DEFAULT_PROJECT_CONFIG
1612    }
1613  
1614    const projectConfig = config.projects[absolutePath] ?? DEFAULT_PROJECT_CONFIG
1615    // Not sure how this became a string
1616    // TODO: Fix upstream
1617    if (typeof projectConfig.allowedTools === 'string') {
1618      projectConfig.allowedTools =
1619        (safeParseJSON(projectConfig.allowedTools) as string[]) ?? []
1620    }
1621  
1622    return projectConfig
1623  }
1624  
1625  export function saveCurrentProjectConfig(
1626    updater: (currentConfig: ProjectConfig) => ProjectConfig,
1627  ): void {
1628    if (process.env.NODE_ENV === 'test') {
1629      const config = updater(TEST_PROJECT_CONFIG_FOR_TESTING)
1630      // Skip if no changes (same reference returned)
1631      if (config === TEST_PROJECT_CONFIG_FOR_TESTING) {
1632        return
1633      }
1634      Object.assign(TEST_PROJECT_CONFIG_FOR_TESTING, config)
1635      return
1636    }
1637    const absolutePath = getProjectPathForConfig()
1638  
1639    let written: GlobalConfig | null = null
1640    try {
1641      const didWrite = saveConfigWithLock(
1642        getGlobalClaudeFile(),
1643        createDefaultGlobalConfig,
1644        current => {
1645          const currentProjectConfig =
1646            current.projects?.[absolutePath] ?? DEFAULT_PROJECT_CONFIG
1647          const newProjectConfig = updater(currentProjectConfig)
1648          // Skip if no changes (same reference returned)
1649          if (newProjectConfig === currentProjectConfig) {
1650            return current
1651          }
1652          written = {
1653            ...current,
1654            projects: {
1655              ...current.projects,
1656              [absolutePath]: newProjectConfig,
1657            },
1658          }
1659          return written
1660        },
1661      )
1662      if (didWrite && written) {
1663        writeThroughGlobalConfigCache(written)
1664      }
1665    } catch (error) {
1666      logForDebugging(`Failed to save config with lock: ${error}`, {
1667        level: 'error',
1668      })
1669  
1670      // Same race window as saveGlobalConfig's fallback -- refuse to write
1671      // defaults over good cached config. See GH #3117.
1672      const config = getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig)
1673      if (wouldLoseAuthState(config)) {
1674        logForDebugging(
1675          'saveCurrentProjectConfig fallback: re-read config is missing auth that cache has; refusing to write. See GH #3117.',
1676          { level: 'error' },
1677        )
1678        logEvent('tengu_config_auth_loss_prevented', {})
1679        return
1680      }
1681      const currentProjectConfig =
1682        config.projects?.[absolutePath] ?? DEFAULT_PROJECT_CONFIG
1683      const newProjectConfig = updater(currentProjectConfig)
1684      // Skip if no changes (same reference returned)
1685      if (newProjectConfig === currentProjectConfig) {
1686        return
1687      }
1688      written = {
1689        ...config,
1690        projects: {
1691          ...config.projects,
1692          [absolutePath]: newProjectConfig,
1693        },
1694      }
1695      saveConfig(getGlobalClaudeFile(), written, DEFAULT_GLOBAL_CONFIG)
1696      writeThroughGlobalConfigCache(written)
1697    }
1698  }
1699  
1700  export function isAutoUpdaterDisabled(): boolean {
1701    return getAutoUpdaterDisabledReason() !== null
1702  }
1703  
1704  /**
1705   * Returns true if plugin autoupdate should be skipped.
1706   * This checks if the auto-updater is disabled AND the FORCE_AUTOUPDATE_PLUGINS
1707   * env var is not set to 'true'. The env var allows forcing plugin autoupdate
1708   * even when the auto-updater is otherwise disabled.
1709   */
1710  export function shouldSkipPluginAutoupdate(): boolean {
1711    return (
1712      isAutoUpdaterDisabled() &&
1713      !isEnvTruthy(process.env.FORCE_AUTOUPDATE_PLUGINS)
1714    )
1715  }
1716  
1717  export type AutoUpdaterDisabledReason =
1718    | { type: 'development' }
1719    | { type: 'env'; envVar: string }
1720    | { type: 'config' }
1721  
1722  export function formatAutoUpdaterDisabledReason(
1723    reason: AutoUpdaterDisabledReason,
1724  ): string {
1725    switch (reason.type) {
1726      case 'development':
1727        return 'development build'
1728      case 'env':
1729        return `${reason.envVar} set`
1730      case 'config':
1731        return 'config'
1732    }
1733  }
1734  
1735  export function getAutoUpdaterDisabledReason(): AutoUpdaterDisabledReason | null {
1736    if (process.env.NODE_ENV === 'development') {
1737      return { type: 'development' }
1738    }
1739    if (isEnvTruthy(process.env.DISABLE_AUTOUPDATER)) {
1740      return { type: 'env', envVar: 'DISABLE_AUTOUPDATER' }
1741    }
1742    const essentialTrafficEnvVar = getEssentialTrafficOnlyReason()
1743    if (essentialTrafficEnvVar) {
1744      return { type: 'env', envVar: essentialTrafficEnvVar }
1745    }
1746    const config = getGlobalConfig()
1747    if (
1748      config.autoUpdates === false &&
1749      (config.installMethod !== 'native' ||
1750        config.autoUpdatesProtectedForNative !== true)
1751    ) {
1752      return { type: 'config' }
1753    }
1754    return null
1755  }
1756  
1757  export function getOrCreateUserID(): string {
1758    const config = getGlobalConfig()
1759    if (config.userID) {
1760      return config.userID
1761    }
1762  
1763    const userID = randomBytes(32).toString('hex')
1764    saveGlobalConfig(current => ({ ...current, userID }))
1765    return userID
1766  }
1767  
1768  export function recordFirstStartTime(): void {
1769    const config = getGlobalConfig()
1770    if (!config.firstStartTime) {
1771      const firstStartTime = new Date().toISOString()
1772      saveGlobalConfig(current => ({
1773        ...current,
1774        firstStartTime: current.firstStartTime ?? firstStartTime,
1775      }))
1776    }
1777  }
1778  
1779  export function getMemoryPath(memoryType: MemoryType): string {
1780    const cwd = getOriginalCwd()
1781  
1782    switch (memoryType) {
1783      case 'User':
1784        return join(getClaudeConfigHomeDir(), 'CLAUDE.md')
1785      case 'Local':
1786        return join(cwd, 'CLAUDE.local.md')
1787      case 'Project':
1788        return join(cwd, 'CLAUDE.md')
1789      case 'Managed':
1790        return join(getManagedFilePath(), 'CLAUDE.md')
1791      case 'AutoMem':
1792        return getAutoMemEntrypoint()
1793    }
1794    // TeamMem is only a valid MemoryType when feature('TEAMMEM') is true
1795    if (feature('TEAMMEM')) {
1796      return teamMemPaths!.getTeamMemEntrypoint()
1797    }
1798    return '' // unreachable in external builds where TeamMem is not in MemoryType
1799  }
1800  
1801  export function getManagedClaudeRulesDir(): string {
1802    return join(getManagedFilePath(), '.claude', 'rules')
1803  }
1804  
1805  export function getUserClaudeRulesDir(): string {
1806    return join(getClaudeConfigHomeDir(), 'rules')
1807  }
1808  
1809  // Exported for testing only
1810  export const _getConfigForTesting = getConfig
1811  export const _wouldLoseAuthStateForTesting = wouldLoseAuthState
1812  export function _setGlobalConfigCacheForTesting(
1813    config: GlobalConfig | null,
1814  ): void {
1815    globalConfigCache.config = config
1816    globalConfigCache.mtime = config ? Date.now() : 0
1817  }