来源与授权
本文来自 Claude-Code-Source-Study 原文,固定导入版本为 31b6e07a82d4。Copyright (c) 2026 Yao,依据 MIT License 提供。
本站仅按“github-public-noncommercial-2026-07-11”用于匿名、免费、非商业学习,并调整章节编排、站内链接、重复标题与阅读标记;不代表原作者对本站的合作、认证或背书。如权利方要求删除,我们会立即下架;授权或删除请求请通过本站首页底部公开联系方式联系。
本章是《深入 Claude Code 源码》系列第 4 章。我们将剖析
migrations/目录下 11 个迁移文件与main.tsx的runMigrations()调度,看 Claude Code 如何把"产品在用户身后偷偷换零件"这件事写成一组幂等、可独立 review、由版本号守护串行执行的小函数。
为什么配置迁移值得单独一篇?
一款活跃迭代的 CLI 工具,避不开"在用户身后改用户的配置"这件事。Claude Code 这一年多里发生过的就有:
- 模型默认值会换 —— Pro 用户的默认从 Sonnet 翻成 Opus,老用户启动时不能莫名其妙被切到一个他没主动选过的模型
- 模型别名会重映射 ——
sonnet半年前指 Sonnet 4.5,今天指 Sonnet 4.6,老用户当时的意图必须保留 - 字段会改家 ——
autoUpdates之类的字段要从~/.claude.json搬到~/.claude/settings.json,且不能丢、不能在两处同时存在 - 键名会改字面 ——
replBridgeEnabled这种实现细节漏出来的键名要换成remoteControlAtStartup这种用户文档里能写的名字 - 弹窗会重弹 —— 老的两选项 AutoMode 弹窗加了第三个选项之后,要让接受过老弹窗的人再看一次
这些动作如果直接做、不管历史,老用户的体验就会在某次升级后突然错乱:模型莫名其妙变了、自动更新莫名其妙打开了、/model 屏显示的是一串再也对不上的 ID。如果用一个"声明式迁移 DSL"去描述?太死板了 —— 上面第 5 项那种"为了避开另一段状态机的反相边而只对 'enabled' 状态生效"的精确条件,DSL 写不出来。如果每次发版前写个一次性脚本跑一遍?也不行 —— 这是产品生命周期里持续发生的事,不是某一个版本的搬家动作。
Claude Code 的答案是:把每一次破坏性的配置改动都写成一段独立的、幂等的、由版本号守护的小函数,串行串在启动时一个叫 runMigrations 的内部钩子里。migrations/ 目录里那 11 个文件,就是这一年多里所有"在用户身后改配置"动作的全部历史账:
migrateAutoUpdatesToSettings.ts
migrateBypassPermissionsAcceptedToSettings.ts
migrateEnableAllProjectMcpServersToSettings.ts
migrateFennecToOpus.ts
migrateLegacyOpusToCurrent.ts
migrateOpusToOpus1m.ts
migrateReplBridgeEnabledToRemoteControlAtStartup.ts
migrateSonnet1mToSonnet45.ts
migrateSonnet45ToSonnet46.ts
resetAutoModeOptInForDefaultOffer.ts
resetProToOpusDefault.ts
11 个文件全部是顶层文件——没有子目录、没有公共基类、没有"框架",每个文件导出一个同名的无参函数,函数名就是它做的那件事。这种克制的组织方式背后有一个很清楚的意图:每一次破坏性的配置改动,都是一段可以被独立 review、独立删除、独立写测试的小代码片段。你不需要去理解一个"迁移引擎"——只需要打开你感兴趣的那个文件,从头读到尾,三五十行内它就讲完了自己的故事。
11 个迁移函数速查表
| 函数 | 分组 | 触发条件 | 一句话作用 | 错误处理 |
|---|---|---|---|---|
migrateAutoUpdatesToSettings | 搬家(第 2 节) | 无条件 | 把 ~/.claude.json 的 autoUpdates 搬到 ~/.claude/settings.json | try/catch + logError |
migrateBypassPermissionsAcceptedToSettings | 搬家(第 2 节) | 无条件 | 把 bypassPermissionsModeAccepted 搬到 settings | try/catch + logError |
migrateEnableAllProjectMcpServersToSettings | 搬家(第 2 节) | 无条件 | 把 enableAllProjectMcpServers 搬到 settings | try/catch + logError |
resetProToOpusDefault | 模型字符串(第 3 节) | 无条件 | Pro 默认从 Sonnet 翻成 Opus,靠横幅时间戳兜底 | 幂等早退 |
migrateSonnet1mToSonnet45 | 模型字符串(第 3 节) | 无条件 | 把 sonnet[1m] 别名展开成 sonnet-4-5-20250929[1m] 显式串 | 幂等早退 |
migrateLegacyOpusToCurrent | 模型字符串(第 3 节) | 无条件 | 把 claude-opus-4-0 这种远古串收敛到 opus 别名 | 幂等早退 |
migrateSonnet45ToSonnet46 | 模型字符串(第 3 节) | 无条件 | 把 sonnet-4-5-* 卷回 sonnet 别名 | 幂等早退 |
migrateOpusToOpus1m | 模型字符串(第 3 节) | 无条件 | 把 opus 升级成 opus[1m] | 幂等早退 |
migrateFennecToOpus | 模型字符串(第 3 节) | 仅内部版("external" === 'ant') | 把内部代号模型迁到 opus | 幂等早退 |
migrateReplBridgeEnabledToRemoteControlAtStartup | 键名清理(第 4 节) | 无条件 | replBridgeEnabled → remoteControlAtStartup | 幂等早退 |
resetAutoModeOptInForDefaultOffer | 键名清理(第 4 节) | feature('TRANSCRIPT_CLASSIFIER') | 让接受过老两选项弹窗的用户重看新三选项 | try/catch + 内层 completion flag |
本章按下面的顺序拆这 11 个函数:先看 main.tsx 里那条把它们串起来的流水线(第 1 节),再分三组挨个看 —— 把字段从一处搬到另一处的 3 个(第 2 节)、围绕模型字符串做重命名的 6 个(第 3 节)、围绕"旧键名 / 旧弹窗选项"做清理的 2 个(第 4 节),最后把贯穿全章的几条纪律收束起来(第 5 节)。
全景图:11 个迁移函数与 main.tsx 的关系
一、流水线:一个版本号守护的串行入口
所有 11 个迁移并不是各自找时机各自跑的,它们被集中在 main.tsx 的一个内部函数里,由一个叫 migrationVersion 的小数字守护。下面这段就是流水线的全貌(main.tsx:323-352):
const CURRENT_MIGRATION_VERSION = 11;
function runMigrations(): void {
if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) {
migrateAutoUpdatesToSettings();
migrateBypassPermissionsAcceptedToSettings();
migrateEnableAllProjectMcpServersToSettings();
resetProToOpusDefault();
migrateSonnet1mToSonnet45();
migrateLegacyOpusToCurrent();
migrateSonnet45ToSonnet46();
migrateOpusToOpus1m();
migrateReplBridgeEnabledToRemoteControlAtStartup();
if (feature('TRANSCRIPT_CLASSIFIER')) resetAutoModeOptInForDefaultOffer();
if ("external" === 'ant') migrateFennecToOpus();
saveGlobalConfig(prev => prev.migrationVersion === CURRENT_MIGRATION_VERSION
? prev
: { ...prev, migrationVersion: CURRENT_MIGRATION_VERSION });
}
migrateChangelogFromConfig().catch(() => {});
}
短短 30 行,把这个子系统的全部纪律都暴露出来了。让我们一条一条看。
先是一个守门员。 if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) 这一行是整个流水线的开关:每次启动时读全局配置里那个名叫 migrationVersion 的小数字,跟代码里硬编码的 CURRENT_MIGRATION_VERSION = 11 比一下,对得上就整段跳过,对不上就把下面所有迁移再跑一遍。注意这不是"只跑没跑过的那几个"——而是把整组迁移再走一次,最后把版本号刷成新值。文件顶部的注释把这个意图说得很直白:「Bump this when adding a new sync migration so existing users re-run the set.」你新加了一个迁移、把版本号从 11 变成 12,已有用户下次启动时这 12 个迁移就会从头到尾再走一遍。所有迁移都得能重复跑而不出错——这条隐含的契约由下文将看到的"幂等"模式承担。
接着是一段固定顺序。 9 个无条件迁移 + 2 个带条件的,顺序写死在源码里,没有依赖图、没有拓扑排序。这个顺序不是随便排的,它隐含了几条事实关系:resetProToOpusDefault 要先于所有 Sonnet/Opus 模型字符串改名跑,因为它会基于"用户当时有没有自定义模型"决定要不要弹横幅;migrateSonnet1mToSonnet45 要先于 migrateSonnet45ToSonnet46,因为它产出的是 sonnet-4-5-20250929[1m] 这种显式串、正好是后者要往回卷成 sonnet[1m] 别名的输入;migrateLegacyOpusToCurrent 和 migrateOpusToOpus1m 一前一后,前者把 claude-opus-4-0 这种远古串收敛到 opus 别名,后者再把 opus 升级成 opus[1m]。顺序就是事实顺序,不需要装出 DAG 的样子。
然后是两段条件分支。 feature('TRANSCRIPT_CLASSIFIER') 是一道编译期 / 运行期同款的开关,命中才跑 resetAutoModeOptInForDefaultOffer;写得最有意思的是那段 if ("external" === 'ant'),这是 bundler 留下的字符串折叠点——发到外部用户的 bundle 里这一支永远是死代码、migrateFennecToOpus 被裁掉,而内部 ant 用户的 bundle 里这一支永远进入、迁移会跑。这是 Claude Code 把"内部独有逻辑"与"外发产物"用同一份源码一起维护的典型手法(详见第 22 章 Feature Flag 与编译期优化)。
最后是落版本号。 saveGlobalConfig(prev => prev.migrationVersion === CURRENT_MIGRATION_VERSION ? prev : { ...prev, migrationVersion: CURRENT_MIGRATION_VERSION }) 这一行也有讲究:内层先比一次再决定要不要返回新对象,避免一次空写——saveGlobalConfig 的 updater 如果返回的是同一个对象引用,下游能省一次磁盘 IO 与一次内存中"已经一致就别落盘"的判断。这种"防御性同等性"在 11 个迁移内部到处都是。
落版本号之后还有一行:migrateChangelogFromConfig().catch(() => {})——这是一个 fire-and-forget 的异步迁移,跟 sync 流水线分开放,自己 catch 掉错误。它不参与版本号机制,每次启动都会试一次、出错就吞掉。这是 Claude Code 给"非阻塞、可重试、不影响启动"的迁移留的另一条路。
把这段读完,你已经掌握了这一章的全部"框架"。剩下的事情就是逐个打开 migrations/ 目录下那 11 个文件,看每段历史债是怎么算的。
二、把字段搬家:3 个 settings.json 落地迁移
第一组的 3 个迁移做的是同一件事——把一个原来住在 ~/.claude.json(GlobalConfig)或项目配置里的字段,搬到 ~/.claude/settings.json(userSettings)或项目 local settings 里。为什么要搬?读读第 17 章你会记得,settings.json 是 Claude Code 面向用户的"正经"配置文件:有多层合并、有企业策略覆盖、有运行时变更监听。而 ~/.claude.json 这个 GlobalConfig 早年承担了太多"既存"职责,里面混了一堆其实是用户偏好的字段。把它们搬到正经家里,整个配置体系才能保持一致。
这一组的三段代码长得非常像,套路是:「读老地方 → 写新地方 → 删老地方」。我们挨个看。
2.1 migrateAutoUpdatesToSettings:把"我不要自动更新"搬到环境变量
第一个文件 migrations/migrateAutoUpdatesToSettings.ts 处理的是 autoUpdates 字段。旧版本里,用户在 /config 屏关掉自动更新,会把 autoUpdates: false 写到全局配置;新版本统一改用环境变量 DISABLE_AUTOUPDATER=1 来表达这件事——理由是原生安装包的更新链路也要消费这个信号,环境变量比全局 JSON 更适合做"全局禁用"开关。函数的核心判断是这样的(migrations/migrateAutoUpdatesToSettings.ts:18-23):
// Only migrate if autoUpdates was explicitly set to false by user preference
// (not automatically for native protection)
if (
globalConfig.autoUpdates !== false ||
globalConfig.autoUpdatesProtectedForNative === true
) {
return
}
注意这两个条件叠起来读:只有当用户明确把 autoUpdates 设成 false、并且不是"原生保护"自动设上的,才会进入迁移。任何"我从来没动过"或"被保护机制顺手设上的"情况都早退。这一段是这一组三个迁移里写得最克制的——它意识到「false 这个值"用户写的"还是"机器写的"」是两件不同的事。
判断通过之后,函数做三件事:往 userSettings 的 env 子对象里写一条 DISABLE_AUTOUPDATER: '1'、立刻把 process.env.DISABLE_AUTOUPDATER 也设上(让当前这次启动就生效,不等下次)、最后把 autoUpdates 和 autoUpdatesProtectedForNative 这两个旧字段从 GlobalConfig 里删掉。删除用的是 ES 解构里那个 { autoUpdates: _, autoUpdatesProtectedForNative: __, ...rest } 模式——这是这一组迁移里反复出现的句式,比 delete obj.foo 更适合配合不可变 updater。整段还包在 try/catch 里,失败就 logError 一行再发一个 tengu_migrate_autoupdates_error 事件,不抛——迁移失败绝不应该阻塞用户启动。
2.2 migrateBypassPermissionsAcceptedToSettings:把"我同意了危险模式"搬到 settings
第二个文件比第一个还要短,只有 40 行。它处理的是一个用户曾经在权限弹窗里点过"我接受危险模式提示"之后留下的标记 bypassPermissionsModeAccepted。这个标记原来住在 GlobalConfig 里,名字也带着实现细节("bypass permissions mode accepted"这种词不是给用户看的);新版本把它换成 settings.json 里的 skipDangerousModePermissionPrompt,名字更直白。代码骨架与第一个迁移如出一辙(migrations/migrateBypassPermissionsAcceptedToSettings.ts:21-34):
try {
if (!hasSkipDangerousModePermissionPrompt()) {
updateSettingsForSource('userSettings', {
skipDangerousModePermissionPrompt: true,
})
}
logEvent('tengu_migrate_bypass_permissions_accepted', {})
saveGlobalConfig(current => {
if (!('bypassPermissionsModeAccepted' in current)) return current
const { bypassPermissionsModeAccepted: _, ...updatedConfig } = current
return updatedConfig
})
} catch (error) {
logError(
new Error(`Failed to migrate bypass permissions accepted: ${error}`),
)
}
但有两个值得指出的细节。第一,写入新键之前先调了一次 hasSkipDangerousModePermissionPrompt() ——只有当新键还不存在时才写。这一招的用意是不要覆盖用户后来通过别的途径(手动编辑 settings.json、或者通过另一台机器的 settings sync 同步过来)已经设过的值。一句小检查,挡掉了"新老来源都被设过,迁移悄无声息地把新的盖成旧的"这条故障路径。第二,删除旧键时 updater 里同样多了一道 if (!('bypassPermissionsModeAccepted' in current)) return current 的同等性短路——返回同一引用,避免 GlobalConfig 出现空写。这是这一组迁移共用的一条"反复跑不出问题"的纪律。
2.3 migrateEnableAllProjectMcpServersToSettings:MCP 三个字段一起搬
第三个文件 migrations/migrateEnableAllProjectMcpServersToSettings.ts 是 118 行,比前两个都长。原因是它一次搬三个字段:enableAllProjectMcpServers(布尔)、enabledMcpjsonServers(字符串数组)、disabledMcpjsonServers(字符串数组)。这三个字段都跟"项目级 MCP 服务器的批准状态"有关,原来住在 project config 里,新版本统一搬到 local settings。
复杂之处不在判断,而在两个数组字段的合并:用户可能此前已经通过别的途径在 local settings 里维护了一份 enabledMcpjsonServers 数组,迁移不应该把它整个覆盖。代码的写法是先读、再合并、再写(migrations/migrateEnableAllProjectMcpServersToSettings.ts:60-71):
// Migrate enabledMcpjsonServers if it exists
if (hasEnabledServers && projectConfig.enabledMcpjsonServers) {
const existingEnabledServers =
existingSettings.enabledMcpjsonServers || []
// Merge the servers (avoiding duplicates)
updates.enabledMcpjsonServers = [
...new Set([
...existingEnabledServers,
...projectConfig.enabledMcpjsonServers,
]),
]
fieldsToRemove.push('enabledMcpjsonServers')
}
用 new Set([...existing, ...incoming]) 去重,再展开回数组。disabledMcpjsonServers 的处理方式一字不改。布尔字段 enableAllProjectMcpServers 走的是另一条逻辑——只有当 settings 里还没设过这个键时才把 project config 的值写进去("已迁移过就不要回写、只清理 project config 里的残留")。所有三个字段处理完之后,一次性把它们从 project config 里 destructure-out 删掉,最后发一条 tengu_migrate_mcp_approval_fields_success 事件并带上迁移的字段数。
这三个迁移合在一起,演示了 Claude Code 处理"字段搬家"这件事的全部套路:判断老地方有没有 / 检查新地方有没有覆盖风险 / 数组型字段做去重合并 / 单值型字段做存在性短路 / 写新地方 / 删老地方 / 发事件 / 失败不抛。下次再有什么字段要搬,照这个模板写一个新文件、加进流水线、把版本号 +1,就完事了。
三、给模型换名字:5 个模型字符串迁移
第二组的 5 个迁移围绕同一件事:Claude Code 在过去一年多里把模型别名重新洗了几遍牌,老用户 settings 里的字符串要跟着卷。这件事比"字段搬家"更微妙——一个用户半年前把 sonnet 钉死在 settings.json 里,他当时想要的是 Sonnet 4.5;今天 sonnet 这个别名已经指向了 Sonnet 4.6,如果什么都不做,这个用户会无声无息地被换到一个他没主动选过的模型上。模型迁移要做的,是让用户当时的意图在别名重映射之后依然得到保留。
5 个文件按时间顺序依次是:resetProToOpusDefault、migrateSonnet1mToSonnet45、migrateLegacyOpusToCurrent、migrateSonnet45ToSonnet46、migrateOpusToOpus1m,再加一个内部专用的 migrateFennecToOpus——共 6 段,把模型字符串的演化全部写在源码里。
3.1 共同纪律:改字符串的迁移都只动 userSettings、不动合并值
5 段改写模型字符串的迁移里最反复出现的一行代码是 getSettingsForSource('userSettings')?.model——而不是 getCurrentModel()、不是合并后的最终值。这个选择有非常具体的理由,migrations/migrateFennecToOpus.ts:14-17 那段注释把它写得最清楚:
// Only touches userSettings. Reading and writing the same source keeps this
// idempotent without a completion flag. Fennec aliases in project/local/policy
// settings are left alone — we can't rewrite those, and reading merged
// settings here would cause infinite re-runs + silent global promotion.
两条理由叠在一起:第一,读写同一个源(userSettings)才能让迁移自然幂等——你写完之后下次再跑同一段代码,源里读到的就是新值,不会再次进入分支。第二,如果读合并后的值(merged settings)来决定要不要往 userSettings 里写,会出现一个静默的"提权"——某个用户只在项目级 settings 里钉了 fennec-latest,merged 视图里看到的也是这串字符,迁移就会把对应的新值 opus 写到 userSettings、把这个原本只在某一个项目里有效的模型偏好"提"成全局默认。后果是:用户在别的项目里也突然被切到新模型。migrateSonnet1mToSonnet45 / migrateLegacyOpusToCurrent / migrateSonnet45ToSonnet46 / migrateOpusToOpus1m / migrateFennecToOpus 这 5 段改写模型字符串的迁移都老老实实地只摸 userSettings.model,project / local / policy 层的旧串留给运行时的 parseUserSpecifiedModel 现场重映射处理。
例外是 resetProToOpusDefault——它不改字符串,只决定要不要给当前用户立一个一次性横幅时间戳,所以它走的是另一条路:用 getSettings_DEPRECATED()(migrations/resetProToOpusDefault.ts:26-30)读合并后的 settings 判断"用户是否设过自定义 model"。这里读 merged 是安全的——它不向 userSettings 写 model 字段,因此不会触发上面那条"silent global promotion"。这条例外也说明:「只读写 userSettings」是给"改字符串"迁移的纪律,不是迁移目录的全局纪律。
3.2 resetProToOpusDefault:Pro 用户的默认从 Sonnet 翻成 Opus
migrations/resetProToOpusDefault.ts 是 51 行的一段。它做的并不是改字符串,而是给 Pro 订阅用户记录一个"我们改过你的默认值"的时间戳,用来在 REPL 顶部弹一个一次性横幅。逻辑是:当且仅当用户是 1P(firstParty)+ Pro 订阅 + settings.model 没设过(一直跟随默认)时,把当前时间写到 opusProMigrationTimestamp;其它情况只把 opusProMigrationComplete 标记上完事。
这段代码的"故事"完全靠两个嵌套 if 撑起来:第一个 if 兜住所有"不适用此次默认值切换"的用户(非 1P、非 Pro),它们也要标记成已完成、不然下次启动还会再走一遍;进入第二个 if 之后再看 settings?.model === undefined,未设过的发横幅、设过的安静通过。注意这里它读 settings 用的是 getSettings_DEPRECATED()(migrations/resetProToOpusDefault.ts:26-30)——也就是合并后的 settings,不是 userSettings 单源——这是上一节"只读写 userSettings"那条纪律的例外:它不向 settings 里写 model 字段,只用 merged 视图判断"用户当前生效的默认是不是没设过"来决定要不要立横幅,因此不存在静默全局提权的风险。没有 try/catch、没有兜底——如果 getGlobalConfig() / saveGlobalConfig / getSettings_DEPRECATED() 任一抛错,错会冒到 runMigrations 的 caller,被外层版本号"先 +1 再循环执行"的设计兜成"这一轮整体作废,下次启动重来"(详见 第 1 节)。每一条分支都对应一条 tengu_reset_pro_to_opus_default 事件并带上 skipped / had_custom_model 标记——你能从遥测里精确统计有多少人是被这个迁移波及到的、有多少人是早就钉了自己的模型不受影响的。
3.3 migrateSonnet1mToSonnet45:从别名锁定到显式串
这段是 main.tsx 那段注释里点名推荐的"教科书例子"。要解决的问题是:用户原本把 sonnet[1m] 写在 settings 里,意思是"Sonnet 4.5 + 1M 上下文";后来 sonnet 别名被升到 4.6 了,但 4.6 1M 是另一个用户群、跟 4.5 1M 不是一回事——为了让原来用 4.5 1M 的人继续用 4.5 1M,得把 sonnet[1m] 替换成显式串 sonnet-4-5-20250929[1m]。代码是这样的(migrations/migrateSonnet1mToSonnet45.ts:31-42):
const model = getSettingsForSource('userSettings')?.model
if (model === 'sonnet[1m]') {
updateSettingsForSource('userSettings', {
model: 'sonnet-4-5-20250929[1m]',
})
}
// Also migrate the in-memory override if already set
const override = getMainLoopModelOverride()
if (override === 'sonnet[1m]') {
setMainLoopModelOverride('sonnet-4-5-20250929[1m]')
}
这段值得圈出来的是文件顶部的一处反常——它不是只靠"读写同源"实现幂等的,而是另外用了一个 sonnet1m45MigrationComplete 标记位(migrations/migrateSonnet1mToSonnet45.ts:27-29):
const config = getGlobalConfig()
if (config.sonnet1m45MigrationComplete) {
return
}
为什么这一段要用完成标记?看代码的下半段:除了改 settings 之外,它还把进程内的 in-memory override(getMainLoopModelOverride())也跟着改了。这个 override 是来自 --model 命令行参数的运行时状态,不是 settings 里持久化的偏好——如果不立 flag,下一次启动 --model sonnet[1m] 这个用法的合法性判断和这条迁移会撞车。立 flag 是为了让 override 的迁移只发生一次,不在每次启动时反复改写一个本应来自命令行的临时值。这是这一组里唯一一个用了显式 completion flag 的迁移,原因专门写在它对 in-memory 状态的处理上。
3.4 migrateLegacyOpusToCurrent:把远古串收敛到 opus 别名
migrations/migrateLegacyOpusToCurrent.ts 处理的是另一组"古董字符串"。它的判断是 4 选 1:
if (
model !== 'claude-opus-4-20250514' &&
model !== 'claude-opus-4-1-20250805' &&
model !== 'claude-opus-4-0' &&
model !== 'claude-opus-4-1'
) {
return
}
命中其中任何一个就改写为 opus,并把这次迁移的时间戳 legacyOpusMigrationTimestamp 写进 GlobalConfig 用于横幅,再发一条带 from_model 字段的 tengu_legacy_opus_migration 事件。函数顶上还有两道前置闸——getAPIProvider() !== 'firstParty' 早退,以及 isLegacyModelRemapEnabled() 这个 feature flag——这一段的服务对象只是 1P 用户、且在 legacy remap 功能开启的窗口内。函数顶上注释里那句「parseUserSpecifiedModel now silently remaps these at runtime anyway — this migration cleans up the settings file so /model shows the right thing」是这段代码的灵魂:运行时其实已经在替你把这些古董串映射到正确的模型上了,迁移做的是把 settings 文件本身洗干净,让 /model 屏看到的是当前合法的别名,不要再显示一串再也对不上的旧 ID。
3.5 migrateSonnet45ToSonnet46:把显式串卷回 sonnet 别名
3.3 留下了 sonnet-4-5-20250929[1m] 这种显式串,现在 4.6 全量发布、sonnet 别名指向 4.6 了,要把当年被钉死到 4.5 显式串的 Pro/Max/Team Premium 1P 用户卷回别名,让他们重新跟随 sonnet(也就是跟随后续的别名升级)。判断同样是 4 选 1(migrations/migrateSonnet45ToSonnet46.ts:39-43):
if (
model !== 'claude-sonnet-4-5-20250929' &&
model !== 'claude-sonnet-4-5-20250929[1m]' &&
model !== 'sonnet-4-5-20250929' &&
model !== 'sonnet-4-5-20250929[1m]'
) {
return
}
命中之后看尾巴有没有 [1m],分别替换成 sonnet[1m] 或 sonnet 写回去。这段还有一个小心思:横幅时间戳只对老用户写(config.numStartups > 1),对启动次数仅 1 的新用户跳过——"刚装上 Claude Code 的人从来没体验过旧默认,不该看到迁移横幅"。这种"对新装的人不打扰"的判断分散在好几个迁移里。
3.6 migrateOpusToOpus1m:把 opus 升级成 opus[1m]
最后这个模型迁移服务的是合并后的 Opus 1M 体验——Max / Team Premium 这类用户在 1P 上 Opus 跟 Opus 1M 已经合并成一个体验,要把钉在 opus 别名上的人升到 opus[1m]。Pro 订阅被显式跳过(他们在 /model 屏里还能看到 Opus 与 Opus 1M 两个独立选项)。最有意思的是写回那一段(migrations/migrateOpusToOpus1m.ts:34-40):
const migrated = 'opus[1m]'
const modelToSet =
parseUserSpecifiedModel(migrated) ===
parseUserSpecifiedModel(getDefaultMainLoopModelSetting())
? undefined
: migrated
updateSettingsForSource('userSettings', { model: modelToSet })
这一段先把目标串 opus[1m] 和"当前的默认值"分别送进 parseUserSpecifiedModel 做结构化解析,如果两者解析结果相同就把字段写成 undefined(也就是从 settings 里清掉),否则才写显式串。换句话说:如果"目标"恰好就是这个用户的默认,那让他跟随默认即可;如果他的默认是别的、却要在 settings 里钉死这个,才落字符串。这是上文一直在讲的"不要做静默全局提权"的另一种形态——能用默认就用默认,不要在 settings 里多留一笔。
3.7 migrateFennecToOpus:内部 ant 用户专用
第 6 段是只对内部 ant 用户生效的(在 main.tsx 的 if ("external" === 'ant') 死代码守护下编进 ant bundle、对外发产物完全裁掉)。它处理的是内部 fennec 系列别名往 opus 别名上卷的工作:fennec-latest[1m] → opus[1m]、fennec-latest → opus、fennec-fast-latest 与 opus-4-5-fast → opus[1m] + fastMode:true,逐条 startsWith 匹配。这里也展示了"读写同源 + 不立 flag"那一招——文件顶部那段注释(3.1 引用过)原话就是为这段服务的。
把 6 段模型迁移连起来读,你能看到 Claude Code 处理"模型字符串演化"这件事的一整套思路:别名升级时,先把老用户从旧别名映射到当时对应的显式串(保留意图)→ 等下一个别名变动时,再把符合条件的人卷回新别名(跟随升级)→ 期间一直只读写 userSettings,不动其它层,不读 merged。同样的模式可以扩到 fast mode 等正交维度。
四、改名、清理、重置:2 个剩下的迁移
最后一组的 2 个迁移不属于上面两类,但都演示了"配置迁移即代码"这种风格下一些独特的小手段。
4.1 migrateReplBridgeEnabledToRemoteControlAtStartup:改键名
最简单的一段,22 行。要做的事是把 GlobalConfig 里那个名叫 replBridgeEnabled 的键改成 remoteControlAtStartup——前者是实现细节漏到用户配置里,后者更直白。整段就是一次 saveGlobalConfig(migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.ts:11-21):
saveGlobalConfig(prev => {
// The old key is no longer in the GlobalConfig type, so access it via
// an untyped cast. Only migrate if the old key exists and the new key
// hasn't been set yet.
const oldValue = (prev as Record<string, unknown>)['replBridgeEnabled']
if (oldValue === undefined) return prev
if (prev.remoteControlAtStartup !== undefined) return prev
const next = { ...prev, remoteControlAtStartup: Boolean(oldValue) }
delete (next as Record<string, unknown>)['replBridgeEnabled']
return next
})
注意三件事:旧键已经从 GlobalConfig 的 TypeScript 类型里删掉了,所以读它的时候用 (prev as Record<string, unknown>)['replBridgeEnabled'] 这种穿透写法(类型已经"忘了"它,但运行时数据里它可能还在);同等性短路两道——旧键不存在直接返回 prev,新键已经设过也返回 prev,避免覆盖用户后来主动改过的值;最后 delete 改写在新对象 next 上而不是原对象 prev 上,保住 immutable updater 的语义。
这一段的"教学价值"在于:当你删除一个已经从源码 type 里抹掉的字段时,类型系统帮不了你,但你不能装作它不存在。 Claude Code 选择用 Record<string, unknown> 这种最朴素的穿透写法直面这个事实,而不是另外造一个"legacy type"去维护。
4.2 resetAutoModeOptInForDefaultOffer:重新弹一次老弹窗
migrations/resetAutoModeOptInForDefaultOffer.ts 服务的场景很窄。它要做的事是:之前用户在两选项的 AutoModeOptInDialog 弹窗里选了"接受"、于是 skipAutoPermissionPrompt 被设上了;后来这个弹窗加了第三个选项"把这个当我的默认模式"——为了让那些之前接受过老弹窗的人也能看到新选项,需要清掉那个标记位,让弹窗重新弹一次。
这一段独特在它的守门员条件比别的迁移多得多(migrations/resetAutoModeOptInForDefaultOffer.ts:26-36):
if (feature('TRANSCRIPT_CLASSIFIER')) {
const config = getGlobalConfig()
if (config.hasResetAutoModeOptInForDefaultOffer) return
if (getAutoModeEnabledState() !== 'enabled') return
try {
const user = getSettingsForSource('userSettings')
if (
user?.skipAutoPermissionPrompt &&
user?.permissions?.defaultMode !== 'auto'
) {
updateSettingsForSource('userSettings', {
skipAutoPermissionPrompt: undefined,
})
外层一个 feature flag、一个 completion flag(防止反复重置)、一个 getAutoModeEnabledState() === 'enabled' 的精确状态闸。这里的关键在源码里那段长注释解释的:如果对 'opt-in' 状态的用户也清这个标记,会因为 permissionSetup.ts:988 的另一段逻辑,反而把 auto 模式从权限轮播里去掉、弹窗变得永远不可达——这个迁移就会自我反噬。它只对 'enabled' 状态的人生效,是为了避开那条状态机的反相边。为了一个看似简单的清理动作得反向追到另一段状态机里,这种小心翼翼是迁移代码里最容易出错、也最不能省的地方。
completion flag 的落盘
这一段还有一个细节值得指出。它落 completion flag 的时候用了一个嵌套同等性短路:
saveGlobalConfig(c => {
if (c.hasResetAutoModeOptInForDefaultOffer) return c
return { ...c, hasResetAutoModeOptInForDefaultOffer: true }
})
外层流水线已经有一个版本号守护,为什么这里还要再立一个 hasResetAutoModeOptInForDefaultOffer?因为这段被 feature('TRANSCRIPT_CLASSIFIER') flag 圈起来——如果未来这个 feature 被开关切回去,外层版本号已经"以为这一段跑过",再切回去就跑不到了;只有迁移自带的 completion flag 才能精准记下"这一段对这个用户究竟有没有真的兑现过"。换句话说,外层流水线的版本号管"这一轮",迁移内部的 completion flag 管"这一段对这一个用户"——两个层次的幂等彼此独立,不冲突也不可互相替代。
总结:贯穿 11 个文件的几条纪律
把 11 个迁移逐个读完之后,你会发现 migrations/ 目录提炼出的是几条贯穿全部文件的纪律。
第一,「配置迁移即代码」是对"声明式迁移 DSL"这条路的明确拒绝。Claude Code 没有把迁移写成 JSON / YAML 配置、没有"from / to / when"这种字段,也没有一个迁移引擎去解释执行。每段迁移就是一个 TypeScript 函数——它能读源码里任何 helper、能调任何 feature flag、能查任何订阅类型、能拿到 in-memory override。把迁移当成普通代码来写,换来的就是"任意复杂的条件 / 任意精确的退出 / 任意细的遥测埋点"——上面 4.2 那种"为了避开另一段状态机的反相边而精确卡 'enabled'"的条件,没有 DSL 写得出来。
第二,所有迁移共享同一组幂等手法:读写同源、存在性短路、destructure-rest 删字段、同等性返回原引用避免空写。这一组手法不需要一个继承体系或抽象基类——每个文件 30 到 120 行内自己把它们用一遍,下一个写迁移的人照葫芦画瓢即可。
第三,外层版本号 + 内层 completion flag 是两个独立维度的幂等。外层管"这一轮发起人会不会再被叫",内层管"这一段对这个用户究竟有没有真的落过盘"。大部分迁移只靠外层 + 读写同源就够了;只有跨进程状态(in-memory override)、或者带 feature flag 开关(TRANSCRIPT_CLASSIFIER)的情况,才额外立一个内层 flag。
第四,迁移不能让错冒到 caller——但实现这一点的手段有两套,分布并不均匀。11 个迁移里只有 4 个 (migrateAutoUpdatesToSettings:25-60、migrateBypassPermissionsAcceptedToSettings:21-39、migrateEnableAllProjectMcpServersToSettings:33-117、resetAutoModeOptInForDefaultOffer:31-49) 显式地用 try/catch 把 settings/config 写入吞住、走 logError;剩下 7 个 (migrateSonnet1mToSonnet45:31-47、migrateLegacyOpusToCurrent:48-56、migrateSonnet45ToSonnet46:48-66、migrateOpusToOpus1m:34-42、migrateFennecToOpus:23-44、migrateReplBridgeEnabledToRemoteControlAtStartup:11-21、resetProToOpusDefault:18-50) 没有任何 try/catch——它们靠的是幂等 + 早退:写入要么命中"读写同源 → 下一次走早退",要么命中"completion flag 已立 → 下一次走早退",所以即使中途异常,下一次启动重跑同一段也不会比跑一次更糟。runMigrations 顶部的版本号比对只看"等不等于",不看"大于小于"——失败那一轮的版本号没写成功,下次启动会被外层重新触发一遍;成功那一轮的版本号写上之后,整组迁移就此沉默。两套机制合起来,效果就是"整组迁移设计目标是重复跑一万次也不会比跑一次更糟",但实现路径不是统一的 try/catch,而是显式 try/catch(4 段)+ 幂等早退靠版本号兜底(7 段)。
第五,模型迁移这一类把"用户当时的意图"作为不可侵犯的不变量来守护。改写字符串那 5 段都只读写 userSettings、不读 merged、不动 project/local/policy、对新装的人不打扰、能用默认就清字段不写显式串;唯一的例外是 resetProToOpusDefault——它不改字符串、只决定横幅时间戳,因此可以用 getSettings_DEPRECATED() 读 merged 来判断"用户的默认是不是没设过",且不会触发那条 silent global promotion。这一整套小心思加起来,让 Claude Code 可以在不挑事的前提下一年里把模型别名洗几次牌。
这一章想留下的最直白的一句话是:当一段产品逻辑要在用户身后改用户的配置时,它要么足够小心、要么干脆别改。migrations/ 目录里的 11 个文件示范的是前者,每一个文件都把"我改了什么、为什么改、怎么保证不出错、怎么保证下次不再误改"写在了同一处。读完这 11 个文件,你也就读完了 Claude Code 处理"产品在用户身后偷偷换零件"这件事的全部答案。
下一章预告
第 5 章:QueryEngine 与对话主循环 — 一次完整 AI 交互的心跳
我们将深入 QueryEngine 与 query.ts 这 1729 行的核心文件,揭示一次完整的 AI 对话是如何被驱动的——从消息组装、API 调用、工具执行到错误恢复。