/* eslint-disable custom-rules/no-process-exit */ import { feature } from 'bun:bundle' import chalk from 'chalk' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { getCwd } from 'src/utils/cwd.js' import { checkForReleaseNotes } from 'src/utils/releaseNotes.js' import { setCwd } from 'src/utils/Shell.js' import { initSinks } from 'src/utils/sinks.js' import { getIsNonInteractiveSession, getProjectRoot, getSessionId, setOriginalCwd, setProjectRoot, switchSession, } from './bootstrap/state.js' import { getCommands } from './commands.js' import { initSessionMemory } from './services/SessionMemory/sessionMemory.js' import { asSessionId } from './types/ids.js' import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js' import { checkAndRestoreTerminalBackup } from './utils/appleTerminalBackup.js' import { prefetchApiKeyFromApiKeyHelperIfSafe } from './utils/auth.js' import { clearMemoryFileCaches } from './utils/claudemd.js' import { getCurrentProjectConfig, getGlobalConfig } from './utils/config.js' import { logForDiagnosticsNoPII } from './utils/diagLogs.js' import { env } from './utils/env.js' import { envDynamic } from './utils/envDynamic.js' import { isBareMode, isEnvTruthy } from './utils/envUtils.js' import { errorMessage } from './utils/errors.js' import { findCanonicalGitRoot, findGitRoot, getIsGit } from './utils/git.js' import { initializeFileChangedWatcher } from './utils/hooks/fileChangedWatcher.js' import { captureHooksConfigSnapshot, updateHooksConfigSnapshot, } from './utils/hooks/hooksConfigSnapshot.js' import { hasWorktreeCreateHook } from './utils/hooks.js' import { checkAndRestoreITerm2Backup } from './utils/iTermBackup.js' import { logError } from './utils/log.js' import { getRecentActivity } from './utils/logoV2Utils.js' import { lockCurrentVersion } from './utils/nativeInstaller/index.js' import type { PermissionMode } from './utils/permissions/PermissionMode.js' import { getPlanSlug } from './utils/plans.js' import { saveWorktreeState } from './utils/sessionStorage.js' import { profileCheckpoint } from './utils/startupProfiler.js' import { createTmuxSessionForWorktree, createWorktreeForSession, generateTmuxSessionName, worktreeBranchName, } from './utils/worktree.js' export async function setup( cwd: string, permissionMode: PermissionMode, allowDangerouslySkipPermissions: boolean, worktreeEnabled: boolean, worktreeName: string | undefined, tmuxEnabled: boolean, customSessionId?: string | null, worktreePRNumber?: number, messagingSocketPath?: string, ): Promise { logForDiagnosticsNoPII('info', 'setup_started') // Check for Node.js version < 18 const nodeVersion = process.version.match(/^v(\d+)\./)?.[1] if (!nodeVersion || parseInt(nodeVersion) < 18) { // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( chalk.bold.red( 'Error: Claude Code requires Node.js version 18 or higher.', ), ) process.exit(1) } // Set custom session ID if provided if (customSessionId) { switchSession(asSessionId(customSessionId)) } // --bare / SIMPLE: skip UDS messaging server and teammate snapshot. // Scripted calls don't receive injected messages and don't use swarm teammates. // Explicit --messaging-socket-path is the escape hatch (per #23222 gate pattern). if (!isBareMode() || messagingSocketPath !== undefined) { // Start UDS messaging server (Mac/Linux only). // Enabled by default for ants — creates a socket in tmpdir if no // --messaging-socket-path is passed. Awaited so the server is bound // and $CLAUDE_CODE_MESSAGING_SOCKET is exported before any hook // (SessionStart in particular) can spawn and snapshot process.env. if (feature('UDS_INBOX')) { const m = await import('./utils/udsMessaging.js') await m.startUdsMessaging( messagingSocketPath ?? m.getDefaultUdsSocketPath(), { isExplicit: messagingSocketPath !== undefined }, ) } } // Teammate snapshot — SIMPLE-only gate (no escape hatch, swarm not used in bare) if (!isBareMode() && isAgentSwarmsEnabled()) { const { captureTeammateModeSnapshot } = await import( './utils/swarm/backends/teammateModeSnapshot.js' ) captureTeammateModeSnapshot() } // Terminal backup restoration — interactive only. Print mode doesn't // interact with terminal settings; the next interactive session will // detect and restore any interrupted setup. if (!getIsNonInteractiveSession()) { // iTerm2 backup check only when swarms enabled if (isAgentSwarmsEnabled()) { const restoredIterm2Backup = await checkAndRestoreITerm2Backup() if (restoredIterm2Backup.status === 'restored') { // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( chalk.yellow( 'Detected an interrupted iTerm2 setup. Your original settings have been restored. You may need to restart iTerm2 for the changes to take effect.', ), ) } else if (restoredIterm2Backup.status === 'failed') { // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( chalk.red( `Failed to restore iTerm2 settings. Please manually restore your original settings with: defaults import com.googlecode.iterm2 ${restoredIterm2Backup.backupPath}.`, ), ) } } // Check and restore Terminal.app backup if setup was interrupted try { const restoredTerminalBackup = await checkAndRestoreTerminalBackup() if (restoredTerminalBackup.status === 'restored') { // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( chalk.yellow( '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.', ), ) } else if (restoredTerminalBackup.status === 'failed') { // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( chalk.red( `Failed to restore Terminal.app settings. Please manually restore your original settings with: defaults import com.apple.Terminal ${restoredTerminalBackup.backupPath}.`, ), ) } } catch (error) { // Log but don't crash if Terminal.app backup restoration fails logError(error) } } // IMPORTANT: setCwd() must be called before any other code that depends on the cwd setCwd(cwd) // Capture hooks configuration snapshot to avoid hidden hook modifications. // IMPORTANT: Must be called AFTER setCwd() so hooks are loaded from the correct directory const hooksStart = Date.now() captureHooksConfigSnapshot() logForDiagnosticsNoPII('info', 'setup_hooks_captured', { duration_ms: Date.now() - hooksStart, }) // Initialize FileChanged hook watcher — sync, reads hook config snapshot initializeFileChangedWatcher(cwd) // Handle worktree creation if requested // IMPORTANT: this must be called befiore getCommands(), otherwise /eject won't be available. if (worktreeEnabled) { // Mirrors bridgeMain.ts: hook-configured sessions can proceed without git // so createWorktreeForSession() can delegate to the hook (non-git VCS). const hasHook = hasWorktreeCreateHook() const inGit = await getIsGit() if (!hasHook && !inGit) { process.stderr.write( chalk.red( `Error: Can only use --worktree in a git repository, but ${chalk.bold(cwd)} is not a git repository. ` + `Configure a WorktreeCreate hook in settings.json to use --worktree with other VCS systems.\n`, ), ) process.exit(1) } const slug = worktreePRNumber ? `pr-${worktreePRNumber}` : (worktreeName ?? getPlanSlug()) // Git preamble runs whenever we're in a git repo — even if a hook is // configured — so --tmux keeps working for git users who also have a // WorktreeCreate hook. Only hook-only (non-git) mode skips it. let tmuxSessionName: string | undefined if (inGit) { // Resolve to main repo root (handles being invoked from within a worktree). // findCanonicalGitRoot is sync/filesystem-only/memoized; the underlying // findGitRoot cache was already warmed by getIsGit() above, so this is ~free. const mainRepoRoot = findCanonicalGitRoot(getCwd()) if (!mainRepoRoot) { process.stderr.write( chalk.red( `Error: Could not determine the main git repository root.\n`, ), ) process.exit(1) } // If we're inside a worktree, switch to the main repo for worktree creation if (mainRepoRoot !== (findGitRoot(getCwd()) ?? getCwd())) { logForDiagnosticsNoPII('info', 'worktree_resolved_to_main_repo') process.chdir(mainRepoRoot) setCwd(mainRepoRoot) } tmuxSessionName = tmuxEnabled ? generateTmuxSessionName(mainRepoRoot, worktreeBranchName(slug)) : undefined } else { // Non-git hook mode: no canonical root to resolve, so name the tmux // session from cwd — generateTmuxSessionName only basenames the path. tmuxSessionName = tmuxEnabled ? generateTmuxSessionName(getCwd(), worktreeBranchName(slug)) : undefined } let worktreeSession: Awaited> try { worktreeSession = await createWorktreeForSession( getSessionId(), slug, tmuxSessionName, worktreePRNumber ? { prNumber: worktreePRNumber } : undefined, ) } catch (error) { process.stderr.write( chalk.red(`Error creating worktree: ${errorMessage(error)}\n`), ) process.exit(1) } logEvent('tengu_worktree_created', { tmux_enabled: tmuxEnabled }) // Create tmux session for the worktree if enabled if (tmuxEnabled && tmuxSessionName) { const tmuxResult = await createTmuxSessionForWorktree( tmuxSessionName, worktreeSession.worktreePath, ) if (tmuxResult.created) { // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( chalk.green( `Created tmux session: ${chalk.bold(tmuxSessionName)}\nTo attach: ${chalk.bold(`tmux attach -t ${tmuxSessionName}`)}`, ), ) } else { // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( chalk.yellow( `Warning: Failed to create tmux session: ${tmuxResult.error}`, ), ) } } process.chdir(worktreeSession.worktreePath) setCwd(worktreeSession.worktreePath) setOriginalCwd(getCwd()) // --worktree means the worktree IS the session's project, so skills/hooks/ // cron/etc. should resolve here. (EnterWorktreeTool mid-session does NOT // touch projectRoot — that's a throwaway worktree, project stays stable.) setProjectRoot(getCwd()) saveWorktreeState(worktreeSession) // Clear memory files cache since originalCwd has changed clearMemoryFileCaches() // Settings cache was populated in init() (via applySafeConfigEnvironmentVariables) // and again at captureHooksConfigSnapshot() above, both from the original dir's // .claude/settings.json. Re-read from the worktree and re-capture hooks. updateHooksConfigSnapshot() } // Background jobs - only critical registrations that must happen before first query logForDiagnosticsNoPII('info', 'setup_background_jobs_starting') // Bundled skills/plugins are registered in main.tsx before the parallel // getCommands() kick — see comment there. Moved out of setup() because // the await points above (startUdsMessaging, ~20ms) meant getCommands() // raced ahead and memoized an empty bundledSkills list. if (!isBareMode()) { initSessionMemory() // Synchronous - registers hook, gate check happens lazily if (feature('CONTEXT_COLLAPSE')) { /* eslint-disable @typescript-eslint/no-require-imports */ ;( require('./services/contextCollapse/index.js') as typeof import('./services/contextCollapse/index.js') ).initContextCollapse() /* eslint-enable @typescript-eslint/no-require-imports */ } } void lockCurrentVersion() // Lock current version to prevent deletion by other processes logForDiagnosticsNoPII('info', 'setup_background_jobs_launched') profileCheckpoint('setup_before_prefetch') // Pre-fetch promises - only items needed before render logForDiagnosticsNoPII('info', 'setup_prefetch_starting') // When CLAUDE_CODE_SYNC_PLUGIN_INSTALL is set, skip all plugin prefetch. // The sync install path in print.ts calls refreshPluginState() after // installing, which reloads commands, hooks, and agents. Prefetching here // races with the install (concurrent copyPluginToVersionedCache / cachePlugin // on the same directories), and the hot-reload handler fires clearPluginCache() // mid-install when policySettings arrives. const skipPluginPrefetch = (getIsNonInteractiveSession() && isEnvTruthy(process.env.CLAUDE_CODE_SYNC_PLUGIN_INSTALL)) || // --bare: loadPluginHooks → loadAllPlugins is filesystem work that's // wasted when executeHooks early-returns under --bare anyway. isBareMode() if (!skipPluginPrefetch) { void getCommands(getProjectRoot()) } void import('./utils/plugins/loadPluginHooks.js').then(m => { if (!skipPluginPrefetch) { void m.loadPluginHooks() // Pre-load plugin hooks (consumed by processSessionStartHooks before render) m.setupPluginHookHotReload() // Set up hot reload for plugin hooks when settings change } }) // --bare: skip attribution hook install + repo classification + // session-file-access analytics + team memory watcher. These are background // bookkeeping for commit attribution + usage metrics — scripted calls don't // commit code, and the 49ms attribution hook stat check (measured) is pure // overhead. NOT an early-return: the --dangerously-skip-permissions safety // gate, tengu_started beacon, and apiKeyHelper prefetch below must still run. if (!isBareMode()) { if (process.env.USER_TYPE === 'ant') { // Prime repo classification cache for auto-undercover mode. Default is // undercover ON until proven internal; if this resolves to internal, clear // the prompt cache so the next turn picks up the OFF state. void import('./utils/commitAttribution.js').then(async m => { if (await m.isInternalModelRepo()) { const { clearSystemPromptSections } = await import( './constants/systemPromptSections.js' ) clearSystemPromptSections() } }) } if (feature('COMMIT_ATTRIBUTION')) { // Dynamic import to enable dead code elimination (module contains excluded strings). // Defer to next tick so the git subprocess spawn runs after first render // rather than during the setup() microtask window. setImmediate(() => { void import('./utils/attributionHooks.js').then( ({ registerAttributionHooks }) => { registerAttributionHooks() // Register attribution tracking hooks (ant-only feature) }, ) }) } void import('./utils/sessionFileAccessHooks.js').then(m => m.registerSessionFileAccessHooks(), ) // Register session file access analytics hooks if (feature('TEAMMEM')) { void import('./services/teamMemorySync/watcher.js').then(m => m.startTeamMemoryWatcher(), ) // Start team memory sync watcher } } initSinks() // Attach error log + analytics sinks and drain queued events // Session-success-rate denominator. Emit immediately after the analytics // sink is attached — before any parsing, fetching, or I/O that could throw. // inc-3694 (P0 CHANGELOG crash) threw at checkForReleaseNotes below; every // event after this point was dead. This beacon is the earliest reliable // "process started" signal for release health monitoring. logEvent('tengu_started', {}) void prefetchApiKeyFromApiKeyHelperIfSafe(getIsNonInteractiveSession()) // Prefetch safely - only executes if trust already confirmed profileCheckpoint('setup_after_prefetch') // Pre-fetch data for Logo v2 - await to ensure it's ready before logo renders. // --bare / SIMPLE: skip — release notes are interactive-UI display data, // and getRecentActivity() reads up to 10 session JSONL files. if (!isBareMode()) { const { hasReleaseNotes } = await checkForReleaseNotes( getGlobalConfig().lastReleaseNotesSeen, ) if (hasReleaseNotes) { await getRecentActivity() } } // If permission mode is set to bypass, verify we're in a safe environment if ( permissionMode === 'bypassPermissions' || allowDangerouslySkipPermissions ) { // Check if running as root/sudo on Unix-like systems // Allow root if in a sandbox (e.g., TPU devspaces that require root) if ( process.platform !== 'win32' && typeof process.getuid === 'function' && process.getuid() === 0 && process.env.IS_SANDBOX !== '1' && !isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP) ) { // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`, ) process.exit(1) } if ( process.env.USER_TYPE === 'ant' && // Skip for Desktop's local agent mode — same trust model as CCR/BYOC // (trusted Anthropic-managed launcher intentionally pre-approving everything). // Precedent: permissionSetup.ts:861, applySettingsChange.ts:55 (PR #19116) process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent' && // Same for CCD (Claude Code in Desktop) — apps#29127 passes the flag // unconditionally to unlock mid-session bypass switching process.env.CLAUDE_CODE_ENTRYPOINT !== 'claude-desktop' ) { // Only await if permission mode is set to bypass const [isDocker, hasInternet] = await Promise.all([ envDynamic.getIsDocker(), env.hasInternetAccess(), ]) const isBubblewrap = envDynamic.getIsBubblewrapSandbox() const isSandbox = process.env.IS_SANDBOX === '1' const isSandboxed = isDocker || isBubblewrap || isSandbox if (!isSandboxed || hasInternet) { // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `--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}`, ) process.exit(1) } } } if (process.env.NODE_ENV === 'test') { return } // Log tengu_exit event from the last session? const projectConfig = getCurrentProjectConfig() if ( projectConfig.lastCost !== undefined && projectConfig.lastDuration !== undefined ) { logEvent('tengu_exit', { last_session_cost: projectConfig.lastCost, last_session_api_duration: projectConfig.lastAPIDuration, last_session_tool_duration: projectConfig.lastToolDuration, last_session_duration: projectConfig.lastDuration, last_session_lines_added: projectConfig.lastLinesAdded, last_session_lines_removed: projectConfig.lastLinesRemoved, last_session_total_input_tokens: projectConfig.lastTotalInputTokens, last_session_total_output_tokens: projectConfig.lastTotalOutputTokens, last_session_total_cache_creation_input_tokens: projectConfig.lastTotalCacheCreationInputTokens, last_session_total_cache_read_input_tokens: projectConfig.lastTotalCacheReadInputTokens, last_session_fps_average: projectConfig.lastFpsAverage, last_session_fps_low_1_pct: projectConfig.lastFpsLow1Pct, last_session_id: projectConfig.lastSessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...projectConfig.lastSessionMetrics, }) // Note: We intentionally don't clear these values after logging. // They're needed for cost restoration when resuming sessions. // The values will be overwritten when the next session exits. } }