源码篇第7章 户口本上的住址——pi-coding-agent

本章导读: 源码路线第 ⑥ 站(产品层)。进入最厚的
pi-coding-agent包,读main.ts、core/agent-session-services.ts和core/model-runtime.ts。这一站要跟着一次pi命令从敲下去到会话跑起来,弄清楚参数怎么解析、资源按哪个目录装配、三种模式怎么分叉。
小A昨天在项目 A 里开了个会话,今天站在项目 B 的目录下想把它恢复出来。他敲下命令,忽然停住:“老Z,这个会话当初是在 A 里建的,恢复的时候项目资源到底按哪个目录加载?按我现在的启动目录,还是按它会话自己的目录?”
“这正好是 pi-coding-agent 启动链里最容易被忽略的一个问题。”老Z说,“终端层只能显示和输入,把命令行参数、当前目录、会话、模型和交互模式装配为产品的是 pi-coding-agent。我们沿入口回答你的问题;工具、扩展和信任边界由下一章展开。”
本章源码证据均来自上游 pi 标签 v1.0.0(提交 a13d35a742c6ef8462812a28fbe1d8c8b7431c32,包版本 1.0.0,2026-10-01)。本章跟读 main() 从参数解析到模式分派的主路径,以及会话目录、服务装配和三种运行模式;不逐项覆盖包管理器、首次设置、迁移、导出 HTML、远程目录刷新和每个 CLI flag,工具、扩展与信任边界留给源码篇第8章。
入口先处理不会启动会话的命令
小A猜:“main() 第一行,该是把参数解析出来吧?”
“恰恰相反,第一行就在过滤‘根本不该启动会话’的东西。”老Z打开 packages/coding-agent/src/main.ts:
export async function main(args: string[], options?: MainOptions) {
if (await runAuthCommand(args)) return;
if (await handlePackageCommand(args, { extensionFactories })) return;
if (await handleConfigCommand(args, { extensionFactories })) return;
if (args[0] === "mcp") { /* 懒加载 MCP 子命令 */ return; }
const parsed = parseArgs(args);
if (parsed.diagnostics.some((d) => d.type === "error")) process.exit(1);
// 建立会话和运行时
}“逐段读,先分清‘命令已处理’与‘发生错误’:前两个是正常早退;参数 diagnostics 中有 error 才设置失败退出。版本、导出、help 和模型列表也各有早退分支,别把所有 return 解读为调用失败。”
“这些早退分支的先后顺序,有讲究吗?”
“有。”老Z说,“runAuthCommand 排在最前,连 bootstrap 设置都还没建;它处理 pi auth print-api-key、pi auth print-bearer-token 和 pi auth check,自己内部 parseArgs 一次。handlePackageCommand、handleConfigCommand 和 pi mcp 子命令也排在 parseArgs 之前,因为它们处理的是 pi install xxx、pi config set、pi mcp add 这种子命令,参数解析规则跟主命令不一样。再往后才是主参数解析、--version、--export。help 和 --list-models 比较特殊,要等 runtime 装出来才能打印:help 要列扩展 flag,list-models 要有 modelRuntime,所以那两个 process.exit(0) 在文件靠后的位置,不在这一段。”
“Windows 那个 pi update 为什么特殊处理?”
“注释里写得很清楚:Node 在 Windows 上,process.exit(0) 如果在 fetch() teardown 期间触发会 assert,所以成功的 pi update 让它自然 drain 而不强制退出。”老Z说,“这是个平台特例,别当成通用模式。注意它只在 exitCode === 0 && args[0] === "update" 时 drain;非零退出码照常 process.exit(exitCode),避免把失败误判为成功。”
cli.ts 调用 main(args)。参数诊断、--version、导出、RPC 文件参数限制、fork/session ID 校验都在会话运行时创建之前完成。把早退分支摊开看,能更快记住哪些 return 是成功、哪些是失败:
main() 的早退分支(v1.0.0,按执行顺序):
| 分支 | 触发条件 | 结果 |
| --- | --- | --- |
| runAuthCommand | `pi auth print-api-key`/`print-bearer-token`/`check` | 自己内部 parseArgs;check 用 0/1/2 区分就绪、未就绪、无效 |
| handlePackageCommand | `pi install`/`update`/`uninstall`… | 合并 exitCode 后退出 |
| handleConfigCommand | `pi config set`… | return |
| mcp 子命令 | `args[0] === "mcp"` | 懒加载后设 exitCode 并 return |
| 参数诊断含 error | `parseArgs` 的 diagnostics | exit(1) |
| --version | `parsed.version` | 打印 VERSION 后 exit(0) |
| --export | `parsed.export` | 导出后 exit(0) |
| RPC 带 @file 参数 | rpc 且 fileArgs 非空 | exit(1) |
| fork/session-id 冲突 | validateForkFlags / validateSessionIdFlags | exit(1) |
| help / --list-models | 要等 runtime 装完才能列 | 文件靠后 exit(0) |“这张表里有个容易看漏的点:handlePackageCommand 退出前还要合并 process.exitCode,因为子命令内部可能已经设过退出码。”老Z说,“除了子命令自己设的退出码,主路径真正的失败信号到头来只有两类:参数诊断里的 error,以及后面 runtime 诊断里的 error。”
主调用链(节选)是:
cli.ts → main(args) → parseArgs()
→ createSessionManager()
→ createAgentSessionRuntime(createRuntime, ...)
→ createAgentSessionServices() → createAgentSessionFromServices()
→ InteractiveMode.run() | runPrintMode() | runRpcMode()节选省略迁移、诊断与主题初始化。模式由 resolveAppMode(parsed, stdinIsTTY, stdoutIsTTY) 决定;非交互输出会接管 stdout,管道 stdin 在非 RPC 情况下可把交互模式改为打印模式。resolveAppMode 还会返回 "json" 这种模式,它和 "print" 走同一个打印 runner,只是输出格式由 toPrintOutputMode() 切换成 JSON。
先找会话,再确定运行时目录
小A把话题拉回开场:“那目录到底在哪一步定下来?”
“看 main() 的 session 创建段。”老Z说:
let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager);
const missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd);
if (missingSessionCwdIssue) {
if (appMode === "interactive") { /* 询问并打开 */ }
else { console.error(...); process.exit(1); }
}“详细状态序列是:启动 cwd → bootstrap settings → sessionDir → SessionManager → session cwd → runtime settings/resources/models → session runtime → mode。main() 以启动目录创建临时设置管理器,用它协助确定 session directory 和创建/打开 SessionManager。随后通过 sessionManager.getCwd() 得到会话目录,再在 createRuntime 内以该目录创建运行时设置、资源和模型范围。”
比喻:户口本上的住址
会话像一个人,metadata 就是他的户口本,
cwd是登记的住址。恢复会话不是把他从口袋里掏出来,而是回到他登记的住址去办事,不跟着你现在的脚后跟走。
“所以答案是:恢复来自另一项目的会话时,项目设置、资源、Provider 注册和模型解析都不该绑定到你敲命令的那个目录,而要跟会话登记的目录走。”老Z说,“若会话缺少 cwd,交互模式调用 promptForMissingSessionCwd() 让用户选择;非交互模式输出 MissingSessionCwdError 并以失败退出。这里宁可走一条明确的错误路径,也不静默猜一个目录。”
“‘缺少 cwd’具体怎么判定?”
“看 getMissingSessionCwdIssue():它先确认有 session 文件(内存会话没有),再取 sessionManager.getCwd(),用 existsSync 判断这个目录还在不在。”老Z说,“不在就返回 issue。fallbackCwd 是当前 process.cwd()。交互模式弹个选择器,让用户在‘继续用当前 cwd’和‘取消’之间选;选完用 SessionManager.open(..., selectedCwd) 重新打开并改写 cwd。非交互模式直接拒,因为没人能选。”
“那 createSessionManager 内部那些 --session/--resume/--fork 又怎么分?”
“它是一条优先级链,按顺序短路。”老Z说:
createSessionManager() 的优先级链(短路求值):
noSession / help / listModels → inMemory(不落盘)
fork → 先查目标 id 是否已存在 → 解析路径 → forkFrom
session → 解析路径:path/local 直接 open;
global 打印“会话在别的项目”并询问是否 fork 进当前目录;
not_found 报错退出
resume → 选择器(本地列表 + 全局列表)→ open
continue → continueRecent
sessionId → 本项目精确或前缀匹配 → open;否则提示并新建
兜底 → SessionManager.create“每个分支的出口含义要分清:fork 里查目标 id 已存在是为了防止撞号,查到就退出;session 里 global 分支问‘要不要 fork 进当前目录’,选‘不’就 process.exit(0) 安静离开;resume 里选择器返回空是用户主动取消。”老Z说,“这些 exit 都是‘用户取消’或‘找不到’,不是 bug。”
“‘全局匹配’靠什么实现?”
“resolveSessionPath:参数里含 /、\ 或 .jsonl 结尾就先当文件路径 resolve;否则先用 SessionManager.findById 在本项目找精确 id,注释说明这只需读会话头;找不到再 SessionManager.list(cwd) 做前缀匹配,还找不到才 listAll 全项目扫。所以‘在别的项目找到’才进入 global 分支,项目内优先本来就是搜索顺序的一部分。”
服务的装配顺序
“会话目录定了,服务又是怎么组装起来的?”
“createAgentSessionServices() 先创建长期服务对象,再加载资源,再刷新模型。”老Z打开 packages/coding-agent/src/core/agent-session-services.ts:
const modelRuntime = await ModelRuntime.create({ authPath, modelsPath, signal: options.modelRuntimeSignal });
const resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await resourceLoader.reload(options.resourceLoaderReloadOptions);
await modelRuntime.refresh({ allowNetwork: false });“注意 allowNetwork: false 只是这个调用点的参数,别把它读成整个产品永不联网的承诺。之后 createAgentSessionFromServices() 才调用 createAgentSession(),因此工具和模型范围可以基于已经解析的目标 cwd 构造。”
“为什么这里偏偏不联网?”
“启动时要快、要可离线。”老Z说,“create() 里有个独立的 allowModelNetwork 开关控制首次 catalog 拉取,refresh({ allowNetwork: false }) 只读本地缓存把模型表建起来。真正联网刷新发生在后面:交互模式在 TUI 起来之后,RPC 模式在分派前后台跑 modelRuntime.refresh({ signal }),15 秒后 abort,失败静默吞掉。main() 还给 ModelRuntime.create、resolveModelScope 和 --list-models 各配了 15 秒超时信号。”
“DefaultResourceLoader 管什么?”
“它管理扩展、skills、提示模板、主题、项目上下文和系统提示词的加载结果及诊断(resource-loader.ts)。ModelRuntime 维护模型注册、配置和认证状态(model-runtime.ts);resolveModelScope()、findInitialModel() 等函数(model-resolver.ts)把配置或命令行模式解析为可用模型。这种先服务、后会话的分离,让调用方能在构造 Agent 前基于目标 cwd 解决模型和资源问题;不过源码并未把它声明为唯一可行架构。”
“Provider 注册的时机我怎么追踪?”
“createAgentSessionServices 里有三段循环:先 drain pendingProviderRegistrations 调 modelRuntime.registerProvider,再 drain pendingNativeProviderRegistrations 调 registerNativeProvider,v1.0.0 又加了 drain pendingVirtualModelRegistrations 调 registerVirtualModel,每次都 try/catch 把异常收成 error diagnostic。”老Z说,“ExtensionRunner.bindCore() 里还有一处 flush,那是绑定会话动作时再补一轮。所以 Provider 注册可能出现在加载时排队、服务创建时落地、绑定后追加三个点。注册失败不会让进程崩,而是变成 diagnostic,由 main() 末端那段 diagnostics.some(type==='error') 决定是否退出。v1.0.0 里,交互模式没有 error 时不在启动阶段打印诊断,而是把去重后的 startupDiagnostics 交给 InteractiveMode 在界面里显示;非交互模式或有 error 时照常打印到 stderr。”
“等等,‘加载时排队’具体怎么理解?”
“看 createExtensionRuntime() 的默认实现:加载阶段的 registerProvider 不是直接注册,而是往 pendingProviderRegistrations 里 push。”老Z说,“原因是扩展加载发生在模型注册表可用之前:registerProvider 要写进 ModelRuntime,而 ModelRuntime 要等 createAgentSessionServices 里才创建。bindCore 里的那处 flush 也一样:把绑定前积压的注册一次性落地到 modelRegistry,异常走 emitError。‘排队’和‘落地’是两段代码、两个时机,都是本快照里可核对的。”
启动状态与失败语义
| 阶段 | 关键状态 | 成功出口 | 错误/边界出口 |
|---|---|---|---|
| 参数 | parsed.diagnostics | 继续选择模式 | error 诊断退出 1 |
| 会话 | SessionManager 与 cwd | 构造 runtime | 非交互缺 cwd 退出 1 |
| 资源 | loader 结果与 diagnostics | 注册 Provider | extension error 阻止启动 |
| 模型 | ModelRuntime/scoped models | 创建 session | 非交互无模型退出 1 |
| 模式 | appMode | interactive/print/rpc 分派 | RPC 参数限制早退 |
模式是同一运行时的不同出口
小A:“交互、打印、RPC 三个模式,是不是三个不同的 Agent?”
“不是。它们共享先前装配出的 session、模型和资源服务。”老Z打开 main.ts 末端的分派:
if (appMode === "rpc") await runRpcMode(runtime);
else if (appMode === "interactive") await interactiveMode.run();
else {
const exitCode = await runPrintMode(runtime, options);
if (exitCode !== 0) process.exitCode = exitCode;
}“打印模式把非零结果交给 Node 的 process.exitCode;交互路径运行 InteractiveMode;RPC 则避免把普通 stdin 文件参数混入协议输入。共享服务不代表每个模式都接受相同的启动条件:打印或 RPC 模式缺少可用模型会报错退出;启动时扩展加载诊断为 error 也会阻止继续运行,并给出可关闭扩展的提示。”
“appMode 是一开始就定死的吗?”
“不一定。”老Z说,“resolveAppMode 根据 parsed.mode、--print、stdin/stdout 是否 TTY 给出初值。但后面有改写:如果非 RPC 模式读到 piped stdin,appMode === "interactive" 会被改成 "print",因为管道喂进来的内容没法走交互界面。还有 trustPromptMode,当 help 或 listModels 时强制用 "print" 做信任询问,避免为了一条元数据命令弹 TUI。”
“为什么打印模式用 process.exitCode 而不是 process.exit()?”
“为了让 Node 自然走完事件循环、把 buffered 输出 flush 掉。”老Z说,“runPrintMode 返回退出码赋给 exitCode,函数 return 后 restoreStdout() 恢复被接管的 stdout,然后正常退出。直接 process.exit() 可能截断未写完的 stdout,这是 Node 的已知陷阱。”
“stdout 被接管,接的是什么机制?”
“看 output-guard.ts 的 takeOverStdout/restoreStdout。”老Z说,“非交互模式不是 interactive 时会 takeOverStdout(),把 process.stdout.write 换成可恢复的实现,这样打印模式的输出不走 TUI 的 ProcessTerminal 渲染路径,两套输出不会打架。restoreStdout 在模式结束时把原实现装回去,和 process.exitCode 正好配套,打印模式的收尾就靠这一对。”
“模式分派的三段我在 main() 末端看到了,但 RPC 为什么单独说‘避免把普通 stdin 文件参数混入协议输入’?”
“因为 RPC 模式用 stdin 走 JSON-RPC。readPipedStdin 那段明确跳过 appMode === 'rpc',而且 parsed.mode === 'rpc' && parsed.fileArgs.length > 0 会直接报错退出,@file 参数进不来。这三个模式里,RPC 对 stdin 的所有权最强,启动条件也最挑剔。”
配置与故障边界
小A:“那配置优先级呢?网上说好几层。”
“别把旧版本文档中的固定‘若干层优先级’当作本快照事实。”老Z说,“packages/coding-agent/src/config.ts 提供包目录、应用名、配置目录、模型、认证、会话和资源路径的解析函数;实际设置合并和诊断由 SettingsManager 等调用者处理,应以当前设置加载代码与具体字段为准。”
“SettingsManager 在这条启动链里要建几份?”
“三份,各管一个阶段。”老Z说,“第一份是 bootstrapSettingsManager,用启动 cwd 建、projectTrusted: false,只用来查 sessionDir 和做首次设置;第二份是 startupSettingsManager,仍是启动 cwd 但带信任,用于会话选择期间的设置读取。第三份是真正绑定目标 cwd 的 runtimeSettingsManager,在 createRuntime 闭包里、用 sessionCwd 建,会话运行期用的就是它。三份管理器对应三个阶段,别把它们当成同一份配置的多次重建。”
“为什么 bootstrapSettingsManager 偏偏 projectTrusted: false?”
“因为它要做的只是查 session directory 和套 HTTP 代理,这两件事不需要信任项目内容,所以用最小信任构造。startupSettingsManager 出现晚一点,参与 --resume 选择器和首启设置,带默认信任。runtimeSettingsManager 的 projectTrusted 是 createRuntime 里现算的,可能被 trustOverride、trustStore 缓存或 resolveProjectTrusted() 的结果改写。”老Z说,“同一个 SettingsManager.create 被调用三次,参数不同、用途不同,追踪启动链时把这三份分开记。”
启动的收益是将命令解析、恢复、服务创建和模式分派集中;代价是入口必须处理大量早退、诊断和 cwd 变化。本文未逐项覆盖包管理器、首次设置、所有迁移、导出 HTML、远程目录刷新或每个 CLI flag。
小结
pi-coding-agent 的 main() 做的事情看起来琐碎,但每一步都在回答一个具体问题。入口先分流掉包管理、配置、版本号、导出这些不该启动会话的命令,它们各有早退分支,不要把这些 return 误读为调用失败。真正的主路径从创建会话管理器开始,这里有一个容易被忽略的关键决策:恢复一个会话时,项目资源、模型解析和 Provider 注册都按会话自己的 cwd 来装配,而不是按你敲命令时所在的目录。会话的 metadata 记着它原本属于哪个项目,恢复就要回到那个项目去办事。如果会话缺少 cwd,交互模式会问用户,非交互模式则直接报错退出:这里拒绝猜测目录,是恢复安全性的一个可见取舍。
服务装配有严格的先后:先创建长期服务对象,再加载资源,再刷新模型可用性,最后才构造会话。交互、打印、RPC 三种运行模式共享的是同一份装配好的运行时,而不是三份独立的 Agent,但共享不代表它们接受相同的启动条件:打印或 RPC 模式缺模型会报错退出,扩展加载诊断为 error 也会阻止运行。
这章另一个值得记住的点是早退分支的语义:版本、导出、help、模型列表都是正常退出,handlePackageCommand 甚至要合并 process.exitCode;真正的失败信号只有参数诊断和 runtime 诊断里的 error 两类。createSessionManager 是一条按优先级短路的链:noSession/help 走内存会话,fork 防撞号,session 遇到跨项目会话会询问是否 fork 进当前目录,resume 走选择器,continue 拿最近,兜底才新建。每条分支的 exit 都各有含义,不能一律当成异常。SettingsManager 在启动链里出现三次,bootstrap/startup/runtime 各管一个阶段,信任参数不同,把它们当成同一份配置的多次重建,就会把目录与信任的绑定关系看错。把入口这一段从头读一遍,最容易理解的不是某个具体 flag,而是“解析、装配、分派”这条主线如何把早期早退、cwd 变化和错误诊断串成一个整体。
源码走查
从 packages/coding-agent/src/main.ts 的 main() 进入,标出 createSessionManager() 到 createAgentSessionRuntime() 的目录变化;接着读 agent-session-services.ts 的两个创建函数,确认资源重载、Provider 注册、模型刷新和会话构造的顺序。最后对照 modes/index.ts 与 main() 的三处分派,验证每个模式在何处得到运行时。
