Skip to content

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

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题:你在终端敲下 pi 回车,到出现输入框之间,程序到底按什么顺序做了哪些事?这些步骤的先后为什么不能随便调换? 前置知识4.4 从哪里开始读源码(已交代 cli.ts → main.ts → AgentSession 的主链路轮廓)、2.8 Node.js 文件、路径与进程 APIprocess.argvprocess.envprocess.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.jsonbin 字段指向 dist/cli.js,对应源码 src/cli.ts。这个文件只做四件事——改进程名、打一个环境标记、屏蔽 Node 警告、配置 HTTP 连接派发器,然后把参数丢给 main()(源码事实):

ts
// 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));
earendil-works/pi@c13ffe1第 1–20 行在 GitHub 查看 ↗
入口薄壳全文 20 行:设定进程级环境后立刻交棒给 main.ts。

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)。它开头先取两个最基础的坐标(源码事实):

ts
// 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();
earendil-works/pi@c13ffe1第 534–538 行在 GitHub 查看 ↗
main 的前两个坐标:当前工作目录 cwd 与配置目录 agentDir;bootstrap 设置管理器显式声明「项目不可信」,只为读全局代理配置。

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

接下来的顺序如下图。

图加载中…

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

图中 B 这一层容易被忽略:handlePackageCommandmain.ts:540)、handleConfigCommandmain.ts:553)、runCredentialPrintCommandmain.ts:557)依次尝试匹配 pi installpi configpi auth print-api-key 这类子命令,任一命中就处理完直接返回,根本不会走到后面的启动流程。这解释了为什么 pi list 秒回而 pi 要等一会儿。

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

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

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

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

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

设置加载:两层文件与 deepMerge

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

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

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

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

两层的合并发生在 SettingsManager 的构造函数里:this.settings = deepMergeSettings(this.globalSettings, this.projectSettings)settings-manager.ts:305)。deepMergeSettingssettings-manager.ts:132-160)的规则很具体,值得记住:嵌套对象做一层浅合并({ ...baseValue, ...overrideValue }),数组与标量整体覆盖。也就是说,项目设置里写一个数组,会把全局的同名数组整个替换掉,而不是拼接。官方文档说明「项目设置覆盖全局设置」(来源:packages/coding-agent/docs/settings.md),源码与之一致,但数组不合并这一点文档没有展开。配置系统的完整讲解在 6.8 配置系统

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

ts
// 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、迁移旧字段)
earendil-works/pi@c13ffe1第 350–353 行在 GitHub 查看 ↗
项目不被信任时,项目设置连读都不读,直接返回空对象。
⚠️ 常见误解以为 .pi/settings.json 一定会生效
它只在项目被信任时才生效。如果你在一个新克隆的仓库里改了 .pi/settings.json 却毫无反应,先想「这个项目我信任过吗」,而不是怀疑配置写错了。本章实践任务会让你亲眼看到这个差别。

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

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

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

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

几个关键点:

  • 什么算「需要信任的资源」hasTrustRequiringProjectResourcescore/trust-manager.ts:184)检查 cwd/.pi 下是否存在 settings.jsonextensionsskillspromptsthemesSYSTEM.mdAPPEND_SYSTEM.md 之一(清单见 trust-manager.ts:29-37),再逐级向上找 .agents/skills 目录。一个干净的空目录不会触发询问——这就是分支 B1。
  • 决定存在哪里ProjectTrustStore 把决定写进 <agentDir>/trust.jsoncore/trust-manager.ts:208-212),对应测试 packages/coding-agent/test/trust-manager.test.ts
  • 谁提供 UIcreateProjectTrustContextcli/project-trust.ts:7)在非交互模式下让 selectconfirminput 全部返回 undefinedfalse,于是决策链只能走到 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:691-714),由 ResourceLoader.reload() 触发。reload() 做的是两趟加载:

ts
// 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,再退到全局设置里的 sessionDirmain.ts:625-629,环境变量名定义在 config.ts:496)。然后 createSessionManager 按 flag 分派:

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

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

条件结果行号
--no-session / --help / --list-modelsSessionManager.inMemory318
--fork <id>解析路径后 forkSessionOrExit322-343
--session <id>本地直接打开;跨项目则询问是否 fork345-367
--resume弹 TUI 选择器 selectSession369-384
--continueSessionManager.continueRecent386-388
--session-id <id>存在则打开,否则以该 id 新建390-400
以上都不满足SessionManager.create402

互斥校验在更早的 validateForkFlagsvalidateSessionIdFlagsmain.ts:253-290,调用点 main.ts:603-604):--fork 不能与 --session--continue--resume--no-session 同用。默认会话目录是 <agentDir>/sessions/--<把斜杠换成短横的绝对路径>--/,编码规则见 core/session-manager.ts:476-489continueRecent 在其中找最近一个会话,找不到就静默新建(session-manager.ts:1557)。会话的存储格式与会话树留到 5.56.5

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

createRuntime:三层工厂

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

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

packages/coding-agent/src/main.ts · createAgentSessionServices
earendil-works/pi@c13ffe1第 685–690 行在 GitHub 查看 ↗
先按最终信任状态新建一个 SettingsManager,再用它造出 cwd 绑定的服务集合。

服务集合包含 ModelRuntime(读 <agentDir>/auth.jsonmodels.json)、SettingsManagerDefaultResourceLoader,以及扩展注册的 Provider,最后 modelRuntime.refresh({ allowNetwork: false }) 离线刷新一次模型目录(定义见 core/agent-session-services.ts:134-191)。

第二层,造会话——buildSessionOptionsmain.ts:748)把 CLI 参数与设置解析成模型、思考等级、工具白名单与黑名单,然后 createAgentSessionFromServicesmain.ts:769)转调 createAgentSessioncore/agent-session-services.ts:200-203core/sdk.ts:169),在那里 new Agent(...)new AgentSession(...) 才真正被创建。这条链的下游是 5.2 的内容。

第三层,造持有者——

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

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

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

resolveAppModemain.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):rpcrunRpcMode(runtime)interactivenew InteractiveMode(runtime, {...})run();其余走 runPrintMode(runtime, {...})。三条分支拿到的都是同一个 runtime 对象——这就是 4.1 说的「四种使用方式共享一个核心」在代码层面的样子,三种模式各自的细节在 6.10

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

两处源码与文档的不一致

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

  1. 帮助文本的 default: google 已陈旧(源码事实加分析解释)。cli/args.ts:242 写着 --provider <name> Provider name (default: google),真实采集的帮助输出(research/cli-captures/pi-help.txt 第 17 行)也是这句。但实际默认模型的选择走的是 core/sdk.ts:206-208findInitialModelcore/model-resolver.ts:572),顺序是「设置里的默认模型,然后是命令行限定的模型集合,最后是第一个有可用凭证的 Provider 的默认模型」,源码里没有任何硬编码的 google 默认值。级别:帮助文本与实现不一致,以实现为准。
  2. cli.ts 顶部注释引用了不存在的文件(源码事实)。cli.ts:6Test with: npx tsx src/cli-new.ts,但 src/ 下并没有 cli-new.ts,同一处注释还称本文件为「refactored coding agent」。级别:重构后遗留的过时注释,不影响行为。
🌱 初学者提示遇到不一致怎么办
先分清「谁是可执行的」。注释和帮助文本都不会被执行,实现才会。确认方法也很直接:grep -rn "cli-new" packages/coding-agent/src 只会命中那句注释本身,这就足以判定它是死引用。

实践任务

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

目标:把配置目录指向一个临时路径,观察 ① 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)。多出来正是因为 -aresolveProjectTrusted 在第一步就返回 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、早退分支、resolveAppModetakeOverStdout、迁移、设置、会话目录与 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:20main.ts:521parseArgscli/args.ts:64)→ resolveAppModemain.ts:109)→ SettingsManager.createcore/settings-manager.ts:309)→ createSessionManagermain.ts:312)→ createRuntimemain.ts:667)→ resolveProjectTrustedcore/project-trust.ts:46)→ createAgentSessionServicescore/agent-session-services.ts:134)→ createAgentSessionFromServicescore/agent-session-services.ts:200)→ createAgentSessioncore/sdk.ts:169)→ createAgentSessionRuntimecore/agent-session-runtime.ts:414)→ 分派(main.ts:868-909)。相关测试:test/args.test.tstest/stdout-cleanliness.test.tstest/trust-manager.test.tstest/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)。

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