source dump of claude code
at main 477 lines 21 kB view raw
1/* eslint-disable custom-rules/no-process-exit */ 2 3import { feature } from 'bun:bundle' 4import chalk from 'chalk' 5import { 6 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 7 logEvent, 8} from 'src/services/analytics/index.js' 9import { getCwd } from 'src/utils/cwd.js' 10import { checkForReleaseNotes } from 'src/utils/releaseNotes.js' 11import { setCwd } from 'src/utils/Shell.js' 12import { initSinks } from 'src/utils/sinks.js' 13import { 14 getIsNonInteractiveSession, 15 getProjectRoot, 16 getSessionId, 17 setOriginalCwd, 18 setProjectRoot, 19 switchSession, 20} from './bootstrap/state.js' 21import { getCommands } from './commands.js' 22import { initSessionMemory } from './services/SessionMemory/sessionMemory.js' 23import { asSessionId } from './types/ids.js' 24import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js' 25import { checkAndRestoreTerminalBackup } from './utils/appleTerminalBackup.js' 26import { prefetchApiKeyFromApiKeyHelperIfSafe } from './utils/auth.js' 27import { clearMemoryFileCaches } from './utils/claudemd.js' 28import { getCurrentProjectConfig, getGlobalConfig } from './utils/config.js' 29import { logForDiagnosticsNoPII } from './utils/diagLogs.js' 30import { env } from './utils/env.js' 31import { envDynamic } from './utils/envDynamic.js' 32import { isBareMode, isEnvTruthy } from './utils/envUtils.js' 33import { errorMessage } from './utils/errors.js' 34import { findCanonicalGitRoot, findGitRoot, getIsGit } from './utils/git.js' 35import { initializeFileChangedWatcher } from './utils/hooks/fileChangedWatcher.js' 36import { 37 captureHooksConfigSnapshot, 38 updateHooksConfigSnapshot, 39} from './utils/hooks/hooksConfigSnapshot.js' 40import { hasWorktreeCreateHook } from './utils/hooks.js' 41import { checkAndRestoreITerm2Backup } from './utils/iTermBackup.js' 42import { logError } from './utils/log.js' 43import { getRecentActivity } from './utils/logoV2Utils.js' 44import { lockCurrentVersion } from './utils/nativeInstaller/index.js' 45import type { PermissionMode } from './utils/permissions/PermissionMode.js' 46import { getPlanSlug } from './utils/plans.js' 47import { saveWorktreeState } from './utils/sessionStorage.js' 48import { profileCheckpoint } from './utils/startupProfiler.js' 49import { 50 createTmuxSessionForWorktree, 51 createWorktreeForSession, 52 generateTmuxSessionName, 53 worktreeBranchName, 54} from './utils/worktree.js' 55 56export async function setup( 57 cwd: string, 58 permissionMode: PermissionMode, 59 allowDangerouslySkipPermissions: boolean, 60 worktreeEnabled: boolean, 61 worktreeName: string | undefined, 62 tmuxEnabled: boolean, 63 customSessionId?: string | null, 64 worktreePRNumber?: number, 65 messagingSocketPath?: string, 66): Promise<void> { 67 logForDiagnosticsNoPII('info', 'setup_started') 68 69 // Check for Node.js version < 18 70 const nodeVersion = process.version.match(/^v(\d+)\./)?.[1] 71 if (!nodeVersion || parseInt(nodeVersion) < 18) { 72 // biome-ignore lint/suspicious/noConsole:: intentional console output 73 console.error( 74 chalk.bold.red( 75 'Error: Claude Code requires Node.js version 18 or higher.', 76 ), 77 ) 78 process.exit(1) 79 } 80 81 // Set custom session ID if provided 82 if (customSessionId) { 83 switchSession(asSessionId(customSessionId)) 84 } 85 86 // --bare / SIMPLE: skip UDS messaging server and teammate snapshot. 87 // Scripted calls don't receive injected messages and don't use swarm teammates. 88 // Explicit --messaging-socket-path is the escape hatch (per #23222 gate pattern). 89 if (!isBareMode() || messagingSocketPath !== undefined) { 90 // Start UDS messaging server (Mac/Linux only). 91 // Enabled by default for ants — creates a socket in tmpdir if no 92 // --messaging-socket-path is passed. Awaited so the server is bound 93 // and $CLAUDE_CODE_MESSAGING_SOCKET is exported before any hook 94 // (SessionStart in particular) can spawn and snapshot process.env. 95 if (feature('UDS_INBOX')) { 96 const m = await import('./utils/udsMessaging.js') 97 await m.startUdsMessaging( 98 messagingSocketPath ?? m.getDefaultUdsSocketPath(), 99 { isExplicit: messagingSocketPath !== undefined }, 100 ) 101 } 102 } 103 104 // Teammate snapshot — SIMPLE-only gate (no escape hatch, swarm not used in bare) 105 if (!isBareMode() && isAgentSwarmsEnabled()) { 106 const { captureTeammateModeSnapshot } = await import( 107 './utils/swarm/backends/teammateModeSnapshot.js' 108 ) 109 captureTeammateModeSnapshot() 110 } 111 112 // Terminal backup restoration — interactive only. Print mode doesn't 113 // interact with terminal settings; the next interactive session will 114 // detect and restore any interrupted setup. 115 if (!getIsNonInteractiveSession()) { 116 // iTerm2 backup check only when swarms enabled 117 if (isAgentSwarmsEnabled()) { 118 const restoredIterm2Backup = await checkAndRestoreITerm2Backup() 119 if (restoredIterm2Backup.status === 'restored') { 120 // biome-ignore lint/suspicious/noConsole:: intentional console output 121 console.log( 122 chalk.yellow( 123 'Detected an interrupted iTerm2 setup. Your original settings have been restored. You may need to restart iTerm2 for the changes to take effect.', 124 ), 125 ) 126 } else if (restoredIterm2Backup.status === 'failed') { 127 // biome-ignore lint/suspicious/noConsole:: intentional console output 128 console.error( 129 chalk.red( 130 `Failed to restore iTerm2 settings. Please manually restore your original settings with: defaults import com.googlecode.iterm2 ${restoredIterm2Backup.backupPath}.`, 131 ), 132 ) 133 } 134 } 135 136 // Check and restore Terminal.app backup if setup was interrupted 137 try { 138 const restoredTerminalBackup = await checkAndRestoreTerminalBackup() 139 if (restoredTerminalBackup.status === 'restored') { 140 // biome-ignore lint/suspicious/noConsole:: intentional console output 141 console.log( 142 chalk.yellow( 143 'Detected an interrupted Terminal.app setup. Your original settings have been restored. You may need to restart Terminal.app for the changes to take effect.', 144 ), 145 ) 146 } else if (restoredTerminalBackup.status === 'failed') { 147 // biome-ignore lint/suspicious/noConsole:: intentional console output 148 console.error( 149 chalk.red( 150 `Failed to restore Terminal.app settings. Please manually restore your original settings with: defaults import com.apple.Terminal ${restoredTerminalBackup.backupPath}.`, 151 ), 152 ) 153 } 154 } catch (error) { 155 // Log but don't crash if Terminal.app backup restoration fails 156 logError(error) 157 } 158 } 159 160 // IMPORTANT: setCwd() must be called before any other code that depends on the cwd 161 setCwd(cwd) 162 163 // Capture hooks configuration snapshot to avoid hidden hook modifications. 164 // IMPORTANT: Must be called AFTER setCwd() so hooks are loaded from the correct directory 165 const hooksStart = Date.now() 166 captureHooksConfigSnapshot() 167 logForDiagnosticsNoPII('info', 'setup_hooks_captured', { 168 duration_ms: Date.now() - hooksStart, 169 }) 170 171 // Initialize FileChanged hook watcher — sync, reads hook config snapshot 172 initializeFileChangedWatcher(cwd) 173 174 // Handle worktree creation if requested 175 // IMPORTANT: this must be called befiore getCommands(), otherwise /eject won't be available. 176 if (worktreeEnabled) { 177 // Mirrors bridgeMain.ts: hook-configured sessions can proceed without git 178 // so createWorktreeForSession() can delegate to the hook (non-git VCS). 179 const hasHook = hasWorktreeCreateHook() 180 const inGit = await getIsGit() 181 if (!hasHook && !inGit) { 182 process.stderr.write( 183 chalk.red( 184 `Error: Can only use --worktree in a git repository, but ${chalk.bold(cwd)} is not a git repository. ` + 185 `Configure a WorktreeCreate hook in settings.json to use --worktree with other VCS systems.\n`, 186 ), 187 ) 188 process.exit(1) 189 } 190 191 const slug = worktreePRNumber 192 ? `pr-${worktreePRNumber}` 193 : (worktreeName ?? getPlanSlug()) 194 195 // Git preamble runs whenever we're in a git repo — even if a hook is 196 // configured — so --tmux keeps working for git users who also have a 197 // WorktreeCreate hook. Only hook-only (non-git) mode skips it. 198 let tmuxSessionName: string | undefined 199 if (inGit) { 200 // Resolve to main repo root (handles being invoked from within a worktree). 201 // findCanonicalGitRoot is sync/filesystem-only/memoized; the underlying 202 // findGitRoot cache was already warmed by getIsGit() above, so this is ~free. 203 const mainRepoRoot = findCanonicalGitRoot(getCwd()) 204 if (!mainRepoRoot) { 205 process.stderr.write( 206 chalk.red( 207 `Error: Could not determine the main git repository root.\n`, 208 ), 209 ) 210 process.exit(1) 211 } 212 213 // If we're inside a worktree, switch to the main repo for worktree creation 214 if (mainRepoRoot !== (findGitRoot(getCwd()) ?? getCwd())) { 215 logForDiagnosticsNoPII('info', 'worktree_resolved_to_main_repo') 216 process.chdir(mainRepoRoot) 217 setCwd(mainRepoRoot) 218 } 219 220 tmuxSessionName = tmuxEnabled 221 ? generateTmuxSessionName(mainRepoRoot, worktreeBranchName(slug)) 222 : undefined 223 } else { 224 // Non-git hook mode: no canonical root to resolve, so name the tmux 225 // session from cwd — generateTmuxSessionName only basenames the path. 226 tmuxSessionName = tmuxEnabled 227 ? generateTmuxSessionName(getCwd(), worktreeBranchName(slug)) 228 : undefined 229 } 230 231 let worktreeSession: Awaited<ReturnType<typeof createWorktreeForSession>> 232 try { 233 worktreeSession = await createWorktreeForSession( 234 getSessionId(), 235 slug, 236 tmuxSessionName, 237 worktreePRNumber ? { prNumber: worktreePRNumber } : undefined, 238 ) 239 } catch (error) { 240 process.stderr.write( 241 chalk.red(`Error creating worktree: ${errorMessage(error)}\n`), 242 ) 243 process.exit(1) 244 } 245 246 logEvent('tengu_worktree_created', { tmux_enabled: tmuxEnabled }) 247 248 // Create tmux session for the worktree if enabled 249 if (tmuxEnabled && tmuxSessionName) { 250 const tmuxResult = await createTmuxSessionForWorktree( 251 tmuxSessionName, 252 worktreeSession.worktreePath, 253 ) 254 if (tmuxResult.created) { 255 // biome-ignore lint/suspicious/noConsole:: intentional console output 256 console.log( 257 chalk.green( 258 `Created tmux session: ${chalk.bold(tmuxSessionName)}\nTo attach: ${chalk.bold(`tmux attach -t ${tmuxSessionName}`)}`, 259 ), 260 ) 261 } else { 262 // biome-ignore lint/suspicious/noConsole:: intentional console output 263 console.error( 264 chalk.yellow( 265 `Warning: Failed to create tmux session: ${tmuxResult.error}`, 266 ), 267 ) 268 } 269 } 270 271 process.chdir(worktreeSession.worktreePath) 272 setCwd(worktreeSession.worktreePath) 273 setOriginalCwd(getCwd()) 274 // --worktree means the worktree IS the session's project, so skills/hooks/ 275 // cron/etc. should resolve here. (EnterWorktreeTool mid-session does NOT 276 // touch projectRoot — that's a throwaway worktree, project stays stable.) 277 setProjectRoot(getCwd()) 278 saveWorktreeState(worktreeSession) 279 // Clear memory files cache since originalCwd has changed 280 clearMemoryFileCaches() 281 // Settings cache was populated in init() (via applySafeConfigEnvironmentVariables) 282 // and again at captureHooksConfigSnapshot() above, both from the original dir's 283 // .claude/settings.json. Re-read from the worktree and re-capture hooks. 284 updateHooksConfigSnapshot() 285 } 286 287 // Background jobs - only critical registrations that must happen before first query 288 logForDiagnosticsNoPII('info', 'setup_background_jobs_starting') 289 // Bundled skills/plugins are registered in main.tsx before the parallel 290 // getCommands() kick — see comment there. Moved out of setup() because 291 // the await points above (startUdsMessaging, ~20ms) meant getCommands() 292 // raced ahead and memoized an empty bundledSkills list. 293 if (!isBareMode()) { 294 initSessionMemory() // Synchronous - registers hook, gate check happens lazily 295 if (feature('CONTEXT_COLLAPSE')) { 296 /* eslint-disable @typescript-eslint/no-require-imports */ 297 ;( 298 require('./services/contextCollapse/index.js') as typeof import('./services/contextCollapse/index.js') 299 ).initContextCollapse() 300 /* eslint-enable @typescript-eslint/no-require-imports */ 301 } 302 } 303 void lockCurrentVersion() // Lock current version to prevent deletion by other processes 304 logForDiagnosticsNoPII('info', 'setup_background_jobs_launched') 305 306 profileCheckpoint('setup_before_prefetch') 307 // Pre-fetch promises - only items needed before render 308 logForDiagnosticsNoPII('info', 'setup_prefetch_starting') 309 // When CLAUDE_CODE_SYNC_PLUGIN_INSTALL is set, skip all plugin prefetch. 310 // The sync install path in print.ts calls refreshPluginState() after 311 // installing, which reloads commands, hooks, and agents. Prefetching here 312 // races with the install (concurrent copyPluginToVersionedCache / cachePlugin 313 // on the same directories), and the hot-reload handler fires clearPluginCache() 314 // mid-install when policySettings arrives. 315 const skipPluginPrefetch = 316 (getIsNonInteractiveSession() && 317 isEnvTruthy(process.env.CLAUDE_CODE_SYNC_PLUGIN_INSTALL)) || 318 // --bare: loadPluginHooks → loadAllPlugins is filesystem work that's 319 // wasted when executeHooks early-returns under --bare anyway. 320 isBareMode() 321 if (!skipPluginPrefetch) { 322 void getCommands(getProjectRoot()) 323 } 324 void import('./utils/plugins/loadPluginHooks.js').then(m => { 325 if (!skipPluginPrefetch) { 326 void m.loadPluginHooks() // Pre-load plugin hooks (consumed by processSessionStartHooks before render) 327 m.setupPluginHookHotReload() // Set up hot reload for plugin hooks when settings change 328 } 329 }) 330 // --bare: skip attribution hook install + repo classification + 331 // session-file-access analytics + team memory watcher. These are background 332 // bookkeeping for commit attribution + usage metrics — scripted calls don't 333 // commit code, and the 49ms attribution hook stat check (measured) is pure 334 // overhead. NOT an early-return: the --dangerously-skip-permissions safety 335 // gate, tengu_started beacon, and apiKeyHelper prefetch below must still run. 336 if (!isBareMode()) { 337 if (process.env.USER_TYPE === 'ant') { 338 // Prime repo classification cache for auto-undercover mode. Default is 339 // undercover ON until proven internal; if this resolves to internal, clear 340 // the prompt cache so the next turn picks up the OFF state. 341 void import('./utils/commitAttribution.js').then(async m => { 342 if (await m.isInternalModelRepo()) { 343 const { clearSystemPromptSections } = await import( 344 './constants/systemPromptSections.js' 345 ) 346 clearSystemPromptSections() 347 } 348 }) 349 } 350 if (feature('COMMIT_ATTRIBUTION')) { 351 // Dynamic import to enable dead code elimination (module contains excluded strings). 352 // Defer to next tick so the git subprocess spawn runs after first render 353 // rather than during the setup() microtask window. 354 setImmediate(() => { 355 void import('./utils/attributionHooks.js').then( 356 ({ registerAttributionHooks }) => { 357 registerAttributionHooks() // Register attribution tracking hooks (ant-only feature) 358 }, 359 ) 360 }) 361 } 362 void import('./utils/sessionFileAccessHooks.js').then(m => 363 m.registerSessionFileAccessHooks(), 364 ) // Register session file access analytics hooks 365 if (feature('TEAMMEM')) { 366 void import('./services/teamMemorySync/watcher.js').then(m => 367 m.startTeamMemoryWatcher(), 368 ) // Start team memory sync watcher 369 } 370 } 371 initSinks() // Attach error log + analytics sinks and drain queued events 372 373 // Session-success-rate denominator. Emit immediately after the analytics 374 // sink is attached — before any parsing, fetching, or I/O that could throw. 375 // inc-3694 (P0 CHANGELOG crash) threw at checkForReleaseNotes below; every 376 // event after this point was dead. This beacon is the earliest reliable 377 // "process started" signal for release health monitoring. 378 logEvent('tengu_started', {}) 379 380 void prefetchApiKeyFromApiKeyHelperIfSafe(getIsNonInteractiveSession()) // Prefetch safely - only executes if trust already confirmed 381 profileCheckpoint('setup_after_prefetch') 382 383 // Pre-fetch data for Logo v2 - await to ensure it's ready before logo renders. 384 // --bare / SIMPLE: skip — release notes are interactive-UI display data, 385 // and getRecentActivity() reads up to 10 session JSONL files. 386 if (!isBareMode()) { 387 const { hasReleaseNotes } = await checkForReleaseNotes( 388 getGlobalConfig().lastReleaseNotesSeen, 389 ) 390 if (hasReleaseNotes) { 391 await getRecentActivity() 392 } 393 } 394 395 // If permission mode is set to bypass, verify we're in a safe environment 396 if ( 397 permissionMode === 'bypassPermissions' || 398 allowDangerouslySkipPermissions 399 ) { 400 // Check if running as root/sudo on Unix-like systems 401 // Allow root if in a sandbox (e.g., TPU devspaces that require root) 402 if ( 403 process.platform !== 'win32' && 404 typeof process.getuid === 'function' && 405 process.getuid() === 0 && 406 process.env.IS_SANDBOX !== '1' && 407 !isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP) 408 ) { 409 // biome-ignore lint/suspicious/noConsole:: intentional console output 410 console.error( 411 `--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`, 412 ) 413 process.exit(1) 414 } 415 416 if ( 417 process.env.USER_TYPE === 'ant' && 418 // Skip for Desktop's local agent mode — same trust model as CCR/BYOC 419 // (trusted Anthropic-managed launcher intentionally pre-approving everything). 420 // Precedent: permissionSetup.ts:861, applySettingsChange.ts:55 (PR #19116) 421 process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent' && 422 // Same for CCD (Claude Code in Desktop) — apps#29127 passes the flag 423 // unconditionally to unlock mid-session bypass switching 424 process.env.CLAUDE_CODE_ENTRYPOINT !== 'claude-desktop' 425 ) { 426 // Only await if permission mode is set to bypass 427 const [isDocker, hasInternet] = await Promise.all([ 428 envDynamic.getIsDocker(), 429 env.hasInternetAccess(), 430 ]) 431 const isBubblewrap = envDynamic.getIsBubblewrapSandbox() 432 const isSandbox = process.env.IS_SANDBOX === '1' 433 const isSandboxed = isDocker || isBubblewrap || isSandbox 434 if (!isSandboxed || hasInternet) { 435 // biome-ignore lint/suspicious/noConsole:: intentional console output 436 console.error( 437 `--dangerously-skip-permissions can only be used in Docker/sandbox containers with no internet access but got Docker: ${isDocker}, Bubblewrap: ${isBubblewrap}, IS_SANDBOX: ${isSandbox}, hasInternet: ${hasInternet}`, 438 ) 439 process.exit(1) 440 } 441 } 442 } 443 444 if (process.env.NODE_ENV === 'test') { 445 return 446 } 447 448 // Log tengu_exit event from the last session? 449 const projectConfig = getCurrentProjectConfig() 450 if ( 451 projectConfig.lastCost !== undefined && 452 projectConfig.lastDuration !== undefined 453 ) { 454 logEvent('tengu_exit', { 455 last_session_cost: projectConfig.lastCost, 456 last_session_api_duration: projectConfig.lastAPIDuration, 457 last_session_tool_duration: projectConfig.lastToolDuration, 458 last_session_duration: projectConfig.lastDuration, 459 last_session_lines_added: projectConfig.lastLinesAdded, 460 last_session_lines_removed: projectConfig.lastLinesRemoved, 461 last_session_total_input_tokens: projectConfig.lastTotalInputTokens, 462 last_session_total_output_tokens: projectConfig.lastTotalOutputTokens, 463 last_session_total_cache_creation_input_tokens: 464 projectConfig.lastTotalCacheCreationInputTokens, 465 last_session_total_cache_read_input_tokens: 466 projectConfig.lastTotalCacheReadInputTokens, 467 last_session_fps_average: projectConfig.lastFpsAverage, 468 last_session_fps_low_1_pct: projectConfig.lastFpsLow1Pct, 469 last_session_id: 470 projectConfig.lastSessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 471 ...projectConfig.lastSessionMetrics, 472 }) 473 // Note: We intentionally don't clear these values after logging. 474 // They're needed for cost restoration when resuming sessions. 475 // The values will be overwritten when the next session exits. 476 } 477}