source dump of claude code
at main 1817 lines 64 kB view raw
1import { feature } from 'bun:bundle' 2import { randomBytes } from 'crypto' 3import { unwatchFile, watchFile } from 'fs' 4import memoize from 'lodash-es/memoize.js' 5import pickBy from 'lodash-es/pickBy.js' 6import { basename, dirname, join, resolve } from 'path' 7import { getOriginalCwd, getSessionTrustAccepted } from '../bootstrap/state.js' 8import { getAutoMemEntrypoint } from '../memdir/paths.js' 9import { logEvent } from '../services/analytics/index.js' 10import type { McpServerConfig } from '../services/mcp/types.js' 11import type { 12 BillingType, 13 ReferralEligibilityResponse, 14} from '../services/oauth/types.js' 15import { getCwd } from '../utils/cwd.js' 16import { registerCleanup } from './cleanupRegistry.js' 17import { logForDebugging } from './debug.js' 18import { logForDiagnosticsNoPII } from './diagLogs.js' 19import { getGlobalClaudeFile } from './env.js' 20import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 21import { ConfigParseError, getErrnoCode } from './errors.js' 22import { writeFileSyncAndFlush_DEPRECATED } from './file.js' 23import { getFsImplementation } from './fsOperations.js' 24import { findCanonicalGitRoot } from './git.js' 25import { safeParseJSON } from './json.js' 26import { stripBOM } from './jsonRead.js' 27import * as lockfile from './lockfile.js' 28import { logError } from './log.js' 29import type { MemoryType } from './memory/types.js' 30import { normalizePathForConfigKey } from './path.js' 31import { getEssentialTrafficOnlyReason } from './privacyLevel.js' 32import { getManagedFilePath } from './settings/managedPath.js' 33import type { ThemeSetting } from './theme.js' 34 35/* eslint-disable @typescript-eslint/no-require-imports */ 36const teamMemPaths = feature('TEAMMEM') 37 ? (require('../memdir/teamMemPaths.js') as typeof import('../memdir/teamMemPaths.js')) 38 : null 39const 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 */ 44import type { ImageDimensions } from './imageResizer.js' 45import type { ModelOption } from './model/modelOptions.js' 46import { 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. 51let insideGetConfig = false 52 53// Image dimension info for coordinate mapping (only set when image was resized) 54export 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 64export interface SerializedStructuredHistoryEntry { 65 display: string 66 pastedContents?: Record<number, PastedContent> 67 pastedText?: string 68} 69export interface HistoryEntry { 70 display: string 71 pastedContents: Record<number, PastedContent> 72} 73 74export type ReleaseChannel = 'stable' | 'latest' 75 76export 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 138const 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 150export type InstallMethod = 'local' | 'native' | 'global' | 'unknown' 151 152export { 153 EDITOR_MODES, 154 NOTIFICATION_CHANNELS, 155} from './configConstants.js' 156 157import type { EDITOR_MODES, NOTIFICATION_CHANNELS } from './configConstants.js' 158 159export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number] 160 161export 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 177export type EditorMode = 'emacs' | (typeof EDITOR_MODES)[number] 178 179export type DiffTool = 'terminal' | 'auto' 180 181export type OutputStyle = string 182 183export 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 */ 585function 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 625export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = createDefaultGlobalConfig() 626 627export 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 668export type GlobalConfigKey = (typeof GLOBAL_CONFIG_KEYS)[number] 669 670export function isGlobalConfigKey(key: string): key is GlobalConfigKey { 671 return GLOBAL_CONFIG_KEYS.includes(key as GlobalConfigKey) 672} 673 674export const PROJECT_CONFIG_KEYS = [ 675 'allowedTools', 676 'hasTrustDialogAccepted', 677 'hasCompletedProjectOnboarding', 678] as const 679 680export 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 */ 691let _trustAccepted = false 692 693export function resetTrustDialogAcceptedCacheForTesting(): void { 694 _trustAccepted = false 695} 696 697export 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 705function 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 */ 752export 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 764const TEST_GLOBAL_CONFIG_FOR_TESTING: GlobalConfig = { 765 ...DEFAULT_GLOBAL_CONFIG, 766 autoUpdates: false, 767} 768const TEST_PROJECT_CONFIG_FOR_TESTING: ProjectConfig = { 769 ...DEFAULT_PROJECT_CONFIG, 770} 771 772export 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 */ 783function 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 797export 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 869let globalConfigCache: { config: GlobalConfig | null; mtime: number } = { 870 config: null, 871 mtime: 0, 872} 873 874// Tracking for config file operations (telemetry) 875let lastReadFileStats: { mtime: number; size: number } | null = null 876let configCacheHits = 0 877let 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. 881let globalConfigWriteCount = 0 882 883export function getGlobalConfigWriteCount(): number { 884 return globalConfigWriteCount 885} 886 887export const CONFIG_WRITE_DISPLAY_THRESHOLD = 20 888 889function 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 904registerCleanup(async () => { 905 reportConfigCacheStats() 906}) 907 908/** 909 * Migrates old autoUpdaterStatus to new installMethod and autoUpdates fields 910 * @internal 911 */ 912function 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 */ 966function 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) 992const CONFIG_FRESHNESS_POLL_MS = 1000 993let 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. 997function 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. 1039function writeThroughGlobalConfigCache(config: GlobalConfig): void { 1040 globalConfigCache = { config, mtime: Date.now() } 1041 lastReadFileStats = null 1042} 1043 1044export 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 */ 1094export 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 1103export 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 1116function 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 */ 1153function 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 1332let configReadingAllowed = false 1333 1334export 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 */ 1362function 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 */ 1372function 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 1421function 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 1588export 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 1602export 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 1625export 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 1700export 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 */ 1710export function shouldSkipPluginAutoupdate(): boolean { 1711 return ( 1712 isAutoUpdaterDisabled() && 1713 !isEnvTruthy(process.env.FORCE_AUTOUPDATE_PLUGINS) 1714 ) 1715} 1716 1717export type AutoUpdaterDisabledReason = 1718 | { type: 'development' } 1719 | { type: 'env'; envVar: string } 1720 | { type: 'config' } 1721 1722export 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 1735export 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 1757export 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 1768export 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 1779export 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 1801export function getManagedClaudeRulesDir(): string { 1802 return join(getManagedFilePath(), '.claude', 'rules') 1803} 1804 1805export function getUserClaudeRulesDir(): string { 1806 return join(getClaudeConfigHomeDir(), 'rules') 1807} 1808 1809// Exported for testing only 1810export const _getConfigForTesting = getConfig 1811export const _wouldLoseAuthStateForTesting = wouldLoseAuthState 1812export function _setGlobalConfigCacheForTesting( 1813 config: GlobalConfig | null, 1814): void { 1815 globalConfigCache.config = config 1816 globalConfigCache.mtime = config ? Date.now() : 0 1817}