# @epoch-agent/runtime 中文文档

规范地址：https://npmcn.com/package/%40epoch-agent/runtime/

版本：6\.2\.0
npm 元数据更新时间：2026-11-13T08:28:10.379Z
中文译文更新时间：2026-11-13T19:51:20.605Z
软件包许可：Apache-2\.0

本文由上游 README 翻译，可能存在偏差，请结合原文核对 API、命令与配置。版权与许可归原作者所有。

原始 npm 页面：https://www.npmjs.com/package/%40epoch-agent/runtime
原文对照：https://npmcn.com/package/%40epoch-agent/runtime/?lang=original
项目主页：https://github.com/Ddbor/epoch-agent#readme
源码仓库：https://github.com/Ddbor/epoch-agent

## 关键信息（来自 npm 元数据）

- 包体积（解压后）：975 kB，5 个文件
- 模块格式：ES Module
- TypeScript 类型：包内自带
- Node.js 要求：\>=22\.0\.0
- 运行依赖：8 个
- peer 依赖：@opentelemetry/api ^1\.9\.0，@opentelemetry/resources ^2\.0\.0，@opentelemetry/sdk-metrics ^2\.0\.0，@opentelemetry/sdk-trace-base ^2\.0\.0，@opentelemetry/exporter-trace-otlp-http ^0\.221\.0，@opentelemetry/exporter-metrics-otlp-http ^0\.221\.0
- 首次发布：2026-08-19T10:31:59.965Z
- 最新版本发布：2026-11-13T08:28:10.127Z
- 近 30 天下载量：4110（2026-10-14 至 2026-11-12，npm 官方下载统计）
- 已发布版本数：57（近 12 个月 57 个）

## 中文简介

时间代理 : 切换核心 / 插件 / MCP 代理

## 安装

```sh
npm install @epoch-agent/runtime
```

也可以使用：`pnpm add @epoch-agent/runtime`、`yarn add @epoch-agent/runtime`、`bun add @epoch-agent/runtime`

## README

# @epoch-agent/runtime

组装层（composition root）。把 [core](https://npmcn.com/package/%40epoch-agent/core) 的服务 + 五个 plugin 装配成一个 可跑的 agent。**嵌入方要装的就是这个包**，不是 core。

-   ✅ **做**：`buildRuntime()` 装配、`AgentSession`（审批桥 / 历史累积 / 持久化）、 会话回放、MCP 运维门面、**插件的宿主控制面**（已装列表 / 本地安装 / 落包 / 更新 / 卸）、 telemetry 接线、**进程级事件观测口**（`EpochRuntime.events`：所有会话产出的每一帧 `AgentEvent`，判据在 [events.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/events.ts) 文件头）、 **子任务那一轮的按步落盘**（[delegate-recorder.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/delegate-recorder.ts)： `delegate_task` 派出去的活从开跑就逐步写进会话库 —— 跑到一半时 `GET /messages` 已经读得到已经跑过的那些步，不必等它收工）
-   ❌ **不做**：领域逻辑（在 [core](https://npmcn.com/package/%40epoch-agent/core)）、命令解析（在 [cli](https://npmcn.com/package/%40epoch-agent/cli)）、 渲染（在 [tui](https://npmcn.com/package/%40epoch-agent/tui) / [web](https://npmcn.com/package/%40epoch-agent/web)）、**插件的热加载**（装完要重建 runtime， 判据在 [plugin-control.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/plugin-control.ts) 文件头第二节）、 **事件的缓冲 / 补帧 / 跨进程**（观测口只在本进程内、不排队不发号； wire 那一层在 [server](https://npmcn.com/package/%40epoch-agent/server) 的 hub）
-   **依赖**：[protocol](https://npmcn.com/package/%40epoch-agent/protocol) + [infra](https://npmcn.com/package/%40epoch-agent/infra) + [core](https://npmcn.com/package/%40epoch-agent/core) + [plugin-file](https://npmcn.com/package/%40epoch-agent/plugins/plugin-file) / [plugin-terminal](https://npmcn.com/package/%40epoch-agent/plugins/plugin-terminal) / [plugin-web](https://npmcn.com/package/%40epoch-agent/plugins/plugin-web) / [plugin-mcp](https://npmcn.com/package/%40epoch-agent/plugins/plugin-mcp) / [plugin-lsp](https://npmcn.com/package/%40epoch-agent/plugins/plugin-lsp)

最小用法——宿主不用关心阻塞审批、历史拼接、落盘：

```ts
import { buildRuntime } from '@epoch-agent/runtime';

const runtime = await buildRuntime({ workDir: process.cwd() });
for await (const event of runtime.session.run('把 README 里过时的命令改掉')) {
  // event 是 protocol 的 AgentEvent
}
```

完整接法（含 Electron 跨进程审批）见 [docs/EMBEDDING.md](https://npmcn.com/package/docs/EMBEDDING.md)。

## 导出

| 导出 | 用途 |
| --- | --- |
| `buildRuntime()` / `EpochRuntime` | 装配入口 |
| `ensureSecretsReady()` | 不建整个 runtime 时单独把凭据存储备好 |
| `AgentSession` | 宿主友好的会话：审批转事件 + 多轮历史 + 持久化 |
| `EpochRuntime.sessionFactory` / `SessionFactory` / `LiveSession` | 一个进程里再开一个会话（方案 30 §6.3）。作用域那张表见下 |
| `SessionStore` / `StoredMessage` / `TurnDetail` | 回放旧会话（刷新页面 / 会话详情），给的是完整 `EpochMessage` |
| `turnAlignedStart()` / `TurnWindow` | 历史分页的**切点**（按轮对齐，口径同 deepseek-harness 的 `turnWindow`）：从末尾往前数够 `minMessages` 条、跨过 `minTurns` 轮之后，在撞到的第一条 `user` 行那里切。`SessionStore.loadMessagePage()` 和 server 的慢路共用这一份判据。⚠️ `minMessages` 必须**小于** `limit`，相等时条数判据总是先到、对齐静默失效 |
| `EpochRuntime.checkpoints` / `RewindScope` | 列出检查点、预览回退、回退（文件 / 对话 / 两者）。快照本身由引擎做。**这一份是引导会话的**，别的会话问 `LiveSession.checkpoints` |
| `buildServices()` / `Services` | 只要服务层、不要 AgentLoop 时用 |
| `wireCommands()` / `CommandControl` / `withToolScope()` | 自定义斜杠命令：命令表 + 展开 + 按轮次收窄工具表 |
| `withModelScope()` / `ModelTurnScope` | 命令 frontmatter 的 `model:` 只在这一轮生效，收尾无条件还原 |
| `withRoleScope()` / `RoleControl` / `LiveSession.roles` | **这一条消息**用哪个专家（方案 57）。名字认不认得问 `has()`，换人问 `enterTurn()`，收尾无条件还原 |
| `EpochRuntime.utility` / `UtilityControl` / `utilityBudgetKey()` | 一次性补全（方案 59 需求 B）：不建会话、不落历史、不装 system prompt、**入参上压根没有 `tools`**。不给 `model` 就跟随 `models.utility`。⚠️ 它挂的是**引导会话**那一份 router；⚠️ 给了 `sessionId` 那笔钱**会拦住那个会话的下一轮**，不给则落 `utility:<label>` 保留桶而**那个桶今天没有上界** —— 两条见下 |
| `EpochRuntime.events` / `AgentEventBus` / `AgentEventSubscriber` | **进程里所有会话**产出的每一帧 `AgentEvent`（方案 61）：`subscribe(fn)` 收 `(event, sessionId)`，回一个幂等的退订。投递发生在 `run()` 的 `yield` **之前**，零订阅者时是一次 `size === 0` 的检查。⚠️ **进程级不是 runtime 级**（两份 runtime 同一个总线）；⚠️ 回调同步执行、不许干重活；⚠️ 审批 / 提问帧上的 `respond` **不许调**，见 [EMBEDDING §13.5](https://npmcn.com/package/docs/EMBEDDING.md) |
| `ApprovalRelay` / `serializeStream()` / `toSerializable()` | 审批 UI 在另一个进程时，把事件里过不去 IPC 的闭包换成 `requestId` |
| `registerLocalTools()` / `registerMcpTools()` / `McpServerBatch` | 单独装工具（自定义宿主）。⚠️ `registerMcpTools` 的第四个参数收的是**批次数组**（宿主注入的 / 插件带的），再来一批就加一个元素，**别加第五个参数** |
| `listMcpServers()` / `mcpLogin()` / `mcpLogout()` / `probeMcpServer()` | `epoch mcp` 的后端，不带 UI |
| `EpochRuntime.mcp` / `McpControl` | MCP 的**写口**，照 `WorkspaceControl` 收窄过：`reconnect()`（重连一台）+ `add()`（加一台并落盘）+ `update()` / `remove()`（改一台 / 删一台，就地改写那份文件，2026-09-02）+ `readConfig()` / `writeConfig()` / `applyConfig()`（那份文件的原文读写与生效，2026-08-18）。⚠️ **`McpRegistry` 刻意不交出去**，加能力是在这上面加方法，见下 |
| `writeMcpServer()` / `McpAddInput` | 往用户手写的 `~/.epoch/mcp.json` 里**插一段**（注释 / 键序 / 缩进一个字节不动）。不带连接，也不带 UI |
| `updateMcpServer()` / `removeMcpServer()` | 那份文件的**就地替换 / 删除**（2026-09-02）：只碰点名那一台，别的 server 和注释 / 键序 / 缩进一个字节不动。判据见下 |
| `createMcpTools()` / `MCP_ADD_TOOL` … | 模型自己管 MCP 那四个工具（`mcp_list` / `mcp_add` / `mcp_update` / `mcp_remove`，2026-09-02）。⚠️ 转出来是给**用例**用的，真正的接线在 `buildRuntime` 里；清单与边界见 [docs/TOOLS.md](https://npmcn.com/package/docs/TOOLS.md) |
| `readMcpConfigFile()` / `writeMcpConfigFile()` / `mcpConfigRevision()` | 那份文件的**整份**读写（2026-08-18）：原文进、原文出，带一个内容指纹防覆盖。⚠️ 和上一条不是一条路 —— 那条写的是**我们生成的**内容，这条写的是**用户敲的**字节，见下 |
| `applyMcpConfig()` / `McpApplyOutcome` | 把盘上那份**搬进这个进程**（2026-08-18）：该断的断、该连的连、没变的一个字节不动。⚠️ 只动 `source === 'user'` 那批，见下 |
| `EpochRuntime.workspace` / `wireWorkspace()` / `splitDirList()` | **引导会话**的地盘（主根 + `--add-dir`）。要判路径在不在范围内问它 |
| `EpochRuntime.workspaces` / `WorkspaceControl` / `SessionWorkspaces` | 每会话绑一个工作区（决定 18）：绑 / 查 / 解绑 + 「已知工作区」清单。`bind()` 收一个可选的 `lang`（方案 58 PR-2）—— 只影响失败那句 `detail`，`reason` 是契约 |
| `EpochRuntime.skillWrite` / `SkillControl` | 技能那一栏的**写口**，落点 `~/.epoch/skills/`：`preview()` 看一眼、`import(source, token)` 真写（方案 42 §六）、`remove(name)` 删掉一份（2026-09-01），外加 `files(name)`（2026-11-06，`SKILL.md` + 四个白名单子目录，base64）。都收一个可选的 `lang`（方案 58 PR-2）。⚠️ 导入**收路径不收字节**，见下；⚠️ `remove` 只删得动用户级，且回执带**刚被销毁的那个目录** |
| `EpochRuntime.roleWrite` / `RoleWriteControl` | **建一个身份**，落在 `~/.epoch/agents/<name>.md`（2026-08-18）：`add()` 与 `remove(name)`（2026-09-16 加，端点 `POST /api/roles/remove`）—— **没有「改」，也没有重命名**。⚠️ 建完**当场重载这个进程的角色表**；⚠️ 那道「回环绑定才给写」的闸门**不在这一层**，见下 |
| `EpochRuntime.plugins` / `PluginControl` | 插件那一页的控制面（方案 59 E1；2026-11-06 只剩本地安装）：`list` / `previewLocal`→`installLocal(token)` / `stage`（压缩包 → 一条目录）/ `previewUpdate`→`update(token)` / `view`（只读盘）/ `export(name)`（打成 zip，回字节）/ `uninstall` / `setEnabled` + 一格 `pendingRestart`。**不可为 null** —— 本地安装不依赖任何宿主开关。⚠️ 只收本机绝对路径 / 压缩包；⚠️ **装完要重建 runtime**，见下。完整能力清单在 [docs/HOST\_CAPABILITIES.md](https://npmcn.com/package/docs/HOST_CAPABILITIES.md) |
| `EpochRuntime.hostSlots` / `HostSlotsControl` / `buildRuntime({ hostSlots })` | 宿主自己那一块配置（侧栏 / 托盘 / 设置页分区 / 能力面板 / 开场卡片 / 宿主权限）：`read` / `write` / `onChange`。**整份替换**，写盘 + 当场改 `runtime.config`；装配前那一档走 `buildRuntime({ hostSlots })`。⚠️ `permissions` 要重建才生效（回执里 `requiresRestart`），见 `docs/HOST_CAPABILITIES.md` §七 |
| `EpochRuntime.trusted` | 这次装配**实际**按不按信任在跑。别在宿主侧重查一遍信任表，见下 |
| `EpochRuntime.trust` | 信任记录的读**与写**：`check` / `record` / `list` / `revoke`，见下 |
| `EpochRuntime.homeDir` / `.artifactsRoot` | 这次生效的数据目录 + artifact 根目录。要路径读它，别自己 `join` |
| `buildRuntime({ hostPreset })` / `HostPreset` | 装配前替宿主把 `models.json` / `config.yaml` / `mcp.json` / `workspaces.json` 落到位。**缺失才写**，见下 |
| `buildRuntime({ hostCapabilities })` / `HostCapabilities` / `HostAgentRole` | 宿主注入自己 ship 的**专家 / 技能 / MCP server / 进程内工具**。前缀由装配层按 `namespace` 拼（⚠️ 后两样是 `__` 不是 `:`），**不落盘**、不进 `epoch plugin list`，见下 |
| `EpochRuntime.schedules` / `ScheduleControl` | 定时任务的宿主出口：增删改查 + `fire()` + `capability()`。**没给 `scheduleRunner` 就拒绝注册进 OS**，见下 |
| `fireSchedule()` / `FireResult` | 执行器本体。`epoch schedule fire` 和宿主的 `headless.js` 跑的是**同一段**，见 [AUTOMATION.md](https://npmcn.com/package/docs/AUTOMATION.md) |
| `AgentRoleError` | `--agent` 给了不认识的角色名。按 `err.name` 认，不用 `instanceof` |
| `listBackgroundTasks(sessionId)` / `backgroundTaskOutput(sessionId, id, since?)` | 后台任务表（从 plugin-terminal 借道，CLI / TUI / server 都不许直接 import 它）。⚠️ **第一个参数是会话 id**，见下 |
| `EpochRuntime.diagnosticList` / `.diagnosticsIn(lang)` | 启动诊断。前者是**进程语言**那个快照（CLI / `epoch doctor` 走它），后者按指定语言重渲染 `detail`（方案 58）—— **同一个 sink 的两个投影**，`module` / `code` / `status` 逐项相同 |
| `SESSION_SEARCH_MODULE` / `WORKSPACE_MODULE` / `HOST_*_MODULE` | 几族启动诊断的 `module` 值。**是契约不是文案**，所以是常量不是 `t()`（方案 58 §1.5 第 1 条修的就是它）。`module` **一律英文 PascalCase**，2026-08-23 改过一批名，迁移表在 [EMBEDDING.md §1](https://npmcn.com/package/docs/EMBEDDING.md) |

## 文件

| 文件 | 职责 |
| --- | --- |
| `build.ts` | `buildRuntime()`。显式手写装配，不用 DI 容器 |
| `host-preset.ts` | 宿主预置（方案 54 §六）：装配前落文件。四条语义写在它的文件头，别在别处再判一次 |
| `host-capabilities.ts` | 宿主注入的能力清单（方案 44 §2.1）：校验 + 拼前缀。**和上一行不是一件事** —— 那个写用户的文件，这个一个字节都不落盘 |
| `plugin-mcp-servers.ts` | 已装插件根下那份 `mcp.json`（方案 44 PR-3）：读路径 → 拼 `<插件名>__` 前缀。core 只交路径（它不许 import `plugin-mcp`），解析在这儿 |
| `plugin-control.ts` | `EpochRuntime.plugins` 那十几个动词（列 / 本地预览与安装 / 落包 / 更新 / 查看 / 卸载 / 启停 + `pendingRestart`）。**一条安装逻辑都没有**（那在 core）——它守的是「这一程加载了谁」和「盘上装了谁」的差；2026-11-06 起市场 ref 那一族随 `hostMarketplaces` 一起删了。⚠️ 其中 `view` 是**唯一只读的那一个**（2026-10-22）：它不经 `flow`、不取包、不过宿主那道动作闸，判据在那一格自己的 JSDoc 上 |
| `plugin-install.ts` | 「装一次」那个动作的**结局形状** + preview → install 两段。中间那条缝用 token 缝上，判据同 core 的 `skill/import.ts` |
| `session-factory.ts` | 多会话工厂：**全仓唯一一处** `new AgentLoop` + `new AgentSession`，外加审批/提问的路由 |
| `utility.ts` | `EpochRuntime.utility` 那**一个动词**。它有一整套自己的判据要落点：挂哪个 router、桶的钥匙（`sessionId ?? utility:<label>`）、以及那两条记账后果 |
| `services.ts` | provider / session / memory / skill / hook / 权限 / 上下文 / 沙箱；设置分层的合并点也在这儿 |
| `tools.ts` | **编译期**插件（`EpochPlugin`）+ 内置工具 + MCP 全部进同一个 `ToolRegistry` |
| `commands.ts` | 自定义斜杠命令的装配：加载命令表、包一层可按轮次收窄的工具 provider |
| `workspace-files.ts` | `@文件` 补全的收窄面（方案 63），2026-09-02 起也是 Web UI「路径可点」的底座：列候选 / 读正文 / 看一眼是什么 / 算「用什么命令交给系统应用」。**四个方法都过同一道 `file_read` 权限闸** —— 少一个，浏览器就能看到模型看不到的东西 |
| `workspace.ts` | `--add-dir` 落地（目录体检 → 告知权限层 → 进启动诊断）+ 每会话的工作区绑定与信任判定 |
| `agent-session.ts` | 审批回调 → 流事件、历史累积、逐轮持久化、**划检查点的「一轮」** |
| `session-store.ts` | `SessionManager` 的薄封装，落盘的是完整形状。**落盘失败一律不拦对话**，只回 `false` / `null` + 记一条日志（`swallow()`），见下 |
| `serialize.ts` | 跨 IPC 的事件序列化 + `ApprovalRelay` |
| `events.ts` | 进程级事件观测口（方案 61）：**总线本体 + 唯一那个单例**。发布点不在这儿，在 `agent-session.ts` 的两处 `yield` 之前。⚠️ 它**不做投影** —— 和 `serialize.ts` 的关系写在它的文件头 |
| `mcp-admin.ts` | MCP 运维门面（`cli` 不许直接依赖 `plugin-mcp`，所以从这儿转） |
| `mcp-config-write.ts` | 往用户手写的 `~/.epoch/mcp.json` 里**插一段**（`POST /api/mcp` 走它）：注释 / 键序 / 缩进一个字节不动。判据同 `infra/src/config-yaml.ts` |
| `mcp-config-file.ts` | 那份文件的**整份**读写（2026-08-18）：原文进、原文出 + 内容指纹 + 换行符跟着盘上那份走。⚠️ 和上一条**不冲突**，主语不同，见下 |
| `mcp-config-apply.ts` | 把盘上那份搬进这个进程：四档动作（新增 / 重连 / 没动 / 断开）+ 「只动用户那批」 |
| `mcp-config-edit.ts` | 那份文件的**就地替换 / 删除**（2026-09-02）：`mcp_update` / `mcp_remove` 走它。**删除是这一族里唯一会动到用户已写下字节的动作**（那个逗号），见下 |
| `mcp-json-scan.ts` | 上面三条写 MCP 配置的路**共用**的那份文本扫描器（字符串 / 两种注释 / 定位 `servers` 那个 `{`）。各写一份的下场是同一份 `mcp.json` 在两个动作下一个能做一个说「看不懂」 |
| `mcp-tools.ts` | 模型自己管 MCP 那四个工具。住在 runtime 而不是 core，因为它们要的 `McpControl` 是装配层的产物 |
| `telemetry/` | OTel / 文件两种 sink，`@opentelemetry/*` 是**可选** peer |

## 设置分层在这儿落地

`loadConfig()` 只解析到**用户级**（`~/.epoch/config.yaml`）。项目级 `<项目根>/.epoch/settings.json` / `settings.local.json` 要先判完工作区信任才敢读， 而信任判定就发生在 `buildServices()` 里 —— 所以合并点只能在这儿， 夹在 `buildTrust()` 之后、`new ProviderRouter(config)` 之前（router 一构造就把 `config.model` 定死了）。分层的规则、优先级和信任闸门的不对称写在 [docs/SETTINGS.md](https://npmcn.com/package/docs/SETTINGS.md)。

对宿主来说只有一句话要记：

> **装完之后用 `services.config`，不要再用你传进去的那份。** `model` / `maxTurns` / `permissions` 都可能已经被项目级或 `--settings` 覆盖过了。`buildRuntime()` 已经 这么做了，`rt.config` 就是合并后那份。

合并把来源拍平了，而「这个值是谁定的」正是用户查配置问题时的第一个问题。所以 **`rt.settings` 把它反查回来**：每一项带上它赢在哪一层，`permissions` / `model` / `maxTurns` 三个能被项目级覆盖的键另带一条六层链（低 → 高，标出哪一层赢了、 被压住的层各自是什么值）。① 内置默认和 ② 用户级那一刀由 `getConfigOrigins()` 切 —— 分不出的话，一个用户从没配过的键会被标成「② 用户级」，而那是个假答案。

写那一半 2026-08-15 补上了（`rt.settings.write()`）：一次写一个键、只写 ② 用户级和 ④ 项目本地、回执带 `apply: 'restart'`。三件保存语义的判据全文在 core 的 [config/write.ts](https://npmcn.com/package/%40epoch-agent/core/src/config/write.ts) 文件头。

> ⚠️ **`rows()` 和 `write()` 都收一个可选的 `sessionId`**，而那不是一个可选的 精细化。多会话之后 ③④ 跟着**工作区**走，而工作区一个会话绑一个（决定 18）—— 写目标文件（`.epoch/settings.local.json` 在哪个仓库）和信任判定（被拒绝时 ④ 里 的标量整份作废）必须来自**同一个**工作区。只改其中一个的表现是「拿 A 工作区的 信任判定去决定往 B 工作区写」，而那在任何一屏上都看不出来。所以两样由同一个 函数（`makeSettingsScope`）一次取齐 —— 不给 `sessionId` 就是引导会话那一份， 单会话宿主的行为逐字不变。
> 
> **停着的会话**（这个进程手里没有绑定的那种）2026-10-15 起也算得出：宿主把会话库里 记着的工作区决定作为第二个参数递进来（`rows(id, persisted)` / `write({ …, persisted })`），runtime 按 `bind()` 那套规范化（项目根 + 信任判定） 算 ③④，**不建绑定**。说不出按哪个目录算的（没记过决定 / 记着的目录没了） `covers(id, persisted)` 答 `false`，这时 `rows()` / `write()` **抛**， 不再落回引导会话那一份 —— 落回去就是「铅笔写进另一个项目」。宿主先问 `covers()`。

第 ①.5 层（插件）也在这儿落地：`buildServices()` 一进门就 `loadPlugins()`， 把已装插件的命令目录 / 角色 / 技能目录 / `hooks.json` / `settings.json` / `mcp.json` 收进 `services.plugins`，再分别喂给命令表、角色注册表、`SkillSystem`、hook 来源和 `resolveSettings`。⚠️ **`mcpPaths` 那一样不在 `services.ts` 里接** —— 读那份文件要用 `plugin-mcp` 的解析器，所以它和 MCP 装配的其余部分一起留在 `tools.ts` / `plugin-mcp-servers.ts` 那一侧（`build.ts` 里 `registerMcpTools(...)` 那一行）。**插件那一层只有 `permissions.deny` 会被采纳**， 不过工作区信任闸门（它不来自仓库）—— 见 [docs/PLUGINS.md](https://npmcn.com/package/docs/PLUGINS.md)。

`buildRuntime({ settingsPath })` 对应第 ⑤ 层；不给时回退到 `EPOCH_SETTINGS` 环境变量 —— CLI 的 `--settings <file>` 走的正是这条路，因为无参进 TUI 时 TUI 是**另一个进程**，只有环境变量带得过去。这一层**不过信任闸门**：它是用户在 命令行上显式给的路径，不是仓库里躺着的文件。**这份文件不是合法 JSON 时抛 `SettingsFileError` 拒绝启动**（其余层坏了只记一条 issue），理由见 [core README](https://npmcn.com/package/%40epoch-agent/core#%E5%87%A0%E4%B8%AA%E5%88%BB%E6%84%8F%E7%9A%84%E5%8F%96%E8%88%8D)。

`addDir` / `agent` 是同一个形状：不给时分别回退到 `EPOCH_ADD_DIR` （按 `path.delimiter` 分隔，用 `splitDirList()` 拆）和 `EPOCH_AGENT`。 加新参数时按「TUI 那条路带不带得过去」判要不要落一个环境变量。

第 ∞ 层（企业托管）也在这儿落地，三处：`services.managedPolicy` 是读出来的结论 （**永远非 null**，没有托管文件时是三个开关全 false）；`allowManagedHooksOnly` 打开时用户级和项目级 hook 都不加载（`loadHookSources()` 整个跳过，压过项目信任）； `disableBypassPermissionsMode` 打开且 `permission` 落在「审批轴 = `never`」那一档（今天的 `auto`；老配置写的 `bypass` 会在解析处映射成它）时**抛 `ManagedPolicyError` 拒绝启动** —— 这一步排在任何资源分配之前，否则每次拒绝都会漏一串 SQLite 连接 （`cleanups` 要等 `buildServices` 正常返回才交得出去）。

> `buildRuntime({ managedSettingsPath })` **只给用例和嵌入宿主**。它没有对应的 环境变量或命令行参数，理由见根 README：有的话一句 `EPOCH_MANAGED=` 就能把 IT 下发的策略整份关掉。

## 一个进程里开多个会话（方案 30 §6.3）

`rt.session` 是**引导会话**；再开一个走 `rt.sessionFactory.create({ workspace })`。 单会话宿主（CLI / TUI）手里只有前者，那正是它留着的理由。

### 把一段**盘上还在、这个进程手里没有**的会话接回来（2026-08-19）

```ts
rt.sessionFactory.revive({ sessionId, decision: { state: 'bound', root } });
```

内部就是 `create()` 那条路上同一段 `assemble()`，两段处置也原样复用： **绑不上就整个不建**、**装不起来就把这一次加的那一笔绑定放下**（不留幽灵 id）。 `hub.register()` 归调用方 —— 这一层不知道 Hub 的存在。

⚠️ **历史不从外面递进来**，虽然方案原稿是 `revive(sessionId, history, decision)`： 模型看的那种历史是 core 的类型而服务端不许 import core，而且 `assemble()` 已经给 这个会话建了一份 `SessionStore` —— 从外面再开一个就是第二个真源。所以历史在装配 之后用 `AgentSession.reloadHistory()` 接上，那也是 `/resume` 走的同一个方法。

⚠️ **角色接不回来**：`sessions` 表没有角色那一列，所以一段用 `explore` 建的会话 复活之后跑的是这个进程的 `--agent`。**如实记着，这是一笔债不是一个判断。**

⚠️ **`decision` 由调用方从库里读出来给，这一层不猜。** 没有决定可循时回 `{ ok: false, reason: 'no-decision' }`，而不是回落到「就绑进程启动目录」—— 那会把一段「明确不使用工作区」的会话悄悄绑进服务进程的 cwd （会话库 v7 那两列的由来，判据全文在 `ReviveSessionOutcome` 上）。

⚠️ **和 CLI 的 `/resume` 是两件事，别合并。** `/resume` 是「把另一段对话接上 **这个**引擎」，所以 `build.ts` 那行 `workspaces.seed()` 让它跑在这个进程的工作区 里；`revive()` 是「把**那段会话本身**装起来」，跑在它自己当初选的地盘上。 两处 JSDoc 互指。

### 这个会话用哪个专家跑（2026-08-15）

```ts
rt.sessionFactory.create({ workspace, reuse: false, role: 'explore' });
```

⚠️ **`explore` 这类名字 2026-11-05 起不再内置**（`general` / `explore` / `review` 三条从内核删了）：要这么写，得自己落一份 `~/.epoch/agents/explore.md` 或项目级的 `.epoch/agents/explore.md`，否则 `create()` 抛 `AgentRoleError`。

在这之前角色只有两条来路：`delegate_task` 派出去的子 loop，和 `--agent <role>` （**进程级**，所有会话共用一个）。这一条补的是**会话级**那一档 —— 装配层在 `assemble()` 里对它走一次角色收窄，和 `--agent` 完全同一条路。

> **2026-08-17：那一次收窄换成了一个槽**（方案 57 §3.2 的 `createRoleScope`）。 会话级角色是槽的**默认值**，行为逐字不变；变的是形状 —— 工具表和身份行 从此读同一个变量，所以「工具按 explore 收窄了、prompt 里写着 build」 这种状态不存在。槽的 `enter()` 目前**没有调用方**，逐条专家的接线还没落地。

三件要知道的事：

-   **认不出这个名字就是拒绝**（`{ ok: false, reason: 'unknown-role', detail }`）， 不静默降级成不分角色。判据照 `--agent` 的 `AgentRoleError`：用户显式点了一个 专家，给他跑一个能力范围不同的 agent 是最坏的结果
-   **不给这个字段时回落到 `--agent`**，也就是这个字段出现之前的行为
-   **`reuse: true` 复用回来的会话不换角色。** 角色同时决定 system prompt 的身份行 和工具表，而 system prompt 是 prompt cache 的前缀 —— 中途换掉等于让同一段历史里 前后两轮的身份声明互相打架。要另一个专家就是**另一个会话**（`reuse: false`）

顶层会话那份角色带着 `scope: 'session'`（`protocol` 的 `AgentRoleScope`）， 于是它拿到的身份行是「这一次以「x」的身份工作」而不是子 agent 那句 「由主智能体派来完成一个独立子任务」—— 后者对顶层会话从 `--agent` 落地那天起 就是一句假话。

作用域怎么分（**完整那张表在 [session-factory.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/session-factory.ts) 的文件头，这儿只列结论**）：

-   **一个进程一份**：provider / SQLite / 记忆 / 技能 / hook / `ToolRegistry` + MCP / `BudgetStore` / 工作区绑定表
-   **一个会话一份**：`AgentSession` + 它的审批桥、`AgentLoop`（`workDir` 与 `projectContext` 跟着绑定的工作区走）、`CheckpointManager`、`SessionStore`

> ⚠️ 「跟着绑定的工作区走」是**每轮现读**，不是装配那一刻的快照（迟绑， 2026-08-20）。`assemble()` 递给 `AgentLoop` 的是 `AgentConfig.site` —— 一个问绑定表的函数。快照那一版的病是：侧栏「新建会话」开出来的会话 （`unbound`，方案 55 PR-1）在底栏 picker 里挑完目录之后，界面上那一格换了、 引擎照旧在服务进程的启动目录里干活。判据全文在 `session-factory.ts` 的 `assemble` 和 core 的 `AgentSiteRef` 上。

> ⚠️ **「一个会话一份」的东西，取数也得按会话取。** `LiveSession.checkpoints` （2026-08-15 加）就是这条：`EpochRuntime.checkpoints` 是**引导会话**那一份， 拿它去答别的会话的表现是列表恒空、回退恒被拒 —— 而这个进程正握着对方的 `CheckpointManager`。web 上那三条回退端点在这个字段之前逐字就是这么错的 （方案 30 §9.5）。同理 `settings.rows(sessionId)` 收 id、 `workspaces.of(sessionId)` 收 id。

⚠️ **模型 / 权限档 / plan 模式三样 2026-08-16 也收窄成了会话级**（方案 30 §十四）：

| 拿哪一份 | 引导会话 | 别的会话 |
| --- | --- | --- |
| 模型（`ModelControl`） | `rt.model`（进程那一份） | `sessionFactory.get(id).model` |
| 权限档（`level` / `setLevel`） | `rt.permissions` | `sessionFactory.get(id).permissions` |
| plan 模式（`PlanControl`） | `rt.plan` | `sessionFactory.get(id).plan` |

⚠️ **`rt.utility`（一次性补全）刻意不在上面那张表里**：它挂的就是引导会话那一份 router，**没有「别的会话那一份」** —— 它答的是「这个进程默认用什么」，而不是 「窗口 2 现在在用什么」。于是第二个会话换过模型之后，`utility` 跟的仍然是引导 会话那个选择。这条代价和 `getUtilityInfo()`（「没配 utility 就跟着主模型走」在 多会话下确切的意思是「跟着**引导**会话走」）是同一条，判据在 [src/utility.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/utility.ts) 的文件头。要打别的就显式给 `model`。

**引导会话那三份就是进程那三份本体，不是副本** —— 所以 TUI 的 `/model` / `/permission`、CLI 的 `--model` 行为一个字节没变，而「两份要同步」这件事在这个 形状下说不出口。三样各自的落法和判据在 [src/session-model.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/session-model.ts) / [src/session-guard.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/session-guard.ts) / [core/src/permission/shared.ts](https://npmcn.com/package/%40epoch-agent/core/src/permission/shared.ts) 的文件头。

⚠️ 分家的**只有档位那一个字段**：规则表、审批缓存、审计流水、沙箱隔离、 额外根、headless 白名单全进程共用同一份引用（`PermissionManager.scoped()`）。 它们是关于**用户**和**这台机器**的事实，跟着会话走反而会说假话。

⚠️ **一样这一轮仍然没有收窄，是已知限制**：**`--add-dir` 的额外根是进程级的。** 新绑的工作区没有额外根，但 `PermissionManager.getExtraRoots()` 仍然报引导会话 那一份。它的**理由 2026-08-16 换了一个**：档位分家之后它技术上随时能搬到会话 那一格，但「新会话该不该继承 `--add-dir` 的地盘」是一次产品判断，不是接线问题。

并发跑两轮时**预算和用量不用额外处理**：`BudgetGuard` 每轮新建且带 `sessionId`，`BudgetStore` 按 sessionId 分格且是同步读-改-写。所以 `rt.usageScope` 那两个口径在多会话下逐字仍然成立。

## 导入技能：**收路径，不收字节**（方案 42 §六）

`EpochRuntime.skillWrite` 转出去的是**三个函数**，不是 `SkillSystem` —— 后者上有六个写方法、一个会 `matchCount++` 的 `view()`、还有一个会**自动删** 低效技能的 `maintain()`。交出去等于让网线那一侧够得着「污染那本喂给模型的评分」 和「一口气自动淘汰一批」。收窄之后 `@epoch-agent/server` 那个包**写不出** 别的动作 —— 那不是靠 review 盯住的，是编译期的事（同 `McpControl` 只露 reconnect）。

⚠️ **2026-09-01 这一格从 `skillImport` 改名成 `skillWrite`**，因为那天它多了 一个 `remove`：一个叫 `skillImport` 的字段上挂着「递归删目录」是这一层最不该有的 假话。而多出来的那一个是**逐条点名删**（`remove(name)`），和上面说的 `maintain()` 那种「自动淘汰一批」仍然是两件事 —— 后者照旧不出这个包。

⚠️ **两个动作都只收一条服务端本机路径。** 判据全文在 core 的 `skill/import.ts` 文件头，一句话：用户级技能目录**连信任闸门都没有**， 往它里面落一份文件 = 让那段文字无条件进往后每一轮的 system prompt； 而 `epoch web --host 0.0.0.0` 那一档下，浏览器可能不在用户自己那台机器上。

**哪天真要做上传，不是在这儿加一个 `bytes` 参数** —— 那会把上面那条性质 一声不吭地删掉。回去读那个文件头最后一节。

嵌入宿主要注意：这条路的落点**永远是 `SkillSystem` 自己那个用户级目录**， 调用方给不了。让宿主传一个目录进来，它就成了「往任意目录写文件」的口子。

## 改 `mcp.json`：**两条路，主语不同**（2026-08-18；2026-09-02 加了两个动作）

这一层有两条写 `~/.epoch/mcp.json` 的路，而它们**不许互相取代**：

| 路 | 写下去的是 | 做法 |
| --- | --- | --- |
| `writeMcpServer()` / `updateMcpServer()` / `removeMcpServer()` | **我们生成的**一段 | 就地改，只碰点名那一台 |
| `writeMcpConfigFile()`（`mcp-config-file`） | **用户敲的**字节 | 整份覆盖 |

`mcp-config-write.ts` 的文件头逐字判掉了「读进来 → 改 → 整份写回」：我们读进来的 不是原文（`parseMcpConfig` 吐的是归一化之后的结构），整份写回会把 `connect_timeout` 改写成 `connectTimeout`、把用户没写的 `streamable` 物化成一行、把未知键删掉 —— 那是**改写他配置的语义**。

那条判据**只管第一行**。第二行上根本没有 parse → serialize 这一步：字节进、字节出， 所以注释 / 空行 / 键序 / snake\_case 键名 / 未知键全都原样活着。⚠️ 别把它读成 「那条判据过期了」，也别拿它去简化第一行。

### 📮 2026-09-02：第一行从「只插入」变成三个动作

`mcp-config-write.ts` 的文件头原来有一句总纲：「**我们对这个文件只有一次插入的 权利**」。这一轮把它推翻了，而理由不是「插入不够用」，是**模型手上那条路没有第二 个选项**：`mcp_update` / `mcp_remove` 那两个工具（[docs/TOOLS.md](https://npmcn.com/package/docs/TOOLS.md#%E7%AE%A1-mcp-%E8%87%AA%E5%B7%B12026-09-02)） 要么走就地改写，要么走第二行那条「读原文 → 整份写回」，而后者意味着把整份 `mcp.json` 交进模型的上下文 —— 里面装着他每一台 server 的 `env` 密钥。 一条为了保住排版而存在的判据，不该以「顺带把密钥全交出去」为代价来维持。

⚠️ **变的是「几次」，不是「什么」。** 替换和删除照旧是定位 + 拼接文本，上面那段 「整份写回会改写他配置的语义」一个字都没过期。**删除是这一族里唯一会动到用户已经 写下的字节的动作** —— 被删那台前面或后面那个逗号是他写的，而留着它就是一份坏 JSON。 边界（只动那一个逗号、只动只剩空白的那一行、行尾注释一律留着不猜归属）逐条写在 `mcp-config-edit.ts` 文件头第一节。

三件这一层管、上面那层管不了的事：

1.  **指纹防覆盖**。这份文件另一个编辑器一直开着（用户的 vim、还有 `writeMcpServer` 自己）。读的时候发一个内容哈希，写的时候拿它和**此刻盘上那份**比，对不上整段 拒绝。⚠️ 是**内容哈希不是 mtime**：后者在「写回同样的内容」和「同一毫秒两次写」 两档下都会骗人；
2.  **换行符跟着盘上那份走**。浏览器的 `<textarea>` 只会吐 `\n`，而 CRLF 的 `mcp.json` 在 Windows 上是常态（Windows 是支持平台）—— 不转回去的话，用户在 自己的编辑器 / `git diff` 里看到的是**整个文件都变了**；
3.  **权限位**。已经在的那份沿用它自己的（用户可能刻意收成 0600），新建的那份给 **0600** 而不是 0644 —— 这个文件天生装着 `env` 里的明文 token。

### `applyConfig()`：「改完 `mcp.json` 要重启」那句话的出路

`reconnect()` 走的是已经建好的那个 client，它手里那份配置是**启动时**读的 —— 所以那一下治的是「对面重启过 / 网线抖了一下」，不是「我刚改了配置」。 `applyConfig()` 才是后者：读盘 → 和进程里那几台 diff → 该断的断、该连的连、 **没变的一个字节不动** → 按台换工具表（走 `ToolSync` 那条**同一条队**）。

-   ⚠️ **只动 `source === 'user'` 那批。** 宿主注入的和插件带来的两批在这份文件里 天生缺席，把它们算进「盘上没了 = 该断开」等于用户存一次自己的文件就把宿主 ship 的那几台全断了 —— 而那批他一点办法都没有；
-   ⚠️ **「读出来一台都没有、而且带诊断」整段拒绝**（最常见的成因是把 `servers` 写成 了 `mcpServers`）：照着应用会把用户所有 MCP 全断掉。而「0 台且**没有**诊断」 （`"servers": {}`）是**照办**的 —— 那是一句清楚的「我要清空」；
-   它为此给 `plugin-mcp` 的 `McpRegistry` 补了两个方法（`disconnect(name)` / `configOf(name)`）：在那之前「让一台 server 在这个进程里消失」做不到， 只有 `disconnectAll()`。

## 建 / 删一个身份：**这一层不带闸门，那是刻意的**（2026-08-18 建、2026-09-16 删）

`EpochRuntime.roleWrite` 收窄到**两个动词** `add()` / `remove()`，形状和上面两条 同源：宿主（`@epoch-agent/server`）于是**写不出**「改一个身份」。

它做两件只有装配层做得了的事，落盘那一半在 core 的 `agent-role/create.ts` 和它的兄弟 `agent-role/remove.ts`：

1.  **查的是合并之后那张角色表**，不只是那个目录。建那一侧漏了这一道不是「多一个 重名」：两条同名定义会互相收窄（`mergeRoles` 的同名语义是工具取交集、 prompt / description 整份替换），而屏幕上只画得出一份。删那一侧靠它答 「这一行是哪一层来的」—— core 只看得见一个目录，一个删不掉的项目级 / 插件 身份在它那儿长得像「找不到」，而屏幕上那一行明明在；
2.  **写完重载角色表**。`EpochRuntime.agentRoles` 是**装配那一刻**加载出来的那张表 （普通属性，不是 getter —— 和 `skills` / `mcpServers` 那两个不一样）， 不重载的话这个进程要到下次启动才认得这次改动。⚠️ 删那一侧**还多靠它一件事**： 重载之后再查一次表，才答得出 `takenOverBy`（删掉的要是一个同名覆盖， 下层那一份会浮上来 —— 那一行不会从能力页上消失）。

⚠️ **落点靠读口解析出来的 `name` 找，不是拿名字拼路径**（`removeRoleFile`）： frontmatter 里的 `name` 优先于文件名，所以 `my-helper.md` 里写 `name: reviewer` 是合法的。`join(dir, name + '.md')` 在那种现场要么删不掉一份界面上明明列着的身份， 要么**删掉另一个身份**，而两种错法都不报错。

⚠️ **那道「回环绑定才给写」的闸门不在这一层，在 `@epoch-agent/server`。** 判据是：`lanExposed` 说的是「那个 HTTP 服务绑在哪儿」，而**直接拿 `EpochRuntime` 用的嵌入宿主压根没有一个 HTTP 绑定** —— 它自己就是那台机器上的进程，不是网线 另一头。所以宿主调 `roleWrite.add()` 不吃那道闸，那是对的。 完整判据（以及「照收 / 借工作区信任 / 只写项目级」那三档为什么不选）在 `protocol/src/wire-agent-role.ts` 的文件头。

⚠️ 反过来说：**嵌入宿主自己开一条 HTTP 路把它转出去的时候，那道闸得自己立一遍。** `server` 那一份长这样：`ctx.lanExposed` 为真 → 403，**而且在读请求体之前**。

⚠️ **删那一条吃的是同一道闸，而理由和 `POST /api/skills/remove` 刻意相反**： 技能删除不吃闸，判据是「导入那条也不吃」（只给删加闸 = 那一档下技能变成 「只能加不能删」）；身份这边建那一侧本来就要回环，所以删也要回环才不制造 那个反面。全文在 `protocol/src/wire-agent-role.ts` 文件头第六节。

## ⚠️ 落盘失败：不拦对话，但**必须有声音**（2026-09-20）

有过一次真实报障：「我打开一个新对话框，给他下达指令后，他完成了指令，但整个沟通 记录一下子跳掉了。」重启之后那段会话也不在了。根因是 `SessionStore.ensure()` 把 **写不进去**读成了**已经存在**，接着无条件 `known.add()` —— 一次偶发的写失败被 放大成这段会话**终身不再尝试建那一行**；没有父行，`messages.session_id` 那条外键 让每一条消息都撞约束，整段对话一个字节都不落盘。而屏幕靠 SSE 直播，一切看着正常， 直到一次重拉把空历史盖上去。

改这一层的落盘路径之前，三条规矩：

-   **`ensure()` 失败时不许 `known.add`。** 那个集合只进不出、且是第一道短路， 往里加一个建不出来的 id 就是把偶发失败变成永久失败。`create` 抛了要去 **问库**（`SessionManager.has()`），「在了」才算已存在；
-   **`catch` 里不许是空的。** 16 处写 / 读路径统一走 `swallow()`（写 error、 读 warn）。规矩本身不变（照旧不拦对话、照旧回 `false` / `null`），变的是 一个「对话没了」的工单能在日志里对到一行。有一条扫源码的 AST 门禁守着 第 17 处（`__tests__/session-store-silent-catch.test.ts`）；
-   **返回值不许掉在地上。** `beginTurn` / `appendSteps` / `appendSteer` 都回 boolean，`AgentSession` 拿到 `false` 时发一帧 `not-persisted` 的 notice， **一轮最多一帧**（磁盘满时一次多工具调用会踩十几次落盘，逐个报就是刷屏）。 ⚠️ 收尾那两处在 `run()` 的 `finally` 里，**只记日志不发帧** —— 那一段一个 `yield` 都不能有（在 `finally` 里 yield 会吞掉正在传播的异常）。

⚠️ 那 17 句日志文案是中文且**不走 i18n** —— 收件人是看日志的人，翻了反而难搜。 `scripts/i18n-scan.mjs` 的文件级白名单里有一条对应的豁免，判据逐字同 `infra/src/sqlite.ts`。往这个文件里加**界面**文案时别顺着那条豁免走。

## 几个容易踩的点

**会话历史的自动压缩在这一层，不在引擎里**（2026-09-10）。引擎那几道压的是 「一次 run 的局部 `messages`」，run 一结束就扔了；而下一条用户消息重新送上去的是 `AgentSession.history` —— 在这一笔之前，那一份**只有 `/compact` 和 `resume()` 换得掉**，于是每条消息都把整段原始历史重付一遍（累计 token 随轮数**平方**涨）， 最后撞窗口，而撞上之后每条消息都报错。现在 `AgentSession` 在跑之前按软线判一次、 压完**写回历史**。三点：

-   判据吃的是上一发**实测**提示词（`onTurnUsage` 免费给的，含缓存命中那部分）， 只有刚起进程 / 刚 `resume` 那一次回落到把历史分词一遍；
-   开关和阈值走 `compressionOptions(config)`，和引擎那几道**同一个入口** —— 用户关掉压缩就是三道全关；
-   **嵌入宿主不给 `autoCompact` 那一格 = 一次都不压**（行为和改造前逐字相同）。 自己 `new AgentSession()` 的宿主要这道闸就得自己传窗口，判据在 `AgentSessionOptions.autoCompact` 上。

**定时任务在嵌入形态下必须由宿主说「怎么唤起我」。** `buildRuntime({ scheduleRunner })` 不给时 `runtime.schedules.create({ register: true })` 会**拒绝注册并回一个 `no-host-runner`**，而不是拿 `process.execPath` + `argv[1]` 去拼 —— 在 Electron 里 那两个值是「Electron 的 exe + 宿主的 main.js」，注册进去等于到点了弹主窗口。 任务照样存得下、`fire()` 照样跑得了（宿主自己掐点），只是界面上必须说清 「关掉应用就不跑」。全文在 [schedule/control.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/schedule/control.ts) 的文件头和 [EMBEDDING.md §9.5](https://npmcn.com/package/docs/EMBEDDING.md)。

**`dispose()` 返回 `Promise`，能 `await` 就 `await`。** 它关 SQLite、销毁沙箱、 杀 MCP 子进程、停定时器，还要收掉 `terminal({ background: true })` 起的后台进程和 PTY 会话 —— 最后这项要先查整棵进程树（`pgrep` / `taskkill`）再 SIGTERM → SIGKILL， 只能异步。同步那批全部跑在**第一个 `await` 之前**，所以不 await 的老代码行为不变， 只是后台进程变成尽力而为。幂等，重复调用是空操作。

**后台任务表按会话分，所以那两个口子的第一个参数是会话 id**（2026-08-15）。 传的必须是**活的**那个 —— `session.sessionId`，不是装配时拍下来的 `rt.sessionId`： `/resume` 会把前者换掉，拿后者去问，resume 之后数的是一个已经不存在的会话。 单会话宿主（CLI / TUI）写成 `rt.session?.sessionId ?? rt.sessionId` 就对了。 收窄的完整判据在 plugin-terminal 的 `src/background.ts` 文件头。

> ⚠️ **这条建议在 2026-08-17 之前是反的。** 那时 `rt.sessions.resume()` 里 「把任务搬到新 id 名下」那一步恒为空操作，于是 resume 之后拿**活的** id 去问 反而一条都数不到。同一处也让 `rt.sessionFactory.get(刚 resume 过去的 id)` 恒为 null。两样都修了，判据在 `src/build.ts` 的 `buildSessionControl`。

**装配是显式手写的，不用 DI 容器。** 十几个模块的依赖关系读一遍就完，引入容器只会把 「谁依赖谁」藏进配置里。

**每个模块独立 `try/catch`（safeInit）。** 少一个能力也比整个 agent 起不来好，失败原因 进 `diagnostics` 交宿主呈现——`epoch doctor` 打的就是这些。

**审批那个 race 不是可以省的。** 审批请求发生在两个事件**之间**（AgentLoop 在工具执行前 阻塞等答复）。只在收到事件时才排空审批队列的话，双方互等——死锁。所以必须显式 race 「下一个事件」和「有新审批请求」。

**要展示「信任了没」就读 `rt.trusted`，别自己 `new TrustManager().check()`。** 两者只在 `trust.enabled: false` 时分叉，而那一档恰好是最容易说反话的：闸门整个不启用， 装配走的是信任那一支（项目指令、角色、自定义命令全加载了），而信任表里可能一条记录都没有 ——宿主自己查会得出「没有决定过」，跟运行时的真实行为不是一回事。

**默认值是信任，所以宿主通常不需要碰 `rt.trust`。** 它现在是「拒绝名单」的读写口： 要给用户一个「不要加载这个目录」的开关，就调 `record(dir, 'untrusted', scope)`； 撤销拒绝则是 `record(dir, 'trusted', scope)`（或 `revoke`）。三条边界：

-   **`record()` 不带闸门，它就是那个决定本身** —— 宿主必须先真的问过用户。 判据是宿主主进程和 CLI 同一档，都是可信代码；浏览器不是（那条路刻意关着， 也不会开：让一张网页决定「哪个目录的文件能当指令读」是这个仓库最贵的开关）
-   **它不是权限开关。** 信任管「文件算不算指令」，权限管「操作要不要问用户」—— [两套机制刻意正交](https://npmcn.com/package/%40epoch-agent/core/src/trust/gate.ts)，被拒绝的目录里 agent 照样能跑 bash
-   **它不是工作区绑定的前置。** 绑一个被拒绝的目录是合法的，只是指令文件不加载

`trust` 为 `null` 只有一种情况：信任记录整份读不出来（那时按默认信任放行并留一条 warn）。 **`trust.enabled: false` 不是那种情况** —— 闸门关了不等于没有记录这回事。

**要预置模型 / MCP / 已知工作区，用 `hostPreset`，别自己写我们的文件** （方案 54 §六）。以前宿主只能逐个手写 `models.json` / `config.yaml` / `mcp.json` / `workspaces.json`，而那几份的格式**一份都没对外承诺过**。四条语义（在 [host-preset.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/host-preset.ts) 的文件头，改之前先读那一段）：

-   **不是新的一层** —— 等于「替你写第 ① 层那份文件」，⑤ / ∞ 照旧压得过它
-   **缺失才写，不覆盖**，按键判。所以每次启动传同一份是对的用法
-   **`apiKey` 给了 `secretStore` 就不落盘**，进你注入的那个存储（否则落 `<homeDir>/.env`）
-   **写了什么全在诊断里**（`module === 'HostPreset'`）。「配了但没生效」是这类东西 唯一的失败模式，而它在此之前完全静默

> ⚠️ **2.0.0 换过一次形状**：`provider: { type, baseUrl, apiKey }` + `model: '<provider>/<model>'` 是**老写法**，删了。今天是 `models: [{ id, provider, model, baseUrl?, apiKey?, … }]` + `model: '@<id>'`（外加可选的 `label`，当设置页里那一组的组名）。文件里已有的老值算「缺失」， 所以照旧每次启动传同一份能把它改过来。
> 
> ⚠️ **2026-10-15 又收窄一次（还没发版）：宿主条目。**
> 
> -   `preset.models` 物化出来的每一条都打 `host: true` —— **只读**（界面上没有编辑 / 删除， 走 API 也回 `entry-host-readonly`），而且**每次启动刷新成宿主给的形状**：只读条目没有 「抹掉用户改动」这回事。同 id 的老装机条目内容相同就收编打标（升级那批，只此一次）， 用户改过的不动（语义 2）；
> -   **端点在条目上**（`hostPreset.models[].baseUrl`）：没有「进程级统一端点」这回事， `EPOCH_BASE_URL` 和它落成的 `config.gateway` 同一天**整个删了**（设着只报一条诊断）。 用户自己加的条目走自己的端点、自己的 key。宿主那一侧的义务只有一条： fetch 桥只按**自己那个网关 origin** 注入凭据（判据在 `__tests__/entry-route-pinned.test.ts`，断言的是出站 URL）。

它**不**预置信任 —— 那是一个决定不是一份配置，走 `rt.trust`（上一段）。

**要把你自己 ship 的专家 / 技能 / MCP server / 进程内工具塞进能力清单，用 `hostCapabilities`，别去写用户的 `~/.epoch/agents/` 或 `mcp.json`** （方案 44 §2.1、方案 59 §四）。它和上面那个 `hostPreset` **不是一件事**： 那一个装配前写用户的文件（缺失才写、用户改得动），这一个是这一程的运行期事实， **一个字节都不落盘**，用户在自己的目录里找不到它，也没有地方能删掉它。

```ts
buildRuntime({
  hostCapabilities: {
    namespace: 'acme',
    roles: [{ name: 'triage', description: '分拣工单，只读', tools: ['file_read'] }],
    skillDirs: [join(app.getAppPath(), 'resources', 'skills')], // 必须绝对路径
    mcpServers: [{ name: 'deploy', transport: 'http', url: 'http://127.0.0.1:3456/mcp' }],
    tools: [listJobsTool], // `EpochTool` 本体，name: 'list_jobs'
  },
});
// → 专家 `acme:triage`、技能 `acme:<分类>/<名字>`、server `acme__deploy`
//   （工具是 `mcp__acme__deploy__<工具>`）、进程内工具 `acme__list_jobs`，
//   四样都标着「宿主」来源
```

六条性质（全文在 [host-capabilities.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/host-capabilities.ts) 的文件头）：

-   **命名空间是强制的**：前缀由装配层按 `namespace` 拼，宿主在 `name` 里自己写前缀 会被判成非法名字。不这么做的话，一个叫 `general` 的注入会顶掉内置那条， 而 `delegate_task` 不带 `role` 时取的就是它
-   ⚠️ **分隔符分两种**：角色 / 技能是 `<namespace>:`，MCP server 和工具是 `<namespace>__` —— 后两样都会原样进工具名，而 provider 硬拒带 `:` 的工具名 （实测）。判据在 plugin-mcp 的 `McpServerStatus.source` 上。 ⚠️ 字符集也分两种：**工具名允许 `_`**（`list_jobs` 是对的），server 名不允许 —— 前者是名字的末段，后者夹在两个 `__` 中间
-   **`source` 宿主写不出来**：`HostAgentRole` 上没有那个字段，`McpServerConfig` 和 `EpochTool` 上也没有，装配层填死 `'host'`
-   **坏的那一条只跳过它自己**，各留一条诊断（`module === 'HostCapabilities'`）。 唯一整份作废的是 `namespace` 本身坏掉 —— 那时没有前缀
-   **注入是装配时一次性的**，但注入进去的东西生命周期照旧：技能照样被 `SkillLearner` 影响（只是那个目录只读），server 照样掉线重连、照样能在能力页上按「重连」。 ⚠️ 工具那一格上「一次性」最硬：注册表没有「换掉某一个工具」的路
-   ⚠️ **注入的工具照样过审批闸门**，而这不是我们答应的一件事：闸门在 `ToolExecutor` 上，它不看来源。免不免确认由 `annotations` / `operation` 决定， 和内置 / 插件 / MCP 工具走同一套。连带一件白拿的：`execute(args, ctx)` 的 `ctx.sessionId` 是**真正在跑的那个会话**（绕一圈 MCP 的话这个值到不了宿主手上）

注入的 server **装配时就真连**，并和用户 `mcp.json` 那批**共用同一份** `MCP_CONNECT_BUDGET_MS`（不是两份相加）。真连是因为**第一轮发给 provider 的那份 工具 schema 数组是按那一刻的注册表拼的**（2026-08-26 之前这句话的凭据是 `## 可用工具` 那段散文清单，那一段删了，结论没变）；共用一份是因为那个预算存在的 全部理由就是「启动不能被一台连不上的 server 拖住」。和用户那批重名时**用户赢**。

**要 artifact 的路径就读 `rt.artifactsRoot`。** 它和 `rt.homeDir` 是这次装配的事实， 不是配置里的一个字段。2026-08-15 之前 artifact 的**写**路径不认 `homeDir` （读那侧一直认），后果是宿主传了 `homeDir` 就产物 404 而全链路 200 —— 在宿主侧 `join(homeDir, 'artifacts')` 拼一遍，就是给同一个目录第二个说法。

**`rt.sessions.delete()` 连带删产物、也连带收掉这个会话的后台作业** （产物 2026-08-20，方案 47 PR-4；作业 2026-10-29）。在这之前它只删检查点， 而 core 的 `SessionManager.delete()` 上写着「产物由装配层跟着一起清」 —— 一句从头到尾没兑现过的话。宿主这边要知道三件：

-   `SessionDeletion` 多了一格 `artifactsRemoved`，和 `checkpointsRemoved` 一样 **与 `deleted` 分开报**：附属物删不掉不会让整次删除变成失败（对话已经没了， 回一个错只会让用户再点一次然后收到 404）。**别把三格 and 起来当成功判据** —— 该说的是「删了，但那个目录还在」，而用户下一步要知道是哪个目录
-   那一格**还没进 `WireDeleteSessionResponse`**（`epoch web` 那条 REST 路只透传， 所以它会随 JSON 出门，但契约上没有它）。走 REST 的宿主要读它，得先加那一格
-   **这个会话名下的作业会被停掉**（`stopSessionJobs()`，逐个调产出方给的 `stop` 再忘掉那一格）。所以 `delete()` **可能比你想的慢**：那几个 `stop` 要等进程树 真收完才 resolve。想先给界面一个回执、后台再收，就在宿主那侧做 —— 别在这里 加超时：超时之后调用方照样得往下走，那个进程就变成一个谁都够不着的孤儿

**`rt.sessions.import()`：把别处的一整段会话原样搬进本机库**（2026-09-20， 给「一个账号多台机器」那类同步 / 迁移用）。

它**不是「带历史的新建」**，三条硬约束：

-   **不跑智能体、不切当前会话、不发任何事件** —— 纯写库，屏幕上不该有反应。 宿主要让用户看见它，得自己再 `list()` 一次
-   **id 和每条消息的 `timestamp` 由调用方给**，库不拿挂钟顶替。会话的最后活动 跟着来源走（`insertMessage` 那句 `MAX()`），一段两年前的对话导进来还是两年前
-   **撞 id 就拒**（`{ ok: false, reason: 'exists' }`），不悄悄合并也不覆盖。 两边都是用户的数据，这一层没有判断谁该赢的依据 —— 那个判断是调用方的事

消息 id **一定会重发**（`messages.id` 是全库一条序列，跨机必撞），回执里的 `idMap` 把来源的号接到本机的号上，调用方拿它接自己的水位线。整段会话 + 全部消息 在一个事务里落地，中途抛了一行不留。

⚠️ **附件 / artifact 的字节不跟着走**。`parts` 里的图片是绝对路径，导进来之后在 这台机器上多半不存在，读回来会退化成「图片已不可用」占位。搬字节是另一件事。 另外两样有意没搬：`estimatedCostUsd`（`priced_call_count` 分得出「0 块钱」和 「不知道」，抄一个数过来会把后者变成前者）和压缩状态。

配套的两样，都是给增量同步用的：

-   `rt.sessions.list(limit, { archived, updatedSince })`。**`archived` 是三态**： 不给 = 只列没归档的，`true` = **只**列归档的，`'any'` = 两边都列。 ⚠️ `'any'` 之前「两边都要」这句话没有任何写法能表达（`false` 和不给是同一个 意思），只能问两次再自己归并 —— 而两次查询之间 `limit` 各算各的。 `updatedSince` 只列最后活动**严格晚于**它的（ms）：严格 `>` 会漏掉同毫秒的行， 所以水位线要往回退一点再传
-   `rt.sessions.setPinned(id, pinned)`，外加 `SessionSummary` 上的 `pinned` / `parentId` 两格。⚠️ 加读必须同时加写：在 `setPinned` 之前 `pinned` 只有 `create()` 写下的那个 `false`，单独暴露出去会是一个永远是 `false` 的字段 （那也是 `SessionDB.pruneCandidates` 里「置顶的不参与保留期清理」那条豁免 一次都没生效过的原因）。⚠️ **置顶不算活动，不推动 `updatedAt`**

**没有对应的 REST 路**（`POST /api/sessions/import` 不存在）。要它的宿主是把 `buildRuntime()` 和 `createWebServer()` 放在同一个进程里那种，手上直接有 `rt.sessions`；开一条写库的 REST 口子要自己那一套鉴权设计，不是这一版的事。

## 开发

```bash
pnpm --filter @epoch-agent/runtime test
```

`@opentelemetry/*` 是可选 peer：不装也能跑，`telemetry/otel.ts` 探测不到就退化成 `NOOP_TELEMETRY`。

两个探针在 `scripts/` 下，都是**跑真实出网请求体**的（本地拦截端点 + 假 model 条目）， 不是断言我们调了什么函数：

| 探针 | 量什么 | 为什么单元测试替不了 |
| --- | --- | --- |
| `probe-tool-tokens.mts` | 工具表占多少 token（0 / 60 个 MCP 两档） | 要**完整装配**出来的 prompt 和 schema 数组 |
| `probe-anthropic-cache.mts` | 工具表的 `cache_control` 有没有真的进请求体 | 标记要经 AI SDK 转一手；我们的对象里有、请求体里没有的话，单元测试全绿而 Anthropic 一次都不命中 |

```bash
cd packages/runtime
pnpm exec tsx scripts/probe-tool-tokens.mts
pnpm exec tsx scripts/probe-anthropic-cache.mts
```

## 常见问题

### @epoch-agent/runtime 是什么？

时间代理 : 切换核心 / 插件 / MCP 代理

### 如何安装 @epoch-agent/runtime？

在项目目录运行 npm install @epoch-agent/runtime，或使用 pnpm add / yarn add / bun add。

### @epoch-agent/runtime 支持 TypeScript 吗？

支持。包内自带 TypeScript 类型声明（package\.json 声明了 types 入口），安装后即可获得类型提示，无需再安装 @types 包。

### @epoch-agent/runtime 支持 ESM 还是 CommonJS？

以 ES Module 形式发布，推荐使用 import 引入。在 CommonJS 项目中，Node\.js 20\.19\+ / 22\.12\+ 可以直接 require\(\) 不含顶层 await 的 ES Module，更早的版本需要改用动态 import\(\)。

### @epoch-agent/runtime 需要什么版本的 Node\.js？

package\.json 的 engines 字段要求 Node\.js \>=22\.0\.0。版本不符时 npm 默认只给出警告，开启 engine-strict 后会拒绝安装。

### @epoch-agent/runtime 可以商用吗？

@epoch-agent/runtime 使用 Apache-2\.0 许可。宽松许可：允许商业使用、修改和再分发，并附带明确的专利授权；分发时需保留许可与 NOTICE 文件，修改过的文件要注明改动。以上为概括，不构成法律意见，请以许可原文为准。

### @epoch-agent/runtime 的下载量有多少？

根据 npm 官方下载统计，@epoch-agent/runtime 在 2026/10/14 – 2026/11/12 这 30 天内被下载 4,110 次，日均约 137 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入，适合横向比较热度，不能直接等同于使用人数。

### @epoch-agent/runtime 还在维护吗？

最新版本 6\.2\.0 发布于 2026/11/13（今天）；至今共发布 57 个版本，其中过去 12 个月发布了 57 个；首次发布于 2026/08/19。发布频率只是参考信号：功能稳定的工具可能很少需要更新，建议结合源码仓库的 issue 和提交记录判断。

