5.1 启动:pi 命令如何跑起来
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:你在终端敲下
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() 正是这么排的,理解了这个顺序,后面每一步为什么在那个位置就不再需要死记。
第一站:20 行的 cli.ts
pi 命令由 packages/coding-agent/package.json 的 bin 字段指向 dist/cli.js,对应源码 src/cli.ts。这个文件只做四件事——改进程名、打一个环境标记、屏蔽 Node 警告、配置 HTTP 连接派发器,然后把参数丢给 main()(源码事实):
// packages/coding-agent/src/cli.ts:8-20
import { APP_NAME } from "./config.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { main } from "./main.ts";
process.title = APP_NAME;
process.env.PI_CODING_AGENT = "true";
process.emitWarning = (() => {}) as typeof process.emitWarning;
// Configure undici's global dispatcher before provider SDKs issue requests.
configureHttpDispatcher();
main(process.argv.slice(2));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 正是这么做的。 main() 的编排顺序
main() 定义在 packages/coding-agent/src/main.ts:521,签名是 main(args: string[], options?: MainOptions)。它开头先取两个最基础的坐标(源码事实):
// packages/coding-agent/src/main.ts:534-538
const cwd = process.cwd();
const agentDir = getAgentDir();
const bootstrapSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
applyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher();getAgentDir注意第三行的 { projectTrusted: false }:此刻还没做任何信任判断,所以这个临时的设置管理器只允许读全局设置。这是「先拿最外层信息」原则的第一个例子。
接下来的顺序如下图。
图 5.1-1 main 的启动编排顺序
从上到下是真实执行顺序,每个节点后的 path:line 都可以直接在 main.ts 里定位。重点关注三处「时机」:模式在很早就定下来,也就是节点 E,但真正分派在最后的节点 O;工厂在 K 处只是被定义,直到 L 才第一次执行;--help 被推迟到 M,因为帮助文本要列出扩展注册的 flag。
图中 B 这一层容易被忽略:handlePackageCommand(main.ts:540)、handleConfigCommand(main.ts:553)、runCredentialPrintCommand(main.ts:557)依次尝试匹配 pi install、pi config、pi auth print-api-key 这类子命令,任一命中就处理完直接返回,根本不会走到后面的启动流程。这解释了为什么 pi list 秒回而 pi 要等一会儿。
参数解析:一个手写的 for 循环
parseArgs 没有用任何第三方命令行库,就是一个从头扫到尾的 for 循环(源码事实):
parseArgs它的收尾分支(args.ts:189-209)值得单独看:@xxx 去掉前缀进 fileArgs;无法识别的 --xxx 不报错,而是进 unknownFlags 这个 Map,留给扩展去认领;只有无法识别的单杠短 flag 才会记一条 error。这个「对未知长 flag 保持宽容」的设计,是第七部分扩展系统能注册自定义 CLI flag 的前提。
解析出的 diagnostics 在 main.ts:562-570 统一打印:warning 只提示,error 会 process.exit(1)。相关测试是 packages/coding-agent/test/args.test.ts(describe("parseArgs")),它覆盖了 --version、-p、--continue 等分支,以及 --name 缺值时报错的情况——想确认某个 flag 的确切行为,读这个测试比读实现更快。
设置加载:两层文件与 deepMerge
getAgentDir() 决定配置目录,规则只有两条(源码事实):
getAgentDir设置本身分两层,路径在 FileSettingsStorage 的构造函数里写死:
projectSettingsPath两层的合并发生在 SettingsManager 的构造函数里:this.settings = deepMergeSettings(this.globalSettings, this.projectSettings)(settings-manager.ts:305)。deepMergeSettings(settings-manager.ts:132-160)的规则很具体,值得记住:嵌套对象做一层浅合并({ ...baseValue, ...overrideValue }),数组与标量整体覆盖。也就是说,项目设置里写一个数组,会把全局的同名数组整个替换掉,而不是拼接。官方文档说明「项目设置覆盖全局设置」(来源:packages/coding-agent/docs/settings.md),源码与之一致,但数组不合并这一点文档没有展开。配置系统的完整讲解在 6.8 配置系统。
真正需要警惕的是下面这段:
// packages/coding-agent/src/core/settings-manager.ts:350-353
private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings {
if (scope === "project" && !projectTrusted) {
return {};
}
// …(省略:读文件、JSON.parse、迁移旧字段)loadFromStorage.pi/settings.json 却毫无反应,先想「这个项目我信任过吗」,而不是怀疑配置写错了。本章实践任务会让你亲眼看到这个差别。 项目信任:一条七步决策链
信任决策的实现是 resolveProjectTrusted,逻辑是一串短路判断:
resolveProjectTrusted图 5.1-2 项目信任的决策链
这张图逐条对应 core/project-trust.ts 第 46 到 96 行的七个分支,从上到下就是源码里的短路顺序。关注两个出口:F1 是非交互模式也就是管道、脚本、CI 的默认结果,即不信任;G 才是你在终端里看到的那个对话框,它写回 trust.json 后下次不再询问。
几个关键点:
- 什么算「需要信任的资源」:
hasTrustRequiringProjectResources(core/trust-manager.ts:184)检查cwd/.pi下是否存在settings.json、extensions、skills、prompts、themes、SYSTEM.md、APPEND_SYSTEM.md之一(清单见trust-manager.ts:29-37),再逐级向上找.agents/skills目录。一个干净的空目录不会触发询问——这就是分支 B1。 - 决定存在哪里:
ProjectTrustStore把决定写进<agentDir>/trust.json(core/trust-manager.ts:208-212),对应测试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):
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:691-714),由 ResourceLoader.reload() 触发。reload() 做的是两趟加载:
// packages/coding-agent/src/core/resource-loader.ts:394-402
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:379-385)先把项目强制标记为不可信,只加载全局与命令行临时指定的扩展——这样「项目自带的扩展代码」在被信任之前绝不会执行;第二趟才按最终结论重新读设置。从源码结构看,这是一个典型的「先降权、再提权」引导过程。
SessionManager:七个入口,一个出口
会话目录先按三级优先级确定:--session-dir 优先于环境变量 PI_CODING_AGENT_SESSION_DIR,再退到全局设置里的 sessionDir(main.ts:625-629,环境变量名定义在 config.ts:496)。然后 createSessionManager 按 flag 分派:
createSessionManager完整分派(源码事实,main.ts:312-403):
| 条件 | 结果 | 行号 |
|---|---|---|
--no-session / --help / --list-models | SessionManager.inMemory | 318 |
--fork <id> | 解析路径后 forkSessionOrExit | 322-343 |
--session <id> | 本地直接打开;跨项目则询问是否 fork | 345-367 |
--resume | 弹 TUI 选择器 selectSession | 369-384 |
--continue | SessionManager.continueRecent | 386-388 |
--session-id <id> | 存在则打开,否则以该 id 新建 | 390-400 |
| 以上都不满足 | SessionManager.create | 402 |
互斥校验在更早的 validateForkFlags 与 validateSessionIdFlags(main.ts:253-290,调用点 main.ts:603-604):--fork 不能与 --session、--continue、--resume、--no-session 同用。默认会话目录是 <agentDir>/sessions/--<把斜杠换成短横的绝对路径>--/,编码规则见 core/session-manager.ts:476-489,continueRecent 在其中找最近一个会话,找不到就静默新建(session-manager.ts:1557)。会话的存储格式与会话树留到 5.5 与 6.5。
--help 走内存会话这一条很能说明设计意图:即使只是看帮助,Pi 也要把整个运行时装配起来,因为帮助文本里要列出扩展注册的 flag(main.ts:804-810);但它绝不会因此在磁盘上留下一个空会话文件。
createRuntime:三层工厂
createRuntime 是一个闭包(main.ts:667-791),类型是 CreateAgentSessionRuntimeFactory(core/agent-session-runtime.ts:35-41)。它内部按三层依次构造:
第一层,造服务——createAgentSessionServices 组装一组绑定到某个 cwd 的服务:
createAgentSessionServices服务集合包含 ModelRuntime(读 <agentDir>/auth.json 与 models.json)、SettingsManager、DefaultResourceLoader,以及扩展注册的 Provider,最后 modelRuntime.refresh({ allowNetwork: false }) 离线刷新一次模型目录(定义见 core/agent-session-services.ts:134-191)。
第二层,造会话——buildSessionOptions(main.ts:748)把 CLI 参数与设置解析成模型、思考等级、工具白名单与黑名单,然后 createAgentSessionFromServices(main.ts:769)转调 createAgentSession(core/agent-session-services.ts:200-203 到 core/sdk.ts:169),在那里 new Agent(...) 与 new AgentSession(...) 才真正被创建。这条链的下游是 5.2 的内容。
第三层,造持有者——
createAgentSessionRuntime第三层是整段设计的点睛之笔:工厂被保存下来了。所以当你在交互模式里敲 /new 或 /resume 切到另一个项目的会话时,Pi 不是去「修改」现有对象,而是用同一个工厂在新的 cwd 上重建整套服务与 AgentSession(AgentSessionRuntime 类见 agent-session-runtime.ts:74)。据此推断(尚未在源码中直接证实):这也是为什么信任决策要做成可按 cwd 缓存的 projectTrustByCwd(main.ts:661)——切换目录时需要重新判断,同一目录内则不重复询问。
模式分派:早决定,晚执行
resolveAppMode(main.ts:109-120)在 4.4 已经读过:--mode rpc 与 --mode json 优先,其次 -p 或任一标准输入输出不是终端(TTY)就进 print,否则 interactive。它在 main.ts:592 就被调用,比设置加载还早,因为紧接着要决定是否 takeOverStdout()(main.ts:594,定义 core/output-guard.ts:45)——把启动期的杂音改道 stderr,保证非交互模式下 stdout 只有干净的结果。这条约束有专门的测试 packages/coding-agent/test/stdout-cleanliness.test.ts,它直接 spawn src/cli.ts 跑真实进程来验证。
真正的分派在最后(main.ts:868-909):rpc 走 runRpcMode(runtime);interactive 走 new InteractiveMode(runtime, {...}) 后 run();其余走 runPrintMode(runtime, {...})。三条分支拿到的都是同一个 runtime 对象——这就是 4.1 说的「四种使用方式共享一个核心」在代码层面的样子,三种模式各自的细节在 6.10。
中间还有一次模式降级:readPipedStdin()(main.ts:819-825)读到管道内容且当前是 interactive 时,会把 appMode 改成 print。调研笔记提出这个分支可能不可达(因为 stdin 非 TTY 时 resolveAppMode 已经返回 print),本书据此推断(尚未在源码中直接证实)它是防御性代码,用于 isTTY 为 undefined 的边缘环境。
两处源码与文档的不一致
阅读时发现了两处对不上的地方,如实记录:
- 帮助文本的
default: google已陈旧(源码事实加分析解释)。cli/args.ts:242写着--provider <name> Provider name (default: google),真实采集的帮助输出(research/cli-captures/pi-help.txt第 17 行)也是这句。但实际默认模型的选择走的是core/sdk.ts:206-208到findInitialModel(core/model-resolver.ts:572),顺序是「设置里的默认模型,然后是命令行限定的模型集合,最后是第一个有可用凭证的 Provider 的默认模型」,源码里没有任何硬编码的 google 默认值。级别:帮助文本与实现不一致,以实现为准。 cli.ts顶部注释引用了不存在的文件(源码事实)。cli.ts:6写Test with: npx tsx src/cli-new.ts,但src/下并没有cli-new.ts,同一处注释还称本文件为「refactored coding agent」。级别:重构后遗留的过时注释,不影响行为。
grep -rn "cli-new" packages/coding-agent/src 只会命中那句注释本身,这就足以判定它是死引用。 实践任务
目标:把配置目录指向一个临时路径,观察 ① Pi 启动时创建了哪些目录与文件;② 项目不被信任时 .pi/settings.json 到底有没有被读;③ 交互模式下的信任询问长什么样。全程不需要任何 API Key。
前提:已完成 4.1 的实践任务(Pi 源码已 npm install,./pi-test.sh 可用)。下文用 <pi> 代表你的 Pi 仓库根目录。
步骤 1 · 造一个会触发信任询问的空项目
mkdir -p /tmp/pi-book-51/proj/.pi
cd /tmp/pi-book-51/proj
printf '{ this is not json }' > .pi/settings.json故意写一个非法 JSON,是为了让「有没有真的去读这个文件」变成肉眼可见的现象:读了就会报解析错误,没读就一声不响。
步骤 2 · 非交互冷启动
PI_CODING_AGENT_DIR=/tmp/pi-book-51/agent <pi>/pi-test.sh --no-env -p "hi"预期现象(本书实际运行所得,路径已按本任务替换):
Running without API keys...
Warning: (startup session lookup, project settings) Expected property name or '}' in JSON at position 2 (line 1 column 3)
No API key found for the selected model.退出码为 1。注意只有一条 warning,来自 main.ts:610-611 那个用于会话查找的设置管理器;运行期的设置管理器因为项目未被信任,压根没读这个文件。
步骤 3 · 看看生成了什么
find /tmp/pi-book-51/agent预期现象:只有 agent/、agent/auth.json(内容是 {})和 agent/sessions/--tmp-pi-book-51-proj--/ 三项。会话目录名就是把 cwd 绝对路径的斜杠换成短横再前后加 --,规则见 core/session-manager.ts:476-480。没有 trust.json,因为非交互模式从没做出过需要记住的决定。
步骤 4 · 加 -a 强制信任,对比差异
PI_CODING_AGENT_DIR=/tmp/pi-book-51/agent <pi>/pi-test.sh --no-env -a -p "hi"预期现象:warning 从 1 条变成 3 条,多出的两条前缀是 (runtime creation, project settings)。多出来正是因为 -a 让 resolveProjectTrusted 在第一步就返回 true(core/project-trust.ts:47-49),运行期设置管理器于是真的去读了那个非法 JSON——构造时读一次、ResourceLoader.reload() 里再读一次(core/resource-loader.ts:402),所以是两条。
步骤 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 会出现,里面记着这个目录的决定。按两次 Ctrl+C 退出后再跑步骤 2,那条 warning 会变成三条,因为这次信任来自 trust.json。
如何判断成功:你能用「warning 有几条」这一个现象,说出当前这次启动到底信不信任项目,并指出决定它的是决策链的哪一个分支。
常见错误:① 忘了设 PI_CODING_AGENT_DIR,结果污染了自己的 ~/.pi/agent,本任务的全部意义就是隔离;② 在步骤 5 用管道或重定向运行,resolveAppMode 会因为 stdout 不是 TTY 直接进 print 模式,对话框永远不会出现;③ 步骤 4 之后忘了清理,导致后续实验带着旧的 trust.json,删掉 /tmp/pi-book-51 重来即可。
对应源码:config.ts:514-521(agentDir 解析)、settings-manager.ts:350-353(不信任则不读项目设置)、core/project-trust.ts:46-96(决策链)、core/resource-loader.ts:387-402(两趟加载)、main.ts:592(模式判定)。
本章小结
cli.ts只有 20 行:设进程环境、配置 HTTP 派发器,然后main(process.argv.slice(2))。main()的顺序是被依赖关系逼出来的:子命令拦截、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:20→main.ts:521→parseArgs(cli/args.ts:64)→resolveAppMode(main.ts:109)→SettingsManager.create(core/settings-manager.ts:309)→createSessionManager(main.ts:312)→createRuntime(main.ts:667)→resolveProjectTrusted(core/project-trust.ts:46)→createAgentSessionServices(core/agent-session-services.ts:134)→createAgentSessionFromServices(core/agent-session-services.ts:200)→createAgentSession(core/sdk.ts:169)→createAgentSessionRuntime(core/agent-session-runtime.ts:414)→ 分派(main.ts:868-909)。相关测试: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)。