# 启动序列：从敲下 `claude` 到系统就绪

从用户敲下 `claude` 到系统准备好接受输入，经历了三个阶段——Pre-import 预加载、初始化连接、UI 渲染。本章解析启动序列的实现细节，以及预连接、延迟加载等优化技巧的权衡。

> **🌍 行业背景：CLI 启动序列是标准工程实践**
>
> 几乎所有 CLI 工具都需要解决"启动时做什么"的问题，区别在于复杂度和优化程度：
>
> - **Aider**（Python CLI AI 编程助手）：启动流程相对简单——解析参数、加载配置、建立 API 连接。Python 的模块加载本身较慢，但 Aider 的依赖树远小于 Claude Code，启动开销主要在 Python 解释器本身。
> - **Cursor**（VS Code 扩展）：作为编辑器扩展，它的"启动"是扩展激活（`activate()`），而非独立进程启动。VS Code 的扩展宿主管理生命周期，和 CLI 冷启动是完全不同的问题域。
> - **gh CLI**（GitHub 官方 CLI）：Go 编译的单二进制文件，启动几乎是瞬间的——没有模块加载问题，Go 的静态链接天然消除了这一开销。
> - **Vercel CLI**：同为 Node.js CLI，也面临模块加载慢的问题，通过 `pkg` 打包和懒加载子命令来缓解。
>
> Claude Code 的特殊之处在于：它是一个 **Node.js/Bun 运行时的重量级 CLI**（1,884 个文件的依赖树），同时需要在启动阶段完成凭据读取、API 预连接等 I/O 密集操作。因此它在 Pre-import 阶段做 I/O 并行——这不是什么独创技巧，但在 JS/TS CLI 工具中确实不常见，因为大多数 CLI 工具没有这么重的模块加载负担。

---

## 引子：电脑开机的三个阶段

你按下电脑的电源键后，发生了三件事：
1. **BIOS/UEFI**（固件）醒来——做最基本的硬件检测，找到操作系统在哪块硬盘上
2. **Bootloader**（引导程序）加载——把操作系统内核从硬盘读到内存
3. **Kernel Init**（内核初始化）——启动所有子系统，最后显示登录界面

Claude Code 的启动也分三个阶段：
1. **Pre-import 阶段**——在大量模块加载之前，穿插启动关键的 I/O 操作
2. **初始化阶段**——加载配置、连接 API、启动子系统
3. **就绪阶段**——渲染 UI，等待用户输入；后台继续预取

> 📚 **课程桥接：** 启动三阶段 = 操作系统的 BIOS → Bootloader → Kernel 启动序列。Pre-import 对应 BIOS 固件层（在主系统加载前做最基本的硬件探测），init() 对应 Bootloader（把所有子系统加载到位），launchRepl() 对应 Kernel 完成初始化后显示登录界面。
>
> **🔑 OS 类比：** 就像开机三步：按电源键检测硬件（main.tsx）→ 加载操作系统（init.ts）→ 显示桌面等你操作（launchRepl()）。
>
> 💡 **通俗理解**：启动序列就像**早上起床流程**——闹钟响 = CLI 启动 → 洗漱 = 加载配置 → 穿衣 = 初始化各模块 → 出门 = 就绪等待输入。趁刷牙的时候热早餐（I/O 并行预取），出门前确认钥匙手机钱包（检查凭据和配置），到公司就能立刻开工。

---

## 1. Pre-import 阶段：在模块加载的缝隙里抢跑

`main.tsx` 的**文件顶部**，三个函数调用**穿插在 import 语句之间**：

```typescript
import { profileCheckpoint } from './utils/startupProfiler.js';
profileCheckpoint('main_tsx_entry');     // 记录时间戳

import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
startMdmRawRead();                       // 启动 MDM 配置读取

import { startKeychainPrefetch } from './utils/secureStorage/keychainPrefetch.js';
startKeychainPrefetch();                 // 启动钥匙串预取

// —— 以下才是其余几十个 import ——
import { Command as CommanderCommand } from '@commander-js/extra-typings';
import chalk from 'chalk';
// ...（1,884 个文件的依赖树）
```

**关键细节**：这里利用的是 **ES 模块（ESM）的顺序求值语义**——按 ECMAScript 规范，模块体按源码顺序同步求值，每条 `import` 会先完成该模块的加载和求值，然后才执行下一条顶层语句。因此代码的实际执行顺序是：导入 `profileCheckpoint` → 调用它 → 导入 `startMdmRawRead` → 调用它 → 导入 `startKeychainPrefetch` → 调用它。每个函数只依赖自己那一条 import，不需要等后续模块加载完成。这比"在所有 import 之前"更精确——它是"在**剩余**大量 import 之前"（不依赖 Bun 特有行为，Node.js 下的 ESM 运行时同样适用）。

**为什么要这样穿插？**

JavaScript/TypeScript 的 `import` 是同步的——解析器必须加载并执行所有被导入的模块才能继续。这三个函数的导入依赖很轻（各自只需几毫秒），剩余的 import 链在完整加载 `src/` 下全部 1,884 个 TypeScript 文件时，大约需要 **~135ms**（源码 `main.tsx:4` 注释中的内部测量值；`src/` 全量 1,884 也是本章 "总依赖树" 的统计口径——两处数字指向同一棵依赖树，只是前者强调"入口前已完成"的剩余加载量）。

这三行代码在大量模块加载**之前**就触发了两个 I/O 操作：
- `startMdmRawRead()`（`utils/settings/mdm/rawRead.js`）：在 macOS 上生成 `plutil` 子进程读取 **MDM（Mobile Device Management，企业移动设备管理）** 配置文件，在 Windows 上用 `reg query` 读取注册表。这是一次子进程操作，开销不小。
- `startKeychainPrefetch()`（`utils/secureStorage/keychainPrefetch.js`）：同时启动**两个**钥匙串读取（OAuth token + 遗留 API key），并行执行。如果不在这里预取，后续 `applySafeConfigEnvironmentVariables()` 会同步阻塞约 65ms。

这两个操作都是异步的——它们在后台运行，和剩余模块加载**并行**。当 ~135ms 的剩余模块加载完成时，这两个 I/O 操作可能已经完成了。之后在 `preAction` 阶段（`main.tsx:914`），`ensureMdmSettingsLoaded()` 和 `ensureKeychainPrefetchCompleted()` 会 await 它们的结果——大部分时候是零等待，因为结果已经准备好了。

**比喻**：等电梯的时候刷手机——不是"刷完手机再等电梯"，而是"等电梯的同时刷手机"。在无法避免的等待时间（模块加载）里塞入有用的工作（I/O 预取）。

---

## 2. main() 函数：模式路由器

模块加载完成后，`main()` 函数开始执行。它的核心职责是**判断应该进入哪种运行模式**：

```
main()
  ├── 检查 CLI 参数
  │   ├── -p / --print → runHeadless()（单次打印模式）
  │   ├── mcp serve → 启动 MCP（Model Context Protocol，模型上下文协议）服务器模式
  │   └── 无特殊参数 → 继续
  │
  ├── 检查环境变量
  │   └── CLAUDE_CODE_COORDINATOR_MODE=1 → Coordinator 模式
  │
  ├── 检查 Feature Gate + 子命令
  │   └── `KAIROS` feature gate + `assistant` 子命令 → Assistant 模式（ant-only，即仅对 Anthropic 内部用户 `USER_TYPE === 'ant'` 开放的功能）
  │
  ├── 检查 Feature Flag（功能开关）
  │   ├── SSH_REMOTE → SSH 远程模式
  │   ├── DIRECT_CONNECT → cc:// URL 直连模式
  │   └── BRIDGE_MODE → 桥接模式
  │
  ├── preAction hook（Commander 的命令前钩子）
  │   ├── await MDM 配置 + 钥匙串预取完成
  │   ├── await init()              ← 子系统初始化
  │   ├── runMigrations()           ← 迁移在 init() 之后执行
  │   ├── loadRemoteManagedSettings()  ← 远程设置（非阻塞）
  │   └── loadPolicyLimits()        ← 策略限制（非阻塞）
  │
  └── 默认 → launchRepl()（交互式 REPL）
```

> **注意**：`preAction` hook 是 Commander.js 的机制——只在**执行命令时**触发，`claude --help` 不会调用 `init()`。这避免了仅查看帮助文档时的初始化开销。

### 运行模式

核心模式（所有构建中可用）：

| 模式 | 触发方式 | 用途 |
|------|---------|------|
| **REPL** | 默认 | 交互式终端对话 |
| **Headless** | `-p/--print` | 单次调用，输出后退出 |
| **MCP Serve** | `mcp serve` 子命令 | 作为 MCP 服务器运行 |

Feature-gated 扩展模式（通过 `bun:bundle` 的 `feature()` 编译时开关控制，部分仅内部构建可用）：

| 模式 | 触发方式 | 用途 | 说明 |
|------|---------|------|------|
| **Coordinator** | 环境变量 `CLAUDE_CODE_COORDINATOR_MODE=1` | 多 Worker 编排 | feature gate: `COORDINATOR_MODE` |
| **KAIROS** | Feature gate + `assistant` 子命令 | 内部 Assistant 模式 | ant-only 实验功能 |
| **SSH Remote** | `claude ssh <host>` | 远程终端 | feature gate: `SSH_REMOTE` |
| **Direct Connect** | `cc://` URL | 连接远程 Claude Code 服务器 | feature gate: `DIRECT_CONNECT` |
| **Bridge / Remote Control** | `--remote-control` / `--rc` | claude.ai/code 远程控制本地环境 | feature gate: `BRIDGE_MODE` |

> **注**：SDK 模式不经过 `main()` 路由，它直接通过代码 import 调用 Claude Code 的 API，因此不在此列。Bridge (Remote Control) 和 Direct Connect 是方向相反的连接模式：Bridge 是远端控制本地，Direct Connect 是本地连接远端服务器。

---

## 3. 迁移系统：确保配置与代码同步

在 `preAction` hook 中，`init()` 完成之后，系统调用 `runMigrations()`（main.tsx:950）检查 `globalConfig.migrationVersion`。当版本号与当前代码期望的版本（11）不匹配时，`runMigrations` 会**同步运行一组预先定义好的迁移函数**（不是严格按"版本 1→2→3"逐级递推运行，而是每次调用就执行该集合中所有尚未应用的迁移），然后把 `migrationVersion` 更新到当前版本。涉及的迁移包括：

```
模型字符串格式迁移
配置路径迁移
... 其他内部字段格式调整 ...
（具体迁移集合以 runMigrations 的实现为准）
```

每个迁移函数是一个幂等操作——运行多次和运行一次效果相同。这保证了即使迁移过程中崩溃，重新运行也不会出错。

> 📚 **课程桥接：** 配置加载 = 操作系统的 `/etc` 配置层级。就像 Linux 启动时读取 `/etc/fstab`（挂载表）、`/etc/passwd`（用户表）、`/etc/resolv.conf`（DNS 配置）这些分层配置文件一样，Claude Code 的迁移系统确保配置文件格式与当前代码版本保持一致。
>
> **🔑 OS 类比：** 就像手机系统升级时自动迁移你的数据——新版本会把旧格式的通讯录、照片库、设置项自动转换成新格式。

---

## 4. 初始化阶段：子系统启动

> 📚 **课程桥接：** 权限初始化 = 操作系统的用户态权限建立。就像 Linux 内核启动后通过 PAM 模块验证用户身份、读取 `/etc/sudoers` 确定权限范围一样，Claude Code 的 init 阶段需要完成 OAuth 验证、钥匙串读取、策略限制加载——确定"这个用户能做什么"。

`entrypoints/init.ts` 的 `init()` 函数（line 57）是系统的"内核初始化"，使用 `memoize` 包装确保只执行一次。它按关键路径顺序启动主要子系统（下方示意图列出的是核心阶段，实际还包含 `setShellIfWindows()`、LSP manager cleanup 注册、session teams cleanup 注册、upstreamproxy 懒加载等辅助步骤，读者可对照 SoT `src/entrypoints/init.ts` 查阅完整顺序）：

```
init()  ← memoized，只执行一次
  │
  ├── 1. enableConfigs()
  │   └── 验证并启用配置系统
  │
  ├── 2. applySafeConfigEnvironmentVariables()
  │   └── 在信任对话框之前设置安全环境变量
  │
  ├── 3. applyExtraCACertsFromConfig()
  │   └── 添加额外 CA 证书（必须在第一次 TLS 连接之前）
  │
  ├── 4. setupGracefulShutdown()
  │   └── 注册退出处理器
  │
  ├── 5. 启动异步后台任务（不等待完成）
  │   ├── 1P（First-Party，Anthropic 自建）事件日志初始化（动态 import，延迟加载 OTel / OpenTelemetry sdk-logs）
  │   ├── OAuth 账户信息获取
  │   ├── JetBrains IDE 检测
  │   ├── Git 仓库检测（detectCurrentRepository）
  │   ├── 远程设置加载 Promise 初始化（仅 isEligibleForRemoteManagedSettings() 时）
  │   ├── 策略限制加载 Promise 初始化（仅 isPolicyLimitsEligible() 时）
  │   └── 记录首次启动时间
  │   注意：远程设置和策略限制在此处只是**初始化 loading promise**，
  │   真正的 loadRemoteManagedSettings() / loadPolicyLimits() 调用
  │   在 preAction hook 中 init() 之后执行
  │
  ├── 6. configureGlobalMTLS()
  │   └── 配置全局 mTLS（双向 TLS）
  │
  ├── 7. configureGlobalAgents()
  │   └── 配置 HTTP 代理和 mTLS agents
  │
  ├── 8. preconnectAnthropicApi()  ← 关键优化
  │   └── 提前建立 TCP + TLS 连接（和后续步骤并行）
  │
  ├── 9. initUpstreamProxy()（CCR = Claude Code Remote，远程代理模式；feature-gated）
  │   └── 启动 CONNECT relay 做凭据注入
  │
  ├── 10. setShellIfWindows() / registerCleanup(shutdownLspServerManager) / registerCleanup(cleanupSessionTeams 懒加载)
  │   └── Windows 下 git-bash 切换、LSP 清理注册、session 级团队清理注册
  │
  └── 11. ensureScratchpadDir()（条件触发，仅当 `isScratchpadEnabled()` 返回 true 时执行）
      └── 创建 Scratchpad 目录（Agent 通信用）
```

### 依赖顺序的精心安排

初始化顺序不是随意的：
- **配置必须先启用**（步骤 1-2）——后续所有子系统都需要读取配置
- **CA 证书在 TLS 之前**（步骤 3）——必须在任何网络连接之前配置好证书链
- **多个异步任务并行启动**（步骤 5）——不等待完成，在"用户还在看界面"时在后台跑。其中远程设置和策略限制的 loading promise 按条件触发。📚 *课程桥接：这种"多个独立 I/O 同时发起，等全部完成后一次性拿到结果数组"的模式对应 `Promise.all`——它会等所有 promise 都完成后再 resolve；如果要"任一完成即用其结果"则应使用 `Promise.race`，两者语义不同，不要混淆*
- **mTLS 在 API 预连接之前**（步骤 6-7 → 8）——先配置好 TLS，再建立连接
- **`preconnectAnthropicApi()`** 在 init 阶段就建立到 Anthropic API 的 TCP + TLS 连接，这样第一次 API 调用时不需要再等握手——这是 HTTP 客户端预连接的常见做法

---

## 5. 就绪阶段：延迟预取

`launchRepl()` 渲染首屏后（用户看到了输入框），系统在后台启动一批**延迟预取**：

```
startDeferredPrefetches()
  ├── initUser()              ← 获取用户信息
  ├── getUserContext()         ← 用户上下文（含 CLAUDE.md）
  ├── prefetchSystemContextIfSafe()  ← 系统上下文含 getGitStatus()（5 个并行 git 操作）
  ├── getRelevantTips()        ← 提示信息
  ├── countFilesRoundedRg()    ← 文件计数统计
  ├── refreshModelCapabilities()  ← 模型能力缓存刷新
  ├── settingsChangeDetector   ← 设置变更监听
  └── skillChangeDetector      ← 技能变更监听
```

**设计理念**：这些信息对首屏渲染都不是必需的。其中用户信息、用户上下文（CLAUDE.md 等）、系统上下文（含 git 状态）是**首轮 AI 调用真正需要拼入 prompt 的输入**；其余（文件计数、模型能力缓存刷新、settings/skill 变更监听）更像"后台预热/监听"——提前准备好以减少首次真正用到时的开销。把这些任务放在"用户还在看界面/打字"的时间窗口内预取，用户真正按 Enter 时，首轮 AI 调用所需的上下文信息已经就绪。

### Git 状态的 5 个并行操作

获取 git 状态不是一个 `git status`——`context.ts` 中的 `getGitStatus()` 通过 `Promise.all` 并行运行 **5 个操作**（其中 2 个是文件系统缓存读取、3 个是真正的 git 子进程命令）：

```
并行执行（Promise.all）：
  getBranch()                              ← 当前分支（通过 gitFilesystem 缓存层读取）
  getDefaultBranch()                       ← 默认分支（同上，缓存层）
  git --no-optional-locks status --short   ← 变更文件列表
  git --no-optional-locks log --oneline -n 5  ← 最近 5 次提交
  git config user.name                     ← 当前 git 用户名
```

注意 `getBranch()` 和 `getDefaultBranch()` 不是直接 spawn git 子进程，而是通过 `gitFilesystem.ts` 的缓存层直接读取 `.git` 目录文件——这比 spawn 子进程更快。`--no-optional-locks` 标志避免了在只读查询时获取 `.git/index.lock`，防止与其他 git 操作冲突。

为什么并行？因为 git 命令需要读取 `.git` 目录或 spawn 子进程——在大型仓库中这可能有明显延迟。5 个串行操作可能需要 500ms，并行可能只需要 150ms。

---

## 6. 完整时间线

```
t=0ms     用户敲下 `claude` 命令
          ↓
          Node.js 开始解析 main.tsx
          ├── startMdmRawRead() 启动 ── I/O 并行 ──┐
          ├── startKeychainPrefetch() 启动 ───────────┤
          └── 开始加载 import 模块                     │
                                                       │
t=~135ms  模块加载完成                                  │
          MDM 配置读取 ← ──── 可能已完成 ──────────────┘
          钥匙串预取 ← ──── 可能已完成 ────────────────┘
          ↓
          main() 开始执行
          ├── 模式路由判断（Commander 解析 CLI 参数）
          └── preAction hook 触发
                ├── await MDM + 钥匙串预取完成
                ├── init() 初始化
                │     ├── 加载配置
                │     ├── CA 证书 + mTLS + 代理
                │     ├── API 预连接
                │     └── 异步后台任务（事件日志、OAuth...）
                ├── runMigrations()（首次/升级时）
                └── loadRemoteManagedSettings() + loadPolicyLimits()（非阻塞）

t=~300ms  初始化完成（估算值）
          ↓
          launchRepl() 渲染首屏
          ↓
          用户看到输入框（可以开始打字）

t=~300ms+ 延迟预取在后台运行（估算值）
          ├── 用户信息 + 用户上下文
          ├── git 状态（5 并行操作）
          ├── 文件计数
          └── 模型能力缓存 + Change detector

t=~500ms  预取基本完成
          ↓
          系统完全就绪
```

**从敲下命令到看到输入框：约数百毫秒量级**（作者本地非正式估算，未公开 benchmark 数据——源码中有 `profileCheckpoint` 埋点供内部 profiling，但未发布基准测试结果）。对比参考：Go 编译的 gh CLI 启动在几十毫秒级，Python 的 Aider 冷启动因为 `litellm`/`tree-sitter` 等重量级依赖通常在秒级（具体以实测为准）。Claude Code 的数百毫秒量级对于一个重量级 Node.js CLI 来说是合理的——Pre-import I/O 并行和延迟预取确实减少了感知延迟，但 ~135ms 的模块加载开销（源码 `main.tsx:4` 注释）仍然是底线，这是 JS/TS 运行时 CLI 工具的固有代价。

---

## 7. 设计取舍

### 有效的工程决策

1. **Pre-import I/O 并行**——在不可避免的等待时间（模块加载）中塞入工作，这是一个务实的优化手段，虽然违反常规但效果明确
2. **延迟预取设计**把不紧急的 I/O 推到"用户打字"窗口——不阻塞首屏渲染，这是 CLI 工具中常见的感知性能优化
3. **5 个并行 git 操作**而不是串行查询——标准的 I/O 并行化实践，且 `getBranch()`/`getDefaultBranch()` 通过文件系统缓存层避免 spawn 子进程
4. **迁移系统的幂等设计**——崩溃安全，重新运行不会出错，这是数据库迁移领域的标准做法
5. **preAction hook 模式**——通过 Commander.js 的 `preAction` hook，`claude --help` 等纯信息命令不触发 `init()`，避免了不必要的初始化开销（钥匙串读取、网络连接等）
6. **OpenTelemetry 延迟加载**——~400KB 的 OTel + protobuf 模块通过动态 `import()` 延迟到遥测实际初始化时加载；gRPC 导出器（~700KB 的 `@grpc/grpc-js`）进一步 lazy-load，仅在 gRPC 协议被选中时导入。总计 ~1.1MB 的遥测依赖不在启动关键路径上
7. **多种模式通过同一入口路由**——减少了入口文件数量，但也集中了复杂度

### 代价与局限

1. **Pre-import 副作用**违反了 JavaScript 的"导入无副作用"惯例——可能让不了解背景的开发者困惑。源码中用 `eslint-disable custom-rules/no-top-level-side-effects` 注释标明了这是有意为之
2. **~135ms 的模块加载时间**说明依赖树很大——1,884 个文件的 import 链不可避免
3. **多种模式的分支逻辑**集中在一个函数中——未来添加新模式可能让 main() 变得很长
4. **延迟预取的时间窗口假设**——如果用户打字非常快（比如粘贴一段文本立即按 Enter），预取可能来不及完成
5. **迁移版本号是硬编码的**——没有自动发现机制，每次迁移都需要手动递增版本号

---

## 8. 代码落点

以下是本章关键概念在源码中的精确位置：

| 概念 | 文件 | 行号 | 说明 |
|------|------|------|------|
| Pre-import 副作用 | `src/main.tsx` | :9-20 | `profileCheckpoint` + `startMdmRawRead` + `startKeychainPrefetch` 穿插在 import 之间执行 |
| preAction 钩子 | `src/main.tsx` | :907-967 | Commander 的 `preAction` hook——await MDM/钥匙串 → `init()` → `runMigrations()` → `loadRemoteManagedSettings()` + `loadPolicyLimits()` |
| init() 主函数 | `src/entrypoints/init.ts` | :57 | `memoize` 包装确保只执行一次，按序启动所有子系统 |
| CA 证书配置 | `src/entrypoints/init.ts` | :77-79 | `applyExtraCACertsFromConfig()`——必须在首次 TLS 握手前完成（Bun 的 BoringSSL 缓存机制） |
| 6 个异步后台任务 + 1 个同步项 | `src/entrypoints/init.ts` | :94-132 | 4 个无条件异步（1P 事件日志、OAuth、JetBrains 检测、git 仓库检测）+ 2 个条件触发异步（远程设置、策略限制）+ 1 个同步项 `recordFirstStartTime()` |
| API 预连接 | `src/entrypoints/init.ts` | :153-155 | `preconnectAnthropicApi()`——TCP+TLS 握手与后续步骤重叠 |

---

> **[图表预留 2.2-A]**：启动时间线图 — 三个阶段的时间分布（Pre-import / Init / Ready）
> **[图表预留 2.2-B]**：模式路由决策树 — CLI参数/环境变量/Feature Flag 到各运行模式的映射
> **[图表预留 2.2-C]**：I/O 并行甘特图 — 模块加载/MDM读取/钥匙串预取/延迟预取的并行时间线
