@epoch-agent/server 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +360 -81
- package/dist/index.d.ts +839 -15
- package/dist/index.js +871 -585
- package/dist/web/assets/index-Bj3SdU3h.js +366 -0
- package/dist/web/assets/index-cRd3MeBE.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -3
- package/dist/web/assets/index-D3Z9KCql.js +0 -295
- package/dist/web/assets/index-W6_gte_K.css +0 -1
package/README.md
CHANGED
|
@@ -28,11 +28,16 @@ created.server.url; // 含一次性 ?token=,首屏换 HttpOnly cookie
|
|
|
28
28
|
created.server.port; // `port: 0` 时内核分配的真实端口——**别写死 4400**
|
|
29
29
|
created.server.token; // 宿主自己发请求时用,不必去正则 url
|
|
30
30
|
created.server.webRoot; // undefined = 这次发的是占位页(没构建过前端)
|
|
31
|
-
created.server.observe(fn); //
|
|
31
|
+
created.server.observe(fn); // 同进程旁听**广播层**的 wire 帧,**不算一条连接**
|
|
32
32
|
created.server.snapshot(); // 首屏那张状态快照(SSE 上的 session-state 只发变化)
|
|
33
33
|
await created.server.close(); // 幂等
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
⚠️ **`observe()` 只看得见「由 hub 驱动」的会话。** 它挂在广播层上(wire 帧、
|
|
37
|
+
序列化之后),所以宿主自己 for-await `session.run()` 的那半边它一帧都收不到。
|
|
38
|
+
那一半走 [runtime](../runtime) 的 `EpochRuntime.events`(方案 61)——
|
|
39
|
+
两者的取舍表在 [EMBEDDING §13.5](../../docs/EMBEDDING.md)。
|
|
40
|
+
|
|
36
41
|
**主题和语言给 `ui`,别去写浏览器的 `localStorage`**(2026-08-15,方案 54 §七):
|
|
37
42
|
|
|
38
43
|
```ts
|
|
@@ -79,67 +84,81 @@ tarball 里真的有那些字节。
|
|
|
79
84
|
|
|
80
85
|
## 端点
|
|
81
86
|
|
|
82
|
-
| 端点 | 说明
|
|
83
|
-
| ------------------------------------------------- |
|
|
84
|
-
| `GET /api/health` | **不鉴权**,只回 `{ok, version}`
|
|
85
|
-
| `GET /api/config` | 模型 / 权限级别 / 工作区 / 品牌 / 启动诊断 / 绑没绑在回环之外(`?lang=` 见下)
|
|
86
|
-
| `GET /api/events` | SSE,负载是 `WireEnvelope`
|
|
87
|
-
| `GET /api/sessions[?q=…]` | 会话列表;带 `q` 走 FTS5 检索
|
|
88
|
-
| `POST /api/sessions` | 幂等确保会话,可带 `{workspace?}`(见下)
|
|
89
|
-
| `GET /api/sessions/:id` | **这一段**自己那一行;**不受上一条那 50 行封顶**(见下)
|
|
90
|
-
| `DELETE /api/sessions/:id` | 真删:Hub + SQLite + 检查点目录
|
|
91
|
-
| `PATCH /api/sessions/:id` | `{title}` → 重命名,回改完之后的整行
|
|
92
|
-
| `GET /api/sessions/:id/messages` | 历史回放(**冷却 / 上个进程留下的也回放**)
|
|
93
|
-
| `POST /api/sessions/:id/messages` | 发消息,立即 202
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `GET /api/sessions/:id/
|
|
101
|
-
| `
|
|
102
|
-
| `POST /api/mcp
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `POST /api/
|
|
107
|
-
| `POST /api/skills/import`
|
|
108
|
-
| `POST /api/
|
|
109
|
-
| `
|
|
110
|
-
| `GET /api/
|
|
111
|
-
| `
|
|
112
|
-
| `POST /api/
|
|
113
|
-
| `
|
|
114
|
-
| `POST /api/
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
117
|
-
| `GET /api/sessions/:id/
|
|
118
|
-
| `GET /api/sessions/:id/
|
|
119
|
-
| `
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
124
|
-
| `GET /api/sessions/:id/
|
|
125
|
-
| `
|
|
126
|
-
| `GET /api/sessions/:id/
|
|
127
|
-
| `
|
|
128
|
-
| `GET /api/
|
|
129
|
-
| `POST /api/sessions/:id/
|
|
130
|
-
| `POST /api/
|
|
131
|
-
| `GET /api/sessions/:id/
|
|
132
|
-
| `GET /api/
|
|
133
|
-
| `POST /api/
|
|
134
|
-
| `GET /api/
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
137
|
-
| `
|
|
138
|
-
| `POST /api/
|
|
139
|
-
| `
|
|
140
|
-
| `GET /api/schedules
|
|
141
|
-
| `GET /api/
|
|
142
|
-
| `
|
|
87
|
+
| 端点 | 说明 |
|
|
88
|
+
| ------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
89
|
+
| `GET /api/health` | **不鉴权**,只回 `{ok, version}` |
|
|
90
|
+
| `GET /api/config` | 模型 / 权限级别 / 工作区 / 品牌 / 启动诊断 / 绑没绑在回环之外(`?lang=` 见下) |
|
|
91
|
+
| `GET /api/events` | SSE,负载是 `WireEnvelope` |
|
|
92
|
+
| `GET /api/sessions[?q=…]` | 会话列表;带 `q` 走 FTS5 检索 |
|
|
93
|
+
| `POST /api/sessions` | 幂等确保会话,可带 `{workspace?}`(见下) |
|
|
94
|
+
| `GET /api/sessions/:id` | **这一段**自己那一行;**不受上一条那 50 行封顶**(见下) |
|
|
95
|
+
| `DELETE /api/sessions/:id` | 真删:Hub + SQLite + 检查点目录 |
|
|
96
|
+
| `PATCH /api/sessions/:id` | `{title}` → 重命名,回改完之后的整行 |
|
|
97
|
+
| `GET /api/sessions/:id/messages` | 历史回放(**冷却 / 上个进程留下的也回放**) |
|
|
98
|
+
| `POST /api/sessions/:id/messages` | 发消息,立即 202;这一轮在跑就**排队**;带 `@:` 时 202 上有 `references` 收据(见下) |
|
|
99
|
+
| `GET /api/sessions/:id/reference-candidates` | 打 `@:` 那一刻面板该列哪几段会话;`?q=` 只碰 id / cwd / 标题(**不搜转录文本**,见下) |
|
|
100
|
+
| `POST /api/sessions/:id/abort` | 中止本轮,并丢掉排在后面的消息 |
|
|
101
|
+
| `GET /api/sessions/:id/approvals` | 重连后补拉挂起的审批 |
|
|
102
|
+
| `POST /api/approvals/:requestId` | `{outcome}` → 接回引擎 |
|
|
103
|
+
| `GET /api/sessions/:id/questions` | 重连后补拉挂起的提问 |
|
|
104
|
+
| `POST /api/questions/:requestId` | `{answers, skipped?}` → 接回引擎 |
|
|
105
|
+
| `GET /api/sessions/:id/capabilities` | 专家 / 技能 / 连接器三栏(方案 42 PR-1) |
|
|
106
|
+
| `GET /api/sessions/:id/skills/:name` | 一个技能的正文(方案 56 §1.2,**不计数**,见下) |
|
|
107
|
+
| `POST /api/mcp` | 加一台 MCP,写进 `~/.epoch/mcp.json`(**进程级 + 落盘,回环绑定才给**,见下) |
|
|
108
|
+
| `POST /api/mcp/:name/reconnect` | 重连一台 MCP(方案 56 §1.1,**进程级**,见下) |
|
|
109
|
+
| `GET /api/mcp/config` | `mcp.json` 的**原文** + 指纹(**回环绑定才给,含这条 GET**,见下) |
|
|
110
|
+
| `PUT /api/mcp/config` | `{text, revision}` → 整份覆盖(**只写盘,不生效**,见下) |
|
|
111
|
+
| `POST /api/mcp/config/apply` | `{revision}` → 把盘上那份搬进这个进程(**立刻生效**,见下) |
|
|
112
|
+
| `POST /api/skills/import/preview` | 「将导入什么」,不写字节(方案 42 §六,**进程级**,见下) |
|
|
113
|
+
| `POST /api/skills/import` | 从本机目录导入技能(**收路径不收字节**,见下) |
|
|
114
|
+
| `POST /api/roles` | 新建一个身份,写进 `~/.epoch/agents/`(**回环绑定才给**,见下) |
|
|
115
|
+
| `GET /api/plugins` | 装着的 + 市场里有什么 + `pendingRestart`(**进程级**,方案 59 E2,见下) |
|
|
116
|
+
| `POST /api/plugins/install/preview` | 「将安装什么」,不写字节(**只收 `<市场>/<插件>`**,见下) |
|
|
117
|
+
| `POST /api/plugins/install` | `{ref, token}` → 真装(**回环绑定才给;装完不生效**,见下) |
|
|
118
|
+
| `POST /api/plugins/update/preview` | `{name}` → 更新会带来什么(**更新也要过这一道**,见下) |
|
|
119
|
+
| `POST /api/plugins/update` | `{name, token}` → 真更新(**回环绑定才给**) |
|
|
120
|
+
| `POST /api/plugins/uninstall` | `{name}` → 卸载(**回环绑定才给**;卸完那些扩展物还活着,见下) |
|
|
121
|
+
| `GET /api/sessions/:id/security` | 权限 / 沙箱 / 策略 / 工作区 / 审计流水五块(42 PR-3) |
|
|
122
|
+
| `GET /api/sessions/:id/tools` | 有哪些工具、此刻谁在挡它们(43 §七,**不是上一条的子集**) |
|
|
123
|
+
| `GET /api/sessions/:id/settings` | 每一项设置赢在哪一层 + 能写进哪几层(43 §七,见下) |
|
|
124
|
+
| `POST /api/sessions/:id/settings` | `{key, layer, value}` → 把一个键写进一层(见下) |
|
|
125
|
+
| `GET /api/sessions/:id/model` | 这个会话**此刻**用哪个模型(方案 26,见下) |
|
|
126
|
+
| `POST /api/sessions/:id/model` | `{model}` → 立刻换;给 `null` = 回到配置里那个 |
|
|
127
|
+
| `GET /api/providers` | 支持哪几家 + 各自 key 配了没(**进程级**,闭合集,见下) |
|
|
128
|
+
| `POST /api/providers/:type/models` | 探这一家有哪些模型(**进程级**,`{refresh?}` 绕缓存,见下) |
|
|
129
|
+
| `GET /api/sessions/:id/commands` | 自定义斜杠命令表(**不含正文**,见下) |
|
|
130
|
+
| `GET /api/sessions/:id/tasks` | 后台任务 + 输出尾巴(**进程级**,见下) |
|
|
131
|
+
| `GET /api/sessions/:id/plan` | 已批准的计划(**单数**)+ plan 模式的状态 |
|
|
132
|
+
| `POST /api/sessions/:id/plan` | `{action:'enter'\|'exit'}` → 进 / 出 plan 模式 |
|
|
133
|
+
| `GET /api/sessions/:id/permission` | 此刻是哪一档 + 哪几档改不成(决定 20 ③,见下) |
|
|
134
|
+
| `POST /api/sessions/:id/permission` | `{level}` → 立刻换档(见下) |
|
|
135
|
+
| `POST /api/sessions/:id/approval-cache` | `{id}` → 撤销一条**缓存的审批决定**(**不是 `/approvals`**,见下) |
|
|
136
|
+
| `GET /api/sessions/:id/checkpoints` | 这个进程现在能退哪几轮(方案 27,见下) |
|
|
137
|
+
| `GET /api/sessions/:id/checkpoints/:turn` | 按下去之前那份清单:会动哪些文件 |
|
|
138
|
+
| `POST /api/sessions/:id/rewind` | `{turnIndex, scope, overwrite?}` → 真的退 |
|
|
139
|
+
| `GET /api/sessions/:id/artifacts` | 这个会话改过哪些**文件**(42 PR-4) |
|
|
140
|
+
| `GET /api/sessions/:id/workspace` | **这个会话**绑在哪儿(见下) |
|
|
141
|
+
| `GET /api/workspaces/dirs[?path=][&hidden=1]` | 这台机器上有哪些目录(**进程级**,见下) |
|
|
142
|
+
| `POST /api/sessions/:id/workspace` | `{root}`\|`{none:true}` → 给还没选地盘的那一档选(方案 55 PR-1,见下) |
|
|
143
|
+
| `POST /api/workspaces` | `{parent, name}` → 新建一个目录(一次 `mkdir`,见下) |
|
|
144
|
+
| `GET /api/sessions/:id/diff` | **这个会话**的工作区 diff(见下) |
|
|
145
|
+
| `GET /api/schedules` | 定时任务两个 tab 一次取齐(**进程级**,方案 45 PR-3,见下) |
|
|
146
|
+
| `GET /api/schedules/pending` | 还欠着的那几张欠条(dock 第四档 / 侧栏那个点,PR-4)。⚠️ `pending` 是**保留段**不是 id |
|
|
147
|
+
| `POST /api/schedules` | 建一条定时任务,**同时注册进 OS**(见下) |
|
|
148
|
+
| `GET /api/schedules/:id` | 一条的全部字段 + 下次运行 + 清单适不适用 |
|
|
149
|
+
| `PATCH /api/schedules/:id` | 改一条;`{enabled}` 就是那个启用开关 |
|
|
150
|
+
| `DELETE /api/schedules/:id` | 删一条,**同时撤掉 OS 注册**;录像留着 |
|
|
151
|
+
| `GET /api/schedules/:id/runs` | 这条任务的运行记录(最近 20 次,最新在前) |
|
|
152
|
+
| `POST /api/schedules/:id/run` | 「先跑一次」——**真的起一次 agent 运行**(见下) |
|
|
153
|
+
| `POST /api/schedules/:id/fix` | `{runId}` → 把那一轮的欠条加进授权清单(见下) |
|
|
154
|
+
| `GET /api/schedules/:id/runs/:runId/recording` | 那一次的 `WireEnvelope` 录像(见下) |
|
|
155
|
+
| `GET /api/sessions/:id/goal` | **这段会话**的目标 + 表单那四把尺子(方案 52 PR-4,见下) |
|
|
156
|
+
| `POST /api/sessions/:id/goal` | `{objective, maxRounds?}` → 建一个(**人专用**) |
|
|
157
|
+
| `PATCH /api/sessions/:id/goal` | `{action}` → edit / budget / pause / resume / complete。**`blocked` 不在这条路上** |
|
|
158
|
+
| `DELETE /api/sessions/:id/goal` | 清掉这个目标 |
|
|
159
|
+
| `GET /api/goals/blocked` | 哪几段会话此刻卡着(dock 第四档,**进程级**,见下) |
|
|
160
|
+
| `GET /api/artifacts/:sid/:name` | 工具产出的大块内容 |
|
|
161
|
+
| `GET /*` | 静态产物 + SPA 回退(`assets.ts`) |
|
|
143
162
|
|
|
144
163
|
载荷契约在 [protocol/src/wire-rest.ts](../protocol/src/wire-rest.ts),能力页那个端点
|
|
145
164
|
的在 [wire-capability.ts](../protocol/src/wire-capability.ts),SSE 的信封在
|
|
@@ -148,6 +167,55 @@ tarball 里真的有那些字节。
|
|
|
148
167
|
清单以外还要知道的六件事(鉴权两段式、`Origin` 校验、`seq` 与 `Last-Event-ID` 续传、
|
|
149
168
|
断连不等于人走了)写在 [docs/EMBEDDING.md §10](../../docs/EMBEDDING.md)。
|
|
150
169
|
|
|
170
|
+
### `/api/sessions/:id/goal` + `/api/goals/blocked` —— 目标那一组(方案 52 PR-4)
|
|
171
|
+
|
|
172
|
+
**四条 session-scoped + 一条进程级**,而那条分界是硬的:前四条问「**这一段**会话的
|
|
173
|
+
目标」,第五条问「这台机器上哪几段卡着」—— 后者挂到 `/sessions/:id/` 底下的话,
|
|
174
|
+
dock 得先知道有哪几段会话才问得出来,而它要的正是那个还不知道的东西
|
|
175
|
+
(判据同 `GET /api/schedules/pending`)。
|
|
176
|
+
|
|
177
|
+
#### ⚠️ 借的是 `runtime.goalCatalog`,**不是** `runtime.goals`
|
|
178
|
+
|
|
179
|
+
后者是**引导会话**那一份(`GoalControl`,收窄到一段会话)。拿它去答 URL 上那个
|
|
180
|
+
sessionId 就是方案 30 §9.5 那条 bug 的形状 —— 编译得过、跑得通、只是答错会话
|
|
181
|
+
(`WebRuntimeView` 上那个进程级的 `checkpoints` 就是因为这个被删掉的)。
|
|
182
|
+
全文在 [src/goal/view.ts](src/goal/view.ts) 的文件头。
|
|
183
|
+
|
|
184
|
+
#### ⚠️ 它**不像检查点 / 计划那样按「这个进程在不在跑这段会话」分支**
|
|
185
|
+
|
|
186
|
+
目标**没有内存态** —— `GoalService` 每次现读 SQLite,所以一段冷却掉的会话在这一格
|
|
187
|
+
上和活着的一样准:
|
|
188
|
+
|
|
189
|
+
| 那一格 | 真源 | 冷却掉的会话答得出吗 |
|
|
190
|
+
| ------ | ------------------------------ | -------------------- |
|
|
191
|
+
| 检查点 | 这个进程的 `CheckpointManager` | ❌ 只能回空列表 |
|
|
192
|
+
| 计划 | 内存那份优先,回退读盘 | ⚠️ 读盘那份可能旧 |
|
|
193
|
+
| 目标 | **只有 SQLite 一份** | ✅ 一样准 |
|
|
194
|
+
|
|
195
|
+
#### 两种「没有」,两个状态码
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
这个进程没有目标存储(会话库起不来) → 503 no-goal-store 「问不出来」
|
|
199
|
+
这段会话没有目标 → 200 {goal: null} 「真的没有」
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
混成一个 `goal: null` 的代价很具体:界面会对着一台没有会话库的机器画出
|
|
203
|
+
「这个会话还没有目标,来定一条」,而按下去必然失败。
|
|
204
|
+
⚠️ **`GET /api/goals/blocked` 在同一档上回空数组而不是 503** —— 那是一条通知条的
|
|
205
|
+
素材,问不出来该少一条通知,不该让首屏那一发变成一条红字。
|
|
206
|
+
|
|
207
|
+
#### ⚠️ 被拒是 200 + `ok:false`,`blocked` 那个态**在契约上说不出口**
|
|
208
|
+
|
|
209
|
+
十个拒绝码(`busy` / `wrong-phase` / `bad-budget` …)走 200,判据同
|
|
210
|
+
`WireScheduleSaveResponse`:请求本身完全合法,是这个值 / 这个态不接受,
|
|
211
|
+
而那句话要出现在用户刚敲字的那一格旁边。400 只留给「这个 JSON 读不懂」。
|
|
212
|
+
|
|
213
|
+
而 `PATCH` 的 `action` **只有五个值,`blocked` 不在其中**:那个 `code` 是给程序
|
|
214
|
+
路由用的键(dock 第四档按它决定给「加进授权清单」还是「去那段会话」),
|
|
215
|
+
收一个浏览器编出来的 `needs-approval`,方向是提权。两道闸各自独立 ——
|
|
216
|
+
契约那一道在 `WireGoalUpdateRequest`,运行时那一道在
|
|
217
|
+
[src/goal/body.ts](src/goal/body.ts)。
|
|
218
|
+
|
|
151
219
|
### `/api/schedules*` —— 定时任务那一组(方案 45 PR-3)
|
|
152
220
|
|
|
153
221
|
**这九条上一个 `:sessionId` 都没有,而那不是漏了。** 一条定时任务不属于任何一段
|
|
@@ -191,7 +259,13 @@ tarball 里真的有那些字节。
|
|
|
191
259
|
### `?lang=` —— 这一次响应用哪个语言(方案 58)
|
|
192
260
|
|
|
193
261
|
API 上有一个通用的可选查询参数 `lang`(常量在 protocol 的 `WIRE_LANG_PARAM`)。
|
|
194
|
-
|
|
262
|
+
**上面那张表里 `/api/` 下的端点都读它**(2026-08-18 起,PR-2 之前只有
|
|
263
|
+
`GET /api/config`),**两条例外,而且都是刻意的**:`GET /api/health`(响应体只有
|
|
264
|
+
`{ok, version}`,一句人话都没有)和 `GET /api/events`(见下面那条 SSE)。
|
|
265
|
+
|
|
266
|
+
⚠️ **它其实比「那张表里的端点」管得宽**(2026-08-21 起):`guard` 拒了那一发的
|
|
267
|
+
四条理由、500 那句、`GET /*` 静态兜底那句 404 **也跟着它走** —— 那三处不属于表里
|
|
268
|
+
任何一条端点。判据见下面那张逐层对照表。
|
|
195
269
|
|
|
196
270
|
```
|
|
197
271
|
GET /api/config?lang=en
|
|
@@ -202,15 +276,38 @@ POST /api/sessions/abc/permission?lang=zh → 错误消息也跟着这个语
|
|
|
202
276
|
(`system | zh | en`)—— 两者拼法相同、值域不同,判据在 `WIRE_LANG_PARAM` 的 JSDoc 上
|
|
203
277
|
- **没带 / 认不出来 → 沿用进程语言**(`EPOCH_LANGUAGE` / `config.yaml` / 系统 locale),
|
|
204
278
|
不报错。这是这个参数出现之前的全部行为,逐字节保留
|
|
205
|
-
-
|
|
206
|
-
`message
|
|
207
|
-
`skipped[].message` / `detail`(那一批走 200
|
|
279
|
+
- 跟着它变的有四类:`GET /api/config` 的 `diagnostics`、**`{error:{code,message}}` 里那个
|
|
280
|
+
`message`**、`POST /api/skills/import[/preview]` 回的 `issues[].message` /
|
|
281
|
+
`skipped[].message` / `detail`(那一批走 200 响应体,不是错误体),
|
|
282
|
+
以及 **`guard` 拒了那一发 / 500 / 静态 404 那三句**(2026-08-21 起,见下)
|
|
208
283
|
- ⚠️ **`code` 一个字都不跟。** 那是契约(前端只认它做分支),同 `Diagnostic.module`。
|
|
209
284
|
跟语言走的只有给人看的那句话
|
|
210
|
-
- ⚠️ **还有一批消息今天不跟,而这是已知的**:`没有会话 X
|
|
211
|
-
`只支持 GET, POST
|
|
212
|
-
|
|
213
|
-
|
|
285
|
+
- ⚠️ **还有一批消息今天不跟,而这是已知的**:`没有会话 X`(`sendUnknownSession`)/
|
|
286
|
+
`没有这个端点`(`sendNoEndpoint`)/ `只支持 GET, POST`(`methodNotAllowed`)——
|
|
287
|
+
它们是**未抽取的中文字面量**(server 的 i18n 基线里剩下那 22 条中的三条)。
|
|
288
|
+
抽取它们归[方案 40](../../docs/verify/VERIFY_RECORD-40-i18n.md) 那条棘轮
|
|
289
|
+
- ⚠️ **「抽取完顺手把 `lang` 递进去」现在对它们全都成立** —— 2026-08-21 起
|
|
290
|
+
`langOf()` 的解析点在 `createRequestHandler`(在 `guard` 之前,全流程只此一处),
|
|
291
|
+
所以每一句话的每一个调用点手边都有 `lang`:
|
|
292
|
+
|
|
293
|
+
| 那句话 | 在哪一层 | 今天怎么样 |
|
|
294
|
+
| -------------------------------- | ---------------------------------------------------------------- | --------------------------------- |
|
|
295
|
+
| 那四条鉴权拒绝理由(`DENY_KEY`) | `dispatch`,在 `guard` 拒绝那一支 | ✅ 已抽取,**跟着 `?lang=` 走** |
|
|
296
|
+
| `服务端内部错误`(500) | `createRequestHandler` 的 `catch` | ✅ 已抽取,**跟着走** |
|
|
297
|
+
| 静态那句 `找不到 X` | `handleStatic`(`dispatch` 调它) | ✅ 已抽取,**跟着走** |
|
|
298
|
+
| `没有会话 X` / `没有这个端点` | `handleApi` 及其分组分发 | ⬜ 还没抽取;`lang` 在作用域里 |
|
|
299
|
+
| `只支持 …`(`methodNotAllowed`) | `handleApi` 及其分组分发,外加 `dispatch` 里静态那条 `GET, HEAD` | ⬜ 还没抽取;两处 `lang` 都在手边 |
|
|
300
|
+
|
|
301
|
+
上面前三格 2026-08-21 之前是 ❌(`langOf()` 那时在 `handleApi` 里解析,
|
|
302
|
+
它们三个都不在那条路上)。**挪解析点是一次决定,不是一次重构** —— 要答的是
|
|
303
|
+
「一条连凭据都没过的请求该不该拿到译文」。答案是「该」,三条判据在
|
|
304
|
+
[`src/routes.ts`](./src/routes.ts) 文件头「三」第 4 条:`langOf()` 是纯函数、
|
|
305
|
+
值域闭合成三个值(注入不了一个字节);请求本来就自己决定拿到四条理由里的哪一条
|
|
306
|
+
和什么状态码(选语言是严格更少的能力);而 `GET /api/health` 排在 `guard` 之前,
|
|
307
|
+
鉴权之前本来就在渲染。落地证明在
|
|
308
|
+
[`__tests__/wire-lang.test.ts`](__tests__/wire-lang.test.ts) 那三个 describe,
|
|
309
|
+
**决定本身**由仓库根那条门禁钉着(`langOf(` 只许一处,且行号小于 `guard(`)
|
|
310
|
+
|
|
214
311
|
- **SSE(`/api/events`)上没有这个参数**,也不该加:那条流上一句我们渲染的文案都没有,
|
|
215
312
|
钉一个语言在一条长期活着的连接上只会造一个静默过期的值(方案 58 §2.2)
|
|
216
313
|
- ⚠️ **它绝不改进程状态。** 两个标签页各切一种语言互不影响,用例钉在
|
|
@@ -221,9 +318,17 @@ POST /api/sessions/abc/permission?lang=zh → 错误消息也跟着这个语
|
|
|
221
318
|
> 这不是一句叮嘱 —— 仓库根的
|
|
222
319
|
> [`__tests__/wire-lang-threading.test.ts`](../../__tests__/wire-lang-threading.test.ts)
|
|
223
320
|
> 扫 `routes.ts` 里每一条分发调用,实参里没有 `lang` 就点名报错;同一份还查
|
|
224
|
-
> 「server 里每个 `t()`
|
|
321
|
+
> 「server 里每个 `t()` 都收了第三个参数」,以及「`langOf(` 只出现一次、而且排在
|
|
322
|
+
> `guard(` 前面」。它防的是一类**只在合并点才存在的缺陷**:
|
|
225
323
|
> 一条并行分支加的 handler,在它自己那条分支上什么都不缺,合进来之后成为唯一一个
|
|
226
324
|
> 不认 `?lang=` 的口子,而它编译得过、跑得通、用例全绿。
|
|
325
|
+
>
|
|
326
|
+
> ⚠️ 还有一条**否定性**的姊妹门禁
|
|
327
|
+
> ([`__tests__/wire-lang-out-of-scope.test.ts`](../../__tests__/wire-lang-out-of-scope.test.ts)):
|
|
328
|
+
> 方案 58 盘完刻意**不接**的那两片(`core/src/config/keybindings.ts` 打在终端上、
|
|
329
|
+
> `core/src/skill/scan.ts` 会落盘)不许收第三个参数。那两片的风险方向是反的 ——
|
|
330
|
+
> 「顺手接上」看起来完全正确,而接上之后终端 / 磁盘上那句话开始跟着某个浏览器
|
|
331
|
+
> 标签页跑,在任何一屏上都看不出来。
|
|
227
332
|
|
|
228
333
|
`GET /api/sessions/:id/settings` 回的不是值,是**值加上它赢在哪一层**:epoch 的
|
|
229
334
|
设置有六层来源(内置默认 → 用户级 → 项目级 → 项目本地 → 命令行 → 企业托管,见
|
|
@@ -595,6 +700,88 @@ frontmatter 逐格对上**(`mcp.json` 那条刻意收窄,这一条刻意不
|
|
|
595
700
|
—— 删一个身份不会往 system prompt 里塞字,但会静默拿掉别人正依赖的一段边界,
|
|
596
701
|
那道闸门的判据和「建」不一样
|
|
597
702
|
|
|
703
|
+
#### `/api/plugins*` 那六条 —— 插件那一页(方案 59 §六 E2,2026-08-21)
|
|
704
|
+
|
|
705
|
+
> handler 在 [src/plugin.ts](./src/plugin.ts)(单独成文件,刀口按题目下同上:
|
|
706
|
+
> 这一份答的是「怎么往这台机器上装一包扩展物」)。底座是 `EpochRuntime.plugins`
|
|
707
|
+
> (方案 59 PR-4),判据全文在 protocol 的
|
|
708
|
+
> [wire-plugin.ts](../protocol/src/wire-plugin.ts) 文件头。
|
|
709
|
+
|
|
710
|
+
**这一层一条策略判断都没有。** 谁能装(清单里那些)、装什么
|
|
711
|
+
(`<市场>/<插件>`)、远程那一档开没开、要不要重启 —— 四个问题的答案全在
|
|
712
|
+
runtime 那一片里。在这儿重判一次的下场是「同一个问题两个答案」,
|
|
713
|
+
而它们分叉那天,界面上是一条画成可点、点了却被拒的行。
|
|
714
|
+
|
|
715
|
+
##### ⚠️ 只收 `<市场>/<插件>`,**收不了路径** —— 这条路的安全性质本体
|
|
716
|
+
|
|
717
|
+
请求体上**没有 `source` / `path` / `content` 那三格**,只有一个 `ref`。
|
|
718
|
+
于是「指一条任意路径装一个插件」在这个形状下**说不出口** —— 不是靠 review
|
|
719
|
+
盯住的,是编译期的事。它比 `POST /api/skills/import` **紧一档**:
|
|
720
|
+
|
|
721
|
+
| 判据 | `skills/import` 今天 | 这一条 |
|
|
722
|
+
| ---------------- | ------------------------- | -------------------------- |
|
|
723
|
+
| 能不能传字节 | 不能(只收路径) | 不能 |
|
|
724
|
+
| 能不能指任意路径 | **能**(收一个任意 path) | **不能**(只能从清单里挑) |
|
|
725
|
+
| 清单是谁放的 | — | 宿主主进程 / 用户自己敲的 |
|
|
726
|
+
|
|
727
|
+
「清单」= `~/.epoch/marketplaces.json` 里那些,也就是宿主预置的加上用户自己
|
|
728
|
+
`epoch plugin marketplace add` 加的。要装清单外的东西路仍然在,只是**在命令行上**。
|
|
729
|
+
|
|
730
|
+
##### ⚠️ 闸门:那五条 POST 在回环之外一律 403,**而且这道比身份那道还远一格**
|
|
731
|
+
|
|
732
|
+
装一包插件会落下 `hooks.json` 和 `mcp.json`,那两样**起子进程** —— 下一程启动时
|
|
733
|
+
照着跑。往 `~/.epoch/agents/` 落一份 md 只影响模型看到的字,这一条影响的是
|
|
734
|
+
**这台机器上会跑起什么进程**。
|
|
735
|
+
|
|
736
|
+
- **`install/preview` 也吃这道闸**,虽然它一个字节都不写:`allowRemote: true`
|
|
737
|
+
的部署上,预览一条远程条目会 spawn `git clone --depth 1` 到 staging。
|
|
738
|
+
按「写不写盘」切一刀会漏掉它
|
|
739
|
+
- **闸门在读请求体之前**(同 `POST /api/roles`),所以用例断的是**那一片的调用
|
|
740
|
+
记录是空的**,不只是状态码
|
|
741
|
+
- ⚠️ **`GET /api/plugins` 刻意不挡**,而这和 `GET /api/mcp/config` 连读都挡
|
|
742
|
+
**不矛盾**:那份文件里的 `env` 是明文密钥,这一份里是插件名 / 版本 / 目录路径,
|
|
743
|
+
和 `GET /api/sessions/:id/capabilities` 早就在发的那些同一档。挡掉它的唯一后果
|
|
744
|
+
是那一页在 LAN 那一档下一片空白 —— 偏偏那时用户最想知道的就是
|
|
745
|
+
「我装了什么、生效了没有」
|
|
746
|
+
|
|
747
|
+
##### ⚠️ `ok: true` 的意思是「盘上变了」,**不是「能用了」**
|
|
748
|
+
|
|
749
|
+
扩展物在启动时加载,所以三条落地端点的回执里都带着 `pendingRestart`,而且
|
|
750
|
+
**成败两支都带**(它可能本来就是 `true`:这一程前面装成过一个,那一发失败时把它
|
|
751
|
+
省掉等于让那句提示凭空消失)。
|
|
752
|
+
|
|
753
|
+
「装完了、还没生效」这个中间态要**两格一起**才说得出来:
|
|
754
|
+
|
|
755
|
+
| 那一格 | 答的是 |
|
|
756
|
+
| -------------------- | ---------------------------- |
|
|
757
|
+
| `pendingRestart` | **这一程**动过插件,还没生效 |
|
|
758
|
+
| `installed[].active` | **这一个**此刻真的在生效吗 |
|
|
759
|
+
|
|
760
|
+
⚠️ 落地成功那一支里的 `entry` 是**回头 `list()` 里查出来的那一条**,不是把安装
|
|
761
|
+
结局回显一遍:`active` 只有 runtime 那一层算得出(它手里才有「这一程加载了谁」
|
|
762
|
+
那张表)。尤其**别在服务端硬写一个 `active: false`** —— 更新一个本来就在生效的
|
|
763
|
+
插件时它是 `true`(生效着的是旧版本),那时那句话就是假的。
|
|
764
|
+
|
|
765
|
+
##### 装 / 更新成功那两档 `detail` 是 `null`,而那是刻意的
|
|
766
|
+
|
|
767
|
+
那两档屏幕上要说的话全在 `entry` 和 `pendingRestart` 这两格数据里,而它是一句
|
|
768
|
+
**界面文案** —— 让服务端拼一句中文发上来就绕过了浏览器那份 catalog
|
|
769
|
+
(同 `wire-settings` 那条「服务端的中文一律不上网线」)。卸载那一档发的是 core
|
|
770
|
+
那句回执。
|
|
771
|
+
|
|
772
|
+
##### `runtime.plugins === null` → 六条一律 503 `no-plugin-control`
|
|
773
|
+
|
|
774
|
+
那一档**不是**「底下那层起不来了」,是「宿主没打算给用户这条路」:
|
|
775
|
+
`buildRuntime()` 没收到 `hostMarketplaces` 时它就是 `null`,而那是刻意的
|
|
776
|
+
(它是一个真的新攻击面,所以是选进来的,不是白送的)。回 503 而不是一份
|
|
777
|
+
「装了 0 个插件」的空列表 —— 判据同 `no-goal-store`:后者会让用户去找一个不存在
|
|
778
|
+
的安装按钮。
|
|
779
|
+
|
|
780
|
+
⚠️ **`epoch web` 这条路今天就落在这一档上**(CLI 不传 `hostMarketplaces`)。
|
|
781
|
+
那一格该不该由 `epoch web` 选进来是一次独立的判断(它把一个新攻击面变成默认开),
|
|
782
|
+
不在方案 59 PR-5 的范围里 ——
|
|
783
|
+
记在[验收记录](../../docs/verify/VERIFY_RECORD-59-plugin-page.md)第六章。
|
|
784
|
+
|
|
598
785
|
### `POST /api/sessions` 的 `role`(决定 20 ②,2026-08-15)
|
|
599
786
|
|
|
600
787
|
```jsonc
|
|
@@ -647,6 +834,30 @@ agent」),前者只能命中这个进程已经加载好的那张角色表。
|
|
|
647
834
|
> 那一层收窄发生在**工具表**上、不经过 `PermissionManager`,所以它不进权限审计流水
|
|
648
835
|
> —— 那本账记的是「有人申请了、我们判了」,而被摘掉的工具一次申请都没发生过。
|
|
649
836
|
|
|
837
|
+
### `@:` 引用另一段会话(方案 53 PR-4)
|
|
838
|
+
|
|
839
|
+
两条路,一读一写,**共用引擎里同一处可读性判定**(方案 48 那一处,在 core 里;
|
|
840
|
+
`@epoch-agent/server` 不许 import core,所以它拿的是
|
|
841
|
+
`EpochRuntime.sessionReferences` 那个收窄面 —— 只交出候选 / 解析 / 当前面三件事,
|
|
842
|
+
**交不出任意 SQL**,让服务端自己去读 `sessions.db` 等于给 `@:` 开一条绕过判定的
|
|
843
|
+
读取通道):
|
|
844
|
+
|
|
845
|
+
- **候选**:`GET /api/sessions/:id/reference-candidates[?q=][&limit=]`。
|
|
846
|
+
⚠️ `?q=` 只碰 sessionId / cwd / 标题,**一个字的转录文本都不搜**(§5.2:
|
|
847
|
+
面板是边打边显示的,一旦搜正文,打 `@:密码` 就会把提到过密码的会话列在屏幕上)。
|
|
848
|
+
会话库起不来时 503 `no-session-store`(不是空清单 —— 「问不出来」和「一段都没有」
|
|
849
|
+
在界面上是两句话);
|
|
850
|
+
- **写**:`POST .../messages` 那条路上把 `@:xxx` 解析成 `EpochSessionPart` 塞在
|
|
851
|
+
正文前面(**塞的是那段会话的当前面,不是原始全量**),202 的 `references` 字段
|
|
852
|
+
带回逐段收据 —— 摘掉正文的部件(正文是给模型的,不是给屏幕的),`omitted` 上
|
|
853
|
+
是那五个稳定错误码(`SESSION_REFERENCE_ERROR_CODES`)。
|
|
854
|
+
⚠️ 解析排在斜杠命令展开**之后**:展开那一步认「这行是不是 `/xxx`」靠第一个
|
|
855
|
+
部件是文本,顺序反过来会让 `/review @:ses_…` 悄悄失效。
|
|
856
|
+
|
|
857
|
+
**没有「解析引用」的独立端点,而且不该有。** 解析发生在发送那一刻(可读性判定、
|
|
858
|
+
读当前面、算预算都在那儿),结果在 202 上回来 —— 另开一条解析端点的话,
|
|
859
|
+
「面板看到的」和「真发出去的」之间会多一个能对不上的地方。
|
|
860
|
+
|
|
650
861
|
### 逐条专家(方案 57)
|
|
651
862
|
|
|
652
863
|
`POST /api/sessions/:id/messages` 上多一个可选字段 `role` ——「**这一条**用哪个专家」。
|
|
@@ -870,6 +1081,52 @@ prompt 的附加段里塞东西。
|
|
|
870
1081
|
「谁在几点几分把档位放开了」要另起一本**动作账**,记在 `src/permission.ts`
|
|
871
1082
|
文件末尾那条给合并的人的话里。
|
|
872
1083
|
|
|
1084
|
+
## 审批缓存:收回一次点错的「拒绝」(2026-08-27)
|
|
1085
|
+
|
|
1086
|
+
`POST /api/sessions/:id/approval-cache`([src/approval-cache.ts](src/approval-cache.ts))。
|
|
1087
|
+
载荷只有一个 id:
|
|
1088
|
+
|
|
1089
|
+
```jsonc
|
|
1090
|
+
{ "id": "0f3c…" } // 取 GET .../security 的 permission.cached 里那一行自己带的 id
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
**读那一半不在这条路上**,它是 `GET .../security` 的 `permission.cached` ——
|
|
1094
|
+
那一屏的纪律是「一次取齐」,而这张表要和它上面那张规则表在同一眼里读。
|
|
1095
|
+
|
|
1096
|
+
补它的理由是**这一格今天根本收不回来**:`allow-always` 落
|
|
1097
|
+
`~/.epoch/approvals.json`(用户至少删得掉那个文件),而 `deny` 只在内存里、
|
|
1098
|
+
`ttlMs: 0` 永不过期 —— 除了重启进程,一次点错的「拒绝」收不回来,而屏幕上
|
|
1099
|
+
只会一直印「操作被拦截 [权限]: 已缓存拒绝」。
|
|
1100
|
+
|
|
1101
|
+
⚠️ **路径逐字是 `approval-cache`,不是 `approvals`。** 上面那条
|
|
1102
|
+
`GET /api/sessions/:id/approvals` 是**挂起的审批请求**(重连后补拉),配套
|
|
1103
|
+
`POST /api/approvals/:requestId`。那一族答「现在有人在问你」,这一条答
|
|
1104
|
+
「你以前答过什么」,两边的 `id` 也不是同一种东西。
|
|
1105
|
+
|
|
1106
|
+
三道闸:
|
|
1107
|
+
|
|
1108
|
+
```
|
|
1109
|
+
1. Hub 认不认识这个会话 → 404 unknown-session
|
|
1110
|
+
2. 这个进程有没有权限层 → 503 no-permission-layer
|
|
1111
|
+
3. 那条 id 在不在表里 → 200 + revoked:false(**不是 404**)
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
⚠️ 第 3 道刻意不是错误码,判据同 `POST .../permission` 那条「本来就是那一档不算
|
|
1115
|
+
错误」:挡的是一张开了很久的标签页,而用户要的结果(那条决定不再生效)已经成立。
|
|
1116
|
+
|
|
1117
|
+
⚠️ **回执带的是撤完之后的整张表**(`{revoked, cached}`),界面直接采信、不自己
|
|
1118
|
+
`filter` 一遍 —— 那一发顺带 `prune()` 掉的过期 `allow-session` 只在回读里看得见。
|
|
1119
|
+
|
|
1120
|
+
⚠️ **撤销撤的只是缓存那一层。** 规则的 `deny`(三张列表 / `policies/*.toml`)
|
|
1121
|
+
排在缓存**前面**,这一发一个字都不动它们。界面上这两者必须分得开 ——
|
|
1122
|
+
用户点了「撤销」发现还是被拦,比压根没有那个按钮更坏(web 的
|
|
1123
|
+
`settings/security-perm.tsx` 里有一条用例专门守这句话在屏幕上)。
|
|
1124
|
+
|
|
1125
|
+
⚠️ **撤销不进权限审计流水**,判据逐字同上面换档那一条(那本账记裁决不记动作,
|
|
1126
|
+
而 `PermissionAuditEntry` 是操作形状,一次撤销手上只有一个 id)。它留下的痕迹
|
|
1127
|
+
在别处、而且看得见:撤完之后同一个操作会**重新弹窗**,用户答完那一下照旧记进
|
|
1128
|
+
流水(`user-approved` / `user-denied`)。
|
|
1129
|
+
|
|
873
1130
|
## 检查点回滚:这张界面上破坏力最大的一条路
|
|
874
1131
|
|
|
875
1132
|
三条端点(方案 27 的 web 那一半,[src/checkpoints.ts](src/checkpoints.ts)):
|
|
@@ -1207,11 +1464,18 @@ wire 变更**(同一个 monorepo 里 `packages/web` 是唯一消费方,同
|
|
|
1207
1464
|
两道 400 都是**不静默降级**(同 `workspace` 字段写坏时那一条):悄悄丢掉一个客户端
|
|
1208
1465
|
明确给了的字段,它会拿到一段和它以为的不一样的会话。
|
|
1209
1466
|
|
|
1210
|
-
|
|
1211
|
-
`workDir` 上(同不绑工作区那一档),差别全在 `WorkspaceControl.stateOf()` 报的是
|
|
1467
|
+
这一档**只多一格记账,不改装配**:差别全在 `WorkspaceControl.stateOf()` 报的是
|
|
1212
1468
|
`unbound` 还是 `none`。所以服务端走的是 `runtime.workspaces.defer()`,不给
|
|
1213
1469
|
`sessionFactory.create()` 加参数 —— 塞进工厂等于让一个纯记账的事实穿过整条装配链。
|
|
1214
1470
|
|
|
1471
|
+
⚠️ **上一段原来还有半句「那段会话的 `AgentLoop` 照旧跑在装配时那个 `workDir` 上」,
|
|
1472
|
+
2026-08-20 起作废。** 那句话描述的是一个 bug:用户在底栏 picker 里挑完目录,界面上
|
|
1473
|
+
那一格换了,而引擎照旧在服务进程的启动目录里干活(system prompt 的项目上下文、
|
|
1474
|
+
工具的相对路径、hook 的 cwd、权限层的越界判定全跟着错)。现在循环**每轮现读**
|
|
1475
|
+
那张绑定表(判据全文在 runtime 的 `SessionFactory.assemble` 上),所以这一档在
|
|
1476
|
+
**选之前**跑在启动目录上、选完之后就真的换过去。「不改装配」那半句仍然成立 ——
|
|
1477
|
+
这条端点一行装配代码都没有。
|
|
1478
|
+
|
|
1215
1479
|
### 给一段 `unbound` 的会话选一个:`POST /api/sessions/:id/workspace`(方案 55 PR-1)
|
|
1216
1480
|
|
|
1217
1481
|
```
|
|
@@ -1224,11 +1488,20 @@ body: {root: '<绝对路径>'} | {none: true}
|
|
|
1224
1488
|
|
|
1225
1489
|
**只有 `unbound` 收得动:**
|
|
1226
1490
|
|
|
1227
|
-
| 这个会话此刻
|
|
1228
|
-
|
|
|
1229
|
-
| `unbound`
|
|
1230
|
-
| `
|
|
1231
|
-
| `
|
|
1491
|
+
| 这个会话此刻 | 结果 |
|
|
1492
|
+
| ----------------- | ---------------------------------------------------- |
|
|
1493
|
+
| `unbound` 且 idle | 绑 / 转 `none`,200 回**改完之后**的整份 binding |
|
|
1494
|
+
| `unbound` 但在跑 | 409 `busy` —— 等这一轮结束再挑(2026-08-20) |
|
|
1495
|
+
| `bound` | 409 `workspace-locked` —— 决定 18「绑定后不给改」 |
|
|
1496
|
+
| `none` | 409 `workspace-locked` —— **「不使用」也是一次决定** |
|
|
1497
|
+
|
|
1498
|
+
⚠️ 第二行和另外两行**不是同一句话**,别合并:`workspace-locked` 说的是「已经定了,
|
|
1499
|
+
永远不能改」(下一步是新建会话),`busy` 说的是「现在不行,等一下」(下一步还是
|
|
1500
|
+
同一个按钮)。合成一句的话,用户会去建一个他不需要的会话。它是迟绑那一轮
|
|
1501
|
+
(2026-08-20)补的:绑定这一下现在会真的改引擎脚下的目录,而一次 `run()` 底下可能有
|
|
1502
|
+
二十次工具调用 —— 半路换根等于同一轮里前几次按 A、后几次按 B 解析相对路径,而历史里
|
|
1503
|
+
那条 `[系统]` 留痕是**轮次开始时**插的,盖不住这一档。响应头带 `X-Epoch-Turn-State`,
|
|
1504
|
+
和 `POST /messages` 那条 409 同一对形状。
|
|
1232
1505
|
|
|
1233
1506
|
⚠️ 第三行是这条端点唯一一处会被读错的地方:`none` 看着像「还空着」,但它是用户
|
|
1234
1507
|
**明确按过**的一档(决定 18 侧栏「任务」组的成员)。让它还能被改,等于给一段说过
|
|
@@ -1240,9 +1513,15 @@ body: {root: '<绝对路径>'} | {none: true}
|
|
|
1240
1513
|
(存不存在、是不是目录一律不看):那道门在 `SessionWorkspaces.bind()` 上,而且它还
|
|
1241
1514
|
连着信任判定,两处各写一份判据它们迟早走散。
|
|
1242
1515
|
|
|
1243
|
-
`{none:true}` 那一支走的是 `workspaces.
|
|
1244
|
-
`unbound`
|
|
1245
|
-
|
|
1516
|
+
`{none:true}` 那一支走的是 `workspaces.decideNone()`,而那**不是「解绑」**:一个
|
|
1517
|
+
`unbound` 的会话身上没有绑定可解,它做的是把 `defer()` 那一笔摘掉**并记一次用户决定**
|
|
1518
|
+
(会话库 v7,重启后仍归侧栏「任务」组)。能这么用的**前提是上面那道 409 闸**
|
|
1519
|
+
(对一个 `bound` 的会话调它就是换绑)。
|
|
1520
|
+
|
|
1521
|
+
> 这一句 2026-08-19 之前写的是 `workspaces.release()` —— 在内存里两者等价,
|
|
1522
|
+
> 换掉是因为 `release()` 同时还是「建会话失败回滚」和「会话被删」两条路,
|
|
1523
|
+
> 在它里面落一次用户决定,会让一段建到一半失败的会话在库里留下「用户选了不使用工作区」。
|
|
1524
|
+
> 判据全文在 runtime 的 `WorkspaceControl.decideNone` 上。
|
|
1246
1525
|
|
|
1247
1526
|
成功时回改完之后的整份 binding,前端因此不用再拉一次 —— 少这一下的代价很具体:
|
|
1248
1527
|
那一发和补拉那一发之间的一帧,底栏那一格写的还是「还没选」。
|