/ setup.ts
setup.ts
  1  /* eslint-disable custom-rules/no-process-exit */
  2  
  3  import { feature } from 'bun:bundle'
  4  import chalk from 'chalk'
  5  import {
  6    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  7    logEvent,
  8  } from 'src/services/analytics/index.js'
  9  import { getCwd } from 'src/utils/cwd.js'
 10  import { checkForReleaseNotes } from 'src/utils/releaseNotes.js'
 11  import { setCwd } from 'src/utils/Shell.js'
 12  import { initSinks } from 'src/utils/sinks.js'
 13  import {
 14    getIsNonInteractiveSession,
 15    getProjectRoot,
 16    getSessionId,
 17    setOriginalCwd,
 18    setProjectRoot,
 19    switchSession,
 20  } from './bootstrap/state.js'
 21  import { getCommands } from './commands.js'
 22  import { initSessionMemory } from './services/SessionMemory/sessionMemory.js'
 23  import { asSessionId } from './types/ids.js'
 24  import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js'
 25  import { checkAndRestoreTerminalBackup } from './utils/appleTerminalBackup.js'
 26  import { prefetchApiKeyFromApiKeyHelperIfSafe } from './utils/auth.js'
 27  import { clearMemoryFileCaches } from './utils/claudemd.js'
 28  import { getCurrentProjectConfig, getGlobalConfig } from './utils/config.js'
 29  import { logForDiagnosticsNoPII } from './utils/diagLogs.js'
 30  import { env } from './utils/env.js'
 31  import { envDynamic } from './utils/envDynamic.js'
 32  import { isBareMode, isEnvTruthy } from './utils/envUtils.js'
 33  import { errorMessage } from './utils/errors.js'
 34  import { findCanonicalGitRoot, findGitRoot, getIsGit } from './utils/git.js'
 35  import { initializeFileChangedWatcher } from './utils/hooks/fileChangedWatcher.js'
 36  import {
 37    captureHooksConfigSnapshot,
 38    updateHooksConfigSnapshot,
 39  } from './utils/hooks/hooksConfigSnapshot.js'
 40  import { hasWorktreeCreateHook } from './utils/hooks.js'
 41  import { checkAndRestoreITerm2Backup } from './utils/iTermBackup.js'
 42  import { logError } from './utils/log.js'
 43  import { getRecentActivity } from './utils/logoV2Utils.js'
 44  import { lockCurrentVersion } from './utils/nativeInstaller/index.js'
 45  import type { PermissionMode } from './utils/permissions/PermissionMode.js'
 46  import { getPlanSlug } from './utils/plans.js'
 47  import { saveWorktreeState } from './utils/sessionStorage.js'
 48  import { profileCheckpoint } from './utils/startupProfiler.js'
 49  import {
 50    createTmuxSessionForWorktree,
 51    createWorktreeForSession,
 52    generateTmuxSessionName,
 53    worktreeBranchName,
 54  } from './utils/worktree.js'
 55  
 56  export 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  }