Skip to content

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 不行。一个配置值从磁盘走到真正影响行为的那一行代码,中间要经过五道关口:

  1. 两个文件——用户级的全局设置,和项目级的项目设置;
  2. 一次迁移——老版本写下的旧字段名会被就地翻译成新字段名;
  3. 一道闸门——项目没有被信任时,项目设置整份被丢弃;
  4. 一次合并——项目盖住全局,得到一份「当前有效设置」;
  5. N 个消费点——每个功能自己决定「命令行参数、环境变量、有效设置、硬编码默认值」谁优先。

这条流水线里最容易被误解的是第 5 步。读者很自然会猜:命令行参数应该是「先写进设置对象、再统一读」。Pi 不是这么做的(源码事实,下文给出证据),因此「优先级」这件事在 Pi 里没有唯一答案,而是每个消费点各写一遍。理解了这一点,很多「为什么这个 flag 覆盖了设置、那个没有」的困惑就解开了。

📘 概念配置作用域(Settings Scope)
Pi 只有两个作用域:"global"(用户级,跟着你这个人走)和 "project"(项目级,跟着当前目录走)。类型声明就一行:export type SettingsScope = "global" | "project"packages/coding-agent/src/core/settings-manager.ts:173)。读取时项目盖全局;写入时两个作用域各写各的文件,互不串味。
⚠️ 常见误解以为「项目配置不生效」是路径写错了
更常见的原因是项目没有被信任。Pi 在交互模式启动时会问「Trust project folder?」;选了不信任、或者在非交互模式下没有已保存的信任决定,.pi/settings.json 会被当成 {} 处理——文件在、路径对、JSON 合法,但一个字段都不会进入合并结果。本章「信任闸门」一节给出源码位置,实践任务里你会亲眼看到这个效果。

最小示例:一层 spread 的合并会丢什么

Pi 的合并函数只有 20 多行,而且不依赖 Pi 的任何东西。先脱离源码把它抄成一个可运行的小脚本,存成 merge-demo.mjs

js
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一层嵌套:项目只写了 reserveTokensenabled: 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 源码

两个文件在哪里

全局目录由一个函数决定,它也是本章实践任务的钥匙:

earendil-works/pi@c13ffe1第 515–521 行在 GitHub 查看 ↗
全局配置目录:环境变量 PI_CODING_AGENT_DIR 优先,否则是 ~/.pi/agent。settings.json、auth.json、models.json、trust.json、sessions/ 全在这个目录下。

其中 .pi 这个目录名不是硬编码字面量,而是从 package.jsonpiConfig.configDir 读出来的(config.ts:491),环境变量名同样按 APP_NAME 拼出来(config.ts:495-496)——这是为了让 fork / 改名发行版能整体换目录(源码事实)。默认发行版下 APP_NAME"pi"config.ts:489),于是环境变量就叫 PI_CODING_AGENT_DIR

两个 settings.json 的路径在存储层拼出来:

earendil-works/pi@c13ffe1第 188–197 行在 GitHub 查看 ↗
文件存储后端:全局路径 = agentDir/settings.json,项目路径 = cwd/.pi/settings.json。SettingsManager 只认 SettingsStorage 接口,所以测试可以换成内存实现。

SettingsStorage 接口只有一个方法 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.inMemorysettings-manager.ts:343-348)。

🌱 初学者提示为什么读一个 JSON 也要加锁
因为你可能同时开着好几个 pi 会话。任何一个会话按下 /settings 改一个开关,都会触发「读文件 → 改字段 → 写回」。没有锁的话,两个进程各自读到旧内容、各自写回,后写的那个会把前一个的修改整段抹掉。锁只是第一层保险,第二层是下文的「字段级写合并」。

SettingsManager 内部的三份状态

SettingsManager 同时持有三个对象(settings-manager.ts:299-305):globalSettings(全局文件的内容)、projectSettings(项目文件的内容)、settings(合并结果)。读走合并结果,写只改其中一份——这个分工是理解后面所有行为的基础。

配置项清单就是一个 interface:

earendil-works/pi@c13ffe1第 83–129 行在 GitHub 查看 ↗
全部配置项的唯一权威声明:45 个字段,全部可选。没有单独的「默认值表」,默认值散落在各个 getter 的 ?? 兜底里。

按用途把这 45 个字段归一下类(源码事实,行号即上面 interface 内的行):

分类字段行号
模型与思考defaultProviderdefaultModeldefaultThinkingLevelhideThinkingBlockenabledModelsthinkingBudgets85-87、95、115、118
请求与传输transportretryhttpProxyhttpIdleTimeoutMswebsocketConnectTimeoutMs88、94、126-128
上下文compactionbranchSummary92-93
消息投递steeringModefollowUpMode89-90
界面与主题themequietStartupterminalimagesdoubleEscapeActiontreeFilterModeeditorPaddingXoutputPadautocompleteMaxVisibleshowHardwareCursormarkdownwarnings91、99、113-114、116-117、119-124
会话sessionDir125
外部程序externalEditorshellPathshellCommandPrefixnpmCommand97-98、101-102
资源加载packagesextensionsskillspromptsthemesenableSkillCommands107-112
信任、遥测与提示lastChangelogVersionshowCacheMissNoticesdefaultProjectTrustcollapseChangelogenableInstallTelemetryenableAnalyticstrackingId84、96、100、103-106

注意最后两类的位置:资源加载(extensions / skills / prompts / themes 的搜索路径)也走 settings,这就是 6.7 系统提示词与 Prompt Templates7.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" 是更上层的主题解析补上的。

合并规则

earendil-works/pi@c13ffe1第 132–160 行在 GitHub 查看 ↗
合并函数:overrides 侧的 undefined 跳过;双方都是纯对象时做一层 spread;标量与数组整体覆盖。构造器和每次 reload 都调用它。

调用点只有四处,全在 SettingsManager 内部:构造器(settings-manager.ts:305)、setProjectTrusted466476)、reload504)、applyOverrides509)。第一个参数永远是 globalSettings,第二个永远是 projectSettings——项目盖全局的方向是写死的,没有任何开关能反过来。

applyOverrides 值得单独说一句:它把任意一份 Partial<Settings> 再叠加到合并结果上、且不落盘。这看起来正是「CLI 参数覆盖配置」的理想工具,但全仓搜索(grep -rn "applyOverrides" packages/)显示,生产代码里没有一个调用者——命中的全是 packages/coding-agent/test/ 下的测试与 packages/coding-agent/examples/sdk/10-settings.ts 这个 SDK 示例(源码事实)。CLI 参数走的是另一条路,见下文「优先级」一节。

信任闸门

earendil-works/pi@c13ffe1第 350–366 行在 GitHub 查看 ↗
读取入口:scope 是 project 且未信任时直接返回空对象——文件根本不会被打开。读到内容后先跑 migrateSettings 再返回。

开头这四行(350-353)就是「项目配置不生效」的全部原因。注意它是读取层的短路:不是读进来再过滤字段,而是压根不读。写入侧有对称的保护,assertProjectTrustedForWritesettings-manager.ts:534-538)在未信任时直接抛错。

projectTrusted 这个布尔值本身由谁决定?

earendil-works/pi@c13ffe1第 46–96 行在 GitHub 查看 ↗
信任决策六步:CLI 覆盖 → 项目没有需要信任的资源则直接 true → 扩展的 project_trust 事件 → trust.json 已存决定 → 全局设置 defaultProjectTrust → 无 UI 返回 false,有 UI 则弹窗询问。

这个顺序里藏着一个循环依赖的解法:defaultProjectTrust 本身是一个配置项,但它只读全局设置getDefaultProjectTrustsettings-manager.ts:899-902,直接访问 this.globalSettings 而非合并结果),否则「用项目配置决定要不要读项目配置」就成了鸡生蛋。非法值一律回落 "ask"

闸门在启动流程里的位置比看上去微妙。main() 里前后一共造了三个 SettingsManager(源码事实):

  • main.ts:536 的 bootstrap 实例,显式传 { projectTrusted: false },只为了尽早拿到 httpProxy 去配置 HTTP 客户端;
  • main.ts:610 的 startup 实例,没传 projectTrusted,而 fromStorage 的默认值是 truesettings-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。

写回:字段级写合并

earendil-works/pi@c13ffe1第 578–607 行在 GitHub 查看 ↗
写盘时在锁内重新读文件,只把本会话真正改过的字段覆盖上去,其余字段保留磁盘上的最新值;嵌套字段的粒度精确到子键。

这是本章最值得抄走的工程手法。朴素做法是 JSON.stringify(this.globalSettings) 整个写回,代价是:你在 pi 运行期间用编辑器手改了 settings.json 的另一个字段,pi 退出时会把你的修改覆盖掉。Pi 的做法是全程记账——每个 setter 都调用 markModified(field, nestedKey?)settings-manager.ts:513-521)登记「我改过谁」,写盘时只应用登记过的字段。

ts
// 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 队列(enqueueWritesettings-manager.ts:556-568),失败不抛出而是记进 errors 数组,由 drainErrors() 取走(654-658)——启动期的 collectSettingsDiagnosticsmain.ts:86-94)就是这么把配置错误变成一行 Warning: 的。相关测试:packages/coding-agent/test/settings-manager.test.tsdescribe("preserves externally added settings") 三个用例专门断言了这条性质;本书实测该文件 34 个用例全部通过,不需要任何 API Key(命令见本章实践任务)。

迁移:老配置怎么办

migrateSettingssettings-manager.ts:381-440)在每次读取时就地翻译四种历史格式:queueModesteeringMode、布尔 websockets → 枚举 transport、旧的 skills 对象格式 → 字符串数组、retry.maxDelayMsretry.provider.maxRetryDelayMs。它的位置很关键——既在 loadFromStorage 里(settings-manager.ts:365),也在 persistScopedSettings 读回磁盘内容时(586)。也就是说,旧字段名在进入内存的那一刻就消失了,后续代码不需要知道历史包袱的存在。

图解

图加载中…

图 6.8-1 一个配置值从磁盘到生效的完整决策路径
阅读顺序:从上到下。上半段(A→H)全部发生在 SettingsManager 内部,对应 settings-manager.tsloadFromStorage(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/agentconfig.ts:495config.ts:515-521
PI_CODING_AGENT_SESSION_DIR会话存储目录(被 --session-dir 覆盖)config.ts:496main.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_SHRINKPI_HARDWARE_CURSOR 作为两个 TUI 设置的兜底(settings-manager.ts:10981182)。

环境变量和 settings 的关系不统一,正好三种都有:

  • 环境变量赢isInstallTelemetryEnabledPI_TELEMETRY 只要有定义就直接决定结果,settings 完全不看(core/telemetry.ts:12)。
  • settings 赢getClearOnShrink() 先看 settings.terminal.clearOnShrink,未设置才落到 PI_CLEAR_ON_SHRINKsettings-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),它被传给 buildSessionOptionsmain.ts:405-501)转成 CreateAgentSessionOptions,与 SettingsManager 并列地交给下游。优先级在每个消费点用 if 写死。三个真实例子:

earendil-works/pi@c13ffe1第 625–629 行在 GitHub 查看 ↗
会话目录的三级优先级:--session-dir 参数 > 环境变量 PI_CODING_AGENT_SESSION_DIR > 全局设置 sessionDir。两个 ?? 一行写完。
packages/coding-agent/src/core/sdk.ts · getDefaultThinkingLevel
earendil-works/pi@c13ffe1第 224–236 行在 GitHub 查看 ↗
思考等级的四级优先级:CLI 传入的 options.thinkingLevel > 会话里恢复出的等级 > 设置里的 defaultThinkingLevel > 常量 DEFAULT_THINKING_LEVEL。

第三个例子在交互界面:启动横幅的显示条件写成 this.options.verbose || !this.settingsManager.getQuietStartup()modes/interactive/interactive-mode.ts:758)。也就是说 --verbose 是一个单向覆盖——它能强制打开横幅,但没有任何 flag 能强制关闭一个 quietStartup: false 的配置。

⚠️ 常见误解以为存在一条统一的「CLI 大于项目大于全局」链条
只有「项目大于全局」这半句是全局成立的(deepMergeSettings 的参数顺序写死)。CLI 与环境变量的位置由消费点决定:sessionDir 是 CLI 胜过环境变量、环境变量胜过设置;PI_TELEMETRY 是环境变量胜过设置;terminal.clearOnShrink 反过来是设置胜过环境变量。要判断某一项,最快的办法是搜它的 getter 名字,看唯一调用点那一行的 ||??

一种看法是:这种「分散判优先级」的写法让每个功能可以定制自己的语义(比如 --verbose 的单向覆盖),代价是没有单一事实来源,读者和维护者都必须逐点查证,也更容易出现不一致。本章的表格与源码索引就是为了降低这个查证成本。

两个改配置的入口

不想手写 JSON 的话,Pi 提供两条路(此处只作定位,界面细节留给 6.10 交互模式与 RPC 模式):

  • pi config:一个独立子命令,在 main() 解析常规参数之前就被拦截(main.ts:553),实现是 handleConfigCommandpackages/coding-agent/src/package-manager-cli.ts:603-674)。它开一个终端界面(TUI,Terminal User Interface)让你启用 / 禁用来自包的资源,-l 切到项目作用域(未信任时明确报错并退出,package-manager-cli.ts:648-652)。
  • 交互模式里的 /settings:真实采集的斜杠命令列表里可以看到这一项——→ settings Open settings menuresearch/cli-captures/pi-tui-slash.txt:23)。它把 20 多个常用开关做成一个选择器(interactive-mode.ts:2692 触发,配置项快照在 interactive-mode.ts:4185-4202),每个回调调用对应的 setter,于是自动享受上文的字段级写合并。

实践任务

🛠 实践任务用 PI_CODING_AGENT_DIR 搭一个隔离配置环境

目标:在完全不碰你真实 ~/.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}

如何判断成功:三个断言都对上就算通过。① 受信任那次 quietStartupfalse,项目盖住了全局的 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-840getProviderRetrySettings)。想再确认一遍这些行为不是本书杜撰,在 $PI_REPO 里跑 npm test --workspace=@earendil-works/pi-coding-agent -- test/settings-manager.test.ts:本书实测 34 个用例全部通过,无需 API Key。

本章小结

  • 配置有且只有两个文件:全局 agentDir/settings.json 与项目 cwd/.pi/settings.jsonsettings-manager.ts:195-196);agentDirgetAgentDir() 决定,可用 PI_CODING_AGENT_DIR 整体重定向(config.ts:515-521)。
  • 合并方向写死为「项目盖全局」,规则是一层 spread:一层嵌套字段会合并,两层及以上整块替换deepMergeSettingssettings-manager.ts:132-160),本章用可运行脚本与真实 SettingsManager 各验证了一次。
  • 项目未被信任时,项目设置在读取层被短路成 {}settings-manager.ts:350-353),写入侧同样被拒(534-538);信任本身由 resolveProjectTrusted 的六步决策链得出(core/project-trust.ts:46-96),其中 defaultProjectTrust 刻意只读全局设置以避免循环依赖。
  • 写盘用「字段级写合并」:锁内重读磁盘、只覆盖本会话登记过的字段(persistScopedSettingssettings-manager.ts:578-607),因此外部编辑不会被抹掉。
  • CLI 参数不进入 settings 对象;applyOverrides 在生产代码里无调用者。优先级由每个消费点各自实现,典型例子是 sessionDirmain.ts:625-629)、thinkingLevelsdk.ts:224-236)与 --verboseinteractive-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-521getAgentDir)、:539-541getSettingsPath
    • packages/coding-agent/src/core/settings-manager.ts:83-129Settings)、:132-160deepMergeSettings)、:173-186(作用域与存储接口)、:188-197FileSettingsStorage)、:350-366loadFromStorage)、:381-440migrateSettings)、:479-505reload)、:578-607persistScopedSettings
    • packages/coding-agent/src/core/project-trust.ts:46-96resolveProjectTrusted
    • 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 优先级)
  • 自测问题:
    1. 全局设置写了 {"retry": {"provider": {"timeoutMs": 5000, "maxRetries": 0}}},项目设置写了 {"retry": {"provider": {"maxRetries": 3}}},最终 getProviderRetrySettings() 返回什么?为什么?
    2. 一个项目被标记为不信任,它的 .pi/settings.json 里的 themedefaultProjectTrust 分别还有没有可能生效?
    3. 你在 pi 运行期间用编辑器给 settings.json 加了一个新字段,pi 退出时它会不会被覆盖?源码里哪一段保证了这一点?
    4. --verbose 能强制打开启动横幅,为什么没有一个 flag 能强制关掉它?
  • 下一章:6.9 pi-tui:终端界面库——本章反复出现的 /settings 选择器、信任询问弹窗都由它渲染。尚未展开的内容:packages 字段背后的 npm / git 包解析与资源路径解析规则(属于 package-manager 的两千多行,本书只在 7.1 Extension 系统 触及入口)、themes 的加载与主题解析,以及 auth.json 里凭证的存储方式(见 6.2 Provider 与模型注册)。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)