@epoch-agent/server 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.
@@ -0,0 +1,3907 @@
1
+ import { Lang, WireSkillImportFailure, WireSkillImportForm, WireCapabilitiesResponse, WireSkillBodyResponse, EpochUserContent, AgentEvent, WireEnvelope, CustomCommandDef, ExpandedCommand, ModelRef, SetSelectionResult, WireCommandExpansion, WireCustomCommand, WireTurnState, WireSessionFinish, ApprovalOutcome, QuestionAnswer, ProviderType, ModelSelectionOrigin, WireModelCaveat, WireModelRejection, PermissionLevel, WireModelSource, WireModelProbeStatus, ScheduleDefinition, ScheduleRun, ScheduleBackendKind, OperationType, ScheduleTrigger, WireScheduleIssue, ScheduleRunStatus, TrustLevel, WireSandboxReason, WireAuditOutcome, WireAuditCode, WireToolVerdict, WireToolGateAxis, WireSecurityResponse, WireRewindScope, WireRewindRequest, WireCheckpointSummary, WireRewindPreview, WireRewindResponse, ActiveAgentRole, WireSettingValue, WireSettingLayer, WireSettingWriteLayer, WireSettingFutile, WireSettingApply, WireSettingsResponse, EpochConfig, WireCompressionLine, WireProviderSummary, WireToolSummary, Diagnostic, WireReplayMessage, WireFileChange, WireAuthGuard, WireWorkspaceDiffResponse, BackgroundTaskInfo, WireTasksResponse, WireBackgroundTask, WireScheduleRow } from '@epoch-agent/protocol';
2
+ import { SerializableApprovalRequest, SerializableQuestionRequest, LevelChangeResult } from '@epoch-agent/runtime';
3
+
4
+ /**
5
+ * 绑定策略 —— 「监听哪儿、给不给 token、拒不拒绝启动」。
6
+ *
7
+ * [方案 20 §6.1](../../../docs/verify/VERIFY_RECORD-20-web.md):默认只绑 `127.0.0.1`;
8
+ * `--host 0.0.0.0` 不给 token 就**拒绝启动**,不是警告后照跑。
9
+ *
10
+ * OpenCode 在没配密码时只打一句 `Warning: ... server is unsecured` 然后照跑
11
+ * (`packages/opencode/src/cli/cmd/serve.ts`)。我们不学这条:本地工具的默认值
12
+ * 必须是安全的,而「警告 + 照跑」在管道里就等于没警告。
13
+ *
14
+ * 整个文件是纯函数 —— 判定不能藏在 CLI 的 action 回调里,那种地方写不了用例,
15
+ * 而这条判定恰恰是验收第 12 / 13 条。
16
+ */
17
+ /** 默认端口。4400 是方案里举的那个,没被常见服务占用 */
18
+ declare const DEFAULT_WEB_PORT = 4400;
19
+ /** 默认监听地址 —— 只有回环。改这个默认值需要重读 §6.1 */
20
+ declare const DEFAULT_WEB_HOST = "127.0.0.1";
21
+ interface BindRequest {
22
+ /** `--host`,缺省 `127.0.0.1` */
23
+ host?: string;
24
+ /** `--port`,缺省 4400。`0` = 让内核分配(用例靠它避免端口打架) */
25
+ port?: number;
26
+ /** `--token`。非回环绑定时**必填** */
27
+ token?: string;
28
+ }
29
+ type BindDecision = {
30
+ ok: true;
31
+ host: string;
32
+ port: number;
33
+ token: string;
34
+ /** 绑在回环之外。鉴权中间件据此放宽 `Host` 白名单,见 auth.ts */
35
+ lanExposed: boolean;
36
+ } | {
37
+ ok: false;
38
+ error: string;
39
+ };
40
+ /**
41
+ * 算出这次该怎么绑。**不做任何 I/O**。
42
+ *
43
+ * 拒绝启动的唯一理由是「暴露到回环之外却没有 token」。端口非法也拒 ——
44
+ * 那属于参数拼错,不该让它一路走到 `listen()` 再抛一个 `ERR_SOCKET_BAD_PORT`。
45
+ */
46
+ declare function decideBinding(req?: BindRequest): BindDecision;
47
+ /**
48
+ * 每次启动一个新 token。
49
+ *
50
+ * 刻意**不用环境变量密码**(OpenCode 走的是 `OPENCODE_SERVER_PASSWORD` + Basic):
51
+ * 那是给远程服务设计的,本地工具不该要求用户先配一个环境变量才能用。
52
+ * 32 字节 = 256 位,base64url 之后可以直接放进 URL 而不用再转义。
53
+ */
54
+ declare function generateToken(): string;
55
+ /**
56
+ * 首屏 query 上那两个键(方案 54 §七)。
57
+ *
58
+ * **和 web 的 `src/prefs.ts` / `index.html` 逐字一致**,三份字面量由
59
+ * `packages/web/__tests__/prefs.test.ts` 钉着 —— 那边读这个文件的原文来比。
60
+ * 不能 import:`@epoch-agent/web` 是 `private: true` 的应用包,server 不依赖它
61
+ * (反过来才对:web 的产物随 server 发)。
62
+ */
63
+ declare const UI_THEME_PARAM = "theme";
64
+ declare const UI_LANG_PARAM = "lang";
65
+ /**
66
+ * 宿主给界面的主题初值。值域和界面存进 `localStorage['epoch.theme']` 的那份**相同**。
67
+ *
68
+ * `'system'` 不是「不给」—— 它是明确的第三档(跟随系统),和不传这个字段
69
+ * (用户上次自己选的那个还算数)是两件事。
70
+ */
71
+ type UiThemePref = 'system' | 'light' | 'dark';
72
+ /** 同上,对应 `localStorage['epoch.lang']`。值域跟着 protocol 的 `LANGS` 走 */
73
+ type UiLangPref = 'system' | 'zh' | 'en';
74
+ /**
75
+ * 宿主给界面的首屏初值(方案 54 §七)。
76
+ *
77
+ * ⚠️ **是初值,不是锁。** 它写进浏览器的 localStorage 之后,用户在设置页的
78
+ * 「外观」分区仍然改得动;下次启动宿主再给一次初值。刻意**没有**「强制主题、
79
+ * 界面上不许改」的开关:那是把宿主的偏好升成锁,而外观是今天用户在这张界面上
80
+ * 唯一能自己调的东西(方案 54 §七末尾 + §十一)。
81
+ */
82
+ interface UiPreset {
83
+ theme?: UiThemePref;
84
+ lang?: UiLangPref;
85
+ }
86
+ /**
87
+ * 首屏地址 —— 带 `?token=` 的那一条,用来换 cookie。
88
+ *
89
+ * @param port 必须是**实际监听**的端口(`--port 0` 时由内核分配),
90
+ * 不是命令行里那个
91
+ * @param ui 宿主给的主题 / 语言初值。不给就一个参数都不拼 ——
92
+ * URL 逐字节和以前一样(`epoch web` 走的就是这条)
93
+ */
94
+ declare function firstScreenUrl(host: string, port: number, token: string, ui?: UiPreset): string;
95
+
96
+ /**
97
+ * `mcp.json` 的**原文**那三条端点 —— `GET` / `PUT /api/mcp/config` 和
98
+ * `POST /api/mcp/config/apply`(2026-08-18)。
99
+ *
100
+ * [connectors.tsx](../../web/src/capability/connectors.tsx) 文件头那句「**「添加
101
+ * server」/「看它的配置」**:没有读写 `mcp.json` 的端点」,前一半那天由
102
+ * `POST /api/mcp` 到期,**后一半就是这三条**。
103
+ *
104
+ * ## 为什么不跟着 [mcp.ts](./mcp.ts) 住一起
105
+ *
106
+ * **刀口按题目下不按行数**(那条规矩是 `mcp.ts` 自己从 `capability.ts` 里搬出来时
107
+ * 立的):那个文件答的是「怎么动这个进程里的 MCP 连接」和「怎么往那份文件里**加
108
+ * 一台**」,而这三条答的是「怎么把**整份原文**发出去、收回来、再搬进这个进程」。
109
+ * 而且这一份自带一道闸门 + 三套结构镜像 + 十档拒绝。
110
+ *
111
+ * ## 三条端点,三个不同的作用域 —— 而这件事必须写在界面上
112
+ *
113
+ * | 端点 | 改的是 | 影响 |
114
+ * | ---------------------------- | -------------------- | ------------------------------------------------- |
115
+ * | `GET /api/mcp/config` | 什么都不改 | — |
116
+ * | `PUT /api/mcp/config` | **落盘的那份文件** | 这台机器上**以后每一个** epoch 进程 |
117
+ * | `POST /api/mcp/config/apply` | **这个进程的连接** | 这个进程里所有会话,**立刻** |
118
+ *
119
+ * ⚠️ 后两条不是同一句话,别合并(判据全文在 protocol 的 `wire-mcp-config.ts`
120
+ * 文件头第二节):一个是「盘上那份变了」,一个是「正在跑的这些变了」。**保存不
121
+ * 生效、应用才生效**,而那句话要恒画在界面上。
122
+ *
123
+ * ## ⚠️ 闸门:`ctx.lanExposed` 为真时三条**全部** 403
124
+ *
125
+ * 判据借的是 `POST /api/roles` 那道([role-add.ts](./role-add.ts) 文件头),
126
+ * 但这条路上**连读都要挡**,两条理由各自成立:
127
+ *
128
+ * 1. **读**:这份文件里的 `env` 是**明文密钥**。把它的原文发给一个我们无法确认
129
+ * 在本机的浏览器就是把那几个 token 发出去 —— 而 agent 自己那条读文件的路
130
+ * 够不到它(在工作区边界之外)。所以这**不是**「反正他本来就读得到」;
131
+ * 2. **写**:这条路写得下 `command` 那一格 = 这台机器上任意一个可执行文件加它的
132
+ * 参数,而 `apply` 会把它 spawn 起来。它比 role-add 那条(往 system prompt 里
133
+ * 塞字)还要直接一格。
134
+ *
135
+ * ⚠️ 界面那一侧另有一道,但它是**给人的**(`GET /api/config` 的 `lanExposed`,
136
+ * 决定 20 ①)。两道各答一半,别拿一条当另一条用 —— 那一格是客户端自己改得动的
137
+ * 布尔。
138
+ *
139
+ * ## 结构镜像,不是 import(同 `mcp.ts` / `role-add.ts` 文件头)
140
+ *
141
+ * `McpControl` 的真身在 runtime,这里按用得到的字段再声明一遍,`EpochRuntime`
142
+ * **结构上**正好满足。镜像里只有三个方法,于是这个包**写不出**
143
+ * `disconnectAll()` —— 那不是靠 review 盯住的,是编译期的事。
144
+ */
145
+
146
+ /** 一条诊断。`McpConfigIssue`(runtime)的镜像,也和网线上那个逐字同形 */
147
+ interface McpConfigIssueView {
148
+ path: string;
149
+ message: string;
150
+ }
151
+ /** 盘上那份此刻的样子。`McpConfigSnapshot`(runtime)的镜像 */
152
+ interface McpConfigSnapshotView {
153
+ configPath: string;
154
+ text: string;
155
+ exists: boolean;
156
+ revision: string;
157
+ issues: readonly McpConfigIssueView[];
158
+ }
159
+ type McpConfigReadView = {
160
+ ok: true;
161
+ snapshot: McpConfigSnapshotView;
162
+ } | {
163
+ ok: false;
164
+ reason: 'unreadable';
165
+ detail: string;
166
+ };
167
+ type McpConfigSaveView = {
168
+ ok: true;
169
+ configPath: string;
170
+ revision: string;
171
+ issues: readonly McpConfigIssueView[];
172
+ } | {
173
+ ok: false;
174
+ reason: 'stale' | 'invalid-json' | 'unwritable';
175
+ detail: string;
176
+ };
177
+ /**
178
+ * 逐台一行。⚠️ `status` 那一格是 **`McpView`**(这个包对状态的镜像),不是
179
+ * runtime 的 `McpServerStatus` —— 判据同 `McpControlView` 上那两条回执。
180
+ */
181
+ interface McpApplyChangeView {
182
+ name: string;
183
+ action: 'added' | 'reconnected' | 'unchanged' | 'removed' | 'skipped';
184
+ status?: McpView;
185
+ reason?: 'name-taken';
186
+ }
187
+ type McpApplyView = {
188
+ ok: true;
189
+ configPath: string;
190
+ revision: string;
191
+ issues: readonly McpConfigIssueView[];
192
+ changes: readonly McpApplyChangeView[];
193
+ } | {
194
+ ok: false;
195
+ reason: 'unreadable' | 'stale' | 'invalid-json' | 'no-servers';
196
+ detail: string;
197
+ };
198
+ /**
199
+ * `McpControl` 里**管那份文件的那三个方法**在服务端眼里的样子。
200
+ *
201
+ * ## 为什么是第二份镜像,而不是往 `McpControlView` 上加三行
202
+ *
203
+ * 那三个方法和 `reconnect` / `add` 挂在**同一个** runtime 对象上(`runtime.mcp`),
204
+ * 所以这两份镜像在装配点上是一个交集(见 `RuntimeCapabilities.mcp`)。分成两份是
205
+ * 为了让**镜像跟着 handler 走**:判据同这个文件为什么不跟 `mcp.ts` 住一起 ——
206
+ * 下一个改这三条端点的人,要读的判据全在这一个文件里。
207
+ *
208
+ * @see McpConfigControlView.writeConfig 上那条 `lang` 的 ⚠️
209
+ */
210
+ interface McpConfigControlView {
211
+ readConfig(): McpConfigReadView;
212
+ /**
213
+ * @param lang 这一次的 `detail` 用哪个语言说(方案 58 PR-2)。
214
+ *
215
+ * ⚠️ **镜像上这一格是必须的**:这个包不 import runtime 的类型
216
+ * (`check-layers` 是硬闸门),少声明它的话 `EpochRuntime` 照样满足这份镜像
217
+ * (多一个可选参数是宽化),而这一层就**递不出去** —— 一声不吭地回中文。
218
+ * 判据逐字同 `RoleWriteControlView.add`。
219
+ */
220
+ writeConfig(text: string, revision: string, lang?: Lang): McpConfigSaveView;
221
+ applyConfig(revision: string, lang?: Lang): Promise<McpApplyView>;
222
+ }
223
+
224
+ /**
225
+ * 那一屏上的两个 **MCP 动作** —— `POST /api/mcp/:name/reconnect`(方案 56 §1.1)
226
+ * 和 `POST /api/mcp`(2026-08-18)。
227
+ *
228
+ * ## 为什么从 [capability.ts](./capability.ts) 里搬出来
229
+ *
230
+ * 那个文件答的是「能力页那三栏取什么数、怎么投影」。这两条答的是**别的问题**:
231
+ * 「怎么动这个进程里的 MCP 连接」和「怎么往用户磁盘上那份 `mcp.json` 里加一台」。
232
+ * 2026-08-18 加完「添加」之后那边到了 546 行(铁律 3 是 500),而**刀口按题目下**
233
+ * ——不是按行数硬切一半。
234
+ *
235
+ * 技能正文那条(`GET /api/sessions/:id/skills/:name`)**留在那边**:它是同一份三栏
236
+ * 数据的下钻,和这两条不是一类。
237
+ *
238
+ * ## ⚠️ 两条 URL 上都没有 `:id`,而且不许有
239
+ *
240
+ * MCP 连接是装配期起的那几个 client,一个进程一份 —— 挂到 `/sessions/:id/` 下面会
241
+ * 得到一条**名字是会话级、行为是进程级**的端点,比不一致更坏。而能力页读的偏偏
242
+ * 是会话作用域的那一发(`GET /api/sessions/:id/capabilities`)。
243
+ *
244
+ * **这处读写作用域不一致由界面在按下去之前说清**(决定 20 ①),不由 URL 假装。
245
+ *
246
+ * ⚠️ 而且**那两句话不是同一句,别合并**:
247
+ *
248
+ * | 端点 | 改的是 | 影响 |
249
+ * | ------------------------------- | --------------------- | ------------------------------------------------------- |
250
+ * | `POST /api/mcp/:name/reconnect` | 这个进程里的一个连接 | 这个进程里所有会话 |
251
+ * | `POST /api/mcp` | **落盘的 `mcp.json`** | 这个进程,**外加这台机器上以后每一个 epoch 进程** |
252
+ *
253
+ * ## ⚠️ 两条端点上只有下面那条有闸门,而那不是不一致
254
+ *
255
+ * `addMcp` 在 `ctx.lanExposed` 为真时 **403 `mcp-add-lan-exposed`,一个字节都不写**;
256
+ * `reconnectMcp` 没有这一道。两者的分界线**逐字就是上面那张表**:重连动的是这个
257
+ * 进程里已经存在的一个连接(那台 server 是用户自己早先写进 `mcp.json` 的),
258
+ * 而添加往那份文件里**新落一条 `command`** —— 那一格是这台机器上任意一个可执行文件,
259
+ * 加进来的那台当场就被 spawn 起来,而且这台机器上以后每一个 epoch 进程都会照着它起。
260
+ *
261
+ * 判据全文在 protocol 的 [wire-agent-role.ts](../../protocol/src/wire-agent-role.ts)
262
+ * 文件头那五节 + [wire-capability.ts](../../protocol/src/wire-capability.ts) 的
263
+ * `WireMcpAddRequest` 上(**同一套论证,三条写口共用**,另两条是 `POST /api/roles`
264
+ * 和 [mcp-config.ts](./mcp-config.ts) 那三条)。这里只记它对这一层的两条直接后果:
265
+ *
266
+ * 1. **闸门在读请求体之前**(同 `addRole` / `saveConfig`):被拒的那一发连正文都不该
267
+ * 被解析,更不该有任何一个字节走到 `ctx.runtime.mcp.add` 那条路上。⚠️ 用例要断的
268
+ * 因此是**写口的调用次数为 0**,不是状态码 —— 一个「先调写口、再看 `lanExposed`
269
+ * 决定回什么码」的实现照样能让「回了 403」那条绿,而它已经写下去、还起了一个进程;
270
+ * 2. **只挡这一条,不挡重连**。别顺手把 `reconnectMcp` 一起关掉:那会让一个绑在
271
+ * `0.0.0.0` 上的服务连「对面重启过、点一下重连」都做不到,而那一下不落任何字节。
272
+ *
273
+ * ## 结构镜像,不是 import(同 capability.ts 文件头)
274
+ *
275
+ * `@epoch-agent/server` 不许 import `@epoch-agent/core`,而 `McpControl` 的真身在
276
+ * runtime。所以这里按用得到的字段再声明一遍,`EpochRuntime` **结构上**正好满足它。
277
+ * 镜像里只有两个方法,于是这个包**写不出** `disconnectAll()` —— 那不是靠 review
278
+ * 盯住的,是编译期的事。
279
+ */
280
+
281
+ /**
282
+ * `McpControl`(runtime)在服务端眼里的样子 —— **重连和添加,就这两件事**。
283
+ *
284
+ * 镜像里只有这两个方法,于是这个包**写不出** `disconnectAll()`:那不是靠
285
+ * review 盯住的,是编译期的事(同五份安全镜像各自漏掉一个中文字段的做法)。
286
+ * ⚠️ 加一格的时候是**在这份镜像上加一个方法**,不是把 `McpRegistry` 转过来 ——
287
+ * 方案 56 立的规矩,判据在 runtime 的 `McpControl` 上。
288
+ */
289
+ interface McpControlView {
290
+ reconnect(name: string): Promise<McpReconnectView>;
291
+ add(input: McpAddView): Promise<McpAddResultView>;
292
+ }
293
+ /**
294
+ * 重连回执的镜像。`detail` 是**底层原样那句话**(可缺席)——
295
+ * 这一层不改写它,也不在它缺席时编一句,判据同 `WireMcpConnector.lastError`。
296
+ */
297
+ type McpReconnectView = {
298
+ ok: true;
299
+ status: McpView;
300
+ } | {
301
+ ok: false;
302
+ reason: 'failed';
303
+ detail?: string;
304
+ status: McpView;
305
+ } | {
306
+ ok: false;
307
+ reason: 'unknown';
308
+ };
309
+ /**
310
+ * `McpAddInput`(runtime)的镜像。**和 `WireMcpAddRequest` 逐字同形,但不能合并**
311
+ * —— 一个是网线契约(浏览器和我们之间),一个是这个包对 runtime 的期望。
312
+ * 两者今天一样,是因为这条路上没有任何投影要做;哪天要做(比如服务端替用户
313
+ * 补一个默认超时),分开的这两份就是那次改动落脚的地方。
314
+ */
315
+ interface McpAddView {
316
+ name: string;
317
+ transport: 'stdio' | 'sse' | 'http';
318
+ command?: string;
319
+ args?: readonly string[];
320
+ url?: string;
321
+ env?: Readonly<Record<string, string>>;
322
+ }
323
+ /**
324
+ * 添加回执的镜像。
325
+ *
326
+ * ⚠️ **`ok: true` 的判据是「配置落了盘」,不是「连上了」。** 连不上的那台照样
327
+ * `ok: true`,那时 `status.connected` 是 false —— 判据全文在
328
+ * `WireMcpAddResponse` 上(「连不上不是失败,那是这条端点上最要紧的一句话」)。
329
+ */
330
+ type McpAddResultView = {
331
+ ok: true;
332
+ status: McpView;
333
+ configPath: string;
334
+ } | {
335
+ ok: false;
336
+ reason: 'invalid-name' | 'invalid-config' | 'duplicate' | 'unwritable';
337
+ detail: string;
338
+ };
339
+
340
+ /**
341
+ * 「新建一个身份」那条端点 —— `POST /api/roles`(2026-08-18)。
342
+ *
343
+ * 能力页那三栏里,技能和连接器 2026-08-18 各长出了一个写口
344
+ * ([skill-import.ts](./skill-import.ts)、[mcp.ts](./mcp.ts)),
345
+ * **只有身份那一栏还是纯只读的**。这一条把它补上。
346
+ *
347
+ * ## ⚠️ 闸门:**绑在回环之外就一律 403**,这是这个文件存在的正题
348
+ *
349
+ * 判据全文在 protocol 的 [wire-agent-role.ts](../../protocol/src/wire-agent-role.ts)
350
+ * 文件头(五节),这里记它对这一层的四条直接后果:
351
+ *
352
+ * 1. **`ctx.lanExposed` 为真时 403 `role-add-lan-exposed`,一个字节都不写。**
353
+ * 往 `~/.epoch/agents/` 落一份 md = 让那段文字无条件进往后每一轮的上下文
354
+ * (`description` 那一格进 `delegate_task` 的工具描述,**不需要任何人挑它**),
355
+ * 而角色目录那张闸门表上用户级那一格逐字写着「无(是用户自己的机器)」——
356
+ * 「是用户自己的机器」正是 `--host 0.0.0.0` 那一档下不成立的那条;
357
+ * 2. **不能拿「反正他能让 agent 跑命令」开绿灯。** `plan` 档和只读会话下 agent
358
+ * 一个字节都落不了盘,而这条端点落得了 —— 那是一次**跨权限轴的提权**,
359
+ * 不是「本来就能做的事换个写法」(方案 42 §11.2 那句现成的话在这里不成立);
360
+ * 3. **是 POST,而且必须是**:`auth.ts` 的 Origin 校验只在非安全方法上要求,
361
+ * 而这一条会在用户机器上真的写文件(同 skill-import 那条第 2 点);
362
+ * 4. **skill-import 那个答案抄不了。** 那条判成「收路径不收字节」,而
363
+ * 「新建一个身份」的本体就是用户敲一段 prompt 正文 —— 收路径不收字节 =
364
+ * 这条路整个不存在。所以这一格必须另外答,答案就是上面第 1 条。
365
+ *
366
+ * ⚠️ **界面那一侧还有一道,但它是给人的**:`GET /api/config` 上带着
367
+ * `lanExposed`,能力页据此在**按下去之前**就把「这一档下建不了」画出来
368
+ * (决定 20 ①)。两道各答一半 —— 别因为界面上画了就把这一道摘掉,
369
+ * 那一格是客户端自己改得动的布尔(同 skill-import 那张两道闸门的表)。
370
+ *
371
+ * ## 为什么单开一个文件
372
+ *
373
+ * 同 [mcp.ts](./mcp.ts) 从 `capability.ts` 里搬出来那次,**刀口按题目下不按行数**:
374
+ * 那个文件答的是「能力页那三栏取什么数、怎么投影」,这一条答的是「怎么往这台
375
+ * 机器上建一个身份」。而且这一份带着一整套结构镜像 + 一道闸门 + 六档拒绝的映射。
376
+ *
377
+ * ## 进程级,所以 URL 上没有 `:id`
378
+ *
379
+ * 落点是 `~/.epoch/agents/`(用户级,一个进程一份),判据逐字同 `reconnectMcp` 和
380
+ * `importSkills`:挂在 `/sessions/:id/` 下面会得到一条名字是会话级、行为是进程级
381
+ * 的端点,比不一致更坏。
382
+ *
383
+ * ⚠️ 而它比重连**远一格**、和 `POST /api/mcp` **同一格**:改的是落盘的东西,
384
+ * 影响这个进程外加这台机器上以后每一个 epoch 进程(CLI / TUI / 别的宿主)。
385
+ * 界面上那句话不能照抄重连那一句。
386
+ */
387
+
388
+ /**
389
+ * `RoleAddInput`(runtime)的镜像。**和 `WireRoleAddRequest` 逐字同形,但不能合并** ——
390
+ * 一个是网线契约(浏览器和我们之间),一个是这个包对 runtime 的期望。判据逐字同
391
+ * `McpAddView`:两者今天一样是因为这条路上没有投影要做;哪天要做,分开的这两份
392
+ * 就是那次改动的落脚点。
393
+ */
394
+ interface RoleAddView {
395
+ name: string;
396
+ description: string;
397
+ prompt?: string;
398
+ tools?: readonly string[];
399
+ maxTurns?: number;
400
+ }
401
+ /**
402
+ * 建成之后那一份。**`role` 是重载之后那张表里的那一个**,不是把请求体抄一遍 ——
403
+ * 判据在 `WireRoleAddResponse.role` 上。
404
+ */
405
+ type RoleAddResultView = {
406
+ ok: true;
407
+ role: RoleView;
408
+ path: string;
409
+ } | {
410
+ ok: false;
411
+ reason: 'invalid-name' | 'invalid-field' | 'duplicate' | 'unknown-tool' | 'unwritable';
412
+ detail: string;
413
+ };
414
+ /**
415
+ * `RoleWriteControl`(runtime)在服务端眼里的样子 —— **只有 `add` 这一个动作**。
416
+ *
417
+ * 镜像里只有它,于是这个包**写不出**「删一个身份」「改一个身份」:那不是靠
418
+ * review 盯住的,是编译期的事(同 `McpControlView` 只有两个方法、
419
+ * `SkillImportControlView` 只有两个动作)。
420
+ *
421
+ * ⚠️ 要加 `remove` 的时候**先回去读 protocol 那份文件头第三节**:删一个身份不会
422
+ * 往 system prompt 里塞字,但会**静默拿掉**别人正依赖的一段边界 ——
423
+ * 那道闸门的判据和「建」不一样,是一次独立的判断。
424
+ */
425
+ interface RoleWriteControlView {
426
+ /**
427
+ * @param lang 这一次的 `detail` 用哪个语言说(方案 58 PR-2)。
428
+ * 六档拒绝里有五档的文案(runtime 那侧的 `role_write.*`)**在那一层
429
+ * 渲染**,这一层只原样转发 —— 所以这一格必须穿过去,否则英文界面上
430
+ * 建身份失败拿到的是一句中文。
431
+ *
432
+ * ⚠️ 镜像上加这一格是**必须的**:这个包不 import runtime 的类型(`check-layers`
433
+ * 是硬闸门),少声明它的话 `EpochRuntime` 照样满足这份镜像(多一个可选参数是
434
+ * 宽化),而这一层就**递不出去** —— 一声不吭地回中文。判据同 `McpView.source`
435
+ * 那条 ⚠️:镜像漏一格不一定会红。
436
+ */
437
+ add(input: RoleAddView, lang?: Lang): RoleAddResultView;
438
+ }
439
+
440
+ /**
441
+ * 从本机目录导入技能的两条端点(方案 42 §六,2026-08-18):
442
+ *
443
+ * ```
444
+ * POST /api/skills/import/preview {path} → 「将导入什么」,不写字节
445
+ * POST /api/skills/import {path, token} → 真导入
446
+ * ```
447
+ *
448
+ * ## ⚠️ 闸门:这两条**收路径,不收字节**
449
+ *
450
+ * 判据全文在 core 的 [skill/import.ts](../../core/src/skill/import.ts) 文件头,
451
+ * 这里记它对这一层的三条直接后果:
452
+ *
453
+ * 1. 请求体里只有 `path` 和 `token`,**没有 `content`**。用户级技能目录连信任
454
+ * 闸门都没有 —— 往它里面落一份文件 = 让那段文字无条件进往后每一轮的 system
455
+ * prompt。而 `--host 0.0.0.0` 那一档下浏览器可能不在用户自己那台机器上;
456
+ * 2. 两条都是 **POST**,而且必须是:`auth.ts` 的 Origin 校验只在非安全方法上
457
+ * 要求,而第二条会在用户机器上真的写文件。写成 GET 等于任何一个页面都能
458
+ * 替用户按下那个确认;
459
+ * 3. `token` 是**服务端自己重算的**,不是信客户端说的。它防的是预览和导入之间
460
+ * 目录被改过 —— 插件那条路在一个进程里跑完,没有这条缝。
461
+ *
462
+ * ⚠️ **界面上那一屏「导入前预览」是另一道闸门,两道都要有。** 它防的是**人**
463
+ * 装错,这一层防的是**网线**决定 system prompt 里的字。两条各答一半,
464
+ * 别拿一条当另一条用(同 core 那个文件头那张表)。
465
+ *
466
+ * ## 为什么单开一个文件
467
+ *
468
+ * 同 `workspace/dirs.ts` 从 `handlers.ts` 里分出来那两条判据:
469
+ *
470
+ * 1. **作用域不同。** [capability.ts](./capability.js) 那几条按 `:id` 取,
471
+ * 这两条答的是「往这台机器的 `~/.epoch/skills/` 里放一份」—— 和你现在看着
472
+ * 哪个会话无关,所以它们连 sessionId 都不收(同 `POST /api/mcp/:name/reconnect`);
473
+ * 2. 那个文件已经 400 多行,而这一份带着一整套结构镜像加两个 handler。
474
+ *
475
+ * ## 进程级,所以 URL 上没有 `:id`
476
+ *
477
+ * 写入落点是 `~/.epoch/skills/`(用户级,一个进程一份),判据逐字同
478
+ * `reconnectMcp`:挂在 `/sessions/:id/` 下面会得到一条名字是会话级、行为是
479
+ * 进程级的端点,比不一致更坏。
480
+ *
481
+ * ## ⚠️ 这两条上的文案是**第三种形态**,不是 `sendError`(方案 58 §1.2 那条 📮)
482
+ *
483
+ * `issues[].message` / `skipped[].message` / `detail` 走的是 **200 响应体**,
484
+ * 不是 `{error:{code,message}}`,也不是启动诊断。它们在 **core** 里现渲染
485
+ * (`skill/import.ts` + `import-source.ts` 十几处 `t()`),所以 `lang` 要从这一层
486
+ * 一路穿过 runtime 的 `SkillControl` 递到 `SkillImportOptions`。
487
+ *
488
+ * 这个病 **2026-08-18 用 CDP 实测见过**:中文界面上那一屏的诊断块里印的是英文
489
+ * (开发机 `resolveLang()` 解析成 en,而那几条当时跟着**进程**语言走)。
490
+ *
491
+ * ⚠️ **只有这两条端点递 `lang`,`SkillSystem` 别的写口一个都不递** —— 那些的产物
492
+ * 会进 system prompt / 落盘,属于方案 58 §2.6 第三档「谁都不跟」。
493
+ *
494
+ * ⚠️ 但**撞名判定里 `shadowed` 那一档确实读了「项目级」那一层**,而项目级跟着
495
+ * 工作区走(决定 18)。今天这个进程里只有一个会话,两者重合;等方案 30 §6.3
496
+ * 的多会话工厂落地,那一档要按 `workspaces.of(sessionId)` 重算 —— 口径逐字同
497
+ * [capability.ts](./capability.js) 文件头最后那条 ⚠️。**写入那一半不受影响**,
498
+ * 它本来就是进程级的。
499
+ */
500
+
501
+ /** 一条诊断在服务端眼里的样子(core 的 `ParseIssue`,不能直接 import) */
502
+ interface IssueView {
503
+ path: string;
504
+ message: string;
505
+ }
506
+ /** `SkillImportEntry` 的镜像。`conflict` 那个联合同 `RoleView.source` 那条 ⚠️ */
507
+ interface ImportEntryView {
508
+ name: string;
509
+ category: string;
510
+ description: string;
511
+ files: number;
512
+ bytes: number;
513
+ conflict?: {
514
+ scope: 'user' | 'project' | 'plugin' | 'host';
515
+ verdict: 'refuse' | 'shadowed';
516
+ };
517
+ }
518
+ interface ImportPreviewView {
519
+ form: WireSkillImportForm;
520
+ source: string;
521
+ skills: readonly ImportEntryView[];
522
+ issues: readonly IssueView[];
523
+ token: string;
524
+ }
525
+ interface ImportDoneView {
526
+ preview: ImportPreviewView;
527
+ imported: readonly string[];
528
+ skipped: readonly IssueView[];
529
+ }
530
+ type ImportResultView<T> = ({
531
+ ok: true;
532
+ } & T) | {
533
+ ok: false;
534
+ reason: WireSkillImportFailure;
535
+ detail: string;
536
+ issues?: readonly IssueView[];
537
+ };
538
+ /**
539
+ * `SkillControl`(runtime)在服务端眼里的样子 —— **只有这两个动作**。
540
+ *
541
+ * 镜像里只有它们,于是这个包**写不出** `SkillSystem` 上那六个写方法、
542
+ * 也写不出会 `matchCount++` 的 `view()` 和会自动删技能的 `maintain()`:
543
+ * 那不是靠 review 盯住的,是编译期的事(同 `McpControlView` 只有 reconnect)。
544
+ */
545
+ interface SkillImportControlView {
546
+ preview(source: string, lang?: Lang): Promise<ImportResultView<{
547
+ preview: ImportPreviewView;
548
+ }>>;
549
+ import(source: string, token: string, lang?: Lang): Promise<ImportResultView<ImportDoneView>>;
550
+ }
551
+
552
+ /**
553
+ * 能力页的取数与投影 —— `GET /api/sessions/:id/capabilities`(方案 42 PR-1),
554
+ * 外加技能正文那条下钻(方案 56 §1.2)。
555
+ *
556
+ * 不在 [api.ts](./api.ts) 里,理由和 `workspace/handlers.ts` 一样:那个文件是
557
+ * 「一个端点一个函数」,而这里有一份 runtime 能力的**结构镜像**加三段投影 ——
558
+ * 塞进去会让它变成「一个端点一个函数,外加一百行别的」。
559
+ *
560
+ * ## 后补的那条下钻(方案 56 §1.2)
561
+ *
562
+ * `GET /api/sessions/:id/skills/:name` —— 一个技能的正文。
563
+ *
564
+ * ⚠️ **走的是 `runtime.skillBody()`,不是引擎那条 `view()`。** 后者会
565
+ * `matchCount++`,而那个数决定模型看得见哪些技能 —— 一次浏览不该被算成一次命中。
566
+ * 判据在 core 的 `SkillSystem.body` 上。
567
+ *
568
+ * ## ⚠️ 那两条 MCP 动作**不在这个文件里**,在 [mcp.ts](./mcp.ts)
569
+ *
570
+ * `POST /api/mcp/:name/reconnect`(方案 56 §1.1)和 `POST /api/mcp`(2026-08-18)
571
+ * 2026-08-18 搬出去了。刀口**按题目下不按行数**:这个文件答的是「那三栏取什么数、
572
+ * 怎么投影」,那两条答的是「怎么动这个进程里的 MCP 连接 / 怎么往用户磁盘上那份
573
+ * `mcp.json` 里加一台」。正文那条留下来,因为它是同一份三栏数据的下钻。
574
+ *
575
+ * (直接原因是加完「添加」之后这个文件到了 546 行,铁律 3 是 500。但**按行数切
576
+ * 一半**会切出一个没有题目的文件,那比超一点更坏。)
577
+ *
578
+ * ## 结构镜像,不是 import
579
+ *
580
+ * `@epoch-agent/server` **不许 import `@epoch-agent/core`**(check-layers.mjs
581
+ * 是硬闸门),而角色 / 技能 / MCP 状态这三样的真身都是 core 或插件的类型。
582
+ * 所以这里按用得到的字段再声明一遍,`EpochRuntime` **结构上**正好满足它 ——
583
+ * 对不上就是镜像漏了一块,装配那一行当场编译不过。收窄到这几项还有一个好处:
584
+ * 用例喂一个十几行的假对象就能跑,不用起一个真 runtime。
585
+ *
586
+ * ## 「进程有什么」和「这个会话有什么」,今天是同一份
587
+ *
588
+ * 角色和技能里的「项目」那一层来自工作区(`<root>/.epoch/agents/`、
589
+ * `<root>/.epoch/skills/`),而工作区是**按会话**绑的(决定 18)。今天这个进程
590
+ * 里只有一个会话(`server/src/index.ts` 只 `hub.register()` 一次),所以两者
591
+ * 重合,直接读 `runtime.agentRoles` / `runtime.skills` 是对的。
592
+ *
593
+ * ⚠️ 等方案 30 §6.3 的多会话工厂落地,同一个进程里的两个会话会绑在两个仓库上,
594
+ * 那时**这里要改**:项目那一层得按 `workspaces.of(sessionId)` 重新加载,
595
+ * 而不是读进程装配时那一份。URL 现在就定成会话作用域,为的就是那天不用再动
596
+ * 一次前端 —— 换成 `/api/capabilities` 的话,那天要改的是两个包。
597
+ */
598
+
599
+ /**
600
+ * `AgentRole` 在服务端眼里的样子(`@epoch-agent/core` 的类型,不能直接 import)。
601
+ *
602
+ * ⚠️ `source` 那个联合是**抄**过来的,所以它是这份镜像上唯一会静默走散的字段 ——
603
+ * 真源加一格而这里不加,`EpochRuntime` 当场不满足 `WebRuntimeView`,装配那一行
604
+ * 编译不过(文件头「结构镜像,不是 import」那一节说的就是这条)。方案 44 加
605
+ * `'host'` 时走的正是这条路,那个红就是它的验收手段。
606
+ */
607
+ interface RoleView {
608
+ name: string;
609
+ description: string;
610
+ tools?: readonly string[];
611
+ maxTurns?: number;
612
+ source: 'builtin' | 'user' | 'project' | 'plugin' | 'host';
613
+ }
614
+ /**
615
+ * `RoleMergeNotice` 的镜像。**只取 `role` 和 `cut`** —— `detail` 是中文文案,
616
+ * 浏览器要用自己的 catalog 组句(同诊断的规矩),下发它等于让英文界面照抄中文。
617
+ */
618
+ interface RoleNoticeView {
619
+ role: string;
620
+ cut: readonly string[];
621
+ }
622
+ /** `SkillMeta` 在服务端眼里的样子。`scope` 同上一条 ⚠️ */
623
+ interface SkillView {
624
+ name: string;
625
+ description: string;
626
+ category: string;
627
+ type: string;
628
+ scope: 'user' | 'project' | 'plugin' | 'host';
629
+ }
630
+ /**
631
+ * `McpServerStatus`(`@epoch-agent/plugin-mcp`)在服务端眼里的样子。
632
+ *
633
+ * ⚠️ `source` 那一格和上面两条 ⚠️ **不是同一回事,别照着读**。角色的 `source` /
634
+ * 技能的 `scope` 是**已有**联合多一格,真源加而这里不加会当场编译不过;这一格
635
+ * 是真源上**新增**的一个字段 —— 加字段是宽化,这份镜像不声明它只会一声不吭地
636
+ * 不下发,没有任何东西会红(方案 44 §3.4 点名的就是这一样)。
637
+ *
638
+ * 它今天之所以照样漏不掉,是因为 `WireMcpConnector.source` **写成了必填**:
639
+ * 这里不声明 → `toWireMcp` 读不到那一格 → 产出的对象不满足 `WireMcpConnector`
640
+ * → 本包 typecheck 当场红。完整判据在 protocol 那一侧那条 JSDoc 上。
641
+ */
642
+ interface McpView {
643
+ name: string;
644
+ connected: boolean;
645
+ toolCount: number;
646
+ lastError?: string;
647
+ reconnectAttempts: number;
648
+ /** 认证过不去。**重连解决不了它**,要 `epoch mcp login <name>` */
649
+ needsLogin?: boolean;
650
+ /** 谁带进来的:用户的 `mcp.json` / 嵌入宿主 / 某个已装插件(方案 44 PR-2、PR-3) */
651
+ source: 'user' | 'host' | 'plugin';
652
+ }
653
+ /** server 从 runtime 借的「能力」那一片。`WebRuntimeView` 继承它 */
654
+ interface RuntimeCapabilities {
655
+ agentRoles: readonly RoleView[];
656
+ agentRoleNotices: readonly RoleNoticeView[];
657
+ readonly skills: readonly SkillView[];
658
+ readonly mcpServers: readonly McpView[];
659
+ /**
660
+ * 一个技能的正文,**不动统计**(方案 56 §1.2)。没有这个技能 / 技能系统
661
+ * 没起来时是 `null` —— 两者对这一层是同一件事:没有正文可发。
662
+ */
663
+ skillBody(name: string): string | null;
664
+ /**
665
+ * 从**本机目录**导入技能(方案 42 §六)。**进程级**,同 {@link mcp}。
666
+ *
667
+ * ⚠️ 它收的是**路径**不是字节 —— 那是这条路上的安全性质本体,判据全文在
668
+ * [skill-import.ts](./skill-import.js) 的文件头。
669
+ */
670
+ readonly skillImport: SkillImportControlView;
671
+ /**
672
+ * MCP 的写口:重连一台(方案 56 §1.1)+ 加一台(2026-08-18)+ **那份文件的原文
673
+ * 读写与应用**(2026-08-18)。**全都是进程级**。
674
+ *
675
+ * 镜像跟着各自的 handler 走:前两个在 [mcp.ts](./mcp.ts),后三个在
676
+ * [mcp-config.ts](./mcp-config.ts)(两个文件头各有全部判据)—— 这一格只是把
677
+ * 它们挂进 `RuntimeCapabilities`,因为**装配那一行是在这儿对形状的**。
678
+ *
679
+ * ## ⚠️ 为什么是交集,不是一份镜像
680
+ *
681
+ * 五个方法挂在**同一个** runtime 对象上(`runtime.mcp`),形状上它们本来就是一个
682
+ * 交集。分成两份声明是为了让**镜像跟着 handler 走**(判据同 `mcp.ts` 当初从这个
683
+ * 文件里搬出去那次:刀口按题目下)—— 改那三条端点的人要读的判据全在那一个文件
684
+ * 里,不用先在这儿绕一圈。
685
+ *
686
+ * ⚠️ 那三条端点各有一道**回环绑定**的闸门(`ctx.lanExposed` 为真就 403,
687
+ * **连读都挡**),而它**不在这份镜像上** —— 镜像答的是「runtime 借得到什么」,
688
+ * 闸门答的是「这一发准不准发」。判据同下面 `roleWrite` 那一格。
689
+ */
690
+ readonly mcp: McpControlView & McpConfigControlView;
691
+ /**
692
+ * 身份那一栏的写口:建一个(2026-08-18)。**进程级**,同上面那两条。
693
+ *
694
+ * 镜像和 handler 一起住在 [role-add.ts](./role-add.ts)(那边的文件头有全部
695
+ * 判据)—— 这一格只是把它挂进 `RuntimeCapabilities`,因为**装配那一行是在
696
+ * 这儿对形状的**(同 `mcp` 那一条)。
697
+ *
698
+ * ⚠️ 那条端点有一道**回环绑定**的闸门(`ctx.lanExposed` 为真就 403),
699
+ * 而它**不在这份镜像上** —— 镜像答的是「runtime 借得到什么」,闸门答的是
700
+ * 「这一发准不准发」。判据全文在 protocol 的 `wire-agent-role.ts` 文件头。
701
+ */
702
+ readonly roleWrite: RoleWriteControlView;
703
+ }
704
+ /**
705
+ * 三栏一次取齐。
706
+ *
707
+ * @param bound 这个会话绑的工作区(`workspaces.of(sessionId)`)。没绑就是 null,
708
+ * 那时「项目」那一层整个不存在 —— 不是「有但是空的」
709
+ */
710
+ declare function collectCapabilities(runtime: RuntimeCapabilities, tools: readonly {
711
+ name: string;
712
+ source: string;
713
+ }[], bound: {
714
+ workspace: {
715
+ root: string;
716
+ };
717
+ trust: {
718
+ trusted: boolean;
719
+ };
720
+ } | null): WireCapabilitiesResponse;
721
+ /**
722
+ * 一份 `SKILL.md` 最多发多少字节。形状照
723
+ * [workspace/diff.ts](./workspace/diff.ts) 的 `MAX_DIFF_BYTES`。
724
+ *
725
+ * ## 为什么必须有一个数
726
+ *
727
+ * `SKILL.md` 没有天然上界 —— 它是一个用户(或者一个 `git clone` 下来的仓库、
728
+ * 一个装进来的插件)自己写的文件。没有上限的话,一次点击就能让服务端把一份
729
+ * 任意大的文件读进内存、序列化成 JSON、再塞进一个 `<pre>`。
730
+ *
731
+ * ## 为什么这个数在**这一层**而不是 core
732
+ *
733
+ * 「多大算太大」是一个**展示决定**,不是引擎的事实:`skill_view` 那条路
734
+ * (模型读正文)一个字节都不该被截,它读多少是上下文预算的问题。
735
+ * `MAX_DIFF_BYTES` 住在 server 而不是 git 那一层,是同一条判据。
736
+ *
737
+ * 256KiB 比 diff 那个数小一半:diff 是「一个文件的两侧」,而这是一份人写的
738
+ * 说明文档 —— 到这个量级它早就不是「点开看一眼」能读的东西了。
739
+ */
740
+ declare const MAX_SKILL_BODY_BYTES: number;
741
+ /**
742
+ * 正文 → 载荷。**截断是要说出来的**(验收第 8 条),不静默。
743
+ *
744
+ * 按**字节**截而不是按字符:上限守的是网线和内存,而一个中文字符在 UTF-8 里
745
+ * 是三个字节 —— 按 `length` 截的话,一份全中文的 SKILL.md 实际能发出三倍。
746
+ *
747
+ * ⚠️ 截口用 `Buffer.toString()` 而不是自己找边界:多字节字符被切成两半时,
748
+ * Node 会把残缺的那几个字节还原成 `U+FFFD`,而**不会**吐出一段非法 UTF-8。
749
+ * 自己按字符倒退着找边界也行,但那是给一处「显示成一个问号」的问题写一段
750
+ * 会算错的代码。
751
+ *
752
+ * ⚠️ **但那个 `U+FFFD` 自己占 3 个字节,比它替掉的那 1–2 个残字节更长** ——
753
+ * 于是 `subarray(0, N).toString()` 的产物重新编码回去可能是 `N + 2`,
754
+ * 而那时这个「上限」就不是上限了。多减一个字符是**必须的一步**,不是保险:
755
+ * 一份全中文的 SKILL.md 每次都会走到这一支(用例里断的就是它)。
756
+ */
757
+ declare function toWireSkillBody(name: string, body: string): WireSkillBodyResponse;
758
+
759
+ /**
760
+ * Hub 和登记表共用的两个契约。
761
+ *
762
+ * 单独一个文件是为了**断开循环依赖**:`hub.ts` 用 `SessionRegistry`,而登记表里
763
+ * 每一格装的是一个 `HubSession` —— 两个类型互相引用的话
764
+ * `import/no-cycle`(oxlint 里是 error)当场红。放在依赖链的底下,两边都只往下引。
765
+ *
766
+ * `hub.ts` 把它们原样再导出一次,所以包的公共 API 形状一个字都没变。
767
+ */
768
+
769
+ /**
770
+ * Hub 需要会话提供的能力 —— `runtime` 的 `AgentSession` 结构上就满足它。
771
+ *
772
+ * 刻意收窄成两个成员而不是直接吃 `AgentSession`:用例要喂一个**脚本化的假 agent**
773
+ * 去对着 `fixtures/wire/*.json` 断言信封序列(§9.5 的交接锚点),而真 AgentSession
774
+ * 背后拖着 provider、SQLite 和一整个 AgentLoop。
775
+ */
776
+ interface HubSession {
777
+ readonly sessionId: string;
778
+ run(message: EpochUserContent, opts?: {
779
+ signal?: {
780
+ readonly aborted: boolean;
781
+ };
782
+ }): AsyncGenerator<AgentEvent>;
783
+ }
784
+ /** SSE 连接从 Hub 收帧的出口 */
785
+ type FrameSink = (frame: WireEnvelope) => void;
786
+ /**
787
+ * 给**这一轮**的事件流套一层的包装(方案 23 的 web 那一半)。
788
+ *
789
+ * 自定义斜杠命令的 `allowed-tools` / `model:` 都只管一轮,而进出必须成对 ——
790
+ * 那两个包装(`withToolScope` / `withModelScope`)住在 runtime,规矩和会红的用例
791
+ * 都在那边。Hub **不认识命令这个概念**:它只知道「这一轮的流要先过一遍这个函数」。
792
+ *
793
+ * ## 为什么是一个闭包,而不是让 Hub 收 `{allowedTools, model}`
794
+ *
795
+ * 收字段的话 Hub 就得自己去 import `commands.ts` 里的收窄逻辑,而 `commands.ts`
796
+ * 要用 `ApiContext`(在 `context.ts`)、`context.ts` 又要 import 本文件旁边的
797
+ * `hub.ts` —— 那是一个环,`import/no-cycle` 在 oxlint 里是 error。
798
+ * 收闭包之后依赖方向是单向的:`api → commands → (protocol, runtime)`,
799
+ * 而 Hub 只多认识一个函数类型。
800
+ *
801
+ * ⚠️ **它在轮次真正开始时才被调用**,不是在收下消息那一刻 —— 排队的消息可能等上
802
+ * 几分钟。这一点是安全的:`withToolScope` 是异步生成器,第一次 `next()` 之前一行
803
+ * 都不执行,所以它读到的仍然是进入作用域之后的工具表(runtime 那边写着同一句)。
804
+ */
805
+ type TurnDecorator = (stream: AsyncGenerator<AgentEvent>) => AsyncIterable<AgentEvent>;
806
+ /**
807
+ * 排队里的一条消息 —— 正文 + 它那一轮的包装。
808
+ *
809
+ * 两者必须**一起排队**:包装描述的是「这条消息该在什么工具表 / 什么模型下跑」,
810
+ * 只排正文的话,一条 `/review`(带 `allowed-tools`)排在别人后面之后,
811
+ * 轮到它时会跑在一张没收窄的工具表上,而屏幕上没有任何迹象。
812
+ */
813
+ interface QueuedMessage {
814
+ message: EpochUserContent;
815
+ /** 不是命令(绝大多数消息)时缺席 —— 零开销直通,不套任何一层 */
816
+ decorate?: TurnDecorator;
817
+ }
818
+
819
+ /**
820
+ * 自定义斜杠命令的取数、判定与展开 —— `GET /api/sessions/:id/commands` 那张表,
821
+ * 加上 `POST /api/sessions/:id/messages` 上那一步「这条输入是不是 `/xxx`」。
822
+ *
823
+ * 不在 [api.ts](./api.ts) 里,理由和 [capability.ts](./capability.ts) 一样:那个
824
+ * 文件是「一个端点一个函数」,而这里有一份 runtime 能力的**结构镜像**、一个
825
+ * 输入解析器、和一段把两个作用域包装接上去的接线 —— 塞进去会让它变成
826
+ * 「一个端点一个函数,外加一百行别的」。
827
+ *
828
+ * ## 结构镜像,不是 import
829
+ *
830
+ * `@epoch-agent/server` 依赖得到 `@epoch-agent/runtime`(`ALLOWED` 表里有),
831
+ * 所以这里**本来可以**直接 import `CommandControl` / `ModelControl`。仍然写镜像,
832
+ * 判据和 `capability.ts` / `security.ts` 那两处逐字相同:
833
+ *
834
+ * - **只借用得到的那几格。** `ModelControl` 上还有 `get` / `set` / `reset` /
835
+ * `drainNotices` 四个动词,本文件一个都不该碰 —— 借过来就是给下一个人一个
836
+ * 「原来这条路上能换模型」的暗示,而 web 上刻意没有那个入口
837
+ * (`web/README.md`「界面上没有的东西」)
838
+ * - **用例喂一个十几行的假对象就能跑**,不用起一个真 runtime(那要 provider、
839
+ * SQLite 和 MCP 子进程)。这一节的核心用例是「中途 abort 之后工具表还全不全」,
840
+ * 它必须能在毫秒级反复跑
841
+ *
842
+ * 镜像和真身**结构上必须相等**:`EpochRuntime` 正好满足 {@link RuntimeCommands},
843
+ * 对不上就是镜像漏了一块,`createWebServer({ runtime })` 那一行当场编译不过。
844
+ * 而下面 {@link scopeTurn} 反过来把镜像传给 runtime 的 `withToolScope` ——
845
+ * 于是这份镜像的两个方向都被编译器钉住了。
846
+ *
847
+ * ## 展开为什么只能在服务端做
848
+ *
849
+ * 插值要引号感知(`/foo "a b" c` 的 `$1` 是 `a b`),那个 tokenizer 住在
850
+ * `@epoch-agent/infra`;`utility` 关键字和 `<provider>/<model>` 的切法在 core。
851
+ * `@epoch-agent/web` 只许依赖 protocol + view(`check-layers.mjs` 是硬闸门),
852
+ * 两样都够不着。在浏览器里手写第二个「差不多的」切词器,后果是同一条命令在
853
+ * TUI 和 web 里断句不一样 —— 而那种分歧只会在用户抱怨「同一条命令在两边结果不同」
854
+ * 的时候才被发现。
855
+ */
856
+
857
+ /**
858
+ * `CommandControl`(runtime)在服务端眼里的样子。
859
+ *
860
+ * 三样一个不多:`list` 画补全面板、`expand` 做插值、`scope` 收窄这一轮的工具表。
861
+ * 一行文件 I/O 都不在这一侧 —— 加载、同名冲突、工作区信任闸门全在引擎里。
862
+ */
863
+ interface CommandsView {
864
+ /** 加载好的命令表,已按名字排序、同名冲突已解决 */
865
+ readonly list: readonly CustomCommandDef[];
866
+ /**
867
+ * 展开成一次 turn 的输入。**命令名不认识时返回 `undefined`** ——
868
+ * 那时这行输入按普通消息原样发给模型,不是报错(见 {@link expandUserContent})。
869
+ */
870
+ expand: (name: string, args: string) => ExpandedCommand | undefined;
871
+ /** 收窄这一轮的工具表,返回还原函数。`allow` 不给就是不收窄 */
872
+ scope: (allow: readonly string[] | undefined) => () => void;
873
+ }
874
+ /**
875
+ * `ModelControl` 里 {@link scopeTurn} 用得到的**那一格**。
876
+ *
877
+ * 只有 `enterTurn`:它的语义是「只换这一轮,`restore` 无条件还原」,
878
+ * 而这正是命令 frontmatter 的 `model:` 要的东西。`set` / `reset` 刻意不借 ——
879
+ * 见文件头。
880
+ *
881
+ * 返回值里的 `result` 这一侧一个字都不读,但形状必须写全:少写它,
882
+ * 这个镜像就不能再传给 runtime 的 `withModelScope`(函数返回值是协变的)。
883
+ */
884
+ interface ModelTurnsView {
885
+ enterTurn: (ref: ModelRef) => {
886
+ result: SetSelectionResult;
887
+ restore: () => void;
888
+ };
889
+ }
890
+ /**
891
+ * `RoleControl` 里 {@link scopeTurn} 用得到的**那一格**(方案 57 §3.4)。
892
+ *
893
+ * 只有 `enterTurn`,判据逐字同上面那个 `ModelTurnsView`:`has()` 是**收下消息
894
+ * 那一刻**才用得到的(`postMessage()` 据此回 400),而这个文件只管「这一轮怎么跑」。
895
+ * 借过来就是给下一个人一个「原来这条路上也能判专家名」的暗示,而在这条路上
896
+ * 判已经太晚了 —— 排队的消息可能等上几分钟。
897
+ *
898
+ * 收**名字**不收角色对象:`@epoch-agent/server` 不许 import `@epoch-agent/core`,
899
+ * 名字 → `AgentRole` 的解析归 runtime 一处(`createRoleControl`)。
900
+ */
901
+ interface RoleTurnsView {
902
+ enterTurn: (name: string) => () => void;
903
+ }
904
+ /**
905
+ * 「这一条消息挑了哪个专家」—— 名字 + 它那个会话的控制口(方案 57 §3.4)。
906
+ *
907
+ * 一个对象而不是两个参数:这两样**必须来自同一个会话**(控制口是
908
+ * `sessionFactory.get(sessionId)?.roles`,名字是那一次请求体里的),
909
+ * 拆成两个参数的话调用方可以只给其中一个,而那时收窄会打在别的会话身上。
910
+ *
911
+ * 整个给 `null` = **这一条没挑专家**,那时 {@link scopeTurn} 对角色那一层是
912
+ * 零开销直通 —— 这个会话跑在它自己的默认角色上,槽里本来就是那一个。
913
+ */
914
+ interface TurnRole {
915
+ control: RoleTurnsView;
916
+ /** 已经过 `has()` 校验的角色名 —— 认不出来的在 `postMessage()` 就 400 了 */
917
+ name: string;
918
+ }
919
+ /** server 从 runtime 借的「斜杠命令」那一片。`WebRuntimeView` 继承它 */
920
+ interface RuntimeCommands {
921
+ /**
922
+ * 命令表 + 展开 + 收窄。**不可为 null** —— 加载失败、目录不存在、工作区不受信任,
923
+ * 得到的都是一张空表加若干条诊断,不是「这个能力没有」(`EpochRuntime.commands`
924
+ * 上写着同一句)。所以这一侧不用为「有没有命令系统」分支。
925
+ */
926
+ commands: CommandsView;
927
+ }
928
+ /**
929
+ * 一条命令 → 补全面板那一行。
930
+ *
931
+ * ⚠️ **`prompt` 不进这个投影**,判据在 `WireCustomCommand` 的 JSDoc 上:
932
+ * 那是用户自己写的工作指令原文,隐私和体积两头都不该下发,而面板一个字都用不上。
933
+ * 少发它是编译期生效的 —— `WireCustomCommand` 上没有这个字段,写了当场不过。
934
+ *
935
+ * `allowedTools` / `model` / `source` / `filePath` 同样不发,理由弱一些但同向:
936
+ * 面板上没有它们的位置,而「这条命令会收窄工具表」是**展开之后**才该说的事
937
+ * (那时说的是「这一轮」,而不是「这条命令平时」)。
938
+ */
939
+ declare function toWireCommand(def: CustomCommandDef): WireCustomCommand;
940
+ /** {@link expandUserContent} 的结果 */
941
+ interface ExpansionOutcome {
942
+ /** 真正发给引擎的那条消息。不是命令时 === 传进来的那条 */
943
+ message: EpochUserContent;
944
+ /**
945
+ * 这一轮的作用域包装。三样都不要(不是命令 / 命令既没收窄也没换模型 /
946
+ * 这一条也没挑专家)时缺席 —— 零开销直通。
947
+ *
948
+ * ⚠️ **它不再只为命令而存在**(方案 57 §3.4):一条普通消息带着一枚 chip
949
+ * 同样要包一层,那时下面三层里只有角色那一层真的进出。
950
+ */
951
+ decorate?: TurnDecorator;
952
+ /** 回给发送方的那句话(命令名 + 警告)。不是命令时缺席 */
953
+ notice?: WireCommandExpansion;
954
+ }
955
+ /**
956
+ * 「这条输入是不是一条自定义斜杠命令」—— 是就展开,不是就原样放行。
957
+ *
958
+ * ## 认不出来的命令名**照原样发给模型**,不是报错
959
+ *
960
+ * `CommandControl.expand` 返回 `undefined` 就是这一档,那条规矩写在 runtime 那侧的
961
+ * 注释里。回 400 的代价:用户问「/etc/hosts 是什么」会收到一句「未知命令」,
962
+ * 而他要的只是一句回答。判错的方向必须偏向「当普通文本」。
963
+ *
964
+ * ## 带附件的那一支也走这条路
965
+ *
966
+ * 输入可以是字符串,也可以是 `EpochContentPart[]`(粘了图片时)。后者**只看第一个
967
+ * 部件**,而且只在它是文本时才试着展开 —— 输入框拼数组时文本永远在最前面
968
+ * (`composer/index.tsx` 的 `submit`)。不处理数组那一支的后果不是报错,是**静默
969
+ * 走样**:打了 `/review` 又顺手贴了张截图,命令就悄悄没生效了,而屏幕上没有
970
+ * 任何区别。
971
+ *
972
+ * ## ⚠️ 逐条专家走的是同一条路,所以「不是命令」不再等于「不用包」
973
+ *
974
+ * `role` 给了(这一条挑了专家,方案 57 §3.4)时,**一条普通消息也要拿到
975
+ * `decorate`** —— 那正是 2026-08-17 之前这个函数唯一的形状变化。漏掉的表现是
976
+ * chip 对普通消息完全无效、只有敲了 `/xxx` 才生效,而屏幕上没有任何区别。
977
+ *
978
+ * @param role 这一条挑的专家,没挑就给 `null`。**名字必须已经过 `has()` 校验** ——
979
+ * 认不出来那一档在 `postMessage()` 就 400 了,这条路上没有回话的地方
980
+ */
981
+ declare function expandUserContent(runtime: RuntimeCommands, message: EpochUserContent, model: ModelTurnsView | null, role: TurnRole | null): ExpansionOutcome;
982
+
983
+ /**
984
+ * SessionHub —— 事件总线 + 回合状态机 + 审批生命周期。
985
+ *
986
+ * [方案 20 §4.1 / §4.2 / §4.3](../../../docs/verify/VERIFY_RECORD-20-web.md) 三块在
987
+ * **同一个类**里(§9.6 第 1 条明写了不要拆):它们共享同一份状态 ——
988
+ * 状态机的迁移由事件流驱动、审批的挂起数又是状态机的输入、而三者都要往同一个
989
+ * 全局游标上发帧。拆成三个文件只会让每一次迁移都跨文件读一遍。
990
+ *
991
+ * ## 唯一写者
992
+ *
993
+ * 今天 `AgentSession.run()` 是**单消费者** async generator:一个 for-await 吃掉
994
+ * 所有事件。浏览器要的是「N 个标签页看同一个会话 + 刷新之后接着看」,所以这里
995
+ * 由 Hub 独占那个 for-await,再扇出给每一条 SSE 连接和环形缓冲。
996
+ *
997
+ * ```
998
+ * AgentSession.run() ─► serializeStream(relay) ─► SessionHub(唯一写者)
999
+ * │
1000
+ * ┌──────────────────────┼──────────────────┐
1001
+ * SSE 客户端 A SSE 客户端 B 环形缓冲
1002
+ * ```
1003
+ *
1004
+ * 方案 54 §三之后多一条同进程的支流(`observe()`):它**只挂在广播上**,
1005
+ * 不进上面那个「连接」的概念里 —— 理由见下面第 5 条。
1006
+ *
1007
+ * ## 五条不许改回去的规矩
1008
+ *
1009
+ * 1. **`seq` 是全局单调计数器**,不是 per-session(理由见 wire.ts 的长注释)。
1010
+ * 2. **连接级帧(`connected` / `stream-reset`)`seq: 0`**,只发给刚接上来的那一个
1011
+ * 连接、不进缓冲、不写 SSE 的 `id:` 行。占了广播序号就意味着「有人开了个新
1012
+ * 标签页」会把别人的续传游标推走。
1013
+ * 3. **断连不是拒绝。** 任何情况下都不许逐个 `relay.respond(id, 'deny')` ——
1014
+ * 模型收到一串「用户拒绝」会认为这是有意否决,然后换个方式再试一遍
1015
+ * (`runtime/src/serialize.ts` 的类注释把这条写死了)。要放弃就 `abandon()`
1016
+ * 整轮 + `abort()`。
1017
+ * 4. **一个会话同一时刻只有一个 `run()`**(方案 30 §2.3)。同一会话的第二条消息
1018
+ * **排队**而不是并发;不同会话之间**可以**并行 —— 那正是多会话的意义。
1019
+ * 理由与「谁在表里、谁排在后面」的实现都在 [sessions/registry.ts](./sessions/registry.ts)。
1020
+ * 5. **旁听者(`observe()`)不许进 `sinks`**(方案 54 §三)。`sinks.size` 是用来回答
1021
+ * 「**人**还在不在」的那个计数器,全仓有四处判它:`connectionCount`、
1022
+ * `armGrace()` 的触发、`drainQueue()`、`reap()`。宿主为了在自己的窗口上点亮一枚
1023
+ * 状态灯会常驻一条旁听 —— 那要是算一条连接,「全断 30 秒放弃挂起的审批 + 中止
1024
+ * 回合」和「全断了丢掉排队的消息」就一起失效了,**而宿主没有任何办法知道**。
1025
+ * 加集合、不加计数:新东西进不了那个计数器。
1026
+ */
1027
+
1028
+ interface SessionHubOptions {
1029
+ /** 环形缓冲容量,默认 512 */
1030
+ ringCapacity?: number;
1031
+ /**
1032
+ * **所有**连接都断开之后,等多久才 `abandon()` + `abort()`。默认 30 秒。
1033
+ *
1034
+ * 不是 0:浏览器刷新是常态,按一下 F5 就把跑了三分钟的任务杀掉是
1035
+ * [EMBEDDING.md 那套 Electron 规矩](../../../docs/EMBEDDING.md)照抄到 web 上的
1036
+ * 后果。也不是 ∞:标签页真关了之后没人会来收尾,审批会永远挂着。
1037
+ */
1038
+ idleGraceMs?: number;
1039
+ /**
1040
+ * 时钟。默认 `Date.now`。
1041
+ *
1042
+ * 注入它是为了让 fixture 用例能断言信封**逐条等于** JSON(含 `ts`)。
1043
+ * 不变量:**每产出一个信封恰好调用一次** —— 用例的脚本化时钟就靠这条对齐。
1044
+ *
1045
+ * ⚠️ 方案 30 之后它多了一个消费者:会话登记表拿它记 `lastActiveAt`(LRU 冷却
1046
+ * 的判据)。那几次读**不产出信封**,所以上面那条不变量的说法要收窄成
1047
+ * 「每产出一个信封恰好读一次」——`scriptedClock` 的用例因此只在
1048
+ * `maxActive` 没被触碰的场景里逐格对齐。
1049
+ */
1050
+ clock?: () => number;
1051
+ /** 同时活跃的会话数上限,缺省 8(方案 30 §2.3) */
1052
+ maxActiveSessions?: number;
1053
+ /** 单个会话最多排几条消息,缺省 8 */
1054
+ maxQueuedMessages?: number;
1055
+ /**
1056
+ * 一个会话被**冷却**掉时叫一声(方案 30 §6.3 的多会话工厂落地之后才真会触发)。
1057
+ *
1058
+ * Hub 这一侧的冷却只是「从这张表里摘掉」——它管不到那个会话在**引擎**里占的
1059
+ * 东西(对话历史、它那个 `AgentLoop`、它的审批桥)。而释放那批内存正是冷却
1060
+ * 的全部意义:不给这个回调的话,`maxActiveSessions` 只挡住了 Hub 的表变长,
1061
+ * 一个人开 50 个标签页照样能把内存吃光 —— 也就是 §2.3 那条上限白设了。
1062
+ *
1063
+ * 是**回调不是返回值**:`register()` 的调用方(`POST /api/sessions`)关心的是
1064
+ * 「我这一个建出来没有」,而被挤走的是**另一个**会话。让它顺手去处理别人的
1065
+ * 后事,等于把一条不变量的执行摊到每个调用点上,漏一处就是一次静默泄漏。
1066
+ */
1067
+ onCooled?: (sessionId: string) => void;
1068
+ }
1069
+ /**
1070
+ * `start()` 的结果。
1071
+ *
1072
+ * `busy` 在方案 30 之后**不再是「同一个会话再发一条」的出口** —— 那条现在排队
1073
+ * (`ok: true, queued: true`)。它只剩下「排满了」这一种,所以带上 `reason`
1074
+ * 让调用方能说清楚是哪一种拒绝。
1075
+ */
1076
+ type StartResult =
1077
+ /** 收下了。`queued` = 排在当前这一轮后面,还没开始跑 */
1078
+ {
1079
+ ok: true;
1080
+ queued: boolean;
1081
+ } | {
1082
+ ok: false;
1083
+ reason: 'unknown-session';
1084
+ }
1085
+ /** 会话被冷却了(活跃数超上限),要宿主重新 `register()` 才能再跑 */
1086
+ | {
1087
+ ok: false;
1088
+ reason: 'cooled';
1089
+ }
1090
+ /** 队列满了。`state` 是当前这一轮的状态,调用方回 409 时带上它 */
1091
+ | {
1092
+ ok: false;
1093
+ reason: 'queue-full';
1094
+ state: WireTurnState;
1095
+ };
1096
+ /** 一个会话在 Hub 侧的实时状态。DB 那半(标题 / 成本 / 消息数)不在这里 */
1097
+ interface HubSessionState {
1098
+ id: string;
1099
+ state: WireTurnState;
1100
+ /** 挂起的审批数。别的会话卡在审批上时,列表要看得见(方案 30 §2.5) */
1101
+ pendingApprovals: number;
1102
+ /** 挂起的提问数,理由同上 */
1103
+ pendingQuestions: number;
1104
+ /**
1105
+ * 这个会话**从什么时候起被拦住**;一条挂起的都没有时 null。
1106
+ *
1107
+ * 上面两个数答「有几条」,这一格答「等了多久」。**两张表合成一格**在这一层
1108
+ * 完成(`earlierOf`),不是往上送两个字段 —— 判据在
1109
+ * `WireSessionSummary.pendingSince` 上,一句话是「合错(取晚的那个)会让一个
1110
+ * 卡了两小时的会话报『已等 3 秒』,而那个判断不该摊到每个读它的人身上」。
1111
+ */
1112
+ pendingSince: number | null;
1113
+ /** 排队等着开的消息数 */
1114
+ queued: number;
1115
+ /** 本轮开跑的时刻;没在跑时 null。见 `WireSessionSummary.turnStartedAt` */
1116
+ turnStartedAt: number | null;
1117
+ /** 上一轮的终局;还没跑过时 null。见 `WireSessionSummary.lastFinish` */
1118
+ lastFinish: WireSessionFinish | null;
1119
+ }
1120
+ declare class SessionHub {
1121
+ private readonly ring;
1122
+ private readonly idleGraceMs;
1123
+ private readonly clock;
1124
+ /** 见 {@link SessionHubOptions.onCooled} */
1125
+ private readonly onCooled;
1126
+ private readonly registry;
1127
+ private readonly sinks;
1128
+ /**
1129
+ * 同进程旁听者(方案 54 §三)。**故意是第二个集合,而不是 `sinks` 上的一个标记位。**
1130
+ *
1131
+ * 标记位那种写法要求每一处 `sinks.size` 都改成「数一下里面有几个不是标记的」,
1132
+ * 而那四处判定里有两处(`reap()` / `drainQueue()`)的错法是**静默的**:
1133
+ * 挂起的审批永远不被放弃、没人看着的时候凭空开始跑一条排队的消息。
1134
+ * 两个集合的写法让那四处一个字都不用改。
1135
+ */
1136
+ private readonly observers;
1137
+ /** requestId → sessionId。`POST /api/approvals/:requestId` 上没有会话 id */
1138
+ private readonly requestOwner;
1139
+ /** 全局广播游标。0 是保留值(连接级帧),所以广播帧从 1 开始 */
1140
+ private seq;
1141
+ private graceTimer;
1142
+ private closed;
1143
+ constructor(opts?: SessionHubOptions);
1144
+ /**
1145
+ * 把一个会话交给 Hub 管。重复注册同一个 id 是空操作。
1146
+ *
1147
+ * 活跃会话超过上限时,最久没动过的**空闲**会话会被顺带冷却掉 ——
1148
+ * 它从此不在 Hub 里,但**仍然在会话列表里**(列表的另一半来自 DB),
1149
+ * 重新打开时历史走 `GET /api/sessions/:id/messages` 回放。
1150
+ */
1151
+ register(session: HubSession): void;
1152
+ has(sessionId: string): boolean;
1153
+ /** 这个 id 是被冷却掉的(而不是从来没存在过)吗 */
1154
+ isCooled(sessionId: string): boolean;
1155
+ /** 不认识的会话按 idle 报 —— 调用方要区分的话先问 `has()` */
1156
+ stateOf(sessionId: string): WireTurnState;
1157
+ /** Hub 手里这些会话此刻的样子。Map 的插入序 = 注册序 */
1158
+ listSessions(): HubSessionState[];
1159
+ /**
1160
+ * 把一个会话从 Hub 里摘掉(`DELETE /api/sessions/:id`)。
1161
+ *
1162
+ * 收尾按 §4.3 那套:**放弃**挂起的请求(不是逐个 deny)+ 中止在跑的回合 +
1163
+ * 丢掉排队的消息。删掉的会话不进冷却名单 —— 它是真没了,不是被挤走。
1164
+ *
1165
+ * @returns Hub 里本来有没有它
1166
+ */
1167
+ unregister(sessionId: string): boolean;
1168
+ /** 当前活着的 SSE 连接数。给 `/api/config` 和用例看 */
1169
+ get connectionCount(): number;
1170
+ /**
1171
+ * 接一条 SSE 连接。
1172
+ *
1173
+ * 同步完成三件事,顺序不能换:先 `connected`(把响应头刷出去、证明流通了),
1174
+ * 再按 `Last-Event-ID` 补发或回 `stream-reset`,最后才登记进扇出集合 ——
1175
+ * 反过来的话补发和实时帧会交织,页面上就是一段乱序的对话。
1176
+ *
1177
+ * @param lastEventId 客户端带上来的断点。省略 = 全新的流(历史归 GET /messages)
1178
+ * @returns 取消订阅。**幂等**,连接关闭和服务 close 会各调一次
1179
+ */
1180
+ subscribe(sink: FrameSink, lastEventId?: number): () => void;
1181
+ /**
1182
+ * 同进程旁听广播(方案 54 §三)。**不是一条连接。**
1183
+ *
1184
+ * 和 `subscribe()` 的差别不是「少发一帧」,是它整条不进那套连接语义:
1185
+ *
1186
+ * - **不进 `sinks`**:不计入 `connectionCount`,不影响 idle-grace 的收摊
1187
+ * (`armGrace()` → `reap()`),也不影响 `drainQueue()` 的「全断了就丢队列」。
1188
+ * 那三处判的是「**人**还在不在」,而旁听者是宿主的代码,不是一双眼睛
1189
+ * - **不发 `connected`、不认 `Last-Event-ID`、不从环形缓冲补帧**:补帧机制存在的
1190
+ * 理由是网线断过一段,而同进程的函数调用不会丢帧。真要历史就走
1191
+ * `GET /api/sessions/:id/messages`,和浏览器同一条路
1192
+ *
1193
+ * 收到的是**和 SSE 上同一个信封对象**(同一个 `seq`),所以「宿主看到的和浏览器
1194
+ * 看到的是同一条流」不需要额外的对齐机制。四种状态怎么映射到宿主界面上的一枚灯,
1195
+ * 那是宿主的产品决定,不在这里做。
1196
+ *
1197
+ * @returns 取消旁听。**幂等** —— 多调几次不会误删后来注册的同一个函数
1198
+ */
1199
+ observe(sink: FrameSink): () => void;
1200
+ /**
1201
+ * 发一条消息。会话空闲就立刻开一轮,否则**排队**(方案 30 §2.3 第 4 条)。
1202
+ *
1203
+ * 排队而不是并发:`AgentSession` 的历史累积发生在 `run()` 的末尾,两轮并发跑
1204
+ * 会让历史顺序变成不确定的 —— 那不是「偶尔乱序」,是每次重开会话看到的对话
1205
+ * 都可能不一样。**不同会话之间不受这条限制**,它们各跑各的。
1206
+ *
1207
+ * 这是对方案 20 验收第 7 条(「running 时第二个 POST 直接 409」)的**有意
1208
+ * 修改**:那时只有一个会话,409 是唯一能给的答复;现在排队和「不交织」
1209
+ * 这个不变量并不冲突,而 409 会逼每个客户端自己实现一遍重试。
1210
+ *
1211
+ * @param decorate 给这一轮的事件流套一层(见 {@link TurnDecorator})。
1212
+ * 自定义斜杠命令的工具收窄和临时模型走的就是它 —— Hub 自己不认识命令这个概念,
1213
+ * 只负责把这个函数**跟着消息一路带到轮次真正开始的那一刻**(排队也带着)。
1214
+ */
1215
+ start(sessionId: string, message: EpochUserContent, decorate?: TurnDecorator): StartResult;
1216
+ /**
1217
+ * 中止本轮,并**丢掉排在后面的消息**。
1218
+ *
1219
+ * 排队的一起丢是刻意的:用户按下「停」要的是「现在别干了」,而不是
1220
+ * 「停掉这一轮然后立刻开始下一轮」—— 后者的表现是按了停之后 agent 又动起来了。
1221
+ *
1222
+ * @returns 是否真有一轮在跑(**只看这个**,丢掉的队列不算)。
1223
+ * `false` 表示这一轮本来就没在跑,不是失败
1224
+ */
1225
+ abort(sessionId: string): boolean;
1226
+ /** 补拉挂起的审批 —— 新连上来的页面靠它把弹层恢复出来(§4.3) */
1227
+ listPending(sessionId: string): SerializableApprovalRequest[];
1228
+ /** 补拉挂起的提问(方案 34 验收 9)。与 `listPending` 同构 */
1229
+ listPendingQuestions(sessionId: string): SerializableQuestionRequest[];
1230
+ /**
1231
+ * 答复一个审批。
1232
+ *
1233
+ * @returns `false` = 这个 requestId 不认识(重复答复 / 上一轮的 ID / 已被放弃)。
1234
+ * 调用方该回 404 而不是 500:用户在两个标签页上各点一下就会走到这里。
1235
+ */
1236
+ respondApproval(requestId: string, outcome: ApprovalOutcome, note?: string): boolean;
1237
+ /**
1238
+ * 答复一个提问(方案 34)。
1239
+ *
1240
+ * **另一个方法而不是给 `respondApproval` 加个联合入参**:两者的载荷没有交集,
1241
+ * 合成一个之后路由层要先猜「这个 requestId 是审批还是提问」,
1242
+ * 而猜错的表现是一个拼错的答案被判成合法的 `deny`。
1243
+ *
1244
+ * @returns `false` = 这个 requestId 不认识(重复答复 / 上一轮的 / 已被放弃)。
1245
+ * 调用方回 404,同审批。
1246
+ */
1247
+ respondQuestion(requestId: string, answer: QuestionAnswer): boolean;
1248
+ /**
1249
+ * 收摊:撤掉倒计时、放弃所有挂起的审批、中止所有在跑的回合。
1250
+ *
1251
+ * 进程收到 SIGINT 时立即调它(§4.3 最后一行),之后宿主再 `dispose()`。
1252
+ * 幂等。
1253
+ */
1254
+ shutdown(): void;
1255
+ /**
1256
+ * 广播一帧:占一个全局序号、进环形缓冲、扇出给所有连接和旁听者。
1257
+ *
1258
+ * **回传发出去的那个信封**(调用方绝大多数不看)。这是「一轮跑了多久」
1259
+ * 唯一合法的取数口:`clock` 上钉着「每产出一个信封恰好读一次」的不变量
1260
+ * (见 `sessions/registry.ts` 的 `lastActiveTick`),想知道某一帧的时刻
1261
+ * 只能从那一帧上拿,**不许再读一次挂钟**。
1262
+ */
1263
+ private publish;
1264
+ /** 连接级帧:`seq: 0`、没有 `sessionId`、不进缓冲、不写 SSE 的 `id:` */
1265
+ private connectionFrame;
1266
+ /**
1267
+ * 状态迁移。**只在真的变了的时候发帧** —— 否则并行审批会刷出一串重复状态。
1268
+ *
1269
+ * 回传那一帧的时刻(没发帧时 undefined),给 {@link beginTurn} 记回合起点用。
1270
+ */
1271
+ private setState;
1272
+ /**
1273
+ * 按「还挂着几条请求」把状态摆正。
1274
+ *
1275
+ * 只在回合真的在跑的时候动(`controller !== null`):回合已经收尾之后
1276
+ * 状态该是 idle,这里不能把它拽回 running。
1277
+ *
1278
+ * **审批优先于提问**:两者同时挂着时报 `awaiting-approval`。理由是界面上
1279
+ * 审批层压在提问层上面(它是安全边界,不该被一个选择题挡住),
1280
+ * 而状态栏那句话得和用户眼前看到的那个框对上。
1281
+ */
1282
+ private syncApprovalState;
1283
+ /**
1284
+ * 开一轮:换一副新的登记表、拉起中止闸、广播状态、然后驱动。
1285
+ *
1286
+ * 状态帧必须在 `start()` 返回前就广播出去:POST 拿到 202 之后前端立刻可能
1287
+ * 再问一次状态,而第二个标签页只靠这一帧才知道「现在正在跑」。
1288
+ */
1289
+ private beginTurn;
1290
+ /**
1291
+ * 独占那个 for-await,把事件扇出去。
1292
+ *
1293
+ * `serializeStream` 负责把 `approval-request` 里的闭包摘成 `requestId` 并登记
1294
+ * 进 relay —— 这一段是 runtime 现成的,不在这里重写一遍。
1295
+ *
1296
+ * ## `decorate` 套在**最里面**,紧贴 `run()`
1297
+ *
1298
+ * 顺序不能换:那一层管的是「这一轮引擎能看见哪些工具、用哪个模型」,
1299
+ * 而 `serializeStream` 只是把事件里的闭包摘掉 —— 套反了的话,收窄会在
1300
+ * 序列化那一层进出,而真正跑工具的 `run()` 在它外面,等于整层白做。
1301
+ */
1302
+ private drive;
1303
+ /**
1304
+ * 一轮跑完,把排在后面的那条接上(方案 30 §2.3 第 4 条)。
1305
+ *
1306
+ * 三个前提缺一不可,缺了就是「浏览器早就关了,服务端自己接着聊」:
1307
+ * 会话还在表里(没被删)、Hub 没收摊、且**还有连接活着**。
1308
+ */
1309
+ private drainQueue;
1310
+ private armGrace;
1311
+ private clearGrace;
1312
+ /**
1313
+ * 放弃挂起的审批并中止在跑的回合。
1314
+ *
1315
+ * **`abandon()` 而不是逐个 `deny`**:前者一个 promise 都不 resolve,随后的
1316
+ * `abort()` 让流收尾,引擎侧的 finally 才去兜底 —— 模型看到的是「这一轮被中止
1317
+ * 了」而不是「用户否了我五次」。后者会让模型换个方式再试一遍。
1318
+ */
1319
+ private reap;
1320
+ }
1321
+
1322
+ /**
1323
+ * 一个会话自己那三样(模型 / 权限档 / plan 模式)在**服务端眼里**的样子
1324
+ * ([方案 30](../../../../docs/verify/VERIFY_RECORD-30-web-multi-session.md) §六,2026-08-16)。
1325
+ *
1326
+ * ## 为什么单开一个文件,而不是挂在 `context.ts` 的 `WebRuntimeView` 上
1327
+ *
1328
+ * 这三样从这一轮起**一个会话一份**,所以它们的落点是
1329
+ * [factory.ts](./factory.ts) 的 `LiveSessionView`,不是那个进程级的视图 ——
1330
+ * 判据逐字同 2026-08-15 那次把 `checkpoints` 从 `WebRuntimeView` 上**删掉**
1331
+ * (不是留着不读)的处置:
1332
+ *
1333
+ * > 留着的话下一个写 handler 的人照样够得着它,而它编译得过、跑得通、
1334
+ * > 只是答错会话 —— 这一整条 bug 就是这么长出来的。
1335
+ *
1336
+ * 镜像声明住在这儿而不是直接写进 `factory.ts`,只有一条理由:
1337
+ * [model.ts](../model.ts) / [permission.ts](../permission.ts) /
1338
+ * [plan-mode.ts](../plan-mode.ts) 三个 handler 都要引它们,而那三个文件
1339
+ * 各自 import 了 `context.ts`(`ApiContext`),`context.ts` 又 import
1340
+ * `factory.ts` —— 把镜像放进 `factory.ts` 就是一个环,
1341
+ * 而 `import/no-cycle` 在 oxlint 里是 error。这个文件**什么都不 import**
1342
+ * (除了 protocol 的类型),所以它在依赖图的最底下。
1343
+ *
1344
+ * ## 同一条老规矩:`@epoch-agent/server` 不许 import `@epoch-agent/core`
1345
+ *
1346
+ * (`check-layers.mjs` 是硬闸门。)所以下面按用得到的字段再声明一遍,
1347
+ * runtime 的 `LiveSession` **结构上**正好满足它们 —— 对不上就是镜像漏了一块,
1348
+ * 装配那一行当场编译不过。收窄之后用例也能喂一个几十行的假对象。
1349
+ *
1350
+ * ## ⚠️ 三份镜像都**刻意漏掉了中文**
1351
+ *
1352
+ * runtime 那边 `SetSelectionResult` 上真有 `reason` / `warnings` 两个中文字段,
1353
+ * `PlanControl.enter()` 回的 `reason` 也是中文。镜像上没有的字段,投影里写不出来 ——
1354
+ * 那就是「中文不上网线」那道闸门本身(判据同 `model.ts` / `security.ts` 的文件头)。
1355
+ */
1356
+
1357
+ /** core 的 `ModelSelection` 在服务端眼里的样子 */
1358
+ interface SelectionView {
1359
+ /**
1360
+ * 钉成 {@link ProviderType} 而不是 `string`,判据同 `settings.ts` 那处 `layer`:
1361
+ * 引擎那边加一家 provider 而忘了同步时**装配那一行当场编译不过**。
1362
+ * 写成 `string` 的话,那家会一路发到浏览器然后在菜单里显示成一个查不到文案的空白。
1363
+ */
1364
+ provider: ProviderType;
1365
+ model: string;
1366
+ origin: ModelSelectionOrigin;
1367
+ }
1368
+ /**
1369
+ * core 的 `SetSelectionResult` 的镜像。
1370
+ *
1371
+ * ⚠️ **`reason` 和 `warnings` 两个字段刻意不在这上面**,虽然引擎侧真有 ——
1372
+ * 它们是中文散文(见文件头)。
1373
+ */
1374
+ type SetSelectionView = {
1375
+ ok: true;
1376
+ selection: SelectionView;
1377
+ contextLength: number;
1378
+ caveats: readonly WireModelCaveat[];
1379
+ } | {
1380
+ ok: false;
1381
+ rejection: WireModelRejection;
1382
+ };
1383
+ /**
1384
+ * **这个会话**的 `ModelControl` —— 这条路借 `get` / `set` / `reset` 三个动词,
1385
+ * 外加 2026-08-16 合并时收进来的 `enterTurn`。
1386
+ *
1387
+ * `drainNotices` 仍然刻意不借(它是轮末该拉的东西,判据在 [model.ts](../model.ts)
1388
+ * 的文件头)。
1389
+ *
1390
+ * 四样都是函数而不是拍下来的值:换模型这件事的全部意义就是它会变,
1391
+ * 快照等于让这个端点永远回装配时那一份。
1392
+ */
1393
+ interface SessionModelView {
1394
+ get(): SelectionView;
1395
+ set(ref: {
1396
+ provider?: ProviderType;
1397
+ model: string;
1398
+ }, ctx: {
1399
+ hasImages?: boolean;
1400
+ usedTokens?: number;
1401
+ }): SetSelectionView;
1402
+ reset(ctx: {
1403
+ hasImages?: boolean;
1404
+ usedTokens?: number;
1405
+ }): SetSelectionView;
1406
+ /**
1407
+ * 只换**这一轮**(自定义斜杠命令 frontmatter 的 `model:`),`restore` 无条件还原。
1408
+ *
1409
+ * ## 为什么它 2026-08-16 才进来,以及进来意味着什么
1410
+ *
1411
+ * `model.ts` 文件头原来那句「`enterTurn` 刻意不借」是这么说的:借它等于
1412
+ * 承认「这条路上有一个和 `run()` 成对的还原动作」,而那句话只有
1413
+ * [commands.ts](../commands.ts) 说得出口。**现在它说了** —— `scopeTurn()`
1414
+ * 那一轮的临时模型从这一格取,不再取引导会话那一份。
1415
+ *
1416
+ * 不收的后果不是少一个功能:`model:` 在第二个会话上**对自己无效**,
1417
+ * 而且**顺带把引导会话的选择换掉一轮**(A 正好在跑就用上了 B 点名的模型)。
1418
+ *
1419
+ * ⚠️ 返回值这一格是**唯一一处**镜像上带 `SetSelectionResult`(也就是带那两个
1420
+ * 中文字段)的地方,而它不违反文件头那道闸:`scopeTurn` 一个字都不读它,
1421
+ * **它一步都不会走上网线**。形状仍然必须写全 —— 少写它,这个镜像就传不给
1422
+ * runtime 的 `withModelScope`(函数返回值协变),判据逐字在
1423
+ * `commands.ts` 的 `ModelTurnsView` 上。
1424
+ */
1425
+ enterTurn(ref: ModelRef): {
1426
+ result: SetSelectionResult;
1427
+ restore: () => void;
1428
+ };
1429
+ }
1430
+ /**
1431
+ * **这个会话**的角色槽 —— 逐条专家(方案 57 §3.4,2026-08-17)。
1432
+ *
1433
+ * 两个动词,一个不多:`has` 给 `postMessage()` 判 400,`enterTurn` 给
1434
+ * `scopeTurn()` 套那一层。runtime 的 `RoleControl` **结构上正好满足它**。
1435
+ *
1436
+ * ## 为什么和 `SessionModelView.enterTurn` 一样住在这一层,而不是进程级
1437
+ *
1438
+ * 槽是 `assemble()` 里一个会话建一个,和那个会话的 `AgentLoop` 绑死 ——
1439
+ * 判据逐字同这个文件里其余几样:拿引导会话那一份去答别的会话,表现是
1440
+ * 「给 B 挑了专家,换掉的是 A 的身份」,而 B 那边一切正常、只是跑在自己的
1441
+ * 默认角色上。方案 57 §3.4 的草图上写的是 `runtime.roles`(进程级那张**角色表**),
1442
+ * 那是 PR-1 把槽落进 `assemble()` 之前的写法。
1443
+ *
1444
+ * ⚠️ **`enterTurn` 收名字不收对象**:`@epoch-agent/server` 不许 import
1445
+ * `@epoch-agent/core`,`AgentRole` 这个类型它够不着 —— 名字 → 角色的解析归
1446
+ * runtime 一处(`createRoleControl`),这一层只负责把网线上那个字符串递下去。
1447
+ */
1448
+ interface SessionRoleView {
1449
+ /**
1450
+ * 这个名字认得吗。
1451
+ *
1452
+ * **和 `enterTurn` 分开**,因为两件事发生在完全不同的两个时刻:判定在收下
1453
+ * 消息那一刻(认不出来就 400,这一条根本不发出去),进作用域要等轮次真正
1454
+ * 开始 —— 排队的消息可能等上几分钟。合成一个动词的话,那句 400 只能等到轮到
1455
+ * 它才说得出口,而那时用户早就以为消息发出去了。
1456
+ */
1457
+ has(name: string): boolean;
1458
+ /** 换上这一条的专家,返回还原函数。⚠️ 调用方必须把还原放进 `finally` */
1459
+ enterTurn(name: string): () => void;
1460
+ }
1461
+ /**
1462
+ * **这个会话**的权限档 —— `level` / `managed` / `setLevel` 三格,一格不多。
1463
+ *
1464
+ * ⚠️ `rules` / `shadows` / `audit` **刻意不在这上面**:那三样是安全中心那一屏的
1465
+ * 素材、而且是**进程级**的一份(规则表全进程一份、流水全进程一本)。放进来只会
1466
+ * 诱使下一个人把它们也塞进这条端点的载荷 —— 而那时它就成了
1467
+ * `GET /api/sessions/:id/security` 的第二个说法。判据同 `permission.ts` 的文件头。
1468
+ */
1469
+ interface SessionPermissionsView {
1470
+ /**
1471
+ * 这个会话此刻那一档。
1472
+ *
1473
+ * ✅ **安全中心那一屏(`GET .../security`)读的就是这一格**(2026-08-17 接上)。
1474
+ *
1475
+ * 在那之前它读的是 `security.ts` 的 `PermissionsView.level()` —— 进程那一份,
1476
+ * 也就是引导会话那一档,于是看着会话 B 时它印的是会话 A 的档位。收的那一笔
1477
+ * 只有一处:`api.ts` 的 `getSecurity()` 多传一个实参。
1478
+ *
1479
+ * ⚠️ **这一层仍然什么都不缺,别在这儿加格子。** 那一屏另外四样
1480
+ * (`rules` / `shadows` / `managed` / `audit`)是**进程级**的,判据在上面那段
1481
+ * 和 core 的 `permission/shared.ts` 里。把它们挪到这个接口上,表现是安全中心
1482
+ * 只剩下这一个会话判过的那几行 —— **一本漏了行的安全流水比没有账更坏,
1483
+ * 因为它看起来是完整的。**
1484
+ */
1485
+ level(): string;
1486
+ /**
1487
+ * 「哪一档改不成」那一格的原料。**只有一个布尔** —— `present` / `rulesOnly` /
1488
+ * `path` 三样归安全中心那一屏,判据在 `permission.ts` 的 `blockedLevels()` 上。
1489
+ */
1490
+ managed(): {
1491
+ bypassDisabled: boolean;
1492
+ };
1493
+ setLevel(level: PermissionLevel): SetLevelView;
1494
+ }
1495
+ /**
1496
+ * 一次换档的结果。**只有码,没有句子** —— 措辞归浏览器的两份 catalog
1497
+ * (runtime 的 `LevelChangeResult` 逐字同形)。
1498
+ */
1499
+ type SetLevelView = {
1500
+ ok: true;
1501
+ } | {
1502
+ ok: false;
1503
+ reason: 'managed-bypass-disabled';
1504
+ };
1505
+ /**
1506
+ * **这个会话**的 plan 模式。
1507
+ *
1508
+ * ⚠️ `forget()` 刻意不借,而理由从 2026-08-15 起就没变过:
1509
+ * 「用户自己设级别 ⇒ plan 模式那段区间作废」是引擎内部两件事的耦合,它住在
1510
+ * runtime 的 `SessionPermissions.setLevel()` 第 3 步。服务端手里同时握着
1511
+ * `setLevel` 和 `forget` 的话,「谁负责作废」立刻有两个答案,而漏调的那一侧
1512
+ * **不会红**:用户在 plan 模式里换完档一切正常,直到下一次计划批准把级别
1513
+ * 悄悄改回进入前那一档。
1514
+ */
1515
+ interface SessionPlanView {
1516
+ active(): boolean;
1517
+ from(): string | null;
1518
+ /**
1519
+ * 用户主动进入。已经在里面时回 `{ok:false}` —— `reason` 是给模型看的那句中文,
1520
+ * **这一层不往下发**(见 `WirePlanModeResponse`),所以镜像上只留 `ok`。
1521
+ */
1522
+ enter(): {
1523
+ ok: boolean;
1524
+ };
1525
+ /** 用户主动退出,恢复进入前那一档并回到底恢复成了什么;本来就不在模式里时 null */
1526
+ leave(): string | null;
1527
+ /** 已批准的计划正文;还没有就是 null */
1528
+ approvedPlan(): string | null;
1529
+ }
1530
+ /**
1531
+ * 「这段会话里出现过图片吗」——只借**读历史**这一件事。
1532
+ *
1533
+ * 收窄到「一串带 `parts` 的东西」而不是把 `AgentSession` 整个借过来:这一层要
1534
+ * 回答的问题只有「有没有 `type: 'image'`」,而 `AgentSession` 上还挂着
1535
+ * `run` / `compact` / `abort` 这些**引擎自己的生命周期**方法。
1536
+ *
1537
+ * ⚠️ 它 2026-08-16 从 `WebRuntimeView.session` 挪到了 `LiveSessionView.session` ——
1538
+ * 判据同这个文件里其余三样:`POST .../model` 现在对**任意一个活着的会话**成立,
1539
+ * 而「这段对话里有没有图片」问的必须是**那一段**(引导会话的历史里有图片、
1540
+ * 而 B 那段没有的话,B 换一个不认图的模型会被一道莫名其妙的闸拦下来)。
1541
+ */
1542
+ interface SessionHistoryFacts {
1543
+ getHistory(): readonly {
1544
+ parts?: readonly {
1545
+ type: string;
1546
+ }[];
1547
+ }[];
1548
+ }
1549
+
1550
+ /**
1551
+ * 这个会话**此刻**用哪个模型 —— `GET` / `POST /api/sessions/:id/model`
1552
+ * (方案 26 的 web 那一半,2026-08-15)。
1553
+ *
1554
+ * ## 为什么有这个文件
1555
+ *
1556
+ * TUI 有 `/model`、CLI 有 `--model`,而 web 的路由表里**一条都没有**:
1557
+ * `runtime.model` 那套 `ModelControl`(`runtime/src/build.ts`)早就做完了,
1558
+ * 缺的只是转出去这一步。`packages/web/README.md` 把这笔账记了三轮
1559
+ * (「要让用户换这个会话此刻的模型,得先在 server 加端点」)。
1560
+ *
1561
+ * ⚠️ **和 [settings.ts](./settings.ts) 那条写端点不是一回事,别合并。**
1562
+ * 那一条改盘上的配置文件、要重启才生效、答的是「以后默认用哪个」;
1563
+ * 这一条改的是这个进程里正在跑的那个选择、立刻生效、什么都不写盘。
1564
+ * 整张对照表在 `protocol/src/wire-model.ts` 的文件头。
1565
+ *
1566
+ * ## 结构镜像,不是 import
1567
+ *
1568
+ * `@epoch-agent/server` **不许 import `@epoch-agent/core`**(`check-layers.mjs`
1569
+ * 是硬闸门),而 `ModelControl` 的真身在 runtime、判据在 core 的
1570
+ * `provider/selection.ts`。所以这里按用得到的字段再声明一遍,
1571
+ * `EpochRuntime` **结构上**正好满足 —— 对不上就是镜像漏了一块,
1572
+ * 装配那一行当场编译不过。同 `settings.ts` / `security.ts` 的规矩。
1573
+ *
1574
+ * ### 只借三个动词,`enterTurn` / `drainNotices` 刻意不借
1575
+ *
1576
+ * 同 `plan` 那一处「`forget()` 刻意不借」的判据:
1577
+ *
1578
+ * - **`enterTurn`** 是斜杠命令 frontmatter 的 `model:` 那条路,它只管一轮、
1579
+ * 轮末无条件还原,而且**必须和一次 `run()` 成对**。web 上那一对的两半分别
1580
+ * 在 `commands.ts` 和 Hub 的 `TurnDecorator` 里,从这个端点上够得着它
1581
+ * 等于给下一个人一条「在这儿也能换一轮」的暗示 —— 而那条路上没有还原的对手方
1582
+ * - **`drainNotices`** 是**拉**模型(引擎攒下的提示,一轮收尾时拉一次)。
1583
+ * 它的正确落点是回合收尾那一帧,不是一个用户点出来的换模型请求。
1584
+ * 借到这儿的后果是:用户不换模型就永远看不到「这个模型连续失败太多次」,
1585
+ * 而那句话恰恰是他该去换模型的理由
1586
+ *
1587
+ * ## 中文一律不上网线:拒绝原因走**码 + 参数**
1588
+ *
1589
+ * core 的 `evaluateSelection()` 产出的是**码加参数**(`SelectionRejection`),
1590
+ * `reason` 那句中文只是它的一次渲染,给同进程宿主(TUI / CLI)用的。
1591
+ * 这一层**只转码,不转那句话** —— 服务端硬塞一句中文等于把界面语言钉死在
1592
+ * 服务进程的 locale 上,判据逐字同 `settings.ts` 文件头第 1 条。
1593
+ * 三种拒绝、两种代价的措辞在浏览器的两份 catalog 里。
1594
+ *
1595
+ * ⚠️ 这一处**特别容易图省事**:镜像上真有中文可拿(`reason` / `warnings`
1596
+ * 就在同一个对象上,字段名还写着「给用户看的」)。所以下面那两份镜像
1597
+ * **刻意漏掉了它们** —— 漏掉之后投影里根本写不出来,同 `security.ts` 那四处
1598
+ * 「刻意漏掉一个中文字段」的机制。
1599
+ *
1600
+ * ## ⚠️ 只剩一道闸了(2026-08-16)
1601
+ *
1602
+ * ```
1603
+ * 1. 工厂手里有没有这个会话的运行时 → 404 unknown-session
1604
+ * ```
1605
+ *
1606
+ * 这一节上一版是**三道**,中间那道是 409 `bootstrap-session-only`
1607
+ * (「`ModelControl` 挂在 runtime 上、不按会话分,所以只有最先打开的那个会话
1608
+ * 换得了」)。**那道闸这一轮整个拆了,因为它挡的那件事不存在了**:模型选择从
1609
+ * 2026-08-16 起一个会话一份(方案 30 §十四,落地在 runtime 的
1610
+ * `session-model.ts`),「A 标签页换了模型把 B 会话一起换了」这种错法
1611
+ * 在底座上已经说不出口。
1612
+ *
1613
+ * 第 3 道(503 `no-model-control`)**也一并没了,而这不是把它藏起来**:
1614
+ * provider 层起不来时这个进程**一个会话都建不出来**(`SessionRuntimeFactory`
1615
+ * 的 `assemble()` 当场回 null),于是第 1 道闸先答 404。硬把 503 挪到最前面
1616
+ * 的话,一个打错的 sessionId 会换回一句「这台机器没有 provider 层」——
1617
+ * 一句真话,但答的不是他问的那个问题。码本身留在契约上,这条端点不再发它。
1618
+ *
1619
+ * ## 问**工厂**,不问 Hub,也不问会话目录
1620
+ *
1621
+ * 这条路要的对手方是「这个会话在这个进程里的那份运行时」,而工厂就是那张表
1622
+ * 本身。Hub 那张表今天和它逐条相同(冷却时两边一起掉),但**问对了对象**
1623
+ * 比「今天恰好相等」强 —— 判据同 2026-08-15 那次把三条回退端点从
1624
+ * `WebRuntimeView.checkpoints` 改成 `checkpointsOf()` 的处置。
1625
+ *
1626
+ * ## `hasImages` 由**这一层**填,不收浏览器报的数
1627
+ *
1628
+ * 判据逐字同 `cli/src/tui-host.ts` 的 `sessionHasImages()`:界面只看得见
1629
+ * 自己画过的那些,`--resume` 接回来的历史、以及压缩掉的那一截里的图片,
1630
+ * 它一概不知道。让界面报这一项等于把一道真闸门交给一个看不全的人,
1631
+ * 而判成 false 的代价很具体:切到不认图的模型,下一轮把整段带图历史发出去,
1632
+ * provider 报 400 且**已经计费**。
1633
+ *
1634
+ * 另一项(`usedTokens`)反过来**只有浏览器有**,见 {@link readModelSetBody}。
1635
+ *
1636
+ * ## ✅ 2026-08-16 合并时收掉了(原文是一张 📮,留着当判据)
1637
+ *
1638
+ * 自定义斜杠命令 frontmatter 的 `model:`(「这一条命令只换这一轮」)走的是
1639
+ * [commands.ts](./commands.ts) 的 `scopeTurn()`,它读的**曾经**是 `runtime.model`
1640
+ * 那一格 —— 也就是引导会话那一份。模型选择这一轮变成会话级之后,那条路在
1641
+ * 第二个会话上的表现是:
1642
+ *
1643
+ * - 那条命令的 `model:` **对这个会话一点作用都没有**(B 的循环读的是 B 自己
1644
+ * 那个 router,而 `enterTurn` 换的是引导会话那个);
1645
+ * - 更糟的一半:它**顺带把引导会话的选择换掉了一轮**。A 正好在跑的话,
1646
+ * A 那一轮会用上 B 那条命令点名的模型。
1647
+ *
1648
+ * ⚠️ **这不是这一轮引入的一个新 bug,是一个旧 bug 换了形状**:在会话级之前,
1649
+ * B 那条命令换的是**所有人共用**的那个选择,A 同样会被拖下水。两版都错,
1650
+ * 而这一版多了「对 B 无效」这一半。如实记着,别当成没动过。
1651
+ *
1652
+ * **写这张 📮 的那一轮不改**:`commands.ts` / `api.ts` 不归它,而正确的改法要动
1653
+ * 它们两个([方案 30 §十二](../../../docs/verify/VERIFY_RECORD-30-web-multi-session.md)
1654
+ * 的第 3 条:不绕过去、不伸手改、就地写下来)。合并的那一轮照着三步收完了:
1655
+ *
1656
+ * 1. `RuntimeCommandModel`(`commands.ts` 上那个 `model: ModelTurnsView | null`)
1657
+ * **整个删掉**了 —— 它借的恒是进程那一份。`expandUserContent()` /
1658
+ * `scopeTurn()` 改成从调用方收这一格,形状照 `checkpointsOf(ctx, sessionId)`;
1659
+ * 2. 真源本来就在手边:`ctx.runtime.sessionFactory.get(sessionId)?.model`。
1660
+ * **runtime 一个字没加** —— `ModelControl` 上五个动词一直都全,
1661
+ * 只差 server 这一跳;
1662
+ * 3. `enterTurn` 加进了 `sessions/scope.ts` 那份镜像。原文说这一步「有意留给
1663
+ * 收的人做」,因为加它等于承认「这条路上有一个和 `run()` 成对的还原动作」——
1664
+ * 那句话由 `commands.ts` 说了,判据抄在 `SessionModelView.enterTurn` 上。
1665
+ *
1666
+ * ⚠️ 一处**没有**跟着变的:取不到会话(冷却中、或 `sessionFactory` 里没有)时
1667
+ * 传 null,`withModelScope` 对 null 是零开销直通 —— 命令正文照跑,只有 `model:`
1668
+ * 那一半不生效。**和 provider 没配起来时同一档**,判据在 `api.ts` 那一处。
1669
+ */
1670
+
1671
+ /**
1672
+ * `ModelControl` 的镜像 —— 这条路上要的 `get` / `set` / `reset` 三个动词,
1673
+ * 外加 `enterTurn`(**这一层一个字都不读它**,见下)。`drainNotices` 不借,
1674
+ * 判据见文件头。
1675
+ *
1676
+ * 三样都是函数而不是拍下来的值:换模型这件事的全部意义就是它会变,
1677
+ * 快照等于让这个端点永远回装配时那一份。
1678
+ *
1679
+ * ## 为什么还 `extends ModelTurnsView`
1680
+ *
1681
+ * 这条路要 `get` / `set` / `reset` 三个,`ModelTurnsView` 那一格
1682
+ * (`enterTurn`)2026-08-16 起**已经没有第二个消费者从 `runtime` 上取它了**
1683
+ * —— 斜杠命令那条路改成从会话那份镜像取(`sessions/scope.ts` 的
1684
+ * `SessionModelView.enterTurn`)。
1685
+ *
1686
+ * 那为什么不把 `enterTurn` 从这儿摘掉?因为**这一份镜像照的是引导会话那个
1687
+ * `ModelControl` 本体**,而它身上就是有这个动词。摘掉等于让镜像比本体窄,
1688
+ * 下一个人会以为「进程那一份没有 enterTurn」——真相是「有,但没人该从这儿拿」。
1689
+ * 那句话由上面 ✅ 那一节说,不由类型形状说。
1690
+ */
1691
+ interface ModelControlView extends ModelTurnsView {
1692
+ get(): SelectionView;
1693
+ set(ref: {
1694
+ provider?: ProviderType;
1695
+ model: string;
1696
+ }, ctx: {
1697
+ hasImages?: boolean;
1698
+ usedTokens?: number;
1699
+ }): SetSelectionView;
1700
+ reset(ctx: {
1701
+ hasImages?: boolean;
1702
+ usedTokens?: number;
1703
+ }): SetSelectionView;
1704
+ }
1705
+ /**
1706
+ * 这段会话里出现过图片吗 —— 只借**读历史**这一件事。
1707
+ *
1708
+ * 形状本体 2026-08-16 搬去了 [sessions/scope.ts](./sessions/scope.js)
1709
+ * (`SessionHistoryFacts`):这条路要问的是**这一段**会话的历史,而那份形状
1710
+ * 现在挂在 `LiveSessionView.session` 上。这个别名留着是因为
1711
+ * `context.ts` 的 `WebRuntimeView.session` 仍然引它 —— 那一格答的是
1712
+ * **引导会话**,形状一个字没变。
1713
+ */
1714
+ type ModelSessionFacts = SessionHistoryFacts;
1715
+ /**
1716
+ * server 从 runtime 借的「模型」那一片。`WebRuntimeView` 继承它。
1717
+ *
1718
+ * **可以为 null** —— provider 层起不来时这个进程整个没有「换模型」这回事,
1719
+ * 那时读端点回 `selection: null`、写端点回 503(`EpochRuntime.model` 上是同一句)。
1720
+ * 不假装成功:回 200 的话,界面会画上新模型名,而引擎下一轮照旧用旧的。
1721
+ */
1722
+ interface RuntimeModel {
1723
+ model: ModelControlView | null;
1724
+ /** 配置里那个模型(`reset` 的目标)。`EpochConfig.model` 结构上满足它 */
1725
+ config: {
1726
+ model: string;
1727
+ };
1728
+ }
1729
+
1730
+ /**
1731
+ * 「我们支持哪几家、这一家有哪些模型」 —— `GET /api/providers` 和
1732
+ * `POST /api/providers/:type/models`。
1733
+ *
1734
+ * ## ⚠️ 这两条**进程级,URL 上一个 `:id` 都没有**
1735
+ *
1736
+ * 判据同 `POST /api/mcp/:name/reconnect` 和 `GET /api/workspaces/dirs`:
1737
+ * provider 清单和模型清单**不属于某一段会话**。挂到 `/sessions/:id/` 底下会得到
1738
+ * 一组名字是会话级、行为是进程级的端点 —— 比不一致更坏(`packages/web/README.md`
1739
+ * 那一节逐字记着这条)。这一下的作用域由**界面在按下去之前说清**,不由 URL 说。
1740
+ *
1741
+ * ## 为什么是两条而不是一条
1742
+ *
1743
+ * 它们的**代价差三个数量级**,合成一条就必然有一半是浪费:
1744
+ *
1745
+ * - `GET /api/providers` 读的是一张编译期常量表加一次凭据探测,**没有网络**。
1746
+ * 界面一打开就要它(不然 provider 那个选择器画不出来)。
1747
+ * - `POST /api/providers/:type/models` 是一发**真的网络请求**(10 秒超时),
1748
+ * 只在用户点了某一家之后才该发。
1749
+ *
1750
+ * 合成一条的话,要么每次开菜单都替用户去 provider 那儿探一次,
1751
+ * 要么第一次开菜单看不到 provider 单子。两条都不行。
1752
+ *
1753
+ * ## 后一条是 POST,而它**不是**因为「要带参数」
1754
+ *
1755
+ * 这一下有两处真实的副作用:带着用户那把 key 发一个外网请求、以及把结果
1756
+ * **写进磁盘缓存**。`auth.ts` 那道 Origin 校验只在非安全方法上要求 ——
1757
+ * 写成 GET 等于让任何一个页面都能驱使这台机器去 provider 那儿发请求。
1758
+ * 判据同 plan / rewind / settings / `POST .../model` 那几条。
1759
+ *
1760
+ * ## 结构镜像,不是 import
1761
+ *
1762
+ * `@epoch-agent/server` **不许 import `@epoch-agent/core`**(`check-layers.mjs`
1763
+ * 是硬闸门),而 `discoverModels()` 的真身在 core、收窄口在 runtime 的
1764
+ * `model-catalog.ts`。所以这里按用得到的字段再声明一遍,`EpochRuntime`
1765
+ * **结构上**正好满足 —— 对不上就是镜像漏了一块,装配那一行当场编译不过。
1766
+ * 同 `model.ts` / `settings.ts` / `security.ts` 的规矩。
1767
+ *
1768
+ * ## ⚠️ 这一层**一个中文字都不发**
1769
+ *
1770
+ * 四档「探到 / 探不到」走的是**码**(`WireModelProbeStatus`),措辞在浏览器的
1771
+ * 两份 catalog 里。判据逐字同 `settings.ts` 文件头第 1 条:服务端硬塞一句中文
1772
+ * 等于把界面语言钉死在服务进程的 locale 上。
1773
+ *
1774
+ * ⚠️ 而这一处**特别容易图省事**:`epoch model` 那边已经有一句现成的中文了
1775
+ * (`cli/src/commands/model.ts` 的 `describeDiscovery()`),照抄过来就能省一份
1776
+ * catalog。不许 —— 那一句是给同进程宿主(终端)拼的一次渲染,
1777
+ * 而且它只分两档(有没有 key),这一屏要分四档。
1778
+ */
1779
+
1780
+ /** 一个可挑的 provider 在服务端眼里的样子。runtime 的 `ProviderOption` 满足它 */
1781
+ interface ProviderOptionView {
1782
+ type: ProviderType;
1783
+ label: string;
1784
+ envVar: string | null;
1785
+ hasKey: boolean;
1786
+ }
1787
+ /** 探一家的结果。runtime 的 `ModelSuggestions` 满足它 */
1788
+ interface ModelSuggestionsView {
1789
+ provider: ProviderType;
1790
+ models: readonly string[];
1791
+ source: WireModelSource;
1792
+ status: WireModelProbeStatus;
1793
+ }
1794
+ /**
1795
+ * `ModelCatalogControl`(runtime)在服务端眼里的样子 —— **只有两个动词**。
1796
+ *
1797
+ * 镜像里只有这两个,于是这个包**写不出**「把 homeDir 拿来自己拼一个缓存路径」
1798
+ * 或者「读一把裸 key」这种事:那两样在收窄口上压根不存在。
1799
+ * 同 `McpControlView` 只有 `reconnect`、五份安全镜像各自漏掉一个中文字段。
1800
+ */
1801
+ interface ModelCatalogView {
1802
+ providers(): readonly ProviderOptionView[];
1803
+ discover(provider: ProviderType, opts?: {
1804
+ refresh?: boolean;
1805
+ }): Promise<ModelSuggestionsView>;
1806
+ }
1807
+ /**
1808
+ * server 从 runtime 借的「provider 目录」那一片。`WebRuntimeView` 继承它。
1809
+ *
1810
+ * ⚠️ **不可为 null,和 `RuntimeModel.model` 刻意不同档。** 那一个是「换模型」,
1811
+ * provider 层起不来就没有对手方;这一个是「有哪些可选」——
1812
+ * 而**恰恰是一把 key 都没配的那台机器最需要它**。判据在 runtime 那个字段上。
1813
+ */
1814
+ interface RuntimeModelCatalog {
1815
+ modelCatalog: ModelCatalogView;
1816
+ }
1817
+
1818
+ /**
1819
+ * `WebRuntimeView.schedules` —— **server 从 runtime 借的定时任务那一片**
1820
+ * ([方案 45](../../../../.agents/plans/45-scheduled-automation-plan.md) PR-3)。
1821
+ *
1822
+ * ## 为什么是一份结构镜像,而不是 `import type { ScheduleControl }`
1823
+ *
1824
+ * `@epoch-agent/server` 依赖得到 runtime,所以直接 import 那个类型在编译上是通的。
1825
+ * 不那么做有两条理由,第二条是硬的:
1826
+ *
1827
+ * 1. **`context.ts` 那句话**:这一层只借「用得到的那一小片」。`ScheduleControl`
1828
+ * 上有 `create` / `update` 的完整入参类型,而它们的字段来自 core
1829
+ * (`CreateScheduleInput`)—— 借整个接口等于让 `@epoch-agent/server` 的公开
1830
+ * `.d.ts` 引用一个它没声明依赖的包;
1831
+ * 2. **用例要造得出一个假的**。`server/__tests__/harness.ts` 的假 runtime 是手写
1832
+ * 对象(那份文件头写着理由:起一个真 runtime 要 provider、SQLite 和 MCP 子进程)。
1833
+ * 镜像用 protocol 的类型写完之后,那个假件一行 core 都不用碰。
1834
+ *
1835
+ * ⚠️ **镜像必须和真身逐字段对得上**,否则 `cli/src/commands/web.ts` 那一行
1836
+ * `createWebServer({ runtime })` 当场编译不过 —— 那正是这份镜像的全部强制力。
1837
+ * 别在这儿「顺手宽松一点」:`create` 的入参在 TS 里是逆变的,宽一格就意味着
1838
+ * 服务端能构造出一个真身收不下的请求。
1839
+ *
1840
+ * ## 判定、算术、缺省值**一样都不在这一层**
1841
+ *
1842
+ * 校验(`validateSchedule`)、注册进 OS(`ScheduleRegistrar`)、跑一次
1843
+ * (`fireSchedule`)、下一次触发时刻(`nextRunAt`)、读录像(`readRecording`)
1844
+ * 全在 core / runtime。这个目录只做两件事:**把 HTTP 翻成那几个方法**,
1845
+ * 以及**把结果投影成网线形状**。
1846
+ */
1847
+
1848
+ /**
1849
+ * 建一条任务要给的东西 —— core 的 `CreateScheduleInput` 那一份。
1850
+ *
1851
+ * ⚠️ **字段清单必须逐项相同**(含可选性),见文件头那条 ⚠️。
1852
+ * `id` / 时间戳 / `os*` 那几样不在这里:它们由 store 和注册器写。
1853
+ */
1854
+ interface ScheduleCreateView {
1855
+ name: string;
1856
+ prompt: string;
1857
+ /** 不给就落到 `~/.epoch/automation/<id>/`,**不是**服务进程的 cwd */
1858
+ workDir?: string;
1859
+ model?: string;
1860
+ permission: PermissionLevel;
1861
+ allowTools?: readonly string[];
1862
+ allowOperations?: readonly OperationType[];
1863
+ allowRules?: readonly string[];
1864
+ maxTurns?: number;
1865
+ /** **必填,没有默认值**(§3.5)。这一条摊在类型上,不靠注释 */
1866
+ maxBudgetUsd: number;
1867
+ timeoutMs?: number;
1868
+ trigger: ScheduleTrigger;
1869
+ startDate?: string;
1870
+ endDate?: string;
1871
+ enabled?: boolean;
1872
+ }
1873
+ /** 改一条任务 —— core 的 `UpdateScheduleInput`。没给的字段一个都不动 */
1874
+ type ScheduleUpdateView = Partial<ScheduleCreateView>;
1875
+ /** 存下来了没有 —— core / runtime 的 `ScheduleSaveResult` */
1876
+ interface ScheduleSaveView {
1877
+ /** 校验不过时 `undefined`,且库里一个字都没写 */
1878
+ schedule?: ScheduleDefinition;
1879
+ /** 一条都没有才算存下来了 */
1880
+ issues: WireScheduleIssue[];
1881
+ /** 没要求注册时不带 */
1882
+ registration?: ScheduleRegisterView;
1883
+ }
1884
+ /**
1885
+ * 注册进操作系统的结果 —— core 的 `RegisterOutcome`。
1886
+ *
1887
+ * 三种 `ok: false` 里只有 `backend-error` 是故障,另两种是如实作答 ——
1888
+ * 判据在网线那一份(`WireScheduleRegistration`)上。
1889
+ */
1890
+ type ScheduleRegisterView = {
1891
+ ok: true;
1892
+ warnings: 'source'[];
1893
+ } | {
1894
+ ok: false;
1895
+ reason: 'refused';
1896
+ refusal: 'npx' | 'no-host-runner';
1897
+ } | {
1898
+ ok: false;
1899
+ reason: 'unsupported-platform';
1900
+ } | {
1901
+ ok: false;
1902
+ reason: 'backend-error';
1903
+ detail: string;
1904
+ };
1905
+ /** 跑一次的结果 —— runtime 的 `FireResult` */
1906
+ interface ScheduleFireView {
1907
+ status: ScheduleRunStatus;
1908
+ exitCode: number;
1909
+ /** 真的落了一条运行记录时才有 */
1910
+ run?: ScheduleRun;
1911
+ /** 库里压根没有这条任务 */
1912
+ missing?: boolean;
1913
+ /** 任务停用着(非 `manual` 路径) */
1914
+ disabled?: boolean;
1915
+ detail?: string;
1916
+ }
1917
+ /** 这台机器 / 这个宿主能做到哪一步 —— runtime 的 `ScheduleCapability` */
1918
+ interface ScheduleCapabilityView {
1919
+ backend: ScheduleBackendKind | null;
1920
+ canRegister: boolean;
1921
+ }
1922
+ /**
1923
+ * 定时任务那一片能力。`EpochRuntime.schedules` 结构上正好满足它。
1924
+ *
1925
+ * ⚠️ **不可为 null**:底下只有一张 SQLite 表和一次平台探测,没有起不来的可能
1926
+ * (`build.ts` 上那个字段的 JSDoc 写着同一句)。本平台没有 OS 后端时
1927
+ * `capability()` 如实说 `backend: null` —— 那是一个答案,不是一个缺失的能力。
1928
+ */
1929
+ interface ScheduleControlView {
1930
+ list: () => ScheduleDefinition[];
1931
+ get: (id: string) => ScheduleDefinition | undefined;
1932
+ /** 某条任务最近几次运行,最新的在前 */
1933
+ runs: (id: string, limit?: number) => ScheduleRun[];
1934
+ /** 全部任务的最近运行 —— 「运行记录」那个 tab 的素材 */
1935
+ recentRuns: (limit?: number) => ScheduleRun[];
1936
+ capability: () => Promise<ScheduleCapabilityView>;
1937
+ create: (input: ScheduleCreateView, opts?: {
1938
+ register?: boolean;
1939
+ bypassAcknowledged?: boolean;
1940
+ }) => Promise<ScheduleSaveView>;
1941
+ update: (id: string, patch: ScheduleUpdateView, opts?: {
1942
+ register?: boolean;
1943
+ bypassAcknowledged?: boolean;
1944
+ }) => Promise<ScheduleSaveView>;
1945
+ remove: (id: string) => Promise<boolean>;
1946
+ setEnabled: (id: string, enabled: boolean) => Promise<ScheduleRegisterView>;
1947
+ /** 立刻跑一次。「先跑一次」那个按钮和 OS 触发跑的是同一段执行器 */
1948
+ fire: (id: string, opts?: {
1949
+ manual?: boolean;
1950
+ }) => Promise<ScheduleFireView>;
1951
+ /** 这一档权限档下那份清单有没有意义(界面据此把整块灰掉) */
1952
+ needsAllowlist: (permission: PermissionLevel) => boolean;
1953
+ }
1954
+
1955
+ /**
1956
+ * 会话 ↔ 工作区的绑定在服务端这一侧([决定 18](../../../../design/web-ui/README.md))。
1957
+ *
1958
+ * 三件事,都刻意**不在** [api.ts](../api.ts) 里:那个文件是「一个端点一个函数」,
1959
+ * 而这三件是端点之间共用的料。
1960
+ *
1961
+ * 1. runtime 那片能力的**结构镜像**(`@epoch-agent/server` 不许 import
1962
+ * `@epoch-agent/core`,而 `WorkspaceControl` 上挂着 `Workspace` /
1963
+ * `TrustDecision` / `ProjectContext` 三个 core 类型)
1964
+ * 2. 绑定 → Wire 载荷的投影
1965
+ * 3. `POST /api/sessions` 那个可选字段的解析,以及绑定失败 → HTTP 状态码的映射
1966
+ *
1967
+ * ## 为什么再声明一遍而不是 import runtime 的类型
1968
+ *
1969
+ * 同 `WebRuntimeView` / `SessionCatalog` 的理由:收窄到真正用得到的那几项,于是
1970
+ * 用例能喂一个几十行的假对象。`EpochRuntime.workspaces` **结构上**满足
1971
+ * {@link WorkspaceView} —— 对不上就是接口漏了一块,当场编译不过。
1972
+ */
1973
+
1974
+ /**
1975
+ * 一个已经绑好的工作区。三段刚好对上 runtime 的 `SessionWorkspace`:
1976
+ * 路径边界 / 信任判定 / 判定的产物。
1977
+ */
1978
+ interface BoundWorkspace {
1979
+ workspace: {
1980
+ root: string;
1981
+ extra: readonly string[];
1982
+ };
1983
+ /** `trusted: false` = 这个目录的指令文件**不进** system prompt */
1984
+ trust: TrustView;
1985
+ projectContext: {
1986
+ /** 真加载了的那几份 */
1987
+ instructions: ReadonlyArray<{
1988
+ path: string;
1989
+ }>;
1990
+ /** 存在、但被信任闸门挡下的那几份 */
1991
+ skippedInstructions: readonly string[];
1992
+ };
1993
+ /**
1994
+ * **额外根**里存在、但一律不加载的约定文件(安全中心要报它,方案 42 PR-3)。
1995
+ *
1996
+ * 和 `projectContext.skippedInstructions` 不是一回事:那一份是**主根**的、
1997
+ * 被信任闸门挡下、信任了就会加载;这一份是 `--add-dir` 进来的目录里那些、
1998
+ * **信任了也不会加载**。合并之后界面只能给一句话,而两处的出路完全不同。
1999
+ *
2000
+ * 为什么由 runtime 算好递过来:算它要 core 的 `unloadedInstructionFiles()`,
2001
+ * 而这个包不许 import core。
2002
+ */
2003
+ unloadedInstructions: readonly string[];
2004
+ }
2005
+ /**
2006
+ * `TrustDecision` 的镜像。
2007
+ *
2008
+ * **没有 `reason`** —— 那是「人类可读的判定依据,进启动 diagnostics」,也就是
2009
+ * 一句中文。浏览器要按自己的语言组句(同 `RoleNoticeView` 丢掉 `detail`)。
2010
+ *
2011
+ * `level` 和 `trusted` 两个都留着,因为它们能不一样、而差别处正是用户要看的:
2012
+ * `unknown` + 未信任 是「还没问过你」,`untrusted` + 未信任 是「你自己拒过」。
2013
+ */
2014
+ interface TrustView {
2015
+ trusted: boolean;
2016
+ /** 存储里的原始判定,**未经**降级规则处理 */
2017
+ level: TrustLevel;
2018
+ }
2019
+ /**
2020
+ * 绑定失败的四种原因。
2021
+ *
2022
+ * **逐字抄 runtime 那个联合而不是收成 `string`**:这四个值决定 HTTP 状态码
2023
+ * (`locked` 是 409,其余是 400),写成 `string` 之后 runtime 那边加第五种,
2024
+ * 这里会静默把它当成 400 —— 而那第五种完全可能该是别的码。
2025
+ */
2026
+ type WorkspaceBindFailure = 'not-absolute' | 'missing' | 'not-a-directory' | 'locked';
2027
+ type WorkspaceBindOutcome =
2028
+ /** `changed: false` = 本来就绑在这个目录上,这次是幂等的 */
2029
+ {
2030
+ ok: true;
2031
+ bound: BoundWorkspace;
2032
+ changed: boolean;
2033
+ } | {
2034
+ ok: false;
2035
+ reason: WorkspaceBindFailure;
2036
+ detail: string;
2037
+ };
2038
+ /**
2039
+ * 一个会话和工作区的关系(方案 55 §2.1)—— 逐字抄 runtime 的
2040
+ * `WorkspaceBindingState`,理由同上面 {@link WorkspaceBindFailure}:
2041
+ * 这三个值直接决定 `POST .../workspace` 收不收得动(只有 `unbound` 收),
2042
+ * 收成 `string` 之后 runtime 那边加第四档,这里会静默把它当成「收得动」。
2043
+ */
2044
+ type WorkspaceBindingState = 'bound' | 'none' | 'unbound';
2045
+ /** runtime 的 `WorkspaceControl` 在服务端眼里的样子 */
2046
+ interface WorkspaceView {
2047
+ of(sessionId: string): BoundWorkspace | null;
2048
+ /**
2049
+ * @param lang 失败那几档的 `detail` 用哪个语言渲染(方案 58 PR-2)。
2050
+ * 不给 = 进程语言,逐字节等于这一轮之前。
2051
+ *
2052
+ * ⚠️ **`detail` 是这条路上唯一跟语言走的东西**:`reason` 是契约
2053
+ * (`bindFailureToHttp` 拿它挑状态码和 `code`),一个字都不许翻。
2054
+ */
2055
+ bind(sessionId: string, dir: string, lang?: Lang): WorkspaceBindOutcome;
2056
+ /** 记一笔「建出来了、还没选地盘」(方案 55 PR-1)。已绑的会话上是空操作 */
2057
+ defer(sessionId: string): void;
2058
+ /**
2059
+ * 记一笔「用户明确选了**不使用**工作区」(会话库 v7,2026-08-19)。
2060
+ *
2061
+ * `POST /api/sessions/:id/workspace` 的 `{none:true}` 那一支走它。
2062
+ * 2026-08-19 之前那一支走的是 {@link release} —— 在内存里两者等价,
2063
+ * 换掉是因为这一档现在要落盘,而 `release()` 同时还是「建会话失败回滚」
2064
+ * 和「会话被删」两条路。判据全文在 runtime 的 `WorkspaceControl.decideNone` 上。
2065
+ */
2066
+ decideNone(sessionId: string): void;
2067
+ /** 这个会话是哪一档。**不答「这个进程认不认识它」**,那一问由 Hub 答 */
2068
+ stateOf(sessionId: string): WorkspaceBindingState;
2069
+ release(sessionId: string): void;
2070
+ /** 本机「已知工作区」清单,最近使用的在前 */
2071
+ known(): ReadonlyArray<{
2072
+ root: string;
2073
+ name: string;
2074
+ }>;
2075
+ /**
2076
+ * **任意**目录的信任判定,不要求它被哪个会话绑着(方案 42 PR-3)。
2077
+ *
2078
+ * {@link known} 给的清单一个信任状态都没有,而安全中心那张状态卡上写的是
2079
+ * 「N 个 · M 个未信任」—— 没有这个方法,M 只能拿 {@link of} 去数当前进程
2080
+ * 绑着的那几个,于是绝大多数行会显示成「未知」,而真相是它们各有各的记录。
2081
+ */
2082
+ trustOf(dir: string): TrustView;
2083
+ }
2084
+
2085
+ /**
2086
+ * 安全中心的取数与投影 —— `GET /api/sessions/:id/security`(方案 42 PR-3)。
2087
+ *
2088
+ * 不在 [api.ts](./api.ts) 里,同 [capability.ts](./capability.ts) 的理由:那个
2089
+ * 文件是「一个端点一个函数」,而这里有五份 runtime 能力的**结构镜像**加六段投影。
2090
+ *
2091
+ * ## 结构镜像,不是 import
2092
+ *
2093
+ * `@epoch-agent/server` **不许 import `@epoch-agent/core`**(`check-layers.mjs`
2094
+ * 是硬闸门),而隔离报告 / 权限规则 / 遮蔽发现 / 信任判定 / 审计流水五样的真身
2095
+ * 都在 core。所以这里按用得到的字段再声明一遍,`EpochRuntime` **结构上**正好
2096
+ * 满足 —— 对不上就是镜像漏了一块,装配那一行当场编译不过。
2097
+ *
2098
+ * ## 这一层的主要工作是**把中文挡在外面**
2099
+ *
2100
+ * 五份镜像里有四份刻意漏掉了一个字段,全是中文散文(判据见
2101
+ * `protocol/wire-security.ts` 的文件头那张表):
2102
+ *
2103
+ * - `IsolationView` 没有 `detail` —— 用 `reason` 那个码
2104
+ * - `ShadowView` 没有 `message` —— 用 `kind` + `rule` + `by`
2105
+ * - `ManagedView` 没有 `switches` —— 用 `rulesOnly` / `bypassDisabled`
2106
+ * - `AuditRowView` 没有 `reason` —— 用 `code` 那个码
2107
+ *
2108
+ * 漏掉是**编译期生效的**:镜像上没有那个字段,投影里就写不出
2109
+ * `iso.detail`,写了当场不过。这比在 review 里靠眼睛盯可靠。
2110
+ *
2111
+ * 第四条是这四条里最硬的一条:前三处丢的是一句可有可无的补充说明,
2112
+ * `PermissionAuditEntry.reason` 是审计流水那张表的**一整列**。
2113
+ *
2114
+ * ## 「进程有什么」和「这个会话有什么」
2115
+ *
2116
+ * 权限规则、托管锁、审计流水、策略目录四样今天是**进程级**的。前三样的判据在
2117
+ * core 的 [permission/shared.ts](../../core/src/permission/shared.ts) 文件头:
2118
+ * 它们是关于**用户**和**这台机器**的事实,跟着会话走反而会说假话(审批缓存
2119
+ * 分了家,「总是允许」在第二个会话上白点;流水分了家,这一屏会漏掉除引导会话
2120
+ * 之外的全部裁决)。策略目录是装配时读一次。工作区和它的信任判定是**会话级**
2121
+ * 的(决定 18)。URL 定成会话作用域是就着后者 —— 反过来定成 `/api/security`
2122
+ * 的话,那天要改的是两个包(同 capabilities 的判断)。
2123
+ *
2124
+ * ⚠️ **档位不在上面任何一档里 —— 它 2026-08-16 起是会话级的**(方案 30 §十四,
2125
+ * core 的 `PermissionManager.scoped()`)。这个文件 2026-08-16 之前发的是进程
2126
+ * 那一份,那是一处真缺陷;**2026-08-17 合并那一轮收掉了**,判据见下一节。
2127
+ *
2128
+ * ⚠️ 等方案 30 §6.3 的多会话工厂落地,策略目录也会跟着工作区变(项目那一级
2129
+ * 是 `<root>/.epoch/policies`),**那时这里要改**:`runtime.policy` 得按
2130
+ * `workspaces.of(sessionId)` 重算,而不是读装配时那一份。
2131
+ *
2132
+ * ## ✅ 收掉了(2026-08-17):`permission.level` 这一格曾经答的是**引导会话**
2133
+ *
2134
+ * 下面整节是那张 📮 的原文,**一个字没改**。它留着不是纪念品:这一格今天对了
2135
+ * 靠的是 `api.ts` 里那**一个实参**,而那一行看上去像是可有可无的第四个参数。
2136
+ * 哪天有人为了「少传一个东西」把它删回三参,症状就是下面写的那些 ——
2137
+ * 印错一个词、外加一张在最该发黄的时候是绿的卡。**判据比修法长,是因为
2138
+ * 这类 bug 编译得过、跑得通、每一条用例都绿。**
2139
+ *
2140
+ * {@link collectSecurity} 里那句 `perms.level()` 读的是 {@link PermissionsView},
2141
+ * 也就是 `WebRuntimeView.permissions`。runtime 的 `buildPermissionsControl()`
2142
+ * 把 `level` / `setLevel` 两格**整个转发给引导会话那一份**(那边逐字是
2143
+ * `level: () => bootstrap?.level() ?? services.config.permission`)。
2144
+ * 于是浏览器看着会话 B 时,安全中心那一格印的是会话 A 那一档。
2145
+ *
2146
+ * 这和 2026-08-15 治掉的 `config.workspace` 是同一类病(方案 30 §10.4 第 1 条):
2147
+ * **端点按会话取,载荷里那一格却是进程级的。** 契约那边早把它说死了 ——
2148
+ * `WirePermissionState.level` 的 JSDoc 逐字写着「安全中心那一格和底栏这一格印的
2149
+ * **必须是同一个名词**」,而底栏那一格从 2026-08-16 起按会话取
2150
+ * ([permission.ts](./permission.js) 的 `permissionState()`)。今天这两格能不一样。
2151
+ *
2152
+ * ⚠️ **代价不止是印错一个词。** 那一屏顶上四张状态卡的第一条纪律是「取最差的
2153
+ * 那一处」,而档位那张卡的色带就照这个值算:会话 B 在 `bypass` 上、引导会话在
2154
+ * `default` 上时,**那张卡是绿的**。一屏「我现在安不安全」的界面,在最该发黄的
2155
+ * 那一刻答了别人的状态。
2156
+ *
2157
+ * **缺的那一段只有一处,而它在 [api.ts](./api.ts) 里,那个文件这一轮不归我改**
2158
+ * (✅ 这一段就是收掉的那一笔,`getSecurity()` 里现在逐字是这个 diff):
2159
+ *
2160
+ * ```diff
2161
+ * // api.ts / getSecurity()
2162
+ * const payload = collectSecurity(
2163
+ * ctx.runtime,
2164
+ * ctx.runtime.workspaces,
2165
+ * ctx.runtime.workspaces.of(sessionId),
2166
+ * + ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null,
2167
+ * );
2168
+ * ```
2169
+ *
2170
+ * 这一层跟着收第四个参数
2171
+ * `session: Pick<SessionPermissionsView, 'level'> | null`(类型在
2172
+ * [sessions/scope.ts](./sessions/scope.js),**那一层已经镜像好了,不用动
2173
+ * core / runtime**),投影里 `level` 改成 `session?.level() ?? perms.level()` ——
2174
+ * 这个进程没在跑那段会话(只剩历史 / 被冷却)时退回进程那一份,那仍然是一句
2175
+ * 真话,判据逐字同 `permissionState()` 那条兜底。
2176
+ *
2177
+ * ⚠️ **`rules` / `shadows` / `managed` / `audit` 四样一个都不许跟着搬。**
2178
+ * 判据在 core 的 `permission/shared.ts`:它们**没有**跟着会话分家。搬过去的
2179
+ * 具体代价是这一屏只剩下这一个会话判过的那几行 ——
2180
+ * **一本漏了行的安全流水比没有账更坏,因为它看起来是完整的。**
2181
+ *
2182
+ * ⚠️ 这一轮**刻意没有**顺手加一个「不传就回落到进程那一份」的可选第四参。
2183
+ * 那个形状编译得过、跑得通、只是答错会话,正是 `context.ts` 删掉进程级
2184
+ * `checkpoints` 那一段点名的病;而且半接上的参数会让下一个人以为这条已经收了。
2185
+ * 要么四个实参一起到位,要么这一格照旧是一处**写在文件头上的**缺陷。
2186
+ *
2187
+ * > ✅ **收的时候按这句办了:第四参是必填的,不是可选的。** 唯一的调用点
2188
+ * > (`api.ts` 的 `getSecurity()`)传的是
2189
+ * > `sessionFactory.get(sessionId)?.permissions ?? null` —— **`null` 是显式的**,
2190
+ * > 由调用方写出来,不是省略出来的。回落发生在投影里
2191
+ * > (`session?.level() ?? perms.level()`),而不是发生在「忘了传」这件事上。
2192
+ * > 这两者的区别不在行为上(都退回进程那一份),在于**下一个新加调用点的人
2193
+ * > 会不会被编译器拦住**:可选参数不拦,必填参数拦。
2194
+ */
2195
+
2196
+ /**
2197
+ * `IsolationReport` 在服务端眼里的样子。
2198
+ *
2199
+ * **没有 `detail`** —— 那是给终端里的人读的一句中文散文。见文件头。
2200
+ */
2201
+ interface IsolationView {
2202
+ level: 'os' | 'process';
2203
+ backend: 'seatbelt' | 'bubblewrap' | 'none';
2204
+ reason: WireSandboxReason;
2205
+ platform: string;
2206
+ }
2207
+ /** `EffectiveRule` 的镜像。`layer` 是 `string`,服务端**如实转发**、不归一 */
2208
+ interface RuleView {
2209
+ bucket: 'allow' | 'ask' | 'deny';
2210
+ rule: string;
2211
+ layer: string;
2212
+ }
2213
+ /** `ShadowFinding` 的镜像。**没有 `message`** —— 中文,见文件头 */
2214
+ interface ShadowView {
2215
+ kind: 'shadowed' | 'partial' | 'redundant';
2216
+ rule: string;
2217
+ by: string;
2218
+ }
2219
+ /** `ManagedNote` 的镜像。**没有 `switches`** —— 那是「逐条一句人话」,见文件头 */
2220
+ interface ManagedView {
2221
+ present: boolean;
2222
+ path: string;
2223
+ rulesOnly: boolean;
2224
+ bypassDisabled: boolean;
2225
+ }
2226
+ /**
2227
+ * `PermissionAuditEntry` 的镜像。**没有 `reason`** —— 中文,见文件头。
2228
+ *
2229
+ * `code` 直接用 wire 上那个联合而不是 `string`,是刻意的:core 那边加一个新的
2230
+ * 原因码而忘了同步 `WireAuditCode` 时,**装配那一行当场编译不过**
2231
+ * (同 `IsolationView.reason` 用 `WireSandboxReason` 的机制)。写成 `string`
2232
+ * 的话,那个新码会一路发到浏览器,然后在界面上显示成一个查不到文案的空白格。
2233
+ */
2234
+ interface AuditRowView {
2235
+ at: number;
2236
+ toolName: string;
2237
+ type: OperationType;
2238
+ target: string;
2239
+ outcome: WireAuditOutcome;
2240
+ code: WireAuditCode;
2241
+ }
2242
+ /** `PermissionAuditSnapshot` 的镜像 */
2243
+ interface AuditView {
2244
+ entries: readonly AuditRowView[];
2245
+ dropped: number;
2246
+ }
2247
+ /**
2248
+ * `PermissionsControl` 的镜像。前五样都是函数,因为它们**每次读都是当前值** ——
2249
+ * 审计流水尤其如此,它每判一次就长一条。
2250
+ *
2251
+ * ## ⚠️ 第六样是**写**,而它的消费方不在这个文件里
2252
+ *
2253
+ * `setLevel` 是 2026-08-15 补的(决定 20 ③),用它的是
2254
+ * [permission.ts](./permission.js) 那条端点 —— 底栏那一格的真菜单。
2255
+ * 声明摆在这儿而不是那儿,判据同 `model.ts` 的
2256
+ * `ModelControlView extends ModelTurnsView` 那一节:`WebRuntimeView.permissions`
2257
+ * 是**一个**字段,两份镜像各声明一次的话,同时继承两者当场编译不过(TS2320)。
2258
+ * 这个文件是那个字段的主人(它最先镜像它),所以新格子加在这儿。
2259
+ *
2260
+ * **安全中心那一屏一个字都没跟着变**:它照旧只读前五样,
2261
+ * 「这一屏全是读」那条纪律没松(判据在 `wire-security.ts` 的文件头)。
2262
+ */
2263
+ interface PermissionsView {
2264
+ rules: () => readonly RuleView[];
2265
+ shadows: () => readonly ShadowView[];
2266
+ /**
2267
+ * ⚠️ **引导会话那一档,不是「这个会话」那一档。**
2268
+ *
2269
+ * 这个名字读起来像是进程级的一个事实,而档位从 2026-08-16 起一个会话一份 ——
2270
+ * runtime 的 `buildPermissionsControl()` 把这一格整个转发给引导会话
2271
+ * (`bootstrap?.level() ?? services.config.permission`)。**按会话取的那一份在
2272
+ * `LiveSessionView.permissions`**(`sessions/scope.ts` 的
2273
+ * `SessionPermissionsView`)。
2274
+ *
2275
+ * ✅ **2026-08-17 起载荷接上它了**:{@link collectSecurity} 的第四参赢过这一格,
2276
+ * 这一格只在工厂手里没有那段会话时兜底。**这个警告留着** —— 它描述的是这个
2277
+ * 类型自己的语义,而那句语义一个字都没变:读它读到的仍然是引导会话那一档。
2278
+ */
2279
+ level: () => string;
2280
+ /**
2281
+ * **配置里那一档** + 它赢在哪一层(方案 56 §1.3)。
2282
+ *
2283
+ * ⚠️ 和上面那个 {@link PermissionsView.level} 是**两个值**,而且这一个
2284
+ * **不跟着会话走**:它是一个配置事实,一个进程一份。所以上面那条
2285
+ * 「第四参赢过这一格」的规矩在这儿不适用,也不该适用 —— 两个值刻意不同步
2286
+ * (决定 20 ④),而界面上正是靠这一点画成两行的。
2287
+ */
2288
+ configured: () => {
2289
+ level: string;
2290
+ layer: 'builtin' | 'user' | 'env';
2291
+ };
2292
+ managed: () => ManagedView;
2293
+ audit: () => AuditView;
2294
+ /**
2295
+ * 切到某一档。**判定本体一个字都不在 server** —— 托管挡 bypass、
2296
+ * `PermissionManager.setLevel()`、`plan.forget()` 三步都在 runtime 的
2297
+ * `PermissionsControl.setLevel()` 里,理由在 `permission.ts` 的文件头。
2298
+ *
2299
+ * 返回类型钉成 `LevelChangeResult` 而不是 `{ok:boolean; reason?:string}`:
2300
+ * runtime 那边加一个拒绝码时,`permission.ts` 那张 `REFUSAL` 映射表
2301
+ * **当场编译不过**(同 `AuditRowView.code` 用 `WireAuditCode` 的机制)。
2302
+ * 写成 `string` 的话,新码会被静默报成 `managed`,而界面照着它说一句
2303
+ * 和真实原因无关的话。
2304
+ */
2305
+ setLevel: (level: PermissionLevel) => LevelChangeResult;
2306
+ /**
2307
+ * ⚠️ 第七样,消费方也不在这个文件里 —— 是 [tools.ts](./tools.js)
2308
+ * (设置 › 工具那一节)。摆在这儿的理由同上面 `setLevel` 那一段:
2309
+ * `WebRuntimeView.permissions` 是**一个**字段,两份镜像各声明一次的话,
2310
+ * 同时继承两者当场编译不过(TS2320)。
2311
+ *
2312
+ * **档位是参数,不是从 `level()` 读的**:那一格是引导会话那一档
2313
+ * (上面那条 ⚠️),而这张表每一行的判定都按档走 —— 在 runtime 里顺手读它的话,
2314
+ * 症状逐字是这个文件头那一整节记着的那件事,只是这次错的是一整张表。
2315
+ *
2316
+ * 返回的是**当下**的判定,别缓存:规则可以在两次请求之间被改盘、
2317
+ * 沙箱可以在第一次 `code_exec` 之后从 `os` 降到 `process`。
2318
+ */
2319
+ gates: (level: PermissionLevel) => readonly GateRowView[];
2320
+ }
2321
+ /**
2322
+ * `ToolGateRow` 的镜像 —— 一个工具此刻被谁挡着(方案 43 §七的「工具」那一节)。
2323
+ *
2324
+ * **这一份没有刻意漏掉的字段**,和上面四份镜像不同:`description` 是工具自己
2325
+ * 报的一句话(`WireToolSummary.description` 早就在网线上发着同一份),
2326
+ * 不是「给终端里的人读的散文」。
2327
+ *
2328
+ * `verdict` / `by` 直接钉成网线那两个联合而不是 `string`,同 `AuditRowView.code`
2329
+ * 的机制:core 那边给 `GateVerdict` 加一档而忘了同步契约时,**装配那一行当场
2330
+ * 编译不过**;写成 `string` 的话,新档会一路发到浏览器,在界面上显示成一个
2331
+ * 查不到文案的空白格。
2332
+ */
2333
+ interface GateRowView {
2334
+ name: string;
2335
+ description: string;
2336
+ source: string;
2337
+ type: OperationType;
2338
+ verdict: WireToolVerdict;
2339
+ by: WireToolGateAxis;
2340
+ rule?: string;
2341
+ layer?: string;
2342
+ conditional: number;
2343
+ }
2344
+ /** `PolicyStatus` 的镜像 */
2345
+ interface PolicyView {
2346
+ dirs: ReadonlyArray<{
2347
+ dir: string;
2348
+ source: 'user' | 'project';
2349
+ ruleCount: number;
2350
+ }>;
2351
+ projectSkipped: boolean;
2352
+ skippedDir: string | null;
2353
+ }
2354
+ /**
2355
+ * server 从 runtime 借的「安全」那一片。`WebRuntimeView` 继承它。
2356
+ *
2357
+ * `permissions` 可为 null:`PermissionManager` 起不来时整个 `PermissionsControl`
2358
+ * 就是 null(`buildPermissionsControl` 那边),而那时「有哪些规则」的诚实答案是
2359
+ * 「这一层没起来」,不是「零条规则」。
2360
+ */
2361
+ interface RuntimeSecurity {
2362
+ isolation: IsolationView | null;
2363
+ permissions: PermissionsView | null;
2364
+ policy: PolicyView;
2365
+ }
2366
+ /**
2367
+ * 五块一次取齐。
2368
+ *
2369
+ * ⚠️ 五块里**只有工作区那两样按会话取**(`bound` 那个参数)。
2370
+ * `permission.level` 今天走的是进程那一份,也就是引导会话那一档 ——
2371
+ * 那是一处真缺陷,缺的那一段和它的代价逐字写在文件头那节 📮 上。
2372
+ *
2373
+ * @param bound 这个会话绑的工作区(`workspaces.of(sessionId)`)。没绑就是 null,
2374
+ * 那时 `workspace` 是 null 而**不是**一个空壳 —— 「不使用工作区」是真的一档
2375
+ */
2376
+ declare function collectSecurity(runtime: RuntimeSecurity, workspaces: Pick<WorkspaceView, 'known' | 'trustOf'>, bound: BoundWorkspace | null, session: Pick<SessionPermissionsView, 'level'> | null): WireSecurityResponse;
2377
+
2378
+ /**
2379
+ * 检查点与回退的取数、投影与三条端点(方案 27 的 web 那一半)。
2380
+ *
2381
+ * ```
2382
+ * GET /api/sessions/:id/checkpoints 这个进程现在能退哪几轮
2383
+ * GET /api/sessions/:id/checkpoints/:turn 按下去之前先看:会动哪些文件
2384
+ * POST /api/sessions/:id/rewind 真的退
2385
+ * ```
2386
+ *
2387
+ * 引擎那三件套(`CheckpointControl.list` / `preview` / `rewind`)2026-05 就做完了,
2388
+ * 缺的一直是这一段传输 —— 方案 54 §1.2 那张表第一行的原话是「**今天没有主**。
2389
+ * web 里 `checkpoint` / `rewind` 零命中」。TUI 那边的形态(`/rewind` + Esc Esc
2390
+ * 那个面板)是现成的参照,交互设计照着它抄。
2391
+ *
2392
+ * 不在 [api.ts](./api.ts) 里,理由同 [capability.ts](./capability.ts) 和
2393
+ * [plan.ts](./plan.ts):那个文件是「一个端点一个函数」,而这里有一份 runtime 能力的
2394
+ * **结构镜像**加三段投影加一段请求体校验。
2395
+ *
2396
+ * ## 结构镜像,不是 import
2397
+ *
2398
+ * `@epoch-agent/server` **不许 import `@epoch-agent/core`**(`check-layers.mjs`
2399
+ * 是硬闸门),而 `CheckpointSummary` / `RewindPreview` / `RewindOutcome` 的真身
2400
+ * 都在 core。所以这里按用得到的字段再声明一遍,`EpochRuntime.checkpoints`
2401
+ * **结构上**正好满足它 —— 对不上就是镜像漏了一块,装配那一行当场编译不过。
2402
+ * 收窄到这几项还有一个好处:用例喂一个几十行的假对象就能跑。
2403
+ *
2404
+ * ⚠️ **别把这份镜像塞进 [workspace/diff.ts](./workspace/diff.ts) 的
2405
+ * `WorkspaceCheckpoints`。** 那个接口的注释写明了它为什么窄:「只要 `list` 和
2406
+ * `preview`:**快照和回退都不该从这里够得着**。这个端点是只读的,而 `rewind`
2407
+ * 会真的改用户的文件」。两个形状因此是**宽的赋给窄的**(`RuntimeCheckpoints`
2408
+ * 结构上满足 `WorkspaceCheckpoints`),diff 那边一行都不用改。
2409
+ *
2410
+ * ## 会话作用域:**向工厂要这个会话自己那一份**
2411
+ *
2412
+ * `CheckpointControl` 的会话 id **由它自己取**(runtime 的 `buildCheckpointControl`
2413
+ * 那句「不让宿主传:`/resume` 之后 `AgentSession.sessionId` 会变」),所以这一层
2414
+ * 不能拿一个 id 去问某一份控制面 —— 它得先拿到**对的那一份**。
2415
+ * 那一份在 {@link checkpointsOf}:`sessionFactory.get(sessionId)?.checkpoints`。
2416
+ *
2417
+ * ⚠️ **这三条端点 2026-08-15 之前读的是 `ctx.runtime.checkpoints`,那是引导会话
2418
+ * 那一份。** 判据写成 `sessionId === ctx.runtime.sessionId` 并叫它 `isLive()`,
2419
+ * 而在多会话工厂之前那两句话恰好同义。工厂落地当天它们分了家:一个进程里活着
2420
+ * N 个 `AgentSession`,每个各有一份 `CheckpointManager`(`workDir` 跟着自己绑的
2421
+ * 工作区走),于是第二个会话拿到的是「列表空数组 + 回退 409『不是本进程正在跑
2422
+ * 的那个』」——**而这个进程正握着它的检查点管理器**。全文与判据在方案 30 §9.5。
2423
+ *
2424
+ * 接对之后两种答法各自答的是真问题:
2425
+ *
2426
+ * - **列表**对「这个进程手里没有的会话」回**空数组**。它答的是「这个进程现在能退
2427
+ * 哪几轮」,而那个问题对一段只剩历史的会话的正确答案就是「一轮都不能」。
2428
+ * 界面据此不画按钮
2429
+ * - **preview / rewind** 对同样的会话回 409 `not-live-session`。它们是「对某一轮
2430
+ * 动手」,而浏览器只可能从上面那份(空的)列表里拿到 turnIndex —— 走到这儿
2431
+ * 说明请求不是这张界面发的,如实拒绝比默默退错一个会话强
2432
+ *
2433
+ * 那个码从今天起**只在这两处**用(`plan-mode.ts` / `model.ts` 换成了
2434
+ * `bootstrap-session-only`),判据见 {@link checkpointsOf}。
2435
+ */
2436
+
2437
+ /**
2438
+ * `CheckpointSummary` 在服务端眼里的样子。
2439
+ *
2440
+ * **没有 `cursor`** —— 那是消息表的主键,浏览器拿它什么都做不了。它唯一的用途
2441
+ * (`>= 0` 就能退对话)在 preview 那份里已经是一个布尔了,见
2442
+ * `WireRewindPreview.canRewindConversation`。镜像上没有这个字段是**编译期生效**的:
2443
+ * 投影里写不出 `summary.cursor`,写了当场不过。
2444
+ */
2445
+ interface CheckpointSummaryView {
2446
+ turnIndex: number;
2447
+ createdAt: number;
2448
+ preview: string;
2449
+ fileCount: number;
2450
+ incomplete: boolean;
2451
+ }
2452
+ /** `RewindAction` 的镜像。四个词逐字对齐 core,别在这一层重命名 */
2453
+ type RewindActionView = 'restore' | 'delete' | 'recreate' | 'skip';
2454
+ /** `RewindDrift` 的镜像。同上 */
2455
+ type RewindDriftView = 'as-agent-left-it' | 'changed-since' | 'unknown';
2456
+ interface RewindFilePlanView {
2457
+ path: string;
2458
+ action: RewindActionView;
2459
+ drift: RewindDriftView;
2460
+ }
2461
+ /** `RewindPreview` 的镜像。同样**没有 `cursor`**,理由见 `CheckpointSummaryView` */
2462
+ interface RewindPreviewView {
2463
+ turnIndex: number;
2464
+ createdAt: number;
2465
+ preview: string;
2466
+ canRewindConversation: boolean;
2467
+ incomplete: boolean;
2468
+ files: readonly RewindFilePlanView[];
2469
+ conflicts: readonly RewindFilePlanView[];
2470
+ }
2471
+ /** `RewindOutcome` 的镜像 */
2472
+ interface RewindOutcomeView {
2473
+ restored: readonly string[];
2474
+ deleted: readonly string[];
2475
+ recreated: readonly string[];
2476
+ skippedConflicts: readonly string[];
2477
+ /** 整体失败的原因;成功时缺席。**失败即什么都没改** */
2478
+ failed?: string;
2479
+ }
2480
+ /** `RewindResult` 的镜像 */
2481
+ interface RewindResultView {
2482
+ files: RewindOutcomeView | null;
2483
+ messagesRemoved: number | null;
2484
+ }
2485
+ /**
2486
+ * server 从 runtime 借的「检查点」那一片 —— `EpochRuntime.checkpoints`
2487
+ * (也就是 `CheckpointControl`)结构上正好满足它。
2488
+ *
2489
+ * ⚠️ **三个方法一起借**,不留「只借读的那两个」这条中间形态:那正是本轮之前的
2490
+ * 状态(`WorkspaceCheckpoints` 只有 `list` + `preview`,而且是给 diff 兜底用的),
2491
+ * 而它的表现是界面上一个回退入口都没有。
2492
+ */
2493
+ interface RuntimeCheckpoints {
2494
+ /** 这个会话有哪些检查点,**新的在前** */
2495
+ list(): Promise<readonly CheckpointSummaryView[]>;
2496
+ /** 回退之前先看:会动哪些文件、哪些因为漂过而不敢动。检查点不存在时 `null` */
2497
+ preview(turnIndex: number): Promise<RewindPreviewView | null>;
2498
+ /**
2499
+ * 真的退。
2500
+ *
2501
+ * `overwrite` 是用户**逐个点过头**的冲突文件路径;不在里面的冲突一律跳过。
2502
+ * 这里刻意没有、将来也不许长出一个 `force: true` —— 判据在
2503
+ * `WireRewindRequest.overwrite` 上。
2504
+ */
2505
+ rewind(turnIndex: number, opts: {
2506
+ scope: WireRewindScope;
2507
+ overwrite?: readonly string[];
2508
+ }): Promise<RewindResultView>;
2509
+ }
2510
+ declare function toWireCheckpoint(summary: CheckpointSummaryView): WireCheckpointSummary;
2511
+ declare function toWirePreview(preview: RewindPreviewView): WireRewindPreview;
2512
+ declare function toWireRewindResult(result: RewindResultView): WireRewindResponse;
2513
+ type RewindInput = {
2514
+ ok: true;
2515
+ value: WireRewindRequest;
2516
+ } | {
2517
+ ok: false;
2518
+ code: string;
2519
+ message: string;
2520
+ };
2521
+ /**
2522
+ * `POST /rewind` 的 body → 一个可执行的请求。
2523
+ *
2524
+ * ## `overwrite` 那一条不许静默降级
2525
+ *
2526
+ * 给了但不是字符串数组时**回 400**,而不是当成「没给」。判据是这个字段的语义:
2527
+ * 它是「用户逐个点过头的那几个文件」。静默当成 `[]` 的话,一个把它拼成
2528
+ * `{overwrite: true}` 的客户端会收到 200 + 一份「全都跳过了」的结果,
2529
+ * 而它以为自己刚刚覆盖成功了 —— 那种误会的代价是用户手写的改动被当成已经没了。
2530
+ *
2531
+ * 反过来 `force: true` 这类形状在这条路上**说不出口**:这里只认路径清单。
2532
+ */
2533
+ declare function readRewindInput(body: unknown, lang?: Lang): RewindInput;
2534
+
2535
+ /**
2536
+ * 多会话工厂在服务端眼里的样子([方案 30 §6.3](../../../../docs/verify/VERIFY_RECORD-30-web-multi-session.md))。
2537
+ *
2538
+ * 同 `WorkspaceView` / `SessionCatalog` 的规矩:`@epoch-agent/server` **不许
2539
+ * import `@epoch-agent/core`**,而 runtime 那个 `SessionFactory` 上挂着
2540
+ * `AgentSession` / `SessionWorkspace` 两个 core 侧的类型。所以这里按用得到的
2541
+ * 字段再声明一遍 —— `EpochRuntime.sessionFactory` **结构上**正好满足
2542
+ * {@link SessionFactoryView},对不上就是镜像漏了一块,装配那一行当场编译不过。
2543
+ *
2544
+ * 收窄之后用例能喂一个几十行的假对象(`__tests__/harness.ts` 的
2545
+ * `fakeSessionFactory`),而不用起一个真 provider。
2546
+ *
2547
+ * ## 建会话的三种结局,和它们各自的 HTTP 码
2548
+ *
2549
+ * | 结局 | 码 | 用户的下一步 |
2550
+ * | -------------------------- | --- | ------------------- |
2551
+ * | 建出来了 / 复用了一个 | 200 | 开始聊 |
2552
+ * | 路径不对(四种) | 400 / 409 | 改路径 |
2553
+ * | 专家名不认识 | 400 | 改专家名 |
2554
+ * | provider 没起来 | 503 | 去配一个 API key |
2555
+ *
2556
+ * 倒数第二档 2026-08-15 加的(决定 20 ② 的底座)。**400 而不是静默降级成
2557
+ * 不分角色**:判据逐字照 `--agent`(runtime 的 `pickMainRole` 抛 `AgentRoleError`)
2558
+ * —— 用户显式点了一个专家,给他跑一个能力范围不同的 agent 是最坏的结果。
2559
+ *
2560
+ * 最后一档是多会话工厂那一轮新出现的:在工厂之前,`POST /api/sessions` 回的恒是
2561
+ * 引导会话,而那个会话就算跑不了也**存在**,所以这条路答不出「建不出来」。
2562
+ * 现在能了 —— 而 503 是唯一诚实的答案:请求没毛病、路径没毛病,是这台机器还没配好。
2563
+ */
2564
+
2565
+ /**
2566
+ * runtime 的 `LiveSession` 在服务端眼里的样子 ——「谁」「怎么跑」「退到哪」,
2567
+ * 外加 2026-08-16 收窄进来的那三样(模型 / 权限档 / plan 模式)。
2568
+ *
2569
+ * ⚠️ **那三样以前挂在 `WebRuntimeView` 上,那是进程级的一份**,于是三条写端点
2570
+ * 对非引导会话只能一律 409 `bootstrap-session-only`(方案 30 §10.4 第 2 条 /
2571
+ * §9.5)。搬到这儿之后那三条 409 全部消失 —— 判据在 [scope.ts](./scope.ts)。
2572
+ */
2573
+ interface LiveSessionView {
2574
+ readonly sessionId: string;
2575
+ /**
2576
+ * `AgentSession` 结构上满足 `HubSession`(两个成员),Hub 直接收它。
2577
+ *
2578
+ * ⚠️ **2026-08-16 起还多要一个 `getHistory()`**({@link SessionHistoryFacts}):
2579
+ * 换模型那条路要问「**这段**对话里有没有图片」。写成交集而不是各借一个字段,
2580
+ * 判据同它原来在 `WebRuntimeView.session` 上那句:它们说的是**同一个对象**,
2581
+ * 拆成两个字段的话装配那边可以只填其中一个。
2582
+ */
2583
+ readonly session: HubSession & SessionHistoryFacts;
2584
+ /**
2585
+ * **这个会话此刻用哪个模型**。runtime 那边 provider 起不来时一个会话都建不出来,
2586
+ * 所以这一格不为 null。
2587
+ */
2588
+ readonly model: SessionModelView;
2589
+ /**
2590
+ * **这一条消息用哪个专家**(方案 57 §3.4,2026-08-17)。
2591
+ *
2592
+ * **不为 null**:一个角色都没加载出来时它照样在,只是 `has()` 恒 false ——
2593
+ * 那是一句真话,不是一个缺失的能力(同 `RuntimeCommands.commands` 那条判据)。
2594
+ * 于是 `POST /messages` 那一侧不用为「这个进程有没有专家」分支。
2595
+ */
2596
+ readonly roles: SessionRoleView;
2597
+ /**
2598
+ * **这个会话以什么身份跑**(会话级那一份,2026-08-18)。
2599
+ *
2600
+ * ⚠️ 和上面 `roles` 是两件事:这一格是**底座身份**(建会话时定,整段不变、决定
2601
+ * system prompt 和工具表),`roles` 是**逐条消息的临时顶替**(那枚 chip)。
2602
+ * 判据全文在 runtime 的 `LiveSession.role` 上。
2603
+ *
2604
+ * `null` = 这个进程压根没指定身份(也没有 `--agent`)。那是一句真话 ——
2605
+ * 界面上那一格整个不画,不是画一个 `general`(决定 20 ①)。
2606
+ */
2607
+ readonly role: ActiveAgentRole | null;
2608
+ /** **这个会话此刻是哪一档**。这个进程没有权限层时 null */
2609
+ readonly permissions: SessionPermissionsView | null;
2610
+ /** **这个会话的 plan 模式**。没有权限层就没有 plan 模式 */
2611
+ readonly plan: SessionPlanView | null;
2612
+ /**
2613
+ * **这个会话自己那一份**安全网(方案 27),2026-08-15 加。
2614
+ *
2615
+ * 它是这一层唯一够得着「第二个会话能退哪几轮」的地方:runtime 那边
2616
+ * `CheckpointManager` 按 `workDir` 解析相对路径、按 sessionId 分目录,两样都
2617
+ * 跟着会话走。在它之前三条回退端点读的是 `WebRuntimeView.checkpoints`
2618
+ * ——**引导会话那一份**,于是第二个会话被告知「一轮都不能退」,
2619
+ * 而这个进程正握着它的检查点管理器(方案 30 §9.5)。
2620
+ */
2621
+ readonly checkpoints: RuntimeCheckpoints;
2622
+ /** 这个会话绑在哪儿;没绑过 null */
2623
+ readonly workspace: BoundWorkspace | null;
2624
+ }
2625
+ type CreateSessionResult = {
2626
+ ok: true;
2627
+ live: LiveSessionView;
2628
+ created: boolean;
2629
+ }
2630
+ /** provider 没起来 —— 这个进程一个会话都跑不了。**没有 detail**,措辞归这一层 */
2631
+ | {
2632
+ ok: false;
2633
+ reason: 'unavailable';
2634
+ } | {
2635
+ ok: false;
2636
+ reason: WorkspaceBindFailure;
2637
+ detail: string;
2638
+ }
2639
+ /** 点名的专家不存在(决定 20 ②)。`detail` 是那个名字,措辞归这一层 */
2640
+ | {
2641
+ ok: false;
2642
+ reason: 'unknown-role';
2643
+ detail: string;
2644
+ };
2645
+ /**
2646
+ * 「这个会话当初把地盘定在哪儿」—— 从**库里**读回来的那一份决定
2647
+ * (`sessions.workspace_state` / `workspace_root`,会话库 v7)。
2648
+ *
2649
+ * 又一份镜像:runtime 那边它叫 `WorkspaceDecision`,而这一层不许 import core
2650
+ * 也拿不到那个类型。**两态就是全部** —— `null`(没记过)不在这个联合里,
2651
+ * 它是 `revive(decision: … | null)` 那个 `null`。
2652
+ */
2653
+ type PersistedWorkspaceDecision = {
2654
+ state: 'bound';
2655
+ root: string;
2656
+ } | {
2657
+ state: 'none';
2658
+ };
2659
+ /**
2660
+ * 复活的四种结局(2026-08-19)。判据全文在 runtime 的 `ReviveSessionOutcome` 上。
2661
+ *
2662
+ * `no-decision` **不是错误**:这个会话没有落盘的工作区决定(v7 之前的老行),
2663
+ * 于是这条路照旧回今天那个 404 —— 见 [revive.ts](./revive.ts)。
2664
+ */
2665
+ type ReviveSessionResult = {
2666
+ ok: true;
2667
+ live: LiveSessionView;
2668
+ } | {
2669
+ ok: false;
2670
+ reason: 'unavailable';
2671
+ } | {
2672
+ ok: false;
2673
+ reason: 'no-decision';
2674
+ } | {
2675
+ ok: false;
2676
+ reason: WorkspaceBindFailure;
2677
+ detail: string;
2678
+ };
2679
+ /** runtime 的 `SessionFactory` 在服务端眼里的样子 */
2680
+ interface SessionFactoryView {
2681
+ /** 装配时那个会话的 id */
2682
+ readonly bootstrapId: string;
2683
+ list(): readonly LiveSessionView[];
2684
+ get(sessionId: string): LiveSessionView | null;
2685
+ /**
2686
+ * @param input.lang 绑工作区失败时那句 `detail` 用哪个语言渲染(方案 58 PR-2)。
2687
+ * 不给 = 进程语言。**只影响 `detail`,`reason` 是契约**
2688
+ */
2689
+ create(input: {
2690
+ workspace: string | null;
2691
+ reuse: boolean;
2692
+ role?: string;
2693
+ lang?: Lang;
2694
+ }): CreateSessionResult;
2695
+ /**
2696
+ * 把一段**库里还在、这个进程手里没有**的会话装起来(2026-08-19)。
2697
+ *
2698
+ * 唯一调用点是 [revive.ts](./revive.ts)(`POST /messages` 上「发第一句话时
2699
+ * 才复活」那一步)。历史**不从这一层递进去** —— 它是 core 那种形状,
2700
+ * 而这个包不许 import core;runtime 那边用会话自己那份 store 读。
2701
+ *
2702
+ * @param input.decision 库里那份决定。**内存里那份优先**(冷却掉的会话
2703
+ * 绑定还在),两份都没有 → `no-decision`。判据全文在 runtime 那一侧
2704
+ */
2705
+ revive(input: {
2706
+ sessionId: string;
2707
+ decision: PersistedWorkspaceDecision | null;
2708
+ lang?: Lang;
2709
+ }): ReviveSessionResult;
2710
+ release(sessionId: string): void;
2711
+ }
2712
+ /**
2713
+ * 建不出来 → HTTP。
2714
+ *
2715
+ * **503 而不是 500**:服务本身是好的,是它依赖的那样东西(provider 凭据)
2716
+ * 还没就位。两者在界面上读起来完全不同 —— 500 让人去重启服务,
2717
+ * 503 加这句话让人去配 key。
2718
+ */
2719
+ declare function unavailableToHttp(lang?: Lang): {
2720
+ status: 503;
2721
+ code: string;
2722
+ message: string;
2723
+ };
2724
+
2725
+ /**
2726
+ * 会话列表的一行是怎么拼出来的(方案 30 §2.2)。
2727
+ *
2728
+ * ## 两个真源
2729
+ *
2730
+ * ```
2731
+ * Hub(这个进程知道的) DB(落盘的)
2732
+ * state / live title / model / messageCount
2733
+ * pendingApprovals costUsd / startedAt / updatedAt
2734
+ * pendingQuestions endedAt
2735
+ * pendingSince
2736
+ * turnStartedAt / lastFinish
2737
+ * \ /
2738
+ * └──► WireSessionSummary
2739
+ * ↑
2740
+ * workspace 这一格是**两边都能答**的那一个(会话库 v7,
2741
+ * 2026-08-19)—— 唯一一处真的要合,合法在
2742
+ * `toWireSummaryWorkspace` 上
2743
+ * ```
2744
+ *
2745
+ * 两边都可能单独存在,所以两边都不能当成必要条件:
2746
+ *
2747
+ * - **只有 Hub 有**:会话刚建出来、一条消息都还没落盘。DB 那半全 null,
2748
+ * 但它必须出现在列表里 —— 否则用户新建一个会话,列表上什么都没有
2749
+ * - **只有 DB 有**:上次进程留下的会话,或者本进程里被冷却掉的。它照样要出现,
2750
+ * `live: false` 说明「Hub 手里没有它」,重新打开时走 `GET /messages` 回放
2751
+ *
2752
+ * ## 排序
2753
+ *
2754
+ * **活着的排前面,然后按最后活动倒序。** 不是纯按时间:正在跑的那个会话是用户
2755
+ * 此刻在意的东西,把它排在一堆历史会话中间等于让人每次都去找。
2756
+ *
2757
+ * ⚠️ 第二段原来是「按开始时间」,2026-08-16 跟着 `WireSessionSummary.updatedAt`
2758
+ * 一起改 —— 理由(选谁进这一页和怎么排这一页必须同一个量)写在 `compareSummaries` 上。
2759
+ */
2760
+
2761
+ /**
2762
+ * 落盘会话的一行 —— `@epoch-agent/runtime` 的 `SessionSummary` 结构上满足它。
2763
+ *
2764
+ * 这里再声明一遍而不是 import 那个类型,理由和 `WebRuntimeView` 一样:
2765
+ * 收窄到真正用得到的那几项,于是用例能喂一个几十行的假对象。
2766
+ */
2767
+ interface CatalogRow {
2768
+ id: string;
2769
+ title: string;
2770
+ startedAt: number;
2771
+ /**
2772
+ * 最后一次动的时刻。**不可空** —— 落盘的每一行都有它(口径见 runtime 的
2773
+ * `SessionSummary.updatedAt`);网线上那一格可空,说的是另一件事
2774
+ * (「DB 里查不到这段会话」)。
2775
+ */
2776
+ updatedAt: number;
2777
+ endedAt: number | null;
2778
+ messageCount: number;
2779
+ model: string;
2780
+ costUsd: number;
2781
+ /**
2782
+ * 用户当初对「这段会话在哪儿干活」做的那次决定(会话库 v7,2026-08-19)。
2783
+ * `undefined` = 库里没记过(v7 之前的老行),网线上那是
2784
+ * {@link WireSummaryWorkspace} 的 `unknown` 档。
2785
+ *
2786
+ * ⚠️ **这是 DB 那半唯一一格「Hub 也答得出同一个问题」的字段**,所以它是
2787
+ * 这个文件里唯一一处**两个真源要合**的地方(其余每一格各归各家)。
2788
+ * 合法在 {@link toWireSummary} 的 `workspace` 那一行上。
2789
+ */
2790
+ workspaceState?: 'bound' | 'none';
2791
+ /** `workspaceState === 'bound'` 那一档的目录 */
2792
+ workspaceRoot?: string;
2793
+ }
2794
+ /** 一条 FTS5 命中 —— runtime 的 `SessionHit` 结构上满足它 */
2795
+ interface CatalogHit {
2796
+ sessionId: string;
2797
+ title: string;
2798
+ snippet: string;
2799
+ score: number;
2800
+ }
2801
+ /** 一次删除的结果 —— runtime 的 `SessionDeletion` 结构上满足它 */
2802
+ interface CatalogDeletion {
2803
+ deleted: boolean;
2804
+ checkpointsRemoved: boolean;
2805
+ }
2806
+ /**
2807
+ * 会话目录 —— server 从 runtime 借的第二片能力(第一片是 `sessionStore`)。
2808
+ *
2809
+ * `@epoch-agent/server` **不许 import `@epoch-agent/core`**,所以「列举 / 检索 /
2810
+ * 改名 / 删除」这些底下全是 `SessionManager` 的动作,只能由 runtime 转出来。
2811
+ * 起不来(SQLite 挂了)时整个是 `null`,列表退化成「只有 Hub 里那几个」。
2812
+ */
2813
+ interface SessionCatalog {
2814
+ list(limit?: number): CatalogRow[];
2815
+ get(sessionId: string): CatalogRow | null;
2816
+ rename(sessionId: string, title: string): CatalogRow | null;
2817
+ delete(sessionId: string): Promise<CatalogDeletion>;
2818
+ search(query: string, limit?: number): CatalogHit[];
2819
+ }
2820
+
2821
+ /**
2822
+ * 设置页的取数、投影与改写 —— `GET` / `POST /api/sessions/:id/settings`
2823
+ * (方案 43 §七)。
2824
+ *
2825
+ * 不在 [api.ts](./api.ts) 里,同 [security.ts](./security.ts) / [capability.ts](./capability.ts)
2826
+ * 的理由:那个文件是「一个端点一个函数」,而这里有一份 runtime 能力的**结构镜像**
2827
+ * 加几段投影。两个端点的处理函数照旧在 `api.ts`(`getSettings` / `writeSetting`),
2828
+ * 这里只管镜像和投影 —— 和 `plan.ts` / `plan-mode.ts` 那对**刻意不同**:
2829
+ * 那两个拆开是因为读写的作用域差一层(会话的一份文档 vs 进程的一个开关),
2830
+ * 而这里读和写答的是同一批键的同一件事,共用同一份镜像,拆开只会让两边各留一半。
2831
+ *
2832
+ * ## 结构镜像,不是 import
2833
+ *
2834
+ * `@epoch-agent/server` **不许 import `@epoch-agent/core`**(`check-layers.mjs`
2835
+ * 是硬闸门),而「每个键赢在哪一层」的真身在 core 的 `config/provenance.ts`。
2836
+ * 所以这里按用得到的字段再声明一遍,`EpochRuntime` **结构上**正好满足 ——
2837
+ * 对不上就是镜像漏了一块,装配那一行当场编译不过。
2838
+ *
2839
+ * ## 这一层的三处编译期闸门
2840
+ *
2841
+ * 1. **镜像上没有的字段,投影里写不出来。** 和 `security.ts` 那四处「刻意漏掉
2842
+ * 一个中文字段」是同一个机制。这一份没有中文候选字段可漏 —— 引擎侧的
2843
+ * `EffectiveSetting` 上只有 `key` / `value` / `layer` / `chain` /
2844
+ * `overridable` / `writes`,一个句子都没有。**行标题、单位、说明全在浏览器的
2845
+ * catalog 里**,key 就是那条点分路径。
2846
+ *
2847
+ * ⚠️ 写那一半的**失败理由**尤其要盯住这一条:core 那边 `writeSettingValue()`
2848
+ * 产出的是一个**码**(`unwritable-file` 之类),不是一句中文 —— 而它下面那些
2849
+ * 分支里真有中文可拿(`describeJsonError()` 就在隔壁)。哪天有人图省事把
2850
+ * 那句话透出来,中文就上了网线。措辞在两份 catalog 里,见 {@link WRITE_FAILURE}。
2851
+ * 2. **`layer` 钉成 {@link WireSettingLayer} 而不是 `string`。** 同
2852
+ * `AuditRowView.code` 那一处:引擎那边加一个新层而忘了同步 wire 上这个联合时,
2853
+ * **装配那一行当场编译不过**。写成 `string` 的话,那个新层会一路发到浏览器,
2854
+ * 然后在徽标上显示成一个查不到文案的空白格 —— 而这一屏存在的全部理由就是
2855
+ * 「告诉用户这个值从哪来」。
2856
+ *
2857
+ * ⚠️ 这一处和 `RuleView.layer: string`(安全中心那份**如实转发、不归一**)
2858
+ * 刻意不同,别照着那一个改回去:规则表上 `unknown` 是一档真会出现的值,
2859
+ * 而设置页这一份的层集合是闭的(core 的 `SettingLayer` 那个联合)。
2860
+ * 3. **{@link WRITE_FAILURE} 是一张 `Record<SettingWriteFailureCode, …>`。**
2861
+ * 引擎那边加一种写失败而忘了同步,这张表当场编译不过。漏一档的后果是那种失败
2862
+ * 落进 500,而 500 在界面上读作「服务坏了」—— 用户会去重启服务,
2863
+ * 而真相可能只是「你填的 maxTurns 不是正整数」。
2864
+ *
2865
+ * ## 「进程有什么」和「这个会话有什么」——✅ 2026-08-15 这笔账还了
2866
+ *
2867
+ * 这一节原来挂着一条欠账:「等方案 30 §6.3 的多会话工厂落地,③④ 会真的跟着
2868
+ * 工作区变,**那时这里要改**」。工厂那一轮同时改了这里。
2869
+ *
2870
+ * 现在的形状是:`collectSettings(runtime, sessionId)` 把会话 id 一路递给
2871
+ * `SettingsControl.rows(sessionId)` / `write({…, sessionId})`,由 runtime 自己去
2872
+ * `workspaces.of(sessionId)` 取工作区并重算 ③④。**这一层不解释那个 id**,
2873
+ * 只是传话 —— 理由见下一段。
2874
+ *
2875
+ * ## ⚠️ 为什么是「递一个 id」而不是「递工作区和信任判定两样」
2876
+ *
2877
+ * 那笔账里最要紧的一句是这个(原话留着,别删):
2878
+ *
2879
+ * - 读算错了,代价是界面上标错一个来源层徽标 —— 难看,看得出来,改一行就好
2880
+ * - 写算错了,代价是**往错的目录里写了一个文件**。`SettingWriteTarget.path`
2881
+ * (④ 写哪个 `.epoch/settings.local.json`)和信任判定(未信任时 ④ 里的标量
2882
+ * 整份被丢)必须属于**同一个工作区**;只把其中一个改成按会话取,表现就是
2883
+ * 「拿 A 工作区的信任判定去决定往 B 工作区写」,而那在任何一屏上都看不出来
2884
+ *
2885
+ * 所以这一层**递的是一个 sessionId,不是拆开的两个参数**:拆开就等于把「这两样
2886
+ * 必须同源」这条不变量交给调用点去守,而这一层有两个调用点(`getSettings` /
2887
+ * `writeSetting`)。递 id 之后,两样由 runtime 侧**同一个函数**一次取齐
2888
+ * (`makeSettingsScope`),「只改了其中一个」这种状态在类型上说不出口。
2889
+ *
2890
+ * 同一句话在 core 的 [config/write.ts](../../core/src/config/write.ts) 文件头
2891
+ * 第四节也记了一份 —— 因为改的人可能从任何一头进来。
2892
+ */
2893
+
2894
+ /** core 的 `SettingStep` 在服务端眼里的样子 */
2895
+ interface SettingStepView {
2896
+ layer: WireSettingLayer;
2897
+ value?: WireSettingValue;
2898
+ }
2899
+ /**
2900
+ * core 的 `SettingFutile` 的镜像 —— 「写进去了也不生效」的两种成因。
2901
+ *
2902
+ * `reason` 钉成闭的联合而不是 `string`,判据同下面 `layer` 那一处:界面按这个码
2903
+ * 去查一句**带下一步**的话(`shadowed` → 改写更高那层,`untrusted` → 去信任这个
2904
+ * 目录)。引擎那边加一种成因而忘了同步,装配那一行当场编译不过;写成 `string`
2905
+ * 的话,那个新成因会一路发到浏览器,然后显示成一句空话 —— 而这个字段存在的
2906
+ * 全部理由就是「别让用户白写一次」。
2907
+ */
2908
+ interface SettingFutileView {
2909
+ reason: WireSettingFutile['reason'];
2910
+ by?: WireSettingLayer;
2911
+ }
2912
+ /** core 的 `SettingWriteTarget` 的镜像 */
2913
+ interface SettingWriteView {
2914
+ layer: WireSettingWriteLayer;
2915
+ path: string;
2916
+ futile?: SettingFutileView;
2917
+ apply: WireSettingApply;
2918
+ }
2919
+ /**
2920
+ * runtime 的 `EffectiveSetting` 的镜像。
2921
+ *
2922
+ * 字段一个不多一个不少 —— 引擎侧那个类型上也只有这几样,所以这里没有
2923
+ * 「刻意漏掉的中文字段」那张表(见文件头)。
2924
+ */
2925
+ interface SettingRowView {
2926
+ key: string;
2927
+ value?: WireSettingValue;
2928
+ layer: WireSettingLayer;
2929
+ chain: readonly SettingStepView[];
2930
+ overridable: boolean;
2931
+ writes: readonly SettingWriteView[];
2932
+ }
2933
+ /** runtime 的 `SettingWriteReport` 的镜像。`reason` 是码,不是句子 —— 见文件头第 1 条 */
2934
+ type SettingWriteReportView = {
2935
+ ok: true;
2936
+ key: string;
2937
+ layer: WireSettingWriteLayer;
2938
+ path: string;
2939
+ value: WireSettingValue;
2940
+ futile?: SettingFutileView;
2941
+ apply: WireSettingApply;
2942
+ } | {
2943
+ ok: false;
2944
+ reason: SettingWriteFailureCode;
2945
+ path?: string;
2946
+ };
2947
+ /**
2948
+ * 写不成的四种理由 —— **和 core 的 `SettingWriteFailure` 逐字相同**。
2949
+ *
2950
+ * 在这儿再声明一遍而不是 import,是这一层通篇的规矩(server 不许 import core)。
2951
+ * 少一档的后果是 {@link WRITE_FAILURE} 那张表编译不过 —— 那正是要的,
2952
+ * 判据见文件头第 3 条。
2953
+ */
2954
+ type SettingWriteFailureCode = 'unknown-key' | 'bad-value' | 'unwritable-file' | 'io';
2955
+ /**
2956
+ * `SettingsControl` 的镜像。三样都是函数,因为它们**每次读都是当前值** ——
2957
+ * 今天这份配置在装配后不会变,但把它拍成快照等于替将来那次「运行期改配置」
2958
+ * 做了一个不该在这层做的决定(同 `PermissionsView` 那五个函数)。
2959
+ */
2960
+ interface SettingsView {
2961
+ /**
2962
+ * @param sessionId 按**这个会话**绑的工作区算 ③④(2026-08-15)。
2963
+ * 不给 = 引导会话那一份 —— 那是 CLI / TUI 这些单会话宿主唯一的答案。
2964
+ */
2965
+ rows: (sessionId?: string) => readonly SettingRowView[];
2966
+ overridableKeys: () => readonly string[];
2967
+ /**
2968
+ * ⚠️ `sessionId` 在这一支上**不是可选的精细化**,是那条「写哪个文件」和
2969
+ * 「按谁的信任判定」必须同源的不变量的载体。判据见文件头第三节。
2970
+ */
2971
+ write: (input: {
2972
+ key: string;
2973
+ layer: WireSettingWriteLayer;
2974
+ value: unknown;
2975
+ sessionId?: string;
2976
+ }) => SettingWriteReportView;
2977
+ }
2978
+ /**
2979
+ * server 从 runtime 借的「设置」那一片。`WebRuntimeView` 继承它。
2980
+ *
2981
+ * **不可为 null** —— 它读的是配置本身加两本已经记好的账,没有「起不来」这回事
2982
+ * (`EpochRuntime.settings` 上写着同一句)。
2983
+ */
2984
+ interface RuntimeSettings {
2985
+ settings: SettingsView;
2986
+ }
2987
+ /**
2988
+ * 一次取齐。
2989
+ *
2990
+ * `managedPath` 借的是**安全中心那一份**(`PermissionsControl.managed()`),
2991
+ * 不另算一遍:同一台机器上的同一个文件出现两个说法,是这个仓库反复吃过亏的形状。
2992
+ * 权限层起不来时它是 `null` —— 那时「托管文件在哪」的诚实答案是「说不出来」。
2993
+ *
2994
+ * **`present: false` 时也回 `null`**,虽然那份镜像里 `path` 照旧带着:
2995
+ * 这个字段只在有行赢在 `managed` 时用得上,而那一档必然 `present`。
2996
+ * 发一个不存在的文件路径过去,界面上只会多一个没人点得动的东西。
2997
+ */
2998
+ declare function collectSettings(runtime: RuntimeSettings & Pick<RuntimeSecurity, 'permissions'>,
2999
+ /**
3000
+ * 按**这个会话**绑的工作区算 ③④(2026-08-15,多会话工厂)。
3001
+ *
3002
+ * `managedPath` 不跟着它变 —— 企业托管是**机器级**的一个文件(第 ∞ 层),
3003
+ * 它跟工作区无关。同一屏上两个作用域并存不是疏漏,是这两层本来就不同级。
3004
+ */
3005
+ sessionId?: string): WireSettingsResponse;
3006
+
3007
+ /**
3008
+ * 端点处理函数共用的两个类型 —— **`server` 从 runtime 借了哪片能力**,以及
3009
+ * 一次请求手里握着什么。
3010
+ *
3011
+ * ## 为什么单开一个文件
3012
+ *
3013
+ * 依赖图必须单向、无环(`import/no-cycle` 在 oxlint 里是 error,而「只是个类型」
3014
+ * 在那条规则眼里不算理由)。这两个类型原来住在 [api.ts](./api.ts),那时所有
3015
+ * 端点也都在那儿,没问题;决定 18 之后端点分成了两处
3016
+ * (`api.ts` 和 [workspace/handlers.ts](./workspace/handlers.ts)),
3017
+ * 留在任何一处都会让另一处反过来 import 它 —— 那就是环。
3018
+ *
3019
+ * 现在的图是:`index → routes → {api, workspace/handlers} → context`。
3020
+ * `api.ts` 和 `index.ts` 各把它们再导出一次,公共 API 的形状不受影响。
3021
+ */
3022
+
3023
+ /**
3024
+ * server 从 runtime 借的那一小片能力。
3025
+ *
3026
+ * `@epoch-agent/server` **不许 import `@epoch-agent/core`**(与
3027
+ * examples/electron-minimal 同规矩):需要什么就让 runtime 转出来。
3028
+ * `sessionStore` 就是为此加到 `EpochRuntime` 上的 —— 回放旧会话要
3029
+ * `loadMessages()`,而那是 core 的 `SessionManager` 才有的东西。
3030
+ */
3031
+ interface WebRuntimeView extends RuntimeCapabilities, RuntimeSecurity, RuntimeSettings, RuntimeCommands, RuntimeModel, RuntimeModelCatalog {
3032
+ /**
3033
+ * 正在跑的那个会话。
3034
+ *
3035
+ * ⚠️ **两个形状的交集**,2026-08-15 起:Hub 要的是 `run()`(开一轮),
3036
+ * 换模型那条路要的是 `getHistory()`(这段对话里有没有图片,判据见
3037
+ * [model.ts](./model.js) 的文件头)。写成交集而不是各借一个字段,
3038
+ * 是因为它们说的是**同一个对象** —— 拆成两个字段的话,装配那边可以
3039
+ * 只填其中一个,而「历史是空的」和「没有会话」在闸门那一侧长得一模一样。
3040
+ * `AgentSession` 结构上同时满足两者。
3041
+ */
3042
+ session: (HubSession & ModelSessionFacts) | null;
3043
+ sessionId: string;
3044
+ /**
3045
+ * 多会话工厂(方案 30 §6.3)。`EpochRuntime.sessionFactory` 结构上满足它。
3046
+ *
3047
+ * **不可为 null** —— provider 起不来时它照样在,只是 `create()` 一律回
3048
+ * `unavailable`(那是一句真话,不是一个缺失的能力)。
3049
+ *
3050
+ * ⚠️ 它和上面那个 `session` 的分工:`session` 是**引导会话**,
3051
+ * `createWebServer()` 起来时 `hub.register()` 的就是它;工厂管的是「再开一个」。
3052
+ * 两者指向同一个对象时(`sessionFactory.get(sessionId)`)不是冗余 ——
3053
+ * 前者是 `createWebServer` 的入参形状(嵌入宿主可能只给这一个),
3054
+ * 后者是这一轮新加的能力。
3055
+ *
3056
+ * ## ⚠️ 这里原来还有一个进程级的 `checkpoints`,2026-08-15 **删掉了**
3057
+ *
3058
+ * 它借的是 `EpochRuntime.checkpoints` ——**引导会话那一份**,而三条回退端点
3059
+ * 加 `GET /diff` 都在拿它答任意一个 sessionId。工厂给每个会话各建了一份
3060
+ * (`LiveSessionView.checkpoints`),所以那个字段从此是同一件事的第二个说法,
3061
+ * 而两个说法只在单会话时恰好相等。
3062
+ *
3063
+ * **删掉而不是留着不读**:留着的话下一个写 handler 的人照样够得着它,
3064
+ * 而它编译得过、跑得通、只是答错会话 —— 这一整条 bug 就是这么长出来的
3065
+ * (方案 30 §9.5)。取数一律走 `checkpoints.ts` 的 `checkpointsOf()`。
3066
+ */
3067
+ sessionFactory: SessionFactoryView;
3068
+ config: EpochConfig;
3069
+ /**
3070
+ * artifact 的根目录(`<homeDir>/artifacts`)——`createWebServer()` 不传
3071
+ * `artifactsRoot` 时的缺省值(方案 54 §二)。`EpochRuntime.artifactsRoot`
3072
+ * 结构上正好满足它。
3073
+ *
3074
+ * **拿的是 runtime 报的字符串,不是自己算的**:`server` 不许 import `infra`,
3075
+ * 而这恰好就是它该有的样子 —— 在这边 `join(config.homeDir, 'artifacts')`
3076
+ * 拼一遍等于给同一个目录第二个说法,而两个说法分叉的表现是产物栏点开 404、
3077
+ * 服务日志状态码诊断全部正常。那正是这一节要修的病本身。
3078
+ */
3079
+ artifactsRoot: string;
3080
+ usageScope: 'session' | 'run';
3081
+ /**
3082
+ * 自动压缩那条线(决定 8 的第三个时刻,2026-08-16)。`EpochRuntime.compression`
3083
+ * 结构上正好满足它 —— **对不上就是两边长歪了,装配那一行当场编译不过**。
3084
+ *
3085
+ * 类型直接钉成网线那一份而不是再写一遍三个字段:这份东西原样进
3086
+ * `GET /api/config` 的响应,中间没有任何投影。抄一遍形状的下场同
3087
+ * `sessionStore.loadMessages` 那条 —— 那个端点的载荷从此没被类型检查过。
3088
+ *
3089
+ * ⚠️ **不可为 null。** 三个数这个进程永远答得出(窗口有装配时那个值、阈值有
3090
+ * schema 默认、`auto` 是个布尔),所以「说不出来」这一档不存在。
3091
+ * 判据与「换了模型这三个数变不变」全文在 {@link WireCompressionLine}。
3092
+ */
3093
+ compression: WireCompressionLine;
3094
+ providerInfo: WireProviderSummary | null;
3095
+ readonly tools: readonly WireToolSummary[];
3096
+ diagnosticList: Diagnostic[];
3097
+ /**
3098
+ * 同一批诊断,**用这次请求的语言再说一遍**(方案 58 §2.4)。
3099
+ * `EpochRuntime.diagnosticsIn` 结构上正好满足它。
3100
+ *
3101
+ * ## ⚠️ 可选,而且这是一句真话不是偷懒
3102
+ *
3103
+ * 自己手拼 `WebRuntimeView` 的嵌入宿主(`__tests__/harness.ts` 里那个假 runtime
3104
+ * 就是)交上来的 `diagnosticList` 是一个**已经渲染好的常量数组** —— 它没法被
3105
+ * 再渲染一次,回落到进程语言是那时唯一说得出口的话。
3106
+ *
3107
+ * 所以 `api.ts` 那一行是 `runtime.diagnosticsIn?.(lang) ?? runtime.diagnosticList`:
3108
+ * 给了就按请求语言,没给就照旧。**不给不是错**,`GET /api/config` 照常回。
3109
+ */
3110
+ diagnosticsIn?: (lang: Lang) => Diagnostic[];
3111
+ /**
3112
+ * 会话持久化。起不来时为 null,回放端点退化成空数组而不是 500。
3113
+ *
3114
+ * 返回类型钉成 `WireReplayMessage[]` 而不是 `unknown[]`:`GET /messages` 直接把它
3115
+ * 塞进响应,`unknown[]` 等于这个端点的载荷从来没被类型检查过。runtime 的
3116
+ * `StoredMessage` 结构上正好满足它 —— 对不上就是契约漏了一块,当场编译不过。
3117
+ */
3118
+ sessionStore: {
3119
+ loadMessages(sessionId: string): WireReplayMessage[];
3120
+ /**
3121
+ * 这个会话在工作区里改过哪些文件(方案 42 PR-4)。
3122
+ *
3123
+ * 返回类型钉成 `WireFileChange[]` 同上一条:这份东西直接进
3124
+ * `GET /api/sessions/:id/artifacts` 的响应。runtime 的 `SessionFileChange`
3125
+ * 结构上正好满足它 —— 对不上就是两边长歪了,装配那一行当场编译不过。
3126
+ *
3127
+ * 聚合本身**只能在 runtime 做**:判据是操作类型(core 的 `mapOperationType`)、
3128
+ * 路径抽取走 core 的 `affectedPaths()`,而 server 不许 import core。
3129
+ * 在这边照着 `toolCalls` 自己数一遍,等于给「一次调用会碰哪些文件」
3130
+ * 这个问题第二个答案。
3131
+ */
3132
+ fileChanges(sessionId: string, workDir: string): WireFileChange[];
3133
+ /**
3134
+ * 这段会话已批准的计划正文(方案 35 PR-2);没有就是 null。
3135
+ *
3136
+ * **只用来答别的会话**:跑在这个进程里的那一个走 `plan.approvedPlan()`
3137
+ * (下一条),理由写在那儿。runtime 的 `SessionStore.loadPlan` 结构上正好
3138
+ * 满足它 —— 那一份不 `ensure()`,查一个不存在的会话回 null 而不是顺手
3139
+ * 建一段空会话,正是这个端点要的语义。
3140
+ */
3141
+ loadPlan(sessionId: string): string | null;
3142
+ } | null;
3143
+ /**
3144
+ * Plan 模式(方案 35)。权限层起不来时为 null ——
3145
+ * 那个模式整个是「临时换一个权限级别」,没有权限层就没有它。
3146
+ *
3147
+ * ⚠️ **这个进程正在跑的那个会话,计划的真源是这儿,不是 SQLite。**
3148
+ * 落盘是 `PlanModeState.setApproved()` 顺手做的(`savePlan` 失败只回 false,
3149
+ * 不抛),而 `--resume` 接回来时又是它把盘上那份 `restore()` 进内存的。
3150
+ * 两者分叉的时候,「模型的 system prompt 里到底挂着哪一份」只有内存这份说得准 ——
3151
+ * 而 `GET .../plan` 那一格问的就是这件事。
3152
+ *
3153
+ * ## 这里原来只借了 `approvedPlan()` 一个方法,2026-08-15 放宽
3154
+ *
3155
+ * 原话是「写那一侧(`enter` / `leave` / `forget`)在这个端点上没有位置,借过来
3156
+ * 就是给浏览器开一条改权限级别的路」。**前半句仍然对**(那是 `GET` 的事),
3157
+ * 后半句被一次人眼验收推翻了:web 上一个进 plan 模式的入口都没有,于是
3158
+ * 「先给我一份计划、别动手」能不能生效取决于模型这一轮愿不愿意调
3159
+ * `enter_plan_mode`(`docs/verify/VERIFY_RECORD-web-loose-ends.md` §4.1)。
3160
+ *
3161
+ * 借的是**两个动词,不是一个级别参数**:`enter` 只能压到 `plan`、`leave` 只能
3162
+ * 恢复成进入前记着的那一档,两者都由 core 的 `PlanModeState` 说了算。所以这条路
3163
+ * 上表达不出「把级别设成 auto」——「给浏览器开一条改权限级别的路」那句担心的事,
3164
+ * 在这个形状下**说不出口**。
3165
+ *
3166
+ * ## ⚠️ `forget()` 照旧不借,而理由 2026-08-15 换了一个
3167
+ *
3168
+ * 原话是「web 上没有任何改级别的入口,借一个没有调用方的方法等于给下一个人
3169
+ * 一个『原来这儿能改级别』的暗示」。**前半句从这一轮起不成立了** ——
3170
+ * `POST /api/sessions/:id/permission` 就是那个入口(决定 20 ③)。
3171
+ *
3172
+ * 但结论没变,而且比原来更硬:**那条路不该从这儿调 `forget()`**。
3173
+ * 「用户自己设级别 ⇒ plan 模式那段区间作废」是引擎内部两件事的耦合,它住在
3174
+ * runtime 的 `PermissionsControl.setLevel()` 第 3 步 —— 服务端手里同时握着
3175
+ * `setLevel` 和 `forget` 的话,「谁负责作废」立刻有两个答案,
3176
+ * 而漏调的那一侧不会红:用户在 plan 模式里换完档一切正常,直到下一次计划
3177
+ * 批准把级别悄悄改回进入前那一档。判据全文在 `PermissionsControl.setLevel`
3178
+ * 的 JSDoc 上。
3179
+ */
3180
+ plan: {
3181
+ approvedPlan(): string | null;
3182
+ /** 现在在不在 plan 模式里 */
3183
+ active(): boolean;
3184
+ /** 进入前的权限级别;不在模式里时 null */
3185
+ from(): string | null;
3186
+ /**
3187
+ * 用户主动进入。**已经在里面时回 `{ok:false}`**,`reason` 是给模型看的那句话
3188
+ * (`server` 不往下发,见 `WirePlanModeResponse`)。重入会把「进入前的级别」
3189
+ * 覆盖成 `plan`,退出时就再也回不去了 —— 那条判据在 core,这一层只是别绕过它
3190
+ */
3191
+ enter(): {
3192
+ ok: boolean;
3193
+ from?: string;
3194
+ reason?: string;
3195
+ };
3196
+ /** 用户主动退出,恢复进入前的级别并返回恢复到哪一档;本来就不在模式里时 null */
3197
+ leave(): string | null;
3198
+ } | null;
3199
+ /**
3200
+ * 会话目录:列举 / 检索 / 改名 / 删除(方案 30 §2.2)。SQLite 起不来时为 null。
3201
+ *
3202
+ * 和 `sessionStore` 一样是「让 runtime 转出来」的产物,`EpochRuntime.sessions`
3203
+ * 结构上正好满足它。为 null 时列表退化成「只有 Hub 里那几个会话」,
3204
+ * 而 DELETE / PATCH 回 503 —— 不是假装成功。
3205
+ */
3206
+ sessions: SessionCatalog | null;
3207
+ /**
3208
+ * 每会话的工作区绑定([决定 18](../../../design/web-ui/README.md))。
3209
+ * `EpochRuntime.workspaces` 结构上满足它。
3210
+ *
3211
+ * **不可为 null** —— 底下只有一个 Map 和一个 JSON 文件。引导会话在装配时就绑好了,
3212
+ * 所以 `workspaces.of(runtime.sessionId)` 永远非 null。
3213
+ *
3214
+ * ⚠️ `ApiContext` 上原来有一个 `workDir: string`,是这个字段替掉的。那个是
3215
+ * **进程级**的展示用工作目录 —— 一旦两个会话跑在两个仓库里,它只能是其中一个的,
3216
+ * 而它不说是哪个。留着两个真源的代价不是多一个字段,是它们慢慢长歪。
3217
+ */
3218
+ workspaces: WorkspaceView;
3219
+ /**
3220
+ * 定时任务(方案 45 PR-3)。`EpochRuntime.schedules` 结构上正好满足它 ——
3221
+ * **对不上就是两边长歪了,`cli/src/commands/web.ts` 那一行当场编译不过**。
3222
+ *
3223
+ * ## ⚠️ 不可为 null,而这是一句真话不是省事
3224
+ *
3225
+ * 底下只有一张 SQLite 表和一次平台探测,没有起不来的可能(`build.ts` 上那个
3226
+ * 字段的 JSDoc 写着同一句)。本平台没有 OS 后端时 `capability()` 如实说
3227
+ * `backend: null` —— 那是一个答案,不是一个缺失的能力。
3228
+ *
3229
+ * ## ⚠️ 它是这份视图上**唯一一格不按会话取的能力状态**
3230
+ *
3231
+ * 一条定时任务不属于任何一段会话 —— 它反过来**产出**会话(每次运行落一条
3232
+ * `source='schedule'` 的)。所以那九条端点挂在 `/api/schedules*` 而不是
3233
+ * `/api/sessions/:id/schedules`,判据全文在 [schedule/handlers.ts](./schedule/handlers.js)
3234
+ * 的文件头。别照着 `settings` / `security` 那两片的样子给它加一个 `:id`。
3235
+ *
3236
+ * 借的是**一整片控制面**而不是几个字段,理由和 `sessionFactory` 同类:
3237
+ * 那一片本来就是一个整体(列、建、改、删、跑、算下一次),而拆成散字段之后
3238
+ * 「校验不过就什么都不写」这条不变量会分散到两处去守。
3239
+ */
3240
+ schedules: ScheduleControlView;
3241
+ dispose(): void;
3242
+ }
3243
+ interface ApiContext {
3244
+ hub: SessionHub;
3245
+ runtime: WebRuntimeView;
3246
+ /** `/api/health` 回的版本号。由 cli 传进来 —— server 不该去猜宿主的版本 */
3247
+ version: string;
3248
+ /** artifacts 目录的**父目录**(即 `~/.epoch/artifacts`) */
3249
+ artifactsRoot: string;
3250
+ /**
3251
+ * 这个服务**绑在回环之外**吗(`--host 0.0.0.0` 那一档)。来自
3252
+ * [bind.ts](./bind.js) 的 `decideBinding().lanExposed` —— `auth.ts` 一直拿它
3253
+ * 放宽 `Host` 白名单,这一格是把同一个事实递给 handler。
3254
+ *
3255
+ * ## ⚠️ **必传,没有默认值**,判据同 `LoadRolesOptions.trusted`
3256
+ *
3257
+ * 给一个 `= false` 的默认参数,就等于「忘了传的人自动获得『我在本机』」——
3258
+ * 而今天读它的那条端点(`POST /api/roles`)正是靠它决定要不要往用户的
3259
+ * `~/.epoch/agents/` 里写一段会进 system prompt 的文字。**fail closed 的
3260
+ * 前提是没人能靠省略拿到 open。**
3261
+ *
3262
+ * 今天的两个消费方:那条端点的 403,和 `GET /api/config` 上原样下发的那一格
3263
+ * (界面据此在按下去之前把话说出来)。完整判据在 protocol 的
3264
+ * `wire-agent-role.ts` 文件头。
3265
+ */
3266
+ lanExposed: boolean;
3267
+ }
3268
+
3269
+ /**
3270
+ * 鉴权中间件 —— cookie + Origin/Host 校验。
3271
+ *
3272
+ * [方案 20 §6.2 / §6.3](../../../docs/verify/VERIFY_RECORD-20-web.md)。纯函数:进去一个
3273
+ * `WireAuthRequestView`,出来一个 `WireAuthVerdict`,不碰 socket 也不写响应。
3274
+ * 于是这一整套能脱离 HTTP 服务器单测 —— 而它恰恰是最不该「等下次再补测」的部分。
3275
+ *
3276
+ * ## 为什么是 cookie 而不是 `Authorization: Bearer`
3277
+ *
3278
+ * **浏览器原生 `EventSource` 不支持自定义 header**,它的构造器只接受
3279
+ * `withCredentials` 一个选项。而 `/api/events` 是整个方案最重要的端点 ——
3280
+ * Bearer 在这里直接不可用。定案是「URL 里的 token 只用来换一次 cookie」,
3281
+ * 之后所有请求(含 SSE)靠 `HttpOnly` cookie,前端一个
3282
+ * `credentials: 'same-origin'` 完事。
3283
+ *
3284
+ * ## 两道,不是一道
3285
+ *
3286
+ * | 挡什么 | 靠什么 | 为什么另一道挡不住 |
3287
+ * | --------------- | -------------------- | --------------------------------------------------------------------- |
3288
+ * | DNS rebinding | Origin / Host 校验 | 简单请求(`text/plain` 之类)**不触发 preflight**,CORS 只能拦住攻击者 |
3289
+ * | | | 读响应,拦不住请求已经执行完了 |
3290
+ * | CSRF | `SameSite=Strict` | Origin 校验对「浏览器自动附 cookie」无能为力 |
3291
+ *
3292
+ * 还有第三道是天然的:cookie 没有 `Domain` 属性,因此**只发给用户实际打开的那个
3293
+ * 主机名**。rebinding 攻击页面的请求会带着「evil.com 的 cookie」过来,也就是
3294
+ * 什么都不带 —— 于是即使前两道有疏漏,落点仍然是 401。
3295
+ */
3296
+
3297
+ interface AuthGuardOptions {
3298
+ /** 本次启动的随机 token */
3299
+ token: string;
3300
+ /** 服务实际监听的端口。`Host` / `Origin` 的端口必须与它一致 */
3301
+ port: number;
3302
+ /**
3303
+ * 是否绑在回环之外(`--host 0.0.0.0`)。
3304
+ *
3305
+ * 这时候**没法枚举合法主机名**(局域网里那台机器叫什么我们不知道),所以
3306
+ * `Host` 只校验端口 + 与 `Origin` 一致。这不是偷懒,是这个模式下 Origin 白名单
3307
+ * 本身就不成立;真正的防线是上面说的第三道(cookie 只发给用户打开的那个主机名)
3308
+ * 加上「不给 token 就拒绝启动」。
3309
+ */
3310
+ lanExposed: boolean;
3311
+ }
3312
+ /**
3313
+ * 造一个鉴权判定函数。
3314
+ *
3315
+ * `/api/health` **不过这一关**(验收第 10 条:不带 cookie 时它仍然 200)——
3316
+ * 那是路由层的事,这里不认端点。
3317
+ */
3318
+ declare function createAuthGuard(opts: AuthGuardOptions): WireAuthGuard;
3319
+
3320
+ /**
3321
+ * 会话登记表 —— Hub 手里「有哪些会话、谁在排队、谁该被冷却」那一半(方案 30 §2.3)。
3322
+ *
3323
+ * 从 [hub.ts](../hub.ts) 里摘出来是因为它是**纯数据结构**:一个 Map、一条
3324
+ * 每会话的消息队列、一条 LRU 冷却策略,没有任何发帧或跑轮次的逻辑。
3325
+ * Hub 那个类刻意不拆(方案 20 §9.6 第 1 条:事件总线 / 状态机 / 审批生命周期
3326
+ * 共享同一份状态),但「谁在表里」不属于那三样中的任何一个。
3327
+ *
3328
+ * ## 两条规矩
3329
+ *
3330
+ * 1. **一个会话同一时刻只有一个 `run()`。** 第二条消息**排队**,不是并发 ——
3331
+ * `AgentSession` 的历史累积发生在 `run()` 的末尾,两轮并发跑会让历史顺序
3332
+ * 变成不确定的。那不是「偶尔乱序」,是每次重开会话看到的对话都可能不一样。
3333
+ * 不同会话之间**可以**并行,那是多会话的意义所在。
3334
+ * 2. **活跃会话有上限。** 超了就把最久没动过的**空闲**会话冷却掉 ——
3335
+ * 不设上限的话,一个人开 50 个标签页就能把内存吃光,而 `epoch web`
3336
+ * 默认只绑回环不代表它可以随便挂。
3337
+ */
3338
+
3339
+ /**
3340
+ * 同时活跃的会话数上限。
3341
+ *
3342
+ * 8 是「一个人能同时盯着的标签页数」的量级 —— 再多也看不过来,而每多一个
3343
+ * 活跃会话就多一份内存里的对话历史。
3344
+ */
3345
+ declare const DEFAULT_MAX_ACTIVE_SESSIONS = 8;
3346
+ /**
3347
+ * 单个会话最多能排多少条消息。
3348
+ *
3349
+ * 排队是给「手快连发两条」准备的,不是消息队列中间件。超过就直说排满了,
3350
+ * 而不是无声地把请求收下 —— 一个能无限入队的端点等于一个内存放大器:
3351
+ * 每条消息都要在服务端活到轮到它为止。
3352
+ */
3353
+ declare const DEFAULT_MAX_QUEUED_MESSAGES = 8;
3354
+
3355
+ /**
3356
+ * 信封环形缓冲 —— SSE 续传的唯一数据源。
3357
+ *
3358
+ * [方案 20 §4.1](../../../docs/verify/VERIFY_RECORD-20-web.md)。单独成文件不是为了凑
3359
+ * 结构,是因为「续得上 / 续不上」这条判定有四种边界,而它们全都只需要
3360
+ * `(lastEventId, 缓冲里现有的 seq 区间)` 就能算出来 —— 从 Hub 里摘出来就能被
3361
+ * 直接单测,不用先起一个 HTTP 服务器再断线重连。
3362
+ *
3363
+ * ## 缓冲是**全局**一份,不是 per-session
3364
+ *
3365
+ * 与 `WireEnvelope.seq` 同一条理由(见 wire.ts):一条 SSE 连接上的 `id:` 行是
3366
+ * 这条流的游标。既然 seq 是全局单调的,缓冲也只能是全局一份,靠 `sessionId`
3367
+ * 把每帧路由回去。per-session 缓冲会让「A 会话刷了 600 条」把 B 会话的可续区间
3368
+ * 也一起顶掉 —— 或者反过来,让全局游标落在一个某会话缓冲里不存在的洞上。
3369
+ *
3370
+ * ## 续不上就说续不上
3371
+ *
3372
+ * `since()` 返回 `null` 表示「这段已经没了」。调用方**必须**回 `stream-reset`
3373
+ * 让前端重拉全量,**不许**把剩下的那半截补给它 —— 那会让页面上出现一段静默
3374
+ * 丢失的对话,而用户永远不知道自己少看了什么。
3375
+ */
3376
+
3377
+ /**
3378
+ * 默认容量(方案 20 §4.1 的初值)。
3379
+ *
3380
+ * 512 条大约是一轮中等长度对话的量级(文本增量占绝大多数)。它是「刷新页面还能
3381
+ * 无缝接上」和「内存不无限涨」之间的折中,不是什么魔法数字 —— 真嫌小就调
3382
+ * `SessionHubOptions.ringCapacity`,而不是让缓冲无界。
3383
+ */
3384
+ declare const DEFAULT_RING_CAPACITY = 512;
3385
+ declare class EnvelopeRing {
3386
+ private readonly capacity;
3387
+ /**
3388
+ * 按 seq 升序、**连续无洞**的一段。
3389
+ *
3390
+ * 连续性是 `since()` 能只靠首尾两个 seq 判定的前提:每个广播帧都让全局计数器
3391
+ * 恰好 +1,所以缓冲里存的一定是 `[latest-size+1, latest]` 这个闭区间。
3392
+ * 哪天出现「某些帧不进缓冲」的需求,这条前提就断了,`since()` 必须跟着改。
3393
+ */
3394
+ private readonly frames;
3395
+ constructor(capacity?: number);
3396
+ /**
3397
+ * 收一帧。**只收广播帧**(`seq >= 1`)。
3398
+ *
3399
+ * 连接级帧(`connected` / `stream-reset`,`seq: 0`)不进来:它们只发给刚接上来
3400
+ * 的那一个连接,进了缓冲就会在下一次续传时被当成历史补给别人。
3401
+ */
3402
+ push(frame: WireEnvelope): void;
3403
+ /** 缓冲里最新那一帧的 seq;空缓冲是 0(= 还没发过任何广播帧) */
3404
+ get latestSeq(): number;
3405
+ /** 缓冲里最老那一帧的 seq;空缓冲是 undefined */
3406
+ get oldestSeq(): number | undefined;
3407
+ /** 现存帧数 */
3408
+ get size(): number;
3409
+ /**
3410
+ * 断点之后的帧。
3411
+ *
3412
+ * @param lastEventId 客户端带上来的 `Last-Event-ID`(它已经收到的最后一个 seq)
3413
+ * @returns 要补发的帧(可能是空数组 = 一条没落下);
3414
+ * **`null` 表示续不上**,调用方必须回 `stream-reset`
3415
+ *
3416
+ * 四种边界,全都在这里一次判掉:
3417
+ *
3418
+ * | 情况 | 返回 | 为什么 |
3419
+ * | --------------------------- | ----------- | -------------------------------------------------- |
3420
+ * | `last === latest` | `[]` | 断得很干净,一条没落下 |
3421
+ * | `last > latest` | `null` | 游标比我们发过的还新 —— 换了个进程,seq 从头数过了 |
3422
+ * | `last + 1 < oldest` | `null` | 中间那段被挤掉了,这就是验收第 4 条 |
3423
+ * | 其余 | `(last, ∞)` | 正常续传,一条不丢一条不重(验收第 3 条) |
3424
+ */
3425
+ since(lastEventId: number): WireEnvelope[] | null;
3426
+ }
3427
+
3428
+ /**
3429
+ * 静态资源 —— artifact 出口(§缺口 2)与 web 产物。
3430
+ *
3431
+ * ## 路径收束是这个文件存在的理由
3432
+ *
3433
+ * [方案 20 §6.4](../../../docs/verify/VERIFY_RECORD-20-web.md) 的三条「绝不」之一:
3434
+ * **artifact 端点绝不直接拼路径**。`GET /api/artifacts/:sid/:name` 的两段都来自
3435
+ * URL,`..` 一穿越就是任意文件读取(验收第 11 条)。
3436
+ *
3437
+ * 这里做四道,缺一不可:
3438
+ *
3439
+ * 1. 拒绝 `name` 里的路径分隔符 —— artifact 是「会话目录里的一个文件」,不是子树
3440
+ * 2. `resolve()` 之后确认仍在 artifacts 目录之内
3441
+ * 3. **`realpath()` 之后再确认一次** —— 第 2 道不看符号链接,而 agent 自己就能
3442
+ * 在 artifact 目录里造一个指向 `~/.ssh` 的链接
3443
+ * 4. 必须是普通文件 —— 否则 `name = '..'` 会解析到目录本身(它确实「在目录之内」)
3444
+ *
3445
+ * ## artifact 的 MIME 与 web 产物的 MIME 是两张表
3446
+ *
3447
+ * 前者是**工具产出的、可能被模型间接控制的**内容:只有图片和音频给 `inline`,
3448
+ * 其余一律 `application/octet-stream` + `attachment`。让浏览器把一个 artifact
3449
+ * 当成 HTML 渲染 = 在本服务的同源里执行任意脚本,而这个源上挂着能操作
3450
+ * agent 的全部 API。后者是我们自己 build 出来的产物,按扩展名正常给。
3451
+ */
3452
+
3453
+ /**
3454
+ * 把 `:sid` / `:name` 解析成一个**确定在 artifacts 目录里的普通文件**。
3455
+ *
3456
+ * @returns 绝对路径;任何一道没过就是 `null`(调用方回 404,**绝不读出文件**)
3457
+ */
3458
+ declare function resolveArtifactPath(root: string, sessionId: string, name: string): string | null;
3459
+
3460
+ /**
3461
+ * 定位随包发出去的 web 前端产物。
3462
+ *
3463
+ * ## 为什么这段代码在 server 而不在 cli
3464
+ *
3465
+ * 到 2026-08-14 之前,产物拷在 `cli/dist/web`,由 `epoch web` 探到之后传给
3466
+ * `createWebServer({ webRoot })`。这让**嵌入宿主**掉进一个不报错的坑:
3467
+ * Electron / VS Code 这类宿主依赖的是 `@epoch-agent/server`(它们要的就是这张
3468
+ * 现成的界面,不想自己拿 REST + SSE 再画一遍),而产物在 cli 包里 ——
3469
+ * 不传 `webRoot` 就是占位页,**服务本身一切正常,日志里一个字都没有**。
3470
+ *
3471
+ * 现在产物随 server 一起发(`scripts/copy-web-assets.mjs`),这里负责在运行期
3472
+ * 找到它。于是 `webRoot` 从「必须传」降级成「想换掉自带界面时才传」。
3473
+ *
3474
+ * ## 两条候选路径
3475
+ *
3476
+ * 产物永远在 `<server 包>/dist/web`,变的只是**本文件相对它的位置**:
3477
+ *
3478
+ * - 发布形态:整个包被 tsup 打进 `dist/index.js`,`import.meta.url` 指向
3479
+ * `dist/index.js`,产物是同级的 `web/`
3480
+ * - 源码形态(vitest、`pnpm epoch:dev` 里被 workspace 链接的那份 src):
3481
+ * `import.meta.url` 指向 `src/web-root.ts`,要退一级再进 `dist/web`
3482
+ *
3483
+ * 两条都只依赖**server 包自己的内部布局**,不去猜 monorepo 的相对位置。
3484
+ * 形状与 `cli/src/tui-entry` 的 `resolveTuiEntry()` 一致。
3485
+ *
3486
+ * 探的是 `index.html` 而不是目录:空目录一样存在,而那时静态请求会逐个 404,
3487
+ * 比直接发占位页更难懂。
3488
+ */
3489
+ /**
3490
+ * 随包发出去的前端产物目录。
3491
+ *
3492
+ * @param baseDir 本模块所在目录。默认现算,参数只给用例注入
3493
+ * @param exists 存在性判据,默认 `existsSync`。同上
3494
+ * @returns 产物目录;没构建过就是 `undefined`(调用方降级到占位页)
3495
+ */
3496
+ declare function defaultWebRoot(baseDir?: string, exists?: (path: string) => boolean): string | undefined;
3497
+
3498
+ /**
3499
+ * `GET /api/sessions/:id/diff` 的实现(方案 30 §2.4,作用域按决定 18 收窄到会话)。
3500
+ *
3501
+ * 「这次会话到底改了什么」是用 Web UI 而不是 TUI 的主要理由(屏幕大,能好好看
3502
+ * diff),而在这之前界面上只有**单次 `file_patch`** 的 diff。
3503
+ *
3504
+ * ## 三条来路,按可靠性排
3505
+ *
3506
+ * | source | 范围 | 旧内容 |
3507
+ * | ------------- | ----------------------------------------- | ------------------------------- |
3508
+ * | `git` | HEAD → 工作区(已暂存 + 未暂存 + 未跟踪) | `git cat-file blob`(原始字节) |
3509
+ * | `checkpoints` | 本次会话经过文件工具改过的路径 | **没有**(见下) |
3510
+ * | `none` | — | — |
3511
+ *
3512
+ * 退化到检查点时**拿不到旧内容**:方案 27 的 blob 在
3513
+ * `~/.epoch/checkpoints/<sid>/<turn>/blobs/<hash>` 底下,读它要 core 的
3514
+ * `CheckpointStore`,而 `@epoch-agent/server` 不许 import core。与其拿现状冒充
3515
+ * 旧内容(那会画出一份「什么都没改」的假 diff),不如如实标 `no-snapshot`。
3516
+ * 想补上得让 runtime 转出一个读 blob 的口子 —— 那是另一次改动。
3517
+ *
3518
+ * ## 二进制和大文件
3519
+ *
3520
+ * 前 8000 字节里有 NUL 就当二进制(git 自己的判据),**不试图画 diff**,
3521
+ * 只报字节数(验收第 21 条)。超过 {@link MAX_DIFF_BYTES} 的同样只报字节数 ——
3522
+ * 把一个 40MB 的 minified bundle 塞进 JSON 响应,页面会直接卡死。
3523
+ *
3524
+ * 两侧**任意一侧**触发就整个文件不给内容:只给半边的 diff 会被画成
3525
+ * 「整个文件都是新增的」,比不给更误导。
3526
+ */
3527
+
3528
+ /** 单个文件单侧的内容上限(512 KB)。超了只报字节数 */
3529
+ declare const MAX_DIFF_BYTES: number;
3530
+ /** 一次响应最多几个文件。超了截断并在 `notes` 里说明 —— **不静默截断** */
3531
+ declare const MAX_DIFF_FILES = 200;
3532
+ /**
3533
+ * 检查点那一侧要的最小能力面 —— runtime 的 `CheckpointControl` 结构上满足它。
3534
+ *
3535
+ * 只要 `list` 和 `preview`:**快照和回退都不该从这里够得着**。这个端点是只读的,
3536
+ * 而 `rewind` 会真的改用户的文件。
3537
+ */
3538
+ interface WorkspaceCheckpoints {
3539
+ list(): Promise<ReadonlyArray<{
3540
+ turnIndex: number;
3541
+ }>>;
3542
+ preview(turnIndex: number): Promise<{
3543
+ files: ReadonlyArray<{
3544
+ path: string;
3545
+ action: string;
3546
+ }>;
3547
+ conflicts: ReadonlyArray<{
3548
+ path: string;
3549
+ action: string;
3550
+ }>;
3551
+ } | null>;
3552
+ }
3553
+ interface WorkspaceDiffOptions {
3554
+ /**
3555
+ * 比较的起点 —— **这个会话绑定的工作区主根**(决定 18),不是服务进程的工作目录。
3556
+ * 不是 git 仓库时它就是 `root`。
3557
+ */
3558
+ workDir: string;
3559
+ /** 非 git 目录时的退路。没有就只能回 `source: 'none'` */
3560
+ checkpoints: WorkspaceCheckpoints | null;
3561
+ }
3562
+ /**
3563
+ * 算一份工作区 diff。**任何失败都不抛** —— 空视图 + 一句说明就是这个端点的
3564
+ * 正常输出(验收第 22、23 条),而 500 会让界面上只剩一个红叉。
3565
+ */
3566
+ declare function collectWorkspaceDiff(opts: WorkspaceDiffOptions): Promise<WireWorkspaceDiffResponse>;
3567
+
3568
+ /**
3569
+ * 后台任务的取数与投影 —— `GET /api/sessions/:id/tasks`(方案 36 PR-2 的 Web 那半)。
3570
+ *
3571
+ * 不在 [api.ts](./api.ts) 里,理由和 [capability.ts](./capability.ts) 一样:那个文件
3572
+ * 是「一个端点一个函数」,而这里有一份任务表的**结构镜像**加一段取尾巴的算术 ——
3573
+ * 塞进去会让它变成「一个端点一个函数,外加一段只有这一个端点用得上的窗口计算」。
3574
+ *
3575
+ * ## ✅ `:id` 现在**真的筛选**(2026-08-15 收窄)
3576
+ *
3577
+ * 这一段原来是一条 ⚠️:任务表曾经是 `plugin-terminal/src/background.ts` 里一个
3578
+ * **进程级**的 `Map`,于是同一个进程里两个会话,这个端点回的是**同一批任务**,
3579
+ * 而 `:id` 只起一件事 —— 认门。当时的理由记在那边的文件头上:宿主是**会话**而
3580
+ * 不是一次工具调用,而 plugin 拿不到会话对象。
3581
+ *
3582
+ * 那条理由 2026-08-15 被拆掉了(plugin 拿不到会话*对象*,但一直拿得到
3583
+ * `ToolContext.sessionId`),任务表按会话分了区。于是 `:id` 现在有**两件事**:
3584
+ *
3585
+ * 1. **认门** —— 不存在的会话 404,不能回一份空清单冒充「这个会话没有后台任务」
3586
+ * 2. **筛选** —— 回的是这个会话起的那些,别的会话一条都不在里面
3587
+ *
3588
+ * ### 这是一次判断被验证的记录,所以留着
3589
+ *
3590
+ * 当时那一段末尾写着:「URL 仍然定成会话作用域,与 `capability.ts` 文件头那一条
3591
+ * 同理由:等任务表真按会话分了(那要 plugin 层拿得到会话),改的是**这一个
3592
+ * 文件**,而不是所有客户端的 URL。」
3593
+ *
3594
+ * 这一轮兑现的正是它:URL 一个字没改,`WireTasksResponse` 一个字段没加没减,
3595
+ * web 那边除了撤掉一句不再成立的说明之外没有动过 —— 改的确实只有这一个文件
3596
+ * (加上任务表自己)。**当初把 URL 定成会话作用域,是对的。**
3597
+ *
3598
+ * 反过来那条代价也随之作废:界面上原来必须画一句「这里是整个服务进程的后台任务」
3599
+ * (`web/src/inspector/output.tsx` 的 `web.ui.output.note`),这一轮**如实撤掉**了。
3600
+ *
3601
+ * ## 从 runtime 借道,不 import plugin-terminal
3602
+ *
3603
+ * `@epoch-agent/server` 只许依赖 protocol + runtime(`scripts/check-layers.mjs`
3604
+ * 是硬闸门)。`listBackgroundTasks` / `backgroundTaskOutput` 都是 runtime 转出来的
3605
+ * 那两个口子,见 `runtime/src/index.ts` 末尾那段注释。
3606
+ */
3607
+
3608
+ /**
3609
+ * 每个任务最多给多少字节输出。
3610
+ *
3611
+ * 8 个任务跑满时这个端点的上限就是 8 × 这个数 —— 检视面板那一格只有 384px 宽,
3612
+ * 再多也没人读得完,而它们要经过一次 `JSON.stringify` 和一次网络往返。
3613
+ *
3614
+ * 数值跟 `plugin-terminal` 的 `MAX_OUTPUT_CHUNK`(给模型的那个上限)**刻意一致
3615
+ * 但不共用**:那一个的判据是「取一次输出就把上下文塞满」,这一个的判据是
3616
+ * 「一块 384px 的面板」。两个判据将来会各自变,共用一个常量的话,调其中一边
3617
+ * 就会静默改掉另一边。
3618
+ *
3619
+ * ⚠️ 它只是**请求**的窗口大小:`backgroundTaskOutput` 自己还有一道
3620
+ * `MAX_OUTPUT_CHUNK` 的截断,真给到多少以返回值为准(见 {@link toWireTask})。
3621
+ */
3622
+ declare const TASK_TAIL_BYTES: number;
3623
+ /**
3624
+ * `taskOutput()` 在服务端眼里的样子(`@epoch-agent/plugin-terminal` 的
3625
+ * `TaskOutputResult`,不能直接 import)。
3626
+ *
3627
+ * 只取用得上的两个字段。`missed` / `hasMore` 不在这里,因为这一层要报的是
3628
+ * {@link WireBackgroundTask.omittedBytes} 那**一个**数,而它由 `nextCursor` 和
3629
+ * `output.length` 算得出来 —— 收窄到两项还顺带让用例喂一个两行的假对象就能跑。
3630
+ */
3631
+ interface TaskOutputView {
3632
+ /** 这一段输出 */
3633
+ output: string;
3634
+ /** 下次传回来的游标(绝对偏移)。`起点 + output.length` */
3635
+ nextCursor: number;
3636
+ }
3637
+ /**
3638
+ * 任务表的只读视图。
3639
+ *
3640
+ * 单开这个接口**只为了用例能注入**:真的那张表是 plugin 里的模块状态,服务端
3641
+ * 用例里起不出一个「跑着的 `pnpm build`」来,于是 running / 已结束 / 被环形缓冲
3642
+ * 截断三种形态一种都摆不出来。默认值 {@link LIVE_TASKS} 接的是真表,路由那一行不传。
3643
+ *
3644
+ * ⚠️ 两个方法**都收 `sessionId`**,而那不是一个可选的精细化:它就是「筛选」
3645
+ * 那一半本身(见文件头)。少一个参数的桩能让「服务端忘了传会话 id」这类错
3646
+ * 在用例里照样绿,而那个错的表现恰好是收窄之前那个毛病原样复发。
3647
+ */
3648
+ interface TaskRegistryView {
3649
+ list(sessionId: string): readonly BackgroundTaskInfo[];
3650
+ /**
3651
+ * 取 `since`(绝对偏移)之后的输出。任务不存在时 `undefined`。
3652
+ *
3653
+ * **别的会话的 `id` 也是 `undefined`** —— 任务表那边故意不区分这两种情况
3654
+ * (判据在 `background.ts` 的 `taskIn()`),这一层原样转它的结论。
3655
+ */
3656
+ output(sessionId: string, id: string, since: number): TaskOutputView | undefined;
3657
+ }
3658
+ /**
3659
+ * 一个任务 → 载荷。
3660
+ *
3661
+ * ## 为什么要自己算 `since` 而不是直接 `output(id, 0)`
3662
+ *
3663
+ * `taskOutput()` 是给模型设计的**增量**读法:从 `since` 往后取一段,一次最多
3664
+ * `MAX_OUTPUT_CHUNK`。传 0 拿到的是缓冲区**开头**那 8KB —— 对一个刷屏的
3665
+ * `pnpm dev` 来说,那是二十分钟前的日志。而后台任务里有价值的永远是最后几行
3666
+ * (构建结果、报错栈),所以这里把游标推到末尾窗口的起点再取。
3667
+ *
3668
+ * `omittedBytes` 取的是**真实起点**(`nextCursor - output.length`)而不是我们
3669
+ * 请求的那个 `since`:两者在两种情况下不同 —— 环形缓冲已经把 `since` 之前的
3670
+ * 丢了(真实起点更靠后),或者取样这一刻任务又长出了新字节。拿请求值去报,
3671
+ * 界面上那句「前面还有 N 字节」就会是一个我们自己编的数。
3672
+ */
3673
+ declare function toWireTask(sessionId: string, info: BackgroundTaskInfo, registry: TaskRegistryView): WireBackgroundTask;
3674
+ /** 这个会话那一格 → 载荷。**纯函数**,好让三种形态的投影直接断言 */
3675
+ declare function collectTasks(sessionId: string, registry: TaskRegistryView): WireTasksResponse;
3676
+
3677
+ /**
3678
+ * 定时任务那九条端点([方案 45](../../../../.agents/plans/45-scheduled-automation-plan.md) PR-3)。
3679
+ *
3680
+ * ```
3681
+ * GET /api/schedules 两个 tab 一次取齐
3682
+ * POST /api/schedules 建一条
3683
+ * GET /api/schedules/:id 一条的全部字段
3684
+ * PATCH /api/schedules/:id 改一条(含那个开关)
3685
+ * DELETE /api/schedules/:id 删一条,同时撤 OS 注册
3686
+ * GET /api/schedules/:id/runs 这条任务的运行记录
3687
+ * POST /api/schedules/:id/run 「先跑一次」
3688
+ * POST /api/schedules/:id/fix 把欠条变成下一次的授权
3689
+ * GET /api/schedules/:id/runs/:runId/recording 那一次的录像
3690
+ * ```
3691
+ *
3692
+ * ## ⚠️ 这一组**没有 `:sessionId`**,而且不许有
3693
+ *
3694
+ * 一条定时任务不属于任何一段会话 —— 它反过来**产出**会话(每次运行落一条
3695
+ * `source='schedule'` 的)。挂到 `/sessions/:id/` 底下会得到一组名字是会话级、
3696
+ * 行为是进程级的端点,比不一致更坏。判据同 `POST /api/mcp/:name/reconnect`
3697
+ * 和 `GET /api/workspaces/dirs` 那两条(`routes.ts` 里各有一段 ⚠️)。
3698
+ *
3699
+ * ## 六条写端点全是非 GET,一条都不许写成带查询串的 GET
3700
+ *
3701
+ * `auth.ts` 的 Origin 校验**只在非安全方法上要求**。而这一组里最该守住 CSRF 的
3702
+ * 不是「改一个字段」,是 `POST /run`:它会在用户机器上**真的起一次 agent 运行**,
3703
+ * 按那条任务自己的清单不经确认地跑命令。写成 `GET /run?id=…` 等于任何一个页面
3704
+ * 都能替用户点那个按钮。
3705
+ *
3706
+ * ## 校验不过是 200 + `issues`,不是 400
3707
+ *
3708
+ * 判据在 `WireScheduleSaveResponse` 上:请求本身完全合法,是表单里某几格填错了,
3709
+ * 而那几格要在表单上逐格标红。这一层的 400 只留给「这个 JSON 读不懂」
3710
+ * (`body.ts` 那一层)。
3711
+ */
3712
+
3713
+ /**
3714
+ * 一份录像最多回多少帧。
3715
+ *
3716
+ * 一次跑飞的运行能写出几万帧(每个 `text-delta` 一帧),整份推给浏览器会让
3717
+ * 那个标签页卡死。超了就截断 + `truncated: true` —— **必须说出来**,
3718
+ * 判据同 `WireSkillBodyResponse.truncated`:一份看起来完整、其实少了后半截的
3719
+ * 回放,比一句「太长了没给全」坏得多。
3720
+ */
3721
+ declare const MAX_RECORDING_FRAMES = 4000;
3722
+ /**
3723
+ * 库里那条任务 → 网线上那一行。
3724
+ *
3725
+ * 两格算出来的东西都在这一层算:
3726
+ *
3727
+ * - `nextRunAt` 走 core 的那份日期算术(runtime 转出来的),**不在浏览器里抄**;
3728
+ * - `allowlistApplies` 问 `needsAllowlist()`,**不在浏览器里判第二遍** ——
3729
+ * 「只读」和「跳过全部检查」两档下都是 `false`,而理由正好相反,
3730
+ * 一个布尔在这儿就够(界面对两档说的话由 `permission` 那一格决定)。
3731
+ */
3732
+ declare function toWireRow(control: ScheduleControlView, def: ScheduleDefinition): WireScheduleRow;
3733
+
3734
+ /**
3735
+ * @epoch-agent/server —— 本地 HTTP + SSE 服务端
3736
+ *
3737
+ * ✅ 做:`node:http` 手写路由、SessionHub 扇出、SSE 续传、cookie 鉴权 + Origin 校验
3738
+ * ❌ 不做:
3739
+ * - **不许 import `@epoch-agent/core`**。与 examples/electron-minimal 同规矩:
3740
+ * 需要什么能力就让 runtime 转出来,这里出现 core 就是接口设计漏了一块
3741
+ * (`pnpm check:layers` 会红)
3742
+ * - **不许写 `console`**。它被宿主嵌着跑,打印 URL 是宿主的事
3743
+ * (在 `LIBRARY_DIRS` 里,同样是 `check:layers` 会红)。宿主有两类:
3744
+ * 仓库里的 cli(`epoch web`),和仓库外**要现成界面**的嵌入方
3745
+ * (Electron / VS Code,见 docs/EMBEDDING.md §9)—— 后者也是
3746
+ * `dist/web` 随本包发的原因,见 web-root.ts
3747
+ * - 不引第三方 HTTP 框架(方案 20 §9.6 第 3 条)
3748
+ *
3749
+ * 契约在 `@epoch-agent/protocol` 的 `wire.ts`:信封 `WireEnvelope`、Hub 事件
3750
+ * `WireHubEvent`、鉴权 `WireAuthGuard`。fixture 在仓库根的 `fixtures/wire/`,
3751
+ * `__tests__/wire-fixtures.test.ts` 断言本服务产出的信封序列逐条等于它。
3752
+ *
3753
+ * ## 端口被占了怎么办(方案 20 §12 留给 Track A 的那条)
3754
+ *
3755
+ * **报错退出,不自动找下一个可用端口。** 自动顺延意味着每次启动的 URL 都可能不同:
3756
+ * 用户收藏的那个标签页会连到一个「不是这次这个」的服务上(或者一个别的程序),
3757
+ * 而两边都不会报错。`--port` 本来就是给这种情况准备的。
3758
+ */
3759
+
3760
+ /**
3761
+ * `createWebServer()` 的返回值 —— cli 的 `epoch web` 拿到的就是这个。
3762
+ *
3763
+ * `close()` 必须是幂等的(Ctrl+C 和进程退出钩子会各调一次),
3764
+ * `url` 含首屏换 cookie 的 `?token=` —— 打给用户看没问题(那是他自己的终端),
3765
+ * 但**不要写进日志文件或遥测**。
3766
+ */
3767
+ interface EpochWebServer {
3768
+ /** 带 `?token=` 的首屏地址。给浏览器打开用 */
3769
+ url: string;
3770
+ /** 实际监听的端口(`--port 0` 时由内核分配) */
3771
+ port: number;
3772
+ /** 实际监听的地址 */
3773
+ host: string;
3774
+ /**
3775
+ * 本次生效的 token —— 宿主没传 `token` 时由这里随机生成,**返回来是唯一出口**。
3776
+ *
3777
+ * 不返回的话宿主只能去正则 `url` 里的 `?token=` 才能自己发一条带鉴权的请求,
3778
+ * 那等于把 URL 的拼法变成公开契约。同样**不要写进日志文件或遥测**。
3779
+ */
3780
+ token: string;
3781
+ /**
3782
+ * 这次实际在发的前端产物目录。`undefined` = 没找到,发的是占位页。
3783
+ *
3784
+ * 返回它是为了让宿主**能把这件事说出来**:占位页和真界面都是 200,
3785
+ * 服务日志里一个字都没有,不返回的话「装出来的界面永远是占位页」这种事
3786
+ * 只能靠人眼发现(这正是 2026-08-14 之前嵌入宿主踩的那个坑,
3787
+ * 见 [web-root.ts](./web-root.ts))。
3788
+ */
3789
+ webRoot?: string;
3790
+ /**
3791
+ * 同进程旁听事件流(方案 54 §三)。**不是一条连接。**
3792
+ *
3793
+ * 不给这个出口的话,嵌入宿主要感知「agent 在跑 / 卡在审批上 / 刚做完」,唯一的
3794
+ * 办法是**从自己的 Node 里连一条 `GET /api/events`** —— 那条连接在 Hub 眼里和一个
3795
+ * 开着的浏览器标签页没有区别,于是宿主为了点亮一枚状态灯,把引擎的两条不变量
3796
+ * 一起废掉了:**全部连接断开 30 秒后放弃挂起的审批 + 中止在跑的回合**,以及
3797
+ * **全断之后丢掉排队的消息**。那不是「接得不方便」,那是我们的行为在嵌入形态下
3798
+ * 和文档写的不一样,**而宿主没有任何办法知道**。所以这里给的是一条明确不算人的路:
3799
+ *
3800
+ * - 不计入连接数、不影响上面那两条判定
3801
+ * - 没有 `Last-Event-ID` / 补帧(同进程的函数调用不会丢帧)
3802
+ * - 收到的是和 SSE 上**同一个** `WireEnvelope`(`seq` 都一样),
3803
+ * 但没有连接级的 `connected` / `stream-reset` —— 那两帧描述的是「这条连接」
3804
+ *
3805
+ * 「四种状态怎么映射到宿主窗口上的一枚灯」是宿主的产品决定,我们不替它做。
3806
+ *
3807
+ * @returns 取消旁听。**幂等**。`close()` 之后所有旁听自动失效
3808
+ */
3809
+ observe(sink: (frame: WireEnvelope) => void): () => void;
3810
+ /**
3811
+ * 当前所有会话在 Hub 侧的状态快照 —— 等价于 `GET /api/sessions` 的实时那一半,
3812
+ * 不过 HTTP。
3813
+ *
3814
+ * **光有 `observe()` 不够,所以它不是顺手加的。** SSE 上的 `session-state`
3815
+ * 只在状态**真的变了**的时候发(`hub.ts` 的 `setState`:不变就不发,否则并行审批
3816
+ * 会刷出一串重复状态)。于是一个刚起来的宿主在下一次状态变化之前什么都不知道 ——
3817
+ * 它会把一个正在跑的回合画成 idle,一直画到那一轮结束。
3818
+ * **首屏要的是快照,之后才是增量**,两个都得有。
3819
+ */
3820
+ snapshot(): HubSessionState[];
3821
+ /** 幂等:断开所有 SSE 连接 + 关监听 + `dispose()` 运行时 */
3822
+ close(): Promise<void>;
3823
+ }
3824
+ interface CreateWebServerOptions {
3825
+ /** `buildRuntime()` 的产物。`EpochRuntime` 结构上满足 `WebRuntimeView` */
3826
+ runtime: WebRuntimeView;
3827
+ /** `/api/health` 回的版本号 —— server 不该去猜宿主的版本 */
3828
+ version: string;
3829
+ /**
3830
+ * artifacts 的**父目录**(`<homeDir>/artifacts`)。
3831
+ *
3832
+ * **缺省是 `runtime.artifactsRoot`,也就是引擎这次真的往里写产物的那个目录** ——
3833
+ * 所以正常宿主一个字都不用填(方案 54 §二)。
3834
+ *
3835
+ * 在这条缺省存在之前,这个选项是**全文档最容易静默出错的一处**:宿主必须
3836
+ * 自己算一个路径,并保证它和引擎算的逐字符相同,而它没有任何办法验证。
3837
+ * 分叉的表现是产物栏点开 404 —— 而服务、日志、状态码、诊断全部正常,一个字不报。
3838
+ *
3839
+ * 传它只有一种正当理由:宿主真的想让界面从别处读(比如把产物挪到了另一个卷)。
3840
+ * **那时对不上就是宿主自己的选择,不再是我们的缺省害的。**
3841
+ * server 不许 import infra,所以这个字符串只能由 runtime 报。
3842
+ */
3843
+ artifactsRoot?: string;
3844
+ /** `--host`,缺省 `127.0.0.1` */
3845
+ host?: string;
3846
+ /** `--port`,缺省 4400。`0` = 内核分配 */
3847
+ port?: number;
3848
+ /** `--token`。非回环绑定时必填,否则**拒绝启动** */
3849
+ token?: string;
3850
+ /**
3851
+ * web 构建产物目录。**缺省是本包自带的那份**(`<server 包>/dist/web`,
3852
+ * 见 [web-root.ts](./web-root.ts)),所以想要第一方界面的宿主什么都不用传。
3853
+ *
3854
+ * 只有两种情况需要显式给:自带的产物不合用(宿主要发自己那套前端),
3855
+ * 或者开发时想指到 vite 的输出目录。
3856
+ */
3857
+ webRoot?: string;
3858
+ /**
3859
+ * 界面的首屏初值 —— 主题和语言(方案 54 §七)。
3860
+ *
3861
+ * 给了就拼进 {@link EpochWebServer.url}(`?theme=dark&lang=zh`),界面在第一帧
3862
+ * 之前读它、写进自己的 `localStorage` 再按现有逻辑走。**不给就一个参数都不拼**,
3863
+ * URL 和以前逐字节相同。
3864
+ *
3865
+ * 存在的理由是宿主今天**只能抄一个我们没承诺的 key 名**:那两个 key
3866
+ * (`epoch.theme` / `epoch.lang`)是 `@epoch-agent/web` 的导出常量,而那个包是
3867
+ * `private: true` —— 宿主 import 不到,只能给 webview 挂一个 preload、
3868
+ * 在页面脚本之前手写 localStorage。能用,但它依赖的是一个我们随时能改的名字。
3869
+ *
3870
+ * ⚠️ **是初值,不是锁**,判据见 {@link UiPreset}。
3871
+ */
3872
+ ui?: UiPreset;
3873
+ /** 环形缓冲容量,缺省 512 */
3874
+ ringCapacity?: number;
3875
+ /**
3876
+ * 同时活跃的会话数上限,缺省 8(方案 30 §2.3)。
3877
+ *
3878
+ * 转出来是因为它**这一轮才真的会被碰到**:在多会话工厂之前活跃会话恒为 1,
3879
+ * 这个旋钮除了用例没人用得上。超了就把最久没动过的**空闲**会话冷却掉 ——
3880
+ * 它从此不在 Hub 里,但仍然在会话列表里(列表的另一半来自 DB),
3881
+ * 重新打开时历史走 `GET /api/sessions/:id/messages` 回放。
3882
+ */
3883
+ maxActiveSessions?: number;
3884
+ /** 全部连接断开之后的宽限期,缺省 30 秒 */
3885
+ idleGraceMs?: number;
3886
+ /** SSE 心跳间隔,缺省 15 秒 */
3887
+ heartbeatMs?: number;
3888
+ /** 时钟注入,缺省 `Date.now`。用例靠它对齐 fixture 的 `ts` */
3889
+ clock?: () => number;
3890
+ }
3891
+ type CreateWebServerResult = {
3892
+ ok: true;
3893
+ server: EpochWebServer;
3894
+ } | {
3895
+ ok: false;
3896
+ error: string;
3897
+ };
3898
+ /**
3899
+ * 起服务。
3900
+ *
3901
+ * 失败一律走返回值不走异常:两种失败(绑定策略不允许 / 端口起不来)都是
3902
+ * 「要打给用户看的一句话」,而不是编程错误 —— 让调用方去 try/catch 一个
3903
+ * 预期之内的结果只会让 cli 那边写得更难看。
3904
+ */
3905
+ declare function createWebServer(opts: CreateWebServerOptions): Promise<CreateWebServerResult>;
3906
+
3907
+ export { type ApiContext, type AuthGuardOptions, type BindDecision, type BindRequest, type BoundWorkspace, type CatalogDeletion, type CatalogHit, type CatalogRow, type CheckpointSummaryView, type CommandsView, type CreateSessionResult, type CreateWebServerOptions, type CreateWebServerResult, DEFAULT_MAX_ACTIVE_SESSIONS, DEFAULT_MAX_QUEUED_MESSAGES, DEFAULT_RING_CAPACITY, DEFAULT_WEB_HOST, DEFAULT_WEB_PORT, EnvelopeRing, type EpochWebServer, type ExpansionOutcome, type FrameSink, type HubSession, type HubSessionState, type LiveSessionView, MAX_DIFF_BYTES, MAX_DIFF_FILES, MAX_RECORDING_FRAMES, MAX_SKILL_BODY_BYTES, type ModelTurnsView, type QueuedMessage, type RewindFilePlanView, type RewindOutcomeView, type RewindPreviewView, type RewindResultView, type RuntimeCapabilities, type RuntimeCheckpoints, type RuntimeCommands, type RuntimeSecurity, type RuntimeSettings, type ScheduleCapabilityView, type ScheduleControlView, type ScheduleCreateView, type ScheduleFireView, type ScheduleRegisterView, type ScheduleSaveView, type ScheduleUpdateView, type SelectionView, type SessionCatalog, type SessionFactoryView, type SessionHistoryFacts, SessionHub, type SessionHubOptions, type SessionModelView, type SessionPermissionsView, type SessionPlanView, type SessionRoleView, type SetLevelView, type SetSelectionView, type StartResult, TASK_TAIL_BYTES, type TaskRegistryView, type TrustView, type TurnDecorator, UI_LANG_PARAM, UI_THEME_PARAM, type UiLangPref, type UiPreset, type UiThemePref, type WebRuntimeView, type WorkspaceBindFailure, type WorkspaceBindOutcome, type WorkspaceCheckpoints, type WorkspaceDiffOptions, type WorkspaceView, collectCapabilities, collectSecurity, collectSettings, collectTasks, collectWorkspaceDiff, createAuthGuard, createWebServer, decideBinding, defaultWebRoot, expandUserContent, firstScreenUrl, generateToken, readRewindInput, resolveArtifactPath, toWireCheckpoint, toWireCommand, toWirePreview, toWireRewindResult, toWireRow, toWireSkillBody, toWireTask, unavailableToHttp };