# 第一代运行时与 OpenCode 遗产

> 第一代运行时包如今在 v2 里扮演什么角色、v2 还引用它哪些模块，旧版以 OpenCode 为执行框架的代码证据，以及旧会话怎样被导入成可继续执行的 pi 会话。

- 作者：David（道雾轩）
- 专栏：MiniMax Code 源码解读（https://daiw.org/manual/minimax-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/minimax-code/runtime-v1
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

`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`，不在这个包里，见[工具体系](https://daiw.org/manual/minimax-code/tool-system)。

## 反方向：v2 给第一代装上对话

第一代宿主里还有渠道、问卷等要“发起对话”的代码，但它自己的对话执行路径已经停用。兼容层创建宿主时传入这几项（`compat/v1/runtime.ts:436`）：

```ts
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 宿主上创建（见[进程内入口](https://daiw.org/manual/minimax-code/runtime-entry)）。

## 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 这边的存储与服务端见[存储](https://daiw.org/manual/opencode/storage)和[HTTP 服务端](https://daiw.org/manual/opencode/server)。代码没有区分桌面端和命令行的旧数据：只要当前数据目录（命令行默认 `~/.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`）：

```ts
/**
 * 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`）按下面的顺序挑来源：

```mermaid
flowchart TD
  A["importHistory"] --> B{"历史里已有迁移种子标记？"}
  B -- 是 --> B1["原样保留"]
  B -- 否 --> C{"旧版转换器导入过，且之后已被 pi 压缩替换？"}
  C -- 是 --> C1["保留现有历史，只更新版本戳"]
  C -- 否 --> D{"OpenCode 原生库可读且转换成功？"}
  D -- 是 --> D1["native-full 或 native-degraded，保留迁移后续写的对话"]
  D -- 否 --> E{"残留旧原生转换的行？"}
  E -- 是 --> E1["写入上下文边界"]
  E -- 否 --> F["display-seed：由展示消息生成种子"]
```

- **原生转换**：`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 的[上下文压缩](https://daiw.org/manual/opencode/compaction)）；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 服务](https://daiw.org/manual/minimax-code/agent-service)）；已经从内置名单里撤下的旧 Agent `general` 与 `coder` 冻结在 `packages/local-runtime-v2/assets/agents/_migration/`，说明文件写明它们原本来自第一代包的 `assets/agents`（`packages/local-runtime-v2/assets/agents/_migration/README.md:19`）。

下一篇：[工具体系](https://daiw.org/manual/minimax-code/tool-system)——模型能调用哪些工具，它们怎样注册、筛选并交给 pi 执行。
