跳到主内容
创建时间:最后更新:作者:白川

版本锁定与桌面适配差异

用能力核对表区分"上游支持"和"你的项目支持",并给出锁定版本、识别接口漂移、升级前回归的具体做法。

版本锁定与桌面适配差异

上游 SDK 每周都在长。文档里写着"支持 X",你打开自己的项目一查——它可能压根没接。不是 bug,是你的项目只实现了上游的一个子集。

把上游新增能力自动算成自己已经有的,是集成类项目最常见的一类错误。 表现是设计文档写着"支持 MCP 工具",实现里一个 MCP 服务器都没接;或者产品说"可以用 codemode 批量编排",代码里 createCodemodeExtension 从没被 import 过。

这篇给出核对方法和核对表,最后给你一份可以直接填的升级前回归清单。

读这篇需要的前置只有一条:知道自己的项目依赖了哪些 @earendil-works/pi-* 包,以及它们的版本写在哪个文件里。

一、为什么必然不同

这不是谁的疏漏,是结构性的。三条原因:

原因具体表现
上游不受你的发布节奏约束
Pi 的 main 分支随时在动,新特性先在 CLI 里落地,再进 SDK
宿主不必实现全部
SDK 的设计就是"每个边界都可以替换",你只需要实现你要的那部分
子集是有意的选择
桌面接 MCP 要处理权限、审计、连接生命周期;不接就没有这些负担

SDK 文档把这一点写得很清楚:

Without overrides, the factory creates a ModelRuntime, file-backed SettingsManager, persistent SessionManager, DefaultResourceLoader, and the configured default tools.

Each boundary can be supplied explicitly.

每个边界都可以被替换,也可以干脆不要。 你的项目选了替换其中几个,剩下的自然就不在支持范围内。

一个具体的例子。CLI 会把 codemode、tool_search 和 MCP 作为内置扩展加载,SDK 会话不加载——想用得自己加:

能力上游 CLI上游 SDK你的项目可能的情况
codemode
内置扩展自动加载
要 createCodemodeExtension()
没接
tool_search
内置扩展自动加载
要 createToolSearchExtension()
没接
MCP
内置扩展自动加载
要 createMcpExtension() + bindExtensions()
自己实现了等价通道

上游"支持"和你的项目"支持"是两个概念。

二、核对方法:三步

第一步:查你装的是什么

bash
# 查你装的是哪一版:安装版本,不是仓库 main 分支的版本
pi --version

这是安装版本。如果你用的是 npm 依赖,直接看锁文件:

bash
# 看 npm 依赖树里解析出来的版本;不带 ^ 或 ~ 的精确版本说明你锁死了
npm ls @earendil-works/pi-coding-agent @earendil-works/pi-agent-core @earendil-works/pi-ai

或者在 package.json 里找那几行依赖。精确版本号(不带 ^ 或 ~)说明你锁死了;带范围说明你可能随时拿到新版本。

要看全部 pi 包的解析结果,用 npm ls 配合过滤:

bash
# 一次性列出所有 pi 包的解析版本,sort -u 去重;npm ls 的告警行在这里被丢掉
npm ls 2>/dev/null | grep -oE "@earendil-works/pi-[a-z-]+@[0-9.]+" | sort -u

对源码 checkout:

bash
# 源码 checkout 的坐标:一个精确提交号,一个距离最近 tag 的距离
git rev-parse HEAD
git describe --tags

第二步:对照上游的变更

上游的 CHANGELOG 是按版本分的,每一节都有 ### New Features、### Added、### Changed、### Fixed。你要找的是"这个能力是哪一版加的"。在仓库根目录下,用内容检索定位这个符号第一次出现的时间:

bash
# -S 按内容检索:这个符号在哪一次提交首次出现,那次提交所属的版本就是引入版本
git log --oneline -S "createCodemodeExtension"

加上路径过滤参数可以缩小范围,结论一样准。

拿到版本号之后,和你装的对上:

情况结论
能力引入版本 ≤ 你的版本
上游有;你的项目仍需确认是否实现了
能力引入版本 > 你的版本
上游还没有;升级后才谈得上
能力在 ### Changed 或 ### Fixed 里
行为可能变了;即使实现了也要复核

关键在第二行。 "上游有"不等于"你的项目有"。第三步才是核对。

第三步:查你的项目实际接了什么

这一步没有捷径,只能查代码。三个方向:

查什么在哪查
依赖了哪些包、什么版本
package.json 的 dependencies / devDependencies
实际 import 了哪些符号
搜索 from "@earendil-works/pi- 的所有 import 语句
有没有实现等价机制
搜关键词:mcp、codemode、tool_search、subagent

"依赖了包"和"用到了某个能力"是两回事。 装了 @earendil-works/pi-coding-agent 只说明你能用它的导出,不代表你的代码路径经过了它。

一个能立刻看清差距的办法:列出上游导出清单,和你项目 import 过的清单做差集。 这个脚本可以直接跑:

javascript
// 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} 个值符号上游没有导出。`);

跑出来的结果长这样:

text
@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 负责——跑一遍类型检查,类型层面的漂移立刻暴露。

三、接口漂移的四种症状

上游改了接口,你的项目没跟上。这四种表现最容易认:

症状根因例子
方法不存在
上游删了或改名
shouldStopAfterTurn 在上游已被 finishTurn 取代
事件字段缺失
上游给事件加了新字段,你的代码没读
tool_execution_end 的 durationMs 是可选的,老版本没有
返回结构变化
上游改了返回类型
afterToolCall 的合并是字段级替换,content 给了会整体替换而不是深合并
默认值变化
上游改了默认行为
codemode 的并发 worker 上限、defaultTools 的解析方式、defaultProjectTrust 的默认 "ask"

第三种最阴险,因为类型检查可能过,运行时才炸。afterToolCall 返回 { content: [...] } 时,structuredContent 会被丢掉——源码里写得很清楚:

structuredContent: if provided, replaces the structured content. If content is provided without it, the structured content is dropped, because it may no longer match the content.

第四种最容易被忽略,因为代码完全没变。上游调整了 codemode 的并发准入策略(当前实现限制同时存活的 worker 数量为 2,被取消的 worker 要等真正退出才释放名额),你的代码一行没动,行为却变了。

四、锁定版本的具体做法

记三样东西

记什么怎么记例子
依赖版本
package.json 里写精确版本,不带 ^
"@earendil-works/pi-agent-core": "1.3.0"
锁文件
提交 package-lock.json
官方说它「is the dependency ground truth」
上游坐标
git rev-parse HEAD 写进内部文档
用来复核某段结论基于哪一版

官方 README 有一段讲供应链加固,值得知道:

Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged.

package-lock.json is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless PI_ALLOW_LOCKFILE_CHANGE=1 is set.

精确版本号 + 锁文件 + 上游坐标,三件一起才有可复现性。 只锁其中一个都不够。

注意官方那句"内部工作区包用版本范围"是针对 Pi 自己的 monorepo 说的,不是给你的项目开的口子。你的项目该锁精确版本还是要锁。

pi update 的影响面

升级命令不只一个,动之前先看清各自影响什么:

命令影响范围
pi update
只更新 Pi 自己
pi update --extensions
更新所有已安装的包(扩展、技能、主题)
pi update <source>
更新某一个包
pi update --models
刷新模型目录,不动代码
pi update --all
Pi 和所有包一起更新
pi update --force
强制重装 Pi

pi update --extensions 的影响面最大。 包可能带扩展代码,扩展在进程里跑,升级等于换了一整套运行时行为。

还有一个限制:

pi update cannot update Pi when another package manager provides it, such as Nix.

Nix 装的要用 nix profile upgrade pi。

升级前先跑一遍回归

bash
# 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 运行时包,它的依赖声明长这样:

json
"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 了这些:

typescript
// 这张 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";

对照上游同版本的导出清单,子集直接算得出来:

能力上游有这个项目接了吗
createAgentSession
有
接了
SessionManager、SettingsManager、DefaultResourceLoader、ModelRuntime
有
接了
ProjectTrustStore、hasTrustRequiringProjectResources
有
接了
parseSessionEntries、CURRENT_SESSION_VERSION
有
接了
createCodemodeExtension
有
没接(不依赖 pi-codemode)
createMcpExtension
有
没接(不依赖 pi-mcp)
createToolSearchExtension
有
没接

"上游支持 codemode"和"这个项目支持 codemode"之间隔着这一列。

更有意思的是扩展加载器。这类宿主会给扩展提供一个裁剪过的 shim,代替完整的编码智能体包。它只暴露:

shim 暴露的符号用途
defineTool
扩展注册工具
isBashToolResult、isEditToolResult、isReadToolResult 等结果判定
类型守卫
isToolCallEventType
判断事件类型
VERSION
暴露一个兼容标记,不是 Pi 运行时版本

那个 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 功能。这是很成熟的做法:不假装支持,而是明确告诉开发者缺什么。

六、能力可用性核对表

这张表是本篇的交付物。每个能力一行,填之前先查代码。

能力上游引入版本上游接口我的版本已实现?验证方式备注
SessionManager
v0.0.2
@earendil-works/pi-coding-agent
1.3.0
是
会话存储用例
SessionManager.inMemory()
v0.17.0
同上
1.3.0
是
内存会话用例
createAgentSession
v0.26.0
同上
1.3.0
是
单测覆盖会话创建
CURRENT_SESSION_VERSION
v0.31.0
同上
1.3.0
是
会话格式用例
升级时重点看
beforeToolCall / afterToolCall
v0.58.0
@earendil-works/pi-agent-core
1.3.0
是
权限门用例
AgentSessionRuntime
v0.65.0
@earendil-works/pi-coding-agent
1.3.0
是
切换会话用例
defineTool
v0.65.0
同上
1.3.0
是
自定义工具用例
ProjectTrustStore
v0.79.0
同上
1.3.0
是
信任决策用例
finishTurn 回合边界
v0.87.0
@earendil-works/pi-agent-core
1.3.0
是
预算用例
取代 shouldStopAfterTurn
createCodemodeExtension
v0.99.0
@earendil-works/pi-coding-agent
1.3.0
否
—
自建等价通道
createMcpExtension
v0.99.0
同上
1.3.0
否
—
同上
createToolSearchExtension
v0.99.0
同上
1.3.0
否
—
runToolCall() 嵌套调用
v0.99.0
@earendil-works/pi-agent-core
1.3.0
是
嵌套工具用例
自建 MCP 适配必用
pi-codemode 包
v0.99.0
@earendil-works/pi-codemode
未安装
否
—
pi-mcp 包
v0.99.0
@earendil-works/pi-mcp
未安装
否
—

四列最关键:

列填法
上游引入版本
从 CHANGELOG 或 git log -S 查
我的版本
npm ls 或 package.json
已实现?
查代码,不是查依赖。有就是有,没有就是没有
验证方式
一个具体的测试或一条命令。填不出验证方式的"已实现"等于"未验证"

"上游引入版本 > 我的版本"这一行,直接划掉,不用往下看。

上表的引入版本是逐个 tag 检查导出符号得到的,方法就是第二节那条 git show <tag>:<file>。每个能力引入的版本不同——SessionManager 从 v0.0.2 就在,createCodemodeExtension 要到 v0.99.0 才有。

七、升级前的回归清单

要升级 Pi 依赖时,逐条过:

检查项怎么查不通过怎么办
依赖版本变化
diff 升级前后的 npm ls 输出
逐个包看 CHANGELOG
破坏性变更
上游 CHANGELOG 的 ### Breaking Changes 段
这一段必须逐条读
事件字段变化
补齐新字段的处理
钩子语义变化
对比同一文件里的 AfterToolCallResult 合并规则
复核字段级替换的影响
工具契约变化
对比 扩展类型定义 的 ToolDefinition
检查自定义工具签名
默认行为变化
CHANGELOG 的 ### Changed 段
尤其注意默认值
会话文件格式
CURRENT_SESSION_VERSION 有没有变
变了要写迁移并保留旧会话可读
扩展 API 兼容性
扩展实际 import 的符号在不在新版本
不在就要改扩展
自己的测试
跑一遍完整测试套件
失败的先定位是接口漂移还是逻辑问题

会话文件格式这一条特别要注意。如果 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,确认后者不会被丢掉。

排障速查

现象检查点处理
文档说有这功能,我这儿没有
上游引入版本 vs 我的版本
能力引入版本更高就升级;已引入则查自己是否实现
升级后某个方法报 undefined
接口改名或删除
查上游 CHANGELOG 的 ### Breaking Changes;搜替代方案
类型检查过了,运行时字段是 undefined
事件字段新增
对比 Agent Core 的事件类型定义
structuredContent 莫名其妙丢了
afterToolCall 合并规则
给了 content 就得同时给 structuredContent
升级后行为变了但代码没改
默认值变化
读 CHANGELOG 的 ### Changed 段
老会话读不了
CURRENT_SESSION_VERSION
写 migrateSessionEntries() 迁移,并测旧文件可读
扩展在新版本加载失败
扩展 import 的符号被删或改名
查扩展的 import 清单,逐个对照新版本导出
pi update 没反应
包管理器冲突
Nix 装的用 nix profile upgrade pi
pi update 后行为诡异
更新了扩展
pi update 只更新 Pi;--extensions 才更新包。分开升,逐个验
依赖装了但功能用不了
依赖 ≠ 实现
查代码路径,别看 package.json
审计脚本报"上游未导出"
脚本跑在项目根目录外
脚本要能 import 到已安装的包,换到项目根目录运行

参考资料