配置体系:配置文件放在哪、SettingsManager 怎么合并与写回
TL;DR:Pi 的配置分两层:用户级放在 agent 目录(默认
~/.pi/agent,可用PI_CODING_AGENT_DIR改),项目级放在工作目录的.pi/。config.ts只负责算路径,SettingsManager负责把两份settings.json读进内存、按"项目覆盖用户、对象递归合并、数组整体替换"合成一份有效设置。项目设置要等项目被信任后才加载,未信任时项目层就是空对象。写回时只写本次改过的字段:在文件锁里重读磁盘、合并后再写,这样多个 Pi 进程同时改同一个文件也不会互相覆盖。坏掉的配置文件不会让 Pi 启动失败,只会变成一条带路径的警告。
它解决什么问题
一个 coding agent 的配置有三类:跟人走的偏好(默认模型、主题、重试策略),跟仓库走的约定(这个项目要加载哪些扩展、skills、默认开哪些工具),以及只影响这一次运行的临时值(--model、--use-theme)。Pi 要回答四个问题:这些东西各存在哪;同一个字段两层都写了听谁的;仓库里的配置能不能直接生效(克隆来的仓库可能带恶意扩展);运行中通过 /settings 改的值怎么安全写回磁盘。
对应的分工很简单:
config.ts:纯路径与身份计算。agent 目录在哪、.pi目录叫什么、包内资源(内置主题、文档、codemode worker)在哪。它不读任何配置内容。SettingsManager:读写两份settings.json,提供合并后的有效设置和一组 getter/setter。是一个进程内的普通对象,不常驻、不监听文件变化,由main.ts、SDK 的createAgentSession()、包管理 CLI 各自创建。- 信任判定(
project-trust.ts/trust-manager.ts):决定项目层能不能加载,结果存在<agent-dir>/trust.json。细节见 资源发现、项目信任与系统提示词。
settings.json 只是 agent 目录里的配置之一,其他文件各有自己的加载者:models.json 归 ModelRegistry,auth.json 归 AuthStorage(见 模型与认证),mcp.json 归内置 MCP 扩展(见 MCP 与 Codemode),keybindings.json 归 TUI。所以 SettingsManager 不是一个"配置中心",它只管偏好和资源声明这一份文件。
配置在哪:两个根目录
config.ts 里最重要的是两个"根":
- 用户世界的根
getAgentDir():优先读环境变量PI_CODING_AGENT_DIR(支持~),否则是~/.pi/agent。settings.json、models.json、auth.json、mcp.json、trust.json、sessions/、extensions/、skills/、prompts/、themes/、托管的bin/(fd、rg)都从它派生。 - 包内世界的根
getPackageDir():Pi 自己安装在哪,用于找内置主题、导出模板、文档。可用PI_PACKAGE_DIR覆盖(Nix/Guix 这种只读 store 路径会用到);Bun 编译出的单文件二进制没有真实的包目录,config.ts会检测$bunfs这类虚拟路径并改用可执行文件旁边的目录。
项目层是 <cwd>/.pi/,里面可以有 settings.json、mcp.json、SYSTEM.md、APPEND_SYSTEM.md 以及 extensions/、skills/、prompts/、themes/。
有一个容易忽略的设计:.pi 这个目录名、pi 这个应用名、环境变量前缀都不是写死的,而是从 package.json 的 piConfig 读出来的(CONFIG_DIR_NAME、APP_NAME,环境变量名由 ${APP_NAME.toUpperCase()}_CODING_AGENT_DIR 拼出)。这样别人基于 Pi 发行一个改名的 agent,配置目录和环境变量会自动跟着变,不会和原版 Pi 抢同一个 ~/.pi。
环境变量在这里的角色是"改路径和改进程行为",不是第三层设置。PI_CODING_AGENT_DIR 改 agent 目录,PI_CODING_AGENT_SESSION_DIR 改会话目录,PI_OFFLINE 关掉自动联网(包括模型目录刷新);Provider 的 API key 变量属于认证体系。只有极少数设置有对应环境变量做兜底,例如 terminal.clearOnShrink 未设置时才看 PI_CLEAR_ON_SHRINK。
还要和 .pi 配置区分开的是上下文文件(AGENTS.md、CLAUDE.md):它们从 agent 目录、工作目录及其各级父目录加载,不属于 .pi 配置,也不需要项目信任。
一次启动里 SettingsManager 被创建了几次
以在一个刚克隆、带 .pi/settings.json 的仓库里运行 pi 为例,按 main.ts 的顺序看:
- 引导阶段:先创建一个
projectTrusted: false的 SettingsManager,只用它读用户层的httpProxy并配置全局 HTTP 代理。此时还没决定信不信任项目,所以项目层不读。httpProxy、defaultProjectTrust、selfUpdateCommand、cacheWarming这几项只认用户层,原因一样:它们要么在信任判定之前就要用,要么本身就是"信任策略",让仓库自己决定是否被信任显然不行。 - 选会话阶段:再创建一个
startupSettingsManager,用来收集配置文件的诊断警告、跑首次设置向导、读sessionDir。sessionDir是文档里唯一明确"在信任之前读取"的项目设置,因为--resume必须先找到会话文件。会话可能属于另一个项目目录,所以真正跟 cwd 绑定的设置要等会话的 cwd 确定之后再读。 - 运行时阶段:每个 runtime(新会话、切换到别的 cwd 的会话都会重建)创建自己的
runtimeSettingsManager。如果项目里有需要信任的资源且还没有信任记录,先以未信任状态创建;资源加载器先只加载用户级扩展,给扩展一个project_trust事件的机会代为决定,然后查trust.json,再看defaultProjectTrust(ask/always/never),最后在交互模式下弹窗询问。拿到结论后调用setProjectTrusted(true),此时才从磁盘读入项目层并重新合并。
这个顺序体现的原则是:项目配置等同于仓库作者写的代码。.pi/settings.json 能声明扩展和 packages,扩展就是任意 TypeScript,所以它必须和 .pi/extensions/ 一起放在信任闸门后面。未信任时 SettingsManager 不是"读了但忽略",而是项目层直接为 {},并且任何写项目层的操作会抛错("Project is not trusted; refusing to write project settings")。
一句话:SettingsManager 很便宜,Pi 按阶段多次创建;信任状态是它的构造参数,项目层在信任前根本不进内存。
合并语义:对象递归、数组替换、工具列表例外
有效设置 = deepMerge(用户层, 项目层),规则是:
- 普通值:项目层覆盖用户层;项目层值为
undefined视为没写。 - 嵌套对象(
compaction、retry、terminal……):递归合并。项目只写{"compaction": {"keepRecentTokens": 40000}}时,用户层的compaction.enabled仍然生效。早期版本这里出过 bug:项目里写了retry.provider的某个子字段会把用户的整个retry.provider冲掉,后来改成了逐层递归。 - 数组:整体替换。
有两处例外,都和"项目想在用户基础上增减,而不是重写"有关:
defaultTools:支持+name/-name修饰语法。一个只含修饰项的列表(如["+codemode"])会追加到上一层的列表后面,相当于"在继承来的选择上改";只要出现一个普通名字,就整体替换。最后再相对内置默认read, bash, edit, write求值。CLI 的--tools +x用的是同一套规则,叠加在合并结果之上。- 资源列表(
packages、extensions、skills、prompts、themes):文档说的是"两层都加载",但这个合并不在 SettingsManager 里发生。包管理器分别调用getGlobalSettings()和getProjectSettings(),两层各自按自己的基准目录解析路径(用户层相对 agent 目录,项目层相对.pi/),再合到一起,并且项目层可以用+builtin:mcp/-builtin:mcp覆盖用户层对内置扩展的开关。原因是两层路径的基准目录不同,深合并成一个数组之后就分不清每条路径该相对哪个目录解析了。
再往上还有一层运行期覆盖 applyOverrides():直接合并进内存里的有效设置,不标记修改、不落盘。在 1.5 里 main.ts 只用它处理 --use-theme。--model、--thinking 这类 CLI 参数不经过 SettingsManager,而是在模型解析阶段直接作为更高优先级的输入(见 模型与认证)。旧笔记里说"pi --model 通过 applyOverrides 覆盖 defaultModel",这个说法不准确。
默认值不进合并。用户层和项目层对象里只保存用户显式写过的字段,默认值集中在 SETTINGS_DEFAULTS(settings-defaults.ts),由 getter 用 ?? 兜底,比如 getCompactionEnabled() 返回 settings.compaction?.enabled ?? 默认值。这样做有两个好处:写回时不会把默认值写进用户的文件;以后默认值变了,用户什么都不用改就能拿到新默认。和旧版把默认值散写在各个 getter 里不同,现在默认值只在一处维护,schema 定义和 /settings 面板也引用同一份。
写回:只写改过的字段,在锁里重读再写
/settings 面板、/model 记住默认模型、pi install 记录 package,最终都调用 SettingsManager 的 setter。写回链路如下:
setter(value)
-> 改内存里的 globalSettings(或 projectSettings)
-> markModified(field[, nestedKey]) 记录"本进程改过哪些字段"
-> 重算有效设置(内存立即生效)
-> 拍一份快照,排进串行 writeQueue
-> storage.withLock(scope):
读磁盘当前内容 -> 迁移旧格式
只把 modified 字段(嵌套对象只到被改的子键)覆盖上去
写回文件
-> 清掉该 scope 的修改标记
几个关键选择:
- 按字段写,不整文件覆盖。磁盘是唯一权威,内存里的两层只是本进程读入时的快照。如果另一个终端里的 Pi 改了
theme,本进程改defaultModel时会先在锁内重读文件,只覆盖defaultModel,不会把别人刚写的theme冲掉。嵌套对象精确到子键,改compaction.enabled不会覆盖磁盘上的compaction.keepRecentTokens。 - 跨进程用文件锁,进程内用 Promise 队列。
FileSettingsStorage用proper-lockfile加同步锁,被占用时最多重试 10 次、每次间隔 20ms。进程内的写操作排成一条writeQueue,setter 本身是同步的、立刻返回,磁盘写在微任务里串行完成。需要确认已落盘时调用flush();reload()会先等队列排空,避免读到自己还没写下去的旧状态。 - 快照在入队时拍下。写任务真正执行时用的是调用 setter 那一刻的
structuredClone,后续内存修改不会混进这次写入。 - 大多数 setter 写用户层。
/settings里能改的偏好都写<agent-dir>/settings.json;只有资源类的setProject*(packages、extensions、skills、prompts、themes)写项目层,并且要先通过信任检查。偏好跟人走,资源声明才可能跟仓库走。 - 文件读失败就不写。如果启动时某一层 JSON 解析失败,这一层的 save 会直接跳过。否则用户手里那份写坏、等着修的文件会被一份"空对象加一个字段"整个覆盖。
- 迁移只在读取时做,不主动回写。
migrateSettings()每次加载都把旧格式转成新格式(queueMode→steeringMode、websockets布尔值→transport、retry.maxDelayMs→retry.provider.maxRetryDelayMs等)。新格式只在用户下次改到对应字段时才落盘,Pi 不会因为升级就改写用户的文件。
存储后端被抽象成只有一个方法的 SettingsStorage.withLock(scope, fn):fn 拿到当前文本,返回新文本表示要写,返回 undefined 表示只读。基于这个接口有两个实现:FileSettingsStorage 读写真实文件,并且只在确实要写时才创建目录;InMemorySettingsStorage 是两个字符串字段。SettingsManager.inMemory({...}) 主要给 SDK 嵌入和测试用:同样的合并、迁移、修改追踪逻辑全部照常运行,只是不碰磁盘。宿主应用嵌入 Pi 时,可以完全不让它读写用户的 ~/.pi。
校验与诊断:编辑器侧严格,运行时宽容
1.x 给 settings.json、models.json、keybindings、主题发布了 JSON Schema(https://pi.dev/schemas/settings.schema.json)。在文件里加 "$schema" 字段,编辑器就能补全并标红。这份 schema 由 settings-schema.ts 里的 TypeBox 定义生成,同一份定义也是 Settings 的 TypeScript 类型来源,字段说明和默认值都写在定义里。
运行时的态度则相反,SettingsManager 加载时不做 schema 校验(源码注释明确写着 "Settings files are not validated")。它只处理两类致命问题:JSON 解析失败,以及少数必须是合法值的字段(如超时设置)解析出错。出错的那一层按空对象处理,错误连同文件路径记进 errors。之后 collectSettingsDiagnostics() 调 drainErrors() 把它们变成 Invalid settings file <path>: ... 警告,交互模式下渲染在 TUI 里,启动期和运行期两个 manager 报的同一个错会去重。字段类型不对、写了未知字段,基本由各个 getter 的兜底吸收掉。
这个取舍是:配置写错不应该让 agent 起不来。用户最需要 Pi 帮忙的时候,往往正是配置刚被改坏的时候。严格校验放在编辑器里,用户写的时候就能看到;运行时只保证"能启动,并且告诉你哪个文件坏了"。
一句话:schema 给编辑器和类型用,运行时只兜底解析错误并报警告,坏文件既不阻断启动,也不会被自动覆盖。
/reload 与热更新
SettingsManager 不监听文件。用户手动改了 settings.json 后要执行 /reload:等写队列排空,重新读两层(项目层按当前信任状态决定读不读),清空所有修改标记,再重新合并。哪一层读失败,就保留该层旧的内存值并记一条错误,不会因为一次编辑没保存完整就丢掉全部配置。
调用方在用到设置时才去调 getter 查询的,reload 后马上生效;只在构造时读一次的(默认模型、启动工具列表),reload 后只影响之后的行为。例如 defaultTools 新增的工具会在 reload 时启用,删掉的工具却不会因此被关掉,以免打断用户手动开关的状态。
常见追问
- 为什么不做三层以上(比如目录级、组织级)的设置? Pi 的定位是最小 harness。两层已经覆盖"跟人走"和"跟仓库走";更细的定制交给扩展,扩展可以在事件里改行为,不需要设置系统支持任意层级。
- 为什么项目层不能设置
httpProxy、defaultProjectTrust? 前者在信任判定之前就要用;后者本身就是信任策略。允许仓库配置它们,就等于让仓库替自己开门,或者把流量导到别处。 - 多个 Pi 进程同时写会怎样? 按字段写回加文件锁,不同字段的修改都能保留;同一字段并发写时后写的赢。它不是事务系统,但对"偏好文件"来说已经够用。
- SDK 嵌入时怎么避免污染用户配置? 传
SettingsManager.inMemory({...}),或者用agentDir选项指向独立目录。前者完全不碰磁盘,后者等价于设置PI_CODING_AGENT_DIR。 - 为什么 setter 是同步的而写盘是异步的? UI 交互(切主题、切模型)要求内存立即生效,不能等 IO。串行队列保证落盘顺序,
flush()给需要确定性的调用方(测试、退出前)一个等待点。
源码入口(v1.5):packages/coding-agent/src/config.ts、packages/coding-agent/src/core/settings-manager.ts、packages/coding-agent/src/core/settings-schema.ts、packages/coding-agent/src/core/settings-diagnostics.ts、packages/coding-agent/src/core/project-trust.ts、packages/coding-agent/src/main.ts