Skip to content

第24章 户口本上的住址——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-mono 提交 583f153d502aa8e958eefdb9af0fbd3344e68f95(包版本 0.83.0,2026-08-01)。

入口先处理不会启动会话的命令 ​

小a:“那 main() 第一行做什么?”

“第一行就在过滤‘根本不该启动会话’的东西。”老z打开 packages/coding-agent/src/main.ts:

ts
export async function main(args: string[], options?: MainOptions) {
  if (await handlePackageCommand(args, { extensionFactories })) return;
  if (await handleConfigCommand(args, { extensionFactories })) return;
  const parsed = parseArgs(args);
  if (parsed.diagnostics.some((d) => d.type === "error")) process.exit(1);
  // 建立会话和运行时
}

“逐段读,先分清**‘命令已处理’与‘发生错误’**:前两者是正常早退;参数 diagnostics 中有 error 才设置失败退出。版本、导出、help 和模型列表也有各自早退分支——不要把所有 return 解读为调用失败。”

“这些早退分支的顺序有意义吗?”

“有。”老z说,“handlePackageCommand 和 handleConfigCommand 在 parseArgs 之前——因为它们处理的是 pi install xxx、pi config set 这种子命令,参数解析规则跟主命令不一样。runCredentialPrintCommand 也在 parseArgs 之前,它自己内部 parseArgs 一次。再往后才是主参数解析、--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 是成功、哪些是失败:

text
main() 的早退分支(583f153d,按执行顺序):
| 分支 | 触发条件 | 结果 |
| --- | --- | --- |
| handlePackageCommand | `pi install`/`update`/`uninstall`… | 合并 exitCode 后退出 |
| handleConfigCommand | `pi config set`… | return |
| runCredentialPrintCommand | `pi auth print` 之类 | 自己内部 parseArgs 一次 |
| 参数诊断含 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) |

“把这张表和直觉对照一下:早退不等于失败。”老z说,“handlePackageCommand 甚至要合并 process.exitCode 再退——子命令内部可能已经设过退出码。真正的失败信号只有两类:参数诊断里的 error,以及后面 runtime 诊断里的 error。帮助文本要列扩展 flag、模型列表要读模型注册表,所以那两个 exit 反而排在装配之后。”

主调用链(节选)是:

text
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说:

ts
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,是恢复安全性的一个可见取舍。”

“‘缺少 cwd’具体怎么判定?”

“看 getMissingSessionCwdIssue():它先确认有 session 文件(内存会话没有),再取 sessionManager.getCwd(),用 existsSync 判断这个目录还在不在。”老z说,“不在就返回 issue。fallbackCwd 是当前 process.cwd()。交互模式弹个选择器让用户‘继续用当前 cwd’还是‘取消’;选了之后用 SessionManager.open(..., selectedCwd) 重新打开并改写 cwd。非交互模式直接拒——因为没人能选。”

“那 createSessionManager 内部那些 --session/--resume/--fork 又怎么分?”

“它是一条优先级链,按顺序短路。”老z说:

text
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——读这段代码最忌讳把每个 exit 都当成异常路径。”

“那这个链条的‘全局匹配’靠什么实现?”

“resolveSessionPath:参数里含 /、\ 或 .jsonl 结尾就先当文件路径 resolve;否则先 SessionManager.list(cwd) 找本项目内的 id(精确或前缀),找不到再 listAll 全项目扫。所以‘在别的项目找到’才进入 global 分支——项目内优先是搜索顺序的一部分。”

服务的装配顺序 ​

小a:“那会话目录定了之后,服务怎么组装?”

“createAgentSessionServices() 先创建长期服务对象,再加载资源,再刷新模型。”老z打开 packages/coding-agent/src/core/agent-session-services.ts:

ts
const modelRuntime = await ModelRuntime.create({ authPath, modelsPath });
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 模式在分派前 void modelRuntime.refresh().catch(() => {}) 后台跑。所以‘启动不联网’只是服务装配这一步的局部事实。”

“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,每次都 try/catch 把异常收成 error diagnostic。”老z说,“ExtensionRunner.bindCore() 里还有第三处 flush——那是绑定会话动作时再补一轮。所以 Provider 注册可能出现在加载时排队、服务创建时落地、绑定后追加三个点。注册失败不会让进程崩,而是变成 diagnostic,由 main() 末端那段 diagnostics.some(type==='error') 决定是否退出。”

“等等——‘加载时排队’具体怎么理解?”

“看 createExtensionRuntime() 的默认实现:加载阶段的 registerProvider 不是直接注册,而是往 pendingProviderRegistrations 里 push。”老z说,“原因是扩展加载发生在模型注册表可用之前——registerProvider 要写进 ModelRuntime,而 ModelRuntime 要等 createAgentSessionServices 里才创建。bindCore 里的第三处 flush 也一样:把绑定前积压的注册一次性落地到 modelRegistry,异常走 emitError。所以‘排队’和‘落地’是两段代码、两个时机,都是本快照里可核对的。”

启动状态与失败语义 ​

阶段关键状态成功出口错误/边界出口
参数parsed.diagnostics继续选择模式error 诊断退出 1
会话SessionManager 与 cwd构造 runtime非交互缺 cwd 退出 1
资源loader 结果与 diagnostics注册 Providerextension error 阻止启动
模型ModelRuntime/scoped models创建 session非交互无模型退出 1
模式appModeinteractive/print/rpc 分派RPC 参数限制早退

模式是同一运行时的不同出口 ​

小a:“交互、打印、RPC 三个模式,是不是三个不同的 Agent?”

“**不是。**它们共享的是先前装配出的 session、模型和资源服务——不是一份‘相同界面’。”老z打开 main.ts 末端的分派:

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 + restoreStdout 是配套的:退出码交给 Node,输出交给事件循环,恢复交给显式调用,三件事各自负责。”

“模式分派的三段我在 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() 的三处分派,验证每个模式在何处得到运行时。