第一代运行时与 OpenCode 遗产
第一代运行时包如今在 v2 里扮演什么角色、v2 还引用它哪些模块,旧版以 OpenCode 为执行框架的代码证据,以及旧会话怎样被导入成可继续执行的 pi 会话。
packages/local-runtime 是仓库里第二大的第一方包:597 个文件、138122 行,只比 v2 少三成。它自己已经用 pi 执行会话——依赖里就有 pi-agent-core、pi-ai、pi-coding-agent(packages/local-runtime/package.json:13),会话路由只区分两种运行时:pi-agent 交给宿主执行,legacy-opencode 只能读(packages/local-runtime/src/sessions/router.ts:33)。所以这里其实有三代:更早以 OpenCode 为执行框架的本地产品,接着是用 pi 的第一代运行时,然后是现在的 local-runtime-v2。
docs/architecture.md:9 对第一代的定位是:提供被复用的宿主设施(数据库、文件工具、权限、存储),历史读取器只读旧会话文件,不代理到 daemon,也不回退到旧执行器。本篇按代码逐项核对这句话,再看 OpenCode 时代的会话怎样被接过来。
怎么用
用户几乎感觉不到第一代运行时,它只在几处露面:
- 旧会话出现在列表里:数据目录下若有旧版的
sqlite.db,会话列表、会话树这类查询会顺手把其中的 OpenCode 会话登记进来,打开或继续时再导入内容(下文“触发时机”)。 - 配置里的
opencode段:config.yaml仍接受opencode.keepAlive、opencode.xdg、opencode.spawnConcurrency,解析函数在packages/config/src/opencode-config.ts:21,注释说新会话已不走这条路,保留类型只为让 daemon 包继续编译(opencode-config.ts:17)。在公开源码里,除了解析本身,没有任何代码读取config.opencode。 - 强制回退无效:
MAVIS_LOCAL_RUNTIME_V2_FORCE_LEGACY=1不会切回第一代,而是直接报错(packages/tui/src/runtime/embedded-host.ts:65)。 - 资产还在这里:
/init等 18 个内置技能放在packages/local-runtime/assets/skills/(查找路径见packages/local-runtime/src/skills/builtin.ts:389),/review用的审查提示词也出自这个包的assets/prompts/code-review:从源码运行时直接读这里,npm 构建则读打包目录下的同名路径(packages/tui/src/runtime/review-assets.ts:9)。
v2 还引用它什么
只有 v2 依赖这个包(packages/local-runtime-v2/package.json:96),TUI 等其他包都不直接引用。v2 的非测试代码里共有 14 条从 @mavis/local-runtime 导入的语句,分布在 13 个文件中;其中 9 个文件只导入类型,真正在运行时取值的只有 4 个:src/runtime.ts、src/index.ts、src/compat/v1/runtime.ts、src/compat/v1/session.ts(路径相对 packages/local-runtime-v2)。第一代的包入口 packages/local-runtime/src/index.ts 只有 95 行,导出的正是这些符号:
| 用途 | 符号 | 第一代源文件 |
|---|---|---|
| 宿主与启动 | createLocalRuntimeHost、ensureCurrentLocalRuntimeDataMigratedToV2OrThrow、isLocalRuntimeStartupExecutionEnabled、resolveLocalRuntimeMode | runtime/host-factory.ts、persistence/migration/v2-migration.ts、runtime/startup-execution-policy.ts、runtime/mode.ts |
| Agent | createDeferredLocalAgentRuntimePort、resolveTaskTarget、withAgentNameConflictMigrationLock、closeAgentDb、子 Agent 遥测 | agent/ 与 persistence/migration/ |
| 持久化 | SqliteLocalTurnDiffStore、SqliteLocalCommunicationMessageStore、SqliteSessionAssetStore、LocalTurnDiffCapability、LocalTurnDiffRewindCapability | persistence/sqlite-persistence.ts、session-assets/、turns/ |
| 资产 | registerLocalAsset、registerSessionLocalAsset、resolveSessionLocalAsset、buildCompressedModelImageFromBuffer | assets/store.ts、utils/model-image-preprocess.ts |
| 工作区 | listFileTree、searchFiles、createLocalWorkspaceGitFacade、ManagedWorktreeService | files/api.ts、files/managed-worktrees.ts |
| 权限 | readLocalPermissionMode、replyLocalPermissionRequests | api/host-helpers.ts、api/routes/permissions.ts |
| 交互与目标 | LocalQuestionnaireService、ThreadGoalContractError | questionnaire/service.ts、thread-goal/contract.ts |
| 杂项 | updateLocalConfigFile、resolveAgentBashEnvPolicy、readPreviewTrainPinnedItemsOrderPreference、配置与传输工具的再导出 | config/update.ts、infra/ensure-rm-shim.ts、pin/legacy-preferences.ts |
静态导入只是一半。v2 启动时照样创建一个完整的第一代宿主(packages/local-runtime-v2/src/runtime.ts:254),再经 createServiceCompatibility(packages/local-runtime-v2/src/compat/v1/runtime.ts:451)从它的 apiHost 上借用对象:技能服务与技能中心存储、后台任务服务、问卷、线程目标、内容安全检查、模型列表刷新、Agent 引用解析、渠道能力、MCP 运行时绑定;进程内应用的“外围”部分——目标、问卷、工作区、权限、账号、诊断、权限模式、后台任务——也直接包装第一代的实现(compat/v1/runtime.ts:664)。所以文档那句“数据库、文件工具、权限、存储”说少了:技能、问卷、线程目标、后台任务同样由第一代提供。反过来,“文件工具”在这里指的是文件树、搜索、Git 门面这类工作区浏览接口;模型调用的 read、edit、bash 等工具实现在 packages/agent-tools 与 packages/agent-core,不在这个包里,见工具体系。
反方向:v2 给第一代装上对话
第一代宿主里还有渠道、问卷等要“发起对话”的代码,但它自己的对话执行路径已经停用。兼容层创建宿主时传入这几项(compat/v1/runtime.ts:436):
configureHostOptions: (options, agentResolver, ports) => ({
...options,
cronConsumerEnabled: false,
mcpRuntime: mcp.capability,
agentResolver,
disableLegacyConversation: true,
deferQuestionnaireRecovery: true,
runtimeConversation: conversation,conversation 起初只是一个 DeferredRuntimeConversation 空壳(src/runtime.ts:376),等 v2 的会话与回合服务就绪后,才绑定到 V1ConversationCompatibilityService(packages/local-runtime-v2/src/service/v1-conversation-compat/v1-conversation-compat.ts:31),类注释说它是注入给第一代兼容消费者的 v2 实现。绑定前先恢复线程目标的启动任务(compat/v1/runtime.ts:540)。第一代代码要对话时一律走 requireRuntimeConversation,拿不到就直接抛错,说明第一代自己的对话已不可用(packages/local-runtime/src/api/host.ts:1605)。于是第一代只剩“外壳”,查询、创建会话、发消息最后都落进 v2。
渠道是这层外壳里最大的一块:packages/local-runtime/src/channels 有 32299 行,接近整个包的四分之一,是飞书、Telegram、微信的 IM 桥接,服务于桌面端。命令行宿主的能力画像是 cli,渠道能力策略因此被设为 disabled(packages/local-runtime/src/api/host-channel-composition.ts:314),启动渠道子系统时直接返回(packages/local-runtime/src/api/host-channels.ts:765);v2 自己的渠道服务也只在 Electron 宿主上创建(见进程内入口)。
OpenCode 的痕迹
公开源码里已经没有 daemon 本体,但它和 OpenCode 的关系留在了很多地方:
| 痕迹 | 位置 |
|---|---|
| 配置类型里专门有一节“旧版 OpenCode 适配器”,注明为 daemon 的编译兼容而保留;默认值里 keepAlive 关闭,上限 5 个进程、每个最长存活 28800000 毫秒(8 小时),并发拉起数 3 | packages/config/src/config.ts:798、config.ts:1773 |
getOpenCodePort 给 OpenCode sidecar 分配端口:运行时端口加 1002,超出范围时改加 1 | config.ts:2635、config.ts:1242 |
旧 daemon 的 agents 表有 framework_type、pid、port、process_alive、opencode_config_hash 列,v2 逐列照搬;v2 新建的行写 'pi-agent' | packages/local-runtime-v2/src/infra/db/schema/agents.ts:8、packages/local-runtime-v2/src/service/agent/storage/agent.repository.ts:499 |
定时任务模块注释:OpenCode 适配器的 /session/status 接口在工具完成与下一次推理之间有约 500 毫秒报告“不忙” | packages/agent-modules/cron/src/host-ports.ts:334 |
| 权限模块注释:拒绝理由由 OpenCode 插件转给模型 | packages/agent-modules/permission/src/tools/slow-command-scan.ts:38 |
执行框架枚举里的 OpenCode 已标为废弃,旧会话只能经遗留边界恢复 | packages/agent-modules/system-reminder/src/types.ts:51 |
todowrite 的描述直接取自 OpenCode 的 todowrite.txt,task 沿用 OpenCode 任务工具的形状 | packages/agent-core/src/tools/builtin-defs.ts:172、builtin-defs.ts:210 |
从这些表结构与注释推断:更早的本地产品(代码里叫 daemon)为 Agent 拉起 OpenCode 进程、经本机端口与之通信,MiniMax 的权限、定时任务等模块以插件或适配器的形式挂在上面;会话数据分成两份,daemon 自己的 sqlite.db 存展示用消息,OpenCode 自己的 opencode.db 存原生消息。OpenCode 这边的存储与服务端见存储和HTTP 服务端。代码没有区分桌面端和命令行的旧数据:只要当前数据目录(命令行默认 ~/.minimax-code)下有 sqlite.db,同一套导入逻辑就会接手;公开源码里也没有记录哪个版本的命令行用过 OpenCode。
谁来迁移:v2 的移植版
第一代有完整的迁移器 LegacyOpencodeMigrator(packages/local-runtime/src/legacy-opencode/legacy-opencode-migrator.ts,2245 行),但命令行用不到它。它只在宿主处于 clean 模式且没有外部对话实现时才被创建(host.ts:1036),而 v2 恰好注入了对话实现;只读的 LegacyHistoryReader 要求 rollback 模式并显式开启(packages/local-runtime/src/runtime/mode.ts:334),可 resolveLocalRuntimeMode 永远返回 clean(mode.ts:88),TUI 还显式传了 legacyOpencodeEnabled: false(embedded-host.ts:86)。
真正干活的是 v2 里的移植版:service/session-system/legacy-migration 加 infra/legacy-db,共 25 个文件、6334 行,核心文件与第一代的 legacy-opencode(12 个文件、5681 行)一一对应——legacy-opencode-pi-seed.ts 对 pi-seed.ts,legacy-opencode-native-conversion.ts 对 conversion/native-conversion.ts,legacy-opencode-v3-upgrade.ts 对 v3-upgrade.ts,legacy-opencode-store.ts 对 readonly-source.ts。常量原样照搬,差别主要是去掉了对 pi 类型的依赖。入口类的注释(packages/local-runtime-v2/src/service/session-system/legacy-migration/importer.ts:101):
/**
* One-way, v2-owned lazy importer. It is the only path allowed to turn an
* `opencode` Session into an executable `pi-agent` Session.
*/读源:daemon 库 <dataDir>/sqlite.db 以只读方式打开(packages/local-runtime-v2/src/infra/legacy-db/readonly-source.ts:94),从 sessions 与 session_messages 表取数据,只挑 framework_type 为 opencode(或为空且所属 Agent 是 OpenCode)的会话(readonly-source.ts:357)。原生消息按会话记录里的 framework_session_id 去四个候选库里找(packages/local-runtime-v2/src/infra/legacy-db/model.ts:100):数据目录下的 opencode/data/opencode/opencode.db 与 opencode/state/opencode/opencode.db,以及 XDG 数据、状态目录下的 opencode/opencode.db。后两个正是 OpenCode 的默认位置,但只有 daemon 库里登记过的会话才会去查。
触发时机全是懒加载,同一会话的操作串行执行(importer.ts:645):
| 时机 | 入口 | 做什么 |
|---|---|---|
| 列会话、会话树、侧栏 | discover(packages/local-runtime-v2/src/service/session-system/sessions/query/query-service.ts:70) | 为每个旧会话建一条 runtime: 'pi-agent'、来源标为 legacy-opencode 的会话记录,账本状态记为 metadata(importer.ts:406、importer.ts:377) |
| 读消息、输入导航 | ensureDisplayReady(packages/local-runtime-v2/src/service/session-system/owner.ts:720) | 导入展示消息,每批最多 250 条或 8 MiB(packages/local-runtime-v2/src/service/session-system/legacy-migration/display-import.ts:8) |
| 开始执行之前 | ensureExecutionReady(packages/local-runtime-v2/src/service/session-system/sessions/recovery/capabilities.ts:100) | 展示消息加 pi 历史 |
| 某 Agent 还没有根会话 | adopt(packages/local-runtime-v2/src/service/session-system/sessions/root/root-invariant-service.ts:149) | 采纳最近更新的旧根会话 |
迁移进度记在 local_runtime_legacy_migrations 表,状态有 discovered、metadata、migrated、failed、deleted 五种(model.ts:194)。
pi 历史从哪来
展示消息只是给人看的;要让模型接着干活,还得有一份 pi 格式的历史。importHistory(importer.ts:477)按下面的顺序挑来源:
- 原生转换:
NATIVE_PI_HISTORY_CONVERTER_VERSION现为 4(packages/local-runtime-v2/src/service/session-system/legacy-migration/conversion/native-conversion.ts:106)。第一代源文件里的注释记下了每一版修了什么(packages/local-runtime/src/legacy-opencode/legacy-opencode-native-conversion.ts:71):v1 输出的工具调用 pi 不认识,工具调用被悄悄丢掉;v2 改用 pi 的类型,并把 OpenCode 合在一个部件里的调用与结果拆成两条;v3 按 OpenCode 消息的创建时间成组排序,避免长时间运行的工具结果和后续用户消息错位,还把 OpenCode 的压缩部件转成一条内容为What did we do so far?的 user 消息,与 OpenCode 自己转成模型消息时的做法一致(参见 OpenCode 的上下文压缩);v4 补上 OpenCode 没有的api字段,否则旧的思考内容会被当成跨提供方内容包进标记、泄漏到输出里。记录的版本号低于当前值就重新迁移,除非迁移后 pi 已经压缩替换过历史——判断函数是detectPostPiCompactionReplacement(packages/local-runtime/src/legacy-opencode/legacy-opencode-v3-upgrade.ts:8)。 - 展示种子:原生库不可用时,把展示消息压成一条 user 消息。阈值在
packages/local-runtime-v2/src/service/session-system/legacy-migration/pi-seed.ts:21:对话消息不超过 80 条且正文不超过 160000 字符就全文放入;不超过 240 条且不超过 240000 字符就取最近 240 条;再大就把完整记录写成一个转录资产文件(最多 5000000 字符),种子里只放最近 60 条(pi-seed.ts:203)。 - 上下文边界:之前按原生方式导入过、现在原生库又读不到时,用一条系统提醒替换旧历史(
packages/local-runtime-v2/src/service/session-system/legacy-migration/canonical-recovery.ts:266):
Earlier legacy conversation context could not be safely recovered during migration. Continue from the next user message as a new context boundary and do not assume details from unavailable history.
失败怎么办
failMigration(importer.ts:602)把账本状态写成 failed,附上错误信息,再抛出 LegacyOpencodeMigrationError(importer.ts:84),这次打开或执行就此失败。下一次访问会重新走导入:若账本显示展示消息已经导入、失败原因是已知的旧历史缺失,就改用会话里现有的历史恢复,不再依赖旧源,现有历史为空时退化为上面的上下文边界(importer.ts:222);旧源整个消失时同样先尝试这种恢复(importer.ts:273)。
旧库也不是完全不碰。删除会话时,markDeleted(importer.ts:139)先写一条墓碑,防止下次列表时又被发现,再调用 deleteLegacyDaemonSessionMessages 删掉旧库里对应的 session_messages 行(packages/local-runtime-v2/src/infra/legacy-db/cleanup.ts:15)。它按账本里记下的精确身份删除,不扫描、不清理其他行,打开连接时 timeout: 0,旧写者占着锁就直接放弃,失败只记警告。另外,启动时若旧库里有与内置 Agent 同名的冲突,Agent 定义切换会先备份,再改写旧库里的 Agent 名(packages/local-runtime/src/persistence/migration/agent-name-conflict-migration.ts:426)。这两处都和文档“历史读取器只读旧会话文件”的说法有出入。
Agent 定义切换还会从同一个旧库把旧 Agent 导入 v2 的 agents 表(见Agent 服务);已经从内置名单里撤下的旧 Agent general 与 coder 冻结在 packages/local-runtime-v2/assets/agents/_migration/,说明文件写明它们原本来自第一代包的 assets/agents(packages/local-runtime-v2/assets/agents/_migration/README.md:19)。
下一篇:工具体系——模型能调用哪些工具,它们怎样注册、筛选并交给 pi 执行。