Claude Code 启动序列
从输入 claude 到看到交互式 REPL 提示符,中间到底经历了哪些启动阶段、并行预取与状态装配。
当你在终端里敲下 claude 时,CLI 并不是立刻把提示符画出来。它会先经过一条多阶段启动链:入口脚本处理快速路径,主函数装配运行环境,setup() 初始化会话状态,最后才把 Ink 驱动的 REPL 渲染出来。
entrypoints/cli.tsx → main.tsx →
setup.ts → bootstrap/state.ts →
replLauncher.tsx → ink.ts
理解这条链路的价值有三个:你能解释首屏延迟来自哪里、能更快定位启动期异常,也能看懂为什么源码里要把某些 I/O 提前并行化,而不是等到真正需要时再顺序执行。
CLI 入口点
cli.tsx — 零成本快速路径、环境准备、argv 调度
主要功能
main.tsx — Commander 解析、初始化、迁移、权限检查
设置 + REPL
setup.ts + replLauncher.tsx — 会话连接、墨水渲染
下图跟踪了从进程进入到第一次渲染的确切调用顺序:
第 1 阶段 — CLI 入口点 (cli.tsx)
cli.tsx 的目标不是“做很多事”,而是“尽量少做事”。它先处理那些无需完整启动链的快速路径,再把剩余情况转交给主模块图,这也是为什么 claude --version 能几乎瞬间返回。
这层最重要的工程思想是:把高频、低复杂度路径从重型依赖树里剥离出去。不是所有命令都值得为了一次简单输出把整套 REPL 和会话系统拉起来。
// 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') 调用是构建时标志(Bun 死代码消除)。 特点如 BRIDGE_MODE, DAEMON, SSH_REMOTE 可以完全从外部构建中剥离。 CLI 调度表是开闭式的:此处添加新的快速路径,无需触及 main.tsx.
装载前 main.tsx, cli.tsx 还可以处理环境突变 must 发生在进程启动时,在任何模块评估之前:COREPACK 固定被禁用,并且 CCR 容器通过以下方式获得 8 GB 堆上限
NODE_OPTIONS.
第 2 阶段 — 并行预取副作用(main.tsx top-level)
main.tsx 文件顶部那些副作用调用,核心价值并不是“早一点开始”,而是“与模块加载并行”。在主模块树还在求值时,MDM 读取和钥匙串预取已经在后台跑了。
这类并行化对启动体验的影响很大,因为首屏延迟往往不是某一个调用慢,而是一串调用排队串行。把 I/O 和模块求值重叠起来,本质上是在缩短关键路径。
// 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)
当大约 135 毫秒的静态导入完成加载时,MDM 策略和钥匙串读取已经开始运行。 这种并行性大大缩短了首次验证流程的时间。
深入探讨 — 为什么解雇 MDM 这么早阅读?
macOS 上的 MDM(移动设备管理)将企业策略存储在
defaults 领域。 阅读它需要产卵 plutil or
reg query 于Windows。 这些子进程每个大约需要 20–40 毫秒。
applySafeConfigEnvironmentVariables() (内部调用 init())需要先加载 MDM 策略,然后才能应用任何托管设置。 通过射击 startMdmRawRead() 在模块评估时,子进程与剩余的导入链同时运行,所以到时候 init()
calls ensureMdmSettingsLoaded(),结果已经在缓存中。
相似地, startKeychainPrefetch() 为 OAuth 令牌和旧版 API 密钥触发两个异步 macOS 钥匙串读取。 如果没有这个,读取将在内部顺序发生 applySafeConfigEnvironmentVariables() 通过同步生成 — 每次 macOS 启动时测量约 65 毫秒。
第 3 阶段 — Commander 参数解析
模块加载完成后,Claude Code 才真正进入参数解析阶段。这里不只是把 flag 读出来,而是在决定会话如何启动:工作目录是什么、权限模式是什么、是否恢复旧会话、要不要连接 MCP、是否走 print / bare 路径。
也正因为这里是启动分叉点,迁移、设置预热和 Commander 解析会被紧密排在一起。只要这一步定下来,后续 setup 就能按一个明确的运行模式来组装整条链路。
// 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。 如果您降级 Claude Code,迁移版本已经是高级的,迁移将不会重新运行 — 这可能会导致细微的配置不一致。
第四阶段—— setup() in setup.ts
setup() 是启动链里真正“把会话接上线”的地方。它不只是做环境检查,而是在确定 cwd、启动 UDS、捕获 Hook 配置、初始化会话记忆、预取命令和插件,并最终把系统推进到可渲染状态。
这一步顺序要求极强。比如先 setCwd() 再抓 Hook,先附加 sinks 再打 tengu_started,都不是风格问题,而是为了保证安全性、统计口径和会话一致性。
- Node.js 版本门(需要≥18)
- 可选的自定义会话 ID 通过
switchSession() - UDS (Unix Domain Socket) 消息传递服务器启动 — 因此挂钩进程可以找到套接字
- 队友/群体快照(仅限非裸模式)
- iTerm2 和 Terminal.app 备份恢复中断的设置
setCwd(cwd)— 必须发生在读取 cwd 的任何内容之前- Hooks 配置快照 — 读取
.claude/settings.json来自新的cwd - FileChanged 钩子观察器初始化
- 可选的工作树创建 + tmux 会话
- 后台工作:
initSessionMemory(),getCommands(), 插件挂钩 initSinks()— 附加分析+错误接收器,排出排队事件logEvent('tengu_started')— 第一个可靠的“进程启动”信标- API 密钥预取(仅限安全路径)
- 发行说明检查+最近活动获取
- 权限安全检查(root/sudo Guard、Docker 沙箱门)
- 记录的上一个会话退出指标
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()
深潜—— tengu_started beacon
源代码中的评论解释了为什么这个事件被精确地放置在它所在的位置:
“会话成功率分母。在附加分析接收器后立即发出 - 在任何可能抛出的解析、获取或 I/O 之前发出。......这个信标是用于发布运行状况监控的最早可靠的“进程启动”信号。”
该评论引用了一个特定事件(inc-3694)发生崩溃的地方
checkForReleaseNotes() 指的是它死后发生的所有事件。 信标放置确保即使下游代码抛出异常也能记录分母。
深潜——裸模式(--bare / CLAUDE_CODE_SIMPLE)
裸模式的本质不是“功能删减版”,而是“最短执行路径”。凡是只服务于交互体验、首屏渲染或后台辅助的工作,都会被系统性跳过。
因此它特别适合 SDK 或 CI 场景:不需要 React、不需要 UDS、不需要插件预取,只想尽快把一轮请求跑完。源码里大量的 !isBareMode() 判断,都是围绕这个目标在切分关键路径。
深入探讨 — Worktree + tmux 创建
When --worktree 已通过, setup() 在其他任何内容接触文件系统之前,为会话创建一个 git 工作树。 顺序很重要:
- 解析规范的 git root(从现有工作树内部调用的句柄)
- 生成一个 slug
getPlanSlug()或公关号码 - Call
createWorktreeForSession()— 如果已配置,则委托给 WorktreeCreate 挂钩 - (可选)创建指向工作树路径的 tmux 会话
- Call
setCwd(worktreePath)andsetProjectRoot() - Call
clearMemoryFileCaches()自从cwd改变之后 - 从工作树重新捕获钩子配置
.claude/settings.json
The setProjectRoot() 这里的调用很重要:它将项目身份(会话历史记录、技能、CLAUDE.md)在会话期间固定到工作树根,而不是原始的存储库根。
阶段 5 — 全局状态 (bootstrap/state.ts)
state.ts 是所有会话范围内的全局状态的单一事实来源。 楼上的评论说的很清楚了: “不要在这里添加更多状态——对全局状态要谨慎。”
跟踪的状态包括:
会话和路径
sessionId, originalCwd, projectRoot, cwd
使用情况追踪
totalCostUSD, modelUsage、令牌计数器、FPS 指标
会话模式
isInteractive, sessionBypassPermissionsMode, isRemoteMode
OTel 提供商
meter, loggerProvider, tracerProvider
提示缓存
promptCache1hEligible, afkModeHeaderLatched, fastModeHeaderLatched
运行时挂钩
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) 专门用于保留 Anthropic API 提示缓存标头 stable 整个会议期间。 一旦标头模式被激活,即使用户在会话中切换设置,它也会保持激活状态——更改标头会破坏昂贵的服务器端缓存。
阶段 6 — 墨水渲染 (replLauncher.tsx + ink.ts)
最后一步是渲染基于 React 的 TUI。 launchRepl() 动态导入 App and REPL 组件(避免循环导入)然后调用 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 包装每个渲染调用 <ThemeProvider>
自动地,所以 ThemedBox and ThemedText 组件无需每个调用站点都安装主题上下文即可工作:
// 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() 触发第一次渲染不需要的后台工作: initUser(),
getUserContext()、 MCP URL 预取、模型功能刷新和文件更改检测器初始化。 这项工作在用户输入第一条消息时运行——被人类反应时间窗口隐藏。
本课要记住什么
- 启动顺序是 三个嵌套层:CLI入口点→主函数→设置+REPL渲染。
- 快速路径
cli.tsx加载前退出 any 重型模块。claude --version从不碰触main.tsx. - MDM 和钥匙串读取在模块评估时触发 与约 135 毫秒的导入链并行——这是一个关键的启动延迟优化。
setCwd()必须先来captureHooksConfigSnapshot()。 顺序是通过代码中的注释强制执行的,违反它会产生错误的钩子配置。- 只是时尚 (
--bare) 删除脚本化/SDK 用例的每个非必要的启动步骤。 了解跳过的内容可以解释为什么裸模式更快。 bootstrap/state.ts是全球状态分类账。 提示缓存锁存字段可确保 API 标头在会话中切换期间保持稳定,以保护服务器端缓存。- The
tengu_started事件是 最早可靠的信标; 之后的一切initSinks()计入会话成功率。 - 延迟预取运行 第一次渲染后,隐藏在人类打字窗口中——围绕感知延迟而设计的架构,而不仅仅是原始延迟。
知识检查
cli.tsx 开头那些 fast-path 检查最核心的目的是什么?--version 这类路径完全没必要把主模块树都拉起来。fast-path 的意义就是把这些命令从重型启动链里摘出去。startMdmRawRead() 和 startKeychainPrefetch() 会在 main.tsx 顶层就触发?captureHooksConfigSnapshot() 必须在 setCwd() 之后调用?--bare / CLAUDE_CODE_SIMPLE)主要跳过的是什么?bootstrap/state.ts 里那些 header latched 字段存在的主要意义是什么?