When you type claude in your terminal, a sophisticated multi-phase boot
pipeline runs before you see the first prompt. Understanding this pipeline helps you:
reason about startup latency, debug weird first-launch behaviors, and appreciate the
careful parallelism engineers built in to keep time-to-interactive low.
entrypoints/cli.tsx → main.tsx →
setup.ts → bootstrap/state.ts →
replLauncher.tsx → ink.ts
At the highest level, boot happens in three nested layers:
CLI Entrypoint
cli.tsx — zero-cost fast paths, environment prep, argv dispatch
Main Function
main.tsx — Commander parsing, init, migrations, permission checks
Setup + REPL
setup.ts + replLauncher.tsx — session wiring, Ink render
The diagram below traces the exact call sequence from process entry to first render:
Phase 1 — CLI Entrypoint (cli.tsx)
cli.tsx is a deliberate thin bootstrap. All imports are dynamic
so the "zero module" fast paths (--version, --daemon-worker,
--claude-in-chrome-mcp) return without loading any of the heavy CLI
surface. This is a significant UX win — claude --version returns in
milliseconds.
// cli.tsx — fast-path: --version needs zero imports
const args = process.argv.slice(2)
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
console.log(`${MACRO.VERSION} (Claude Code)`)
return
}
// For all other paths, load the startup profiler first
const { profileCheckpoint } = await import('../utils/startupProfiler.js')
profileCheckpoint('cli_entry')
feature('X') calls are build-time flags (Bun dead-code elimination).
Features like BRIDGE_MODE, DAEMON, SSH_REMOTE can be
stripped from external builds entirely. The CLI dispatch table is open-closed:
new fast-paths are added here without touching main.tsx.
Before loading main.tsx, cli.tsx also handles
environment mutations that must happen at process start, before any module
evaluates: COREPACK pinning is disabled, and CCR containers get an 8 GB heap cap via
NODE_OPTIONS.
Phase 2 — Parallel prefetch side-effects (main.tsx top-level)
The very top of main.tsx has three side-effects that execute before
any other imports are evaluated — marked with an ESLint disable comment to make the
intent explicit:
// These side-effects must run before all other imports:
profileCheckpoint('main_tsx_entry') // timestamp: module eval started
startMdmRawRead() // fires plutil/reg query subprocesses in parallel
startKeychainPrefetch() // starts macOS keychain reads (OAuth + API key)
By the time the ~135ms of static imports finish loading, MDM policy and keychain reads are already in flight. This parallelism shaves significant time from first-auth flows.
Deep dive — Why fire MDM reads this early?
MDM (Mobile Device Management) on macOS stores enterprise policy in the
defaults domain. Reading it requires spawning plutil or
reg query on Windows. These subprocesses take ~20–40ms each.
applySafeConfigEnvironmentVariables() (called inside init())
needs MDM policy to be loaded before it can apply any managed settings.
By firing startMdmRawRead() at module eval time, the subprocess runs
concurrently with the remaining import chain, so by the time init()
calls ensureMdmSettingsLoaded(), the result is already in cache.
Similarly, startKeychainPrefetch() fires two async macOS keychain
reads for OAuth token and legacy API key. Without this, the reads would happen
sequentially inside applySafeConfigEnvironmentVariables() via sync
spawn — measured at ~65ms on every macOS startup.
Phase 3 — Commander argument parsing
After imports load, main() calls eagerLoadSettings() to handle
--settings and --setting-sources flags before Commander even runs,
then invokes Commander's .parse(). Commander resolves and validates:
cwd, permissionMode, --print/-p mode,
--model, --resume, --session, MCP server configs,
and many more flags.
// main.tsx — runs migrations once per config version bump
const CURRENT_MIGRATION_VERSION = 11
function runMigrations(): void {
if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) {
migrateAutoUpdatesToSettings()
migrateSonnet45ToSonnet46() // example: model string upgrades
migrateOpusToOpus1m()
// ...8 more migration functions...
saveGlobalConfig(prev => ({ ...prev, migrationVersion: CURRENT_MIGRATION_VERSION }))
}
}
migrationVersion. If you downgrade Claude Code, the migration version
is already advanced and migrations won't re-run — which can cause subtle config
inconsistencies.
Phase 4 — setup() in setup.ts
setup() is where the session is actually wired up. It receives the parsed
arguments and performs checks in a carefully ordered sequence:
- Node.js version gate (≥18 required)
- Optional custom session ID via
switchSession() - UDS (Unix Domain Socket) messaging server startup — so hook processes can find the socket
- Teammate/swarm snapshot (non-bare mode only)
- iTerm2 and Terminal.app backup restoration for interrupted setups
setCwd(cwd)— must happen before anything that reads cwd- Hooks config snapshot — reads
.claude/settings.jsonfrom the new cwd - FileChanged hook watcher initialization
- Optional worktree creation + tmux session
- Background jobs:
initSessionMemory(),getCommands(), plugin hooks initSinks()— attaches analytics + error sinks, drains queued eventslogEvent('tengu_started')— the first reliable "process started" beacon- API key prefetch (safe path only)
- Release notes check + recent activity fetch
- Permission safety checks (root/sudo guard, Docker sandbox gate)
- Previous session exit metrics logged from
projectConfig
// setup.ts — setCwd ordering comment (verbatim from source)
// IMPORTANT: setCwd() must be called before any other code that depends on the cwd
setCwd(cwd)
// IMPORTANT: Must be called AFTER setCwd() so hooks are loaded from the correct directory
captureHooksConfigSnapshot()
Deep dive — The tengu_started beacon
The comment in the source explains why this event is placed precisely where it is:
"Session-success-rate denominator. Emit immediately after the analytics sink is attached — before any parsing, fetching, or I/O that could throw. … This beacon is the earliest reliable 'process started' signal for release health monitoring."
The comment references a specific incident (inc-3694) where a crash in
checkForReleaseNotes() meant every event after it was dead. The beacon
placement ensures the denominator is recorded even when downstream code throws.
Deep dive — Bare mode (--bare / CLAUDE_CODE_SIMPLE)
Several steps are guarded by !isBareMode(). Bare mode is used for
scripted/SDK calls (claude -p "..." style). In bare mode, the following
are skipped:
- UDS messaging server (no hook injection)
- Teammate snapshot (swarm not used)
- Session memory initialization
- Plugin hook pre-loading
- Attribution hooks + repo classification
- All deferred prefetches (
startDeferredPrefetches())
The design principle: bare mode is latency-sensitive. Every millisecond saved here matters when you're calling Claude from a CI pipeline hundreds of times a day.
Deep dive — Worktree + tmux creation
When --worktree is passed, setup() creates a git worktree
for the session before anything else touches the filesystem. The order matters:
- Resolve canonical git root (handles being invoked from inside an existing worktree)
- Generate a slug from
getPlanSlug()or PR number - Call
createWorktreeForSession()— delegates to WorktreeCreate hook if configured - Optionally create a tmux session pointing at the worktree path
- Call
setCwd(worktreePath)andsetProjectRoot() - Call
clearMemoryFileCaches()since cwd changed - Re-capture hooks config from the worktree's
.claude/settings.json
The setProjectRoot() call here is important: it fixes the project identity
(session history, skills, CLAUDE.md) to the worktree root for the duration of the
session, rather than the original repo root.
Phase 5 — Global State (bootstrap/state.ts)
state.ts is the single source of truth for all session-scoped global state.
The comment at the top says it plainly: "DO NOT ADD MORE STATE HERE — BE JUDICIOUS
WITH GLOBAL STATE."
State tracked includes:
Session & Paths
sessionId, originalCwd, projectRoot, cwd
Usage Tracking
totalCostUSD, modelUsage, token counters, FPS metrics
Session Mode
isInteractive, sessionBypassPermissionsMode, isRemoteMode
OTel Providers
meter, loggerProvider, tracerProvider
Prompt Cache
promptCache1hEligible, afkModeHeaderLatched, fastModeHeaderLatched
Runtime Hooks
registeredHooks, invokedSkills, sessionCronTasks
// bootstrap/state.ts — initial state factory (simplified excerpt)
function getInitialState(): State {
let resolvedCwd = ''
try {
// Resolve symlinks so session storage paths are consistent
resolvedCwd = realpathSync(cwd())
} catch { resolvedCwd = cwd() }
return {
originalCwd: resolvedCwd,
projectRoot: resolvedCwd,
sessionId: asSessionId(randomUUID()),
isInteractive: true,
totalCostUSD: 0,
// ... ~60 more fields
}
}
afkModeHeaderLatched,
fastModeHeaderLatched, thinkingClearLatched) exist
specifically to keep Anthropic API prompt cache headers stable throughout a
session. Once a header mode is activated, it stays activated even if the user toggles
settings mid-session — changing the header would bust the expensive server-side cache.
Phase 6 — Ink render (replLauncher.tsx + ink.ts)
The final step is rendering the React-based TUI. launchRepl() dynamically
imports App and REPL components (avoiding circular imports)
then calls renderAndRun():
// replLauncher.tsx
export async function launchRepl(
root: Root,
appProps: AppWrapperProps,
replProps: REPLProps,
renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>,
): Promise<void> {
const { App } = await import('./components/App.js')
const { REPL } = await import('./screens/REPL.js')
await renderAndRun(root, <App {...appProps}><REPL {...replProps} /></App>)
}
ink.ts wraps every render call with <ThemeProvider>
automatically, so ThemedBox and ThemedText components work
without each call site having to mount the theme context:
// ink.ts — wraps every render with ThemeProvider
function withTheme(node: ReactNode): ReactNode {
return createElement(ThemeProvider, null, node)
}
export async function render(node, options) {
return inkRender(withTheme(node), options)
}
startDeferredPrefetches() fires background
work that's NOT needed for the first render: initUser(),
getUserContext(), MCP URL prefetch, model capability refresh, and file
change detector initialization. This work runs while the user is typing their first
message — hidden by the human reaction time window.
What to remember from this lesson
- The boot sequence is three nested layers: CLI entrypoint → main function → setup + REPL render.
- Fast paths in
cli.tsxexit before loading any heavy modules.claude --versionnever touchesmain.tsx. - MDM and keychain reads are fired at module-eval time to parallelize with the ~135ms import chain — a key startup latency optimization.
setCwd()must come beforecaptureHooksConfigSnapshot(). The ordering is enforced by comments in the code and violating it produces wrong hook configs.- Bare mode (
--bare) strips every non-essential startup step for scripted/SDK use-cases. Understanding what's skipped explains why bare mode is faster. bootstrap/state.tsis the global state ledger. Prompt cache latch fields ensure API headers stay stable across mid-session toggles to protect the server-side cache.- The
tengu_startedevent is the earliest reliable beacon; everything afterinitSinks()counts toward session success rate. - Deferred prefetches run after first render, hidden in the human typing window — architecture designed around perceived latency, not just raw latency.
Quiz — 5 Questions
cli.tsx?cli.tsx use dynamic imports so subcommands
like --version, --daemon-worker, and remote-control
exit without ever importing the heavy main.tsx module graph.
startMdmRawRead() and startKeychainPrefetch() called as top-level side effects at the very start of main.tsx, before other imports?plutil) and keychain reads take
20–65ms. By launching them during module evaluation they run concurrently with the import
chain and are already resolved when init() needs them. Sequential reads would add
that latency to the critical path.
setup.ts, why must setCwd(cwd) be called before captureHooksConfigSnapshot()?setCwd()
so hooks are loaded from the correct directory." captureHooksConfigSnapshot() reads
the project's settings file to snapshot which hooks are configured — if cwd is still the shell's
working directory rather than the intended project root, the wrong hooks get loaded.
--bare / CLAUDE_CODE_SIMPLE) skip during boot?afkModeHeaderLatched and fastModeHeaderLatched exist in bootstrap/state.ts?state.ts explain these are
"sticky-on latches." Once AFK mode, fast mode, or cache-editing mode is first activated,
the corresponding API header stays on for the rest of the session. If the header toggled
with each GrowthBook/settings change, the server's cached prompt prefix would be busted,
causing expensive cache misses on the Anthropic side (~50–70K tokens re-processed).