6.8 配置系统
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:
~/.pi/agent/settings.json和项目里的.pi/settings.json各管什么?两个文件冲突时谁赢?为什么有时候明明写了项目配置却「不生效」?命令行参数、环境变量、配置文件三者的优先级到底在哪一行代码里决定? 前置知识:5.1 启动:pi 命令如何跑起来、1.3 npm、package.json 与项目结构、2.8 Node.js 文件、路径与进程 API。 学习目标:读完后你能 ① 说出两个 settings.json 的路径以及决定它们位置的两个函数;② 复述deepMergeSettings的合并规则,并指出它「只合并一层」带来的一个真实后果;③ 解释项目信任(Project Trust)如何让.pi/settings.json整体归零,以及这个闸门在启动流程里的确切位置;④ 说明 Pi 为什么不用「CLI 参数改写 settings 对象」的做法,而是在每个消费点各自判优先级;⑤ 在不消耗任何 API Key 的前提下,搭一个隔离配置环境并亲眼看到配置合并与信任闸门的效果。
建立直觉:配置不是一个文件,是一条流水线
很多工具的配置系统可以用一句话概括:「读一个 JSON,用里面的值」。Pi 不行。一个配置值从磁盘走到真正影响行为的那一行代码,中间要经过五道关口:
- 两个文件——用户级的全局设置,和项目级的项目设置;
- 一次迁移——老版本写下的旧字段名会被就地翻译成新字段名;
- 一道闸门——项目没有被信任时,项目设置整份被丢弃;
- 一次合并——项目盖住全局,得到一份「当前有效设置」;
- N 个消费点——每个功能自己决定「命令行参数、环境变量、有效设置、硬编码默认值」谁优先。
这条流水线里最容易被误解的是第 5 步。读者很自然会猜:命令行参数应该是「先写进设置对象、再统一读」。Pi 不是这么做的(源码事实,下文给出证据),因此「优先级」这件事在 Pi 里没有唯一答案,而是每个消费点各写一遍。理解了这一点,很多「为什么这个 flag 覆盖了设置、那个没有」的困惑就解开了。
"global"(用户级,跟着你这个人走)和 "project"(项目级,跟着当前目录走)。类型声明就一行:export type SettingsScope = "global" | "project"(packages/coding-agent/src/core/settings-manager.ts:173)。读取时项目盖全局;写入时两个作用域各写各的文件,互不串味。 .pi/settings.json 会被当成 {} 处理——文件在、路径对、JSON 合法,但一个字段都不会进入合并结果。本章「信任闸门」一节给出源码位置,实践任务里你会亲眼看到这个效果。 最小示例:一层 spread 的合并会丢什么
Pi 的合并函数只有 20 多行,而且不依赖 Pi 的任何东西。先脱离源码把它抄成一个可运行的小脚本,存成 merge-demo.mjs:
function deepMergeSettings(base, overrides) {
const result = { ...base };
for (const key of Object.keys(overrides)) {
const overrideValue = overrides[key];
const baseValue = base[key];
if (overrideValue === undefined) continue;
if (
typeof overrideValue === "object" && overrideValue !== null && !Array.isArray(overrideValue) &&
typeof baseValue === "object" && baseValue !== null && !Array.isArray(baseValue)
) {
result[key] = { ...baseValue, ...overrideValue }; // 注意:只合并一层
} else {
result[key] = overrideValue; // 标量与数组整体覆盖
}
}
return result;
}
const globalSettings = { theme: "dark", compaction: { enabled: true, reserveTokens: 16384 }, retry: { enabled: true, provider: { maxRetries: 0, timeoutMs: 1000 } } };
const projectSettings = { compaction: { reserveTokens: 8192 }, retry: { provider: { maxRetries: 2 } } };
console.log(JSON.stringify(deepMergeSettings(globalSettings, projectSettings), null, 1));用 node merge-demo.mjs 运行,本书实测输出(Node 26):
{
"theme": "dark",
"compaction": {
"enabled": true,
"reserveTokens": 8192
},
"retry": {
"enabled": true,
"provider": {
"maxRetries": 2
}
}
}看两处对比。compaction 是一层嵌套:项目只写了 reserveTokens,enabled: true 被保留——这正是官方文档承诺的行为。retry 是两层嵌套:项目只写了 retry.provider.maxRetries,结果全局的 retry.provider.timeoutMs: 1000 消失了。原因就在 { ...baseValue, ...overrideValue } 这一行:它在 retry 这一层做展开,provider 作为一个整体的属性值被右边覆盖掉,函数并不会递归下去。
官方文档说明(packages/coding-agent/docs/settings.md:300)写的是「Nested objects are merged」,给出的示例(同文件 300-319 行)只嵌套一层,因此与实现一致;但两层以上的字段(retry.provider.* 之类)在项目层覆盖时是整块替换,这一点文档没有明说。实践任务里你会用真实的 SettingsManager 复现它。
回到 Pi 源码
两个文件在哪里
全局目录由一个函数决定,它也是本章实践任务的钥匙:
getAgentDir其中 .pi 这个目录名不是硬编码字面量,而是从 package.json 的 piConfig.configDir 读出来的(config.ts:491),环境变量名同样按 APP_NAME 拼出来(config.ts:495-496)——这是为了让 fork / 改名发行版能整体换目录(源码事实)。默认发行版下 APP_NAME 是 "pi"(config.ts:489),于是环境变量就叫 PI_CODING_AGENT_DIR。
两个 settings.json 的路径在存储层拼出来:
FileSettingsStorageSettingsStorage 接口只有一个方法 withLock(scope, fn)(settings-manager.ts:179-181):调用方给一个函数,拿到当前文件内容(不存在则是 undefined),返回新内容则写回、返回 undefined 则只读。文件实现用 proper-lockfile 加同步锁,遇到 ELOCKED 时最多重试 10 次、每次退避 20 毫秒(settings-manager.ts:199-224)。这层抽象让 packages/coding-agent/test/settings-manager.test.ts 可以完全在内存里跑(SettingsManager.inMemory,settings-manager.ts:343-348)。
/settings 改一个开关,都会触发「读文件 → 改字段 → 写回」。没有锁的话,两个进程各自读到旧内容、各自写回,后写的那个会把前一个的修改整段抹掉。锁只是第一层保险,第二层是下文的「字段级写合并」。 SettingsManager 内部的三份状态
SettingsManager 同时持有三个对象(settings-manager.ts:299-305):globalSettings(全局文件的内容)、projectSettings(项目文件的内容)、settings(合并结果)。读走合并结果,写只改其中一份——这个分工是理解后面所有行为的基础。
配置项清单就是一个 interface:
export interface Settings按用途把这 45 个字段归一下类(源码事实,行号即上面 interface 内的行):
| 分类 | 字段 | 行号 |
|---|---|---|
| 模型与思考 | defaultProvider、defaultModel、defaultThinkingLevel、hideThinkingBlock、enabledModels、thinkingBudgets | 85-87、95、115、118 |
| 请求与传输 | transport、retry、httpProxy、httpIdleTimeoutMs、websocketConnectTimeoutMs | 88、94、126-128 |
| 上下文 | compaction、branchSummary | 92-93 |
| 消息投递 | steeringMode、followUpMode | 89-90 |
| 界面与主题 | theme、quietStartup、terminal、images、doubleEscapeAction、treeFilterMode、editorPaddingX、outputPad、autocompleteMaxVisible、showHardwareCursor、markdown、warnings | 91、99、113-114、116-117、119-124 |
| 会话 | sessionDir | 125 |
| 外部程序 | externalEditor、shellPath、shellCommandPrefix、npmCommand | 97-98、101-102 |
| 资源加载 | packages、extensions、skills、prompts、themes、enableSkillCommands | 107-112 |
| 信任、遥测与提示 | lastChangelogVersion、showCacheMissNotices、defaultProjectTrust、collapseChangelog、enableInstallTelemetry、enableAnalytics、trackingId | 84、96、100、103-106 |
注意最后两类的位置:资源加载(extensions / skills / prompts / themes 的搜索路径)也走 settings,这就是 6.7 系统提示词与 Prompt Templates 与 7.2 Skill 系统 里那些「从哪里发现 skill」的起点;defaultProjectTrust 则被下一节的信任决策直接消费。
官方文档说明(packages/coding-agent/docs/settings.md)给出了每个字段的推荐默认值表;需要注意那张表写的是最终生效的默认值,不一定等于 SettingsManager 的 getter 返回值。例如 theme 在文档里标默认 "dark"(docs/settings.md:54),而 getThemeSetting()(settings-manager.ts:723-727)在没有配置时返回 undefined,"dark" 是更上层的主题解析补上的。
合并规则
deepMergeSettings调用点只有四处,全在 SettingsManager 内部:构造器(settings-manager.ts:305)、setProjectTrusted(466、476)、reload(504)、applyOverrides(509)。第一个参数永远是 globalSettings,第二个永远是 projectSettings——项目盖全局的方向是写死的,没有任何开关能反过来。
applyOverrides 值得单独说一句:它把任意一份 Partial<Settings> 再叠加到合并结果上、且不落盘。这看起来正是「CLI 参数覆盖配置」的理想工具,但全仓搜索(grep -rn "applyOverrides" packages/)显示,生产代码里没有一个调用者——命中的全是 packages/coding-agent/test/ 下的测试与 packages/coding-agent/examples/sdk/10-settings.ts 这个 SDK 示例(源码事实)。CLI 参数走的是另一条路,见下文「优先级」一节。
信任闸门
loadFromStorage开头这四行(350-353)就是「项目配置不生效」的全部原因。注意它是读取层的短路:不是读进来再过滤字段,而是压根不读。写入侧有对称的保护,assertProjectTrustedForWrite(settings-manager.ts:534-538)在未信任时直接抛错。
projectTrusted 这个布尔值本身由谁决定?
resolveProjectTrusted这个顺序里藏着一个循环依赖的解法:defaultProjectTrust 本身是一个配置项,但它只读全局设置(getDefaultProjectTrust,settings-manager.ts:899-902,直接访问 this.globalSettings 而非合并结果),否则「用项目配置决定要不要读项目配置」就成了鸡生蛋。非法值一律回落 "ask"。
闸门在启动流程里的位置比看上去微妙。main() 里前后一共造了三个 SettingsManager(源码事实):
main.ts:536的 bootstrap 实例,显式传{ projectTrusted: false },只为了尽早拿到httpProxy去配置 HTTP 客户端;main.ts:610的 startup 实例,没传projectTrusted,而fromStorage的默认值是true(settings-manager.ts:320)。它的用途在main.ts:620-624的注释里写明了:只用于会话查找期的sessionDir;main.ts:685的 runtime 实例,需要询问信任时先传false,随后由DefaultResourceLoader.reload()在拿到信任结论后调用setProjectTrusted(...)并重新加载(resource-loader.ts:395-402)。
据此推断(尚未在源码中直接证实这是有意设计):即使你用 --no-approve 明确拒绝信任,项目 .pi/settings.json 里的 sessionDir 仍可能影响启动期的会话查找,因为那一步用的是 startup 实例。本章实践任务的扩展步骤里能观察到这个实例确实读了项目文件——它会为损坏的 JSON 报一条带 startup session lookup 前缀的 warning。
写回:字段级写合并
persistScopedSettings这是本章最值得抄走的工程手法。朴素做法是 JSON.stringify(this.globalSettings) 整个写回,代价是:你在 pi 运行期间用编辑器手改了 settings.json 的另一个字段,pi 退出时会把你的修改覆盖掉。Pi 的做法是全程记账——每个 setter 都调用 markModified(field, nestedKey?)(settings-manager.ts:513-521)登记「我改过谁」,写盘时只应用登记过的字段。
// packages/coding-agent/src/core/settings-manager.ts:588-603(节选)
const mergedSettings: Settings = { ...currentFileSettings };
for (const field of modifiedFields) {
const value = snapshotSettings[field];
if (modifiedNestedFields.has(field) && typeof value === "object" && value !== null) {
// …(省略:只把登记过的 nestedKey 从内存值搬到磁盘值上,得到 mergedNested)
(mergedSettings as Record<string, unknown>)[field] = mergedNested;
} else {
(mergedSettings as Record<string, unknown>)[field] = value;
}
}写操作还被串成一条 Promise 队列(enqueueWrite,settings-manager.ts:556-568),失败不抛出而是记进 errors 数组,由 drainErrors() 取走(654-658)——启动期的 collectSettingsDiagnostics(main.ts:86-94)就是这么把配置错误变成一行 Warning: 的。相关测试:packages/coding-agent/test/settings-manager.test.ts 的 describe("preserves externally added settings") 三个用例专门断言了这条性质;本书实测该文件 34 个用例全部通过,不需要任何 API Key(命令见本章实践任务)。
迁移:老配置怎么办
migrateSettings(settings-manager.ts:381-440)在每次读取时就地翻译四种历史格式:queueMode → steeringMode、布尔 websockets → 枚举 transport、旧的 skills 对象格式 → 字符串数组、retry.maxDelayMs → retry.provider.maxRetryDelayMs。它的位置很关键——既在 loadFromStorage 里(settings-manager.ts:365),也在 persistScopedSettings 读回磁盘内容时(586)。也就是说,旧字段名在进入内存的那一刻就消失了,后续代码不需要知道历史包袱的存在。
图解
图 6.8-1 一个配置值从磁盘到生效的完整决策路径
阅读顺序:从上到下。上半段(A→H)全部发生在 SettingsManager 内部,对应 settings-manager.ts 的 loadFromStorage(350-366)与 deepMergeSettings(132-160);下半段(I→M)没有统一实现,J / K / L 三条分支的先后顺序由每个消费点各自决定——这正是本章要强调的:菱形节点 C 是全局唯一的闸门,而菱形节点 I 是几十处各写各的。
启动期那三个 SettingsManager 的先后关系,画成时序更清楚:
图 6.8-2 启动期三个 SettingsManager 实例与信任闸门的时序
关注两处。第一,bootstrap 实例(main.ts:536)和 runtime 实例(main.ts:685)都主动传了「不信任」,只有 startup 实例(main.ts:610)用了默认值 true——因为它诞生时信任还没被解析。第二,信任结论不是 SettingsManager 自己求出来的,而是 DefaultResourceLoader.reload() 通过回调求出后回灌给它(resource-loader.ts:395-402),随后 reload() 会按新的信任状态把两个文件重读一遍。
环境变量家族
除了 settings.json,Pi 还认一组环境变量。下面这张表的变量名来自真实采集的 pi --help 输出(research/cli-captures/pi-help.txt 的 Environment Variables 一节),源码位置是本书 Read 复核的结果:
| 环境变量 | 作用 | 源码位置 |
|---|---|---|
PI_CODING_AGENT_DIR | 覆盖全局配置目录(默认 ~/.pi/agent) | config.ts:495、config.ts:515-521 |
PI_CODING_AGENT_SESSION_DIR | 会话存储目录(被 --session-dir 覆盖) | config.ts:496、main.ts:625-629 |
PI_PACKAGE_DIR | 覆盖包目录(为 Nix/Guix 这类 store 路径准备) | config.ts:367-372 |
PI_OFFLINE | 设为 1/true/yes 时关闭启动期全部网络操作 | main.ts:524-528 |
PI_TELEMETRY | 覆盖安装遥测开关 | core/telemetry.ts:8-13 |
PI_SHARE_VIEWER_URL | /share 命令的查看器基址 | config.ts:502-508 |
还有几个没进 --help、只在源码里出现的(源码事实):PI_EXPERIMENTAL=1 打开实验特性(core/experimental.ts:2)、PI_SKIP_VERSION_CHECK=1 跳过版本检查(main.ts:527)、PI_STARTUP_BENCHMARK 打开启动耗时测量(main.ts:857)、PI_CLEAR_ON_SHRINK 与 PI_HARDWARE_CURSOR 作为两个 TUI 设置的兜底(settings-manager.ts:1098、1182)。
环境变量和 settings 的关系不统一,正好三种都有:
- 环境变量赢:
isInstallTelemetryEnabled里PI_TELEMETRY只要有定义就直接决定结果,settings 完全不看(core/telemetry.ts:12)。 - settings 赢:
getClearOnShrink()先看settings.terminal.clearOnShrink,未设置才落到PI_CLEAR_ON_SHRINK(settings-manager.ts:1093-1099,注释写得很直白:「Settings takes precedence, then env var, then default false」)。 - 各管一段:
PI_CODING_AGENT_DIR决定的是 settings.json 自己在哪,逻辑上先于任何配置项,谈不上谁覆盖谁。
优先级:CLI flag、环境变量与配置文件
现在可以正面回答开头那个问题了。Pi 没有一处「把 CLI 参数写进 settings」的代码——parseArgs 的结果是一个独立的 Args 对象(cli/args.ts:12-56),它被传给 buildSessionOptions(main.ts:405-501)转成 CreateAgentSessionOptions,与 SettingsManager 并列地交给下游。优先级在每个消费点用 if 写死。三个真实例子:
getSessionDirgetDefaultThinkingLevel第三个例子在交互界面:启动横幅的显示条件写成 this.options.verbose || !this.settingsManager.getQuietStartup()(modes/interactive/interactive-mode.ts:758)。也就是说 --verbose 是一个单向覆盖——它能强制打开横幅,但没有任何 flag 能强制关闭一个 quietStartup: false 的配置。
deepMergeSettings 的参数顺序写死)。CLI 与环境变量的位置由消费点决定:sessionDir 是 CLI 胜过环境变量、环境变量胜过设置;PI_TELEMETRY 是环境变量胜过设置;terminal.clearOnShrink 反过来是设置胜过环境变量。要判断某一项,最快的办法是搜它的 getter 名字,看唯一调用点那一行的 || 和 ??。 一种看法是:这种「分散判优先级」的写法让每个功能可以定制自己的语义(比如 --verbose 的单向覆盖),代价是没有单一事实来源,读者和维护者都必须逐点查证,也更容易出现不一致。本章的表格与源码索引就是为了降低这个查证成本。
两个改配置的入口
不想手写 JSON 的话,Pi 提供两条路(此处只作定位,界面细节留给 6.10 交互模式与 RPC 模式):
pi config:一个独立子命令,在main()解析常规参数之前就被拦截(main.ts:553),实现是handleConfigCommand(packages/coding-agent/src/package-manager-cli.ts:603-674)。它开一个终端界面(TUI,Terminal User Interface)让你启用 / 禁用来自包的资源,-l切到项目作用域(未信任时明确报错并退出,package-manager-cli.ts:648-652)。- 交互模式里的
/settings:真实采集的斜杠命令列表里可以看到这一项——→ settings Open settings menu(research/cli-captures/pi-tui-slash.txt:23)。它把 20 多个常用开关做成一个选择器(interactive-mode.ts:2692触发,配置项快照在interactive-mode.ts:4185-4202),每个回调调用对应的 setter,于是自动享受上文的字段级写合并。
实践任务
目标:在完全不碰你真实 ~/.pi 的前提下,亲眼看到 ① 项目设置盖住全局设置;② 未信任时项目设置整份归零;③ 两层嵌套字段被整块替换。全程不需要任何 API Key。
前置:本地有 Pi 源码仓库(本书锁定的 commit 即可)。下文用 $PI_REPO 表示仓库根目录的绝对路径,请先执行 export PI_REPO=/你的路径/pi。
步骤 1:造沙盒
mkdir -p /tmp/pi-lab/agent /tmp/pi-lab/proj/.pi
cat > /tmp/pi-lab/agent/settings.json <<'EOF'
{
"quietStartup": true,
"theme": "dark",
"retry": { "enabled": true, "provider": { "maxRetries": 0, "timeoutMs": 1000 } }
}
EOF
cat > /tmp/pi-lab/proj/.pi/settings.json <<'EOF'
{
"quietStartup": false,
"retry": { "provider": { "maxRetries": 2 } }
}
EOF步骤 2:写一个只读探针,存成 /tmp/pi-lab/probe.ts,把第一行的路径换成你的 $PI_REPO 实际值:
import { SettingsManager } from "/你的路径/pi/packages/coding-agent/src/core/settings-manager.ts";
const [agentDir, cwd, trustArg] = process.argv.slice(2);
const sm = SettingsManager.create(cwd, agentDir, { projectTrusted: trustArg !== "untrusted" });
console.log("trusted =", sm.isProjectTrusted());
console.log("global file =", JSON.stringify(sm.getGlobalSettings()));
console.log("project file =", JSON.stringify(sm.getProjectSettings()));
console.log("quietStartup =", sm.getQuietStartup());
console.log("providerRetry =", JSON.stringify(sm.getProviderRetrySettings()));步骤 3:跑两遍。用仓库自带的 tsx,不需要 build,也不会改动仓库里的任何文件:
cd $PI_REPO
./node_modules/.bin/tsx --tsconfig ./tsconfig.json /tmp/pi-lab/probe.ts /tmp/pi-lab/agent /tmp/pi-lab/proj
./node_modules/.bin/tsx --tsconfig ./tsconfig.json /tmp/pi-lab/probe.ts /tmp/pi-lab/agent /tmp/pi-lab/proj untrusted预期现象——第一条命令(受信任)本书实测输出:
trusted = true
global file = {"quietStartup":true,"theme":"dark","retry":{"enabled":true,"provider":{"maxRetries":0,"timeoutMs":1000}}}
project file = {"quietStartup":false,"retry":{"provider":{"maxRetries":2}}}
quietStartup = false
providerRetry = {"maxRetries":2,"maxRetryDelayMs":60000}第二条命令(不受信任)本书实测输出:
trusted = false
global file = {"quietStartup":true,"theme":"dark","retry":{"enabled":true,"provider":{"maxRetries":0,"timeoutMs":1000}}}
project file = {}
quietStartup = true
providerRetry = {"timeoutMs":1000,"maxRetries":0,"maxRetryDelayMs":60000}如何判断成功:三个断言都对上就算通过。① 受信任那次 quietStartup 是 false,项目盖住了全局的 true;② 不受信任那次 project file 是 {} 且 quietStartup 变回 true;③ 受信任那次 providerRetry 里没有 timeoutMs——全局的 retry.provider.timeoutMs: 1000 因为一层 spread 被整块替换掉了,而不受信任那次它又回来了。
扩展步骤(可选):让真实 CLI 跑在沙盒里
cd /tmp/pi-lab/proj
PI_CODING_AGENT_DIR=/tmp/pi-lab/agent PI_OFFLINE=1 $PI_REPO/pi-test.sh --no-env --list-models
ls -1 /tmp/pi-lab/agent本书实测:第一条命令因为没有凭证会打印 No models available. Use /login to log into a provider via OAuth or API key.;ls 会显示沙盒里多出一个 auth.json——证明 PI_CODING_AGENT_DIR 确实把整个配置目录重定向了,你的 ~/.pi/agent 一个字节都没动。想顺带验证「startup 实例会读项目文件」,把 /tmp/pi-lab/proj/.pi/settings.json 改成一段非法 JSON 再跑一次:加 --approve 时本书实测出现三条 Warning: ... project settings ...,加 --no-approve 时仍有一条,且前缀是 startup session lookup。
常见错误:① 忘了把 probe.ts 第一行的 import 改成绝对路径,相对路径解析不到 pi 源码;② 不在 $PI_REPO 目录下运行 tsx,会因为找不到 tsconfig.json 与依赖而报错;③ 步骤 1 的 heredoc 用了不带引号的形式,导致 JSON 内容被 shell 改写(保持 <<'EOF' 的单引号)。
对应源码位置:packages/coding-agent/src/config.ts:515-521(PI_CODING_AGENT_DIR)、settings-manager.ts:188-197(两个文件路径)、settings-manager.ts:350-366(信任闸门)、settings-manager.ts:132-160(合并规则)、settings-manager.ts:834-840(getProviderRetrySettings)。想再确认一遍这些行为不是本书杜撰,在 $PI_REPO 里跑 npm test --workspace=@earendil-works/pi-coding-agent -- test/settings-manager.test.ts:本书实测 34 个用例全部通过,无需 API Key。
本章小结
- 配置有且只有两个文件:全局
agentDir/settings.json与项目cwd/.pi/settings.json(settings-manager.ts:195-196);agentDir由getAgentDir()决定,可用PI_CODING_AGENT_DIR整体重定向(config.ts:515-521)。 - 合并方向写死为「项目盖全局」,规则是一层 spread:一层嵌套字段会合并,两层及以上整块替换(
deepMergeSettings,settings-manager.ts:132-160),本章用可运行脚本与真实SettingsManager各验证了一次。 - 项目未被信任时,项目设置在读取层被短路成
{}(settings-manager.ts:350-353),写入侧同样被拒(534-538);信任本身由resolveProjectTrusted的六步决策链得出(core/project-trust.ts:46-96),其中defaultProjectTrust刻意只读全局设置以避免循环依赖。 - 写盘用「字段级写合并」:锁内重读磁盘、只覆盖本会话登记过的字段(
persistScopedSettings,settings-manager.ts:578-607),因此外部编辑不会被抹掉。 - CLI 参数不进入 settings 对象;
applyOverrides在生产代码里无调用者。优先级由每个消费点各自实现,典型例子是sessionDir(main.ts:625-629)、thinkingLevel(sdk.ts:224-236)与--verbose(interactive-mode.ts:758)。 - 关键术语:配置作用域(Settings Scope)、深合并(deep merge)、项目信任(Project Trust)、字段级写合并、设置迁移(settings migration)。
- 关键源码索引:
packages/coding-agent/src/config.ts:489-496(APP_NAME / CONFIG_DIR_NAME / 环境变量名)、:515-521(getAgentDir)、:539-541(getSettingsPath)packages/coding-agent/src/core/settings-manager.ts:83-129(Settings)、:132-160(deepMergeSettings)、:173-186(作用域与存储接口)、:188-197(FileSettingsStorage)、:350-366(loadFromStorage)、:381-440(migrateSettings)、:479-505(reload)、:578-607(persistScopedSettings)packages/coding-agent/src/core/project-trust.ts:46-96(resolveProjectTrusted)packages/coding-agent/src/main.ts:536/:610/:685(三个 SettingsManager)、:625-629(sessionDir 优先级)packages/coding-agent/src/core/resource-loader.ts:379-402(信任结论回灌与重载)- 测试:
packages/coding-agent/test/settings-manager.test.ts(34 个用例,含外部字段保留、迁移、信任、sessionDir 优先级)
- 自测问题:
- 全局设置写了
{"retry": {"provider": {"timeoutMs": 5000, "maxRetries": 0}}},项目设置写了{"retry": {"provider": {"maxRetries": 3}}},最终getProviderRetrySettings()返回什么?为什么? - 一个项目被标记为不信任,它的
.pi/settings.json里的theme与defaultProjectTrust分别还有没有可能生效? - 你在 pi 运行期间用编辑器给 settings.json 加了一个新字段,pi 退出时它会不会被覆盖?源码里哪一段保证了这一点?
--verbose能强制打开启动横幅,为什么没有一个 flag 能强制关掉它?
- 全局设置写了
- 下一章:6.9 pi-tui:终端界面库——本章反复出现的
/settings选择器、信任询问弹窗都由它渲染。尚未展开的内容:packages字段背后的 npm / git 包解析与资源路径解析规则(属于 package-manager 的两千多行,本书只在 7.1 Extension 系统 触及入口)、themes的加载与主题解析,以及auth.json里凭证的存储方式(见 6.2 Provider 与模型注册)。