@epoch-agent/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +219 -0
- package/README.md +470 -0
- package/dist/index.d.ts +5652 -0
- package/dist/index.js +5548 -0
- package/package.json +83 -0
package/README.md
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
# @epoch-agent/runtime
|
|
2
|
+
|
|
3
|
+
组装层(composition root)。把 [core](../core) 的服务 + 五个 plugin 装配成一个
|
|
4
|
+
可跑的 agent。**嵌入方要装的就是这个包**,不是 core。
|
|
5
|
+
|
|
6
|
+
- ✅ **做**:`buildRuntime()` 装配、`AgentSession`(审批桥 / 历史累积 / 持久化)、
|
|
7
|
+
会话回放、MCP 运维门面、telemetry 接线
|
|
8
|
+
- ❌ **不做**:领域逻辑(在 [core](../core))、命令解析(在 [cli](../cli))、
|
|
9
|
+
渲染(在 [tui](../tui) / [web](../web))
|
|
10
|
+
- **依赖**:[protocol](../protocol) + [infra](../infra) + [core](../core) +
|
|
11
|
+
[plugin-file](../plugins/plugin-file) / [plugin-terminal](../plugins/plugin-terminal) /
|
|
12
|
+
[plugin-web](../plugins/plugin-web) / [plugin-mcp](../plugins/plugin-mcp) /
|
|
13
|
+
[plugin-lsp](../plugins/plugin-lsp)
|
|
14
|
+
|
|
15
|
+
最小用法——宿主不用关心阻塞审批、历史拼接、落盘:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { buildRuntime } from '@epoch-agent/runtime';
|
|
19
|
+
|
|
20
|
+
const runtime = await buildRuntime({ workDir: process.cwd() });
|
|
21
|
+
for await (const event of runtime.session.run('把 README 里过时的命令改掉')) {
|
|
22
|
+
// event 是 protocol 的 AgentEvent
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
完整接法(含 Electron 跨进程审批)见 [docs/EMBEDDING.md](../../docs/EMBEDDING.md)。
|
|
27
|
+
|
|
28
|
+
## 导出
|
|
29
|
+
|
|
30
|
+
| 导出 | 用途 |
|
|
31
|
+
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| `buildRuntime()` / `EpochRuntime` | 装配入口 |
|
|
33
|
+
| `ensureSecretsReady()` | 不建整个 runtime 时单独把凭据存储备好 |
|
|
34
|
+
| `AgentSession` | 宿主友好的会话:审批转事件 + 多轮历史 + 持久化 |
|
|
35
|
+
| `EpochRuntime.sessionFactory` / `SessionFactory` / `LiveSession` | 一个进程里再开一个会话(方案 30 §6.3)。作用域那张表见下 |
|
|
36
|
+
| `SessionStore` / `StoredMessage` / `TurnDetail` | 回放旧会话(刷新页面 / 会话详情),给的是完整 `EpochMessage` |
|
|
37
|
+
| `EpochRuntime.checkpoints` / `RewindScope` | 列出检查点、预览回退、回退(文件 / 对话 / 两者)。快照本身由引擎做。**这一份是引导会话的**,别的会话问 `LiveSession.checkpoints` |
|
|
38
|
+
| `buildServices()` / `Services` | 只要服务层、不要 AgentLoop 时用 |
|
|
39
|
+
| `wireCommands()` / `CommandControl` / `withToolScope()` | 自定义斜杠命令:命令表 + 展开 + 按轮次收窄工具表 |
|
|
40
|
+
| `withModelScope()` / `ModelTurnScope` | 命令 frontmatter 的 `model:` 只在这一轮生效,收尾无条件还原 |
|
|
41
|
+
| `withRoleScope()` / `RoleControl` / `LiveSession.roles` | **这一条消息**用哪个专家(方案 57)。名字认不认得问 `has()`,换人问 `enterTurn()`,收尾无条件还原 |
|
|
42
|
+
| `ApprovalRelay` / `serializeStream()` / `toSerializable()` | 审批 UI 在另一个进程时,把事件里过不去 IPC 的闭包换成 `requestId` |
|
|
43
|
+
| `registerLocalTools()` / `registerMcpTools()` / `McpServerBatch` | 单独装工具(自定义宿主)。⚠️ `registerMcpTools` 的第四个参数收的是**批次数组**(宿主注入的 / 插件带的),再来一批就加一个元素,**别加第五个参数** |
|
|
44
|
+
| `listMcpServers()` / `mcpLogin()` / `mcpLogout()` / `probeMcpServer()` | `epoch mcp` 的后端,不带 UI |
|
|
45
|
+
| `EpochRuntime.mcp` / `McpControl` | MCP 的**写口**,照 `WorkspaceControl` 收窄过:`reconnect()`(重连一台)+ `add()`(加一台并落盘)+ `readConfig()` / `writeConfig()` / `applyConfig()`(那份文件的原文读写与生效,2026-08-18)。⚠️ **`McpRegistry` 刻意不交出去**,加能力是在这上面加方法,见下 |
|
|
46
|
+
| `writeMcpServer()` / `McpAddInput` | 往用户手写的 `~/.epoch/mcp.json` 里**插一段**(注释 / 键序 / 缩进一个字节不动)。不带连接,也不带 UI |
|
|
47
|
+
| `readMcpConfigFile()` / `writeMcpConfigFile()` / `mcpConfigRevision()` | 那份文件的**整份**读写(2026-08-18):原文进、原文出,带一个内容指纹防覆盖。⚠️ 和上一条不是一条路 —— 那条写的是**我们生成的**内容,这条写的是**用户敲的**字节,见下 |
|
|
48
|
+
| `applyMcpConfig()` / `McpApplyOutcome` | 把盘上那份**搬进这个进程**(2026-08-18):该断的断、该连的连、没变的一个字节不动。⚠️ 只动 `source === 'user'` 那批,见下 |
|
|
49
|
+
| `EpochRuntime.workspace` / `wireWorkspace()` / `splitDirList()` | **引导会话**的地盘(主根 + `--add-dir`)。要判路径在不在范围内问它 |
|
|
50
|
+
| `EpochRuntime.workspaces` / `WorkspaceControl` / `SessionWorkspaces` | 每会话绑一个工作区(决定 18):绑 / 查 / 解绑 + 「已知工作区」清单。`bind()` 收一个可选的 `lang`(方案 58 PR-2)—— 只影响失败那句 `detail`,`reason` 是契约 |
|
|
51
|
+
| `EpochRuntime.skillImport` / `SkillControl` | 从**本机目录**导入技能到 `~/.epoch/skills/`(方案 42 §六):`preview()` 看一眼、`import(source, token)` 真写。两者都收一个可选的 `lang`(方案 58 PR-2)。⚠️ **收路径不收字节**,见下 |
|
|
52
|
+
| `EpochRuntime.roleWrite` / `RoleWriteControl` | **建一个身份**,落在 `~/.epoch/agents/<name>.md`(2026-08-18)。只有 `add()` —— 没有删也没有改。⚠️ 建完**当场重载这个进程的角色表**;⚠️ 那道「回环绑定才给写」的闸门**不在这一层**,见下 |
|
|
53
|
+
| `EpochRuntime.trusted` | 这次装配**实际**按不按信任在跑。别在宿主侧重查一遍信任表,见下 |
|
|
54
|
+
| `EpochRuntime.trust` | 信任记录的读**与写**:`check` / `record` / `list` / `revoke`,见下 |
|
|
55
|
+
| `EpochRuntime.homeDir` / `.artifactsRoot` | 这次生效的数据目录 + artifact 根目录。要路径读它,别自己 `join` |
|
|
56
|
+
| `buildRuntime({ hostPreset })` / `HostPreset` | 装配前替宿主把 `config.yaml` / `mcp.json` / `workspaces.json` 落到位。**缺失才写**,见下 |
|
|
57
|
+
| `buildRuntime({ hostCapabilities })` / `HostCapabilities` / `HostAgentRole` | 宿主注入自己 ship 的**专家 / 技能 / MCP server**。前缀由装配层按 `namespace` 拼(⚠️ server 那一样是 `__` 不是 `:`),**不落盘**、不进 `epoch plugin list`,见下 |
|
|
58
|
+
| `EpochRuntime.schedules` / `ScheduleControl` | 定时任务的宿主出口:增删改查 + `fire()` + `capability()`。**没给 `scheduleRunner` 就拒绝注册进 OS**,见下 |
|
|
59
|
+
| `fireSchedule()` / `FireResult` | 执行器本体。`epoch schedule fire` 和宿主的 `headless.js` 跑的是**同一段**,见 [AUTOMATION.md](../../docs/AUTOMATION.md) |
|
|
60
|
+
| `AgentRoleError` | `--agent` 给了不认识的角色名。按 `err.name` 认,不用 `instanceof` |
|
|
61
|
+
| `listBackgroundTasks(sessionId)` / `backgroundTaskOutput(sessionId, id, since?)` | 后台任务表(从 plugin-terminal 借道,CLI / TUI / server 都不许直接 import 它)。⚠️ **第一个参数是会话 id**,见下 |
|
|
62
|
+
| `EpochRuntime.diagnosticList` / `.diagnosticsIn(lang)` | 启动诊断。前者是**进程语言**那个快照(CLI / `epoch doctor` 走它),后者按指定语言重渲染 `detail`(方案 58)—— **同一个 sink 的两个投影**,`module` / `code` / `status` 逐项相同 |
|
|
63
|
+
| `SESSION_SEARCH_MODULE` | 会话检索那两条启动诊断的 `module`。**是契约不是文案**,所以是常量不是 `t()`(方案 58 §1.5 第 1 条修的就是它) |
|
|
64
|
+
|
|
65
|
+
## 文件
|
|
66
|
+
|
|
67
|
+
| 文件 | 职责 |
|
|
68
|
+
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
69
|
+
| `build.ts` | `buildRuntime()`。显式手写装配,不用 DI 容器 |
|
|
70
|
+
| `host-preset.ts` | 宿主预置(方案 54 §六):装配前落文件。四条语义写在它的文件头,别在别处再判一次 |
|
|
71
|
+
| `host-capabilities.ts` | 宿主注入的能力清单(方案 44 §2.1):校验 + 拼前缀。**和上一行不是一件事** —— 那个写用户的文件,这个一个字节都不落盘 |
|
|
72
|
+
| `plugin-mcp-servers.ts` | 已装插件根下那份 `mcp.json`(方案 44 PR-3):读路径 → 拼 `<插件名>__` 前缀。core 只交路径(它不许 import `plugin-mcp`),解析在这儿 |
|
|
73
|
+
| `session-factory.ts` | 多会话工厂:**全仓唯一一处** `new AgentLoop` + `new AgentSession`,外加审批/提问的路由 |
|
|
74
|
+
| `services.ts` | provider / session / memory / skill / hook / 权限 / 上下文 / 沙箱;设置分层的合并点也在这儿 |
|
|
75
|
+
| `tools.ts` | **编译期**插件(`EpochPlugin`)+ 内置工具 + MCP 全部进同一个 `ToolRegistry` |
|
|
76
|
+
| `commands.ts` | 自定义斜杠命令的装配:加载命令表、包一层可按轮次收窄的工具 provider |
|
|
77
|
+
| `workspace.ts` | `--add-dir` 落地(目录体检 → 告知权限层 → 进启动诊断)+ 每会话的工作区绑定与信任判定 |
|
|
78
|
+
| `agent-session.ts` | 审批回调 → 流事件、历史累积、逐轮持久化、**划检查点的「一轮」** |
|
|
79
|
+
| `session-store.ts` | `SessionManager` 的薄封装,落盘的是完整形状 |
|
|
80
|
+
| `serialize.ts` | 跨 IPC 的事件序列化 + `ApprovalRelay` |
|
|
81
|
+
| `mcp-admin.ts` | MCP 运维门面(`cli` 不许直接依赖 `plugin-mcp`,所以从这儿转) |
|
|
82
|
+
| `mcp-config-write.ts` | 往用户手写的 `~/.epoch/mcp.json` 里**插一段**(`POST /api/mcp` 走它):注释 / 键序 / 缩进一个字节不动。判据同 `infra/src/config-yaml.ts` |
|
|
83
|
+
| `mcp-config-file.ts` | 那份文件的**整份**读写(2026-08-18):原文进、原文出 + 内容指纹 + 换行符跟着盘上那份走。⚠️ 和上一条**不冲突**,主语不同,见下 |
|
|
84
|
+
| `mcp-config-apply.ts` | 把盘上那份搬进这个进程:四档动作(新增 / 重连 / 没动 / 断开)+ 「只动用户那批」 |
|
|
85
|
+
| `telemetry/` | OTel / 文件两种 sink,`@opentelemetry/*` 是**可选** peer |
|
|
86
|
+
|
|
87
|
+
## 设置分层在这儿落地
|
|
88
|
+
|
|
89
|
+
`loadConfig()` 只解析到**用户级**(`~/.epoch/config.yaml`)。项目级
|
|
90
|
+
`<项目根>/.epoch/settings.json` / `settings.local.json` 要先判完工作区信任才敢读,
|
|
91
|
+
而信任判定就发生在 `buildServices()` 里 —— 所以合并点只能在这儿,
|
|
92
|
+
夹在 `buildTrust()` 之后、`new ProviderRouter(config)` 之前(router 一构造就把
|
|
93
|
+
`config.model` 定死了)。分层的规则、优先级和信任闸门的不对称写在
|
|
94
|
+
[docs/SETTINGS.md](../../docs/SETTINGS.md)。
|
|
95
|
+
|
|
96
|
+
对宿主来说只有一句话要记:
|
|
97
|
+
|
|
98
|
+
> **装完之后用 `services.config`,不要再用你传进去的那份。** `model` / `maxTurns` /
|
|
99
|
+
> `permissions` 都可能已经被项目级或 `--settings` 覆盖过了。`buildRuntime()` 已经
|
|
100
|
+
> 这么做了,`rt.config` 就是合并后那份。
|
|
101
|
+
|
|
102
|
+
合并把来源拍平了,而「这个值是谁定的」正是用户查配置问题时的第一个问题。所以
|
|
103
|
+
**`rt.settings` 把它反查回来**:每一项带上它赢在哪一层,`permissions` / `model` /
|
|
104
|
+
`maxTurns` 三个能被项目级覆盖的键另带一条六层链(低 → 高,标出哪一层赢了、
|
|
105
|
+
被压住的层各自是什么值)。① 内置默认和 ② 用户级那一刀由 `getConfigOrigins()` 切 ——
|
|
106
|
+
分不出的话,一个用户从没配过的键会被标成「② 用户级」,而那是个假答案。
|
|
107
|
+
|
|
108
|
+
写那一半 2026-08-15 补上了(`rt.settings.write()`):一次写一个键、只写 ②
|
|
109
|
+
用户级和 ④ 项目本地、回执带 `apply: 'restart'`。三件保存语义的判据全文在 core 的
|
|
110
|
+
[config/write.ts](../core/src/config/write.ts) 文件头。
|
|
111
|
+
|
|
112
|
+
> ⚠️ **`rows()` 和 `write()` 都收一个可选的 `sessionId`**,而那不是一个可选的
|
|
113
|
+
> 精细化。多会话之后 ③④ 跟着**工作区**走,而工作区一个会话绑一个(决定 18)——
|
|
114
|
+
> 写目标文件(`.epoch/settings.local.json` 在哪个仓库)和信任判定(未信任时 ④ 里
|
|
115
|
+
> 的标量整份作废)必须来自**同一个**工作区。只改其中一个的表现是「拿 A 工作区的
|
|
116
|
+
> 信任判定去决定往 B 工作区写」,而那在任何一屏上都看不出来。所以两样由同一个
|
|
117
|
+
> 函数(`makeSettingsScope`)一次取齐 —— 不给 `sessionId` 就是引导会话那一份,
|
|
118
|
+
> 单会话宿主的行为逐字不变。
|
|
119
|
+
|
|
120
|
+
第 ①.5 层(插件)也在这儿落地:`buildServices()` 一进门就 `loadPlugins()`,
|
|
121
|
+
把已装插件的命令目录 / 角色 / 技能目录 / `hooks.json` / `settings.json` / `mcp.json`
|
|
122
|
+
收进 `services.plugins`,再分别喂给命令表、角色注册表、`SkillSystem`、hook 来源和
|
|
123
|
+
`resolveSettings`。⚠️ **`mcpPaths` 那一样不在 `services.ts` 里接** —— 读那份文件要用
|
|
124
|
+
`plugin-mcp` 的解析器,所以它和 MCP 装配的其余部分一起留在 `tools.ts` /
|
|
125
|
+
`plugin-mcp-servers.ts` 那一侧(`build.ts` 里 `registerMcpTools(...)` 那一行)。**插件那一层只有 `permissions.deny` 会被采纳**,
|
|
126
|
+
不过工作区信任闸门(它不来自仓库)——
|
|
127
|
+
见 [docs/PLUGINS.md](../../docs/PLUGINS.md)。
|
|
128
|
+
|
|
129
|
+
`buildRuntime({ settingsPath })` 对应第 ⑤ 层;不给时回退到 `EPOCH_SETTINGS`
|
|
130
|
+
环境变量 —— CLI 的 `--settings <file>` 走的正是这条路,因为无参进 TUI 时
|
|
131
|
+
TUI 是**另一个进程**,只有环境变量带得过去。这一层**不过信任闸门**:它是用户在
|
|
132
|
+
命令行上显式给的路径,不是仓库里躺着的文件。**这份文件不是合法 JSON 时抛
|
|
133
|
+
`SettingsFileError` 拒绝启动**(其余层坏了只记一条 issue),理由见
|
|
134
|
+
[core README](../core#几个刻意的取舍)。
|
|
135
|
+
|
|
136
|
+
`addDir` / `agent` 是同一个形状:不给时分别回退到 `EPOCH_ADD_DIR`
|
|
137
|
+
(按 `path.delimiter` 分隔,用 `splitDirList()` 拆)和 `EPOCH_AGENT`。
|
|
138
|
+
加新参数时按「TUI 那条路带不带得过去」判要不要落一个环境变量。
|
|
139
|
+
|
|
140
|
+
第 ∞ 层(企业托管)也在这儿落地,三处:`services.managedPolicy` 是读出来的结论
|
|
141
|
+
(**永远非 null**,没有托管文件时是三个开关全 false);`allowManagedHooksOnly`
|
|
142
|
+
打开时用户级和项目级 hook 都不加载(`loadHookSources()` 整个跳过,压过项目信任);
|
|
143
|
+
`disableBypassPermissionsMode`
|
|
144
|
+
打开且 `permission: bypass` 时**抛 `ManagedPolicyError` 拒绝启动** ——
|
|
145
|
+
这一步排在任何资源分配之前,否则每次拒绝都会漏一串 SQLite 连接
|
|
146
|
+
(`cleanups` 要等 `buildServices` 正常返回才交得出去)。
|
|
147
|
+
|
|
148
|
+
> `buildRuntime({ managedSettingsPath })` **只给用例和嵌入宿主**。它没有对应的
|
|
149
|
+
> 环境变量或命令行参数,理由见根 README:有的话一句 `EPOCH_MANAGED=` 就能把
|
|
150
|
+
> IT 下发的策略整份关掉。
|
|
151
|
+
|
|
152
|
+
## 一个进程里开多个会话(方案 30 §6.3)
|
|
153
|
+
|
|
154
|
+
`rt.session` 是**引导会话**;再开一个走 `rt.sessionFactory.create({ workspace })`。
|
|
155
|
+
单会话宿主(CLI / TUI)手里只有前者,那正是它留着的理由。
|
|
156
|
+
|
|
157
|
+
### 把一段**盘上还在、这个进程手里没有**的会话接回来(2026-08-19)
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
rt.sessionFactory.revive({ sessionId, decision: { state: 'bound', root } });
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
内部就是 `create()` 那条路上同一段 `assemble()`,两段处置也原样复用:
|
|
164
|
+
**绑不上就整个不建**、**装不起来就把这一次加的那一笔绑定放下**(不留幽灵 id)。
|
|
165
|
+
`hub.register()` 归调用方 —— 这一层不知道 Hub 的存在。
|
|
166
|
+
|
|
167
|
+
⚠️ **历史不从外面递进来**,虽然方案原稿是 `revive(sessionId, history, decision)`:
|
|
168
|
+
模型看的那种历史是 core 的类型而服务端不许 import core,而且 `assemble()` 已经给
|
|
169
|
+
这个会话建了一份 `SessionStore` —— 从外面再开一个就是第二个真源。所以历史在装配
|
|
170
|
+
之后用 `AgentSession.reloadHistory()` 接上,那也是 `/resume` 走的同一个方法。
|
|
171
|
+
|
|
172
|
+
⚠️ **角色接不回来**:`sessions` 表没有角色那一列,所以一段用 `explore` 建的会话
|
|
173
|
+
复活之后跑的是这个进程的 `--agent`。**如实记着,这是一笔债不是一个判断。**
|
|
174
|
+
|
|
175
|
+
⚠️ **`decision` 由调用方从库里读出来给,这一层不猜。** 没有决定可循时回
|
|
176
|
+
`{ ok: false, reason: 'no-decision' }`,而不是回落到「就绑进程启动目录」——
|
|
177
|
+
那会把一段「明确不使用工作区」的会话悄悄绑进服务进程的 cwd
|
|
178
|
+
(会话库 v7 那两列的由来,判据全文在 `ReviveSessionOutcome` 上)。
|
|
179
|
+
|
|
180
|
+
⚠️ **和 CLI 的 `/resume` 是两件事,别合并。** `/resume` 是「把另一段对话接上
|
|
181
|
+
**这个**引擎」,所以 `build.ts` 那行 `workspaces.seed()` 让它跑在这个进程的工作区
|
|
182
|
+
里;`revive()` 是「把**那段会话本身**装起来」,跑在它自己当初选的地盘上。
|
|
183
|
+
两处 JSDoc 互指。
|
|
184
|
+
|
|
185
|
+
### 这个会话用哪个专家跑(2026-08-15)
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
rt.sessionFactory.create({ workspace, reuse: false, role: 'explore' });
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
在这之前角色只有两条来路:`delegate_task` 派出去的子 loop,和 `--agent <role>`
|
|
192
|
+
(**进程级**,所有会话共用一个)。这一条补的是**会话级**那一档 —— 装配层在
|
|
193
|
+
`assemble()` 里对它走一次角色收窄,和 `--agent` 完全同一条路。
|
|
194
|
+
|
|
195
|
+
> **2026-08-17:那一次收窄换成了一个槽**(方案 57 §3.2 的 `createRoleScope`)。
|
|
196
|
+
> 会话级角色是槽的**默认值**,行为逐字不变;变的是形状 —— 工具表和身份行
|
|
197
|
+
> 从此读同一个变量,所以「工具按 explore 收窄了、prompt 里写着 build」
|
|
198
|
+
> 这种状态不存在。槽的 `enter()` 目前**没有调用方**,逐条专家的接线还没落地。
|
|
199
|
+
|
|
200
|
+
三件要知道的事:
|
|
201
|
+
|
|
202
|
+
- **认不出这个名字就是拒绝**(`{ ok: false, reason: 'unknown-role', detail }`),
|
|
203
|
+
不静默降级成不分角色。判据照 `--agent` 的 `AgentRoleError`:用户显式点了一个
|
|
204
|
+
专家,给他跑一个能力范围不同的 agent 是最坏的结果
|
|
205
|
+
- **不给这个字段时回落到 `--agent`**,也就是这个字段出现之前的行为
|
|
206
|
+
- **`reuse: true` 复用回来的会话不换角色。** 角色同时决定 system prompt 的身份行
|
|
207
|
+
和工具表,而 system prompt 是 prompt cache 的前缀 —— 中途换掉等于让同一段历史里
|
|
208
|
+
前后两轮的身份声明互相打架。要另一个专家就是**另一个会话**(`reuse: false`)
|
|
209
|
+
|
|
210
|
+
顶层会话那份角色带着 `scope: 'session'`(`protocol` 的 `AgentRoleScope`),
|
|
211
|
+
于是它拿到的身份行是「这一次以「x」的身份工作」而不是子 agent 那句
|
|
212
|
+
「由主智能体派来完成一个独立子任务」—— 后者对顶层会话从 `--agent` 落地那天起
|
|
213
|
+
就是一句假话。
|
|
214
|
+
|
|
215
|
+
作用域怎么分(**完整那张表在
|
|
216
|
+
[session-factory.ts](src/session-factory.ts) 的文件头,这儿只列结论**):
|
|
217
|
+
|
|
218
|
+
- **一个进程一份**:provider / SQLite / 记忆 / 技能 / hook / `ToolRegistry` + MCP /
|
|
219
|
+
`BudgetStore` / 工作区绑定表
|
|
220
|
+
- **一个会话一份**:`AgentSession` + 它的审批桥、`AgentLoop`(`workDir` 与
|
|
221
|
+
`projectContext` 跟着绑定的工作区走)、`CheckpointManager`、`SessionStore`
|
|
222
|
+
|
|
223
|
+
> ⚠️ **「一个会话一份」的东西,取数也得按会话取。** `LiveSession.checkpoints`
|
|
224
|
+
> (2026-08-15 加)就是这条:`EpochRuntime.checkpoints` 是**引导会话**那一份,
|
|
225
|
+
> 拿它去答别的会话的表现是列表恒空、回退恒被拒 —— 而这个进程正握着对方的
|
|
226
|
+
> `CheckpointManager`。web 上那三条回退端点在这个字段之前逐字就是这么错的
|
|
227
|
+
> (方案 30 §9.5)。同理 `settings.rows(sessionId)` 收 id、
|
|
228
|
+
> `workspaces.of(sessionId)` 收 id。
|
|
229
|
+
|
|
230
|
+
⚠️ **模型 / 权限档 / plan 模式三样 2026-08-16 也收窄成了会话级**(方案 30 §十四):
|
|
231
|
+
|
|
232
|
+
| 拿哪一份 | 引导会话 | 别的会话 |
|
|
233
|
+
| ------------------------------ | ------------------------ | ------------------------------------ |
|
|
234
|
+
| 模型(`ModelControl`) | `rt.model`(进程那一份) | `sessionFactory.get(id).model` |
|
|
235
|
+
| 权限档(`level` / `setLevel`) | `rt.permissions` | `sessionFactory.get(id).permissions` |
|
|
236
|
+
| plan 模式(`PlanControl`) | `rt.plan` | `sessionFactory.get(id).plan` |
|
|
237
|
+
|
|
238
|
+
**引导会话那三份就是进程那三份本体,不是副本** —— 所以 TUI 的 `/model` /
|
|
239
|
+
`/permission`、CLI 的 `--model` 行为一个字节没变,而「两份要同步」这件事在这个
|
|
240
|
+
形状下说不出口。三样各自的落法和判据在
|
|
241
|
+
[src/session-model.ts](src/session-model.ts) /
|
|
242
|
+
[src/session-guard.ts](src/session-guard.ts) /
|
|
243
|
+
[core/src/permission/shared.ts](../core/src/permission/shared.ts) 的文件头。
|
|
244
|
+
|
|
245
|
+
⚠️ 分家的**只有档位那一个字段**:规则表、审批缓存、审计流水、沙箱隔离、
|
|
246
|
+
额外根、headless 白名单全进程共用同一份引用(`PermissionManager.scoped()`)。
|
|
247
|
+
它们是关于**用户**和**这台机器**的事实,跟着会话走反而会说假话。
|
|
248
|
+
|
|
249
|
+
⚠️ **一样这一轮仍然没有收窄,是已知限制**:**`--add-dir` 的额外根是进程级的。**
|
|
250
|
+
新绑的工作区没有额外根,但 `PermissionManager.getExtraRoots()` 仍然报引导会话
|
|
251
|
+
那一份。它的**理由 2026-08-16 换了一个**:档位分家之后它技术上随时能搬到会话
|
|
252
|
+
那一格,但「新会话该不该继承 `--add-dir` 的地盘」是一次产品判断,不是接线问题。
|
|
253
|
+
|
|
254
|
+
并发跑两轮时**预算和用量不用额外处理**:`BudgetGuard` 每轮新建且带
|
|
255
|
+
`sessionId`,`BudgetStore` 按 sessionId 分格且是同步读-改-写。所以
|
|
256
|
+
`rt.usageScope` 那两个口径在多会话下逐字仍然成立。
|
|
257
|
+
|
|
258
|
+
## 导入技能:**收路径,不收字节**(方案 42 §六)
|
|
259
|
+
|
|
260
|
+
`EpochRuntime.skillImport` 转出去的是**两个函数**,不是 `SkillSystem` ——
|
|
261
|
+
后者上有六个写方法、一个会 `matchCount++` 的 `view()`、还有一个会**自动删**
|
|
262
|
+
低效技能的 `maintain()`。交出去等于让网线那一侧够得着「删掉用户的技能」
|
|
263
|
+
和「污染那本喂给模型的评分」。收窄之后 `@epoch-agent/server` 那个包**写不出**
|
|
264
|
+
别的动作 —— 那不是靠 review 盯住的,是编译期的事(同 `McpControl` 只露 reconnect)。
|
|
265
|
+
|
|
266
|
+
⚠️ **两个动作都只收一条服务端本机路径。** 判据全文在 core 的
|
|
267
|
+
`skill/import.ts` 文件头,一句话:用户级技能目录**连信任闸门都没有**,
|
|
268
|
+
往它里面落一份文件 = 让那段文字无条件进往后每一轮的 system prompt;
|
|
269
|
+
而 `epoch web --host 0.0.0.0` 那一档下,浏览器可能不在用户自己那台机器上。
|
|
270
|
+
|
|
271
|
+
**哪天真要做上传,不是在这儿加一个 `bytes` 参数** —— 那会把上面那条性质
|
|
272
|
+
一声不吭地删掉。回去读那个文件头最后一节。
|
|
273
|
+
|
|
274
|
+
嵌入宿主要注意:这条路的落点**永远是 `SkillSystem` 自己那个用户级目录**,
|
|
275
|
+
调用方给不了。让宿主传一个目录进来,它就成了「往任意目录写文件」的口子。
|
|
276
|
+
|
|
277
|
+
## 改 `mcp.json`:**两条路,主语不同**(2026-08-18)
|
|
278
|
+
|
|
279
|
+
这一层有两条写 `~/.epoch/mcp.json` 的路,而它们**不许互相取代**:
|
|
280
|
+
|
|
281
|
+
| 路 | 写下去的是 | 做法 |
|
|
282
|
+
| ------------------------------------------- | ------------------ | ------------------------ |
|
|
283
|
+
| `writeMcpServer()`(`mcp-config-write`) | **我们生成的**一段 | 只插入,一个已有字节不碰 |
|
|
284
|
+
| `writeMcpConfigFile()`(`mcp-config-file`) | **用户敲的**字节 | 整份覆盖 |
|
|
285
|
+
|
|
286
|
+
`mcp-config-write.ts` 的文件头逐字判掉了「读进来 → 改 → 整份写回」:我们读进来的
|
|
287
|
+
不是原文(`parseMcpConfig` 吐的是归一化之后的结构),整份写回会把 `connect_timeout`
|
|
288
|
+
改写成 `connectTimeout`、把用户没写的 `streamable` 物化成一行、把未知键删掉 ——
|
|
289
|
+
那是**改写他配置的语义**。
|
|
290
|
+
|
|
291
|
+
那条判据**只管第一行**。第二行上根本没有 parse → serialize 这一步:字节进、字节出,
|
|
292
|
+
所以注释 / 空行 / 键序 / snake_case 键名 / 未知键全都原样活着。⚠️ 别把它读成
|
|
293
|
+
「那条判据过期了」,也别拿它去简化第一行。
|
|
294
|
+
|
|
295
|
+
三件这一层管、上面那层管不了的事:
|
|
296
|
+
|
|
297
|
+
1. **指纹防覆盖**。这份文件另一个编辑器一直开着(用户的 vim、还有 `writeMcpServer`
|
|
298
|
+
自己)。读的时候发一个内容哈希,写的时候拿它和**此刻盘上那份**比,对不上整段
|
|
299
|
+
拒绝。⚠️ 是**内容哈希不是 mtime**:后者在「写回同样的内容」和「同一毫秒两次写」
|
|
300
|
+
两档下都会骗人;
|
|
301
|
+
2. **换行符跟着盘上那份走**。浏览器的 `<textarea>` 只会吐 `\n`,而 CRLF 的
|
|
302
|
+
`mcp.json` 在 Windows 上是常态(Windows 是支持平台)—— 不转回去的话,用户在
|
|
303
|
+
自己的编辑器 / `git diff` 里看到的是**整个文件都变了**;
|
|
304
|
+
3. **权限位**。已经在的那份沿用它自己的(用户可能刻意收成 0600),新建的那份给
|
|
305
|
+
**0600** 而不是 0644 —— 这个文件天生装着 `env` 里的明文 token。
|
|
306
|
+
|
|
307
|
+
### `applyConfig()`:「改完 `mcp.json` 要重启」那句话的出路
|
|
308
|
+
|
|
309
|
+
`reconnect()` 走的是已经建好的那个 client,它手里那份配置是**启动时**读的 ——
|
|
310
|
+
所以那一下治的是「对面重启过 / 网线抖了一下」,不是「我刚改了配置」。
|
|
311
|
+
`applyConfig()` 才是后者:读盘 → 和进程里那几台 diff → 该断的断、该连的连、
|
|
312
|
+
**没变的一个字节不动** → 按台换工具表(走 `ToolSync` 那条**同一条队**)。
|
|
313
|
+
|
|
314
|
+
- ⚠️ **只动 `source === 'user'` 那批。** 宿主注入的和插件带来的两批在这份文件里
|
|
315
|
+
天生缺席,把它们算进「盘上没了 = 该断开」等于用户存一次自己的文件就把宿主 ship
|
|
316
|
+
的那几台全断了 —— 而那批他一点办法都没有;
|
|
317
|
+
- ⚠️ **「读出来一台都没有、而且带诊断」整段拒绝**(最常见的成因是把 `servers` 写成
|
|
318
|
+
了 `mcpServers`):照着应用会把用户所有 MCP 全断掉。而「0 台且**没有**诊断」
|
|
319
|
+
(`"servers": {}`)是**照办**的 —— 那是一句清楚的「我要清空」;
|
|
320
|
+
- 它为此给 `plugin-mcp` 的 `McpRegistry` 补了两个方法(`disconnect(name)` /
|
|
321
|
+
`configOf(name)`):在那之前「让一台 server 在这个进程里消失」做不到,
|
|
322
|
+
只有 `disconnectAll()`。
|
|
323
|
+
|
|
324
|
+
## 建一个身份:**这一层不带闸门,那是刻意的**(2026-08-18)
|
|
325
|
+
|
|
326
|
+
`EpochRuntime.roleWrite` 收窄到**一个动词** `add()`,形状和上面两条同源:
|
|
327
|
+
宿主(`@epoch-agent/server`)于是**写不出**「删一个身份」「改一个身份」。
|
|
328
|
+
|
|
329
|
+
它做两件只有装配层做得了的事,落盘那一半在 core 的 `agent-role/create.ts`:
|
|
330
|
+
|
|
331
|
+
1. **撞名查的是合并之后那张角色表**,不只是那个目录 —— 漏了这一道不是「多一个
|
|
332
|
+
重名」:一份叫 `general` 的用户级身份会**静默换掉 `delegate_task` 不带 `role`
|
|
333
|
+
时的默认人选**(`mergeRoles` 的同名语义是 prompt / description 整份替换);
|
|
334
|
+
2. **建完重载角色表**。`EpochRuntime.agentRoles` 是**装配那一刻**加载出来的那张表
|
|
335
|
+
(普通属性,不是 getter —— 和 `skills` / `mcpServers` 那两个不一样),
|
|
336
|
+
不重载的话这个进程要到下次启动才认得它。
|
|
337
|
+
|
|
338
|
+
⚠️ **那道「回环绑定才给写」的闸门不在这一层,在 `@epoch-agent/server`。**
|
|
339
|
+
判据是:`lanExposed` 说的是「那个 HTTP 服务绑在哪儿」,而**直接拿 `EpochRuntime`
|
|
340
|
+
用的嵌入宿主压根没有一个 HTTP 绑定** —— 它自己就是那台机器上的进程,不是网线
|
|
341
|
+
另一头。所以宿主调 `roleWrite.add()` 不吃那道闸,那是对的。
|
|
342
|
+
完整判据(以及「照收 / 借工作区信任 / 只写项目级」那三档为什么不选)在
|
|
343
|
+
`protocol/src/wire-agent-role.ts` 的文件头。
|
|
344
|
+
|
|
345
|
+
⚠️ 反过来说:**嵌入宿主自己开一条 HTTP 路把它转出去的时候,那道闸得自己立一遍。**
|
|
346
|
+
`server` 那一份长这样:`ctx.lanExposed` 为真 → 403,**而且在读请求体之前**。
|
|
347
|
+
|
|
348
|
+
## 几个容易踩的点
|
|
349
|
+
|
|
350
|
+
**定时任务在嵌入形态下必须由宿主说「怎么唤起我」。** `buildRuntime({ scheduleRunner })`
|
|
351
|
+
不给时 `runtime.schedules.create({ register: true })` 会**拒绝注册并回一个
|
|
352
|
+
`no-host-runner`**,而不是拿 `process.execPath` + `argv[1]` 去拼 —— 在 Electron 里
|
|
353
|
+
那两个值是「Electron 的 exe + 宿主的 main.js」,注册进去等于到点了弹主窗口。
|
|
354
|
+
任务照样存得下、`fire()` 照样跑得了(宿主自己掐点),只是界面上必须说清
|
|
355
|
+
「关掉应用就不跑」。全文在
|
|
356
|
+
[schedule/control.ts](src/schedule/control.ts) 的文件头和
|
|
357
|
+
[EMBEDDING.md §9.5](../../docs/EMBEDDING.md)。
|
|
358
|
+
|
|
359
|
+
**`dispose()` 返回 `Promise`,能 `await` 就 `await`。** 它关 SQLite、销毁沙箱、
|
|
360
|
+
杀 MCP 子进程、停定时器,还要收掉 `terminal({ background: true })` 起的后台进程和
|
|
361
|
+
PTY 会话 —— 最后这项要先查整棵进程树(`pgrep` / `taskkill`)再 SIGTERM → SIGKILL,
|
|
362
|
+
只能异步。同步那批全部跑在**第一个 `await` 之前**,所以不 await 的老代码行为不变,
|
|
363
|
+
只是后台进程变成尽力而为。幂等,重复调用是空操作。
|
|
364
|
+
|
|
365
|
+
**后台任务表按会话分,所以那两个口子的第一个参数是会话 id**(2026-08-15)。
|
|
366
|
+
传的必须是**活的**那个 —— `session.sessionId`,不是装配时拍下来的 `rt.sessionId`:
|
|
367
|
+
`/resume` 会把前者换掉,拿后者去问,resume 之后数的是一个已经不存在的会话。
|
|
368
|
+
单会话宿主(CLI / TUI)写成 `rt.session?.sessionId ?? rt.sessionId` 就对了。
|
|
369
|
+
收窄的完整判据在 plugin-terminal 的 `src/background.ts` 文件头。
|
|
370
|
+
|
|
371
|
+
> ⚠️ **这条建议在 2026-08-17 之前是反的。** 那时 `rt.sessions.resume()` 里
|
|
372
|
+
> 「把任务搬到新 id 名下」那一步恒为空操作,于是 resume 之后拿**活的** id 去问
|
|
373
|
+
> 反而一条都数不到。同一处也让 `rt.sessionFactory.get(刚 resume 过去的 id)`
|
|
374
|
+
> 恒为 null。两样都修了,判据在 `src/build.ts` 的 `buildSessionControl`。
|
|
375
|
+
|
|
376
|
+
**装配是显式手写的,不用 DI 容器。** 十几个模块的依赖关系读一遍就完,引入容器只会把
|
|
377
|
+
「谁依赖谁」藏进配置里。
|
|
378
|
+
|
|
379
|
+
**每个模块独立 `try/catch`(safeInit)。** 少一个能力也比整个 agent 起不来好,失败原因
|
|
380
|
+
进 `diagnostics` 交宿主呈现——`epoch doctor` 打的就是这些。
|
|
381
|
+
|
|
382
|
+
**审批那个 race 不是可以省的。** 审批请求发生在两个事件**之间**(AgentLoop 在工具执行前
|
|
383
|
+
阻塞等答复)。只在收到事件时才排空审批队列的话,双方互等——死锁。所以必须显式 race
|
|
384
|
+
「下一个事件」和「有新审批请求」。
|
|
385
|
+
|
|
386
|
+
**要展示「信任了没」就读 `rt.trusted`,别自己 `new TrustManager().check()`。**
|
|
387
|
+
两者只在 `trust.enabled: false` 时分叉,而那一档恰好是最容易说反话的:闸门整个不启用,
|
|
388
|
+
装配走的是信任那一支(项目指令、角色、自定义命令全加载了),而信任表里一条记录都没有
|
|
389
|
+
——宿主自己查会得出「未信任」,跟运行时的真实行为正好相反。
|
|
390
|
+
|
|
391
|
+
**要给用户一个「信任这个目录」的按钮,走 `rt.trust`**(方案 54 §五)。以前这一层
|
|
392
|
+
只报告不给写,于是嵌入宿主(装 `server` + `runtime`、用户没有 CLI)**没有任何办法**
|
|
393
|
+
授予信任,项目级 `AGENTS.md` / `.epoch/` 永远不加载 —— 界面把这件事说得越清楚,
|
|
394
|
+
把死路指得越明确。三条边界:
|
|
395
|
+
|
|
396
|
+
- **`record()` 不带闸门,它就是那个决定本身** —— 宿主必须先真的问过用户。
|
|
397
|
+
判据是宿主主进程和 CLI 同一档,都是可信代码;浏览器不是(那条路刻意关着,
|
|
398
|
+
也不会开:让一张网页决定「哪个目录的文件能当指令读」是这个仓库最贵的开关)
|
|
399
|
+
- **它不是权限开关。** 信任管「文件算不算指令」,权限管「操作要不要问用户」——
|
|
400
|
+
[两套机制刻意正交](../core/src/trust/gate.ts),未信任目录里 agent 照样能跑 bash
|
|
401
|
+
- **它不是工作区绑定的前置。** 绑一个未信任目录是合法的,只是指令文件不加载
|
|
402
|
+
|
|
403
|
+
`trust` 为 `null` 只有一种情况:信任记录整份读不出来(那时闸门 fail closed)。
|
|
404
|
+
**`trust.enabled: false` 不是那种情况** —— 闸门关了不等于没有记录这回事。
|
|
405
|
+
|
|
406
|
+
**要预置模型 / provider / MCP / 已知工作区,用 `hostPreset`,别自己写我们的文件**
|
|
407
|
+
(方案 54 §六)。以前宿主只能逐个手写 `config.yaml` / `mcp.json` / `workspaces.json`,
|
|
408
|
+
而那三份的格式**一份都没对外承诺过**。四条语义(在 [host-preset.ts](src/host-preset.ts)
|
|
409
|
+
的文件头,改之前先读那一段):
|
|
410
|
+
|
|
411
|
+
- **不是新的一层** —— 等于「替你写第 ① 层那份文件」,⑤ / ∞ 照旧压得过它
|
|
412
|
+
- **缺失才写,不覆盖**,按键判。所以每次启动传同一份是对的用法
|
|
413
|
+
- **`apiKey` 给了 `secretStore` 就不落盘**,进你注入的那个存储
|
|
414
|
+
- **写了什么全在诊断里**(`module === 'HostPreset'`)。「配了但没生效」是这类东西
|
|
415
|
+
唯一的失败模式,而它在此之前完全静默
|
|
416
|
+
|
|
417
|
+
它**不**预置信任 —— 那是一个决定不是一份配置,走 `rt.trust`(上一段)。
|
|
418
|
+
|
|
419
|
+
**要把你自己 ship 的专家 / 技能 / MCP server 塞进能力清单,用 `hostCapabilities`,
|
|
420
|
+
别去写用户的 `~/.epoch/agents/` 或 `mcp.json`**(方案 44 §2.1)。它和上面那个
|
|
421
|
+
`hostPreset` **不是一件事**:那一个装配前写用户的文件(缺失才写、用户改得动),
|
|
422
|
+
这一个是这一程的运行期事实,**一个字节都不落盘**,用户在自己的目录里找不到它,
|
|
423
|
+
也没有地方能删掉它。
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
buildRuntime({
|
|
427
|
+
hostCapabilities: {
|
|
428
|
+
namespace: 'acme',
|
|
429
|
+
roles: [{ name: 'triage', description: '分拣工单,只读', tools: ['file_read'] }],
|
|
430
|
+
skillDirs: [join(app.getAppPath(), 'resources', 'skills')], // 必须绝对路径
|
|
431
|
+
mcpServers: [{ name: 'deploy', transport: 'http', url: 'http://127.0.0.1:3456/mcp' }],
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
// → 专家 `acme:triage`、技能 `acme:<分类>/<名字>`、server `acme__deploy`
|
|
435
|
+
// (工具是 `mcp__acme__deploy__<工具>`),能力页上各自单独一组,标着「宿主」
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
五条性质(全文在 [host-capabilities.ts](src/host-capabilities.ts) 的文件头):
|
|
439
|
+
|
|
440
|
+
- **命名空间是强制的**:前缀由装配层按 `namespace` 拼,宿主在 `name` 里自己写前缀
|
|
441
|
+
会被判成非法名字。不这么做的话,一个叫 `general` 的注入会顶掉内置那条,
|
|
442
|
+
而 `delegate_task` 不带 `role` 时取的就是它
|
|
443
|
+
- ⚠️ **三样的分隔符不一样**:角色 / 技能是 `<namespace>:`,MCP server 是
|
|
444
|
+
`<namespace>__` —— server 名会原样变成工具名的一部分,而 provider 硬拒带 `:`
|
|
445
|
+
的工具名(实测)。判据在 plugin-mcp 的 `McpServerStatus.source` 上
|
|
446
|
+
- **`source` 宿主写不出来**:`HostAgentRole` 上没有那个字段,`McpServerConfig`
|
|
447
|
+
上也没有,装配层填死 `'host'`
|
|
448
|
+
- **坏的那一条只跳过它自己**,各留一条诊断(`module === 'HostCapabilities'`)。
|
|
449
|
+
唯一整份作废的是 `namespace` 本身坏掉 —— 那时没有前缀
|
|
450
|
+
- **注入是装配时一次性的**,但注入进去的东西生命周期照旧:技能照样被 `SkillLearner`
|
|
451
|
+
影响(只是那个目录只读),server 照样掉线重连、照样能在能力页上按「重连」
|
|
452
|
+
|
|
453
|
+
注入的 server **装配时就真连**,并和用户 `mcp.json` 那批**共用同一份**
|
|
454
|
+
`MCP_CONNECT_BUDGET_MS`(不是两份相加)。真连是因为 `## 可用工具` 是按第一轮的
|
|
455
|
+
工具表拼进 system prompt 的;共用一份是因为那个预算存在的全部理由就是
|
|
456
|
+
「启动不能被一台连不上的 server 拖住」。和用户那批重名时**用户赢**。
|
|
457
|
+
|
|
458
|
+
**要 artifact 的路径就读 `rt.artifactsRoot`。** 它和 `rt.homeDir` 是这次装配的事实,
|
|
459
|
+
不是配置里的一个字段。2026-08-15 之前 artifact 的**写**路径不认 `homeDir`
|
|
460
|
+
(读那侧一直认),后果是宿主传了 `homeDir` 就产物 404 而全链路 200 ——
|
|
461
|
+
在宿主侧 `join(homeDir, 'artifacts')` 拼一遍,就是给同一个目录第二个说法。
|
|
462
|
+
|
|
463
|
+
## 开发
|
|
464
|
+
|
|
465
|
+
```bash
|
|
466
|
+
pnpm --filter @epoch-agent/runtime test
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`@opentelemetry/*` 是可选 peer:不装也能跑,`telemetry/otel.ts` 探测不到就退化成
|
|
470
|
+
`NOOP_TELEMETRY`。
|