版本锁定与桌面适配差异
用能力核对表区分"上游支持"和"你的项目支持",并给出锁定版本、识别接口漂移、升级前回归的具体做法。
版本锁定与桌面适配差异
上游 SDK 每周都在长。文档里写着"支持 X",你打开自己的项目一查——它可能压根没接。不是 bug,是你的项目只实现了上游的一个子集。
把上游新增能力自动算成自己已经有的,是集成类项目最常见的一类错误。 表现是设计文档写着"支持 MCP 工具",实现里一个 MCP 服务器都没接;或者产品说"可以用 codemode 批量编排",代码里 createCodemodeExtension 从没被 import 过。
这篇给出核对方法和核对表,最后给你一份可以直接填的升级前回归清单。
读这篇需要的前置只有一条:知道自己的项目依赖了哪些 @earendil-works/pi-* 包,以及它们的版本写在哪个文件里。
一、为什么必然不同
这不是谁的疏漏,是结构性的。三条原因:
SDK 文档把这一点写得很清楚:
Without overrides, the factory creates a
ModelRuntime, file-backedSettingsManager, persistentSessionManager,DefaultResourceLoader, and the configured default tools.Each boundary can be supplied explicitly.
每个边界都可以被替换,也可以干脆不要。 你的项目选了替换其中几个,剩下的自然就不在支持范围内。
一个具体的例子。CLI 会把 codemode、tool_search 和 MCP 作为内置扩展加载,SDK 会话不加载——想用得自己加:
上游"支持"和你的项目"支持"是两个概念。
二、核对方法:三步
第一步:查你装的是什么
# 查你装的是哪一版:安装版本,不是仓库 main 分支的版本
pi --version这是安装版本。如果你用的是 npm 依赖,直接看锁文件:
# 看 npm 依赖树里解析出来的版本;不带 ^ 或 ~ 的精确版本说明你锁死了
npm ls @earendil-works/pi-coding-agent @earendil-works/pi-agent-core @earendil-works/pi-ai或者在 package.json 里找那几行依赖。精确版本号(不带 ^ 或 ~)说明你锁死了;带范围说明你可能随时拿到新版本。
要看全部 pi 包的解析结果,用 npm ls 配合过滤:
# 一次性列出所有 pi 包的解析版本,sort -u 去重;npm ls 的告警行在这里被丢掉
npm ls 2>/dev/null | grep -oE "@earendil-works/pi-[a-z-]+@[0-9.]+" | sort -u对源码 checkout:
# 源码 checkout 的坐标:一个精确提交号,一个距离最近 tag 的距离
git rev-parse HEAD
git describe --tags第二步:对照上游的变更
上游的 CHANGELOG 是按版本分的,每一节都有 ### New Features、### Added、### Changed、### Fixed。你要找的是"这个能力是哪一版加的"。在仓库根目录下,用内容检索定位这个符号第一次出现的时间:
# -S 按内容检索:这个符号在哪一次提交首次出现,那次提交所属的版本就是引入版本
git log --oneline -S "createCodemodeExtension"加上路径过滤参数可以缩小范围,结论一样准。
拿到版本号之后,和你装的对上:
关键在第二行。 "上游有"不等于"你的项目有"。第三步才是核对。
第三步:查你的项目实际接了什么
这一步没有捷径,只能查代码。三个方向:
"依赖了包"和"用到了某个能力"是两回事。 装了 @earendil-works/pi-coding-agent 只说明你能用它的导出,不代表你的代码路径经过了它。
一个能立刻看清差距的办法:列出上游导出清单,和你项目 import 过的清单做差集。 这个脚本可以直接跑:
// audit-pi-caps.mjs
// 用法:node audit-pi-caps.mjs <你的源码目录>;前提是在项目根目录运行,脚本要能 import 到已安装的 pi 包
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
const projectRoot = resolve(process.argv[2] ?? ".");
const parent = new URL(`file://${join(projectRoot, "package.json")}`);
// 1. 收集你的项目从各个 pi 包导入的符号,区分类型和值
const imports = new Map();
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "node_modules" || entry === "dist") continue;
walk(full);
continue;
}
if (!/\.tsx?$/.test(full)) continue;
// 跳过测试文件:里面的 import 语句常常是字符串字面量(扩展样例、fixture),不是真的依赖
if (/\.(test|spec)\.tsx?$/.test(full)) continue;
const src = readFileSync(full, "utf8");
for (const m of src.matchAll(/import\s*(type\s*)?\{([^}]*)\}\s*from\s*["'](@earendil-works\/pi-[^"']+)["']/g)) {
const [, typeOnly, body, pkg] = m;
if (!imports.has(pkg)) imports.set(pkg, { values: new Set(), types: new Set() });
for (const raw of body.split(",")) {
const item = raw.trim();
if (!item) continue;
const isType = Boolean(typeOnly) || item.startsWith("type ");
const name = item.replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
if (!name) continue;
imports.get(pkg)[isType ? "types" : "values"].add(name);
}
}
}
};
walk(projectRoot);
// 2. 逐个包对照上游运行时导出
let missingCount = 0;
for (const [pkg, { values, types }] of imports) {
let exported;
try {
// 第二个参数是父 URL:包要从你的项目里解析;这里只取运行时导出,类型符号交给 tsc
const mod = await import(import.meta.resolve(pkg, parent));
exported = new Set(Object.keys(mod));
} catch {
console.log(`${pkg}: 未安装,跳过`);
continue;
}
const missing = [...values].filter((n) => !exported.has(n));
missingCount += missing.length;
console.log(`${pkg}: 值 ${values.size} 个 / 类型 ${types.size} 个;上游运行时导出 ${exported.size} 个`);
if (missing.length > 0) console.log(` 上游未导出:${missing.join(", ")}`);
}
console.log(missingCount === 0 ? "OK:所有值符号都能在上游找到。" : `共 ${missingCount} 个值符号上游没有导出。`);跑出来的结果长这样:
@earendil-works/pi-coding-agent: 值 9 个 / 类型 4 个;上游运行时导出 157 个
@earendil-works/pi-agent-core: 值 1 个 / 类型 13 个;上游运行时导出 8 个
@earendil-works/pi-tui: 值 3 个 / 类型 0 个;上游运行时导出 91 个
OK:所有值符号都能在上游找到。"上游运行时导出 157 个,你只用了 9 个"——这就是子集的量化形态。运行时导出数远大于你的实际使用量,这是常态,不是问题。
最后那行 OK 同样有诊断价值:所有值符号都能在上游找到,说明你的项目没有引用任何上游不存在的东西。哪天这行变成"共 N 个值符号上游没有导出",你就知道代码里引了个上游已经删掉的符号。
脚本只检查值符号。类型符号(AgentMessage、AgentSessionEvent 这类)在运行时不存在,由 tsc 负责——跑一遍类型检查,类型层面的漂移立刻暴露。
三、接口漂移的四种症状
上游改了接口,你的项目没跟上。这四种表现最容易认:
第三种最阴险,因为类型检查可能过,运行时才炸。afterToolCall 返回 { content: [...] } 时,structuredContent 会被丢掉——源码里写得很清楚:
structuredContent: if provided, replaces the structured content. Ifcontentis provided without it, the structured content is dropped, because it may no longer match the content.
第四种最容易被忽略,因为代码完全没变。上游调整了 codemode 的并发准入策略(当前实现限制同时存活的 worker 数量为 2,被取消的 worker 要等真正退出才释放名额),你的代码一行没动,行为却变了。
四、锁定版本的具体做法
记三样东西
官方 README 有一段讲供应链加固,值得知道:
Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged.
package-lock.jsonis the dependency ground truth. Pre-commit blocks accidental lockfile commits unlessPI_ALLOW_LOCKFILE_CHANGE=1is set.
精确版本号 + 锁文件 + 上游坐标,三件一起才有可复现性。 只锁其中一个都不够。
注意官方那句"内部工作区包用版本范围"是针对 Pi 自己的 monorepo 说的,不是给你的项目开的口子。你的项目该锁精确版本还是要锁。
pi update 的影响面
升级命令不只一个,动之前先看清各自影响什么:
pi update --extensions 的影响面最大。 包可能带扩展代码,扩展在进程里跑,升级等于换了一整套运行时行为。
还有一个限制:
pi updatecannot update Pi when another package manager provides it, such as Nix.
Nix 装的要用 nix profile upgrade pi。
升级前先跑一遍回归
# 1. 记录当前状态:提交号和依赖清单各存一份,升级后好比对
git rev-parse HEAD > /tmp/pi-before.txt
npm ls 2>/dev/null | grep -oE "@earendil-works/pi-[a-z-]+@[0-9.]+" | sort -u > /tmp/pi-deps-before.txt
# 2. 升级:--all 把 Pi 本体和已安装的包一起更新
pi update --all
# 3. 对比:同样的两条命令再跑一遍,diff 的结果就是这次升级动了什么
git rev-parse HEAD > /tmp/pi-after.txt
npm ls 2>/dev/null | grep -oE "@earendil-works/pi-[a-z-]+@[0-9.]+" | sort -u > /tmp/pi-deps-after.txt
diff /tmp/pi-deps-before.txt /tmp/pi-deps-after.txt升级前后的依赖清单 diff 出来给你看,比任何 changelog 都直观。
五、一个真实的子集实例
看一个真实的嵌入式宿主怎么划这条边界。这类项目——把 Pi SDK 嵌进自己的应用,而不是直接用 pi 命令行——几乎必然只实现上游的一个子集。
一个典型的 Agent 运行时包,它的依赖声明长这样:
"dependencies": {
"@earendil-works/pi-agent-core": "1.3.0",
"@earendil-works/pi-ai": "1.3.0",
"@earendil-works/pi-coding-agent": "1.3.0",
"typebox": "1.3.27"
}三个包,全是精确版本,没有 ^。 注意它没有依赖 @earendil-works/pi-codemode 和 @earendil-works/pi-mcp。第四项 typebox 是 JSON Schema 描述库——工具参数用它声明,所以它必须和 pi 的版本一起锁。它实际 import 了这些:
// 这张 import 清单就是"这个宿主接了哪些上游能力"的最短答案
import {
// 会话格式与内容:版本常量用来判断老会话能不能读
CURRENT_SESSION_VERSION,
DefaultResourceLoader,
ModelRuntime,
ProjectTrustStore,
SessionManager,
SettingsManager,
createAgentSession,
hasTrustRequiringProjectResources,
parseSessionEntries,
// 下面三个是纯类型,运行时不存在,漂移由 tsc 负责发现
type AgentSession,
type AgentSessionEvent,
type SessionMessageEntry,
} from "@earendil-works/pi-coding-agent";对照上游同版本的导出清单,子集直接算得出来:
"上游支持 codemode"和"这个项目支持 codemode"之间隔着这一列。
更有意思的是扩展加载器。这类宿主会给扩展提供一个裁剪过的 shim,代替完整的编码智能体包。它只暴露:
那个 VERSION 值的注释很值得读:
Legacy compatibility marker exposed by the trusted-extension coding-agent shim. It identifies the subset modeled on the 0.87.1 extension API; it is intentionally independent from the installed Pi runtime package versions.
扩展看到的 VERSION 是"我按哪一版 API 建模的",不是"我装了哪个版本的 Pi"。 这就是"上游能力"和"宿主支持子集"分离的一个具体实现。
而且这个 shim 会报告扩展用了哪些它没有的符号,诊断面板里能列出扩展期望了哪些终端 UI 功能。这是很成熟的做法:不假装支持,而是明确告诉开发者缺什么。
六、能力可用性核对表
这张表是本篇的交付物。每个能力一行,填之前先查代码。
四列最关键:
"上游引入版本 > 我的版本"这一行,直接划掉,不用往下看。
上表的引入版本是逐个 tag 检查导出符号得到的,方法就是第二节那条 git show <tag>:<file>。每个能力引入的版本不同——SessionManager 从 v0.0.2 就在,createCodemodeExtension 要到 v0.99.0 才有。
七、升级前的回归清单
要升级 Pi 依赖时,逐条过:
会话文件格式这一条特别要注意。如果 CURRENT_SESSION_VERSION 变了,你已有的会话文件可能读不了。 官方为此提供了 migrateSessionEntries(),但迁移逻辑要你自己写测试。
八、官方已定义、源码事实与教学设计
官方已定义的行为:SDK 的每个边界(ModelRuntime、SettingsManager、SessionManager、ResourceLoader、工具集)都可以显式提供;CLI 把 codemode、tool_search、MCP 作为内置扩展加载而 SDK 会话不加载;codemode 与 tool_search 注册时 inactive;finishTurn 取代 shouldStopAfterTurn;package-lock.json 是依赖的权威事实来源;直接依赖钉到精确版本、内部工作区包用版本范围;pi update 不能更新 Nix 安装;pi update --extensions 更新所有包。
源码事实但官方文档没单独成段的:编码智能体包的运行时导出共 157 个值符号;afterToolCall 的 content 与 structuredContent 不同时替换会丢掉后者;tool_execution_end 的 durationMs 是可选字段;codemode 同时存活的 worker 上限是 2。
教学设计:能力可用性核对表这张模板;升级前回归清单这九项;"查上游导出清单和本地 import 做差集"这个核对动作,以及第二节那个审计脚本——官方没有提供现成的兼容性检查工具。
你现在能做什么
- 说清"上游能力"和"你的项目支持的能力"为什么必然不同,并给出三条结构性原因。
- 拿到一个新特性时,用
git log -S定位它的引入版本,再决定要不要升级。 - 跑一遍本文的审计脚本,得出"上游导出多少个值符号 / 你的项目实际用了多少个"这组数字。
- 写出一份填好的能力可用性核对表,每一行的"验证方式"都指向一个具体的测试或命令。
- 用
npm ls确认你的 pi 依赖是精确版本还是版本范围,并把这个事实写进内部文档。 - 升级前跑一遍依赖清单 diff,把结果贴给团队而不是只说"我升了"。
- 在你的扩展加载器里检查一遍:扩展 import 的符号是不是宿主真能提供的,不能提供的是否有明确诊断。
- 检查你的代码有没有依赖
afterToolCall的structuredContent语义——如果同时返回content和structuredContent,确认后者不会被丢掉。