Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

配置体系:配置文件放在哪、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 的顺序看:

  1. 引导阶段:先创建一个 projectTrusted: false 的 SettingsManager,只用它读用户层的 httpProxy 并配置全局 HTTP 代理。此时还没决定信不信任项目,所以项目层不读。httpProxy、defaultProjectTrust、selfUpdateCommand、cacheWarming 这几项只认用户层,原因一样:它们要么在信任判定之前就要用,要么本身就是"信任策略",让仓库自己决定是否被信任显然不行。
  2. 选会话阶段:再创建一个 startupSettingsManager,用来收集配置文件的诊断警告、跑首次设置向导、读 sessionDir。sessionDir 是文档里唯一明确"在信任之前读取"的项目设置,因为 --resume 必须先找到会话文件。会话可能属于另一个项目目录,所以真正跟 cwd 绑定的设置要等会话的 cwd 确定之后再读。
  3. 运行时阶段:每个 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