@epoch-agent/runtime

中文文档
6.2.0发布于 2026/11/13Apache-2.0TypeScriptES Module近 30 天下载 4110

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

中文使用文档
文档目录14 节
  1. 导出
  2. 文件
  3. 设置分层在这儿落地
  4. 一个进程里开多个会话(方案 30 §6.3)
  5. 把一段 盘上还在、这个进程手里没有 的会话接回来(2026-08-19)
  6. 这个会话用哪个专家跑(2026-08-15)
  7. 导入技能: 收路径,不收字节 (方案 42 §六)
  8. 改 mcp.json : 两条路,主语不同 (2026-08-18;2026-09-02 加了两个动作)
  9. 📮 2026-09-02:第一行从「只插入」变成三个动作
  10. applyConfig() :「改完 mcp.json 要重启」那句话的出路
  11. 建 / 删一个身份: 这一层不带闸门,那是刻意的 (2026-08-18 建、2026-09-16 删)
  12. ⚠️ 落盘失败:不拦对话,但 必须有声音 (2026-09-20)
  13. 几个容易踩的点
  14. 开发

@epoch-agent/runtime

组装层(composition root)。把 core 的服务 + 五个 plugin 装配成一个 可跑的 agent。嵌入方要装的就是这个包,不是 core。

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

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

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。

导出

导出用途
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
ApprovalRelay / serializeStream() / toSerializable()审批 UI 在另一个进程时,把事件里过不去 IPC 的闭包换成 requestId
registerLocalTools() / registerMcpTools() / McpServerBatch单独装工具(自定义宿主)。⚠️ registerMcpTools 的第四个参数收的是批次数组(宿主注入的 / 插件带的),再来一批就加一个元素,别加第五个参数
listMcpServers() / mcpLogin() / mcpLogout() / probeMcpServer()epoch mcp 的后端,不带 UI
EpochRuntime.mcp / McpControlMCP 的写口,照 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
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
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
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

文件

文件职责
build.tsbuildRuntime()。显式手写装配,不用 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.tsEpochRuntime.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.tsEpochRuntime.utility 那一个动词。它有一整套自己的判据要落点:挂哪个 router、桶的钥匙(sessionId ?? utility:<label>)、以及那两条记账后果
services.tsprovider / 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.tsSessionManager 的薄封装,落盘的是完整形状。落盘失败一律不拦对话,只回 false / null + 记一条日志(swallow()),见下
serialize.ts跨 IPC 的事件序列化 + ApprovalRelay
events.ts进程级事件观测口(方案 61):总线本体 + 唯一那个单例。发布点不在这儿,在 agent-session.ts 的两处 yield 之前。⚠️ 它不做投影 —— 和 serialize.ts 的关系写在它的文件头
mcp-admin.tsMCP 运维门面(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。

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

装完之后用 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 文件头。

⚠️ 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。

buildRuntime({ settingsPath }) 对应第 ⑤ 层;不给时回退到 EPOCH_SETTINGS 环境变量 —— CLI 的 --settings <file> 走的正是这条路,因为无参进 TUI 时 TUI 是另一个进程,只有环境变量带得过去。这一层不过信任闸门:它是用户在 命令行上显式给的路径,不是仓库里躺着的文件。这份文件不是合法 JSON 时抛 SettingsFileError 拒绝启动(其余层坏了只记一条 issue),理由见 core README。

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)

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)

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 的文件头,这儿只列结论):

  • 一个进程一份: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.permissionssessionFactory.get(id).permissions
plan 模式(PlanControl)rt.plansessionFactory.get(id).plan

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

引导会话那三份就是进程那三份本体,不是副本 —— 所以 TUI 的 /model / /permission、CLI 的 --model 行为一个字节没变,而「两份要同步」这件事在这个 形状下说不出口。三样各自的落法和判据在 src/session-model.ts / src/session-guard.ts / 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) 要么走就地改写,要么走第二行那条「读原文 → 整份写回」,而后者意味着把整份 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 的文件头和 EMBEDDING.md §9.5。

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 同一档,都是可信代码;浏览器不是(那条路刻意关着, 也不会开:让一张网页决定「哪个目录的文件能当指令读」是这个仓库最贵的开关)
  • 它不是权限开关。 信任管「文件算不算指令」,权限管「操作要不要问用户」—— 两套机制刻意正交,被拒绝的目录里 agent 照样能跑 bash
  • 它不是工作区绑定的前置。 绑一个被拒绝的目录是合法的,只是指令文件不加载

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

要预置模型 / MCP / 已知工作区,用 hostPreset,别自己写我们的文件 (方案 54 §六)。以前宿主只能逐个手写 models.json / config.yaml / mcp.json / workspaces.json,而那几份的格式一份都没对外承诺过。四条语义(在 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 不是一件事: 那一个装配前写用户的文件(缺失才写、用户改得动),这一个是这一程的运行期事实, 一个字节都不落盘,用户在自己的目录里找不到它,也没有地方能删掉它。

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 的文件头):

  • 命名空间是强制的:前缀由装配层按 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 口子要自己那一套鉴权设计,不是这一版的事。

开发

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 一次都不命中
cd packages/runtime
pnpm exec tsx scripts/probe-tool-tokens.mts
pnpm exec tsx scripts/probe-anthropic-cache.mts

版本与发布记录

@epoch-agent/runtime 共发布 57 个版本,首次发布于 2026/08/19;过去 12 个月发布了 57 个版本。

版本发布日期距今
6.2.0latest2026/11/13今天
6.1.02026/11/112 天前
6.0.02026/11/112 天前
5.0.02026/11/103 天前
4.1.12026/11/067 天前
4.1.02026/11/067 天前

关于 @epoch-agent/runtime 的常见问题

@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 和提交记录判断。