配置系统
settings.json 的分层、合并规则、资源目录与环境变量
Pi 的配置面看着大,实际结构简单得出奇:只有两个文件、一种格式、一套合并规则。绝大多数「我改了配置但没生效」的问题,都出在这三个词上——文件、格式、合并。
先把结论摆出来,因为它决定了本文的读法:
Pi 的配置是 JSON,不是 YAML。配置分两层:用户层的
~/.pi/agent/settings.json和项目层的<工作目录>/.pi/settings.json。项目层覆盖用户层,但有例外:资源列表(扩展、Skill、Prompt Template、Theme、Package)是合并而不是替换,defaultTools另有一套自己的合并规则。
这一节先把「两层 + 合并规则」讲透,后面几节再逐类拆设置项。
5.1 两层配置与合并规则
两个文件,位置是固定的
用户层的根目录叫 agent 目录,默认是 ~/.pi/agent。它可以改:
# 改 agent 目录的位置
PI_CODING_AGENT_DIR=/path/to/agent pi也可以通过 SDK 的 agentDir 选项指定。整篇专栏里出现的所有 ~/.pi/agent/... 路径,都应该理解成「agent 目录下的 ...」,而不是固定的 home 路径。
项目层受 项目信任 门禁保护。只有一个例外:sessionDir。 官方文档写得很直接:
Project configuration lives in
.piunder the working directory and loads after project trust is granted. The only exception issessionDir, which Pi reads before resolving trust so it can locate sessions.
这个例外的后果在 工具与信任边界 里讲过:拒绝信任也回退不了这一次读取。
合并规则:深合并,但数组整体替换
两层配置不是简单的「项目覆盖用户」。实际规则由一个函数决定:
// packages/coding-agent/src/core/settings-manager.ts
function deepMergeObjects(base: Record<string, unknown>, overrides: Record<string, unknown>) {
const result = { ...base };
for (const key of Object.keys(overrides)) {
const overrideValue = overrides[key];
if (overrideValue === undefined) continue; // undefined 跳过
const baseValue = base[key];
result[key] =
isMergeableObject(baseValue) && isMergeableObject(overrideValue)
? deepMergeObjects(baseValue, overrideValue) // 嵌套对象递归合并
: overrideValue; // 数组与标量整体替换
}
return result;
}拆成四条可操作的规则:
两条容易被忽略的细节:
第一,嵌套对象是合并的。 这意味着项目层写 "compaction": { "enabled": false } 不会把用户在 compaction 下的其它键清掉——它只覆盖 enabled 这一个键,reserveTokens 和 keepRecentTokens 仍然继承用户层。
第二,undefined 被跳过,但 null 不是。 源码里 if (overrideValue === undefined) continue; 明确跳过 undefined。所以「在项目层把某个设置清掉」这件事做不到——你无法用配置把继承来的值重置为「未设置」。想覆盖成一个值,只能写那个值本身。
三类只在用户层生效的设置
有几个键只能写在 agent 目录的 settings.json 里,写在项目层会被忽略:
这不是文档约定,源码里读的就是用户层:
// settings-manager.ts
getDefaultProjectTrust(): DefaultProjectTrust {
const value = this.globalSettings.defaultProjectTrust; // 不是 this.settings
...
}
getCacheWarmingMode(): CacheWarmingMode {
const mode = this.globalSettings.cacheWarming; // 不是 this.settings
...
}settings.md 的表格里对这几项都标注了 "Can only be set in agent-directory settings" 或 "Global setting only"。
三个改配置的入口
第三种容易误解。pi config 不是一个「列出所有配置项」的命令,而是一个资源选择器:它解析全局和项目两套 Package 声明,让你决定每个包里哪些扩展、Skill、Prompt、Theme 要加载。pi config --help 会打印用法;pi install / pi remove / pi list 才是包管理命令。
给编辑器补全
settings.json 支持一个 $schema 键:
{
"$schema": "https://pi.dev/schemas/settings.schema.json"
}加上它,VS Code 之类的编辑器就能给出补全和校验。这张 schema 是从 TypeScript 源码生成的(schema 文件头部写着 "This file is generated from TypeScript source. Do not edit it manually"),所以它和源码永远一致。
一个完整的例子
{
"$schema": "https://pi.dev/schemas/settings.schema.json",
"defaultThinkingLevel": "high",
"defaultTools": ["read", "bash", "edit", "write", "+grep"],
"compaction": {
"reserveTokens": 24576
},
"skills": ["./skills", "!./skills/experimental"]
}这段配置做了三件事:提高默认思考等级、在默认四个工具之上加上 grep(5.4 会讲清楚同一个列表里纯名和增量项是怎么配合的)、把压缩预留从 16384 提到 24576,同时在 Skill 列表里排除了一个目录。
5.2 agent 目录里有什么
agent 目录不只是「放配置文件的地方」。它是 Pi 的用户级状态根目录:凭据、会话、下载的二进制、信任记录、缓存都在这儿。
文件
trust.json 值得单独说一句:它是用户级单文件,项目侧没有对应的标记文件。查找规则是从当前目录逐级向上找最近的一条决定。细节在 工具与信任边界 里。
目录
项目侧的对应物
工作目录下的 .pi/ 是项目层的镜像,结构一一对应:
两个 .md 文件的覆盖规则是官方文档里明确写的一条:
For
SYSTEM.mdandAPPEND_SYSTEM.md, the trusted project file takes precedence over the corresponding agent-directory file. Files with the same name are not combined.
也就是说 agent 目录有 APPEND_SYSTEM.md、项目也有 APPEND_SYSTEM.md 时,项目那份完全取代 agent 那份,两份不会拼在一起。想要叠加,得自己在文件里写全。
上下文文件不在 .pi/ 里
AGENTS.md、AGENTS.override.md、CLAUDE.md 这几个不是 .pi/ 的成员。它们是上下文文件,从 agent 目录、工作目录以及工作目录的所有祖先目录加载,一个文件在它所在的目录及以下都生效。
两条规则:
AGENTS.override.md只在同一个目录里替换AGENTS.md或CLAUDE.md,不影响 agent 目录或其它目录的上下文文件。- 上下文文件的发现不需要项目信任。 拒绝信任一个项目,它目录里的
AGENTS.md照样会被读进系统提示。
第二条是安全上最容易被忽略的一条:project trust 防的是代码执行,不是提示注入。细节在 工具与信任边界 里展开。
5.3 模型与思考等级
模型相关的设置分三类:选哪个模型、思考多深、缓存与显示。
三个需要展开的点。
思考等级有七档,但预算只覆盖四档
defaultThinkingLevel 的枚举是七档:off、minimal、low、medium、high、xhigh、max。
但 thinkingBudgets 的 schema 只有四个键:
// packages/coding-agent/src/core/settings-schema.ts
const ThinkingBudgetsSettingsSchema = thinkingBudgetsSettings();
// → { minimal?, low?, medium?, high? }也就是说 off 和 xhigh(以及 max)没有可配置的 token 预算。你只能改这四档中每一档花多少 token,off 是关闭,xhigh/max 用的是供应商自己那一档的默认预算。写 thinkingBudgets: { "xhigh": 5000 } 不会报错——JSON Schema 没给这个对象设 additionalProperties: false——但这个键从来不会被读,运行时只认那四档。
这个缺口的实际影响很小,但值得知道:如果你觉得 xhigh 太贵,你唯一能做的是降档到 high,而不是给 xhigh 配一个小预算。
每模型覆盖是精确键控的
modelThinkingLevels 和 compaction.modelOverrides 都用 provider/modelId 这种精确字符串做键,没有通配:
{
"modelThinkingLevels": {
"anthropic/claude-sonnet-4": "high",
"openai/gpt-5": "low"
}
}键必须完全等于 provider/modelId。写 anthropic/claude-* 不会生效——没有模式匹配。
cacheWarming 的两个约束
cacheWarming 默认 "streaming":在活跃的运行期间保持符合条件的供应商 prompt 缓存温热。"idle" 额外覆盖两次运行之间的空档期。
但它有两条硬约束,源码和文档都写了:
- 只在模型声明了缓存生命周期时运行,而且 Pi 估算的「省下的缓存未命中成本」至少要值 0.05 美元。
- 只在全局设置里生效。 源码注释写得很直白:
/** Read from global settings only because warming costs money. */
预热的用量会计入会话统计,但不进模型上下文。/session 会显示下一次决策;扩展可以用 cache_warming_decision 事件覆盖它。
5.4 工具选择:defaultTools 的两种写法
这是整个配置系统里最容易踩坑的一处,所以单独一节。
defaultTools 是一个字符串数组,数组里的条目分两类:
先分清两件事:能不能混,和混了会怎样
这是本文最需要说清楚的一点,因为「纯名列表 vs 增量列表,不能混用,混用报错」这句话只对一半的场景成立:
settings.json 这一侧的官方原文是:
In one list, plain names form the selection, and
+nameand-namethen apply in order.
翻译过来就是:同一个列表里,纯名先形成选择,然后增量项按顺序在这个选择上生效。 所以下面这个写法在 settings.json 里是合法的:
{
"defaultTools": ["read", "bash", "edit", "write", "+grep"]
}它等价于「四个默认工具 + 一个 grep」。
而 --tools 那一侧的校验函数长这样:
// packages/coding-agent/src/core/settings-manager.ts:97-104
export function getToolListError(entries: readonly string[]): string | undefined {
const modifiers = entries.filter(isToolModifier);
if (modifiers.length === 0) return undefined;
if (modifiers.length < entries.length) return "tool names cannot be mixed with +name or -name entries";
const pattern = modifiers.find((entry) => entry.includes("*"));
if (pattern) return `+name and -name entries take exact tool names, not patterns: ${pattern}`;
return undefined;
}三行代码三条规则:
- 混用会报错。 报错文案是
tool names cannot be mixed with +name or -name entries。pi --tools read,+grep是不合法的。 - 增量形式只接受精确名字。
+grep*不行,报错文案是+name and -name entries take exact tool names, not patterns: ...。想按模式禁用要用--exclude-tools。 - 全是纯名字时,第二行不触发,所以纯名单永远合法。
关键在于这个函数的调用点只有两处——src/cli/args.ts:156(--tools)和 src/core/sdk.ts:280(SDK 的 tools)。settings.json 走的是另一条路径,压根不经过它。所以「混用报错」这条规则不会在配置文件里触发。
实践上的推论:
在
settings.json里放心混用;在命令行上老老实实二选一。
写命令行时想同时表达「保留默认」和「加一个工具」,用纯增量形式 pi --tools +codemode,-write;想指定完整名单,就写纯名字并把想要的都列上。
两层之间怎么合并
这是最容易搞混的地方:同一个数组,在用户层和项目层里的语义不一样。
合并函数长这样:
// settings-manager.ts:126-131
function mergeDefaultTools(base: string[] | undefined, overrides: string[] | undefined): string[] | undefined {
if (overrides === undefined) return base;
// Settings files are not validated; a malformed value replaces instead of throwing here.
if (!Array.isArray(base) || !Array.isArray(overrides) || !overrides.every(isToolModifier)) return overrides;
return [...base, ...overrides];
}读一下这个函数体:
overrides全是+name/-name→ 走最后一行,拼接到 base 后面。overrides里含任何纯名字 → 走return overrides,整体替换 base。
所以:
这就是官方文档那句话的精确含义:
Project settings apply on top of user settings: a project list with only
+nameand-nameentries changes the user's selection, and a project list with a plain name replaces it.
实践含义:如果你的项目配置里要调整工具集合,用纯增量写法。这样它在用户层之上做加法,不会把用户在全局里精心加的工具清掉。反过来,如果你确实想让项目完全接管工具集合,就写纯名单,并且清楚这会覆盖全局。
完整的演算
假设用户层是:
{
"defaultTools": ["read", "bash", "edit", "write", "+codemode"]
}解析过程分两步。第一步,合并两层:
base = ["read", "bash", "edit", "write", "+codemode"]
项目层 = ["-bash", "+grep"]
overrides 全是 modifier → 拼接
merged = ["read", "bash", "edit", "write", "+codemode", "-bash", "+grep"]第二步,把合并结果解析成最终名单:
// settings-manager.ts:137-140
function resolveDefaultTools(entries: string[]): string[] {
const plain = entries.filter((entry) => !isToolModifier(entry));
return applyToolModifiers(plain.length > 0 || entries.length === 0 ? plain : DEFAULT_TOOL_NAMES, entries);
}plain = ["read", "bash", "edit", "write"] 非 modifier 项
base = plain(plain 非空)
按顺序应用 modifier:
"+codemode" → 加入 codemode
"-bash" → 移除 bash
"+grep" → 加入 grep
结果 = ["read", "edit", "write", "codemode", "grep"]注意 applyToolModifiers 是按列表顺序执行的。所以 ["-bash", "+bash"] 会得到 bash(先删后加),而 ["+bash", "-bash"] 会得到没有 bash(先加后删)。顺序有意义。
可用的名字
defaultTools 里能写的名字:
codemode 和 tool_search 默认是关闭的,因为内置扩展注册它们时带了 defaultActive: false——「注册即激活」对 direct 和 model-only 这两种曝光成立,但这两个工具显式关掉了它。你可以在 defaultTools 里写 "+codemode" 打开它们。
空数组的含义:"defaultTools": [] 会关掉所有内置工具,但不影响扩展和 SDK 注册的工具。这一点和 --no-tools 不一样——--no-tools 关的是内置、扩展、自定义、MCP 工具全部。
/reload 与命令行的优先级
三条规则,官方文档写得很清楚:
/reload会启用defaultTools里新增的工具,但不会禁掉用-name关掉的工具,也不会把你手动关掉的工具重新打开。--tools(纯名单)、--no-tools、--no-builtin-tools会覆盖defaultTools,而且在 reload 之后依然覆盖。--tools只带+name/-name时不是白名单,而是修改解析后的defaultTools选择;reload 时这些增量项照样生效,所以用-name关掉的工具保持关闭。
这三条合起来给出一个可操作的结论:defaultTools 是「基线」,不是「锁定」。 它决定启动时的选择,/reload 之后仍然由命令行开关和 reload 的语义决定最终状态。
想完全掌控某一次运行,用命令行:
pi --tools read,grep,find,ls --print "Review this project"命令行的四个开关在 工具与信任边界 里逐个讲过,本文不重复。那里还讲了 --tools 与 MCP 工具的交互——--tools 选的是声明给模型的工具,不负责移除 MCP 工具。
一个反直觉的细节
源码注释里有一句:
Settings files are not validated; a malformed value replaces instead of throwing here.
也就是说,写进 settings.json 的值不做校验。defaultTools 写成一个字符串而不是数组,不会抛错,它会「替换」而不是「报错」。
这一条在 工具与信任边界 里有更完整的讨论,包括它和 --tools 白名单的区别。
5.5 界面与显示
这一节的设置项最多,但它们有个共同点:改错了顶多难看,不会坏。 所以本文只给完整默认值表,不逐个展开。
完整默认值表
几个值得注意的点
quietStartup 是三态的。 不只是开和关,中间那档 "header" 常被忽略:它保留版本号和快捷键提示(新手需要),但隐藏「当前模型范围」那一行和已加载资源清单(老手不需要,而且刷屏)。在 CI 或录屏场景里 "header" 往往比 true 更合适。
fullscreen* 四个设置在 regular 模式下全部无效。 schema 描述里直接写着 "No effect in regular TUI mode."。tuiMode 默认是 "fullscreen",所以它们默认是生效的;一旦你切到 "regular",这四个键就是死设置。
showHardwareCursor 有环境变量兜底。 源码里是:
getShowHardwareCursor(): boolean {
return this.settings.showHardwareCursor ?? process.env.PI_HARDWARE_CURSOR === "1";
}设置项优先于环境变量。环境变量那一层在 5.8 讲。
terminal.* 这一组是「覆盖探测结果」的。 Pi 会自己探测终端能力(支不支持真彩色、用的是 kitty 还是 iTerm2 的图片协议、有没有超链接),这几个键让你在探测不准则时手动纠正。同名的环境变量(PI_TRUE_COLOR、PI_IMAGE_PROTOCOL、PI_HYPERLINKS)也在 5.8 讲。
theme 的值里不能有 /。 因为 / 被保留给「浅色/深色自动配对」的写法("light/dark")。主题机制本身在 Skills、Prompt Templates 与 Themes 里展开。
5.6 会话与压缩
会话
sessionDir 的优先级链有三层,从高到低:
--session-dir 命令行开关
> PI_CODING_AGENT_SESSION_DIR 环境变量
> settings.json 里的 sessionDir 设置官方文档一句话写完:PI_CODING_AGENT_SESSION_DIR and --session-dir override this setting。
sessionDir 也是那个「在信任判定之前就被读取」的例外。实践含义是:如果你在不可信的目录里跑 Pi,不要指望拒绝信任能保证会话文件写到你想让它去的地方。
压缩
三个数字的关系需要说清楚,因为它们不是同一层的量:
reserveTokens = 16384是触发的余量,不是百分比。触发条件是contextTokens > contextWindow - reserveTokens。keepRecentTokens = 20000是压缩时保留多少近期内容。它和reserveTokens是两个独立的旋钮:前者决定摘要后留什么,后者决定什么时候动手。branchSummary.reserveTokens = 16384是另一条路径——分支摘要,不是上下文压缩。
一个实践上的推论:keepRecentTokens 设得比 reserveTokens 大是允许的,源码不会拦(两者只校验「非负安全整数」)。但如果 keepRecentTokens 大到超过触发时刻的可用上下文,压缩会没有空间可省。默认值 20000 > 16384 就是这个关系,官方显然认为这样是合理的——留 20000 token 近期内容、预留 16384 余量,两者相加约 3.6 万 token,对绝大多数模型来说不宽裕但够用。
每个值的解析顺序
压缩的 token 值有三级回退:
模型覆盖(compaction.modelOverrides["provider/modelId"])
→ 普通设置(compaction.reserveTokens / keepRecentTokens)
→ 内置默认值(16384 / 20000)官方文档说得更精确:「Each value resolves independently from the matching model override, then the ordinary compaction setting, then the built-in default. Project and user objects merge before model lookup.」
最后半句很重要:compaction 这个对象在项目层和用户层先合并,然后才按模型查覆盖。所以项目层写 "compaction": {"modelOverrides": {...}} 不会把用户层的 modelOverrides 整块顶掉——两个对象的键会先合并。
三级回退的完整机制在 上下文与压缩 里展开。
5.7 资源加载
五个资源数组
资源列表是合并的,不是替换的
这是配置系统里第二条「例外规则」。前面说过标量和嵌套对象怎么合并,数组一般整体替换——但这五个资源数组是合并的。
源码在包管理器里。解析时它同时读两层:
// packages/coding-agent/src/core/package-manager.ts:947-973
for (const resourceType of RESOURCE_TYPES) {
const target = this.getTargetMap(accumulator, resourceType);
const globalEntries = (globalSettings[resourceType] ?? []) as string[];
const projectEntries = (projectSettings[resourceType] ?? []) as string[];
this.resolveLocalEntries(projectEntries, resourceType, target, { scope: "project", ... }, projectBaseDir);
this.resolveLocalEntries(globalEntries, resourceType, target, { scope: "user", ... }, globalBaseDir);
}两层都写进同一个累加器。所以:
- 用户层写
"skills": ["./my-skills"],项目层写"skills": ["./team-skills"]→ 两个都加载。 - 项目层先解析,所以同名冲突时项目优先。
packages 走的是同一条路的上一段:包来源先被解析进同一个累加器(项目包在前、全局包在后,同一个包以项目作用域为准去重),然后才是设置里显式声明的路径,最后是自动发现的目录。整个解析过程里,资源的先后顺序就是「先到的赢」。
路径解析的基准也不同:
绝对路径和 ~ 都支持。
三种精确操作语法
资源数组的每一项可以是四种形态之一:
生效顺序是固定的四步:
// packages/coding-agent/src/core/package-manager.ts:743-797
/**
* Apply patterns to paths and return a Set of enabled paths.
* Pattern types:
* - Plain patterns: include matching paths
* - `!pattern`: exclude matching paths
* - `+path`: force-include exact path (overrides exclusions)
* - `-path`: force-exclude exact path (overrides force-includes)
*/第 1 步:普通模式包含(没有普通模式时 = 全部)
第 2 步:`!pattern` 排除
第 3 步:`+path` 强制包含(从全集里加回来,压过排除)
第 4 步:`-path` 强制排除(压过一切)关键差别在匹配方式:!pattern 走的是 glob 匹配,+path 和 -path 走的是精确路径匹配。精确匹配对 Skill 文件还有一条特殊规则——匹配到 SKILL.md 时,父目录也算命中,因为一个 Skill 的单位是目录。
这套语义的实际用途是做例外。比如你在全局配置里引入了整个团队技能库,但某个项目只想排除其中一个:
{
"skills": ["~/team-skills", "!~/team-skills/experimental"]
}反过来,某个被全局排除的 Skill,在某个项目里要单独启用:
{
"skills": ["+~/team-skills/experimental/ocr/SKILL.md"]
}内置扩展也在这个列表里
四个内置扩展在 extensions 设置里有名:
它们默认加载。要关掉一个,写 -builtin:mcp。项目层写 +builtin:<name> 或 -builtin:<name> 会覆盖用户层的决定。
Skill 命令开关
enableSkillCommands 默认 true,作用范围很窄:
Register skills as
/skill:namecommands.
关掉它之后,Skill 不会出现在交互式命令发现(自动补全)里,但手打 /skill:name 仍然可用。这是源码里两处独立逻辑决定的——一个控制自动补全列表,一个控制实际执行。
自动发现的目录
除了设置里显式写的路径,Pi 还会自动发现这些目录:
最后一条是 Agent Skills 规范的兼容位:.agents/skills/ 从当前目录一路往上找,任意一级存在就会被发现,但存在 git 仓库根目录时就在那里停下,不会一路爬到文件系统根。
~/.agents/skills/ 有一个例外规则:即使你的工作目录就是 $HOME 本身,它也永远被当作可信的用户资源。
这些自动发现的目录同样受设置里的 ! / + / - 语法控制。
5.8 环境变量
环境变量在 Pi 里有三种完全不同的用途,混起来是常见困惑的来源:
第三类最容易被误解,下面单独说。
Pi 自己读的配置变量
供应商的凭据变量(ANTHROPIC_API_KEY、OPENAI_API_KEY 等)不在这里,单独列在 模型与供应商 里。
两个进程标记
CLI 和 RPC 入口会设置两个标记:
子进程会继承这两个标记。它们不是会话相关的,而且通过 SDK 嵌入 Pi 时不会自动设置。
shell 工具的会话环境:五个变量
这是第三类,也是最容易被写成「Pi 的配置环境变量」的一类。真相是:
这五个变量只注入给 LLM 通过
bash/powershell工具跑的命令。它们不是 Pi 的配置开关,Pi 自己不读它们。
三条使用约束:
- 值在每条命令开始时解析。 所以换模型或改思考等级会影响下一条 shell 命令,不用重启 Pi。
PI_PROVIDER和PI_MODEL指的是 Pi 选中的模型,不是路由器内部可能选的上游模型。- 它们只注入给 LLM 调用的
bash/powershell。用户手打的!或!!命令不会拿到。
第 3 条的源码依据在 bash.ts 里:工具启动子进程前会先把继承来的这五个变量全部删掉,然后再按需逐个赋新值。所以父进程里残留的旧值不会漏进子命令。
所以官方文档给的用法是「让模型自己检查这些变量,而不是从系统提示里推断」:
printf '%s/%s\n' "$PI_PROVIDER" "$PI_MODEL"
printf 'reasoning=%s session=%s\n' "$PI_REASONING_LEVEL" "$PI_SESSION_ID"换模型要写进 settings 的是 defaultProvider / defaultModel(或 /model 命令),不是 PI_PROVIDER / PI_MODEL。 后两个只是给 shell 工具的只读信息。
还有一个不存在的环境变量值得点名:PI_THEME。主题是 settings.json 里的 theme 键,或者一次性的 --use-theme 开关。
自定义 shell 工具的环境
用 createBashTool() / createPowerShellTool() 造的工具,默认也会暴露会话环境。注入发生在 spawnHook 之前,所以钩子能在 ctx.env 里看到这些变量:
const bashTool = createBashTool(cwd, {
spawnHook: (ctx) => ({ ...ctx, env: { ...ctx.env, CI: "1" } }),
});要独立关掉会话元数据(避免嵌套 Pi 进程暴露父会话的陈旧信息),用 exposeSessionEnvironment: false:
const powershellTool = createPowerShellTool(cwd, {
exposeSessionEnvironment: false,
spawnHook: (ctx) => ctx,
});5.9 加载期的自动迁移
Pi 有两套完全不同的迁移机制,混淆它们是旧资料里最常见的错误来源之一:
migrateSettings:4 处内存重写
读 settings.json 时,Pi 会对解析出的对象做四种重写:
源码里 migrateSettings 就这四个 if 块,最后 return settings as Settings。
它不写盘。 迁移后的对象只存在于内存里。只有当你在会话里改了这个设置、触发 save() 时,整个文件才会被重新序列化写回——那时写出去的是已经迁移过的形态。
它不备份。 没有 .bak 文件,没有时间戳副本。
它不打日志。 没有「迁移了 X → Y」这类输出。
这三点合起来推翻了旧资料里「Pi 启动时会备份配置并打印迁移日志」的说法。三条都不成立。
第 4 条重写还有个小细节:它会删掉 retry.maxDelayMs 这个键(delete retrySettings.maxDelayMs;),即使目标值没写进去。因为迁移只发生在内存里,这个删除对磁盘上的文件不生效——只有当 settings.json 因为别的原因被整体重写时,磁盘上的旧键才真正消失。
runMigrations:5 个动磁盘的动作
启动时跑的迁移在另一个文件里,它们真的会改文件系统:
第 1 条是唯一一处留下痕迹的迁移——oauth.json 会被改名成 oauth.json.migrated 而不是删除。但注意这只是「改名」,不是备份机制:其它所有迁移都是移动或原地重写,没有一个会留副本。
第 5 条是唯一一处真的会打印的地方,但它打印的是弃用警告,不是迁移日志:
warnings.push(`${label} hooks/ directory found. Hooks have been renamed to extensions.`);
warnings.push(`${label} tools/ directory contains custom tools. Custom tools have been merged into extensions.`);这些警告会显示在终端上,并等待一次按键才继续。
两条迁移的实际影响
第一,/reload 不会重跑迁移。 runMigrations 在 main.ts 的启动路径上跑一次;/reload 只重新读 settings、keybindings、指令和资源。如果你改了一个旧格式的设置键并 /reload,迁移会在读取时发生(migrateSettings),所以这一半是生效的;但磁盘级别的迁移(会话挪窝、二进制搬家)不会因为 /reload 而重跑。
第二,第 2 条迁移和 5.6 的 sessionDir 例外有同一个根源。 Pi 需要在信任判定之前就知道会话在哪、会话文件是什么。sessionDir 设置、项目 sessionDir 的提前读取、旧版 ~/.pi/agent/*.jsonl 的迁移,都服务于同一件事:尽快定位到会话存储。这也是为什么「拒绝信任」不等于「Pi 没碰过你的项目配置」。
5.10 事实来源
本文所有数字、开关名、默认值都可在仓库里复算:
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。