Skip to content

5.1 启动:pi 命令如何跑起来 ​

本页分析版本earendil-works/pi@16787ad2026-09-21

本章解决什么问题:你在终端敲下 pi 回车,到出现输入框之间,程序到底按什么顺序做了哪些事?这些步骤的先后为什么不能随便调换? 前置知识:4.4 从哪里开始读源码(已交代 cli.ts → main.ts → AgentSession 的主链路轮廓)、2.8 Node.js 文件、路径与进程 API(process.argv、process.env、process.cwd())。 学习目标:读完后你能 ① 复述 main() 的编排顺序并说出每步的输入与产物;② 解释 Pi 的两层设置(全局 / 项目)如何合并;③ 说清「项目信任」的完整决策链以及它为什么必须在加载项目设置之前发生;④ 说出 createRuntime 三层工厂各自造了什么;⑤ 用一条无需 API Key 的命令亲手观察启动产生的目录与信任行为。

建立直觉:启动就是「一层层填满上下文」 ​

一个 Agent 真正开始工作之前,需要知道一堆事情:我在哪个目录?用户给了什么参数?用哪个模型?允许哪些工具?历史会话要不要接着?这些信息不是同时到位的,它们之间有依赖顺序:

  • 不先解析参数,就不知道要不要读会话(--continue)、要不要禁用工具(--no-tools)。
  • 不先确定会话,就不知道最终工作目录是哪个(--resume 可能选中另一个项目的会话)。
  • 不先决定「这个项目可信吗」,就不敢读项目里的 .pi/settings.json、更不敢执行项目里的扩展(Extension)代码。

所以启动过程可以理解成:先拿到最外层、最不需要依赖的信息,再用它去解锁下一层。Pi 的 main() 正是这么排的,理解了这个顺序,后面每一步为什么在那个位置就不再需要死记。

第一站:6 行的 cli.ts ​

pi 命令由 packages/coding-agent/package.json 的 bin 字段指向 dist/bundle/cli.js。这个文件不是手写的源码,而是构建脚本 scripts/build-coding-agent-bundle.mjs 生成的一个小启动器:它先打开 Node 的编译缓存(enableCompileCache()),再加载同目录下的 cli-runtime.js;后者是 esbuild 把编译后的 dist/cli.js 连同它依赖的各个包打成的一份 bundle(源码事实)。

earendil-works/pi@16787ad第 209–215 行在 GitHub 查看 ↗
构建期写出的启动器 dist/bundle/cli.js:开启编译缓存后 require 打包好的 cli-runtime.js。

从源码结构看,这样做是为了缩短冷启动:一份打包文件比成百上千个零散模块少了大量文件系统读取,编译缓存又让第二次启动省掉重新编译(上游 CHANGELOG 对这两项的说明也是「减少启动时的文件读取」「降低重复启动耗时」)。对读源码的人来说,这一层是透明的——bundle 的入口仍然是 src/cli.ts,它只剩 6 行(源码事实):

ts
// packages/coding-agent/src/cli.ts:1-6
#!/usr/bin/env node
import { setupCli } from "./cli/setup.ts";
import { main } from "./main.ts";

setupCli();
main(process.argv.slice(2));
earendil-works/pi@16787ad第 1–6 行在 GitHub 查看 ↗
入口薄壳全文 6 行:setupCli() 设定进程级环境,随后立刻交棒给 main.ts。

进程级的准备工作被挪进了 setupCli():改进程名、打两个环境标记(PI_CODING_AGENT 与 AI_AGENT)、屏蔽 Node 警告、配置 HTTP 连接派发器。

earendil-works/pi@16787ad第 4–13 行在 GitHub 查看 ↗
setupCli:进程名、环境标记、屏蔽 emitWarning、在任何 Provider SDK 发请求之前配置 undici 派发器。

process.argv 的前两项是 Node 可执行文件路径和脚本路径,所以 slice(2) 之后才是用户真正输入的参数。configureHttpDispatcher() 必须在任何 Provider(模型服务提供方)SDK 发出请求之前调用,这是注释里写明的原因;它稍后在 main() 里还会带上设置里的超时值再调用一次。

🌱 初学者提示为什么入口要这么薄
把入口写薄有两个好处:一是 main() 变成一个可以被别人 import 的普通函数(第 6.11 章讲的 SDK 用法、以及本章后面提到的 MainOptions.extensionFactories 都依赖这一点);二是别的入口可以复用同一套准备工作——测试直接 spawn src/cli.ts 来跑真实进程(仓库里的 test/stdout-cleanliness.test.ts 正是这么做的),本章实践任务用的 pi-test.sh 则指向 src/experimental/cli.ts,它同样先调 setupCli(),只有在开启了实验特性、且第一个参数是 server 或 client 时才不走 main()。

main() 的编排顺序 ​

main() 定义在 packages/coding-agent/src/main.ts:566,签名是 main(args: string[], options?: MainOptions)。它先处理 --offline 标记,并把 pi auth … 子命令整个拦下(runAuthCommand,main.ts:575);除此之外的调用才开始取两个最基础的坐标(源码事实):

ts
// packages/coding-agent/src/main.ts:584-588
const cwd = process.cwd();
const agentDir = getAgentDir();
const bootstrapSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
applyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher();
earendil-works/pi@16787ad第 584–588 行在 GitHub 查看 ↗
main 的前两个坐标:当前工作目录 cwd 与配置目录 agentDir;bootstrap 设置管理器显式声明「项目不可信」,只为读全局代理配置。

注意第三行的 { projectTrusted: false }:此刻还没做任何信任判断,所以这个临时的设置管理器只允许读全局设置。这是「先拿最外层信息」原则的第一个例子。

接下来的顺序如下图。

图 5.1-1 main 的启动编排顺序
从上到下是真实执行顺序,每个节点后的 path:line 都可以直接在 main.ts 里定位。重点关注三处「时机」:模式在很早就定下来,也就是节点 E,但真正分派在最后的节点 O;工厂在 K 处只是被定义,直到 L 才第一次执行;--help 被推迟到 M,因为帮助文本要列出扩展注册的 flag。

图中 B 这一层容易被忽略:runAuthCommand(main.ts:575)、handlePackageCommand(main.ts:590)、handleConfigCommand(main.ts:603)依次尝试匹配 pi auth check/pi auth print-api-key、pi install、pi config 这类子命令,任一命中就处理完直接返回,根本不会走到后面的启动流程。这解释了为什么 pi list 秒回而 pi 要等一会儿。其中 auth 子命令排得最靠前,连 cwd、agentDir 和 bootstrap 设置管理器都还没取;从源码结构看,它只需要凭证与模型目录,用不着任何项目信息。

参数解析:一个手写的 for 循环 ​

parseArgs 没有用任何第三方命令行库,就是一个从头扫到尾的 for 循环(源码事实):

earendil-works/pi@16787ad第 71–77 行在 GitHub 查看 ↗
parseArgs 的初始结果对象:messages 收裸参数、fileArgs 收 @file、unknownFlags 收无法识别的长 flag、diagnostics 收解析期的警告与错误。

它的收尾分支(args.ts:225-245)值得单独看:@xxx 去掉前缀进 fileArgs;无法识别的 --xxx 不报错,而是进 unknownFlags 这个 Map,留给扩展去认领;只有无法识别的单杠短 flag 才会记一条 error。这个「对未知长 flag 保持宽容」的设计,是第七部分扩展系统能注册自定义 CLI flag 的前提。

解析出的 diagnostics 在 main.ts:608-616 统一打印:warning 只提示,error 会 process.exit(1)。相关测试是 packages/coding-agent/test/args.test.ts(describe("parseArgs")),它覆盖了 --version、-p、--continue 等分支,以及 --name 缺值时报错的情况——想确认某个 flag 的确切行为,读这个测试比读实现更快。

设置加载:两层文件与 deepMerge ​

getAgentDir() 决定配置目录,规则只有两条(源码事实):

earendil-works/pi@16787ad第 527–534 行在 GitHub 查看 ↗
环境变量 PI_CODING_AGENT_DIR 优先,否则回退到 ~/.pi/agent。变量名由 APP_NAME 拼出,fork 改名后自动变成对应前缀。

设置本身分两层,路径在 FileSettingsStorage 的构造函数里写死:

earendil-works/pi@16787ad第 234–239 行在 GitHub 查看 ↗
全局设置在 agentDir 下的 settings.json;项目设置在 cwd 下的 .pi/settings.json。

两层的合并发生在 SettingsManager 的构造函数里:this.settings = deepMergeSettings(this.globalSettings, this.projectSettings)(settings-manager.ts:350)。deepMergeSettings(settings-manager.ts:189-191)转调 deepMergeObjects(settings-manager.ts:169-186),规则很具体,值得记住:两边都是普通对象就递归合并,其余情况(数组、标量、一边不是对象)都由项目值整体覆盖。也就是说,项目设置可以只改 compaction 里的某一个字段而保留全局的其余字段;但项目设置里写一个数组,会把全局的同名数组整个替换掉,而不是拼接。官方文档说明「项目设置覆盖全局设置」、两层设置「递归合并」(来源:packages/coding-agent/docs/settings.md),源码与之一致,但数组不合并这一点文档没有展开。配置系统的完整讲解在 6.8 配置系统。

真正需要警惕的是下面这段:

ts
// packages/coding-agent/src/core/settings-manager.ts:410-413
private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings {
	if (scope === "project" && !projectTrusted) {
		return {};
	}
	// …(省略:读文件、JSON.parse、迁移旧字段)
earendil-works/pi@16787ad第 410–413 行在 GitHub 查看 ↗
项目不被信任时,项目设置连读都不读,直接返回空对象。
⚠️ 常见误解以为 .pi/settings.json 一定会生效
它只在项目被信任时才生效。如果你在一个新克隆的仓库里改了 .pi/settings.json 却毫无反应,先想「这个项目我信任过吗」,而不是怀疑配置写错了。本章实践任务会让你亲眼看到这个差别。

项目信任:一条七步决策链 ​

信任决策的实现是 resolveProjectTrusted,逻辑是一串短路判断:

earendil-works/pi@16787ad第 46–96 行在 GitHub 查看 ↗
项目信任的完整决策链:CLI 覆盖、无需信任的资源、扩展事件、trust.json、默认策略、有无 UI、TUI 询问。

图 5.1-2 项目信任的决策链
这张图逐条对应 core/project-trust.ts 第 46 到 96 行的七个分支,从上到下就是源码里的短路顺序。关注两个出口:F1 是非交互模式也就是管道、脚本、CI 的默认结果,即不信任;G 才是你在终端里看到的那个对话框,它写回 trust.json 后下次不再询问。

几个关键点:

  • 什么算「需要信任的资源」:hasTrustRequiringProjectResources(core/trust-manager.ts:185)检查 cwd/.pi 下是否存在 settings.json、extensions、skills、prompts、themes、SYSTEM.md、APPEND_SYSTEM.md 之一(清单见 trust-manager.ts:30-38),再逐级向上找 .agents/skills 目录。一个干净的空目录不会触发询问——这就是分支 B1。
  • 决定存在哪里:ProjectTrustStore 把决定写进 <agentDir>/trust.json(core/trust-manager.ts:209-213),对应测试 packages/coding-agent/test/trust-manager.test.ts。
  • 谁提供 UI:createProjectTrustContext(cli/project-trust.ts:7)在非交互模式下让 select、confirm、input 全部返回 undefined 或 false,于是决策链只能走到 F1。

真实的询问界面长这样(真实采集,来自 research/cli-captures/pi-tui-startup.txt):

text
 Trust project folder?
 /Volumes/macport/pibook/pipibook/_sources/pi

 This allows pi to load .pi settings and resources, install missing project packages, and execute
 project extensions.

 → Trust
   Trust parent folder (/Volumes/macport/pibook/pipibook/_sources)
   Trust (this session only)
   Do not trust
   Do not trust (this session only)

最容易被忽略的是信任决策在什么时候发生。它不在 main() 主干上,而是被塞进资源加载流程里的一个回调(main.ts:742-765),由 ResourceLoader.reload() 触发。reload() 做的是两趟加载:

ts
// packages/coding-agent/src/core/resource-loader.ts:395-403
let preTrustExtensions: LoadExtensionsResult | undefined;
if (options?.resolveProjectTrust) {
	preTrustExtensions = await this.loadProjectTrustExtensions();      // 强制 untrusted 的第一趟
	const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions });
	this.settingsManager.setProjectTrusted(projectTrusted);
}
// reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state.
await this.settingsManager.reload();                                   // 按最终信任状态的第二趟

第一趟(resource-loader.ts:380-386)先把项目强制标记为不可信,只加载全局与命令行临时指定的扩展——这样「项目自带的扩展代码」在被信任之前绝不会执行;第二趟才按最终结论重新读设置。从源码结构看,这是一个典型的「先降权、再提权」引导过程。

SessionManager:七个入口,一个出口 ​

会话目录先按三级优先级确定:--session-dir 优先于环境变量 PI_CODING_AGENT_SESSION_DIR,再退到设置里的 sessionDir(main.ts:675-679,环境变量名定义在 config.ts:509)。这里读设置用的是 startupSettingsManager(main.ts:656),它创建时没有传 projectTrusted,默认按可信处理,所以全局与项目两层都会读;源码注释(main.ts:670-674)说明它只用于会话选择阶段的 sessionDir 查找,真正运行期的设置要等信任决策之后重新创建。然后 createSessionManager 按 flag 分派:

packages/coding-agent/src/main.ts · createSessionManager
earendil-works/pi@16787ad第 357–365 行在 GitHub 查看 ↗
会话管理器工厂:第一分支就是 --no-session、--help、--list-models 走内存会话,不落盘。

完整分派(源码事实,main.ts:357-448):

条件结果行号
--no-session / --help / --list-modelsSessionManager.inMemory363-365
--fork <id>解析路径后 forkSessionOrExit367-388
--session <id>本地直接打开;跨项目则询问是否 fork390-412
--resume弹 TUI 选择器 selectSession414-429
--continueSessionManager.continueRecent431-433
--session-id <id>存在则打开,否则以该 id 新建435-445
以上都不满足SessionManager.create447

互斥校验在更早的 validateForkFlags 与 validateSessionIdFlags(main.ts:298-335,调用点 main.ts:649-650):--fork 不能与 --session、--continue、--resume、--no-session 同用。默认会话目录是 <agentDir>/sessions/--<把斜杠换成短横的绝对路径>--/,编码规则见 core/session-manager.ts:589-602,continueRecent 在其中找最近一个会话,找不到就静默新建(session-manager.ts:1790)。会话的存储格式与会话树留到 5.5 与 6.5。

--help 走内存会话这一条很能说明设计意图:即使只是看帮助,Pi 也要把整个运行时装配起来,因为帮助文本里要列出扩展注册的 flag(main.ts:857-864);但它绝不会因此在磁盘上留下一个空会话文件。

createRuntime:三层工厂 ​

createRuntime 是一个闭包(main.ts:717-843),类型是 CreateAgentSessionRuntimeFactory(core/agent-session-runtime.ts:35-41)。它内部按三层依次构造:

第一层,造服务——createAgentSessionServices 组装一组绑定到某个 cwd 的服务:

packages/coding-agent/src/main.ts · createAgentSessionServices
earendil-works/pi@16787ad第 735–741 行在 GitHub 查看 ↗
先按已知的信任状态新建一个 SettingsManager(还需要询问时暂按不可信,main.ts:726-734 决定),再用它造出 cwd 绑定的服务集合。

服务集合包含 ModelRuntime(读 <agentDir>/auth.json 与 models.json,动态刷新到的 Provider 模型目录则缓存在同目录的 models-store.json,见 core/model-runtime.ts:176-182)、SettingsManager、DefaultResourceLoader,以及扩展注册的 Provider,最后 modelRuntime.refresh({ allowNetwork: false }) 离线刷新一次模型目录(定义见 core/agent-session-services.ts:135-193)。

第二层,造会话——buildSessionOptions(main.ts:801)把 CLI 参数与设置解析成模型、思考等级、工具白名单与黑名单,然后 createAgentSessionFromServices(main.ts:821)转调 createAgentSession(core/agent-session-services.ts:202-205 到 core/sdk.ts:175),在那里 new Agent(...) 与 new AgentSession(...) 才真正被创建。这条链的下游是 5.2 的内容。

第三层,造持有者——

earendil-works/pi@16787ad第 422–440 行在 GitHub 查看 ↗
用工厂造出第一个 runtime,并把工厂本身存进 AgentSessionRuntime,供后续 /new、/resume、/fork 复用。

第三层是整段设计的点睛之笔:工厂被保存下来了。所以当你在交互模式里敲 /new 或 /resume 切到另一个项目的会话时,Pi 不是去「修改」现有对象,而是用同一个工厂在新的 cwd 上重建整套服务与 AgentSession(AgentSessionRuntime 类见 agent-session-runtime.ts:74)。据此推断(尚未在源码中直接证实):这也是为什么信任决策要做成可按 cwd 缓存的 projectTrustByCwd(main.ts:711)——切换目录时需要重新判断,同一目录内则不重复询问。

模式分派:早决定,晚执行 ​

resolveAppMode(main.ts:111-122)在 4.4 已经读过:--mode rpc 与 --mode json 优先,其次 -p 或任一标准输入输出不是终端(TTY)就进 print,否则 interactive。它在 main.ts:638 就被调用,比设置加载还早,因为紧接着要决定是否 takeOverStdout()(main.ts:639-641,定义 core/output-guard.ts:45)——把启动期所有写往 stdout 的杂音改道 stderr,保证非交互模式下 stdout 只有干净的结果。例外是不带 -p、--mode 的 --help 与 --list-models(isPlainRuntimeMetadataCommand,main.ts:128-130):它们的输出本身就是结果,所以不接管。这条约束有专门的测试 packages/coding-agent/test/stdout-cleanliness.test.ts,它直接 spawn src/cli.ts 跑真实进程来验证。

真正的分派在最后(main.ts:930-980):rpc 走 runRpcMode(runtime);interactive 走 new InteractiveMode(runtime, {...}) 后 run();其余走 runPrintMode(runtime, {...})。三条分支拿到的都是同一个 runtime 对象——这就是 4.1 说的「四种使用方式共享一个核心」在代码层面的样子,三种模式各自的细节在 6.10。

中间还有一次模式降级:readPipedStdin()(main.ts:875-880)读到管道内容且当前是 interactive 时,会把 appMode 改成 print。调研笔记提出这个分支可能不可达(因为 stdin 非 TTY 时 resolveAppMode 已经返回 print),本书据此推断(尚未在源码中直接证实)它是防御性代码,用于 isTTY 为 undefined 的边缘环境。

一处源码与文档的不一致 ​

阅读时发现了一处对不上的地方,如实记录:

帮助文本的 default: google 已陈旧(源码事实加分析解释)。cli/args.ts:278 写着 --provider <name> Provider name (default: google),真实采集的帮助输出(research/cli-captures/pi-help.txt 第 17 行)也是这句。但实际默认模型的选择走的是 core/sdk.ts:212-214 到 findInitialModel(core/model-resolver.ts:622):这条路径上 scopedModels 传的是空数组,于是顺序是「设置里的默认模型(且它的 Provider 已配置凭证),然后在所有有可用凭证的模型里,按 defaultModelPerProvider(core/model-resolver.ts:20)的 Provider 顺序找各自的默认模型」,源码里没有任何把 google 当默认值的逻辑。级别:帮助文本与实现不一致,以实现为准。

🌱 初学者提示遇到不一致怎么办
先分清「谁是可执行的」。注释和帮助文本都不会被执行,实现才会。确认方法也很直接:grep -rn "default: google" packages/coding-agent/src 只会命中帮助文本那一行,而真正挑选默认模型的 findInitialModel 里找不到任何偏向 google 的分支,这就足以判定帮助文本过时了。

实践任务 ​

🛠 实践任务用 PI_CODING_AGENT_DIR 观察一次干净的冷启动

目标:把配置目录指向一个临时路径,观察 ① Pi 启动时创建了哪些目录与文件;② 项目不被信任时,项目里的扩展代码到底有没有被执行;③ 交互模式下的信任询问长什么样。全程不需要任何 API Key。

前提:已完成 4.1 的实践任务(Pi 源码已 npm install,./pi-test.sh 可用)。下文用 <pi> 代表你的 Pi 仓库根目录。

步骤 1 · 造一个会触发信任询问的空项目

mkdir -p /tmp/pi-book-51/proj/.pi/extensions
cd /tmp/pi-book-51/proj
printf '{ this is not json }' > .pi/settings.json
printf 'export default function () {\n  console.error("[probe] project extension loaded");\n}\n' > .pi/extensions/probe.ts

这里放了两个「探针」。probe.ts 是一个什么都不做、只在被加载时往 stderr 打一行字的项目扩展:看到 [probe] 那一行,就说明项目扩展代码真的执行了。非法 JSON 则用来观察「谁读了 .pi/settings.json」:读了就会报解析错误。

步骤 2 · 非交互冷启动

PI_CODING_AGENT_DIR=/tmp/pi-book-51/agent <pi>/pi-test.sh --no-env -p "hi"

预期现象(本书在 macOS 上实际运行所得,末尾两行文档路径从略):

text
Running without API keys...
Warning: Invalid settings file /private/tmp/pi-book-51/proj/.pi/settings.json: Expected property name or '}' in JSON at position 2 (line 1 column 3)
No API key found for the selected model.

Use /login to log into a provider via OAuth or API key. See:

退出码为 1。没有 [probe] 那一行:项目不被信任,项目扩展没有执行。那条 warning 来自 main.ts:656 的 startupSettingsManager——前文说过,它只为会话查找服务,创建时默认按可信读了两层设置;运行期的设置管理器则因为项目未被信任,压根没读这个文件。(macOS 上 /tmp 是指向 /private/tmp 的符号链接,process.cwd() 返回的是真实路径,所以输出里是 /private/tmp;Linux 上就是 /tmp。)

步骤 3 · 看看生成了什么

find /tmp/pi-book-51/agent

预期现象:agent/ 下只有 auth.json、models-store.json(内容都是 {})和 sessions/--private-tmp-pi-book-51-proj--/(Linux 上是 --tmp-pi-book-51-proj--)。会话目录名就是把 cwd 绝对路径的斜杠换成短横再前后加 --,规则见 core/session-manager.ts:589-594。没有 trust.json,因为非交互模式从没做出过需要记住的决定。

步骤 4 · 加 -a 强制信任,对比差异

PI_CODING_AGENT_DIR=/tmp/pi-book-51/agent <pi>/pi-test.sh --no-env -a -p "hi"

预期现象:在 Running without API keys... 之后多出一行 [probe] project extension loaded,其余输出与步骤 2 相同。多出来正是因为 -a 让 resolveProjectTrusted 在第一步就返回 true(core/project-trust.ts:47-49),资源加载的第二趟于是加载并执行了项目扩展。

注意 warning 仍然只有一条。这次运行期的设置管理器其实也读了那个非法 JSON,但报出的消息与启动期那条一字不差,main.ts:896 用 deduplicateDiagnostics(core/settings-diagnostics.ts:15-25)把重复的诊断合并了。所以「warning 有几条」反映不了信任状态,这正是本任务改用扩展探针的原因。

步骤 5 · 交互模式看信任询问(需要真实终端,不能在管道里跑)

PI_CODING_AGENT_DIR=/tmp/pi-book-51/agent <pi>/pi-test.sh --no-env

预期现象:出现本章前面引用的「Trust project folder?」五选一对话框。选 Trust 后 /tmp/pi-book-51/agent/trust.json 会出现,内容是 { "/private/tmp/pi-book-51/proj": true };进入界面后启动信息的 [Extensions] 一栏列出了 probe.ts,那条设置 warning 也显示在界面里,而不是打到 stderr(交互模式把启动诊断交给 InteractiveMode 显示,见 main.ts:898-900)。首次进入交互模式还可能看到 fd not found. Downloading...,这是界面在 agent/bin/ 下安装它依赖的 fd 工具,需要联网,与信任无关。按两次 Ctrl+C 退出后再跑步骤 2,这次会出现 [probe] 那一行,因为信任来自 trust.json(决策链的分支 D)。

如何判断成功:你能用「[probe] 那一行有没有出现」这一个现象,说出当前这次启动到底信不信任项目,并指出决定它的是决策链的哪一个分支。

常见错误:① 忘了设 PI_CODING_AGENT_DIR,结果污染了自己的 ~/.pi/agent,本任务的全部意义就是隔离;② 在步骤 5 用管道或重定向运行,resolveAppMode 会因为 stdout 不是 TTY 直接进 print 模式,对话框永远不会出现;③ 步骤 5 之后忘了清理,导致后续实验带着旧的 trust.json,删掉 /tmp/pi-book-51 重来即可。

对应源码:config.ts:527-534(agentDir 解析)、settings-manager.ts:410-413(不信任则不读项目设置)、core/project-trust.ts:46-96(决策链)、core/resource-loader.ts:380-403(两趟加载)、core/settings-diagnostics.ts:15-25(诊断去重)、main.ts:638(模式判定)。

本章小结 ​

  • pi 实际启动的是构建出的 bundle(dist/bundle/cli.js 加载 cli-runtime.js),源码入口 cli.ts 只有 6 行:setupCli() 设进程环境、配置 HTTP 派发器,然后 main(process.argv.slice(2))。
  • main() 的顺序是被依赖关系逼出来的:子命令拦截(auth 最先)、parseArgs、早退分支、resolveAppMode 加 takeOverStdout、迁移、设置、会话目录与 SessionManager、定义 createRuntime、执行工厂、--help、初始消息、三分支分派。
  • 设置分全局 <agentDir>/settings.json 与项目 <cwd>/.pi/settings.json 两层,deepMergeSettings 让项目覆盖全局;嵌套对象递归合并,数组与标量整体覆盖。
  • 项目信任是一条七步短路链,非交互模式没有 UI 时保守返回 false;不被信任时项目设置直接返回 {}。信任决策被安排在资源加载的「第一趟」之后,保证项目扩展在获得信任前不会执行。
  • createRuntime 三层工厂:造服务、造 AgentSession、造持有者;工厂本身被存进 AgentSessionRuntime,会话切换等于整套重建。
  • 关键术语:配置目录(agentDir)、项目信任(Project Trust)、深合并(deepMerge)、运行时工厂(Runtime Factory)、AppMode。
  • 关键源码索引(起点到终点):cli.ts:5-6(setupCli,cli/setup.ts:4)→ main.ts:566 → parseArgs(cli/args.ts:71)→ resolveAppMode(main.ts:111)→ SettingsManager.create(core/settings-manager.ts:354)→ createSessionManager(main.ts:357)→ createRuntime(main.ts:717)→ resolveProjectTrusted(core/project-trust.ts:46)→ createAgentSessionServices(core/agent-session-services.ts:135)→ createAgentSessionFromServices(core/agent-session-services.ts:202)→ createAgentSession(core/sdk.ts:175)→ createAgentSessionRuntime(core/agent-session-runtime.ts:422)→ 分派(main.ts:930-980)。相关测试:test/args.test.ts、test/stdout-cleanliness.test.ts、test/trust-manager.test.ts、test/settings-manager.test.ts。
  • 自测问题:① 为什么 resolveAppMode 要在设置加载之前调用?② 你在一个从未打开过的仓库里执行 pi -p "hi",仓库里的 .pi/settings.json 会生效吗?依据是决策链的哪一步?③ --help 为什么要等运行时装配完成才处理?④ /resume 切到另一个项目的会话时,为什么必须重建整套服务而不能只换个会话对象?
  • 下一章:5.2 一次普通请求的完整路径——从 session.prompt() 出发,走完一次不含工具调用的对话。本章刻意未展开的内容:buildSessionOptions 里的模型解析与回退策略(6.2)、ResourceLoader 如何发现扩展与技能(7.1)、InteractiveMode 的 TUI 装配细节(6.9)、runRpcMode 的协议(6.10)。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 因为紧接着要决定是否 takeOverStdout()(main.ts:638-641)——非交互模式下 stdout 必须只剩干净的结果,启动期的杂音得改道 stderr。而紧随其后的步骤就可能往 stdout 打字,比如 runMigrations 迁移旧目录时会 console.log 一句「Migrated … commands/ → prompts/」(migrations.ts:144),要是先做这些再判定模式,杂音已经漏进 stdout 了。所以顺序不是随意排的,是被依赖关系逼出来的。这条约束还有专门的测试 test/stdout-cleanliness.test.ts,直接 spawn 真实进程来验证。
  2. 不会生效。pi -p "hi" 是非交互模式,createProjectTrustContext 让 select / confirm 全部返回 undefined,决策链走到「当前有可交互 UI 吗」这一步(图 5.1-2 的 F)时没有 UI,于是走 F1:保守返回 false。而 loadFromStorage(settings-manager.ts:410-413)在项目不被信任时连读都不读,直接返回 {}。(唯一读了它的是启动期的 startupSettingsManager,所以你会看到那条解析 warning;源码注释称它只用于会话选择阶段的 sessionDir 查找。)想让它生效,要么加 -a 强制信任(决策链第一步就返回 true),要么先在交互模式里信任过一次、把决定写进 trust.json。
  3. 因为帮助文本里要列出扩展注册的 CLI flag(main.ts:857-864)——而扩展是运行时装配过程中才被加载的,不装配就不知道有哪些 flag。所以 --help 被推迟到工厂执行之后。作为补偿,--help 会走 SessionManager.inMemory(main.ts:363):整个运行时照常装配,但绝不在磁盘上留下一个空会话文件。
  4. 因为整套服务都是绑定到某个 cwd 的:SettingsManager 读的是那个目录的 .pi/settings.json、信任状态是按 cwd 判定的、ResourceLoader 加载的是那个目录的扩展与技能、会话目录名也由 cwd 编码而来。换个项目意味着这些全都要变,只换会话对象会留下一堆指向旧目录的服务。所以 createRuntime 这个工厂被存进了 AgentSessionRuntime,/new、/resume、/fork 时用同一个工厂在新的 cwd 上重建整套服务与 AgentSession。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)