@xrn1997/dsh-session-delete 0.4.0 → 0.6.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.
Files changed (70) hide show
  1. package/README.md +39 -10
  2. package/cordis.patch.yml +4 -0
  3. package/icon.svg +18 -18
  4. package/lib/client.js +1897 -859
  5. package/lib/index.js +1803 -1058
  6. package/lib/types/api/attachment-orphans.d.ts +9 -3
  7. package/lib/types/api/attachment.d.ts +17 -7
  8. package/lib/types/api/connection-route.d.ts +23 -0
  9. package/lib/types/api/dispatch.d.ts +25 -14
  10. package/lib/types/api/route-table.d.ts +47 -11
  11. package/lib/types/api/trash-rows.d.ts +3 -3
  12. package/lib/types/client/batch-bar.d.ts +1 -1
  13. package/lib/types/client/batch-pick.d.ts +19 -0
  14. package/lib/types/client/batch-select.d.ts +7 -5
  15. package/lib/types/client/busy-gate.d.ts +6 -2
  16. package/lib/types/client/confirm-close.d.ts +4 -1
  17. package/lib/types/client/context.d.ts +3 -2
  18. package/lib/types/client/copy.d.ts +54 -4
  19. package/lib/types/client/delete-confirm.d.ts +1 -1
  20. package/lib/types/client/delete-menu-item.d.ts +1 -1
  21. package/lib/types/client/host-rows.d.ts +29 -30
  22. package/lib/types/client/main-view.d.ts +2 -1
  23. package/lib/types/client/markdown.d.ts +6 -7
  24. package/lib/types/client/orphan-maintenance.d.ts +10 -25
  25. package/lib/types/client/pending-open.d.ts +17 -0
  26. package/lib/types/client/receipt.d.ts +3 -1
  27. package/lib/types/client/remote.d.ts +21 -18
  28. package/lib/types/client/store.d.ts +15 -10
  29. package/lib/types/client/trash-detail.d.ts +48 -6
  30. package/lib/types/client/trash-entry.d.ts +1 -1
  31. package/lib/types/client/trash-nav.d.ts +12 -8
  32. package/lib/types/client/ui/Toast.d.ts +3 -1
  33. package/lib/types/client/ui/icons.d.ts +1 -1
  34. package/lib/types/client/undo-toast.d.ts +2 -1
  35. package/lib/types/client/use-maintenance-section.d.ts +52 -0
  36. package/lib/types/client/use-maintenance.d.ts +7 -35
  37. package/lib/types/client/use-trash-panel-controller.d.ts +35 -14
  38. package/lib/types/client/use-trash-query.d.ts +10 -8
  39. package/lib/types/client/use-trash-selection.d.ts +15 -12
  40. package/lib/types/client/write-seats.d.ts +32 -16
  41. package/lib/types/client/write-settlement.d.ts +1 -1
  42. package/lib/types/client/write.d.ts +13 -3
  43. package/lib/types/config.d.ts +45 -0
  44. package/lib/types/deps.d.ts +8 -3
  45. package/lib/types/domain/closure.d.ts +47 -0
  46. package/lib/types/index.d.ts +13 -12
  47. package/lib/types/log/zstd-frames.d.ts +2 -3
  48. package/lib/types/ports/attachments-port.d.ts +40 -0
  49. package/lib/types/ports/fs-port.d.ts +15 -12
  50. package/lib/types/ports/host-port.d.ts +4 -5
  51. package/lib/types/ports/presence-port.d.ts +9 -0
  52. package/lib/types/shared/wire.d.ts +137 -31
  53. package/lib/types/trash/attachments.d.ts +10 -1
  54. package/lib/types/trash/detail.d.ts +32 -22
  55. package/lib/types/trash/entry.d.ts +7 -1
  56. package/lib/types/trash/groups.d.ts +6 -1
  57. package/lib/types/trash/manifest.d.ts +4 -4
  58. package/lib/types/verbs/batch-carried.d.ts +1 -3
  59. package/lib/types/verbs/live-admission.d.ts +1 -1
  60. package/lib/types/verbs/member-presence.d.ts +3 -1
  61. package/lib/types/verbs/move-group.d.ts +2 -1
  62. package/lib/types/verbs/orphan-subagents.d.ts +12 -5
  63. package/lib/types/verbs/permanent-removal.d.ts +39 -10
  64. package/lib/types/verbs/trash-scope.d.ts +36 -0
  65. package/locale/en.json +6 -6
  66. package/locale/zh.json +6 -6
  67. package/package.json +13 -3
  68. package/lib/types/api/request-trust.d.ts +0 -45
  69. package/lib/types/ports/connection-port.d.ts +0 -33
  70. package/lib/types/verbs/purge-trash.d.ts +0 -35
package/lib/index.js CHANGED
@@ -12,53 +12,98 @@ import { homedir } from "node:os";
12
12
  * 信封 {ok:true,value} / {ok:false,error:{code,message,details?}} 也是两半的共同契约。
13
13
  */
14
14
  /**
15
- * 插件专属前缀。
15
+ * 插件专属前缀(九段相对它命名)。
16
16
  *
17
- * 为什么必须带插件名:宿主 web server 的 `register({kind:'prefix', path})` 对**重复的 (kind, path)
18
- * 直接抛错**(`dsh-host-webserver` 原文 `webserver: duplicate ${route.kind} route "${route.path}"`),
19
- * 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
17
+ * 为什么必须带插件名:宿主 `connection.fetch.register` 对**重复的精确路径**直接抛错
18
+ * (`connection: exact Fetch route "…" is already registered`),撞了就是整棵插件树 boot 失败——
19
+ * 所以这条路径是与生态的硬边界,不是显示好不好看的问题。
20
20
  */
21
21
  const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
22
- /** 九个段名(相对前缀)。写动作统一走一段 `/apply`:一次 HTTP 带一组 id、逐条回结果;
22
+ /**
23
+ * 唯一的取数基础路径:宿主 `/api` + 插件前缀。九段 exact Fetch 路由、客户端探测与附件 URL
24
+ * 都从它起;`SESSION_DELETE_API_PREFIX` 只作为段名的相对前缀与错误原文。
25
+ */
26
+ const OFFICIAL_SESSION_DELETE_API_PREFIX = `/api${SESSION_DELETE_API_PREFIX}`;
27
+ /** 九个段名(相对前缀)。**每项 `{ path, method }` 两键**:某段用什么方法也是线上形状的一部分
28
+ * (ADR-0003),家就在这一张表——路由记录(`src/api/route-table.ts`)逐行投影(`ROUTES.x.method`),
29
+ * 浏览器半的 GET 段同样投影、写段把 `ROUTES.x.method` 交给 `src/client/remote.ts` 的 `post()`,
30
+ * 两半都不逐段手写一份。
31
+ * 写动作统一走一段 `/apply`:一次 HTTP 带一组 id、逐条回结果;
23
32
  * 单条就是长度为 1 的批量,仓里不留第二条执行路径。
24
- * - `GET /list`:对账并读清单(`session-trash/` 里的目录是准)。
25
- * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链),供客户端探测。
26
- * - `POST /detail`:单条详情(用 POST 表达一个读,判据在 `src/api/route-table.ts`)。
27
- * - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
28
- * - `POST /empty`:清空回收站(聚合回执,不按条回)。
29
- * - `GET /orphans`:无主子会话的扫描报告(纯读)。清理是 `/apply` 的第五个动作,
33
+ * - `list`:对账并读清单(`session-trash/` 里的目录是准)。
34
+ * - `ping`:最便宜的在/不在(不碰清册、不碰盘、不进链),供客户端探测。
35
+ * - `detail`:单条详情(用 POST 表达一个读,判据在 `src/api/route-table.ts` 的 detail 行)。
36
+ * - `apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
37
+ * - `empty`:清空回收站(聚合回执,不按条回)。
38
+ * - `orphans`:无主子会话的扫描报告(纯读)。清理是 `/apply` 的第五个动作,
30
39
  * 这一段只负责"有多少、在哪、多大"。
31
- * - `GET /attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
32
- * - `GET /attachment-orphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
33
- * - `POST /attachment-clean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
40
+ * - `attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
41
+ * - `attachmentOrphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
42
+ * - `attachmentClean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
34
43
  const ROUTES = {
35
- list: "/list",
44
+ list: {
45
+ path: "/list",
46
+ method: "GET"
47
+ },
36
48
  /** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
37
- ping: "/ping",
49
+ ping: {
50
+ path: "/ping",
51
+ method: "GET"
52
+ },
38
53
  /** 单条详情(读回收站目录里那条会话自己的日志)。 */
39
- detail: "/detail",
54
+ detail: {
55
+ path: "/detail",
56
+ method: "POST"
57
+ },
40
58
  /** 一组 id 上的同一个动作;删除确认框那句「含 N 个子会话」也走这里(`action: 'count'`,纯读语义)。 */
41
- apply: "/apply",
59
+ apply: {
60
+ path: "/apply",
61
+ method: "POST"
62
+ },
42
63
  /** 清空回收站。 */
43
- empty: "/empty",
64
+ empty: {
65
+ path: "/empty",
66
+ method: "POST"
67
+ },
44
68
  /** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
45
- orphans: "/orphans",
69
+ orphans: {
70
+ path: "/orphans",
71
+ method: "GET"
72
+ },
46
73
  /** 附件字节(图片/普通文件):全仓唯一的二进制响应段,读的是宿主附件库。 */
47
- attachment: "/attachment",
74
+ attachment: {
75
+ path: "/attachment",
76
+ method: "GET"
77
+ },
48
78
  /** 纯读:扫全部引用源与附件库 `objects`,报无引用图片的数量、字节与预览清单。 */
49
- attachmentOrphans: "/attachment-orphans",
79
+ attachmentOrphans: {
80
+ path: "/attachment-orphans",
81
+ method: "GET"
82
+ },
50
83
  /** 聚合清理:只删 `objects` 下无引用的图片,回 `AttachmentCleanupWire`。 */
51
- attachmentClean: "/attachment-clean"
84
+ attachmentClean: {
85
+ path: "/attachment-clean",
86
+ method: "POST"
87
+ }
52
88
  };
53
89
  /** 请求参数名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。
54
90
  * 附件段是 **query 参数**(GET 二进制响应,没有请求体)。 */
55
91
  const PARAMS = {
56
92
  entryId: "entryId",
93
+ /** `/detail` 的分页游标:只取这个块序号之前的块。缺省 = 从尾部取。 */
94
+ before: "before",
57
95
  action: "action",
58
96
  ids: "ids",
59
97
  attachmentId: "id",
60
98
  attachmentKind: "kind",
61
- attachmentName: "name"
99
+ attachmentName: "name",
100
+ /** 图片引用的媒体类型 / 宽高:只给宿主 `attachments.readImage` 做引用校验,不参与路径拼接。 */
101
+ attachmentMediaType: "mediaType",
102
+ /** 字节数:图片支与文件支都带——它是宿主读面的引用一部分,并先过 `maxAttachmentBytes` 闸
103
+ * (别为一张已知超限的图让宿主整份读出来)。 */
104
+ attachmentBytes: "bytes",
105
+ attachmentWidth: "width",
106
+ attachmentHeight: "height"
62
107
  };
63
108
  /**
64
109
  * 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
@@ -113,6 +158,50 @@ function verbCodeOf(error) {
113
158
  /** 回收站目录名(相对宿主数据根)。Node 半拼路径,浏览器半在页脚念它——同一个字面量。 */
114
159
  const TRASH_DIR_NAME = "session-trash";
115
160
  //#endregion
161
+ //#region src/verbs/errors.ts
162
+ /** 失败的**结构与措辞**。码本身、码的前缀读法与动词词表都住在 `shared/wire.ts`(ADR-0003):
163
+ * 浏览器半也要认它们,它们属于两半共同的契约,不属于 Node 半。这里只做动词抛错与回滚措辞。 */
164
+ function verbError(code, message, cause, details) {
165
+ const e = new Error(message);
166
+ e.code = SESSION_DELETE_CODES[code];
167
+ if (cause !== void 0) e.cause = cause;
168
+ if (details !== void 0) e.details = details;
169
+ return e;
170
+ }
171
+ /** `trash-empty` 的原文:有具体条目就点名那个 id,拿不到时只报通用那句。 */
172
+ function trashEmpty(entryId) {
173
+ return verbError("trash-empty", entryId === void 0 ? "回收站里没有这一条" : `回收站里没有这一条:${entryId}`);
174
+ }
175
+ /** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
176
+ function routeError(code, httpStatus, message) {
177
+ const e = verbError("io", message);
178
+ e.code = SESSION_DELETE_CODES[code];
179
+ e.httpStatus = httpStatus;
180
+ return e;
181
+ }
182
+ /** 一条回滚步骤失败的**标签句**:label 说的是"这一步在退什么",原文带出**那一步抛的东西**
183
+ * (`Error` 取 message,其余一律 `String()`;对象带 `message` 的差异归 `map-error.messageOf` 管)。
184
+ * 调用方不许另抄这份格式——那样 details 里的句子会随各处 catch 变形,界面上就变成"同一个失败三个说法"。 */
185
+ function rollbackReason(label, error) {
186
+ return `${label}:${error instanceof Error ? error.message : String(error)}`;
187
+ }
188
+ /** 内层错误已经挂着的回滚失败(成员自己的就地回滚失败时会有)。组级 catch 再包一次时不能丢。 */
189
+ function carriedRollbackFailures(error) {
190
+ const raw = error?.details?.rollbackFailures;
191
+ return Array.isArray(raw) ? raw.filter((item) => typeof item === "string") : [];
192
+ }
193
+ /** 原错误保留,回滚失败作为 `details.rollbackFailures` 上线;码仍按原错误的动词码,
194
+ * 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
195
+ * 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。 */
196
+ function withRollbackFailures(error, failures) {
197
+ const code = verbCodeOf(error) ?? "io";
198
+ const carried = carriedRollbackFailures(error);
199
+ const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
200
+ const base = error instanceof Error ? error.message : String(error);
201
+ const extra = failures.filter((failure) => !carried.includes(failure));
202
+ return verbError(code, base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`, error, { rollbackFailures: merged });
203
+ }
204
+ //#endregion
116
205
  //#region src/remote/map-error.ts
117
206
  /** 宿主两条类型化错误 → 我们的码(本文件的两行映射)。
118
207
  * 类不可 import(跨插件值 import 是红线;且宿主包与运行中的宿主不同代)⇒ 按官方构造器写死的 `this.name` 认。
@@ -123,14 +212,21 @@ const HOST_ERROR_CODE = {
123
212
  WorkspaceUnknownSessionError: "not-found",
124
213
  WorkspaceActiveSessionError: "live"
125
214
  };
126
- /** cause 链上第一个**宿主类型化错误**本身(不只它的名字)——它自己身上的字段还要当 `details` 用。
215
+ /** cause 链上第一个**宿主类型化错误**本身(不只它的名字)——它自己身上的字段还要当 `details` 用;
216
+ * 码在 hasOwn 证明的同一处取回(表值恒在,编译器只认贴着取的这一份)。
127
217
  * 深度有界(链坏掉不递归下去);查表必须 `hasOwn`:`in` 会命中 `Object.prototype` 的键。 */
128
218
  function hostErrorOf(error) {
129
219
  let current = error;
130
220
  for (let depth = 0; depth < 8; depth++) {
131
221
  if (current === null || typeof current !== "object") return void 0;
132
222
  const name = current.name;
133
- if (typeof name === "string" && Object.hasOwn(HOST_ERROR_CODE, name)) return current;
223
+ if (typeof name === "string" && Object.hasOwn(HOST_ERROR_CODE, name)) {
224
+ const code = HOST_ERROR_CODE[name];
225
+ if (code !== void 0) return {
226
+ host: current,
227
+ code
228
+ };
229
+ }
134
230
  current = current.cause;
135
231
  }
136
232
  }
@@ -213,12 +309,12 @@ function toRemoteFailure(error) {
213
309
  }
214
310
  const fromVerb = verbCodeOf(error);
215
311
  const host = hostErrorOf(error);
216
- const own = detailsOf(error) ?? (host === void 0 ? void 0 : detailsOf(host));
312
+ const own = detailsOf(error) ?? (host === void 0 ? void 0 : detailsOf(host.host));
217
313
  if (host === void 0 && fromVerb === void 0 && typeof raw === "string" && raw.includes("/")) return {
218
314
  code: SESSION_DELETE_CODES.io,
219
315
  message: messageOf(error)
220
316
  };
221
- const code = host !== void 0 ? HOST_ERROR_CODE[host.name] : fromVerb ?? "io";
317
+ const code = host !== void 0 ? host.code : fromVerb ?? "io";
222
318
  return {
223
319
  code: SESSION_DELETE_CODES[code],
224
320
  message: messageOf(error),
@@ -226,629 +322,195 @@ function toRemoteFailure(error) {
226
322
  };
227
323
  }
228
324
  //#endregion
229
- //#region src/verbs/errors.ts
230
- /** 失败的**结构与措辞**。码本身、码的前缀读法与动词词表都住在 `shared/wire.ts`(ADR-0003):
231
- * 浏览器半也要认它们,它们属于两半共同的契约,不属于 Node 半。这里只做动词抛错与回滚措辞。 */
232
- function verbError(code, message, cause, details) {
233
- const e = new Error(message);
234
- e.code = SESSION_DELETE_CODES[code];
235
- if (cause !== void 0) e.cause = cause;
236
- if (details !== void 0) e.details = details;
237
- return e;
238
- }
239
- /** `trash-empty` 的原文:有具体条目就点名那个 id,拿不到时只报通用那句。 */
240
- function trashEmpty(entryId) {
241
- return verbError("trash-empty", entryId === void 0 ? "回收站里没有这一条" : `回收站里没有这一条:${entryId}`);
325
+ //#region src/domain/closure.ts
326
+ /** 会话 id 去掉 `session-` 前缀后的形状:同一个会话在两代宿主里写法不同(v3 代 id 不带前缀、
327
+ * 现行代带),归堆、成组、去重一律按这个形状比——两边都归一,不赌调用方传的是哪一代写法。 */
328
+ function bareSessionId(id) {
329
+ return id.startsWith("session-") ? id.slice(8) : id;
242
330
  }
243
- /** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
244
- function routeError(code, httpStatus, message) {
245
- const e = verbError("io", message);
246
- e.code = SESSION_DELETE_CODES[code];
247
- e.httpStatus = httpStatus;
248
- return e;
331
+ /** 一个 id 的全部可能写法,原写法在前:`session-x` → `[session-x, x]`;`x` → `[x, session-x]`。
332
+ * 逐层问宿主时按这些写法问:宿主那支按 `header.parentSession` 原文比,不替我们归一。 */
333
+ function sessionIdShapes(id) {
334
+ const bare = bareSessionId(id);
335
+ return [.../* @__PURE__ */ new Set([
336
+ id,
337
+ bare,
338
+ `session-${bare}`
339
+ ])];
249
340
  }
250
- /** 一条回滚步骤失败的**标签句**:label 说的是"这一步在退什么",原文带出**那一步抛的东西**
251
- * (`Error` 取 message,其余一律 `String()`;对象带 `message` 的差异归 `map-error.messageOf` 管)。
252
- * 调用方不许另抄这份格式——那样 details 里的句子会随各处 catch 变形,界面上就变成"同一个失败三个说法"。 */
253
- function rollbackReason(label, error) {
254
- return `${label}:${error instanceof Error ? error.message : String(error)}`;
341
+ /** parent(bare) 到 children 索引上的传递闭包:深度不设上限、防环、首见去重(都按 bare 归一后比)。
342
+ * 根的两种写法都认(内部归一次);结果不含根自己。数据里真出现环时代价是「少数一条」,
343
+ * 不是把清单读挂死——环本来不该出现(parentSession 是一条有向边),出现就是手工改过日志或宿主 bug。 */
344
+ function closureOf(children, root) {
345
+ const out = [];
346
+ const seen = /* @__PURE__ */ new Set([bareSessionId(root)]);
347
+ let frontier = [...seen];
348
+ while (frontier.length > 0) {
349
+ const next = [];
350
+ for (const bare of frontier) for (const kid of children.get(bare) ?? []) {
351
+ const kidBare = bareSessionId(kid.sessionId);
352
+ if (seen.has(kidBare)) continue;
353
+ seen.add(kidBare);
354
+ out.push(kid);
355
+ next.push(kidBare);
356
+ }
357
+ frontier = next;
358
+ }
359
+ return out;
255
360
  }
256
- /** 内层错误已经挂着的回滚失败(成员自己的就地回滚失败时会有)。组级 catch 再包一次时不能丢。 */
257
- function carriedRollbackFailures(error) {
258
- const raw = error?.details?.rollbackFailures;
259
- return Array.isArray(raw) ? raw.filter((item) => typeof item === "string") : [];
361
+ /** 同一条记录两代写法只认第一次出现(保原序)。全局去重只有这一处实现:谁要按 bare 去重,
362
+ * 交一条列表进来,别在调用点再手写一个 seen 集合。 */
363
+ function firstByBare(members) {
364
+ const seen = /* @__PURE__ */ new Set();
365
+ const out = [];
366
+ for (const member of members) {
367
+ const bare = bareSessionId(member.sessionId);
368
+ if (seen.has(bare)) continue;
369
+ seen.add(bare);
370
+ out.push(member);
371
+ }
372
+ return out;
260
373
  }
261
- /** 原错误保留,回滚失败作为 `details.rollbackFailures` 上线;码仍按原错误的动词码,
262
- * 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
263
- * 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。 */
264
- function withRollbackFailures(error, failures) {
265
- const code = verbCodeOf(error) ?? "io";
266
- const carried = carriedRollbackFailures(error);
267
- const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
268
- const base = error instanceof Error ? error.message : String(error);
269
- const extra = failures.filter((failure) => !carried.includes(failure));
270
- return verbError(code, base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`, error, { rollbackFailures: merged });
374
+ /**
375
+ * 整表语料 → 根闭包组:`parentSessionId` 不在 `known` 里的那些 subagent 各自成一组。`known` 两侧都
376
+ * 归一后再比(传原始 id 即可)。「已知父为什么排除在外」是调用方的领域判据(住
377
+ * `src/verbs/orphan-subagents.ts` 与 `CONTEXT.md` 的「无主子会话」),这一层不替它复述。
378
+ *
379
+ * 组内的后代仍走 `closureOf` 那一份闭包,只是多一条跨组的 `claimed`:一条记录只归第一个认领它的根。
380
+ * 已被前一组收走的那条,连同它整支子树都不再出现在这一组里——后代在那一组已被全部认领,所以
381
+ * 「丢掉已认领的」与「不走进已认领的那支」交回的是同一个集合。环上的代价是「少数一条」,
382
+ * 不是把一次扫描挂死。组序与成员序都按语料原序。
383
+ *
384
+ * 后代也必须是 subagent 起源:普通 fork 的 `parentSession` 只是来源记录,它不进组、也不穿过它收孩子
385
+ * (否则从 subagent 分出去的分支会被当成孤儿永久删掉)。
386
+ */
387
+ function orphanRootsOf(corpus, known) {
388
+ const knownBares = /* @__PURE__ */ new Set();
389
+ for (const id of known) knownBares.add(bareSessionId(id));
390
+ const children = /* @__PURE__ */ new Map();
391
+ for (const session of corpus) {
392
+ const parent = session.parentSessionId;
393
+ if (parent === void 0) continue;
394
+ if (!session.subagent) continue;
395
+ const key = bareSessionId(parent);
396
+ const list = children.get(key);
397
+ if (list === void 0) children.set(key, [session]);
398
+ else list.push(session);
399
+ }
400
+ const claimed = /* @__PURE__ */ new Set();
401
+ const groups = [];
402
+ for (const session of corpus) {
403
+ const parent = session.parentSessionId;
404
+ if (!session.subagent || parent === void 0) continue;
405
+ if (knownBares.has(bareSessionId(parent))) continue;
406
+ const rootBare = bareSessionId(session.sessionId);
407
+ if (claimed.has(rootBare)) continue;
408
+ claimed.add(rootBare);
409
+ const members = [session];
410
+ for (const descendant of closureOf(children, rootBare)) {
411
+ const bare = bareSessionId(descendant.sessionId);
412
+ if (claimed.has(bare)) continue;
413
+ claimed.add(bare);
414
+ members.push(descendant);
415
+ }
416
+ groups.push({
417
+ root: session,
418
+ parentSessionId: parent,
419
+ members
420
+ });
421
+ }
422
+ return groups;
271
423
  }
272
424
  //#endregion
273
- //#region src/api/request-trust.ts
274
- /** 桌面壳那条路(asar `/lib/main.js` 的 `forwardWebRequest`):渲染进程从 `dsh-app://app/` 加载,
275
- * 转发前把 `host` / `origin` / `cookie` / `sec-fetch-site` 删掉、换上一枚宿主自己的 cookie,
276
- * `Referer` 保留。判据按 `protocol` + `host` 比,**不能比 `URL.origin`**(`dsh-app:` 不是 WHATWG
277
- * special scheme,`new URL('dsh-app://app/').origin` 是字符串 `"null"`)。 */
278
- const DESKTOP_APP_SCHEME = "dsh-app:";
279
- const DESKTOP_APP_HOST = "app";
280
- /** 回环 socket 的三种写法(IPv4 / IPv6 / IPv4-mapped IPv6)。 */
281
- const LOOPBACK_SOCKETS = [
282
- "127.0.0.1",
283
- "::1",
284
- "::ffff:127.0.0.1"
285
- ];
286
- function isDesktopShellUrl(value) {
287
- try {
288
- const url = new URL(value);
289
- return url.protocol === DESKTOP_APP_SCHEME && url.host === DESKTOP_APP_HOST;
290
- } catch {
291
- return false;
292
- }
425
+ //#region src/trash/groups.ts
426
+ /** 这些孩子的父。 */
427
+ function kidsOf(rows, parentId) {
428
+ return rows.filter((row) => row.parentEntryId === parentId);
293
429
  }
294
- /** 裸 authority(`host` / `host:port`)按给定 scheme 解析;解析不了回 `undefined`。 */
295
- function parseAuthority(authority, scheme = "http:") {
296
- try {
297
- return new URL(`${scheme}//${authority}`);
298
- } catch {
299
- return;
430
+ /**
431
+ * 清单露面的一行 = 组根,按清册原序。孩子折进根那一行(宿主的客户端本来就把 `origin:"subagent"`
432
+ * 的行整排藏了 ⇒ 我们若单列就会冒出一堆点不到的行)。
433
+ *
434
+ * 折不进去的那两种孩子(父行不在 / 父行自己也是孩子)**自己站出来占一行**——那正是
435
+ * `visibleGroups` 与 `groupOf` 共用同一条判据的地方。
436
+ */
437
+ function visibleGroups(rows) {
438
+ const byId = new Map(rows.map((row) => [row.id, row]));
439
+ const kidsByRoot = /* @__PURE__ */ new Map();
440
+ const folded = /* @__PURE__ */ new Set();
441
+ for (const row of rows) {
442
+ if (row.parentEntryId === void 0) continue;
443
+ const parent = byId.get(row.parentEntryId);
444
+ if (parent === void 0 || parent.parentEntryId !== void 0) continue;
445
+ folded.add(row.id);
446
+ const siblings = kidsByRoot.get(parent.id);
447
+ if (siblings === void 0) kidsByRoot.set(parent.id, [row]);
448
+ else siblings.push(row);
300
449
  }
450
+ return rows.filter((row) => !folded.has(row.id)).map((root) => ({
451
+ root,
452
+ kids: kidsByRoot.get(root.id) ?? []
453
+ }));
301
454
  }
302
- /** 与宿主 `isLoopbackHostname` 同口径:`localhost`、`[::1]`、任意 127/8。 */
303
- function isLoopbackHostname(hostname) {
304
- if (hostname === "localhost" || hostname === "[::1]") return true;
305
- const parts = hostname.split(".");
306
- return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
307
- }
308
- /** Host 是不是本部署的 bind 字面量:忽略端口与 `%zone`(zone 是接口选择器,不是地址文本)。 */
309
- function isBindAddress(hostUrl, bindHost) {
310
- if (bindHost === void 0) return false;
311
- const zoneAt = bindHost.indexOf("%");
312
- const address = zoneAt === -1 ? bindHost : bindHost.slice(0, zoneAt);
313
- const bindUrl = parseAuthority(address.includes(":") ? `[${address}]` : address);
314
- return bindUrl !== void 0 && bindUrl.hostname === hostUrl.hostname;
315
- }
316
- /** 携带 authority 的头(Origin / Referer)是否与 Host 同 authority:按这个头自己的 scheme 归一
317
- * 默认端口(`http://x:80` 与 Host `x` 是同一个 authority)。 */
318
- function isSameAuthority(value, hostHeader) {
319
- let url;
320
- try {
321
- url = new URL(value);
322
- } catch {
323
- return false;
324
- }
325
- const scheme = url.protocol === "https:" ? "https:" : url.protocol === "http:" ? "http:" : void 0;
326
- if (scheme === void 0) return false;
327
- const hostUrl = parseAuthority(hostHeader, scheme);
328
- return hostUrl !== void 0 && url.host === hostUrl.host;
329
- }
330
- /** 头栅栏:`sec-fetch-site` / Origin / Referer 三条,任何客户端都要过。 */
331
- function passesHeaderFence(headers, host) {
332
- if (headers["sec-fetch-site"] === "cross-site") return false;
333
- const origin = headers.origin;
334
- if (origin !== void 0 && !isDesktopShellUrl(origin) && !isSameAuthority(origin, host)) return false;
335
- const referer = headers.referer;
336
- if (referer !== void 0 && !isDesktopShellUrl(referer) && !isSameAuthority(referer, host)) return false;
337
- return true;
455
+ /**
456
+ * 一条 id 所属的整组。三种形状,三个答案:
457
+ * - **组根** ⇒ 它自己 + 它接住的孩子(UI 点的是父那一行,删除/恢复/彻底删除都按整组走);
458
+ * - **孩子**(自己记着 `parentEntryId`)⇒ **只回它自己**。清单不单独列它,只有手工/降级路径会点到这里;
459
+ * - **清册里没有这一条 id,但还有孩子指向它**(父行已被别的路径摘掉)⇒ 那一批孩子;仍然没有 ⇒ 空数组,
460
+ * 调用方据此报 `trash-empty` / 判"已被同批前一条带走"。
461
+ */
462
+ function groupOf(rows, id) {
463
+ const root = rows.find((row) => row.id === id);
464
+ if (root !== void 0) return root.parentEntryId === void 0 ? [root, ...kidsOf(rows, id)] : [root];
465
+ return kidsOf(rows, id);
338
466
  }
339
467
  /**
340
- * 这条请求归不归本插件办。`untrusted` = 归属不对(跨源 / 重绑 / 头不成形);`unauthenticated` =
341
- * 归属对但远程客户端没带宿主身份(原文会提示用 `dsh web` 打印的 URL 打开)。
468
+ * 把一条 id 所属的**整组**从快照里摘掉,回一份新快照(组语义仍只读 `groupOf`,这一条判据不另写一遍)。
469
+ * 「同批前一条已把这一组带走」的收缩就是它:调用方各自持有那份可变视图,本函数不记账。
470
+ */
471
+ function withoutGroup(rows, id) {
472
+ const gone = new Set(groupOf(rows, id).map((row) => row.id));
473
+ return rows.filter((row) => !gone.has(row.id));
474
+ }
475
+ /**
476
+ * 「这条是被**同批前一条**带走的,还是整批开始前就没有,还是仍在?」
342
477
  *
343
- * 远程支的两个失败裁决由端口翻译宿主的区分(401 = 缺身份、403 = 归属不对)。身份层缺席(端口报
344
- * `absent`)一律按归属不对处理:那时没有可换的身份,不能让原文提示用户去补一个不存在的东西。
478
+ * 幂等语义只属于 `carried`:后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它)必须照样响亮报
479
+ * `trash-empty`,不能被"已被带走"这句话掩盖成成功。**所以判据是快照的原始形状,不是当前形状**:
480
+ * 原始快照里认得出(它自己、或指向它的孩子任一在),而当前快照里已经空了 ⇒ 同批带走的。
345
481
  */
346
- function judgeRequest(req, facts) {
347
- const host = req.headers.host;
348
- if (host === void 0 || !passesHeaderFence(req.headers, host)) return "untrusted";
349
- const hostUrl = parseAuthority(host);
350
- if (hostUrl === void 0) return "untrusted";
351
- if ((isLoopbackHostname(hostUrl.hostname) || isBindAddress(hostUrl, facts.bindHost)) && LOOPBACK_SOCKETS.includes(req.socket.remoteAddress ?? "")) return "allowed";
352
- const verdict = facts.connection.verdictFor(req);
353
- if (verdict.kind === "allowed") return "allowed";
354
- return verdict.kind === "unauthenticated" ? "unauthenticated" : "untrusted";
482
+ function claimOf(original, current, id) {
483
+ if (groupOf(original, id).length === 0) return { verdict: "absent" };
484
+ const group = groupOf(current, id);
485
+ return group.length === 0 ? { verdict: "carried" } : {
486
+ verdict: "fresh",
487
+ group
488
+ };
355
489
  }
356
490
  //#endregion
357
- //#region src/ports/agent-handles.ts
358
- const CAPTURED_AGENT_HANDLES = Symbol.for("dsh-session-delete.captured-agent-handles");
359
- /** 在 `ctx.agents` 上包一层 `resume` / `create`,把返回的 agent handle 的 dispose 按会话 id 记下。
360
- * 服务缺席时走 `ctx.inject(['agents'])` **等服务挂上再装**——live profile 的插件激活顺序可能早于
361
- * `agents` 服务就绪;只在 apply 时 `get` 一次会让捕获静默缺席(真机症状:重启前在回收站的能冷恢复,
362
- * 刚删除的会话恢复后旧 agent 拦住 prompt)。形状不符 / 属性不可写都只是**不捕获**,不影响插件激活。
491
+ //#region src/verbs/batch-carried.ts
492
+ /**
493
+ * **同批已带走集**(「已被同批前一条带走」的 id)的唯一实现(ADR-0002 的内部实现)。
363
494
  *
364
- * **安装时机**:先 `get('agents')`,没装成再 `inject(['agents'])`(两者只装一次,符号表兜住)。
365
- * **symbol 表**:句柄表挂在服务实例的 {@link CAPTURED_AGENT_HANDLES} 上;同一个 agents 服务上的第二次
366
- * 安装(`get` 与 `inject` 各一次,或第二个 `createHostPort`)看见它就直接共用,不把 wrapper 套第二层。
367
- * **读回判据**:cordis 的 traceable 服务代理每次读函数都会新包一层(`get` 里的 `createShadowMethod`),
368
- * 所以只能看 own property descriptor 的身份;拿 `service[name]` 读回比身份是必然的假警报。
495
+ * 两种 id 域各一个 adapter:
496
+ * - entry 适配:restore 的条目 id,`claim` 是三态(fresh / carried / absent),组语义仍读
497
+ * `trash/groups.ts`;
498
+ * - session 适配:delete 的会话 id,`claim` 只有 fresh / carried,集合由动词结果长出。
499
+ *
500
+ * entry 适配的三态判据读 `groups.claimOf`;session 适配没有原始清册视图,只记 `take` 过的会话 id。
501
+ * 收缩中的当前只读视图不再外递:purge 那一半连同视图住「清册作用域」(`src/verbs/trash-scope.ts`)。
502
+ * 两个 adapter 只各自**持有**那份视图;按组收缩的判据不抄第二遍,都读 `groups.withoutGroup`。
369
503
  */
370
- function captureAgentHandles(ctx) {
371
- let handles = /* @__PURE__ */ new Map();
372
- let installed = false;
373
- const install = (candidate) => {
374
- if (installed) return;
375
- const service = candidate;
376
- if (service === void 0 || service === null) return;
377
- const existing = service[CAPTURED_AGENT_HANDLES];
378
- if (existing !== void 0) {
379
- handles = existing;
380
- installed = true;
381
- return;
504
+ /** 恢复:原始快照认得、当前视图已经空了 ⇒ 同批前一条带走的。 */
505
+ function createEntryCarried(original) {
506
+ let current = [...original];
507
+ return {
508
+ claim(id) {
509
+ return claimOf(original, current, id);
510
+ },
511
+ take(id) {
512
+ current = withoutGroup(current, id);
382
513
  }
383
- let wrappedCount = 0;
384
- for (const name of ["resume", "create"]) try {
385
- const original = service[name];
386
- if (typeof original !== "function") continue;
387
- const wrapped = function(...args) {
388
- const result = Reflect.apply(original, this, args);
389
- result.then((value) => {
390
- const handle = value;
391
- const id = handle?.agent?.id;
392
- if (typeof id !== "string" || id.length === 0 || typeof handle?.dispose !== "function") return;
393
- handles.set(id, async () => {
394
- await handle.dispose();
395
- });
396
- }, () => void 0);
397
- return result;
398
- };
399
- service[name] = wrapped;
400
- if (Object.getOwnPropertyDescriptor(service, name)?.value !== wrapped) throw new Error(`agents.${name} is not writable`);
401
- wrappedCount += 1;
402
- } catch {}
403
- if (wrappedCount === 0) return;
404
- try {
405
- service[CAPTURED_AGENT_HANDLES] = handles;
406
- } catch {}
407
- installed = true;
408
- };
409
- const service = ctx.get("agents");
410
- if (service !== void 0 && service !== null) install(service);
411
- if (!installed && typeof ctx.inject === "function") try {
412
- ctx.inject(["agents"], (child) => {
413
- install(child.get("agents"));
414
- });
415
- } catch {}
416
- return { async dispose(sessionId) {
417
- const dispose = handles.get(sessionId);
418
- if (dispose === void 0) return;
419
- try {
420
- await dispose();
421
- } finally {
422
- handles.delete(sessionId);
423
- }
424
- } };
425
- }
426
- //#endregion
427
- //#region src/ports/host-port.ts
428
- /** live store 的最小方法面判据(同 `projectionCacheOf` 的手法:按方法面判,不按 constructor.name)。 */
429
- function liveSessionsOf(ctx) {
430
- const store = ctx.get("sessions");
431
- if (store === void 0 || store === null) return void 0;
432
- if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
433
- return store;
434
- }
435
- /** 官方的「这些父的孩子」那一支:`kind:'parent'` 的判据逐字是 `values.includes(record.header.parentSession ?? null)`。 */
436
- async function filterChildrenOf(sessionQuery, values) {
437
- return sessionQuery.filterSessions([{
438
- kind: "parent",
439
- values: [...values].sort()
440
- }]);
441
- }
442
- /**
443
- * 从若干根逐层向下问出后代,交回 `父(bare)→ 孩子` 的索引。
444
- * 用 `kind:'parent'` 而不是 traceSession:父进回收站后 traceSession 认不出目标(宿主失败码
445
- * `SESSION_QUERY_SESSION_NOT_FOUND`),而这里要的正是父不在宿主列表之后仍留在盘上的那批。
446
- * 出处:asar `dsh-session-query/lib/index.js` 的 `filterSessions`。一层一次宿主调用,问空即停;
447
- * `seen` 防环;没有 `header.id` 的记录整条跳过(无法作为身份继续下问)。
448
- */
449
- async function descendantsByParent(sessionQuery, roots) {
450
- const children = /* @__PURE__ */ new Map();
451
- const seen = new Set(roots.map(bareSessionId));
452
- let frontier = [...seen];
453
- while (frontier.length > 0) {
454
- const values = [];
455
- for (const bare of frontier) for (const shape of sessionIdShapes(bare)) if (!values.includes(shape)) values.push(shape);
456
- const next = [];
457
- for (const record of await filterChildrenOf(sessionQuery, values)) {
458
- const id = record.header?.id;
459
- const parent = record.header?.parentSession;
460
- if (typeof id !== "string" || id.length === 0) continue;
461
- if (typeof parent !== "string" || parent.length === 0) continue;
462
- const bare = bareSessionId(id);
463
- if (seen.has(bare)) continue;
464
- seen.add(bare);
465
- const cwd = record.header?.cwd;
466
- const descendant = {
467
- id,
468
- bare,
469
- projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd)
470
- };
471
- const parentBare = bareSessionId(parent);
472
- const siblings = children.get(parentBare);
473
- if (siblings === void 0) children.set(parentBare, [descendant]);
474
- else siblings.push(descendant);
475
- next.push(bare);
476
- }
477
- frontier = next;
478
- }
479
- return children;
480
- }
481
- /** 某个根在 `descendantsByParent` 那棵树里的全部后代(深度不设上限,同一条只收一次)。 */
482
- function subtreeOf(children, rootBare) {
483
- const out = [];
484
- const seen = /* @__PURE__ */ new Set([rootBare]);
485
- let frontier = [rootBare];
486
- while (frontier.length > 0) {
487
- const next = [];
488
- for (const bare of frontier) for (const kid of children.get(bare) ?? []) {
489
- if (seen.has(kid.bare)) continue;
490
- seen.add(kid.bare);
491
- out.push(kid);
492
- next.push(kid.bare);
493
- }
494
- frontier = next;
495
- }
496
- return out;
497
- }
498
- function toMember(d) {
499
- return {
500
- sessionId: d.id,
501
- projectDir: d.projectDir
502
- };
503
- }
504
- /** {@link HostCorpusPort.subagentMembersOf} 的那一次 BFS。
505
- * 空清单在调用宿主之前就返回(不为一张空表扫全库)。 */
506
- async function membersFor(sessionQuery, parentIds) {
507
- const wanted = [];
508
- for (const id of parentIds) if (id.length > 0 && !wanted.includes(id)) wanted.push(id);
509
- if (wanted.length === 0) return {};
510
- const children = await descendantsByParent(sessionQuery, wanted);
511
- const out = {};
512
- for (const id of wanted) out[id] = subtreeOf(children, bareSessionId(id));
513
- return out;
514
- }
515
- /** 无 cwd 的会话落在 `_no-cwd/`;`projectDirOf` 与回收站对账共用这个字面量。
516
- * 出处:asar `dsh-session-persistence-jsonl/lib/index.js` 的 `projectDir`(`if (cwd === void 0) return join(root, "_no-cwd");`)。 */
517
- const NO_CWD_PROJECT_DIR = "_no-cwd";
518
- /** 宿主 `projectKey(cwd)` 的逐字镜像:跨插件不能 import,宿主也没把它暴露成服务。
519
- * 出处:asar `dsh-session-persistence-jsonl/lib/index.js` 的 `projectKey`;调用链
520
- * `locate` → `logPath` → `projectDir` → `projectKey`。本机实测 `D:/develop/GitHub/dsh-session-delete`
521
- * → `--D-develop-GitHub-dsh-session-delete--`。`projectDirOf` 与回收站对账重建共用这一份;
522
- * 宿主换代先复核算法。 */
523
- function projectKey(cwd) {
524
- let readable = "";
525
- let separatorRun = false;
526
- for (let i = 0; i < cwd.length; i++) {
527
- const code = cwd.charCodeAt(i);
528
- const ch = String.fromCharCode(code);
529
- if (ch === "/" || ch === "\\" || ch === ":") {
530
- if (!separatorRun) readable += "-";
531
- separatorRun = true;
532
- } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
533
- readable += ch;
534
- separatorRun = false;
535
- } else {
536
- readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
537
- separatorRun = false;
538
- }
539
- }
540
- return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
541
- }
542
- /** 投影缓存服务的最小方法面判据(按方法面判,不按 `constructor.name` 判)。 */
543
- function projectionCacheOf(ctx) {
544
- const cache = ctx.get("sessionProjectionCache");
545
- if (cache === void 0 || cache === null) return void 0;
546
- if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
547
- return cache;
548
- }
549
- /** 宿主 `readSessionHeader` 对未知会话抛的是裸 `Error`,只认这句;真存储故障照抛,不能折成未命中。
550
- * 出处:asar `dsh-workspace/lib/types/index.js` 的
551
- * `cannot validate session '${id}': session persistence holds no such session`。
552
- * 同文件有类型的近似句是 `WorkspaceUnknownSessionError`(`hold` 无 `s`),不会误伤。 */
553
- const NO_SUCH_SESSION = "session persistence holds no such session";
554
- /** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
555
- function isUnknownSession(error) {
556
- return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
557
- }
558
- /** 会话 id 去掉 `session-` 前缀后的形状(同一个父在两代里写法不同,见 {@link HostCorpusPort.subagentMembersOf})。 */
559
- function bareSessionId(id) {
560
- return id.startsWith("session-") ? id.slice(8) : id;
561
- }
562
- /** 一个 id 的全部可能写法,原写法在前:`session-x` → `[session-x, x]`;`x` → `[x, session-x]`。 */
563
- function sessionIdShapes(id) {
564
- const bare = bareSessionId(id);
565
- return [.../* @__PURE__ */ new Set([
566
- id,
567
- bare,
568
- `session-${bare}`
569
- ])];
570
- }
571
- /** 宿主会话表的一次取数(`ctx.sessionController.list()` 的两种返回形状都折成数组)。
572
- * 服务没挂 / 形状不对 / 抛错 ⇒ 空表——**这条不替宿主兜错误**:调用方拿空表的语义都是"少发一条通告",
573
- * 不是"恢复失败"。 */
574
- async function controllerItems(host) {
575
- try {
576
- const controller = host.get("sessionController");
577
- if (controller === void 0 || controller === null || typeof controller.list !== "function") return [];
578
- const listed = await controller.list();
579
- const items = Array.isArray(listed) ? listed : listed?.items;
580
- return Array.isArray(items) ? items : [];
581
- } catch {
582
- return [];
583
- }
584
- }
585
- /** 账本里没有那个工作区时 {@link HostPort.attach} / {@link HostPort.detach} 抛的原话开头。
586
- * **原话与判据同住这里**:抛它的是这个端口,认它的也是端口给的那句,调用方不许另抄一份字符串。 */
587
- const WORKSPACE_MISSING_MESSAGE = "workspace not found";
588
- /** 这次失败是不是"账本里没那个工作区"(而不是另有故障)。恢复侧靠它把两种失败分开:
589
- * 前者按「未分组」收,后者照旧回滚报 `io`——不给真故障静默改归属的机会。 */
590
- function isWorkspaceMissingError(error) {
591
- return error instanceof Error && error.message.startsWith(WORKSPACE_MISSING_MESSAGE);
592
- }
593
- /** 宿主服务的真实绑定;出错原样抛,由 `toRemoteFailure` 按 name 映射。
594
- * 各方法调用的服务与方法见上方成员注释;错误映射表住 `src/remote/map-error.ts`。 */
595
- function createHostPort(ctx) {
596
- const host = ctx;
597
- const registry = host.workspaceRegistry;
598
- const agentHandles = captureAgentHandles(host);
599
- return {
600
- /** 账本里记着这个会话的工作区:`registry.list()` 的实体 `sessionIds`(该 getter 已按 canonical cwd 过滤过)。 */
601
- async workspaceOf(sessionId) {
602
- return registry.list().find((workspace) => workspace.sessionIds.includes(sessionId))?.id;
603
- },
604
- /** 账本里还有这个工作区吗:`registry.get(id)` 是 `this.entities.get(id)` 的直通(同步 Map 查找),
605
- * 未知 id 回 `undefined` ⇒ 这里只做一次判别,不抛。 */
606
- async workspaceExists(workspaceId) {
607
- return registry.get(workspaceId) !== void 0;
608
- },
609
- /**
610
- * cwd → 宿主项目目录名;没有 cwd 落到 `_no-cwd`。
611
- * 账本里没有这个会话回 `undefined`(未命中,不是故障);真存储故障照抛。
612
- * 未命中由 `delete-session.ts` 折成 `not-found`,真故障留 `io`——两者不能混。
613
- */
614
- async projectDirOf(sessionId) {
615
- let header;
616
- try {
617
- header = await registry.readSessionHeader(sessionId);
618
- } catch (error) {
619
- if (isUnknownSession(error)) return void 0;
620
- throw error;
621
- }
622
- return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
623
- },
624
- /** 标题走宿主投影缓存;尽力取,取不到返回空串,绝不因此让删除失败。 */
625
- async titleOf(sessionId) {
626
- try {
627
- const header = await registry.readSessionHeader(sessionId);
628
- const cache = projectionCacheOf(host);
629
- const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
630
- return typeof title === "string" ? title : "";
631
- } catch {
632
- return "";
633
- }
634
- },
635
- /** 活跃判据走宿主 `workspace/session-activity` waterfall:空数组 = 无活动;
636
- * 与宿主 archiveSession 的准入同源。 */
637
- async isLive(sessionId) {
638
- return (await host.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]))).length > 0;
639
- },
640
- /** registry 全局归档集(数组,`state.archivedSessionIds`)。 */
641
- async isArchived(sessionId) {
642
- return registry.archivedSessionIds.includes(sessionId);
643
- },
644
- /**
645
- * 从 `ctx.sessions` 摘掉会话,并关闭捕获到的 agent handle。
646
- *
647
- * 会话列表 = 磁盘持久化 ∪ 内存活体,搬走文件只清掉一侧;宿主 `SessionController` 会丢掉
648
- * `AgentHandle`,所以 `agent-handles.ts` 按会话 id 留住 handle,这里先 detach 再 dispose。
649
- * 捕获不到 handle(插件激活前已启动,或 `ctx.agents` 形状变化)时只能摘 session,这是已知限制,
650
- * 不拿归档兜底(ADR-0008)。
651
- *
652
- * 三态:读不到 store / 形状不符 / detach 或 dispose 抛 ⇒ `unknown`(不通告);
653
- * 内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。任何一步都不抛。
654
- */
655
- async evictLive(sessionId) {
656
- try {
657
- const store = liveSessionsOf(host);
658
- const session = store?.get(sessionId);
659
- if (store === void 0) return "unknown";
660
- if (session === void 0 || session === null) {
661
- await agentHandles.dispose(sessionId);
662
- return "not-live";
663
- }
664
- const entry = store.liveEntryFor(session);
665
- if (entry === void 0 || entry === null || typeof entry.detach !== "function") return "unknown";
666
- entry.detach();
667
- await agentHandles.dispose(sessionId);
668
- return "evicted";
669
- } catch {
670
- return "unknown";
671
- }
672
- },
673
- /**
674
- * 通告客户端这个会话没了:发宿主自己转发的 `api-session/removed`,客户端当场删行。
675
- * 冷会话没有 `session/disposed`,不发这条就只能等重连。
676
- * 只在宿主列表确实已不含它时调用(调用方按 {@link EvictionOutcome} 判);不发假通告。
677
- */
678
- async announceRemoved(sessionId) {
679
- try {
680
- host.emit("api-session/removed", sessionId);
681
- } catch {}
682
- },
683
- /**
684
- * 通告这组会话回来了:拉一次宿主会话表,按 id 逐条发 `api-session/added`。
685
- * 摘要直接用宿主 `sessionController.list()` 的形状;取不到就不发。
686
- * 批量只预取一次,避免按条全表扫。
687
- */
688
- async announceRestored(sessionIds) {
689
- if (sessionIds.length === 0) return;
690
- const summaries = [];
691
- for (const item of await controllerItems(host)) {
692
- const sessionId = item?.sessionId;
693
- if (typeof sessionId === "string") summaries.push({
694
- sessionId,
695
- summary: item
696
- });
697
- }
698
- for (const id of sessionIds) {
699
- const summary = summaries.find((item) => item.sessionId === id)?.summary;
700
- if (summary === void 0) continue;
701
- try {
702
- host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
703
- } catch {}
704
- }
705
- },
706
- /** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
707
- async unarchive(sessionId) {
708
- await registry.unarchiveSession(sessionId);
709
- },
710
- /** 摘账本:实体方法 `detachSession`(顶层没有);未知工作区**响亮失败**,不静默当成功。 */
711
- async detach(sessionId, workspaceId) {
712
- const workspace = registry.get(workspaceId);
713
- if (workspace === void 0) throw new Error(`${WORKSPACE_MISSING_MESSAGE} (detach): ${workspaceId}`);
714
- await workspace.detachSession(sessionId);
715
- },
716
- /** 挂账本:实体方法 `attachSession`(顶层没有)。 */
717
- async attach(sessionId, workspaceId) {
718
- const workspace = registry.get(workspaceId);
719
- if (workspace === void 0) throw new Error(`${WORKSPACE_MISSING_MESSAGE} (attach): ${workspaceId}`);
720
- await workspace.attachSession(sessionId);
721
- },
722
- /** 确保归档:官方 `archiveSession(sessionId)`。归档是 registry **全局**的,与工作区账目无关
723
- * (JSDoc 原文 "its workspace accounting — or lack of one — is irrelevant"),所以 `workspaceId` 不用
724
- * ——未分组的会话没有工作区可传,签名里它是可选的。
725
- * 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,src/shared/wire.ts)。 */
726
- async archive(sessionId) {
727
- await registry.archiveSession(sessionId);
728
- },
729
- async listSessions() {
730
- const sessionQuery = host.get("sessionQuery");
731
- if (typeof sessionQuery?.listSessions !== "function") return { available: false };
732
- const records = await sessionQuery.listSessions();
733
- const out = [];
734
- for (const record of records) {
735
- const id = record.header?.id;
736
- if (typeof id !== "string" || id === "") continue;
737
- const parent = record.header?.parentSession;
738
- const cwd = record.header?.cwd;
739
- out.push({
740
- sessionId: id,
741
- ...typeof parent === "string" && parent !== "" ? { parentSessionId: parent } : {},
742
- projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd),
743
- subagent: record.header?.origin === "subagent"
744
- });
745
- }
746
- return {
747
- available: true,
748
- value: out
749
- };
750
- },
751
- /**
752
- * 官方 `sessionQuery.filterSessions([{kind:'parent', values}])`,不自扫 `~/.dsh/sessions`。
753
- * 数传递闭包(孩子与孙代),不是直接孩子;`session-` 前缀两边都去掉再比,
754
- * 按调用方给的写法回键。`sessionQuery` 缺席或方法缺失 ⇒ `{available:false}`,
755
- * 不拿空表冒充“没有后代”;服务在场时空入参零宿主调用。
756
- */
757
- async subagentMembersOf(parentIds) {
758
- const sessionQuery = host.get("sessionQuery");
759
- if (typeof sessionQuery?.filterSessions !== "function") return { available: false };
760
- const members = await membersFor(sessionQuery, parentIds);
761
- return {
762
- available: true,
763
- value: Object.fromEntries(Object.entries(members).map(([id, list]) => [id, list.map(toMember)]))
764
- };
765
- }
766
- };
767
- }
768
- //#endregion
769
- //#region src/trash/groups.ts
770
- /** 这些孩子的父。 */
771
- function kidsOf(rows, parentId) {
772
- return rows.filter((row) => row.parentEntryId === parentId);
773
- }
774
- /**
775
- * 清单露面的一行 = 组根,按清册原序。孩子折进根那一行(宿主的客户端本来就把 `origin:"subagent"`
776
- * 的行整排藏了 ⇒ 我们若单列就会冒出一堆点不到的行)。
777
- *
778
- * 折不进去的那两种孩子(父行不在 / 父行自己也是孩子)**自己站出来占一行**——那正是
779
- * `visibleGroups` 与 `groupOf` 共用同一条判据的地方。
780
- */
781
- function visibleGroups(rows) {
782
- const byId = new Map(rows.map((row) => [row.id, row]));
783
- const kidsByRoot = /* @__PURE__ */ new Map();
784
- const folded = /* @__PURE__ */ new Set();
785
- for (const row of rows) {
786
- if (row.parentEntryId === void 0) continue;
787
- const parent = byId.get(row.parentEntryId);
788
- if (parent === void 0 || parent.parentEntryId !== void 0) continue;
789
- folded.add(row.id);
790
- const siblings = kidsByRoot.get(parent.id);
791
- if (siblings === void 0) kidsByRoot.set(parent.id, [row]);
792
- else siblings.push(row);
793
- }
794
- return rows.filter((row) => !folded.has(row.id)).map((root) => ({
795
- root,
796
- kids: kidsByRoot.get(root.id) ?? []
797
- }));
798
- }
799
- /**
800
- * 一条 id 所属的整组。三种形状,三个答案:
801
- * - **组根** ⇒ 它自己 + 它接住的孩子(UI 点的是父那一行,删除/恢复/彻底删除都按整组走);
802
- * - **孩子**(自己记着 `parentEntryId`)⇒ **只回它自己**。清单不单独列它,只有手工/降级路径会点到这里;
803
- * - **清册里没有这一条 id,但还有孩子指向它**(父行已被别的路径摘掉)⇒ 那一批孩子;仍然没有 ⇒ 空数组,
804
- * 调用方据此报 `trash-empty` / 判"已被同批前一条带走"。
805
- */
806
- function groupOf(rows, id) {
807
- const root = rows.find((row) => row.id === id);
808
- if (root !== void 0) return root.parentEntryId === void 0 ? [root, ...kidsOf(rows, id)] : [root];
809
- return kidsOf(rows, id);
810
- }
811
- /**
812
- * 「这条是被**同批前一条**带走的,还是整批开始前就没有,还是仍在?」
813
- *
814
- * 幂等语义只属于 `carried`:后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它)必须照样响亮报
815
- * `trash-empty`,不能被"已被带走"这句话掩盖成成功。**所以判据是快照的原始形状,不是当前形状**:
816
- * 原始快照里认得出(它自己、或指向它的孩子任一在),而当前快照里已经空了 ⇒ 同批带走的。
817
- */
818
- function claimOf(original, current, id) {
819
- if (groupOf(original, id).length === 0) return { verdict: "absent" };
820
- const group = groupOf(current, id);
821
- return group.length === 0 ? { verdict: "carried" } : {
822
- verdict: "fresh",
823
- group
824
- };
825
- }
826
- //#endregion
827
- //#region src/verbs/batch-carried.ts
828
- /**
829
- * **同批已带走集**(「已被同批前一条带走」的 id)的唯一实现(ADR-0002 的内部实现)。
830
- *
831
- * 两种 id 域各一个 adapter:
832
- * - entry 适配:restore / purge 的条目 id,`claim` 是三态(fresh / carried / absent),组语义仍读
833
- * `trash/groups.ts`;
834
- * - session 适配:delete 的会话 id,`claim` 只有 fresh / carried,集合由动词结果长出。
835
- *
836
- * entry 适配的三态判据读 `groups.claimOf`;session 适配没有原始清册视图,只记 `take` 过的会话 id。
837
- * 当前只读视图(`rows`)只挂在 entry 域上:只有 purge 要它——盘上后代要按清册里还在的 id 排除。
838
- */
839
- /** 恢复 / 彻底删除:原始快照认得、当前视图已经空了 ⇒ 同批前一条带走的。 */
840
- function createEntryCarried(original) {
841
- let current = [...original];
842
- return {
843
- claim(id) {
844
- return claimOf(original, current, id);
845
- },
846
- take(id) {
847
- const gone = new Set(groupOf(current, id).map((row) => row.id));
848
- if (gone.size === 0) return;
849
- current = current.filter((row) => !gone.has(row.id));
850
- },
851
- rows: () => current
852
514
  };
853
515
  }
854
516
  /** 删除:会话 id 域;`take` 只收这次真的搬走的成员(父 + 盘上的孩子)。 */
@@ -884,6 +546,22 @@ const REQUIRED_ENTRY_FIELDS = {
884
546
  title: "string",
885
547
  sizeBytes: "number"
886
548
  };
549
+ /** 清册行 → 线上清册行形状。**显式字面量、不用 `...row`**:线上多一格必填时这里编译不过。
550
+ * 只搬清册里真有的事实、不带任何只读数;`/list` 的 `toWire` 与详情的子会话摘要共用这一份
551
+ * (否则同一条清册行会有两份会漂移的投影)。 */
552
+ function entryWire(row) {
553
+ return {
554
+ id: row.id,
555
+ sessionId: row.sessionId,
556
+ projectDir: row.projectDir,
557
+ workspaceId: row.workspaceId,
558
+ wasArchived: row.wasArchived,
559
+ deletedAt: row.deletedAt,
560
+ title: row.title,
561
+ sizeBytes: row.sizeBytes,
562
+ ...row.parentEntryId === void 0 ? {} : { parentEntryId: row.parentEntryId }
563
+ };
564
+ }
887
565
  const pad = (n, w = 2) => String(n).padStart(w, "0");
888
566
  function makeEntryId(deletedAt, sessionDirName) {
889
567
  const d = new Date(deletedAt);
@@ -896,6 +574,7 @@ function parseEntryId(id) {
896
574
  const m = ENTRY_ID_RE.exec(id);
897
575
  if (!m) throw new Error(`bad entry id: ${id}`);
898
576
  const [, y, mo, d, h, mi, s, sessionDirName] = m;
577
+ if (y === void 0 || mo === void 0 || d === void 0 || h === void 0 || mi === void 0 || s === void 0 || sessionDirName === void 0) throw new Error(`bad entry id: ${id}`);
899
578
  return {
900
579
  deletedAt: Date.UTC(+y, +mo - 1, +d, +h, +mi, +s),
901
580
  sessionDirName
@@ -1021,7 +700,9 @@ async function assertNoLive(host, sessionIds) {
1021
700
  }
1022
701
  //#endregion
1023
702
  //#region src/verbs/member-presence.ts
1024
- /** 本仓的在场能力实现:端口形状住 `src/ports/presence-port.ts`,这里只做三态与真值表。
703
+ /** 本仓的在场能力实现,也是**全仓唯一的盖章处**:端口形状住 `src/ports/presence-port.ts`,这里只做
704
+ * 三态与真值表。品牌字段是类型层的(unique symbol 不导出、没有运行时值),所以只有这一处显式断言
705
+ * 能把三件方法盖成 `PresenceCapability`——手拼同样的三件满足不了那个面(`tests/deps.test.ts` 钉住)。
1025
706
  * `announceRestored` 直接交宿主那一手:能力面与宿主面同名同签名,再包一层只会多一个转调。 */
1026
707
  function createMemberPresence(host) {
1027
708
  return {
@@ -1289,7 +970,11 @@ function isMagicAt(raw, at) {
1289
970
  return MAGIC.every((byte, i) => raw[at + i] === byte);
1290
971
  }
1291
972
  function readUint24LE(raw, at) {
1292
- return raw[at] | raw[at + 1] << 8 | raw[at + 2] << 16;
973
+ const b0 = raw[at];
974
+ const b1 = raw[at + 1];
975
+ const b2 = raw[at + 2];
976
+ if (b0 === void 0 || b1 === void 0 || b2 === void 0) return void 0;
977
+ return b0 | b1 << 8 | b2 << 16;
1293
978
  }
1294
979
  /** 块体之后有没有那 4 字节尾巴:只有"跳过它之后仍是帧头或到 EOF"才认。 */
1295
980
  function withFrameTail(raw, at) {
@@ -1319,7 +1004,7 @@ function frameRanges(raw) {
1319
1004
  for (;;) {
1320
1005
  const blockHeader = readUint24LE(raw, cursor);
1321
1006
  cursor += BLOCK_HEADER_BYTES;
1322
- if (cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
1007
+ if (blockHeader === void 0 || cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
1323
1008
  const blockType = blockHeader >> BLOCK_TYPE_SHIFT & 3;
1324
1009
  const blockSize = blockHeader >> BLOCK_SIZE_SHIFT & BLOCK_SIZE_MASK;
1325
1010
  if (blockType === BLOCK_RESERVED) throw new Error(`zstd 保留块类型:帧起于 ${at},偏移 ${cursor - BLOCK_HEADER_BYTES} 的块头 type=3(规范 Reserved,对面解不开)`);
@@ -1420,7 +1105,7 @@ function walkSessionLog(raw, onRecord) {
1420
1105
  try {
1421
1106
  plain = zlib.zstdDecompressSync(raw.subarray(start, end));
1422
1107
  } catch (error) {
1423
- throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`);
1108
+ throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`, { cause: error });
1424
1109
  }
1425
1110
  const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
1426
1111
  const cut = merged.lastIndexOf(NEWLINE);
@@ -1464,8 +1149,14 @@ function readSessionHeader(raw) {
1464
1149
  * ② 只要剩余引用源读不出来,就整体跳过(fail closed)——宁可留孤儿,不许删共享。
1465
1150
  * 引用源"读不出来"包括:目录/文件读失败、目录非空却没有规范 zstd 日志(明文/陌生布局)、
1466
1151
  * JSON 超出扫描上限、日志有坏行或尾巴被截断。
1152
+ * 宿主的内核写锁 `session.lock` 例外:它不是会话内容,恒为 0 字节时既不算读不懂、也不算
1153
+ * "目录里有东西";非空才回落到上面的 fail closed(判据见 `imageIdsOfDir`)。
1467
1154
  */
1468
1155
  const ATTACHMENT_ID = /^sha256:[0-9a-f]{64}$/u;
1156
+ /** 合法附件 ID:形状是 `sha256:` + 64 位小写十六进制。
1157
+ * 谓词必须收成**模板字面量形状**而不是 `string`:`attachmentPathOf` 里 `!isAttachmentId(id)` 的
1158
+ * 假支若按「是 string」取反会被 TS 收成 `never`(参数本来就是 string),插值就成了 never 读数。
1159
+ * 这里判的仍是同一条正则,谓词只是把「形状」写进类型面。 */
1469
1160
  function isAttachmentId(value) {
1470
1161
  return typeof value === "string" && ATTACHMENT_ID.test(value);
1471
1162
  }
@@ -1543,8 +1234,15 @@ function scanImageIdsInLog(raw) {
1543
1234
  }
1544
1235
  /** 宿主写日志时的临时名:`session.vN.jsonl.zstd.<hash>.tmp`(内容写完后才 rename 成规范名)。 */
1545
1236
  const TEMP_LOG_NAME = /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?\.[0-9a-f]+\.tmp$/u;
1237
+ /**
1238
+ * 宿主的内核写锁:宿主以写方式打开它即截断、**从不写入内容**、释放也从不删除(POSIX 上是
1239
+ * `flock(2)` 的目标,Windows 上没有这个文件)——所以每个已实体化的会话目录、以及跟着搬进
1240
+ * 回收站的条目里,它都与日志常态并存。锁只表达"谁在写",不承载会话记录。
1241
+ */
1242
+ const LOCK_NAME = "session.lock";
1546
1243
  /** 规范日志之外允许并存的两类文件:更老一代的规范日志(新代包含旧代)与 0 字节临时日志(没有消息)。
1547
- * 其余任何文件都可能是规范日志没覆盖到的引用(例如写入中的非空临时日志),一律按不可读处理。 */
1244
+ * 其余任何文件都可能是规范日志没覆盖到的引用(例如写入中的非空临时日志),一律按不可读处理。
1245
+ * 宿主的 `session.lock` 到不了这里——它在 `imageIdsOfDir` 挑名单时就按"不是会话内容"剥掉了。 */
1548
1246
  async function unreadableExtraFile(deps, dir, names) {
1549
1247
  for (const name of names) {
1550
1248
  if (isCanonicalSessionLogName(name)) continue;
@@ -1559,12 +1257,27 @@ async function unreadableExtraFile(deps, dir, names) {
1559
1257
  * 同样按不可读处理(宁可留下对象等维护卡兜底,不冒"没引用")。
1560
1258
  * **规范日志旁的文件也要过关**:老一代规范日志可以并存(新代包含旧代);0 字节临时日志是
1561
1259
  * 写盘未完成的空产物,也没有引用。非空临时日志、明文日志或其他任何文件仍按不可读处理(fail closed)。
1260
+ * **宿主的内核写锁不是会话内容**:0 字节的锁在判目录形状之前先剥掉——不然每个已实体化的会话目录
1261
+ * (含搬进回收站的条目)都会被读成"读不懂",整份引用扫描在真机上恒跳过;没有日志的锁目录因此与
1262
+ * 空目录同判。非空的锁说明我们对这个文件的理解失效(宿主不会往里写字节),仍按不可读处理。
1562
1263
  */
1563
1264
  async function imageIdsOfDir(deps, dir) {
1564
1265
  if (!await deps.fs.exists(dir)) return { ids: /* @__PURE__ */ new Set() };
1565
1266
  let names;
1566
1267
  try {
1567
1268
  names = await deps.fs.listFiles(dir);
1269
+ const withoutLock = [];
1270
+ for (const name of names) {
1271
+ if (name !== LOCK_NAME) {
1272
+ withoutLock.push(name);
1273
+ continue;
1274
+ }
1275
+ if ((await deps.fs.statFile(`${dir}/${name}`)).size > 0) return {
1276
+ ids: /* @__PURE__ */ new Set(),
1277
+ unreadable: `${dir}: 锁文件 ${name} 非空,无法确认里面没有引用`
1278
+ };
1279
+ }
1280
+ names = withoutLock;
1568
1281
  } catch (error) {
1569
1282
  return {
1570
1283
  ids: /* @__PURE__ */ new Set(),
@@ -1634,8 +1347,6 @@ async function imageIdsOfDir(deps, dir) {
1634
1347
  };
1635
1348
  }
1636
1349
  }
1637
- /** 单个 JSON 引用源的扫描上限;超过就记成不可读(fail closed),**不许当作"没有引用"跳过**。 */
1638
- const MAX_JSON_REF_BYTES = 10485760;
1639
1350
  /** 读全部剩余引用源;任何一处读不出都记进 `unreadable`。 */
1640
1351
  async function collectRemainingImageRefs(deps, input) {
1641
1352
  const exclude = new Set(input.excludeDirs);
@@ -1658,6 +1369,7 @@ async function collectRemainingImageRefs(deps, input) {
1658
1369
  if (await deps.fs.exists(trashRoot)) for (const name of await deps.fs.listDirs(trashRoot)) await visit(`${trashRoot}/${name}`);
1659
1370
  const scanJson = async (root) => {
1660
1371
  if (!await deps.fs.exists(root)) return;
1372
+ const maxJsonRefBytes = deps.config.maxJsonRefBytes;
1661
1373
  const stack = [root];
1662
1374
  const seen = /* @__PURE__ */ new Set();
1663
1375
  while (stack.length > 0) {
@@ -1671,8 +1383,8 @@ async function collectRemainingImageRefs(deps, input) {
1671
1383
  if (!name.endsWith(".json")) continue;
1672
1384
  const file = `${dir}/${name}`;
1673
1385
  const stat = await deps.fs.statFile(file);
1674
- if (stat.size > MAX_JSON_REF_BYTES) {
1675
- unreadable.push(`${file}: JSON 引用源 ${stat.size} 字节超过扫描上限 ${MAX_JSON_REF_BYTES} 字节,未扫描`);
1386
+ if (stat.size > maxJsonRefBytes) {
1387
+ unreadable.push(`${file}: JSON 引用源 ${stat.size} 字节超过扫描上限 ${maxJsonRefBytes} 字节,未扫描`);
1676
1388
  continue;
1677
1389
  }
1678
1390
  const text = (await deps.fs.readFile(file)).toString("utf8");
@@ -1878,7 +1590,11 @@ async function cleanOrphanImageAttachments(deps) {
1878
1590
  //#region src/verbs/permanent-removal.ts
1879
1591
  /**
1880
1592
  * 永久删除的测量、计划与执行:先记录目录/字节/账本归属,再按计划动盘。
1881
- * 计划函数只读;活体准入在计划阶段、任何写之前完成。
1593
+ * 计划函数只读;活体准入在计划阶段、任何写之前完成,执行器动第一笔写前再查一次。
1594
+ *
1595
+ * 测量与补账本归属**分家**:`/list` 的只读计划只吃 {@link measureMembers}(零账本读,不再为每个
1596
+ * 盘上后代问一次 `workspaceOf`,`/list` 的失败面不再含账本归属读);`workspaceOf` 只有
1597
+ * purge / orphan-clean 的删除计划({@link planMemberRemovals})才付——executor 摘账本要用它。
1882
1598
  */
1883
1599
  /** `exists → sizeOfDir` 的唯一一条度量口径(成员目录与回收站目录共用):先问在不在,再量字节;
1884
1600
  * ENOENT 是唯一一种当"不在"的失败,别的错误照旧响亮。别再在调用点各写一遍这段 try/catch。
@@ -1916,18 +1632,32 @@ async function measureMemberDir(deps, member) {
1916
1632
  bytes: probe.bytes
1917
1633
  } : void 0;
1918
1634
  }
1919
- /** 把一组成员折成"删之前要记下的计划";目录不在的成员跳过(记录与盘不一致是那条成员自己的事)。
1920
- * `purge` 的盘上后代、`orphan-clean` 的整组与 `/list` 的盘上残留都走这一处,别再各写一遍
1921
- * measure + workspaceOf。 */
1922
- async function planMemberRemovals(deps, members) {
1923
- const plans = [];
1635
+ /** 只测量(目录 + 实测字节):`/list` 的只读计划只吃这一支,零账本读。目录不在的成员跳过
1636
+ * (记录与盘不一致是那条成员自己的事)。为什么不顺手把归属也问了:`/list` 投影只用字节与条数,
1637
+ * 那一格立刻被丢弃——每开一次回收站为它付 N 次宿主调用,还把账本归属读拉进 `/list` 的失败面。 */
1638
+ async function measureMembers(deps, members) {
1639
+ const out = [];
1924
1640
  for (const member of members) {
1925
1641
  const measured = await measureMemberDir(deps, member);
1926
1642
  if (measured === void 0) continue;
1927
- const workspaceId = await deps.host.workspaceOf(member.sessionId);
1928
- plans.push({
1643
+ out.push({
1929
1644
  sessionId: member.sessionId,
1930
- ...measured,
1645
+ projectDir: member.projectDir,
1646
+ ...measured
1647
+ });
1648
+ }
1649
+ return out;
1650
+ }
1651
+ /** 把一组成员折成"删之前要记下的计划":{@link measureMembers} 的测量之外**逐成员补账本归属**
1652
+ * (`workspaceOf`)——executor 摘账本要用。`/list` 不走这里(它只吃 measureMembers,零账本读)。 */
1653
+ async function planMemberRemovals(deps, members) {
1654
+ const plans = [];
1655
+ for (const measured of await measureMembers(deps, members)) {
1656
+ const workspaceId = await deps.host.workspaceOf(measured.sessionId);
1657
+ plans.push({
1658
+ sessionId: measured.sessionId,
1659
+ dir: measured.dir,
1660
+ bytes: measured.bytes,
1931
1661
  ...workspaceId === void 0 ? {} : { workspaceId }
1932
1662
  });
1933
1663
  }
@@ -1947,7 +1677,7 @@ async function measureTrashRow(deps, entry) {
1947
1677
  /**
1948
1678
  * 盘上后代的选择判据(`/list` 与 purge 的删除计划共用这一份):`subagentMembersOf` 一次问全部父,
1949
1679
  * 再按 `bareSessionId` **排除全部清册 id**(跟着父进来的孩子不按盘上残留再算一遍),同一个 bare id 只认
1950
- * **遍历顺序里第一次**出现的那条(同一个会话在两代宿主里的写法不同)。
1680
+ * **遍历顺序里第一次**出现的那条(同一个会话在两代宿主里的写法不同;去重口径住 `src/domain/closure.ts` 的 `firstByBare`)。
1951
1681
  *
1952
1682
  * 语料看不见(`available: false`)⇒ 每个根都按空成员收:`/list` 与 purge 只许静默降级,
1953
1683
  * 盘上残留留给 `GET /orphans` 兜底。
@@ -1962,32 +1692,41 @@ async function measureTrashRow(deps, entry) {
1962
1692
  async function selectStrandedMembers(host, manifest, parentIds) {
1963
1693
  const manifestIds = new Set(manifest.map((row) => bareSessionId(row.sessionId)));
1964
1694
  const read = await host.subagentMembersOf(parentIds);
1965
- const seen = /* @__PURE__ */ new Set();
1966
- return parentIds.map((parentId) => {
1967
- const wanted = [];
1968
- if (!read.available) return wanted;
1695
+ if (!read.available) return parentIds.map(() => []);
1696
+ const flat = [];
1697
+ parentIds.forEach((parentId, groupIndex) => {
1969
1698
  for (const member of read.value[parentId] ?? []) {
1970
- const bare = bareSessionId(member.sessionId);
1971
- if (manifestIds.has(bare) || seen.has(bare)) continue;
1972
- seen.add(bare);
1973
- wanted.push(member);
1699
+ if (manifestIds.has(bareSessionId(member.sessionId))) continue;
1700
+ flat.push({
1701
+ sessionId: member.sessionId,
1702
+ projectDir: member.projectDir,
1703
+ member,
1704
+ groupIndex
1705
+ });
1974
1706
  }
1975
- return wanted;
1976
1707
  });
1708
+ const out = parentIds.map(() => []);
1709
+ for (const entry of firstByBare(flat)) {
1710
+ const bucket = out[entry.groupIndex];
1711
+ if (bucket === void 0) throw verbError("io", `子会话分组越界:${entry.member.sessionId}`);
1712
+ bucket.push(entry.member);
1713
+ }
1714
+ return out;
1977
1715
  }
1978
1716
  /**
1979
1717
  * `/list` 的只读计划:一行 = 一组。释放量与 purge 用同一条口径——回收站目录与成员目录**现测**,
1980
1718
  * 不用清册里记的 `sizeBytes`(那是删除那一刻的事实,盘上早就变了)。
1981
1719
  *
1982
1720
  * 盘上后代经 `selectStrandedMembers` 选出(排除全部清册 id、bare id 全局只算一次);目录不在的成员
1983
- * 不进 `members`(与 `planMemberRemovals` 同口径)。
1721
+ * 不进 `members`(与 purge 计划同口径)。成员只吃 {@link measureMembers}:**零账本读**——不再为每个
1722
+ * 盘上后代问一次 `workspaceOf`,`/list` 的失败面不再含账本归属读(归属只有 purge 摘账本才付)。
1984
1723
  */
1985
1724
  async function planVisibleTrash(deps, rows, groups) {
1986
1725
  const candidates = await selectStrandedMembers(deps.host, rows, groups.map((group) => group.root.sessionId));
1987
1726
  return await Promise.all(groups.map(async ({ root, kids }, index) => {
1988
1727
  const rootRow = await measureTrashRow(deps, root);
1989
1728
  const kidRows = await Promise.all(kids.map((kid) => measureTrashRow(deps, kid)));
1990
- const members = await planMemberRemovals(deps, candidates[index] ?? []);
1729
+ const members = await measureMembers(deps, candidates[index] ?? []);
1991
1730
  const subagentBytes = kidRows.reduce((total, row) => total + row.bytes, 0);
1992
1731
  const strandedBytes = members.reduce((total, member) => total + member.bytes, 0);
1993
1732
  return {
@@ -2007,7 +1746,8 @@ function totalBytes(items) {
2007
1746
  }
2008
1747
  /**
2009
1748
  * purge 的删除计划:`targets` 由调用方按组语义(`trash/groups.ts`)选好,这里只量、只筛、只查活体。
2010
- * **计划函数就是执行前的闸**——调用方拿到计划才允许写,所以准入必须发生在任何写之前。
1749
+ * **计划函数是执行前的第一道闸**——调用方拿到计划才允许写;计划与第一笔写之间的窗口由
1750
+ * `executeRemoval` 在首笔写前复查(见该函数头注)。
2011
1751
  *
2012
1752
  * - `trash` 按 targets 原序逐个实测(目录缺席的行 `present:false` / 0 字节,但清册行仍要摘)。
2013
1753
  * - 盘上后代经 `selectStrandedMembers` 选出(一次问全部 targets、排除全部清册 id、bare id 全局只计划一次)。
@@ -2016,7 +1756,7 @@ function totalBytes(items) {
2016
1756
  * - `attachments` 由被删目录(targets + 这些成员)里的图片引用与全部剩余引用源算独占;共享的保留、
2017
1757
  * 剩余引用源读不出就整体跳过(判据住 `trash/attachments.ts`,这里只接线)。
2018
1758
  * - 活体准入在量目录与任何写之前逐个查 targets 与**全部枚举到的**盘上后代;任一在跑就整条拒绝、
2019
- * 零副作用。没有跳过它的开关——计划就是执行前的闸(ADR-0006)。
1759
+ * 零副作用。没有跳过它的开关;执行器首笔写前还会用计划里的 `unarchive` 名单复查(ADR-0027)。
2020
1760
  * - `sessionQuery` 缺席时端口回 `{ available: false }` ⇒ members 为空、不抛(盘上残留留给 `GET /orphans` 兜底)。
2021
1761
  */
2022
1762
  async function planTrashRemoval(deps, rows, targets) {
@@ -2059,8 +1799,15 @@ async function planOrphanRemoval(deps, members) {
2059
1799
  };
2060
1800
  }
2061
1801
  /**
2062
- * 按计划执行一次永久删除:只写、不再量、不再判活体(计划就是执行前的闸)。回执的字节念计划
2063
- * (`plan.bytes`),执行期不再累加一遍。
1802
+ * 按计划执行一次永久删除:不再量。回执的字节念计划(`plan.bytes`),执行期不再累加一遍。
1803
+ *
1804
+ * **计划 + 执行器、准入查两遍**(ADR-0027):计划期准入证明计划成立时没有会话在跑;执行器首笔写前
1805
+ * 再用同一份名单复查,补上附件扫描撑开的窗口。
1806
+ *
1807
+ * 计划期准入与第一笔写之间还隔着附件引用的全 home 扫描(`planImageAttachmentRemoval`),大 home
1808
+ * 上那是秒级窗口——期间用户 resume 一个会话,它的目录随后仍会被删。所以动任何写之前用计划里同一份
1809
+ * 会话名单**再查一次活体**(只读、廉价);这一查失败仍然零写入。名单取 `plan.unarchive`:
1810
+ * 它覆盖所有会被删目录的会话(回收站行与盘上成员),目录已不在的成员没有可删的东西。
2064
1811
  *
2065
1812
  * 顺序:整批摘归档集 → 摘有账本的成员的账本 → 逐条回收站行
2066
1813
  * (在才删目录 → 摘清册行 → 通告成员离场)→ 逐条盘上成员(删目录 → 通告成员离场)→ 逐条附件对象。
@@ -2077,6 +1824,7 @@ async function planOrphanRemoval(deps, members) {
2077
1824
  * 通告只经 `member-presence`(摘活体三态 + `api-session/removed`),不在这里另写一份。
2078
1825
  */
2079
1826
  async function executeRemoval(deps, plan) {
1827
+ await assertNoLive(deps.host, plan.unarchive);
2080
1828
  const presence = deps.presence;
2081
1829
  let removed = 0;
2082
1830
  /** 删到这一刻已经腾出的**进度**:只喂 `orphan` 的部分回执;成功回执念计划的合计(`plan.bytes`)。 */
@@ -2123,7 +1871,7 @@ async function executeRemoval(deps, plan) {
2123
1871
  try {
2124
1872
  await deps.ledger.remove(row.entry.id);
2125
1873
  } catch (error) {
2126
- fail("删除回收站目录失败", error, row.entry.sessionId);
1874
+ fail("摘除回收站条目失败", error, row.entry.sessionId);
2127
1875
  }
2128
1876
  removed += 1;
2129
1877
  await presence.memberGone(row.entry.sessionId);
@@ -2158,47 +1906,14 @@ async function executeRemoval(deps, plan) {
2158
1906
  * 彻底删除(组语义见 `src/trash/groups.ts`),把它的根当孤儿删掉就是**替用户做了一个没人点过的决定**。孤儿只
2159
1907
  * 有一种来路:父被彻底删掉了(或被手工从盘上清掉了),清册里再也没有这一行。
2160
1908
  *
2161
- * 根按**传递闭包**收全部后代(与 `HostCorpusPort.subagentMembersOf` 同一口径:孩子与孙代都算,深度不设上限),
2162
- * 同一条记录只归一组(`claimed`)——数据里真出现环时结果是"少数一条",不是把一个会话删两遍。
1909
+ * 根按**传递闭包**收全部 subagent 后代(与 `HostCorpusPort.subagentMembersOf` 同一实现:闭包核心住
1910
+ * `src/domain/closure.ts` 的 `orphanRootsOf`,孩子与孙代都算,深度不设上限;普通 fork 不是成员、
1911
+ * 也不穿过它收后代),同一条记录只归一组
1912
+ * (`claimed`)——数据里真出现环时结果是"少数一条",不是把一个会话删两遍。
2163
1913
  * 归堆与去重**两边都去 `session-` 前缀**:同一个父在两代宿主里写法不同(见 `bareSessionId` 的来历)。
2164
1914
  */
2165
1915
  function orphanGroupsOf(corpus, trashSessionIds) {
2166
- const known = /* @__PURE__ */ new Set();
2167
- for (const session of corpus) known.add(bareSessionId(session.sessionId));
2168
- for (const id of trashSessionIds) known.add(bareSessionId(id));
2169
- const children = /* @__PURE__ */ new Map();
2170
- for (const session of corpus) {
2171
- const parent = session.parentSessionId;
2172
- if (parent === void 0) continue;
2173
- const key = bareSessionId(parent);
2174
- const list = children.get(key);
2175
- if (list === void 0) children.set(key, [session]);
2176
- else list.push(session);
2177
- }
2178
- const claimed = /* @__PURE__ */ new Set();
2179
- const groups = [];
2180
- for (const session of corpus) {
2181
- const parent = session.parentSessionId;
2182
- if (!session.subagent || parent === void 0) continue;
2183
- if (known.has(bareSessionId(parent))) continue;
2184
- if (claimed.has(bareSessionId(session.sessionId))) continue;
2185
- const members = [];
2186
- const queue = [session];
2187
- for (let i = 0; i < queue.length; i += 1) {
2188
- const current = queue[i];
2189
- const id = bareSessionId(current.sessionId);
2190
- if (claimed.has(id)) continue;
2191
- claimed.add(id);
2192
- members.push(current);
2193
- for (const child of children.get(id) ?? []) queue.push(child);
2194
- }
2195
- groups.push({
2196
- root: session,
2197
- parentSessionId: parent,
2198
- members
2199
- });
2200
- }
2201
- return groups;
1916
+ return orphanRootsOf(corpus, [...corpus.map((session) => session.sessionId), ...trashSessionIds]);
2202
1917
  }
2203
1918
  /** **本函数是纯扫描,不对账、不写清册行**(`/orphans` 声明为 pure,一次扫描不能顺手写行)。
2204
1919
  * 清册行由调用方选好再交进来:`/orphans` 交 `read.rows()`(raw 索引),`orphan-clean` 交
@@ -2207,12 +1922,17 @@ function orphanGroupsOf(corpus, trashSessionIds) {
2207
1922
  *
2208
1923
  * `listSessions()` 回 `{ available: false }` 时**必须抛**:那是宿主的会话语料服务没挂,不是"这个家没有孤儿"。
2209
1924
  * 拿缺席当空语料 ⇒ 报告说"零条孤儿"、界面上一句"没有要清理的",而盘上真有几百 MB——那是假话,
2210
- * 所以这里按 `io` 响亮失败(AGENTS.md:`available: false` 意味着服务缺席,扫描方不许冒充读数)。 */
2211
- async function loadOrphanGroups(deps, trash) {
1925
+ * 所以这里按 `io` 响亮失败(AGENTS.md:`available: false` 意味着服务缺席,扫描方不许冒充读数)。
1926
+ *
1927
+ * `roots` 只收请求点名的那几组、**在量字节之前过滤**:`/orphans` 不传(报告全部),
1928
+ * `orphan-clean` 传这次的根——否则点一组会把整个家所有孤儿的树都递归量一遍。 */
1929
+ async function loadOrphanGroups(deps, trash, roots) {
2212
1930
  const read = await deps.host.listSessions();
2213
1931
  if (!read.available) throw verbError("io", "无主子会话扫描需要宿主的 sessionQuery.listSessions");
2214
1932
  const corpus = read.value;
2215
- const groups = orphanGroupsOf(corpus, trash.map((entry) => entry.sessionId));
1933
+ const all = orphanGroupsOf(corpus, trash.map((entry) => entry.sessionId));
1934
+ const wanted = roots === void 0 ? void 0 : new Set(roots.map((root) => bareSessionId(root)));
1935
+ const groups = wanted === void 0 ? all : all.filter((group) => wanted.has(bareSessionId(group.root.sessionId)));
2216
1936
  const out = [];
2217
1937
  for (const group of groups) {
2218
1938
  const members = [];
@@ -2224,8 +1944,10 @@ async function loadOrphanGroups(deps, trash) {
2224
1944
  bytes: measured?.bytes ?? 0
2225
1945
  });
2226
1946
  }
1947
+ const root = members[0];
1948
+ if (root === void 0) throw verbError("io", `无主子会话组没有成员(父 ${group.parentSessionId})`);
2227
1949
  out.push({
2228
- root: members[0],
1950
+ root,
2229
1951
  parentSessionId: group.parentSessionId,
2230
1952
  members
2231
1953
  });
@@ -2260,49 +1982,401 @@ function reportOrphans(groups) {
2260
1982
  })).sort((a, b) => b.bytes - a.bytes)
2261
1983
  };
2262
1984
  }
2263
- /**
2264
- * 清一组:`planOrphanRemoval`(只读计划,整组 live 准入在一切写之前)→ `executeRemoval`(只写)。
2265
- * 量目录、摘归档集与账本、删目录与成员通告的顺序和失败形状都住 `verbs/permanent-removal.ts`;
2266
- * 这里只把这一组接上去,别在这再长出一段手写步骤(purge 走的是同一条路)。
2267
- *
2268
- * 顺序与删除那一半是**对偶**的,但不是照抄:
2269
- * - 删除那一半不再用归档藏会话(ADR-0008):它摘账本、搬目录,最后才摘活体。这里目录是**删掉**
2270
- * 不是搬走,藏不藏都不影响可见性 ⇒ 归档集反而**在写之前整批摘掉**:失败时目录还在,下一次还能重试,
2271
- * 不会留下"会话没了但还在归档集里挂着"的幽灵 id。
2272
- * - `unarchive` 对未归档的会话是官方幂等 no-op(`registry.unarchiveSession`),所以这里不需要那次
2273
- * `wasArchived` 读——这里没有"还原到删除前"的义务。
2274
- * - 摘账本只在账本真记着它时做(`workspaceOf` 未命中 = 本来就是「未分组」,不是故障)。
2275
- * - 独占图片的剩余引用源由附件域自己扫:活动会话 + 全部 session-trash + storages/cache。
2276
- */
2277
- async function cleanOrphanGroup(deps, group) {
2278
- return await executeRemoval(deps, await planOrphanRemoval(deps, group.members));
1985
+ /**
1986
+ * 清一组:`planOrphanRemoval`(只读计划,整组 live 准入在一切写之前)→ `executeRemoval`(只写)。
1987
+ * 量目录、摘归档集与账本、删目录与成员通告的顺序和失败形状都住 `verbs/permanent-removal.ts`;
1988
+ * 这里只把这一组接上去,别在这再长出一段手写步骤(purge 走的是同一条路)。
1989
+ *
1990
+ * 顺序与删除那一半是**对偶**的,但不是照抄:
1991
+ * - 删除那一半不再用归档藏会话(ADR-0008):它摘账本、搬目录,最后才摘活体。这里目录是**删掉**
1992
+ * 不是搬走,藏不藏都不影响可见性 ⇒ 归档集反而**在写之前整批摘掉**:失败时目录还在,下一次还能重试,
1993
+ * 不会留下"会话没了但还在归档集里挂着"的幽灵 id。
1994
+ * - `unarchive` 对未归档的会话是官方幂等 no-op(`registry.unarchiveSession`),所以这里不需要那次
1995
+ * `wasArchived` 读——这里没有"还原到删除前"的义务。
1996
+ * - 摘账本只在账本真记着它时做(`workspaceOf` 未命中 = 本来就是「未分组」,不是故障)。
1997
+ * - 独占图片的剩余引用源由附件域自己扫:活动会话 + 全部 session-trash + storages/cache。
1998
+ */
1999
+ async function cleanOrphanGroup(deps, group) {
2000
+ return await executeRemoval(deps, await planOrphanRemoval(deps, group.members));
2001
+ }
2002
+ //#endregion
2003
+ //#region src/ports/agent-handles.ts
2004
+ const CAPTURED_AGENT_HANDLES = Symbol.for("dsh-session-delete.captured-agent-handles");
2005
+ /** 在 `ctx.agents` 上包一层 `resume` / `create`,把返回的 agent handle 的 dispose 按会话 id 记下。
2006
+ * 服务缺席时走 `ctx.inject(['agents'])` **等服务挂上再装**——live profile 的插件激活顺序可能早于
2007
+ * `agents` 服务就绪;只在 apply 时 `get` 一次会让捕获静默缺席(真机症状:重启前在回收站的能冷恢复,
2008
+ * 刚删除的会话恢复后旧 agent 拦住 prompt)。形状不符 / 属性不可写都只是**不捕获**,不影响插件激活。
2009
+ *
2010
+ * **安装时机**:先 `get('agents')`,没装成再 `inject(['agents'])`(两者只装一次,符号表兜住)。
2011
+ * **symbol 表**:句柄表挂在服务实例的 {@link CAPTURED_AGENT_HANDLES} 上;同一个 agents 服务上的第二次
2012
+ * 安装(`get` 与 `inject` 各一次,或第二个 `createHostPort`)看见它就直接共用,不把 wrapper 套第二层。
2013
+ * **读回判据**:cordis 的 traceable 服务代理每次读函数都会新包一层(`get` 里的 `createShadowMethod`),
2014
+ * 所以只能看 own property descriptor 的身份;拿 `service[name]` 读回比身份是必然的假警报。
2015
+ */
2016
+ function captureAgentHandles(ctx) {
2017
+ let handles = /* @__PURE__ */ new Map();
2018
+ let installed = false;
2019
+ const install = (candidate) => {
2020
+ if (installed) return;
2021
+ const service = candidate;
2022
+ if (service === void 0 || service === null) return;
2023
+ const existing = service[CAPTURED_AGENT_HANDLES];
2024
+ if (existing !== void 0) {
2025
+ handles = existing;
2026
+ installed = true;
2027
+ return;
2028
+ }
2029
+ let wrappedCount = 0;
2030
+ for (const name of ["resume", "create"]) try {
2031
+ const original = service[name];
2032
+ if (typeof original !== "function") continue;
2033
+ const wrapped = function(...args) {
2034
+ const result = Reflect.apply(original, this, args);
2035
+ result.then((value) => {
2036
+ const handle = value;
2037
+ const id = handle?.agent?.id;
2038
+ const dispose = handle?.dispose;
2039
+ if (typeof id !== "string" || id.length === 0 || typeof dispose !== "function") return;
2040
+ handles.set(id, async () => {
2041
+ await dispose.call(handle);
2042
+ });
2043
+ }, () => void 0);
2044
+ return result;
2045
+ };
2046
+ service[name] = wrapped;
2047
+ if (Object.getOwnPropertyDescriptor(service, name)?.value !== wrapped) throw new Error(`agents.${name} is not writable`);
2048
+ wrappedCount += 1;
2049
+ } catch {}
2050
+ if (wrappedCount === 0) return;
2051
+ try {
2052
+ service[CAPTURED_AGENT_HANDLES] = handles;
2053
+ } catch {}
2054
+ installed = true;
2055
+ };
2056
+ const service = ctx.get("agents");
2057
+ if (service !== void 0 && service !== null) install(service);
2058
+ if (!installed && typeof ctx.inject === "function") try {
2059
+ ctx.inject(["agents"], (child) => {
2060
+ install(child.get("agents"));
2061
+ });
2062
+ } catch {}
2063
+ return { async dispose(sessionId) {
2064
+ const dispose = handles.get(sessionId);
2065
+ if (dispose === void 0) return;
2066
+ try {
2067
+ await dispose();
2068
+ } finally {
2069
+ handles.delete(sessionId);
2070
+ }
2071
+ } };
2072
+ }
2073
+ //#endregion
2074
+ //#region src/ports/host-port.ts
2075
+ /** live store 的最小方法面判据(同 `projectionCacheOf` 的手法:按方法面判,不按 constructor.name)。 */
2076
+ function liveSessionsOf(ctx) {
2077
+ const store = ctx.get("sessions");
2078
+ if (store === void 0 || store === null) return void 0;
2079
+ if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
2080
+ return store;
2081
+ }
2082
+ /** 官方的「这些父的孩子」那一支:`kind:'parent'` 的判据逐字是 `values.includes(record.header.parentSession ?? null)`。
2083
+ * 它只按 `parentSession` 原样匹配,**不是**子会话分类器:旧宿主(≤0.2.1-rc.5)的普通 fork 也写
2084
+ * `parentSession`,0.2.2-alpha.2 起才不写。子会话的判据是 `origin` 的 subagent 标记(宿主归档闸、归档
2085
+ * 准入、持久化找子日志三处一致),所以调用方必须再按 origin 筛一遍。 */
2086
+ async function filterChildrenOf(sessionQuery, values) {
2087
+ return sessionQuery.filterSessions([{
2088
+ kind: "parent",
2089
+ values: [...values].sort()
2090
+ }]);
2091
+ }
2092
+ /**
2093
+ * 从若干根逐层向下问出后代,交回 `父(bare)→ 孩子` 的索引。
2094
+ * 用 `kind:'parent'` 而不是 traceSession:父进回收站后 traceSession 认不出目标(宿主失败码
2095
+ * `SESSION_QUERY_SESSION_NOT_FOUND`),而这里要的正是父不在宿主列表之后仍留在盘上的那批。
2096
+ * 出处:asar `dsh-session-query/lib/index.js` 的 `filterSessions`。一层一次宿主调用,问空即停;
2097
+ * `seen` 防环;没有 `header.id` 的记录整条跳过(无法作为身份继续下问)。
2098
+ */
2099
+ async function descendantsByParent(sessionQuery, roots) {
2100
+ const children = /* @__PURE__ */ new Map();
2101
+ const seen = new Set(roots.map(bareSessionId));
2102
+ let frontier = [...seen];
2103
+ while (frontier.length > 0) {
2104
+ const values = [];
2105
+ for (const bare of frontier) for (const shape of sessionIdShapes(bare)) if (!values.includes(shape)) values.push(shape);
2106
+ const next = [];
2107
+ for (const record of await filterChildrenOf(sessionQuery, values)) {
2108
+ const id = record.header?.id;
2109
+ const parent = record.header?.parentSession;
2110
+ if (typeof id !== "string" || id.length === 0) continue;
2111
+ if (typeof parent !== "string" || parent.length === 0) continue;
2112
+ if (record.header?.origin !== "subagent") continue;
2113
+ const bare = bareSessionId(id);
2114
+ if (seen.has(bare)) continue;
2115
+ seen.add(bare);
2116
+ const cwd = record.header.cwd;
2117
+ const descendant = {
2118
+ sessionId: id,
2119
+ projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd)
2120
+ };
2121
+ const parentBare = bareSessionId(parent);
2122
+ const siblings = children.get(parentBare);
2123
+ if (siblings === void 0) children.set(parentBare, [descendant]);
2124
+ else siblings.push(descendant);
2125
+ next.push(bare);
2126
+ }
2127
+ frontier = next;
2128
+ }
2129
+ return children;
2130
+ }
2131
+ /** {@link HostCorpusPort.subagentMembersOf} 的那一次 BFS:这里只负责问宿主,某个根的后代闭包
2132
+ * 由纯部件的 `closureOf` 在交回的索引上收(深度不设上限、防环、首见去重都在那一处)。
2133
+ * 空清单在调用宿主之前就返回(不为一张空表扫全库)。 */
2134
+ async function membersFor(sessionQuery, parentIds) {
2135
+ const wanted = [];
2136
+ for (const id of parentIds) if (id.length > 0 && !wanted.includes(id)) wanted.push(id);
2137
+ if (wanted.length === 0) return {};
2138
+ const children = await descendantsByParent(sessionQuery, wanted);
2139
+ const out = {};
2140
+ for (const id of wanted) out[id] = closureOf(children, id);
2141
+ return out;
2142
+ }
2143
+ /** 无 cwd 的会话落在 `_no-cwd/`;`projectDirOf` 与回收站对账共用这个字面量。
2144
+ * 出处:asar `dsh-session-persistence-jsonl/lib/index.js` 的 `projectDir`(`if (cwd === void 0) return join(root, "_no-cwd");`)。 */
2145
+ const NO_CWD_PROJECT_DIR = "_no-cwd";
2146
+ /** 宿主 `projectKey(cwd)` 的逐字镜像:跨插件不能 import,宿主也没把它暴露成服务。
2147
+ * 出处:asar `dsh-session-persistence-jsonl/lib/index.js` 的 `projectKey`;调用链
2148
+ * `locate` → `logPath` → `projectDir` → `projectKey`。本机实测 `D:/develop/GitHub/dsh-session-delete`
2149
+ * → `--D-develop-GitHub-dsh-session-delete--`。`projectDirOf` 与回收站对账重建共用这一份;
2150
+ * 宿主换代先复核算法。 */
2151
+ function projectKey(cwd) {
2152
+ let readable = "";
2153
+ let separatorRun = false;
2154
+ for (let i = 0; i < cwd.length; i++) {
2155
+ const code = cwd.charCodeAt(i);
2156
+ const ch = String.fromCharCode(code);
2157
+ if (ch === "/" || ch === "\\" || ch === ":") {
2158
+ if (!separatorRun) readable += "-";
2159
+ separatorRun = true;
2160
+ } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
2161
+ readable += ch;
2162
+ separatorRun = false;
2163
+ } else {
2164
+ readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
2165
+ separatorRun = false;
2166
+ }
2167
+ }
2168
+ return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
2169
+ }
2170
+ /** 投影缓存服务的最小方法面判据(按方法面判,不按 `constructor.name` 判)。 */
2171
+ function projectionCacheOf(ctx) {
2172
+ const cache = ctx.get("sessionProjectionCache");
2173
+ if (cache === void 0 || cache === null) return void 0;
2174
+ if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
2175
+ return cache;
2176
+ }
2177
+ /** 宿主 `readSessionHeader` 对未知会话抛的是裸 `Error`,只认这句;真存储故障照抛,不能折成未命中。
2178
+ * 出处:asar `dsh-workspace/lib/types/index.js` 的
2179
+ * `cannot validate session '${id}': session persistence holds no such session`。
2180
+ * 同文件有类型的近似句是 `WorkspaceUnknownSessionError`(`hold` 无 `s`),不会误伤。 */
2181
+ const NO_SUCH_SESSION = "session persistence holds no such session";
2182
+ /** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
2183
+ function isUnknownSession(error) {
2184
+ return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
2185
+ }
2186
+ /** 宿主会话表的一次取数(`ctx.sessionController.list()` 的两种返回形状都折成数组)。
2187
+ * 服务没挂 / 形状不对 / 抛错 ⇒ 空表——**这条不替宿主兜错误**:调用方拿空表的语义都是"少发一条通告",
2188
+ * 不是"恢复失败"。 */
2189
+ async function controllerItems(host) {
2190
+ try {
2191
+ const controller = host.get("sessionController");
2192
+ if (controller === void 0 || controller === null || typeof controller.list !== "function") return [];
2193
+ const listed = await controller.list();
2194
+ const items = Array.isArray(listed) ? listed : listed?.items;
2195
+ return Array.isArray(items) ? items.map((item) => item) : [];
2196
+ } catch {
2197
+ return [];
2198
+ }
2199
+ }
2200
+ /** 账本里没有那个工作区时 {@link HostPort.attach} / {@link HostPort.detach} 抛的原话开头。
2201
+ * **原话与判据同住这里**:抛它的是这个端口,认它的也是端口给的那句,调用方不许另抄一份字符串。 */
2202
+ const WORKSPACE_MISSING_MESSAGE = "workspace not found";
2203
+ /** 这次失败是不是"账本里没那个工作区"(而不是另有故障)。恢复侧靠它把两种失败分开:
2204
+ * 前者按「未分组」收,后者照旧回滚报 `io`——不给真故障静默改归属的机会。 */
2205
+ function isWorkspaceMissingError(error) {
2206
+ return error instanceof Error && error.message.startsWith(WORKSPACE_MISSING_MESSAGE);
2279
2207
  }
2280
- //#endregion
2281
- //#region src/verbs/purge-trash.ts
2282
- /**
2283
- * 彻底删除:从回收站里**真的**抹掉一条(或整组、或整站),并连带那些"还留在盘上、没被带走"的后代。
2284
- *
2285
- * 与另外两个动词的关系(ADR-0002):它的 interface 是"这一组目标 + 一份只读清册快照
2286
- *(targets 缺席 = 清空)";目标由 `verbs/batch-carried.ts` 按组语义算好,批量的"已被同批前一条
2287
- * 带走"判据不在这里。`requestedId` 是唯一的例外参数,但它**不参与任何判据**:只是让
2288
- * `trash-empty` 的原文点得出用户点的是哪一条(原文要上屏,点名承重),批量语义仍在批量层。
2289
- *
2290
- * **这条动词只做两件事:拿目标、把计划交给执行器**。量目录、筛盘上后代、活体准入与"先退归档集、
2291
- * 再摘账本、最后删目录"的顺序都住 `verbs/permanent-removal.ts`——计划只读,执行器只写,计划就是
2292
- * 执行前的闸(别在这里再长出一段手写步骤)。
2293
- *
2294
- * **这条动词不做回滚**:目录删掉就没有"退回去"这回事(删到一半失败时已删目录不回滚;
2295
- * 带部分计数的 `details` 回执只属于 `orphan-clean`,purge 的 `removed` / `freedBytes` 仍只在成功回执里给)。
2296
- * 对应地,`kind:'trash'` 的计划失败时执行器只抛那一步的 `io` 与原文、不带 details。
2297
- *
2298
- * 回执里的 `attachments` 原样来自执行器:独占图片在会话目录删完之后才清,附件失败只计数、不改
2299
- * `removed` / `freedBytes`(`skipped` = 有候选或剩余引用源读不出、这一轮部分或整体没清,原因上屏)。
2300
- */
2301
- function purgeTrash(deps) {
2302
- return async function purgeTrash(targets, rows, requestedId) {
2303
- const selected = targets ?? rows;
2304
- if (targets !== void 0 && selected.length === 0) throw trashEmpty(requestedId);
2305
- return await executeRemoval(deps, await planTrashRemoval(deps, rows, selected));
2208
+ /** 宿主服务的真实绑定;出错原样抛,由 `toRemoteFailure` 按 name 映射。
2209
+ * 各方法调用的服务与方法见上方成员注释;错误映射表住 `src/remote/map-error.ts`。 */
2210
+ function createHostPort(ctx) {
2211
+ const host = ctx;
2212
+ const registry = host.workspaceRegistry;
2213
+ const agentHandles = captureAgentHandles(host);
2214
+ return {
2215
+ /** 账本里记着这个会话的工作区:`registry.list()` 的实体 `sessionIds`(该 getter 已按 canonical cwd 过滤过)。 */
2216
+ async workspaceOf(sessionId) {
2217
+ return registry.list().find((workspace) => workspace.sessionIds.includes(sessionId))?.id;
2218
+ },
2219
+ /** 账本里还有这个工作区吗:`registry.get(id)` 是 `this.entities.get(id)` 的直通(同步 Map 查找),
2220
+ * 未知 id 回 `undefined` ⇒ 这里只做一次判别,不抛。 */
2221
+ async workspaceExists(workspaceId) {
2222
+ return registry.get(workspaceId) !== void 0;
2223
+ },
2224
+ /**
2225
+ * cwd → 宿主项目目录名;没有 cwd 落到 `_no-cwd`。
2226
+ * 账本里没有这个会话回 `undefined`(未命中,不是故障);真存储故障照抛。
2227
+ * 未命中由 `delete-session.ts` 折成 `not-found`,真故障留 `io`——两者不能混。
2228
+ */
2229
+ async projectDirOf(sessionId) {
2230
+ let header;
2231
+ try {
2232
+ header = await registry.readSessionHeader(sessionId);
2233
+ } catch (error) {
2234
+ if (isUnknownSession(error)) return void 0;
2235
+ throw error;
2236
+ }
2237
+ return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
2238
+ },
2239
+ /** 标题走宿主投影缓存;尽力取,取不到返回空串,绝不因此让删除失败。 */
2240
+ async titleOf(sessionId) {
2241
+ try {
2242
+ const header = await registry.readSessionHeader(sessionId);
2243
+ const cache = projectionCacheOf(host);
2244
+ const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
2245
+ return typeof title === "string" ? title : "";
2246
+ } catch {
2247
+ return "";
2248
+ }
2249
+ },
2250
+ /** 活跃判据走宿主 `workspace/session-activity` waterfall:空数组 = 无活动;
2251
+ * 与宿主 archiveSession 的准入同源。 */
2252
+ async isLive(sessionId) {
2253
+ return (await host.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]))).length > 0;
2254
+ },
2255
+ /** registry 全局归档集(数组,`state.archivedSessionIds`)。 */
2256
+ async isArchived(sessionId) {
2257
+ return registry.archivedSessionIds.includes(sessionId);
2258
+ },
2259
+ /**
2260
+ * 从 `ctx.sessions` 摘掉会话,并关闭捕获到的 agent handle。
2261
+ *
2262
+ * 会话列表 = 磁盘持久化 ∪ 内存活体,搬走文件只清掉一侧;宿主 `SessionController` 会丢掉
2263
+ * `AgentHandle`,所以 `agent-handles.ts` 按会话 id 留住 handle,这里先 detach 再 dispose。
2264
+ * 捕获不到 handle(插件激活前已启动,或 `ctx.agents` 形状变化)时只能摘 session,这是已知限制,
2265
+ * 不拿归档兜底(ADR-0008)。
2266
+ *
2267
+ * 三态:读不到 store / 形状不符 / detach 或 dispose 抛 ⇒ `unknown`(不通告);
2268
+ * 内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。任何一步都不抛。
2269
+ */
2270
+ async evictLive(sessionId) {
2271
+ try {
2272
+ const store = liveSessionsOf(host);
2273
+ const session = store?.get(sessionId);
2274
+ if (store === void 0) return "unknown";
2275
+ if (session === void 0 || session === null) {
2276
+ await agentHandles.dispose(sessionId);
2277
+ return "not-live";
2278
+ }
2279
+ const entry = store.liveEntryFor(session);
2280
+ if (entry === void 0 || entry === null || typeof entry.detach !== "function") return "unknown";
2281
+ entry.detach();
2282
+ await agentHandles.dispose(sessionId);
2283
+ return "evicted";
2284
+ } catch {
2285
+ return "unknown";
2286
+ }
2287
+ },
2288
+ /**
2289
+ * 通告客户端这个会话没了:发宿主自己转发的 `api-session/removed`,客户端当场删行。
2290
+ * 冷会话没有 `session/disposed`,不发这条就只能等重连。
2291
+ * 只在宿主列表确实已不含它时调用(调用方按 {@link EvictionOutcome} 判);不发假通告。
2292
+ */
2293
+ async announceRemoved(sessionId) {
2294
+ try {
2295
+ host.emit("api-session/removed", sessionId);
2296
+ } catch {}
2297
+ },
2298
+ /**
2299
+ * 通告这组会话回来了:拉一次宿主会话表,按 id 逐条发 `api-session/added`。
2300
+ * 摘要直接用宿主 `sessionController.list()` 的形状;取不到就不发。
2301
+ * 批量只预取一次,避免按条全表扫。
2302
+ */
2303
+ async announceRestored(sessionIds) {
2304
+ if (sessionIds.length === 0) return;
2305
+ const summaries = [];
2306
+ for (const item of await controllerItems(host)) {
2307
+ const sessionId = item?.sessionId;
2308
+ if (typeof sessionId === "string") summaries.push({
2309
+ sessionId,
2310
+ summary: item
2311
+ });
2312
+ }
2313
+ for (const id of sessionIds) {
2314
+ const summary = summaries.find((item) => item.sessionId === id)?.summary;
2315
+ if (summary === void 0) continue;
2316
+ try {
2317
+ host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
2318
+ } catch {}
2319
+ }
2320
+ },
2321
+ /** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
2322
+ async unarchive(sessionId) {
2323
+ await registry.unarchiveSession(sessionId);
2324
+ },
2325
+ /** 摘账本:实体方法 `detachSession`(顶层没有);未知工作区**响亮失败**,不静默当成功。 */
2326
+ async detach(sessionId, workspaceId) {
2327
+ const workspace = registry.get(workspaceId);
2328
+ if (workspace === void 0) throw new Error(`${WORKSPACE_MISSING_MESSAGE} (detach): ${workspaceId}`);
2329
+ await workspace.detachSession(sessionId);
2330
+ },
2331
+ /** 挂账本:实体方法 `attachSession`(顶层没有)。 */
2332
+ async attach(sessionId, workspaceId) {
2333
+ const workspace = registry.get(workspaceId);
2334
+ if (workspace === void 0) throw new Error(`${WORKSPACE_MISSING_MESSAGE} (attach): ${workspaceId}`);
2335
+ await workspace.attachSession(sessionId);
2336
+ },
2337
+ /** 确保归档:官方 `archiveSession(sessionId)`。归档是 registry **全局**的,与工作区账目无关
2338
+ * (JSDoc 原文 "its workspace accounting — or lack of one — is irrelevant")⇒ 没有工作区入参,
2339
+ * 调用方也不许再传(传了就是暗示一个从不生效的作用域)。
2340
+ * 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,src/shared/wire.ts)。 */
2341
+ async archive(sessionId) {
2342
+ await registry.archiveSession(sessionId);
2343
+ },
2344
+ async listSessions() {
2345
+ const sessionQuery = host.get("sessionQuery");
2346
+ if (typeof sessionQuery?.listSessions !== "function") return { available: false };
2347
+ const records = await sessionQuery.listSessions();
2348
+ const out = [];
2349
+ for (const record of records) {
2350
+ const id = record.header?.id;
2351
+ if (typeof id !== "string" || id === "") continue;
2352
+ const parent = record.header?.parentSession;
2353
+ const cwd = record.header?.cwd;
2354
+ out.push({
2355
+ sessionId: id,
2356
+ ...typeof parent === "string" && parent !== "" ? { parentSessionId: parent } : {},
2357
+ projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd),
2358
+ subagent: record.header?.origin === "subagent"
2359
+ });
2360
+ }
2361
+ return {
2362
+ available: true,
2363
+ value: out
2364
+ };
2365
+ },
2366
+ /**
2367
+ * 官方 `sessionQuery.filterSessions([{kind:'parent', values}])`,不自扫 `~/.dsh/sessions`。
2368
+ * 数传递闭包(孩子与孙代),不是直接孩子;`session-` 前缀两边都去掉再比,
2369
+ * 按调用方给的写法回键。`sessionQuery` 缺席或方法缺失 ⇒ `{available:false}`,
2370
+ * 不拿空表冒充“没有后代”;服务在场时空入参零宿主调用。
2371
+ */
2372
+ async subagentMembersOf(parentIds) {
2373
+ const sessionQuery = host.get("sessionQuery");
2374
+ if (typeof sessionQuery?.filterSessions !== "function") return { available: false };
2375
+ return {
2376
+ available: true,
2377
+ value: await membersFor(sessionQuery, parentIds)
2378
+ };
2379
+ }
2306
2380
  };
2307
2381
  }
2308
2382
  //#endregion
@@ -2416,7 +2490,7 @@ async function memberRestore(deps, presence, entry) {
2416
2490
  run: async () => {
2417
2491
  await deps.host.unarchive(entry.sessionId);
2418
2492
  },
2419
- undo: () => stepUndo("重新归档失败", () => deps.host.archive(entry.sessionId, workspaceId))
2493
+ undo: () => stepUndo("重新归档失败", () => deps.host.archive(entry.sessionId))
2420
2494
  }],
2421
2495
  {
2422
2496
  key: "manifest",
@@ -2435,6 +2509,43 @@ async function memberRestore(deps, presence, entry) {
2435
2509
  }) };
2436
2510
  }
2437
2511
  //#endregion
2512
+ //#region src/verbs/trash-scope.ts
2513
+ function createTrashScope(deps, rows) {
2514
+ const original = rows;
2515
+ let current = [...rows];
2516
+ /** 拿目标、把计划交给执行器:`targets` 缺席(`undefined`)= 清空。计划只读——活体准入在**一切写
2517
+ * 之前**(任一在跑就整条拒绝、零副作用)。盘上后代按**当前视图里还在的 id** 排除(跟着父进来的
2518
+ * 孩子不按"盘上残留"再删一遍);`sessionQuery` 缺席时端口回 `{ available: false }` ⇒ 这次只清
2519
+ * 清册里的那部分,盘上残留留给 `GET /orphans` 兜底(与 delete 的降级一致,不折成失败)。 */
2520
+ async function purgeGroup(targets, requestedId) {
2521
+ const selected = targets ?? current;
2522
+ if (targets !== void 0 && selected.length === 0) throw trashEmpty(requestedId);
2523
+ return await executeRemoval(deps, await planTrashRemoval(deps, current, selected));
2524
+ }
2525
+ /** 收缩是内部闭包、不外露:唯一的观察面是后续 `purge` / `purgeAll` / `orphanGroups` 看见的当前视图。 */
2526
+ const take = (id) => {
2527
+ current = withoutGroup(current, id);
2528
+ };
2529
+ return {
2530
+ purge: async (id) => {
2531
+ const claim = claimOf(original, current, id);
2532
+ if (claim.verdict === "carried") return {
2533
+ removed: 0,
2534
+ freedBytes: 0
2535
+ };
2536
+ const stats = await purgeGroup(claim.verdict === "fresh" ? claim.group : [], id);
2537
+ take(id);
2538
+ return stats;
2539
+ },
2540
+ purgeAll: async () => {
2541
+ const stats = await purgeGroup(void 0);
2542
+ current = [];
2543
+ return stats;
2544
+ },
2545
+ orphanGroups: (roots) => loadOrphanGroups(deps, current, roots)
2546
+ };
2547
+ }
2548
+ //#endregion
2438
2549
  //#region src/verbs/batch.ts
2439
2550
  /**
2440
2551
  * 「这一批」的唯一实现(ADR-0002):POST /apply 的落点——一组 id 上的同一个动作。
@@ -2445,7 +2556,8 @@ async function memberRestore(deps, presence, entry) {
2445
2556
  * - 恢复通告攒到批尾,一次 announceRestored(ids)。
2446
2557
  *
2447
2558
  * 「已被同批前一条带走」是幂等成功(delete/restore 回 null,purge 回 0),不是失败;
2448
- * 判据在 trash/groups.ts,同批已带走集在 batch-carried.ts。
2559
+ * 判据在 trash/groups.ts,同批已带走集:delete / restore 在 batch-carried.ts,purge 的那份连同
2560
+ * 收缩视图住 trash-scope.ts(清册作用域)。
2449
2561
  */
2450
2562
  function failure(id, action, error) {
2451
2563
  return {
@@ -2498,8 +2610,8 @@ async function applyDelete(deps, ids) {
2498
2610
  return entry;
2499
2611
  });
2500
2612
  }
2501
- /** 「这条是不是被同批前一条带走了」与视图收缩都归 `batch-carried.ts`;`apply` 整批只读一次清册,
2502
- * 动词只拿它交回的只读组与当前清册。 */
2613
+ /** restore 的「这条是不是被同批前一条带走了」与视图收缩都归 `batch-carried.ts`(purge 那一半归
2614
+ * `trash-scope.ts`);`apply` 整批只读一次清册,动词只拿它交回的只读组与当前清册。 */
2503
2615
  async function applyRestore(deps, ids) {
2504
2616
  const carried = createEntryCarried(await deps.ledger.list());
2505
2617
  const run = restoreSession(deps);
@@ -2516,20 +2628,12 @@ async function applyRestore(deps, ids) {
2516
2628
  if (restored.length > 0) await deps.presence.announceRestored(restored);
2517
2629
  return outcomes;
2518
2630
  }
2631
+ /** purge 的整批语义收进清册作用域:选组、收缩视图、同批带走的幂等(回 0)与 absent 的
2632
+ * trash-empty(原文点名)都在 `scope.purge(id)` 里——这里整批只读一次清册、逐 id 只交一个 id,
2633
+ * 两份数组(算好的组 + 收缩中的视图)不再从批量层递两遍。 */
2519
2634
  async function applyPurge(deps, ids) {
2520
- const carried = createEntryCarried(await deps.ledger.list());
2521
- const run = purgeTrash(deps);
2522
- return forEachId("purge", ids, async (entryId) => {
2523
- const claim = carried.claim(entryId);
2524
- if (claim.verdict === "carried") return {
2525
- removed: 0,
2526
- freedBytes: 0
2527
- };
2528
- const group = claim.verdict === "fresh" ? claim.group : [];
2529
- const stats = await run(group, carried.rows(), entryId);
2530
- carried.take(entryId);
2531
- return stats;
2532
- });
2635
+ const scope = createTrashScope(deps, await deps.ledger.list());
2636
+ return forEachId("purge", ids, (entryId) => scope.purge(entryId));
2533
2637
  }
2534
2638
  /** 删除确认框那句「含 N 个子会话」:一次问完整批(`subagentMembersOf` 本来就是批量口)。
2535
2639
  * 宿主口缺席(`available:false`)时按 0(这里不再有"空表"这一步);真读失败则整次调用失败,
@@ -2551,7 +2655,7 @@ async function applyCount(deps, ids) {
2551
2655
  * 点过的决定(那种组该跟着父恢复或彻底删除)。
2552
2656
  */
2553
2657
  async function applyOrphanClean(deps, ids) {
2554
- const groups = await loadOrphanGroups(deps, await deps.ledger.list());
2658
+ const groups = await createTrashScope(deps, await deps.ledger.list()).orphanGroups(ids);
2555
2659
  const byRoot = new Map(groups.map((group) => [bareSessionId(group.root.sessionId), group]));
2556
2660
  return forEachId("orphan-clean", ids, async (id) => {
2557
2661
  const group = byRoot.get(bareSessionId(id));
@@ -2571,38 +2675,42 @@ function applyActions(deps, action, ids) {
2571
2675
  //#endregion
2572
2676
  //#region src/trash/detail.ts
2573
2677
  /**
2574
- * 回收站**单条详情**:从回收站目录里那条会话自己的日志读出元信息与正文(用例见 `tests/trash-detail.test.ts`)。
2678
+ * 回收站**单条详情**:从回收站目录里那条会话自己的日志读出元信息与正文块(用例见 `tests/trash-detail.test.ts`)。
2575
2679
  *
2576
2680
  * 三层里的策略层——编解码在 `src/log/*`,这里只回答"给谁看、给多少、算不算失败"。
2577
2681
  *
2578
- * 三条硬口径,都不是口味:
2682
+ * 四条硬口径,都不是口味:
2579
2683
  * ① **寻址只认清册(raw 索引)**:`entryId` 先在对账之外的 raw 索引里查,目录一律由 `trashDirOf(record.id, home)` 推
2580
2684
  * ⇒ 外部输入进不了文件系统寻址(不给"传个路径就读任意目录"留缝)。
2581
- * ② **正文只认 `source.kind === "user"`**:`user/message` 里绝大多数是宿主注入的上下文
2582
- * (本机实测一条会话:真实提问 21 条、注入 241 条),不过滤就被淹掉;注入条数照样上线
2583
- * (`injectedCount`),让"为什么少了这么多条"能与页面上的数字对账。
2584
- * ③ **两个闸是载荷闸,不是读盘闸**:每条 3 000 字符、累计 50 万字符(CJK 最坏 ≈ 1.5 MB UTF-8)。
2585
- * 本机存量一条都触发不了(量级随盘上会话数摆动,不在这里钉数)⇒ 它们是防异常
2586
- * 膨胀的;**不设"文件太大就拒绝"**,那种阈值只会把真读得出来的正文挡在外面。
2587
- *
2588
- * 计数与正文**同源过滤、各过各的闸**:`user/message` 先要求 `source.kind === 'user'`,两类都过"空文本**且**
2589
- * 无附件"过滤;`userMessageCount` / `assistantMessageCount` 数的是通过过滤的条数,**不受载荷闸影响**。正文再受
2590
- * 单条/累计两个闸:单条超限截断并把那一条标成 `truncated`,累计到上限后剩下的整条不再收、只累进
2591
- * `omittedByPayloadCap`——所以计数可以大于正文条数,详情里两件事分开说。
2592
- * 附件随消息一起收(`attachmentsInContent`),也有两个闸:每条 20、总量 200;超出的**静默丢**,
2593
- * 不累进 `omittedByPayloadCap`——那个计数说的是"整条正文没进来",拿它兼报少了几张附件会让两个数都对不上。
2594
- */
2595
- /** 每条文字的上限(字符)。超出即截断并置 `truncated`。 */
2596
- const PER_MESSAGE_CHAR_CAP = 3e3;
2597
- /** 一次回信的正文总量上限(字符)。 */
2598
- const TOTAL_CHAR_CAP = 5e5;
2599
- /** 一条消息带多少张附件上线。 */
2600
- const MAX_ATTACHMENTS_PER_MESSAGE = 20;
2601
- /** 一次回信的附件总量上限。 */
2602
- const MAX_ATTACHMENTS_TOTAL = 200;
2685
+ * ② **正文是块,不是消息**:`user/message` 按 `source.kind` 拆成 user / injection;`assistant/message.content`
2686
+ * 按 `text` / `reasoning` 拆块,附件并进相邻的文字块;`tool/call` 建工具块、`tool/result` 按 `callId` 回填;
2687
+ * `system/message` 与 `compaction/summary` 各成一块。turn/step/job/request 这类簿记事件不进正文——它们不是
2688
+ * "用户想看的对话",铺进来只会把真正的提问和回复淹掉。
2689
+ * ③ **分页是游标,不是总量闸**:一次只回尾部一页,`olderCursor` 指页首;客户端把游标原样回传取更早一页。
2690
+ * 页有两道闸:块数 `pageBlocks` 与正文总量 `pageChars`(单块超字符闸仍保留一块,游标才能前进);
2691
+ * 每块另有 `perBlockChars` 的单块闸(正文与工具参数/结果各自算),超了标 `truncated`。
2692
+ * 构建时用**有界缓冲只留当前页**,不把整条日志的正文堆在内存里;工具结果可能落在游标之后,
2693
+ * 仍会回填到页内那个调用块(别把"已完成"误报成"未完成")。
2694
+ * ④ **计数与正文同源过滤、各过各的闸**:`userMessageCount` / `injectedCount` 只数建了块的;`assistantMessageCount`
2695
+ * 数**每一条** assistant/message(含只带 reasoning / tool-call 的);`toolCallCount` 数每一条 tool/call。
2696
+ * 计数是整条日志的,不受分页与任何字符闸影响。
2697
+ *
2698
+ * 附件随块一起收(`attachmentsInContent`):每块最多 20、每页最多 200;超出的**静默丢**,不另立计数——
2699
+ * "少了几张图"与"这块被截断"不是同一件事,拿一个数兼报会让两个数都对不上。
2700
+ */
2701
+ /** 每块文字的上限(字符)。正文、工具参数、工具结果各自吃这一刀。 */
2702
+ const PER_BLOCK_CHAR_CAP = 16e3;
2703
+ /** 一页正文的总字符上限(块按日志顺序累计;单块超闸时仍保留一块)。 */
2704
+ const PAGE_CHAR_LIMIT = 5e5;
2705
+ /** 一块最多带多少张附件上线。 */
2706
+ const MAX_ATTACHMENTS_PER_BLOCK = 20;
2707
+ /** 一页最多带多少张附件上线。 */
2708
+ const MAX_ATTACHMENTS_PER_PAGE = 200;
2603
2709
  const DEFAULT_LIMITS = {
2604
- perMessageChars: PER_MESSAGE_CHAR_CAP,
2605
- totalChars: TOTAL_CHAR_CAP
2710
+ perBlockChars: PER_BLOCK_CHAR_CAP,
2711
+ pageBlocks: 30,
2712
+ pageChars: PAGE_CHAR_LIMIT,
2713
+ subagents: 200
2606
2714
  };
2607
2715
  /** 一条记录里那些 `content[]` 段的文字拼起来(`\n` 分隔;非 text 段一律不参与)。 */
2608
2716
  function textOf(content) {
@@ -2621,9 +2729,195 @@ function attachmentWires(content) {
2621
2729
  ...ref.kind === "image" && ref.height !== void 0 ? { height: ref.height } : {}
2622
2730
  }));
2623
2731
  }
2732
+ /** 单块闸:超过上限就截一刀并置标记。 */
2733
+ function clip(text, cap) {
2734
+ return text.length > cap ? {
2735
+ text: text.slice(0, cap),
2736
+ truncated: true
2737
+ } : {
2738
+ text,
2739
+ truncated: false
2740
+ };
2741
+ }
2742
+ /** 「这块有没有内容」的**唯一判据**:非空正文或至少一个附件。建块(`blockBody`)与出线收尾
2743
+ * (`PageBuilder.finish` 的附件总量闸之后)都认这一支——判据写两遍就是两份会漂移的抄本,
2744
+ * 渲染侧因此不必、也不许再兜空块。 */
2745
+ function hasContent(text, attachments) {
2746
+ return text !== "" || attachments.length > 0;
2747
+ }
2748
+ /** 一块的正文:没内容 ⇒ 这块不建;否则过单块闸截一刀。 */
2749
+ function blockBody(rawText, attachments, cap) {
2750
+ const text = rawText.trim();
2751
+ const kept = attachments.slice(0, MAX_ATTACHMENTS_PER_BLOCK);
2752
+ if (!hasContent(text, kept)) return void 0;
2753
+ const clipped = clip(text, cap);
2754
+ return {
2755
+ text: clipped.text,
2756
+ truncated: clipped.truncated,
2757
+ attachments: kept
2758
+ };
2759
+ }
2760
+ function textBlock(kind, time, rawText, attachments, cap) {
2761
+ const body = blockBody(rawText, attachments, cap);
2762
+ return body === void 0 ? void 0 : {
2763
+ kind,
2764
+ id: "",
2765
+ time,
2766
+ ...body
2767
+ };
2768
+ }
2769
+ function injectionBlock(time, sourceKind, rawText, attachments, cap) {
2770
+ const body = blockBody(rawText, attachments, cap);
2771
+ return body === void 0 ? void 0 : {
2772
+ kind: "injection",
2773
+ id: "",
2774
+ time,
2775
+ sourceKind,
2776
+ ...body
2777
+ };
2778
+ }
2779
+ /** 一块在页级字符闸里占多少字符:正文与工具参数/结果都算;截断标记与附件元数据不计。 */
2780
+ function blockChars(block) {
2781
+ switch (block.kind) {
2782
+ case "tool": return block.args.length + block.result.length;
2783
+ case "user":
2784
+ case "assistant":
2785
+ case "reasoning":
2786
+ case "injection":
2787
+ case "system":
2788
+ case "compaction": return block.text.length;
2789
+ default: return unhandledChars(block);
2790
+ }
2791
+ }
2792
+ /** 新块类型没登记字符口径时编译失败;运行时(前后半版本错配)按 0 计。 */
2793
+ function unhandledChars(block) {
2794
+ return 0;
2795
+ }
2796
+ /**
2797
+ * 一页的有界缓冲:只留游标之前**最后** `pageBlocks` 块,且正文总量不超过 `pageChars`。
2798
+ *
2799
+ * `nextIndex` 数的是整条日志的块序(包括游标之后的块),`buffer` 只装本页。工具结果可能落在
2800
+ * 游标之后:`toolByCallId` 让它在页内那个调用块上回填。
2801
+ *
2802
+ * 配对认的是**块对象**,不是 callId 这一格:宿主重试会让同一个 callId 建出两块,按 callId 摘映射
2803
+ * 会把仍然在页内的另一块误摘成「未完成」——ADR-0026 决定 3 明令不许。所以每个 callId 挂一串页内
2804
+ * 块(按日志顺序),挤出谁就摘谁。本机回收站与 live 日志实测:callId 与结果一比一、无重复、无逆序,
2805
+ * 这一串常态只有一个元素;串与下面的配法是给非常态兜底的。
2806
+ */
2807
+ var PageBuilder = class PageBuilder {
2808
+ buffer = [];
2809
+ toolByCallId = /* @__PURE__ */ new Map();
2810
+ nextIndex = 0;
2811
+ chars = 0;
2812
+ /** `end` 是本页的右边界(不含):块序号大于等于它的不入页;`Infinity` = 从尾部取。 */
2813
+ end;
2814
+ limit;
2815
+ cap;
2816
+ pageChars;
2817
+ constructor(end, limit, cap, pageChars) {
2818
+ this.end = end;
2819
+ this.limit = limit;
2820
+ this.cap = cap;
2821
+ this.pageChars = pageChars;
2822
+ }
2823
+ /** 追加一块:id 用整条日志的块序号盖,页内只留最后 `limit` 块 / `pageChars` 字符。 */
2824
+ add(block) {
2825
+ const index = this.nextIndex++;
2826
+ if (index >= this.end) return;
2827
+ block.id = String(index);
2828
+ const chars = blockChars(block);
2829
+ this.buffer.push({
2830
+ index,
2831
+ block,
2832
+ chars
2833
+ });
2834
+ this.chars += chars;
2835
+ this.trim();
2836
+ if (block.kind === "tool") this.hook(block);
2837
+ }
2838
+ /** 挤出页首:块数超限,或正文超字符闸(但至少留一块,游标才能前进)。 */
2839
+ trim() {
2840
+ while (this.buffer.length > this.limit || this.buffer.length > 1 && this.chars > this.pageChars) {
2841
+ const evicted = this.buffer.shift();
2842
+ if (evicted === void 0) return;
2843
+ this.chars -= evicted.chars;
2844
+ if (evicted.block.kind === "tool") this.unhook(evicted.block);
2845
+ }
2846
+ }
2847
+ /** 没带 callId 的调用块无从配对:照常入页(界面念「未完成」),只是不进配对串。 */
2848
+ static pairable(block) {
2849
+ return block.callId !== "";
2850
+ }
2851
+ hook(block) {
2852
+ if (!PageBuilder.pairable(block)) return;
2853
+ const queued = this.toolByCallId.get(block.callId);
2854
+ if (queued === void 0) this.toolByCallId.set(block.callId, [block]);
2855
+ else queued.push(block);
2856
+ }
2857
+ /** 挤出页的块从它的配对串里按对象摘掉;串空了连 key 一起摘,不留空串。 */
2858
+ unhook(block) {
2859
+ if (!PageBuilder.pairable(block)) return;
2860
+ const queued = this.toolByCallId.get(block.callId);
2861
+ if (queued === void 0) return;
2862
+ const at = queued.indexOf(block);
2863
+ if (at < 0) return;
2864
+ if (queued.length === 1) this.toolByCallId.delete(block.callId);
2865
+ else queued.splice(at, 1);
2866
+ }
2867
+ /**
2868
+ * `tool/result` 回填:配给这一串里**最早那还没有结果**的调用块(同一个 callId 出两块时,结果按
2869
+ * 到达顺序配给先出的调用,两块各留一个读数)。
2870
+ *
2871
+ * 两种情况落不了点,都不改写已经配上结果的块:调用块在更早的页里(那一页自己走一趟时配上);
2872
+ * 结果条数多过页内待配的调用块(一次调用一个读数,后到的不覆盖先配上的)。口径见
2873
+ * `tests/trash-detail.test.ts` 的工具配对那几例。
2874
+ *
2875
+ * 落不了点就丢,不缓存:日志按时间落盘、结果恒在它的调用之后,逆序时这条结果无家可归、块停在
2876
+ * 未完成——为它把游标之后的结果正文留在手里,就拆掉了「只留当前页」这个内存界(ADR-0026 决定 3)。
2877
+ *
2878
+ * 同一 callId 的前一块已被挤出页时,结果会配给页内后一块,后一块自己的结果随之落空:宿主重试
2879
+ * 造出的双块没有可区分的结果序,宁可配对错位也不把仍在页内的块报成「未完成」。这是非常态兜底的
2880
+ * 代价,不另开缓存去修(那会重新把游标之后的正文留在手里)。
2881
+ *
2882
+ * 回填的正文也要计入页级字符闸:超了就挤页首,被挤掉的调用块会随更早一页重新走一遍日志、
2883
+ * 在那一页里配上同一个结果,不会丢成「未完成」。
2884
+ */
2885
+ result(callId, rawResult, isError, attachments) {
2886
+ const queued = this.toolByCallId.get(callId);
2887
+ if (queued === void 0) return;
2888
+ const block = queued.find((candidate) => candidate.status === "unfinished");
2889
+ if (block === void 0) return;
2890
+ const clipped = clip(rawResult.trim(), this.cap);
2891
+ block.result = clipped.text;
2892
+ block.resultTruncated = clipped.truncated;
2893
+ block.status = isError ? "error" : "ok";
2894
+ block.attachments = attachments.slice(0, MAX_ATTACHMENTS_PER_BLOCK);
2895
+ const entry = this.buffer.find((candidate) => candidate.block === block);
2896
+ if (entry !== void 0) {
2897
+ const chars = blockChars(block);
2898
+ this.chars += chars - entry.chars;
2899
+ entry.chars = chars;
2900
+ this.trim();
2901
+ }
2902
+ }
2903
+ finish() {
2904
+ let used = 0;
2905
+ for (const { block } of this.buffer) {
2906
+ const room = Math.max(0, Math.min(block.attachments.length, MAX_ATTACHMENTS_PER_PAGE - used));
2907
+ block.attachments = block.attachments.slice(0, room);
2908
+ used += room;
2909
+ }
2910
+ const first = this.buffer[0];
2911
+ return {
2912
+ blocks: this.buffer.map(({ block }) => block).filter((block) => block.kind === "tool" || hasContent(block.text, block.attachments)),
2913
+ olderCursor: first !== void 0 && first.index > 0 ? first.index : null
2914
+ };
2915
+ }
2916
+ };
2624
2917
  function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
2625
- return async function readTrashDetail(entryId) {
2626
- const record = (await deps.rows()).find((e) => e.id === entryId);
2918
+ return async function readTrashDetail(entryId, before) {
2919
+ const all = await deps.rows();
2920
+ const record = all.find((e) => e.id === entryId);
2627
2921
  if (record === void 0) throw trashEmpty(entryId);
2628
2922
  const dir = trashDirOf(record.id, deps.home);
2629
2923
  if (!await deps.fs.exists(dir)) throw verbError("io", `回收站目录不在:${dir}`);
@@ -2648,42 +2942,16 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
2648
2942
  assistantMessageCount: 0,
2649
2943
  toolCallCount: 0,
2650
2944
  injectedCount: 0,
2651
- messages: [],
2652
- omittedByPayloadCap: 0
2945
+ blocks: [],
2946
+ olderCursor: null
2653
2947
  };
2654
2948
  const models = /* @__PURE__ */ new Set();
2655
- let usedChars = 0;
2656
- let usedAttachments = 0;
2657
- let capped = false;
2658
- /** 收进正文,顺带管两个闸:截到单条上限,累计到总量就停;附件另有 20/200 两道闸。 */
2659
- function push(role, time, text, attachments) {
2660
- if (capped) {
2661
- detail.omittedByPayloadCap += 1;
2662
- return;
2663
- }
2664
- const truncated = text.length > limits.perMessageChars;
2665
- const kept = truncated ? text.slice(0, limits.perMessageChars) : text;
2666
- if (usedChars + kept.length > limits.totalChars) {
2667
- capped = true;
2668
- detail.omittedByPayloadCap += 1;
2669
- return;
2670
- }
2671
- usedChars += kept.length;
2672
- const room = Math.max(0, Math.min(MAX_ATTACHMENTS_PER_MESSAGE, MAX_ATTACHMENTS_TOTAL - usedAttachments));
2673
- const keptAttachments = attachments.slice(0, room);
2674
- usedAttachments += keptAttachments.length;
2675
- detail.messages.push({
2676
- role,
2677
- time,
2678
- text: kept,
2679
- truncated,
2680
- attachments: keptAttachments
2681
- });
2682
- }
2949
+ const page = new PageBuilder(before ?? Number.POSITIVE_INFINITY, Math.max(1, limits.pageBlocks), limits.perBlockChars, Math.max(1, limits.pageChars ?? 5e5));
2683
2950
  try {
2684
2951
  walkSessionLog(raw, (r) => {
2685
2952
  if (typeof r.time === "number" && r.time > detail.lastActivity) detail.lastActivity = r.time;
2686
2953
  const data = r.data ?? {};
2954
+ const time = r.time ?? 0;
2687
2955
  switch (r.type) {
2688
2956
  case "session":
2689
2957
  if (typeof r.cwd === "string") detail.cwd = r.cwd;
@@ -2695,30 +2963,95 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
2695
2963
  case "turn/start":
2696
2964
  detail.turnCount += 1;
2697
2965
  return;
2698
- case "tool/call":
2699
- detail.toolCallCount += 1;
2700
- return;
2701
2966
  case "user/message": {
2702
- if ((data.source ?? {}).kind !== "user") {
2703
- detail.injectedCount += 1;
2704
- return;
2705
- }
2967
+ const source = data.source ?? {};
2968
+ const sourceKind = typeof source.kind === "string" ? source.kind : "";
2706
2969
  const text = textOf(data.content);
2707
2970
  const attachments = attachmentWires(data.content);
2708
- if (text === "" && attachments.length === 0) return;
2709
- detail.userMessageCount += 1;
2710
- push("user", r.time ?? 0, text, attachments);
2971
+ if (sourceKind === "user") {
2972
+ const block = textBlock("user", time, text, attachments, limits.perBlockChars);
2973
+ if (block !== void 0) {
2974
+ detail.userMessageCount += 1;
2975
+ page.add(block);
2976
+ }
2977
+ return;
2978
+ }
2979
+ const block = injectionBlock(time, sourceKind, text, attachments, limits.perBlockChars);
2980
+ if (block !== void 0) {
2981
+ detail.injectedCount += 1;
2982
+ page.add(block);
2983
+ }
2711
2984
  return;
2712
2985
  }
2713
2986
  case "assistant/message": {
2987
+ detail.assistantMessageCount += 1;
2714
2988
  const message = data.message ?? {};
2715
2989
  const source = message.source ?? {};
2716
2990
  if (typeof source.model === "string") models.add(source.model);
2717
- const text = textOf(message.content);
2718
- const attachments = attachmentWires(message.content);
2719
- if (text === "" && attachments.length === 0) return;
2720
- detail.assistantMessageCount += 1;
2721
- push("assistant", r.time ?? 0, text, attachments);
2991
+ const parts = Array.isArray(message.content) ? message.content : [];
2992
+ let pendingText = [];
2993
+ let pendingAttachments = [];
2994
+ const flush = () => {
2995
+ const block = textBlock("assistant", time, pendingText.join("\n"), pendingAttachments, limits.perBlockChars);
2996
+ if (block !== void 0) page.add(block);
2997
+ pendingText = [];
2998
+ pendingAttachments = [];
2999
+ };
3000
+ for (const part of parts) {
3001
+ if (part?.type === "text" && typeof part.text === "string") {
3002
+ pendingText.push(part.text);
3003
+ continue;
3004
+ }
3005
+ if (part?.type === "reasoning" && typeof part.text === "string") {
3006
+ flush();
3007
+ const block = textBlock("reasoning", time, part.text, [], limits.perBlockChars);
3008
+ if (block !== void 0) page.add(block);
3009
+ continue;
3010
+ }
3011
+ if (part?.type === "image" || part?.type === "file") {
3012
+ pendingAttachments.push(...attachmentWires([part]));
3013
+ continue;
3014
+ }
3015
+ }
3016
+ flush();
3017
+ return;
3018
+ }
3019
+ case "tool/call": {
3020
+ detail.toolCallCount += 1;
3021
+ const callId = typeof data.callId === "string" ? data.callId : "";
3022
+ const name = typeof data.name === "string" ? data.name : "";
3023
+ const args = clip(typeof data.arguments === "string" ? data.arguments : "", limits.perBlockChars);
3024
+ page.add({
3025
+ kind: "tool",
3026
+ id: "",
3027
+ time,
3028
+ callId,
3029
+ name,
3030
+ status: "unfinished",
3031
+ args: args.text,
3032
+ argsTruncated: args.truncated,
3033
+ result: "",
3034
+ resultTruncated: false,
3035
+ attachments: []
3036
+ });
3037
+ return;
3038
+ }
3039
+ case "tool/result": {
3040
+ const message = data.message ?? {};
3041
+ const callId = typeof message.toolCallId === "string" ? message.toolCallId : "";
3042
+ if (callId === "") return;
3043
+ page.result(callId, textOf(message.content), message.isError === true, attachmentWires(message.content));
3044
+ return;
3045
+ }
3046
+ case "system/message": {
3047
+ const message = data.message ?? {};
3048
+ const block = textBlock("system", time, textOf(message.content), attachmentWires(message.content), limits.perBlockChars);
3049
+ if (block !== void 0) page.add(block);
3050
+ return;
3051
+ }
3052
+ case "compaction/summary": {
3053
+ const block = textBlock("compaction", time, textOf(data.summary), [], limits.perBlockChars);
3054
+ if (block !== void 0) page.add(block);
2722
3055
  return;
2723
3056
  }
2724
3057
  default: return;
@@ -2727,13 +3060,56 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
2727
3060
  } catch (error) {
2728
3061
  throw verbError("io", error instanceof Error ? `读回收站日志失败:${error.message}` : `读回收站日志失败:${String(error)}`, error);
2729
3062
  }
3063
+ const finished = page.finish();
3064
+ detail.blocks = finished.blocks;
3065
+ detail.olderCursor = finished.olderCursor;
2730
3066
  detail.models = [...models];
3067
+ const kids = visibleGroups(all).find((group) => group.root.id === record.id)?.kids ?? [];
3068
+ if (kids.length > 0) {
3069
+ const limit = limits.subagents ?? 200;
3070
+ detail.subagents = kids.slice(0, limit).map(entryWire);
3071
+ if (kids.length > limit) detail.subagentsTotal = kids.length;
3072
+ }
2731
3073
  return detail;
2732
3074
  };
2733
3075
  }
2734
3076
  //#endregion
3077
+ //#region src/ports/attachments-port.ts
3078
+ /** 服务在 `available()` 之后消失/形状变了:调用方可以退回直接 fs,而不是把这次读取判成 io。 */
3079
+ var AttachmentsUnavailableError = class extends Error {
3080
+ constructor(message = "attachments service absent") {
3081
+ super(message);
3082
+ this.name = "AttachmentsUnavailableError";
3083
+ }
3084
+ };
3085
+ /** 每次调用惰性解析服务:插件与 `attachments` 的激活顺序没有承诺。 */
3086
+ function createAttachmentsPort(ctx) {
3087
+ const serviceOf = () => {
3088
+ try {
3089
+ const service = ctx.get("attachments");
3090
+ if (service === void 0 || service === null) return void 0;
3091
+ if (typeof service.readImage !== "function" || typeof service.readFileStream !== "function") return void 0;
3092
+ return service;
3093
+ } catch {
3094
+ return;
3095
+ }
3096
+ };
3097
+ return {
3098
+ available: () => serviceOf() !== void 0,
3099
+ async readImage(ref) {
3100
+ const service = serviceOf();
3101
+ if (service === void 0) throw new AttachmentsUnavailableError();
3102
+ return await service.readImage(ref);
3103
+ },
3104
+ async *readFileStream(ref) {
3105
+ const service = serviceOf();
3106
+ if (service === void 0) throw new AttachmentsUnavailableError();
3107
+ yield* service.readFileStream(ref);
3108
+ }
3109
+ };
3110
+ }
3111
+ //#endregion
2735
3112
  //#region src/api/attachment.ts
2736
- const MAX_ATTACHMENT_BYTES = 20971520;
2737
3113
  const PNG_MAGIC = Buffer.from([
2738
3114
  137,
2739
3115
  80,
@@ -2744,6 +3120,21 @@ const PNG_MAGIC = Buffer.from([
2744
3120
  26,
2745
3121
  10
2746
3122
  ]);
3123
+ const IMAGE_MEDIA_TYPES = [
3124
+ "image/png",
3125
+ "image/jpeg",
3126
+ "image/webp",
3127
+ "image/gif"
3128
+ ];
3129
+ function isImageMediaType(value) {
3130
+ return value !== null && IMAGE_MEDIA_TYPES.includes(value);
3131
+ }
3132
+ /** 引用里的正整数字段(bytes / width / height);缺、空、非整数、非正数都当"元数据不够",退回直接 fs。 */
3133
+ function positiveInt(value) {
3134
+ if (value === null || value === "") return void 0;
3135
+ const parsed = Number(value);
3136
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
3137
+ }
2747
3138
  /** 按魔数认图片类型;认不出回 `undefined`(调用方按未知字节处理,不猜)。 */
2748
3139
  function sniffImage(bytes) {
2749
3140
  if (bytes.length >= PNG_MAGIC.length && bytes.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) return "image/png";
@@ -2758,62 +3149,185 @@ function sniffImage(bytes) {
2758
3149
  * 否则 `encodeURIComponent` 会在写头时抛)。
2759
3150
  */
2760
3151
  function safeFilename(name, fallback) {
2761
- const base = [...name === null || name === "" ? fallback : name].slice(0, 120).join("").replace(/[\\/:*?"<>|\u0000-\u001f\uD800-\uDFFF]/gu, "_").replace(/\.\./gu, "_");
3152
+ const raw = name === null || name === void 0 || name === "" ? fallback : name;
3153
+ const base = Array.from(raw).slice(0, 120).join("").replace(/[\\/:*?"<>|\u0000-\u001f\uD800-\uDFFF]/gu, "_").replace(/\.\./gu, "_");
2762
3154
  return base === "" ? fallback : base;
2763
3155
  }
2764
- async function readAttachmentResponse(deps, query) {
3156
+ /** 引用里的短 id:错误文案与下载名兜底共用,别让 `sha256:` 前缀的切片散在各处。 */
3157
+ function shortId(id) {
3158
+ return id.slice(7, 19);
3159
+ }
3160
+ /** 普通文件的下载响应;图片认不出类型时也走这一支。 */
3161
+ function downloadResponse(body, filename) {
3162
+ return {
3163
+ body,
3164
+ contentType: "application/octet-stream",
3165
+ disposition: "attachment",
3166
+ filename
3167
+ };
3168
+ }
3169
+ /** 服务读取/校验失败按 io 上屏,不 fallback 直接 fs:宿主读 `files/.../<name>` 别名并做摘要/元数据
3170
+ * 校验,直接 fs 读 `file-objects/...` 对象;绕过会跳过宿主的校验(ADR-0035 边界)。 */
3171
+ function attachmentsIoError(id, error) {
3172
+ throw verbError("io", `读取附件失败:${id}(${String(error)})`, error);
3173
+ }
3174
+ /** 同一道上限:声明字节、磁盘字节、服务返回字节都从这里过。 */
3175
+ function assertWithinLimit(id, observed, maxBytes) {
3176
+ if (observed > maxBytes) throw verbError("io", `附件超过 ${maxBytes} 字节上限:${id}(${observed} 字节)`);
3177
+ }
3178
+ /** 流式读超过上限:没有"实际总字节",原文用 `>上限`。 */
3179
+ function streamOverflow(id, maxBytes) {
3180
+ throw verbError("io", `附件超过 ${maxBytes} 字节上限:${id}(>${maxBytes} 字节)`);
3181
+ }
3182
+ /** 服务路径要的引用元数据;任一字段不够就回 `undefined`(调用方退回直接 fs,不拿半截 ref 去猜)。 */
3183
+ function imageServiceRef(id, query) {
3184
+ const mediaType = query.get(PARAMS.attachmentMediaType);
3185
+ const bytes = positiveInt(query.get(PARAMS.attachmentBytes));
3186
+ const width = positiveInt(query.get(PARAMS.attachmentWidth));
3187
+ const height = positiveInt(query.get(PARAMS.attachmentHeight));
3188
+ if (!isImageMediaType(mediaType) || bytes === void 0 || width === void 0 || height === void 0) return void 0;
3189
+ const name = query.get(PARAMS.attachmentName);
3190
+ return {
3191
+ attachmentId: id,
3192
+ mediaType,
3193
+ bytes,
3194
+ width,
3195
+ height,
3196
+ ...name === null || name === "" ? {} : { name }
3197
+ };
3198
+ }
3199
+ function fileServiceRef(id, query) {
3200
+ const bytes = positiveInt(query.get(PARAMS.attachmentBytes));
3201
+ const name = query.get(PARAMS.attachmentName);
3202
+ if (bytes === void 0 || name === null || name === "") return void 0;
3203
+ return {
3204
+ attachmentId: id,
3205
+ name,
3206
+ bytes
3207
+ };
3208
+ }
3209
+ function parseServiceRef(id, kind, query) {
3210
+ if (kind === "image") {
3211
+ const ref = imageServiceRef(id, query);
3212
+ return ref === void 0 ? void 0 : {
3213
+ kind: "image",
3214
+ ref
3215
+ };
3216
+ }
3217
+ const ref = fileServiceRef(id, query);
3218
+ return ref === void 0 ? void 0 : {
3219
+ kind: "file",
3220
+ ref
3221
+ };
3222
+ }
3223
+ /** query 只解析一次:id/kind 的 400 闸与两套引用元数据都在这里收口。 */
3224
+ function parseAttachmentRequest(query) {
2765
3225
  const id = query.get(PARAMS.attachmentId);
2766
3226
  const kind = query.get(PARAMS.attachmentKind);
2767
3227
  if (!isAttachmentId(id)) throw routeError("bad-request", 400, `附件 id 需为 sha256:<64 位小写 hex>:${String(id)}`);
2768
3228
  if (kind !== "image" && kind !== "file") throw routeError("bad-request", 400, `附件 kind 只认 image / file:${String(kind)}`);
2769
- const path = attachmentPathOf(deps.home, kind, id);
3229
+ const name = query.get(PARAMS.attachmentName);
3230
+ return {
3231
+ id,
3232
+ kind,
3233
+ name: name === null || name === "" ? void 0 : name,
3234
+ serviceRef: parseServiceRef(id, kind, query)
3235
+ };
3236
+ }
3237
+ async function readImageViaAttachments(deps, ref, maxBytes) {
3238
+ assertWithinLimit(ref.attachmentId, ref.bytes, maxBytes);
3239
+ let stored;
3240
+ try {
3241
+ stored = await deps.attachments.readImage(ref);
3242
+ } catch (error) {
3243
+ if (error instanceof AttachmentsUnavailableError) return void 0;
3244
+ attachmentsIoError(ref.attachmentId, error);
3245
+ }
3246
+ const body = Buffer.from(stored.data);
3247
+ assertWithinLimit(ref.attachmentId, body.length, maxBytes);
3248
+ return {
3249
+ body,
3250
+ contentType: stored.ref.mediaType,
3251
+ disposition: "inline"
3252
+ };
3253
+ }
3254
+ /** 普通文件走宿主服务的分块读;超限在累计处停下,不把整份字节读进内存。 */
3255
+ async function readFileViaAttachments(deps, ref, maxBytes) {
3256
+ assertWithinLimit(ref.attachmentId, ref.bytes, maxBytes);
3257
+ const chunks = [];
3258
+ let total = 0;
3259
+ let tooLarge = false;
3260
+ try {
3261
+ for await (const chunk of deps.attachments.readFileStream(ref)) {
3262
+ const buffer = Buffer.from(chunk);
3263
+ total += buffer.length;
3264
+ if (total > maxBytes) {
3265
+ tooLarge = true;
3266
+ break;
3267
+ }
3268
+ chunks.push(buffer);
3269
+ }
3270
+ } catch (error) {
3271
+ if (error instanceof AttachmentsUnavailableError) return void 0;
3272
+ attachmentsIoError(ref.attachmentId, error);
3273
+ }
3274
+ if (tooLarge) streamOverflow(ref.attachmentId, maxBytes);
3275
+ return downloadResponse(Buffer.concat(chunks), safeFilename(ref.name, shortId(ref.attachmentId)));
3276
+ }
3277
+ /** 服务优先的唯一入口;`undefined` = 服务缺席 / 元数据不够 / 服务中途消失,调用方退回直接 fs。 */
3278
+ async function readViaAttachments(deps, request, maxBytes) {
3279
+ if (request.serviceRef === void 0) return void 0;
3280
+ if (!deps.attachments.available()) return void 0;
3281
+ const serviceRef = request.serviceRef;
3282
+ return serviceRef.kind === "image" ? readImageViaAttachments(deps, serviceRef.ref, maxBytes) : readFileViaAttachments(deps, serviceRef.ref, maxBytes);
3283
+ }
3284
+ /** 旧宿主 / 元数据不够:退回直接 fs(路径仍只由 id 与 kind 推导)。 */
3285
+ async function readViaFs(deps, request, maxBytes) {
3286
+ const path = attachmentPathOf(deps.home, request.kind, request.id);
2770
3287
  let size;
2771
3288
  try {
2772
3289
  size = (await deps.fs.statFile(path)).size;
2773
3290
  } catch (error) {
2774
- throw verbError("io", `附件不在:${id}(${String(error)})`, error);
3291
+ throw verbError("io", `附件不在:${request.id}(${String(error)})`, error);
2775
3292
  }
2776
- if (size > MAX_ATTACHMENT_BYTES) throw verbError("io", `附件超过 ${MAX_ATTACHMENT_BYTES} 字节上限:${id}(${size} 字节)`);
3293
+ assertWithinLimit(request.id, size, maxBytes);
2777
3294
  const bytes = await deps.fs.readFile(path);
2778
- if (bytes.length > MAX_ATTACHMENT_BYTES) throw verbError("io", `附件超过 ${MAX_ATTACHMENT_BYTES} 字节上限:${id}(${bytes.length} 字节)`);
2779
- if (kind === "file") return {
2780
- body: bytes,
2781
- contentType: "application/octet-stream",
2782
- disposition: "attachment",
2783
- filename: safeFilename(query.get(PARAMS.attachmentName), id.slice(7, 19))
2784
- };
3295
+ assertWithinLimit(request.id, bytes.length, maxBytes);
3296
+ if (request.kind === "file") return downloadResponse(bytes, safeFilename(request.name, shortId(request.id)));
2785
3297
  const contentType = sniffImage(bytes);
2786
- if (contentType === void 0) return {
2787
- body: bytes,
2788
- contentType: "application/octet-stream",
2789
- disposition: "attachment",
2790
- filename: safeFilename(query.get(PARAMS.attachmentName), `${id.slice(7, 19)}.bin`)
2791
- };
3298
+ if (contentType === void 0) return downloadResponse(bytes, safeFilename(request.name, `${shortId(request.id)}.bin`));
2792
3299
  return {
2793
3300
  body: bytes,
2794
3301
  contentType,
2795
3302
  disposition: "inline"
2796
3303
  };
2797
3304
  }
3305
+ async function readAttachmentResponse(deps, query) {
3306
+ const request = parseAttachmentRequest(query);
3307
+ const maxBytes = deps.config.maxAttachmentBytes;
3308
+ const served = await readViaAttachments(deps, request, maxBytes);
3309
+ if (served !== void 0) return served;
3310
+ return readViaFs(deps, request, maxBytes);
3311
+ }
2798
3312
  //#endregion
2799
3313
  //#region src/api/attachment-orphans.ts
2800
3314
  /**
2801
3315
  * 孤儿附件的报告段:`GET /attachment-orphans`。
2802
3316
  *
2803
3317
  * 报告把扫描计划折成数量/字节 + 对象清单(`id`/`bytes`,供设置页预览缩略图;最多
2804
- * `ORPHAN_PREVIEW_LIMIT` 条,清理不受预览条数限制)。扫描跳过(引用源或对象列表读不出)时
2805
- * **报告必须失败**——回一份 0 会让界面说"没有孤儿",而这类失败恰恰是"不知道",不是"没有"。
3318
+ * `src/config.ts` 的 `orphanPreviewLimit`(默认 200,ADR-0037),清理不受预览条数限制)。
3319
+ * 扫描跳过(引用源或对象列表读不出)时**报告必须失败**——回一份 0 会让界面说"没有孤儿",
3320
+ * 而这类失败恰恰是"不知道",不是"没有"。
2806
3321
  * 清理是 `POST /attachment-clean`,由路由直接接到 `cleanOrphanImageAttachments`。
2807
3322
  */
2808
- /** 预览清单的上限:与详情附件的载荷闸同一量级。数量与字节仍按全量报告。 */
2809
- const ORPHAN_PREVIEW_LIMIT = 200;
2810
3323
  async function reportAttachmentOrphans(deps) {
2811
3324
  const plan = await scanOrphanImageAttachments(deps);
2812
3325
  if (plan.skipped !== void 0) throw verbError("io", plan.skipped);
3326
+ const limit = deps.config.orphanPreviewLimit;
2813
3327
  return {
2814
3328
  count: plan.entries.length,
2815
3329
  totalBytes: plan.bytes,
2816
- items: plan.entries.slice(0, ORPHAN_PREVIEW_LIMIT).map((entry) => ({
3330
+ items: plan.entries.slice(0, limit).map((entry) => ({
2817
3331
  attachmentId: entry.id,
2818
3332
  bytes: entry.bytes
2819
3333
  }))
@@ -2822,21 +3336,13 @@ async function reportAttachmentOrphans(deps) {
2822
3336
  //#endregion
2823
3337
  //#region src/api/trash-rows.ts
2824
3338
  /**
2825
- * 清册那一行 → 线上那一行。**显式字面量、不用 `...row`**:线上形状多一个必填字段时,这里编译不过,
2826
- * 于是"线上多了一格却没人填"不可能悄悄发生(`...row` 会把它带过去,看上去一切正常)。
3339
+ * 清册那一行 → 线上那一行。清册字段走 `entryWire` 那份显式字面量(线上形状多一个必填字段时编译不过,
3340
+ * `...row` 会把它带过去),这里只补 `/list` 的只读数。
2827
3341
  */
2828
3342
  function toWire(row, readouts) {
2829
3343
  return {
2830
- id: row.id,
2831
- sessionId: row.sessionId,
2832
- projectDir: row.projectDir,
2833
- workspaceId: row.workspaceId,
2834
- wasArchived: row.wasArchived,
2835
- deletedAt: row.deletedAt,
2836
- title: row.title,
2837
- sizeBytes: row.sizeBytes,
3344
+ ...entryWire(row),
2838
3345
  releaseBytes: readouts.releaseBytes,
2839
- ...row.parentEntryId === void 0 ? {} : { parentEntryId: row.parentEntryId },
2840
3346
  ...readouts.subagentCount === void 0 ? {} : { subagentCount: readouts.subagentCount },
2841
3347
  ...readouts.subagentBytes === void 0 ? {} : { subagentBytes: readouts.subagentBytes },
2842
3348
  ...readouts.strandedSubagents === void 0 ? {} : { strandedSubagents: readouts.strandedSubagents },
@@ -2873,15 +3379,17 @@ async function assembleTrashRows(deps, rows) {
2873
3379
  //#endregion
2874
3380
  //#region src/api/route-table.ts
2875
3381
  /**
2876
- * 路由表(ADR-0004):段名 → { 方法, 纯度, 读面, body, 处理 }。一条记录,两个消费者。
3382
+ * 路由表(ADR-0004;deps 切片见 ADR-0032):段名 → { 方法, 纯度, 读面, body, deps 切片, 处理 }。一条记录,两个消费者。
2877
3383
  *
2878
3384
  * 一致性要求:
2879
3385
  * - 该纯的判成写 ⇒ 探测/正文读/扫描被堵在串行链后;
2880
3386
  * - 该写的判成纯 ⇒ 写动作绕开到达序链,与对账交错;
2881
- * - body 该读没读 ⇒ 下游拿到 null;读两遍 ⇒ 流已被消费。
3387
+ * - body 该读没读 ⇒ 下游拿到 null;读两遍 ⇒ 流已被消费;
3388
+ * - 切片声明宽了 ⇒ 危险能力(删目录 / 时钟)漏进读段的 run;窄了 ⇒ 写段的处理编译不过。
2882
3389
  *
2883
3390
  * 这张表不放 shared/wire.ts:处理函数会 import 动词与 node:fs,而浏览器半要 import wire.ts。
2884
- * 段名与动作仍住 wire.ts;这张表只回答“怎么处理”。
3391
+ * 段名、方法与动作仍住 wire.ts(`ROUTES` 每项 `{ path, method }`,本表的 method 只投影、不写第二份字面量);
3392
+ * 这张表只回答“怎么处理”。
2885
3393
  */
2886
3394
  /** 必填的非空字符串字段(缺、空串、类型不对都是**同一句话**:说清是哪个字段)。 */
2887
3395
  function requireString(body, field) {
@@ -2889,9 +3397,17 @@ function requireString(body, field) {
2889
3397
  if (typeof value !== "string" || value === "") throw routeError("bad-request", 400, `请求体需带非空字符串字段 ${field}`);
2890
3398
  return value;
2891
3399
  }
3400
+ /** `/detail` 的分页游标:缺省 = 从尾部取;给了就必须是非负整数(负数、小数、字符串都算请求不对)。 */
3401
+ function optionalCursor(body) {
3402
+ const value = body?.[PARAMS.before];
3403
+ if (value === void 0) return void 0;
3404
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw routeError("bad-request", 400, `请求体字段 ${PARAMS.before} 需是非负整数`);
3405
+ return value;
3406
+ }
2892
3407
  /**
2893
- * `/apply` 的五个动作与各自的**静态纯度**。一张表两个用途:动作名单由键派生,纯度也从同一格读——
2894
- * 新增动作必须同时给出纯度,漏了编译不过;新加一个纯读动作也不会再静默落进 `chained`。
3408
+ * `/apply` 的五个动作与各自的**静态纯度**。一张表三个用途:动作名单由键派生,纯度从同一格读,
3409
+ * 唯一识别器(`actionOf`)也只认这些键——新增动作必须同时给出纯度,漏了编译不过;
3410
+ * 新加一个纯读动作也不会再静默落进 `chained`。
2895
3411
  */
2896
3412
  const APPLY_ACTION_TABLE = {
2897
3413
  delete: "chained",
@@ -2901,15 +3417,21 @@ const APPLY_ACTION_TABLE = {
2901
3417
  "orphan-clean": "chained"
2902
3418
  };
2903
3419
  const APPLY_ACTIONS = Object.keys(APPLY_ACTION_TABLE);
2904
- /** 认不认得这个动作:`/apply` 的纯度与拒否共用同一条判据,名单只有上面那一张。 */
2905
- function isApplyAction(value) {
2906
- return typeof value === "string" && Object.hasOwn(APPLY_ACTION_TABLE, value);
3420
+ /**
3421
+ * `/apply` 的动作:**全仓唯一识别器**——纯度函数与动作拒否都从这里过,
3422
+ * 不各解析一遍动作。不抛:缺动作 / 不是字符串 / 不在名单,一律回 `undefined`;
3423
+ * 抛不抛、抛哪句话归 `requireAction`。
3424
+ */
3425
+ function actionOf(body) {
3426
+ const value = body?.[PARAMS.action];
3427
+ if (typeof value !== "string" || !Object.hasOwn(APPLY_ACTION_TABLE, value)) return void 0;
3428
+ return value;
2907
3429
  }
2908
3430
  /** `/apply` 的动作:认不得的一律点名拒掉,不折成"空批量"糊弄过去。 */
2909
3431
  function requireAction(body) {
2910
- const value = body?.[PARAMS.action];
2911
- if (!isApplyAction(value)) throw routeError("bad-request", 400, `批量动作不认识:需是 ${APPLY_ACTIONS.join(" / ")} 之一`);
2912
- return value;
3432
+ const action = actionOf(body);
3433
+ if (action === void 0) throw routeError("bad-request", 400, `批量动作不认识:需是 ${APPLY_ACTIONS.join(" / ")} 之一`);
3434
+ return action;
2913
3435
  }
2914
3436
  /** `/apply` 的 id 组:必须是非空字符串数组(空数组不是"什么都不做",是调用方自己没组好)。 */
2915
3437
  function requireIdArray(body) {
@@ -2918,81 +3440,91 @@ function requireIdArray(body) {
2918
3440
  return value;
2919
3441
  }
2920
3442
  /**
2921
- * 九个段名各一行。`Record<段名, RouteSpec>` 是闸:`wire.ts` 加了段名而这里没加,编译不过。
3443
+ * 九个段名各一行。`Record<段名, AnyRouteSpec>` 是闸:`wire.ts` 加了段名而这里没加,编译不过;
3444
+ * 每行的 `slice` 又把该行的 `run` 钉在自己那一格 deps 上(ADR-0032)。
2922
3445
  */
2923
3446
  const ROUTE_TABLE = {
2924
- [ROUTES.list]: {
2925
- method: "GET",
3447
+ [ROUTES.list.path]: {
3448
+ slice: "core",
3449
+ method: ROUTES.list.method,
2926
3450
  body: "none",
2927
3451
  purity: "chained",
2928
3452
  read: "list",
2929
3453
  response: "json",
2930
3454
  run: async (deps, _body, read) => assembleTrashRows(deps, await read.rows())
2931
3455
  },
2932
- [ROUTES.ping]: {
2933
- method: "GET",
3456
+ [ROUTES.ping.path]: {
3457
+ slice: "core",
3458
+ method: ROUTES.ping.method,
2934
3459
  body: "none",
2935
3460
  purity: "pure",
2936
3461
  read: "none",
2937
3462
  response: "json",
2938
3463
  run: async () => "pong"
2939
3464
  },
2940
- [ROUTES.attachment]: {
2941
- method: "GET",
3465
+ [ROUTES.attachment.path]: {
3466
+ slice: "attachment",
3467
+ method: ROUTES.attachment.method,
2942
3468
  body: "none",
2943
3469
  purity: "pure",
2944
3470
  read: "none",
2945
3471
  response: "binary",
2946
- run: async (deps, _body, _read, query) => readAttachmentResponse(deps, query)
3472
+ fetchGate: "same-origin",
3473
+ run: async (deps, query) => readAttachmentResponse(deps, query)
2947
3474
  },
2948
- [ROUTES.attachmentOrphans]: {
2949
- method: "GET",
3475
+ [ROUTES.attachmentOrphans.path]: {
3476
+ slice: "core",
3477
+ method: ROUTES.attachmentOrphans.method,
2950
3478
  body: "none",
2951
3479
  purity: "pure",
2952
3480
  read: "none",
2953
3481
  response: "json",
2954
3482
  run: async (deps) => reportAttachmentOrphans(deps)
2955
3483
  },
2956
- [ROUTES.attachmentClean]: {
2957
- method: "POST",
3484
+ [ROUTES.attachmentClean.path]: {
3485
+ slice: "removal",
3486
+ method: ROUTES.attachmentClean.method,
2958
3487
  body: "none",
2959
3488
  purity: "chained",
2960
3489
  read: "none",
2961
3490
  response: "json",
2962
3491
  run: async (deps) => cleanOrphanImageAttachments(deps)
2963
3492
  },
2964
- [ROUTES.orphans]: {
2965
- method: "GET",
3493
+ [ROUTES.orphans.path]: {
3494
+ slice: "core",
3495
+ method: ROUTES.orphans.method,
2966
3496
  body: "none",
2967
3497
  purity: "pure",
2968
3498
  read: "raw",
2969
3499
  response: "json",
2970
3500
  run: async (deps, _body, read) => reportOrphans(await loadOrphanGroups(deps, await read.rows()))
2971
3501
  },
2972
- [ROUTES.apply]: {
2973
- method: "POST",
3502
+ [ROUTES.apply.path]: {
3503
+ slice: "full",
3504
+ method: ROUTES.apply.method,
2974
3505
  body: "json",
2975
3506
  purity: (body) => {
2976
- const action = body?.[PARAMS.action];
2977
- return isApplyAction(action) ? APPLY_ACTION_TABLE[action] : "chained";
3507
+ const action = actionOf(body);
3508
+ return action === void 0 ? "chained" : APPLY_ACTION_TABLE[action];
2978
3509
  },
2979
3510
  read: "none",
2980
3511
  response: "json",
2981
3512
  run: async (deps, body) => applyActions(deps, requireAction(body), requireIdArray(body))
2982
3513
  },
2983
- [ROUTES.empty]: {
2984
- method: "POST",
3514
+ [ROUTES.empty.path]: {
3515
+ slice: "removal",
3516
+ method: ROUTES.empty.method,
2985
3517
  body: "none",
2986
3518
  purity: "chained",
2987
3519
  read: "list",
2988
3520
  response: "json",
2989
3521
  run: async (deps, _body, read) => {
2990
- const rows = await read.rows();
2991
- return await purgeTrash(deps)(void 0, rows);
3522
+ return await createTrashScope(deps, await read.rows()).purgeAll();
2992
3523
  }
2993
3524
  },
2994
- [ROUTES.detail]: {
2995
- method: "POST",
3525
+ [ROUTES.detail.path]: {
3526
+ slice: "core",
3527
+ method: ROUTES.detail.method,
2996
3528
  body: "json",
2997
3529
  purity: "pure",
2998
3530
  read: "raw",
@@ -3001,7 +3533,7 @@ const ROUTE_TABLE = {
3001
3533
  fs: deps.fs,
3002
3534
  rows: () => read.rows(),
3003
3535
  home: deps.home
3004
- })(requireString(body, PARAMS.entryId))
3536
+ })(requireString(body, PARAMS.entryId), optionalCursor(body))
3005
3537
  }
3006
3538
  };
3007
3539
  /** 段名 → 那一行;不认识的段名回 `undefined`(调用方抛 404,并带出原路径)。 */
@@ -3010,92 +3542,91 @@ function routeOf(rest) {
3010
3542
  }
3011
3543
  //#endregion
3012
3544
  //#region src/api/dispatch.ts
3013
- /** 请求体字节上限:这条通道只收几个 id,1MiB 是"够用且不给自己开一条内存放大器"的量级。 */
3014
- const JSON_BODY_MAX_BYTES = 1048576;
3015
- function writeJson(res, status, body) {
3016
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
3017
- res.end(JSON.stringify(body));
3018
- }
3019
- function writeOk(res, value) {
3020
- writeJson(res, 200, {
3021
- ok: true,
3022
- value
3023
- });
3024
- }
3025
3545
  /**
3026
- * 二进制响应(`/attachment` 专用)。四道头都是判据不是口味:
3027
- * - `content-type` 由处理函数给(图片按魔数认,文件一律 octet-stream);
3028
- * - `content-length` 与字节数同源;
3029
- * - `cache-control: private, no-store`:附件可能来自已删除会话,不许被任何中间层留下;
3030
- * - `nosniff` + `attachment` 下载名:字节内容与类型不一致时浏览器不许改猜。
3546
+ * /dsh-session-delete-api 的分发核心:只负责路由记录的执行。
3547
+ *
3548
+ * 传输适配器只有 `src/api/connection-route.ts` 一个:九段注册到宿主 `connection.fetch.register`
3549
+ * 的 exact Fetch 路由(`Request` / `Response`),Host/Origin/浏览器认证由宿主 `/api` bridge 完成
3550
+ * (ADR-0033)。`Request` 的字节闸 / JSON 解析、`Response` 的头与失败信封都归适配器;
3551
+ * 这里只收已经解析好的请求,做查表 → 按段注入窄面 → 跑处理 → 回结果。
3031
3552
  *
3032
- * **失败不走这里**:处理函数抛出的错误仍由 `writeFailure` 写 JSON 信封(图片的 `onError` 会换成
3033
- * 占位;普通文件走 `<a download>`,没有页面内失败面)。
3553
+ * 纯度、body 声明、读面与方法仍住 `src/api/route-table.ts` 与 `src/shared/wire.ts`;这里只按判据执行。
3554
+ *
3555
+ * - 请求本身不对用 4xx(400 请求体 / 405 方法 / 404 未知路由);操作失败回 200 + {ok:false,error}。
3556
+ * 浏览器半先解信封,再看状态。宿主 exact route 已在 handler 之前把未声明方法 / 路径回 404,
3557
+ * 所以 405 / 未知路由 404 只在适配器直调时可达(`tests/connection-route.test.ts` 钉防御分支)。
3558
+ * - `purity` 为 chained 的请求串行执行,避免读清册/动文件/写清册交错;整个进程共用一条到达序链。
3034
3559
  */
3035
- function writeBinary(res, body) {
3036
- const headers = {
3037
- "content-type": body.contentType,
3038
- "content-length": String(body.body.length),
3039
- "cache-control": "private, no-store",
3040
- "x-content-type-options": "nosniff"
3041
- };
3042
- if (body.filename !== void 0) headers["content-disposition"] = `${body.disposition}; filename*=UTF-8''${encodeURIComponent(body.filename)}`;
3043
- else if (body.disposition === "attachment") headers["content-disposition"] = "attachment";
3044
- res.writeHead(200, headers);
3045
- res.end(body.body);
3046
- }
3047
- /** 失败信封:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表)。
3048
- * **状态语义**:**请求本身不对**不再伪装成 200——请求体不对 400、方法不对 405、
3049
- * 未知路由 404、栅栏拒 403(它们不是"一次办成的操作",是"这条请求不归我办/你没资格办")。
3050
- * **操作失败**仍回 200 + 信封。浏览器半先解信封再看状态,所以 4xx 上带的信封照样读得出原文;
3051
- * `code` 一律折成 `sessiondelete/io`,那三个路由标签(`bad-request` / `bad-method` / `not-found-route`)
3052
- * 是**抛出处给读代码的人看的站点名**,不许冒充 `src/shared/wire.ts` 的五个动词码上客户端。 */
3053
- function writeFailure(res, error, status) {
3054
- const carried = error?.httpStatus;
3055
- writeJson(res, status ?? (typeof carried === "number" ? carried : 200), {
3056
- ok: false,
3057
- error: toRemoteFailure(error)
3058
- });
3059
- }
3060
- /** 栅栏拒绝的两种原文:归属不对(跨源 / 重绑 / 头不成形)与远程客户端没带宿主身份。
3061
- * 两者都是 403,但客户端会上屏,原文要能告诉用户下一步做什么。 */
3062
- const UNTRUSTED_MESSAGE = "非受信来源";
3063
- const UNAUTHENTICATED_MESSAGE = "需要宿主的 Web 身份:请用 dsh web 打印的那一行 URL 打开页面后重试";
3064
- /** 流式读 body 并解析成 JSON 对象:空体回 `null`,超限 / 非法 JSON / 不是对象一律抛(→ 400 + 失败信封)。
3065
- * **请求本身不对**(400)与**那次操作读不到东西**(200 + 信封)是两件事:前者是调用方没组好请求,
3066
- * 后者是盘上没有;把前者也写成 200,代理与日志里就再也分不出这两类。 */
3067
- async function readJsonBody(req) {
3068
- const chunks = [];
3069
- let size = 0;
3070
- for await (const chunk of req) {
3071
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3072
- size += buf.length;
3073
- if (size > JSON_BODY_MAX_BYTES) throw routeError("bad-request", 400, `请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
3074
- chunks.push(buf);
3075
- }
3076
- const text = Buffer.concat(chunks).toString("utf8").trim();
3077
- if (text === "") return null;
3078
- try {
3079
- const parsed = JSON.parse(text);
3080
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw routeError("bad-request", 400, "请求体需为 JSON 对象");
3081
- return parsed;
3082
- } catch (error) {
3083
- if (error instanceof SyntaxError) throw routeError("bad-request", 400, `请求体不是合法 JSON:${error.message}`);
3084
- throw error;
3085
- }
3086
- }
3087
3560
  function requireMethod(method, want, route) {
3088
3561
  if (method !== want) throw routeError("bad-method", 405, `方法不允许:${method}(${SESSION_DELETE_API_PREFIX}${route} 需 ${want})`);
3089
3562
  }
3563
+ /** 段名的 404 与方法的 405 只在这里判一次;空段名按插件前缀显示,避免 404 原文出现一个空串。 */
3564
+ function resolveRoute(method, rest) {
3565
+ const spec = routeOf(rest);
3566
+ if (spec === void 0) throw routeError("not-found-route", 404, `未知路由:${method} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
3567
+ requireMethod(method, spec.method, rest);
3568
+ return spec;
3569
+ }
3090
3570
  function isPure(purity, body) {
3091
3571
  return typeof purity === "function" ? purity(body) === "pure" : purity === "pure";
3092
3572
  }
3093
- function readBodyFor(kind, req) {
3094
- return kind === "json" ? readJsonBody(req) : null;
3573
+ /**
3574
+ * 全量面按行收窄(ADR-0032):组合根装出的 {@link DeleteDeps} 只交给 `/apply`;每一段只拿到
3575
+ * 自己声明的那一格(路由表的 `slice`)。收窄是**运行期事实**,不是类型投影。
3576
+ */
3577
+ function coreOf(full) {
3578
+ return {
3579
+ fs: {
3580
+ exists: (p) => full.fs.exists(p),
3581
+ ensureDir: (p) => full.fs.ensureDir(p),
3582
+ rename: (from, to) => full.fs.rename(from, to),
3583
+ sizeOfDir: (p) => full.fs.sizeOfDir(p),
3584
+ listFiles: (p) => full.fs.listFiles(p),
3585
+ listDirs: (p) => full.fs.listDirs(p),
3586
+ realpath: (p) => full.fs.realpath(p),
3587
+ readFile: (p) => full.fs.readFile(p),
3588
+ statFile: (p) => full.fs.statFile(p)
3589
+ },
3590
+ host: full.host,
3591
+ ledger: full.ledger,
3592
+ home: full.home,
3593
+ presence: full.presence,
3594
+ config: full.config
3595
+ };
3596
+ }
3597
+ /** `removal` 保留删除能力,但不带会话删除的时钟;`full` 只给 `/apply`。 */
3598
+ function removalOf(full) {
3599
+ return {
3600
+ fs: full.fs,
3601
+ host: full.host,
3602
+ ledger: full.ledger,
3603
+ home: full.home,
3604
+ presence: full.presence,
3605
+ config: full.config
3606
+ };
3607
+ }
3608
+ /** 四格只建一次:收窄对象在 dispatcher 构造期固定,请求路径只做引用选择。 */
3609
+ function slicesOf(full) {
3610
+ const core = coreOf(full);
3611
+ return {
3612
+ core,
3613
+ removal: removalOf(full),
3614
+ full,
3615
+ attachment: {
3616
+ ...core,
3617
+ attachments: full.attachments
3618
+ }
3619
+ };
3620
+ }
3621
+ /** 按行选格:`spec.slice` 与 {@link SliceDeps} 的键同域,查表结果的类型由 `S` 直接给出。 */
3622
+ function depsFor(spec, slices) {
3623
+ return slices[spec.slice];
3095
3624
  }
3096
- function createApiHandler(deps, trust) {
3625
+ /** 路由执行器:查段 → 方法闸 → 读面 → 处理;返回归一结果。组合根只建一次。 */
3626
+ function createDispatcher(deps) {
3627
+ const slices = slicesOf(deps);
3097
3628
  /**
3098
- * 到达序串行链(见文件头):路由表里 `purity === 'chained'` 的请求一个跑完再放下一个。
3629
+ * 到达序串行链:`purity === 'chained'` 的请求一个跑完再放下一个。
3099
3630
  * `tail` 由构造保证**永不 reject**(每个任务自己的失败留在交回给那次请求的那个 promise 上),
3100
3631
  * 所以一次失败不会卡死链。
3101
3632
  */
@@ -3105,53 +3636,272 @@ function createApiHandler(deps, trust) {
3105
3636
  tail = started.then(() => void 0, () => void 0);
3106
3637
  return started;
3107
3638
  };
3108
- return async function handle(req, res) {
3109
- const verdict = judgeRequest(req, trust);
3110
- if (verdict !== "allowed") {
3111
- writeFailure(res, /* @__PURE__ */ new Error(verdict === "untrusted" ? UNTRUSTED_MESSAGE : UNAUTHENTICATED_MESSAGE), 403);
3112
- return;
3639
+ return async function dispatch(input) {
3640
+ const spec = resolveRoute(input.method, input.rest);
3641
+ const kind = spec.read;
3642
+ let rowsPromise;
3643
+ const read = { rows() {
3644
+ if (kind === "none") return Promise.reject(/* @__PURE__ */ new Error("route read: none"));
3645
+ rowsPromise ??= kind === "list" ? deps.ledger.list() : deps.ledger.raw();
3646
+ return rowsPromise;
3647
+ } };
3648
+ const rowDeps = depsFor(spec, slices);
3649
+ const task = () => spec.response === "binary" ? spec.run(rowDeps, input.search) : spec.run(rowDeps, input.body, read);
3650
+ const result = await (isPure(spec.purity, input.body) ? task() : throughChain(task));
3651
+ if (spec.response === "binary") return {
3652
+ kind: "binary",
3653
+ body: result
3654
+ };
3655
+ return {
3656
+ kind: "json",
3657
+ status: 200,
3658
+ body: {
3659
+ ok: true,
3660
+ value: result
3661
+ }
3662
+ };
3663
+ };
3664
+ }
3665
+ //#endregion
3666
+ //#region src/api/connection-route.ts
3667
+ /**
3668
+ * 现代宿主的 exact Fetch 路由适配器:把九段注册到 `connection.fetch.register`,
3669
+ * 由宿主 `/api` bridge 做 Host/Origin/浏览器认证(ADR-0033)。
3670
+ *
3671
+ * 为什么路径必须带 `/api`:宿主 `assertFetchRoute` 用 `endpointFromPath('/api', path)` 校验,
3672
+ * 不带 `/api/` 前缀会在注册时直接抛错。`request.url` 会被 bridge 重写成 `http://dsh.internal/...`,
3673
+ * 这里只取 `pathname` / `searchParams`,不依赖外部 origin。
3674
+ *
3675
+ * 这条路由**不重复宿主的鉴权**:Host/Origin/浏览器认证在 handler 之前已经由宿主完成;只有路由记录
3676
+ * 声明了 `fetchGate` 的段(`/attachment`)再判一次同源取数(`isSameOriginFetch`)——那是字节 oracle
3677
+ * 的收口,不是身份。插件的 4xx(请求体 / 方法)与 200 操作失败信封在这里落地——`createDispatcher`
3678
+ * 只抛带 `httpStatus` 的请求错误。401/403 是宿主的纯文本响应,客户端负责映射。适配器拥有 wire 机制:
3679
+ * body 字节闸、JSON 解析、二进制头与失败信封。
3680
+ */
3681
+ /** 请求体字节上限:这条通道只收几个 id,1MiB 是"够用且不给自己开一条内存放大器"的量级。 */
3682
+ const JSON_BODY_MAX_BYTES = 1048576;
3683
+ /** 请求体文本 → JSON 对象;空体 `null`;非对象 / 非法 JSON 抛 400。 */
3684
+ function parseJsonBody(text) {
3685
+ const trimmed = text.trim();
3686
+ if (trimmed === "") return null;
3687
+ try {
3688
+ const parsed = JSON.parse(trimmed);
3689
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw routeError("bad-request", 400, "请求体需为 JSON 对象");
3690
+ return parsed;
3691
+ } catch (error) {
3692
+ if (error instanceof SyntaxError) throw routeError("bad-request", 400, `请求体不是合法 JSON:${error.message}`);
3693
+ throw error;
3694
+ }
3695
+ }
3696
+ /**
3697
+ * 二进制响应的四道头(`/attachment` 专用)。判据不是口味:类型由处理函数给、长度与字节同源、
3698
+ * 不许任何中间层留下已删除会话的附件、字节与声明类型不一致时浏览器不许改猜。
3699
+ */
3700
+ function binaryHeaders(body) {
3701
+ const headers = {
3702
+ "content-type": body.contentType,
3703
+ "content-length": String(body.body.length),
3704
+ "cache-control": "private, no-store",
3705
+ "x-content-type-options": "nosniff"
3706
+ };
3707
+ if (body.filename !== void 0) headers["content-disposition"] = `${body.disposition}; filename*=UTF-8''${encodeURIComponent(body.filename)}`;
3708
+ else if (body.disposition === "attachment") headers["content-disposition"] = "attachment";
3709
+ return headers;
3710
+ }
3711
+ /**
3712
+ * 失败的落地:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表)。
3713
+ * 状态语义:**请求本身不对**不伪装成 200——请求体 400、方法 405、未知路由 404;
3714
+ * **操作失败**回 200 + 信封。`code` 一律折成 `sessiondelete/io`,路由站点名不冒充动词码。
3715
+ */
3716
+ function failureResult(error, status) {
3717
+ const carried = error?.httpStatus;
3718
+ return {
3719
+ kind: "json",
3720
+ status: status ?? (typeof carried === "number" ? carried : 200),
3721
+ body: {
3722
+ ok: false,
3723
+ error: toRemoteFailure(error)
3113
3724
  }
3725
+ };
3726
+ }
3727
+ /**
3728
+ * 同源取数闸(ADR-0033 边界收口):附件字节能被任意页面当 `<img>` 渲染,而宿主 cookie 的
3729
+ * `SameSite=Strict` 按 **site** 判——同 IP 不同端口仍会带上。宿主 `/api` 只拦 `cross-site` 与
3730
+ * authority 不符的 `Origin`,这一格补在插件这一侧:
3731
+ * - `Sec-Fetch-Site` 为 `same-site` / `cross-site` ⇒ 拒;
3732
+ * - http(s) 的 `Origin` / `Referer` 与 `Host` 不同 authority ⇒ 拒(覆盖不发送 Fetch Metadata 的代次);
3733
+ * - 桌面壳的 `dsh-app:` 来源与三头全缺的非浏览器客户端放行——桌面转发器已摘掉
3734
+ * `host` / `origin` / `sec-fetch-site`,「摘干净」不是不可信。
3735
+ *
3736
+ * `Host` 依赖的是宿主 bridge 保留原始请求头(它只改写 `request.url`);真机与
3737
+ * `tests/api.test.ts` 的 `node:http` 桥都钉住这一条:同源 `Referer` 能对上 `Host`。
3738
+ */
3739
+ function isSameOriginFetch(request) {
3740
+ const site = request.headers.get("sec-fetch-site");
3741
+ if (site === "same-origin") return true;
3742
+ if (site === "same-site" || site === "cross-site") return false;
3743
+ const host = request.headers.get("host");
3744
+ for (const name of ["origin", "referer"]) {
3745
+ const value = request.headers.get(name);
3746
+ if (value === null) continue;
3747
+ let source;
3114
3748
  try {
3115
- const url = new URL(req.url ?? "/", "http://dsh.internal");
3116
- if (!url.pathname.startsWith("/dsh-session-delete-api")) throw routeError("not-found-route", 404, `未知路由:${url.pathname}`);
3117
- const rest = url.pathname.slice(23);
3118
- const spec = routeOf(rest);
3119
- if (spec === void 0) throw routeError("not-found-route", 404, `未知路由:${req.method ?? "GET"} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
3120
- requireMethod(req.method ?? "GET", spec.method, rest);
3121
- const body = await readBodyFor(spec.body, req);
3122
- const kind = spec.read;
3123
- let rowsPromise;
3124
- const read = { rows() {
3125
- if (kind === "none") return Promise.reject(/* @__PURE__ */ new Error("route read: none"));
3126
- rowsPromise ??= kind === "list" ? deps.ledger.list() : deps.ledger.raw();
3127
- return rowsPromise;
3128
- } };
3129
- const task = () => spec.run(deps, body, read, url.searchParams);
3130
- const result = await (isPure(spec.purity, body) ? task() : throughChain(task));
3131
- if (spec.response === "binary") writeBinary(res, result);
3132
- else writeOk(res, result);
3133
- } catch (error) {
3134
- writeFailure(res, error);
3749
+ source = new URL(value);
3750
+ } catch {
3751
+ return false;
3752
+ }
3753
+ if (source.protocol !== "http:" && source.protocol !== "https:" && source.protocol !== "dsh-app:") return false;
3754
+ if (source.protocol === "dsh-app:") continue;
3755
+ if (host === null || !isSameAuthority(source, host)) return false;
3756
+ }
3757
+ return true;
3758
+ }
3759
+ /** 按来源的协议解析 `Host`,两侧都过 WHATWG 归一(默认端口、大小写)后比 authority。 */
3760
+ function isSameAuthority(source, host) {
3761
+ try {
3762
+ return new URL(`${source.protocol}//${host}`).host === source.host;
3763
+ } catch {
3764
+ return false;
3765
+ }
3766
+ }
3767
+ /** 边读边数:宿主按 `requestBody: 'buffered'` 已预读整段,这里仍守插件侧的 1 MiB 逻辑闸;
3768
+ * 超限即停,不再把读到的分片拼成更大的字符串。 */
3769
+ async function readRequestJson(request) {
3770
+ const declared = Number(request.headers.get("content-length"));
3771
+ if (Number.isFinite(declared) && declared > 1048576) throw routeError("bad-request", 400, `请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
3772
+ const stream = request.body;
3773
+ if (stream === null) return null;
3774
+ const reader = stream.getReader();
3775
+ const chunks = [];
3776
+ let size = 0;
3777
+ try {
3778
+ for (;;) {
3779
+ const { done, value } = await reader.read();
3780
+ if (done) break;
3781
+ size += value.byteLength;
3782
+ if (size > 1048576) {
3783
+ await reader.cancel();
3784
+ throw routeError("bad-request", 400, `请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
3785
+ }
3786
+ chunks.push(value);
3135
3787
  }
3788
+ } finally {
3789
+ reader.releaseLock();
3790
+ }
3791
+ return parseJsonBody(Buffer.concat(chunks).toString("utf8"));
3792
+ }
3793
+ function toResponse(result) {
3794
+ if (result.kind === "binary") return new Response(new Uint8Array(result.body.body), {
3795
+ status: 200,
3796
+ headers: binaryHeaders(result.body)
3797
+ });
3798
+ return new Response(JSON.stringify(result.body), {
3799
+ status: result.status,
3800
+ headers: { "content-type": "application/json; charset=utf-8" }
3801
+ });
3802
+ }
3803
+ /**
3804
+ * 注册九段 exact Fetch 路由。返回一个组合 disposer;调用方把它挂在自己的 effect 上。
3805
+ * `connection` 是插件的硬依赖(web UI / 桌面端都装 `client-connection`);`fetch.register`
3806
+ * 缺席属于宿主形状漂移,直接抛——静默返回空 disposer 会让插件「在但没有入口」。
3807
+ * `dispatch` 与分发器同源。
3808
+ */
3809
+ function registerConnectionRoutes(service, dispatch) {
3810
+ const fetchService = service.fetch;
3811
+ if (fetchService === void 0 || typeof fetchService.register !== "function") throw new Error("session-delete: connection.fetch.register 不可用");
3812
+ const disposers = [];
3813
+ const disposeAll = async () => {
3814
+ for (const dispose of [...disposers].reverse()) try {
3815
+ await dispose();
3816
+ } catch {}
3136
3817
  };
3818
+ try {
3819
+ for (const [routePath, spec] of Object.entries(ROUTE_TABLE)) {
3820
+ const path = `${OFFICIAL_SESSION_DELETE_API_PREFIX}${routePath}`;
3821
+ disposers.push(fetchService.register({
3822
+ path,
3823
+ methods: [spec.method],
3824
+ requestBody: "buffered",
3825
+ fetch: async (request) => {
3826
+ if (spec.fetchGate === "same-origin" && !isSameOriginFetch(request)) return toResponse(failureResult(routeError("bad-request", 403, "附件只允许同源页面读取")));
3827
+ try {
3828
+ const body = spec.body === "json" ? await readRequestJson(request) : null;
3829
+ return toResponse(await dispatch({
3830
+ method: request.method,
3831
+ rest: routePath,
3832
+ search: new URL(request.url).searchParams,
3833
+ body
3834
+ }));
3835
+ } catch (error) {
3836
+ return toResponse(failureResult(error));
3837
+ }
3838
+ }
3839
+ }));
3840
+ }
3841
+ } catch (error) {
3842
+ for (const dispose of [...disposers].reverse()) dispose().catch(() => void 0);
3843
+ throw error;
3844
+ }
3845
+ return disposeAll;
3137
3846
  }
3138
3847
  //#endregion
3139
- //#region src/ports/connection-port.ts
3140
- const HOST_UNAUTHENTICATED = 401;
3141
- /** 成员抛错按「归属不对」拒绝(fail-closed)。 */
3142
- function createConnectionPort(ctx) {
3143
- return { verdictFor: (req) => {
3144
- const service = ctx.get("connection");
3145
- if (service === void 0 || typeof service.requestRejection !== "function") return { kind: "absent" };
3848
+ //#region src/config.ts
3849
+ const DEFAULT_CONFIG = Object.freeze({
3850
+ orphanPreviewLimit: 200,
3851
+ maxAttachmentBytes: 20971520,
3852
+ maxJsonRefBytes: 10485760
3853
+ });
3854
+ const LIMITS = {
3855
+ orphanPreviewLimit: {
3856
+ min: 1,
3857
+ max: 1e4
3858
+ },
3859
+ maxAttachmentBytes: {
3860
+ min: 1,
3861
+ max: 209715200
3862
+ },
3863
+ maxJsonRefBytes: {
3864
+ min: 1,
3865
+ max: 104857600
3866
+ }
3867
+ };
3868
+ function intField(input, key, range) {
3869
+ const value = input[key];
3870
+ if (value === void 0) return DEFAULT_CONFIG[key];
3871
+ if (typeof value !== "number" || !Number.isInteger(value) || value < range.min || value > range.max) throw new Error(`配置项 ${key} 需为 ${range.min}..${range.max} 的整数`);
3872
+ return value;
3873
+ }
3874
+ /**
3875
+ * 把 patch 里的 `config` 归一成完整配置:缺省取默认值,未知键 / 非整数 / 越界都抛。
3876
+ * 抛错会让插件激活失败(响亮),而不是悄悄用半套配置跑。
3877
+ */
3878
+ function normalizeConfig(value) {
3879
+ if (value === void 0 || value === null) return DEFAULT_CONFIG;
3880
+ if (typeof value !== "object" || Array.isArray(value)) throw new Error("配置需为对象");
3881
+ const input = value;
3882
+ const known = new Set(Object.keys(LIMITS));
3883
+ for (const key of Object.keys(input)) if (!known.has(key)) throw new Error(`未知配置项:${key}`);
3884
+ return {
3885
+ orphanPreviewLimit: intField(input, "orphanPreviewLimit", LIMITS.orphanPreviewLimit),
3886
+ maxAttachmentBytes: intField(input, "maxAttachmentBytes", LIMITS.maxAttachmentBytes),
3887
+ maxJsonRefBytes: intField(input, "maxJsonRefBytes", LIMITS.maxJsonRefBytes)
3888
+ };
3889
+ }
3890
+ /**
3891
+ * 插件 Config:Cordis 接受 Standard Schema,`~standard.validate` 交回归一后的配置。
3892
+ * 宿主在激活前校验;`apply` 再归一一次(同一份事实,不会漂)。
3893
+ */
3894
+ const Config = { "~standard": {
3895
+ version: 1,
3896
+ vendor: "dsh-session-delete",
3897
+ validate(value) {
3146
3898
  try {
3147
- const status = service.requestRejection(req);
3148
- if (status === void 0) return { kind: "allowed" };
3149
- return { kind: status === HOST_UNAUTHENTICATED ? "unauthenticated" : "untrusted" };
3150
- } catch {
3151
- return { kind: "untrusted" };
3899
+ return { value: normalizeConfig(value) };
3900
+ } catch (error) {
3901
+ return { issues: [{ message: error instanceof Error ? error.message : String(error) }] };
3152
3902
  }
3153
- } };
3154
- }
3903
+ }
3904
+ } };
3155
3905
  //#endregion
3156
3906
  //#region src/ports/fs-port.ts
3157
3907
  /** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
@@ -3405,8 +4155,10 @@ function domainTable(schema) {
3405
4155
  function defineDomain(spec) {
3406
4156
  if (!UNIT_NAME_RE.test(spec.name)) throw new Error(`domain name '${spec.name}' must match ${UNIT_NAME_RE}`);
3407
4157
  if (!Number.isInteger(spec.version) || spec.version < 0) throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`);
3408
- if (spec.layout !== void 0 && spec.layout !== "single" && spec.layout !== "per-record") throw new Error(`domain '${spec.name}' layout must be 'single' or 'per-record', got ${spec.layout}`);
3409
- if (spec.invalidRecords !== void 0 && spec.invalidRecords !== "backup-and-skip") throw new Error(`domain '${spec.name}' invalidRecords must be 'backup-and-skip' when present`);
4158
+ const layout = spec.layout;
4159
+ if (layout !== void 0 && layout !== "single" && layout !== "per-record") throw new Error(`domain '${spec.name}' layout must be 'single' or 'per-record', got ${layout}`);
4160
+ const invalidRecords = spec.invalidRecords;
4161
+ if (invalidRecords !== void 0 && invalidRecords !== "backup-and-skip") throw new Error(`domain '${spec.name}' invalidRecords must be 'backup-and-skip' when present`);
3410
4162
  for (const table of Object.keys(spec.tables)) if (!UNIT_NAME_RE.test(table)) throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`);
3411
4163
  return spec;
3412
4164
  }
@@ -3482,14 +4234,11 @@ const name = "@xrn1997/dsh-session-delete";
3482
4234
  * - `agents`:只给 handle 捕获用(`ctx.get('agents')`;缺席时 `ctx.inject(['agents'])` 等服务就绪再装)。
3483
4235
  * 它同样可能晚于本插件激活,写进插件 inject 只会让整个插件 pending。
3484
4236
  *
3485
- * `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
3486
- * 前缀路由,浏览器半同源 fetch 那九个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
3487
- * `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
3488
- * `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
3489
- *
3490
- * `connection` 只喂请求栅栏的远程支(`src/api/request-trust.ts` ②),按 `ctx.get` 可选读:
3491
- * 服务缺席时插件照常激活,远程客户端 fail-closed。它不进 `inject`——写进去会让没有 web 组成的
3492
- * profile 整个停在 pending。
4237
+ * `connection` 是浏览器半取数通道的唯一承载者:九段注册到 `connection.fetch.register` 的
4238
+ * `/api/dsh-session-delete-api/*`,Host/Origin/浏览器认证由宿主 `/api` bridge 完成(ADR-0033)。
4239
+ * 支持面是 web UI 与桌面端,二者的标准组合都装 `client-connection`;服务缺席时插件停在 pending
4240
+ * ——可见的激活失败,而不是静默地没有入口。`webServer` 不再直接使用:exact route 仍由宿主的
4241
+ * `connection` 挂在其 `/api` 前缀上,插件不需要自己碰物理服务器。
3493
4242
  *
3494
4243
  * 清册的三层各有各的家:raw 绑定 `src/trash/storage-manifest.ts`、读面(对账 + 读)`src/trash/ledger.ts`、
3495
4244
  * 数据根 `src/ports/home.ts`。这里只把它们接起来,任何一层都不在组合根里长判据。
@@ -3497,16 +4246,18 @@ const name = "@xrn1997/dsh-session-delete";
3497
4246
  const inject = [
3498
4247
  "workspaceRegistry",
3499
4248
  "storageDomain",
3500
- "webServer"
4249
+ "connection"
3501
4250
  ];
3502
4251
  /**
3503
- * 装配顺序:**清单 → 端口 → 在场能力 → 动词依赖 → 前缀路由**(`DeleteDeps` 把这些依赖面合成一颗)。
4252
+ * 装配顺序:**配置归一 → 清单 → 端口 → 在场能力 → 动词依赖 → 分发器 → exact Fetch 路由**
4253
+ * (`DeleteDeps` 把这些依赖面合成一颗;`config` 是归一后的只读快照)。
3504
4254
  *
3505
4255
  * 路由用 `ctx.effect` 挂、disposer 归装载它的那根 fiber:插件卸载即摘路由(`register` 返回的
3506
- * disposer 是官方契约)。**同一进程里只注册一次**这件事由宿主把住:重复的 `(kind, path)`
3507
- * 在 `register` 里直接抛(`webserver: duplicate prefix route "…"`),响亮失败而不是悄悄覆盖。
4256
+ * disposer 是官方契约)。**同一进程里只注册一次**这件事由宿主把住:重复的 `(path, method)`
4257
+ * 在 `connection.fetch.register` 里直接抛,响亮失败而不是悄悄覆盖。
3508
4258
  */
3509
- function apply(ctx) {
4259
+ function apply(ctx, rawConfig) {
4260
+ const config = normalizeConfig(rawConfig);
3510
4261
  const fs = createFsPort();
3511
4262
  const home = resolveHome();
3512
4263
  const rawManifest = createHostStorageManifest(ctx);
@@ -3517,23 +4268,17 @@ function apply(ctx) {
3517
4268
  ledger: createTrashLedger(rawManifest, fs, home),
3518
4269
  home,
3519
4270
  presence: createMemberPresence(host),
4271
+ config,
4272
+ attachments: createAttachmentsPort(ctx),
3520
4273
  now: () => Date.now()
3521
4274
  };
3522
- const webServer = ctx.webServer;
3523
- const connection = createConnectionPort(ctx);
4275
+ const connection = ctx.get("connection");
4276
+ if (connection === void 0) throw new Error("session-delete: connection 服务缺席");
4277
+ const dispatch = createDispatcher(deps);
3524
4278
  ctx.effect(() => {
3525
- const dispose = webServer.register({
3526
- kind: "prefix",
3527
- path: SESSION_DELETE_API_PREFIX,
3528
- handler: createApiHandler(deps, {
3529
- bindHost: webServer.host,
3530
- connection
3531
- })
3532
- });
3533
- return () => {
3534
- dispose();
3535
- };
3536
- }, "session-delete: /dsh-session-delete-api routes");
4279
+ const dispose = registerConnectionRoutes(connection, dispatch);
4280
+ return () => dispose();
4281
+ }, "session-delete: /api exact Fetch routes");
3537
4282
  }
3538
4283
  //#endregion
3539
- export { apply, inject, name };
4284
+ export { Config, apply, inject, name };