@xrn1997/dsh-session-delete 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +5 -4
  2. package/icon.svg +18 -18
  3. package/lib/client.js +1407 -427
  4. package/lib/index.js +785 -182
  5. package/lib/types/api/dispatch.d.ts +51 -0
  6. package/lib/types/client/batch-bar.d.ts +24 -0
  7. package/lib/types/client/batch-confirm.d.ts +31 -0
  8. package/lib/types/client/batch-menu-item.d.ts +19 -0
  9. package/lib/types/client/batch-select.d.ts +97 -0
  10. package/lib/types/client/context.d.ts +31 -0
  11. package/lib/types/client/copy.d.ts +167 -0
  12. package/lib/types/client/delete-confirm.d.ts +40 -0
  13. package/lib/types/client/delete-menu-item.d.ts +20 -0
  14. package/lib/types/client/index.d.ts +19 -0
  15. package/lib/types/client/markdown.d.ts +11 -0
  16. package/lib/types/client/remote.d.ts +178 -0
  17. package/lib/types/client/trash-detail.d.ts +27 -0
  18. package/lib/types/client/trash-entry.d.ts +6 -0
  19. package/lib/types/client/trash-panel.d.ts +10 -0
  20. package/lib/types/client/trash-store.d.ts +4 -0
  21. package/lib/types/client/ui/Button.d.ts +21 -0
  22. package/lib/types/client/ui/Checkbox.d.ts +29 -0
  23. package/lib/types/client/ui/MenuItemButton.d.ts +32 -0
  24. package/lib/types/client/ui/Modal.d.ts +35 -0
  25. package/lib/types/client/ui/Tag.d.ts +16 -0
  26. package/lib/types/client/ui/Toast.d.ts +36 -0
  27. package/lib/types/client/ui/Tooltip.d.ts +37 -0
  28. package/lib/types/client/ui/format.d.ts +18 -0
  29. package/lib/types/client/ui/icons.d.ts +33 -0
  30. package/lib/types/client/ui/recipe.d.ts +11 -0
  31. package/lib/types/client/ui/styles.d.ts +2 -0
  32. package/lib/types/client/undo-toast.d.ts +49 -0
  33. package/lib/types/client/use-column-resize.d.ts +62 -0
  34. package/lib/types/client/use-panel-width.d.ts +15 -0
  35. package/lib/{index.d.ts → types/index.d.ts} +9 -36
  36. package/lib/types/log/session-log.d.ts +40 -0
  37. package/lib/types/log/zstd-frames.d.ts +30 -0
  38. package/lib/types/ports/fs-port.d.ts +16 -0
  39. package/lib/types/ports/host-port.d.ts +85 -0
  40. package/lib/types/remote/map-error.d.ts +19 -0
  41. package/lib/types/shared/wire.d.ts +134 -0
  42. package/lib/types/trash/detail.d.ts +40 -0
  43. package/lib/types/trash/entry.d.ts +35 -0
  44. package/lib/types/trash/manifest.d.ts +18 -0
  45. package/lib/types/trash/reconcile.d.ts +35 -0
  46. package/lib/types/verbs/apply.d.ts +3 -0
  47. package/lib/types/verbs/delete-session.d.ts +30 -0
  48. package/lib/types/verbs/errors.d.ts +20 -0
  49. package/lib/types/verbs/purge-trash.d.ts +6 -0
  50. package/lib/types/verbs/restore-session.d.ts +19 -0
  51. package/locale/en.json +6 -6
  52. package/locale/zh.json +6 -6
  53. package/package.json +14 -9
  54. package/lib/client.js.map +0 -1
package/lib/index.js CHANGED
@@ -15,7 +15,7 @@ import { mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
15
15
  * ② 跨插件一律 `import type`——本文件干脆没有 import。
16
16
  *
17
17
  * 信封(`RemoteResult`)与失败形状(`RemoteFailure`)也在这里——**因为它们本来就是两半的共同契约**:
18
- * Node 半按它产出(`src/api/dispatch.ts` 的失败支就是 `RemoteFailure` 那两件),浏览器半按它判别
18
+ * Node 半按它产出(`src/api/dispatch.ts` 的失败支就是 `RemoteFailure` 那三件),浏览器半按它判别
19
19
  * (`src/client/remote.ts` 的 `unwrapRemoteResult` / `failureOf`)。形状取自宿主网关客户端面的原文
20
20
  * 「每次 unary 调用 resolve 成 RemoteResult、不为载体问题 reject」,但**它是本仓自己的 wire 契约**,
21
21
  * 不是对宿主模块的 import。
@@ -28,20 +28,29 @@ import { mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
28
28
  * 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
29
29
  */
30
30
  const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
31
- /** 五个端点的段名(相对前缀;`GET list` **对账并读**清单——`session-trash/` 里的目录是准,其余四个
32
- * `POST` 按 id 取数或写)。 */
31
+ /** 五个段名(相对前缀)。2026-10-17 批量重构:写动词从「一条一段」收成**一段 `/apply`**——
32
+ * 一次 HTTP 带一组 id、逐条回结果;单条就是长度为 1 的批量,仓里不再留第二条执行路径。
33
+ * - `GET /list`:**对账并读**清单(`session-trash/` 里的目录是准)。
34
+ * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链)——客户端探测用它,不用一次全表读。
35
+ * - `POST /detail`:单条详情(用 POST 表达一个读:见设计稿 2026-10-15 §3)。
36
+ * - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count`),逐条回 `ApplyOutcome`。
37
+ * - `POST /empty`:清空回收站(聚合回执,**不**按条回)。 */
33
38
  const ROUTES = {
34
39
  list: "/list",
35
- delete: "/delete",
36
- restore: "/restore",
37
- purge: "/purge",
38
- /** 单条详情(读回收站目录里那条会话自己的日志)。用 POST 表达一个读:见设计稿 2026-10-15 §3。 */
39
- detail: "/detail"
40
+ /** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
41
+ ping: "/ping",
42
+ /** 单条详情(读回收站目录里那条会话自己的日志)。 */
43
+ detail: "/detail",
44
+ /** 一组 id 上的同一个动作;删除确认框那句「含 N 个子会话」也走这里(`action: 'count'`,纯读语义)。 */
45
+ apply: "/apply",
46
+ /** 清空回收站。 */
47
+ empty: "/empty"
40
48
  };
41
- /** 请求体字段名:浏览器半发、Node 半读,同名同义(`purge` 的 `entryId` 缺席 = 清空)。 */
49
+ /** 请求体字段名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。 */
42
50
  const PARAMS = {
43
- sessionId: "sessionId",
44
- entryId: "entryId"
51
+ entryId: "entryId",
52
+ action: "action",
53
+ ids: "ids"
45
54
  };
46
55
  /**
47
56
  * 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
@@ -67,10 +76,18 @@ const VERB_CODES = [
67
76
  "project-missing",
68
77
  "io"
69
78
  ];
70
- function verbError(code, message, cause) {
79
+ function verbError(code, message, cause, details) {
71
80
  const e = new Error(message);
72
81
  e.code = `sessiondelete/${code}`;
73
- e.cause = cause;
82
+ if (cause !== void 0) e.cause = cause;
83
+ if (details !== void 0) e.details = details;
84
+ return e;
85
+ }
86
+ /** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
87
+ function routeError(code, httpStatus, message) {
88
+ const e = verbError("io", message);
89
+ e.code = `sessiondelete/${code}`;
90
+ e.httpStatus = httpStatus;
74
91
  return e;
75
92
  }
76
93
  //#endregion
@@ -86,13 +103,14 @@ const HOST_ERROR_CODE = {
86
103
  WorkspaceUnknownSessionError: "not-found",
87
104
  WorkspaceActiveSessionError: "live"
88
105
  };
89
- /** 错误与它的 `cause` 链上最近的一个宿主类型化错误码(深度有界,链坏掉不递归下去)。 */
90
- function hostCodeOf(error) {
106
+ /** cause 链上第一个**宿主类型化错误**本身(不只它的名字)——它自己身上的字段还要当 `details` 用。
107
+ * 深度有界(链坏掉不递归下去);查表必须 `hasOwn`:`in` 会命中 `Object.prototype` 的键。 */
108
+ function hostErrorOf(error) {
91
109
  let current = error;
92
110
  for (let depth = 0; depth < 8; depth++) {
93
111
  if (current === null || typeof current !== "object") return void 0;
94
112
  const name = current.name;
95
- if (typeof name === "string" && Object.hasOwn(HOST_ERROR_CODE, name)) return HOST_ERROR_CODE[name];
113
+ if (typeof name === "string" && Object.hasOwn(HOST_ERROR_CODE, name)) return current;
96
114
  current = current.cause;
97
115
  }
98
116
  }
@@ -106,32 +124,86 @@ function messageOf(error) {
106
124
  }
107
125
  return String(error);
108
126
  }
127
+ /** 官方 `<域>/<名>` 形状的 code。动词码也是这个形状,所以调用方另要**排除本域**才作数。 */
128
+ const OFFICIAL_CODE = /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/;
129
+ /** 抛出物**自己身上**挂的结构化字段(rc.2:`WorkspaceActiveSessionError` 带 `sessionId` / `activity`,
130
+ * `WorkspaceUnknownSessionError` 带 `sessionId`;控制器把它们折进 `RemoteError.details` 才上客户端)。
131
+ * `activity` 过一遍 JSON 往返再交回:既保住它的结构(官方 schema 是 `Record<string, unknown>`),
132
+ * 也保证信封那一侧 `JSON.stringify` 不会撞上循环引用。
133
+ * 一件都没有 ⇒ `undefined`(不硬造一个空表冒充"带过 details")。 */
134
+ function ownDetailsOf(error) {
135
+ if (error === null || typeof error !== "object") return void 0;
136
+ const out = {};
137
+ const source = error;
138
+ for (const key of [
139
+ "sessionId",
140
+ "workspaceId",
141
+ "path"
142
+ ]) if (typeof source[key] === "string") out[key] = source[key];
143
+ if (source.activity !== void 0) try {
144
+ out.activity = JSON.parse(JSON.stringify(source.activity));
145
+ } catch {}
146
+ return Object.keys(out).length > 0 ? out : void 0;
147
+ }
148
+ /** 一个可以过线的 details 对象(数组与 null 不算;官方是 `z.record`)。 */
149
+ function isDetailsRecord(value) {
150
+ return typeof value === "object" && value !== null && !Array.isArray(value);
151
+ }
152
+ /** `details` 的来历,就近优先:自己挂着的 `details`(官方 `RemoteError` 那件)→ 自己身上的字段 → 无。 */
153
+ function detailsOf(error) {
154
+ if (error === null || typeof error !== "object") return void 0;
155
+ const carried = error.details;
156
+ if (isDetailsRecord(carried)) return carried;
157
+ return ownDetailsOf(error);
158
+ }
109
159
  /**
110
- * 把任意抛出物折成失败信封里的**那两件**(设计稿 §6 唯一那张映射表)。
160
+ * 把任意抛出物折成失败信封里的**那三件**(设计稿 §6 唯一那张映射表)。
111
161
  *
112
- * - 已经是远程失败(`isDSHRemoteError` 认得的结构标记)⇒ 原样带出它自己的 `code` / `message`,不再包一层。
113
- * - 宿主类型化错误(自身或 `cause` 链上,见 {@link HOST_ERROR_CODE})⇒ 映射成 `live` / `not-found`。
114
- * 放在动词码之前判:动词把宿主错误包成笼统的 `io` 并把原错误挂在 cause 上(`verbError('io', …, cause)`),
115
- * 不这样看进 cause 链,§6 那两行映射就永远到不了客户端。
116
- * - 动词的 `sessiondelete/<五个码>` ⇒ 原样过桥。
117
- * - 其余(含别域码、前缀外的因、非 Error 抛出物)⇒ `io`,message 保留原文。
162
+ * - 已经是远程失败(`isDSHRemoteError` 认得的结构标记)⇒ 原样带出它自己的 `code` / `message`,
163
+ * 挂着的 `details` 一起过桥,不再包一层。
164
+ * - **带官方形状 code 的抛出物**(`workspace/session-active`、`session/not-found`…)⇒ **原样过桥**。
165
+ * 把它们折成 `sessiondelete/io` 就是把宿主的判断抹成我们的,客户端拿到的 code 从此不能说真话。
166
+ * - 宿主类型化错误(自身或 `cause` 链上,见 {@link hostErrorOf})⇒ 映射成 `live` / `not-found`,
167
+ * 并把它自己身上的结构化字段带成 `details`。这一条排在动词码之前:动词会把宿主错误包成笼统的 `io`
168
+ * 并把原错误挂在 cause 上(`verbError('io', …, cause)`),不看进 cause 链,§6 那两行映射永远到不了客户端。
169
+ * - 动词的 `sessiondelete/<五个码>` ⇒ 原样过桥(`details` 同样带着)。
170
+ * - 其余(别域的怪 code、前缀外的因、非 Error 抛出物)⇒ `io`,message 保留原文。
118
171
  *
119
- * **返回的是线上形状、不是 `Error`**:这份东西唯一的去处是 `src/api/dispatch.ts` 的失败信封,两种判别
120
- * (`isDSHRemoteError` 与 {@link HOST_ERROR_CODE})都只用到 `code` / `message`——造一个真 `Error` 没有消费者,
121
- * 而本仓的跨插件 import 红线也不许为此去引宿主的 `RemoteError` 类(实例也上不了线:信封只走这两件)。
172
+ * **返回的是线上形状、不是 `Error`**:这份东西唯一的去处是 `src/api/dispatch.ts` 的失败信封
173
+ * (以及 `verbs/apply.ts` 的逐条 `error`),而本仓的跨插件 import 红线也不许为此去引宿主的
174
+ * `RemoteError` 类(实例也上不了线:信封只走这三件)。
122
175
  */
123
176
  function toRemoteFailure(error) {
124
- if (isDSHRemoteError(error)) return {
125
- code: error.code,
126
- message: String(error.message)
127
- };
177
+ if (isDSHRemoteError(error)) {
178
+ const carried = error.details;
179
+ return {
180
+ code: error.code,
181
+ message: String(error.message),
182
+ ...isDetailsRecord(carried) ? { details: carried } : {}
183
+ };
184
+ }
128
185
  const raw = error?.code;
186
+ if (typeof raw === "string" && OFFICIAL_CODE.test(raw) && !raw.startsWith(`${DOMAIN}/`)) {
187
+ const details = detailsOf(error);
188
+ return {
189
+ code: raw,
190
+ message: messageOf(error),
191
+ ...details === void 0 ? {} : { details }
192
+ };
193
+ }
129
194
  const reason = typeof raw === "string" && raw.startsWith(`${DOMAIN}/`) ? raw.slice(14) : void 0;
130
195
  const fromVerb = reason !== void 0 && VERB_CODES.includes(reason) ? reason : void 0;
131
- const code = hostCodeOf(error) ?? fromVerb ?? "io";
196
+ const host = hostErrorOf(error);
197
+ const own = detailsOf(error) ?? (host === void 0 ? void 0 : detailsOf(host));
198
+ if (host === void 0 && fromVerb === void 0 && typeof raw === "string" && raw.includes("/")) return {
199
+ code: `${DOMAIN}/io`,
200
+ message: messageOf(error)
201
+ };
202
+ const code = host !== void 0 ? HOST_ERROR_CODE[host.name] : fromVerb ?? "io";
132
203
  return {
133
204
  code: `${DOMAIN}/${code}`,
134
- message: messageOf(error)
205
+ message: messageOf(error),
206
+ ...own === void 0 ? {} : { details: own }
135
207
  };
136
208
  }
137
209
  //#endregion
@@ -273,8 +345,33 @@ function frameRanges(raw) {
273
345
  * 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
274
346
  */
275
347
  const NEWLINE = 10;
276
- /** 会话日志的文件名后缀(`session.vN.jsonl.zstd`)。详情与回收站对账都按它挑日志,名字只有这一处家。 */
277
- const SESSION_LOG_SUFFIX = ".jsonl.zstd";
348
+ /**
349
+ * 宿主给每一代日志的词法(逐字取自 `@deepseek-ai/dsh-session-format@0.2.0-rc.2` 的
350
+ * `CANONICAL_LOG_FILENAME`,剥掉压缩后缀那段由我们自己在正则里补上 `.zstd`):
351
+ * 裸名 `session.jsonl` 是**第 0 代**,往后是 `session.vN`;`.v0`、前导零、大写、临时名都不是规范名。
352
+ */
353
+ const CANONICAL_ZSTD_LOG = /^session(?:\.v([1-9][0-9]*))?\.jsonl\.zstd$/u;
354
+ /**
355
+ * 同一个会话目录里**可以并存多代日志**(宿主换代时把老代留在原地当历史语料,本机实测 7 个目录如此),
356
+ * 而新代是老代的**迁移后超集**(实测:`firstTs` 与开头那条提问完全相同、记录更多、末尾更晚)
357
+ * ⇒ 要读的是版本号最大的那一代,读旧代 = 读到一份截了尾巴的快照;把几代拼起来 = 重复计数。
358
+ *
359
+ * 明文(无 `.zstd`)不在此列:本插件的读法逐帧解 zstd,明文得走另一条解码路。
360
+ */
361
+ function pickSessionLog(names) {
362
+ let best;
363
+ let bestVersion = -1;
364
+ for (const name of names) {
365
+ const match = CANONICAL_ZSTD_LOG.exec(name);
366
+ if (match === null) continue;
367
+ const version = match[1] === void 0 ? 0 : Number(match[1]);
368
+ if (version > bestVersion) {
369
+ bestVersion = version;
370
+ best = name;
371
+ }
372
+ }
373
+ return best;
374
+ }
278
375
  /**
279
376
  * 逐帧走一遍日志,每条记录交给回调。
280
377
  *
@@ -358,8 +455,8 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
358
455
  if (record === void 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
359
456
  const dir = trashDirOf(record.id, deps.home);
360
457
  if (!await deps.fs.exists(dir)) throw verbError("io", `回收站目录不在:${dir}`);
361
- const names = (await deps.fs.listFiles(dir)).sort();
362
- const logName = names.find((n) => n.endsWith(SESSION_LOG_SUFFIX));
458
+ const names = await deps.fs.listFiles(dir);
459
+ const logName = pickSessionLog(names);
363
460
  if (logName === void 0) throw verbError("io", `回收站目录里没有会话日志:${dir}(里面有:${names.join(", ") || "空目录"})`);
364
461
  let raw;
365
462
  try {
@@ -464,6 +561,74 @@ function liveSessionsOf(ctx) {
464
561
  if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
465
562
  return store;
466
563
  }
564
+ /** 官方的「这些父的孩子」那一支:`kind:'parent'` 的判据逐字是 `values.includes(record.header.parentSession ?? null)`。 */
565
+ async function filterChildrenOf(sessionQuery, values) {
566
+ return sessionQuery.filterSessions([{
567
+ kind: "parent",
568
+ values: [...values].sort()
569
+ }]);
570
+ }
571
+ /**
572
+ * 从若干根出发**逐层往下**把盘上的后代问出来,交回 `父(bare)→ 孩子` 的索引。
573
+ *
574
+ * 为什么一次问一层而不是官方的 `traceSession`:那条在父已经进回收站时认不出目标
575
+ * (`SESSION_QUERY_SESSION_NOT_FOUND`),而我们要的那批正是"父已经不在宿主列表里之后还剩在盘上的"。
576
+ * `kind:'parent'` 是唯一能按 `header.parentSession` 反着问的一支。
577
+ *
578
+ * 停下来的条件与代价:**层数**决定宿主调用次数,行数不参与(一层一次,问空即停)。同一条记录只收一次
579
+ * (`seen` 连着根一起记),所以数据里真出现环时结果是"少数一条",不是把清单读挂死。
580
+ * 交回的 id 没有 `header.id` 的记录**整条跳过**——身份冒充不起(同 {@link HostPort.subagentMembers} 那条),
581
+ * 而且没有身份就没法往下问,硬数进去只会让它将来被重复计。
582
+ */
583
+ async function descendantsByParent(sessionQuery, roots) {
584
+ const children = /* @__PURE__ */ new Map();
585
+ const seen = new Set(roots.map(bareSessionId));
586
+ let frontier = [...seen];
587
+ while (frontier.length > 0) {
588
+ const values = [];
589
+ for (const bare of frontier) for (const shape of sessionIdShapes(bare)) if (!values.includes(shape)) values.push(shape);
590
+ const next = [];
591
+ for (const record of await filterChildrenOf(sessionQuery, values)) {
592
+ const id = record.header?.id;
593
+ const parent = record.header?.parentSession;
594
+ if (typeof id !== "string" || id.length === 0) continue;
595
+ if (typeof parent !== "string" || parent.length === 0) continue;
596
+ const bare = bareSessionId(id);
597
+ if (seen.has(bare)) continue;
598
+ seen.add(bare);
599
+ const cwd = record.header?.cwd;
600
+ const descendant = {
601
+ id,
602
+ bare,
603
+ projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd)
604
+ };
605
+ const parentBare = bareSessionId(parent);
606
+ const siblings = children.get(parentBare);
607
+ if (siblings === void 0) children.set(parentBare, [descendant]);
608
+ else siblings.push(descendant);
609
+ next.push(bare);
610
+ }
611
+ frontier = next;
612
+ }
613
+ return children;
614
+ }
615
+ /** 某个根在 `descendantsByParent` 那棵树里的全部后代(深度不设上限,同一条只收一次)。 */
616
+ function subtreeOf(children, rootBare) {
617
+ const out = [];
618
+ const seen = /* @__PURE__ */ new Set([rootBare]);
619
+ let frontier = [rootBare];
620
+ while (frontier.length > 0) {
621
+ const next = [];
622
+ for (const bare of frontier) for (const kid of children.get(bare) ?? []) {
623
+ if (seen.has(kid.bare)) continue;
624
+ seen.add(kid.bare);
625
+ out.push(kid);
626
+ next.push(kid.bare);
627
+ }
628
+ frontier = next;
629
+ }
630
+ return out;
631
+ }
467
632
  /** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
468
633
  * 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。
469
634
  * `projectDirOf` 与回收站对账(`src/trash/reconcile.ts` 重建条目)共用这一个字面量。 */
@@ -529,8 +694,35 @@ const NO_SUCH_SESSION = "session persistence holds no such session";
529
694
  function isUnknownSession(error) {
530
695
  return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
531
696
  }
697
+ /** 会话 id 去掉 `session-` 前缀后的形状(同一个父在两代里写法不同,见 {@link HostPort.subagentCounts})。 */
698
+ function bareSessionId(id) {
699
+ return id.startsWith("session-") ? id.slice(8) : id;
700
+ }
701
+ /** 一个 id 的全部可能写法,原写法在前:`session-x` → `[session-x, x]`;`x` → `[x, session-x]`。 */
702
+ function sessionIdShapes(id) {
703
+ const bare = bareSessionId(id);
704
+ return [.../* @__PURE__ */ new Set([
705
+ id,
706
+ bare,
707
+ `session-${bare}`
708
+ ])];
709
+ }
710
+ /** 宿主会话表的一次取数(`ctx.sessionController.list()` 的两种返回形状都折成数组)。
711
+ * 服务没挂 / 形状不对 / 抛错 ⇒ 空表——**这条不替宿主兜错误**:调用方拿空表的语义都是"少发一条通告",
712
+ * 不是"恢复失败"。 */
713
+ async function controllerItems(host) {
714
+ try {
715
+ const controller = host.get("sessionController");
716
+ if (controller === void 0 || controller === null || typeof controller.list !== "function") return [];
717
+ const listed = await controller.list();
718
+ const items = Array.isArray(listed) ? listed : listed?.items;
719
+ return Array.isArray(items) ? items : [];
720
+ } catch {
721
+ return [];
722
+ }
723
+ }
532
724
  /**
533
- * 宿主服务层的**真实绑定**:十二个方法各自把动词层的问题翻成宿主官方服务的调用。
725
+ * 宿主服务层的**真实绑定**:各方法把动词层的问题翻成宿主官方服务的调用。
534
726
  *
535
727
  * 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
536
728
  * 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
@@ -650,19 +842,42 @@ function createHostPort(ctx) {
650
842
  * 摘要**取自宿主自己的会话列表**(`ctx.sessionController.list()`),不自己造一行:客户端 `handleSessionAdded`
651
843
  * 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
652
844
  * 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**——恢复本身已经完成,只是要等下次重连才上屏。
845
+ *
846
+ * **单条路各拉一次表**;批量走 {@link HostPort.announceRestoredBatch} + 一次预取(见 {@link restoredSummariesOf}),
847
+ * 100 条的批量恢复用这条就是 100 遍全表扫——那是 2026-10-17 那次"恢复很慢"的头号来源。
653
848
  */
654
849
  async announceRestored(sessionId) {
850
+ const summary = (await this.restoredSummariesOf()).find((item) => item.sessionId === sessionId)?.summary;
851
+ if (summary === void 0) return;
655
852
  try {
656
- const controller = host.get("sessionController");
657
- if (controller === void 0 || controller === null || typeof controller.list !== "function") return;
658
- const listed = await controller.list();
659
- const items = Array.isArray(listed) ? listed : listed?.items;
660
- if (!Array.isArray(items)) return;
661
- const summary = items.find((item) => item?.sessionId === sessionId);
662
- if (summary === void 0) return;
663
853
  host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
664
854
  } catch {}
665
855
  },
856
+ /** 整批预取一次会话表(`[{ sessionId, summary }]`):通告的路径与单条一致,**拉表从每条一次抬成整批一次**。 */
857
+ async restoredSummariesOf() {
858
+ const out = [];
859
+ for (const item of await controllerItems(host)) {
860
+ const sessionId = item?.sessionId;
861
+ if (typeof sessionId === "string") out.push({
862
+ sessionId,
863
+ summary: item
864
+ });
865
+ }
866
+ return out;
867
+ },
868
+ /** 一组会话的通告(2026-10-17 批量重构):`known` 给了就**一次都不拉表**,只按它筛。
869
+ * 与 {@link announceRestored} 同一判据、同一事件、同一载荷形状,只是取数由整批共享。 */
870
+ async announceRestoredBatch(ids, known) {
871
+ if (ids.length === 0) return;
872
+ const summaries = known ?? await this.restoredSummariesOf();
873
+ for (const id of ids) {
874
+ const summary = summaries.find((item) => item.sessionId === id)?.summary;
875
+ if (summary === void 0) continue;
876
+ try {
877
+ host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
878
+ } catch {}
879
+ }
880
+ },
666
881
  /** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
667
882
  async unarchive(sessionId) {
668
883
  await registry.unarchiveSession(sessionId);
@@ -685,6 +900,47 @@ function createHostPort(ctx) {
685
900
  * 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,设计稿 §6)。 */
686
901
  async archive(sessionId) {
687
902
  await registry.archiveSession(sessionId);
903
+ },
904
+ /**
905
+ * 官方 `sessionQuery.filterSessions([{kind:'parent', values}])`,不自扫 `~/.dsh/sessions`。
906
+ *
907
+ * 数的是**传递闭包**(孩子与孙代都算),不是"直接孩子"(issue #1):宿主客户端对所有
908
+ * `origin:"subagent"` 一律藏行 ⇒ 深度 ≥2 上"删了父还剩多少看不见"原样存在,只数一层就是把那件事
909
+ * 说成没有。问法见 {@link descendantsByParent}。
910
+ *
911
+ * 归堆必须**两边都去前缀**再比:本机实测父会话自己的 header id 带 `session-` 前缀
912
+ * (`session-ac07f2bb-…`)、它的子会话写的 `parentSession` 也带,但 v3 那一代的 id 不带 ⇒
913
+ * 同一个父有两种写法。所以问的时候把一个 id 的两种形状都塞进 `values`,回来按去前缀后的形状归堆,
914
+ * 最后按**调用方给的那个写法**把键交回去(调用方拿 `entry.sessionId` 直接查得到)。
915
+ */
916
+ async subagentCounts(parentIds) {
917
+ const wanted = [];
918
+ for (const id of parentIds) if (id.length > 0 && !wanted.includes(id)) wanted.push(id);
919
+ if (wanted.length === 0) return {};
920
+ const sessionQuery = host.get("sessionQuery");
921
+ if (typeof sessionQuery?.filterSessions !== "function") return {};
922
+ const children = await descendantsByParent(sessionQuery, wanted);
923
+ const counts = {};
924
+ for (const id of wanted) {
925
+ const n = subtreeOf(children, bareSessionId(id)).length;
926
+ if (n > 0) counts[id] = n;
927
+ }
928
+ return counts;
929
+ },
930
+ /**
931
+ * 删除要一起带走**整个组**,先得把他们点出来:身份取宿主自己记录里的 `header.id`,项目目录由
932
+ * `header.cwd` 走同一个 `projectKey`(与 {@link HostPort.projectDirOf} 同一根算法,不另起一套)。
933
+ * 交回的是**全部后代**——孙代跟着父一起进回收站(issue #1),顺序是逐层往下问出来的(近的先)。
934
+ * `header.id` 缺席的后代直接跳过——不拿目录名冒充身份(冒充了会把一个来路不明的目录搬进回收站)。
935
+ * 服务没挂回 `[]` ⇒ 删除照旧只管父,不因此失败(父的删除本来就已经是完整的一件事)。
936
+ */
937
+ async subagentMembers(sessionId) {
938
+ const sessionQuery = host.get("sessionQuery");
939
+ if (typeof sessionQuery?.filterSessions !== "function") return [];
940
+ return subtreeOf(await descendantsByParent(sessionQuery, [sessionId]), bareSessionId(sessionId)).map((d) => ({
941
+ sessionId: d.id,
942
+ projectDir: d.projectDir
943
+ }));
688
944
  }
689
945
  };
690
946
  }
@@ -735,7 +991,7 @@ async function buildEntry(deps, id) {
735
991
  const dir = trashDirOf(id, deps.home);
736
992
  let logName;
737
993
  try {
738
- logName = (await deps.fs.listFiles(dir)).find((name) => name.endsWith(SESSION_LOG_SUFFIX));
994
+ logName = pickSessionLog(await deps.fs.listFiles(dir));
739
995
  } catch {
740
996
  return;
741
997
  }
@@ -793,74 +1049,11 @@ async function reconcileTrash(deps) {
793
1049
  return await deps.manifest.list();
794
1050
  }
795
1051
  //#endregion
796
- //#region src/verbs/delete-session.ts
797
- function deleteSession(deps) {
798
- return async function deleteSession(sessionId) {
799
- if (await deps.host.isLive(sessionId)) throw verbError("live", `会话正在运行:${sessionId}`);
800
- const projectDir = await deps.host.projectDirOf(sessionId);
801
- if (!projectDir) throw verbError("not-found", `宿主的持久化里没有这个会话:${sessionId}`);
802
- const workspaceId = await deps.host.workspaceOf(sessionId);
803
- const sessionDir = sessionDirOf(projectDir, sessionId, deps.home);
804
- if (!await deps.fs.exists(sessionDir)) throw verbError("not-found", `会话目录不存在:${sessionDir}`);
805
- const wasArchived = await deps.host.isArchived(sessionId);
806
- const deletedAt = deps.now();
807
- const entryId = makeEntryId(deletedAt, sessionId);
808
- const trashDir = trashDirOf(entryId, deps.home);
809
- let title;
810
- let sizeBytes;
811
- try {
812
- title = await deps.host.titleOf(sessionId);
813
- sizeBytes = await deps.fs.sizeOfDir(sessionDir);
814
- } catch (error) {
815
- throw verbError("io", `读取会话现场失败:${String(error)}`, error);
816
- }
817
- if (!wasArchived) try {
818
- await deps.host.archive(sessionId, workspaceId);
819
- } catch (error) {
820
- throw verbError("io", `归档失败:${String(error)}`, error);
821
- }
822
- if (workspaceId !== void 0) try {
823
- await deps.host.detach(sessionId, workspaceId);
824
- } catch (error) {
825
- if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
826
- throw verbError("io", `摘账本失败:${String(error)}`, error);
827
- }
828
- const entry = {
829
- id: entryId,
830
- sessionId,
831
- projectDir,
832
- workspaceId: workspaceId ?? "",
833
- wasArchived,
834
- deletedAt,
835
- title,
836
- sizeBytes
837
- };
838
- try {
839
- await deps.fs.ensureDir(`${deps.home}/session-trash`);
840
- await deps.fs.rename(sessionDir, trashDir);
841
- } catch (error) {
842
- if (workspaceId !== void 0) await deps.host.attach(sessionId, workspaceId).catch(() => {});
843
- if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
844
- throw verbError("io", `移动会话目录失败:${String(error)}`, error);
845
- }
846
- try {
847
- await deps.manifest.add(entry);
848
- } catch (error) {
849
- await deps.fs.rename(trashDir, sessionDir).catch(() => {});
850
- if (workspaceId !== void 0) await deps.host.attach(sessionId, workspaceId).catch(() => {});
851
- if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
852
- throw verbError("io", `登记回收站条目失败:${String(error)}`, error);
853
- }
854
- if (await deps.host.evictLive(sessionId) !== "unknown") await deps.host.announceRemoved(sessionId);
855
- return entry;
856
- };
857
- }
858
- //#endregion
859
1052
  //#region src/verbs/purge-trash.ts
860
1053
  function purgeTrash(deps) {
861
- return async function purgeTrash(entryId) {
862
- const all = await deps.manifest.list();
863
- const targets = entryId ? all.filter((e) => e.id === entryId) : all;
1054
+ return async function purgeTrash(entryId, snapshot) {
1055
+ const all = snapshot ?? await deps.manifest.list();
1056
+ const targets = entryId ? all.filter((e) => e.id === entryId || e.parentEntryId === entryId) : [...all];
864
1057
  if (entryId && targets.length === 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
865
1058
  let freedBytes = 0;
866
1059
  for (const entry of targets) {
@@ -872,6 +1065,8 @@ function purgeTrash(deps) {
872
1065
  });
873
1066
  }
874
1067
  await deps.manifest.remove(entry.id);
1068
+ const at = all.indexOf(entry);
1069
+ if (at >= 0) all.splice(at, 1);
875
1070
  if (await deps.host.evictLive(entry.sessionId) !== "unknown") await deps.host.announceRemoved(entry.sessionId);
876
1071
  }
877
1072
  return {
@@ -882,43 +1077,361 @@ function purgeTrash(deps) {
882
1077
  }
883
1078
  //#endregion
884
1079
  //#region src/verbs/restore-session.ts
1080
+ /**
1081
+ * 恢复 = 把删除那次**按原路退回**,而且按组走:当年是按组一起搬进来的(父 + 它在盘上的孩子),
1082
+ * 只回父就会在回收站里留下一堆点不到的孩子行——清单只列父行,那些行既看不见也清不掉。
1083
+ *
1084
+ * 三段与删除对称:
1085
+ * ① **预检整组**(一个字节都不写):每个成员的回收站目录与项目目录都得在。缺席的判据与原来一致
1086
+ * (回收站目录不在 = `trash-empty`;原项目目录不在 = `project-missing`,且绝不静默建目录)。
1087
+ * 预检在写之前,是为了不让"父已经回到 `sessions/`、孩子因为项目目录没了而回不去"这种半组留在外面。
1088
+ * ② **逐个恢复**,顺序与删除相反:文件先回来 → 挂账本 → 还原可见性 → 摘清册行 → 通告回来。
1089
+ * ③ 中途失败把这个成员自己退回回收站(就地回滚),并把**已经恢复完的**兄弟按相反顺序一起退回,失败照抛。
1090
+ */
885
1091
  function restoreSession(deps) {
886
- return async function restoreSession(entryId) {
887
- const entry = (await deps.manifest.list()).find((e) => e.id === entryId);
888
- if (!entry) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
889
- const trashDir = trashDirOf(entry.id, deps.home);
890
- const sessionDir = sessionDirOf(entry.projectDir, parseEntryId(entry.id).sessionDirName, deps.home);
891
- if (!await deps.fs.exists(trashDir)) throw verbError("trash-empty", `回收站条目已不在磁盘上:${trashDir}`);
892
- const projectRoot = sessionDirOf(entry.projectDir, "", deps.home).replace(/\/$/, "");
893
- if (!await deps.fs.exists(`${deps.home}/sessions`) || !await deps.fs.exists(projectRoot)) throw verbError("project-missing", `原项目目录已不存在:${projectRoot}`);
894
- await deps.fs.rename(trashDir, sessionDir).catch((error) => {
895
- throw verbError("io", `放回会话目录失败:${String(error)}`, error);
1092
+ return async function restoreSession(entryId, options = {}) {
1093
+ const all = options.snapshot ?? await deps.manifest.list();
1094
+ const target = all.find((e) => e.id === entryId);
1095
+ if (!target) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
1096
+ const group = target.parentEntryId === void 0 ? [target, ...all.filter((e) => e.parentEntryId === target.id)] : [target];
1097
+ for (const entry of group) {
1098
+ const trashDir = trashDirOf(entry.id, deps.home);
1099
+ if (!await deps.fs.exists(trashDir)) throw verbError("trash-empty", `回收站条目已不在磁盘上:${trashDir}`);
1100
+ const projectRoot = sessionDirOf(entry.projectDir, "", deps.home).replace(/\/$/, "");
1101
+ if (!await deps.fs.exists(`${deps.home}/sessions`) || !await deps.fs.exists(projectRoot)) throw verbError("project-missing", `原项目目录已不存在:${projectRoot}`);
1102
+ }
1103
+ const done = [];
1104
+ const restored = [];
1105
+ try {
1106
+ for (const entry of group) {
1107
+ done.push(await restoreOne(deps, entry));
1108
+ restored.push(entry.sessionId);
1109
+ }
1110
+ } catch (error) {
1111
+ for (const undo of done.reverse()) await undo();
1112
+ throw error;
1113
+ }
1114
+ if (options.suppressAnnounce !== true) await deps.host.announceRestoredBatch(restored);
1115
+ return target;
1116
+ };
1117
+ }
1118
+ /** 放回哪儿:用**删除时记进 entryId 的那段原目录名**,不从 `sessionId` 现推(设计稿 §3)——
1119
+ * 因此不依赖宿主当时的目录命名约定(v3 无前缀 / v4 带 `session-` 前缀)。 */
1120
+ function restoreTargetDir(deps, entry) {
1121
+ return sessionDirOf(entry.projectDir, parseEntryId(entry.id).sessionDirName, deps.home);
1122
+ }
1123
+ /** 恢复一个成员,交回它的反向动作。 */
1124
+ async function restoreOne(deps, entry) {
1125
+ const trashDir = trashDirOf(entry.id, deps.home);
1126
+ const sessionDir = restoreTargetDir(deps, entry);
1127
+ await deps.fs.rename(trashDir, sessionDir).catch((error) => {
1128
+ throw verbError("io", `放回会话目录失败:${String(error)}`, error);
1129
+ });
1130
+ if (entry.workspaceId !== "") try {
1131
+ await deps.host.attach(entry.sessionId, entry.workspaceId);
1132
+ } catch (error) {
1133
+ await deps.fs.rename(sessionDir, trashDir).catch(() => {});
1134
+ throw verbError("io", `挂回账本失败:${String(error)}`, error);
1135
+ }
1136
+ if (!entry.wasArchived) try {
1137
+ await deps.host.unarchive(entry.sessionId);
1138
+ } catch (error) {
1139
+ await deps.host.archive(entry.sessionId, entry.workspaceId || void 0).catch(() => {});
1140
+ if (entry.workspaceId !== "") await deps.host.detach(entry.sessionId, entry.workspaceId).catch(() => {});
1141
+ await deps.fs.rename(sessionDir, trashDir).catch(() => {});
1142
+ throw verbError("io", `取消归档失败:${String(error)}`, error);
1143
+ }
1144
+ try {
1145
+ await deps.manifest.remove(entry.id);
1146
+ } catch (error) {
1147
+ if (!entry.wasArchived) await deps.host.archive(entry.sessionId, entry.workspaceId || void 0).catch(() => {});
1148
+ if (entry.workspaceId !== "") await deps.host.detach(entry.sessionId, entry.workspaceId).catch(() => {});
1149
+ await deps.fs.rename(sessionDir, trashDir).catch(() => {});
1150
+ throw verbError("io", `摘回收站条目失败:${String(error)}`, error);
1151
+ }
1152
+ return async () => {
1153
+ await deps.manifest.add(entry).catch(() => {});
1154
+ if (!entry.wasArchived) await deps.host.archive(entry.sessionId, entry.workspaceId || void 0).catch(() => {});
1155
+ if (entry.workspaceId !== "") await deps.host.detach(entry.sessionId, entry.workspaceId).catch(() => {});
1156
+ await deps.fs.rename(sessionDir, trashDir).catch(() => {});
1157
+ if (await deps.host.evictLive(entry.sessionId).catch(() => "unknown") !== "unknown") await deps.host.announceRemoved(entry.sessionId).catch(() => {});
1158
+ };
1159
+ }
1160
+ //#endregion
1161
+ //#region src/verbs/delete-session.ts
1162
+ /**
1163
+ * 删除 = 移入回收站,**按组走**:用户点掉一条父会话时,宿主的客户端把 `origin:"subagent"` 的孩子
1164
+ * 整排藏了 ⇒ 那些目录既看不见也继续占着字节(本机实测 41 条父留了 320 个目录 / 74.6 MB)。
1165
+ * 所以这里把父在盘上的孩子一起搬进来,各自成目录、各自一行,清册用 `parentEntryId` 记它是谁的孩子。
1166
+ *
1167
+ * 三段,顺序承重:
1168
+ * ① **先点组、先验活体**(一个字节都不写)。任一成员在跑就整组拒绝——不替用户终止正在跑的活,
1169
+ * 也绝不允许"父已经搬进回收站、孩子因为在跑而搬不动"这种半套。
1170
+ * ② **逐个搬**:每个成员内部沿用原来那套顺序与就地回滚(归档 → 摘账本 → 搬目录 → 写清册),
1171
+ * 任何一步失败就把这个成员自己退回原地。
1172
+ * ③ **组级回滚**:某一个成员搬不动时,把**已经搬进去的**那些按相反顺序退回原处(清册行跟着摘、
1173
+ * 账本跟着挂、可见性跟着还原),失败照抛——不留"半组在回收站、半组在原地"的世界。
1174
+ */
1175
+ function deleteSession(deps) {
1176
+ return async function deleteSession(sessionId, options = {}) {
1177
+ const kids = await deps.host.subagentMembers(sessionId);
1178
+ const parentProjectDir = await deps.host.projectDirOf(sessionId);
1179
+ if (parentProjectDir === void 0) throw verbError("not-found", `宿主的持久化里没有这个会话:${sessionId}`);
1180
+ const candidates = [{
1181
+ sessionId,
1182
+ projectDir: parentProjectDir
1183
+ }, ...kids.map((k) => ({
1184
+ sessionId: k.sessionId,
1185
+ projectDir: k.projectDir
1186
+ }))];
1187
+ for (const member of candidates) if (await deps.host.isLive(member.sessionId)) throw verbError("live", `会话正在运行:${member.sessionId}`);
1188
+ const parentDir = sessionDirOf(parentProjectDir, sessionId, deps.home);
1189
+ if (!await deps.fs.exists(parentDir)) throw verbError("not-found", `会话目录不存在:${parentDir}`);
1190
+ const members = [{
1191
+ sessionId,
1192
+ projectDir: parentProjectDir
1193
+ }];
1194
+ for (const kid of kids) if (await deps.fs.exists(sessionDirOf(kid.projectDir, kid.sessionId, deps.home))) members.push({
1195
+ sessionId: kid.sessionId,
1196
+ projectDir: kid.projectDir
896
1197
  });
897
- if (entry.workspaceId !== "") try {
898
- await deps.host.attach(entry.sessionId, entry.workspaceId);
1198
+ options.onMembers?.(members.map((member) => member.sessionId));
1199
+ const deletedAt = deps.now();
1200
+ const done = [];
1201
+ let parentEntry;
1202
+ try {
1203
+ for (const member of members) {
1204
+ const isParent = member.sessionId === sessionId;
1205
+ const entry = await moveOneIn(deps, member, deletedAt, isParent ? void 0 : parentEntry.id);
1206
+ if (isParent) parentEntry = entry;
1207
+ done.push(() => undoOneIn(deps, entry));
1208
+ }
899
1209
  } catch (error) {
900
- await deps.fs.rename(sessionDir, trashDir).catch(() => {});
901
- throw verbError("io", `挂回账本失败:${String(error)}`, error);
1210
+ for (const undo of done.reverse()) await undo();
1211
+ throw error;
902
1212
  }
903
- if (!entry.wasArchived) try {
904
- await deps.host.unarchive(entry.sessionId);
1213
+ return parentEntry;
1214
+ };
1215
+ }
1216
+ /**
1217
+ * 搬一个成员进回收站。五件写的顺序照设计稿 §4 那五触点,每一步的失败都**就地**把这个成员自己退回原地:
1218
+ * 归档必须先于摘账本(否则中间有一段"无主且可见"的窗口,它会掉进「未分组」),文件在两件纯读之后,
1219
+ * 清册行是最后一件写(它失败就把目录挪回去、账本挂回去、可见性还原)。
1220
+ */
1221
+ async function moveOneIn(deps, member, deletedAt, parentEntryId) {
1222
+ const { sessionId, projectDir } = member;
1223
+ const workspaceId = await deps.host.workspaceOf(sessionId);
1224
+ const sessionDir = sessionDirOf(projectDir, sessionId, deps.home);
1225
+ const wasArchived = await deps.host.isArchived(sessionId);
1226
+ const entryId = makeEntryId(deletedAt, sessionId);
1227
+ const trashDir = trashDirOf(entryId, deps.home);
1228
+ let title;
1229
+ let sizeBytes;
1230
+ try {
1231
+ title = await deps.host.titleOf(sessionId);
1232
+ sizeBytes = await deps.fs.sizeOfDir(sessionDir);
1233
+ } catch (error) {
1234
+ throw verbError("io", `读取会话现场失败:${String(error)}`, error);
1235
+ }
1236
+ if (!wasArchived) try {
1237
+ await deps.host.archive(sessionId, workspaceId ?? void 0);
1238
+ } catch (error) {
1239
+ throw verbError("io", `归档失败:${String(error)}`, error);
1240
+ }
1241
+ if (workspaceId !== void 0) try {
1242
+ await deps.host.detach(sessionId, workspaceId);
1243
+ } catch (error) {
1244
+ if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
1245
+ throw verbError("io", `摘账本失败:${String(error)}`, error);
1246
+ }
1247
+ const entry = {
1248
+ id: entryId,
1249
+ sessionId,
1250
+ projectDir,
1251
+ workspaceId: workspaceId ?? "",
1252
+ wasArchived,
1253
+ deletedAt,
1254
+ title,
1255
+ sizeBytes,
1256
+ ...parentEntryId === void 0 ? {} : { parentEntryId }
1257
+ };
1258
+ try {
1259
+ await deps.fs.ensureDir(`${deps.home}/session-trash`);
1260
+ await deps.fs.rename(sessionDir, trashDir);
1261
+ } catch (error) {
1262
+ if (workspaceId !== void 0) await deps.host.attach(sessionId, workspaceId).catch(() => {});
1263
+ if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
1264
+ throw verbError("io", `移动会话目录失败:${String(error)}`, error);
1265
+ }
1266
+ try {
1267
+ await deps.manifest.add(entry);
1268
+ } catch (error) {
1269
+ await deps.fs.rename(trashDir, sessionDir).catch(() => {});
1270
+ if (workspaceId !== void 0) await deps.host.attach(sessionId, workspaceId).catch(() => {});
1271
+ if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
1272
+ throw verbError("io", `登记回收站条目失败:${String(error)}`, error);
1273
+ }
1274
+ if (await deps.host.evictLive(sessionId) !== "unknown") await deps.host.announceRemoved(sessionId);
1275
+ return entry;
1276
+ }
1277
+ /** 把一个**已经搬进去**的成员退回删除前:目录 → 账本 → 可见性 → 清册行 → 通告回来。 */
1278
+ async function undoOneIn(deps, entry) {
1279
+ const sessionDir = sessionDirOf(entry.projectDir, entry.sessionId, deps.home);
1280
+ await deps.fs.rename(trashDirOf(entry.id, deps.home), sessionDir).catch(() => {});
1281
+ if (entry.workspaceId !== "") await deps.host.attach(entry.sessionId, entry.workspaceId).catch(() => {});
1282
+ if (!entry.wasArchived) await deps.host.unarchive(entry.sessionId).catch(() => {});
1283
+ await deps.manifest.remove(entry.id).catch(() => {});
1284
+ await deps.host.announceRestored(entry.sessionId).catch(() => {});
1285
+ }
1286
+ //#endregion
1287
+ //#region src/verbs/apply.ts
1288
+ /**
1289
+ * 批量动作(2026-10-17 重构批):`POST /apply` 的落点——**一组 id 上的同一个动作**。
1290
+ *
1291
+ * 为什么要这一层:批量恢复 100+ 条的真实成本不在搬文件,在**每条一次的全表读**——旧形状是
1292
+ * 浏览器半循环 N 次 HTTP,每次都走「读全量清册 + 预检 + 逐成员通告时再拉一遍宿主会话表」。
1293
+ * 这一层把整批抬成一次:清册只读一次、宿主会话表只预取一次、通告一次批量发;
1294
+ * 单条动作就是 N=1 的批量,**仓里不再留第二条执行路径**。
1295
+ *
1296
+ * 三条批量语义(都是接口的一部分,不是实现细节):
1297
+ * ① 输入去重、保持输入顺序(界面上"清单从上往下"的那一份读感);
1298
+ * ② 一条失败不传染下一条——**逐条 try/catch**,失败按 `map-error` 那张表折成信封里的
1299
+ * `{code,message,details?}`;
1300
+ * ③ 「已被同批前一条带走」是**幂等成功**,不是失败:恢复一条已经跟着父回来的孩子,
1301
+ * 用户要的结果本来就已经达成了(`delete`/`restore` 回 `null`;`purge` 回
1302
+ * `{removed:0,freedBytes:0}`)。
1303
+ */
1304
+ function failure(id, action, error) {
1305
+ return {
1306
+ id,
1307
+ action,
1308
+ ok: false,
1309
+ error: toRemoteFailure(error)
1310
+ };
1311
+ }
1312
+ /** 按动作收窄的成功支(判别联合要的是**字面量**,所以动作参数按 `A` 收,不收成宽化的 `ApplyAction`)。 */
1313
+ function success(id, action, value) {
1314
+ return {
1315
+ id,
1316
+ action,
1317
+ ok: true,
1318
+ value
1319
+ };
1320
+ }
1321
+ /** 清册快照里"这条 id 还认得出一个组吗":父行在、或它是某个还在的父亲的孩子,都算在。 */
1322
+ function groupOf(snapshot, id) {
1323
+ const root = snapshot.find((e) => e.id === id);
1324
+ if (root !== void 0) return root.parentEntryId === void 0 ? [root, ...snapshot.filter((e) => e.parentEntryId === id)] : [root];
1325
+ const kids = snapshot.filter((e) => e.parentEntryId === id);
1326
+ return kids.length > 0 ? kids : [];
1327
+ }
1328
+ /**
1329
+ * 「这条是被**同批前一条**带走的,还是整批开始前就没有?」——幂等语义只属于前者。
1330
+ * 后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它),必须照样响亮报 `trash-empty`,
1331
+ * 不能被"已被带走"这句话掩盖成一次成功。**所以判据是快照的原始形状,不是当前形状**:
1332
+ * 原始快照里认得出(它自己、它的父、或它的孩子任一在),而当前快照里已经空了 ⇒ 同批带走的。
1333
+ */
1334
+ function takenByEarlierInBatch(original, current, id) {
1335
+ return groupOf(original, id).length > 0 && groupOf(current, id).length === 0;
1336
+ }
1337
+ function applyActions(deps, action, ids) {
1338
+ switch (action) {
1339
+ case "delete": return applyDelete(deps, ids);
1340
+ case "restore": return applyRestore(deps, ids);
1341
+ case "purge": return applyPurge(deps, ids);
1342
+ case "count": return applyCount(deps, ids);
1343
+ }
1344
+ }
1345
+ async function applyDelete(deps, ids) {
1346
+ const run = deleteSession(deps);
1347
+ const outcomes = [];
1348
+ const taken = /* @__PURE__ */ new Set();
1349
+ for (const sessionId of unique(ids)) {
1350
+ if (taken.has(sessionId)) {
1351
+ outcomes.push(success(sessionId, "delete", null));
1352
+ continue;
1353
+ }
1354
+ let members = [];
1355
+ try {
1356
+ const entry = await run(sessionId, { onMembers: (sessionIds) => {
1357
+ members = sessionIds;
1358
+ } });
1359
+ for (const id of members) taken.add(id);
1360
+ outcomes.push(success(sessionId, "delete", entry));
905
1361
  } catch (error) {
906
- await deps.host.archive(entry.sessionId, entry.workspaceId || void 0).catch(() => {});
907
- if (entry.workspaceId !== "") await deps.host.detach(entry.sessionId, entry.workspaceId).catch(() => {});
908
- await deps.fs.rename(sessionDir, trashDir).catch(() => {});
909
- throw verbError("io", `取消归档失败:${String(error)}`, error);
1362
+ outcomes.push(failure(sessionId, "delete", error));
1363
+ }
1364
+ }
1365
+ return outcomes;
1366
+ }
1367
+ async function applyRestore(deps, ids) {
1368
+ const snapshot = await deps.manifest.list();
1369
+ const original = [...snapshot];
1370
+ const run = restoreSession(deps);
1371
+ const outcomes = [];
1372
+ const restored = [];
1373
+ for (const entryId of unique(ids)) {
1374
+ const group = groupOf(snapshot, entryId);
1375
+ if (group.length === 0 && takenByEarlierInBatch(original, snapshot, entryId)) {
1376
+ outcomes.push(success(entryId, "restore", null));
1377
+ continue;
910
1378
  }
911
1379
  try {
912
- await deps.manifest.remove(entry.id);
1380
+ const entry = await run(entryId, {
1381
+ snapshot,
1382
+ suppressAnnounce: true
1383
+ });
1384
+ restored.push(...(group.length > 0 ? group : [entry]).map((e) => e.sessionId));
1385
+ for (const member of group.length > 0 ? group : [entry]) {
1386
+ const at = snapshot.indexOf(member);
1387
+ if (at >= 0) snapshot.splice(at, 1);
1388
+ }
1389
+ outcomes.push(success(entryId, "restore", entry));
913
1390
  } catch (error) {
914
- if (!entry.wasArchived) await deps.host.archive(entry.sessionId, entry.workspaceId || void 0).catch(() => {});
915
- if (entry.workspaceId !== "") await deps.host.detach(entry.sessionId, entry.workspaceId).catch(() => {});
916
- await deps.fs.rename(sessionDir, trashDir).catch(() => {});
917
- throw verbError("io", `摘回收站条目失败:${String(error)}`, error);
1391
+ outcomes.push(failure(entryId, "restore", error));
918
1392
  }
919
- await deps.host.announceRestored(entry.sessionId);
920
- return entry;
921
- };
1393
+ }
1394
+ if (restored.length > 0) await deps.host.announceRestoredBatch(restored, await deps.host.restoredSummariesOf());
1395
+ return outcomes;
1396
+ }
1397
+ async function applyPurge(deps, ids) {
1398
+ const snapshot = await deps.manifest.list();
1399
+ const original = [...snapshot];
1400
+ const run = purgeTrash(deps);
1401
+ const outcomes = [];
1402
+ for (const entryId of unique(ids)) {
1403
+ if (groupOf(snapshot, entryId).length === 0 && takenByEarlierInBatch(original, snapshot, entryId)) {
1404
+ outcomes.push(success(entryId, "purge", {
1405
+ removed: 0,
1406
+ freedBytes: 0
1407
+ }));
1408
+ continue;
1409
+ }
1410
+ try {
1411
+ outcomes.push(success(entryId, "purge", await run(entryId, snapshot)));
1412
+ } catch (error) {
1413
+ outcomes.push(failure(entryId, "purge", error));
1414
+ }
1415
+ }
1416
+ return outcomes;
1417
+ }
1418
+ /** 删除确认框那句「含 N 个子会话」:一次问完整批(`subagentCounts` 本来就是批量口)。
1419
+ * 宿主口缺席时它回 `{}`(这里折成 0);真读失败则整次调用失败,浏览器半按"读不到"把整句不说。 */
1420
+ async function applyCount(deps, ids) {
1421
+ const wanted = unique(ids);
1422
+ const counts = await deps.host.subagentCounts(wanted);
1423
+ return wanted.map((id) => success(id, "count", counts[id] ?? 0));
1424
+ }
1425
+ /** 去重且保持输入顺序(重复 id 跑第二遍是"幂等成功"的伪装,不该发生)。 */
1426
+ function unique(ids) {
1427
+ const seen = /* @__PURE__ */ new Set();
1428
+ const out = [];
1429
+ for (const id of ids) {
1430
+ if (seen.has(id)) continue;
1431
+ seen.add(id);
1432
+ out.push(id);
1433
+ }
1434
+ return out;
922
1435
  }
923
1436
  //#endregion
924
1437
  //#region src/api/dispatch.ts
@@ -967,9 +1480,16 @@ function writeOk(res, value) {
967
1480
  value
968
1481
  });
969
1482
  }
970
- /** 失败信封:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表),值就是线上形状本身。 */
971
- function writeFailure(res, error, status = 200) {
972
- writeJson(res, status, {
1483
+ /** 失败信封:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表)。
1484
+ * **状态语义(2026-10-17 错误面对齐)**:**请求本身不对**不再伪装成 200——请求体不对 400、方法不对 405、
1485
+ * 未知路由 404、栅栏拒 403(它们不是"一次办成的操作",是"这条请求不归我办/你没资格办")。
1486
+ * **操作失败**仍回 200 + 信封(口径 ③ 不变:盘上没有、原目录已不在、会话在跑……都是办完了才知道的结果)。
1487
+ * 浏览器半先解信封再看状态,所以 4xx 上带的信封照样读得出原文;`code` 一律折成 `sessiondelete/io`,
1488
+ * 那三个路由标签(`bad-request` / `bad-method` / `not-found-route`)是**抛出处给读代码的人看的站点名**,
1489
+ * 不许冒充 §6 的五个动词码上客户端。 */
1490
+ function writeFailure(res, error, status) {
1491
+ const carried = error?.httpStatus;
1492
+ writeJson(res, status ?? (typeof carried === "number" ? carried : 200), {
973
1493
  ok: false,
974
1494
  error: toRemoteFailure(error)
975
1495
  });
@@ -1005,68 +1525,136 @@ function isTrustedRequest(req) {
1005
1525
  return false;
1006
1526
  }
1007
1527
  }
1008
- /** 流式读 body 并解析成 JSON 对象:空体回 `null`,超限 / 非法 JSON / 不是对象一律抛(→ 失败信封)。 */
1528
+ /** 流式读 body 并解析成 JSON 对象:空体回 `null`,超限 / 非法 JSON / 不是对象一律抛(→ 400 + 失败信封)。
1529
+ * **请求本身不对**(400)与**那次操作读不到东西**(200 + 信封)是两件事:前者是调用方没组好请求,
1530
+ * 后者是盘上没有;把前者也写成 200,代理与日志里就再也分不出这两类。 */
1009
1531
  async function readJsonBody(req) {
1010
1532
  const chunks = [];
1011
1533
  let size = 0;
1012
1534
  for await (const chunk of req) {
1013
1535
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1014
1536
  size += buf.length;
1015
- if (size > JSON_BODY_MAX_BYTES) throw new Error(`请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
1537
+ if (size > JSON_BODY_MAX_BYTES) throw routeError("bad-request", 400, `请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
1016
1538
  chunks.push(buf);
1017
1539
  }
1018
1540
  const text = Buffer.concat(chunks).toString("utf8").trim();
1019
1541
  if (text === "") return null;
1020
- let parsed;
1021
1542
  try {
1022
- parsed = JSON.parse(text);
1543
+ const parsed = JSON.parse(text);
1544
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw routeError("bad-request", 400, "请求体需为 JSON 对象");
1545
+ return parsed;
1023
1546
  } catch (error) {
1024
- throw new Error(`请求体不是合法 JSON:${error instanceof Error ? error.message : String(error)}`);
1547
+ if (error instanceof SyntaxError) throw routeError("bad-request", 400, `请求体不是合法 JSON:${error.message}`);
1548
+ throw error;
1025
1549
  }
1026
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("请求体需为 JSON 对象");
1027
- return parsed;
1028
1550
  }
1029
1551
  /** 必填的非空字符串字段(缺、空串、类型不对都是**同一句话**:说清是哪个字段)。 */
1030
1552
  function requireString(body, field) {
1031
1553
  const value = body?.[field];
1032
- if (typeof value !== "string" || value === "") throw new Error(`请求体需带非空字符串字段 ${field}`);
1554
+ if (typeof value !== "string" || value === "") throw routeError("bad-request", 400, `请求体需带非空字符串字段 ${field}`);
1033
1555
  return value;
1034
1556
  }
1035
- /** `purge` 的 `entryId` 可选(缺席 = 清空),但在场就必须是像样的值——不把 `42` 当成"没带"。 */
1036
- function optionalString(body, field) {
1037
- const value = body?.[field];
1038
- if (value === void 0) return void 0;
1039
- if (typeof value !== "string" || value === "") throw new Error(`字段 ${field} 若在场必须是非空字符串`);
1557
+ /** `/apply` 的动作:认不得的一律点名拒掉,不折成"空批量"糊弄过去。 */
1558
+ const APPLY_ACTIONS = [
1559
+ "delete",
1560
+ "restore",
1561
+ "purge",
1562
+ "count"
1563
+ ];
1564
+ function requireAction(body) {
1565
+ const value = body?.[PARAMS.action];
1566
+ if (typeof value !== "string" || !APPLY_ACTIONS.includes(value)) throw routeError("bad-request", 400, `批量动作不认识:需是 ${APPLY_ACTIONS.join(" / ")} 之一`);
1567
+ return value;
1568
+ }
1569
+ /** `/apply` 的 id 组:必须是非空字符串数组(空数组不是"什么都不做",是调用方自己没组好)。 */
1570
+ function requireIdArray(body) {
1571
+ const value = body?.[PARAMS.ids];
1572
+ if (!Array.isArray(value) || value.length === 0 || value.some((id) => typeof id !== "string" || id === "")) throw routeError("bad-request", 400, `请求体需带 ${PARAMS.ids}:非空字符串组成的非空数组`);
1040
1573
  return value;
1041
1574
  }
1042
1575
  function requireMethod(method, want, route) {
1043
- if (method !== want) throw new Error(`方法不允许:${method}(${SESSION_DELETE_API_PREFIX}${route} 需 ${want})`);
1576
+ if (method !== want) throw routeError("bad-method", 405, `方法不允许:${method}(${SESSION_DELETE_API_PREFIX}${route} 需 ${want})`);
1577
+ }
1578
+ /**
1579
+ * 把清册的全部行装配成「一行 = 一组」:孩子折进父那一行、不单独列(宿主的客户端本来就藏孩子,
1580
+ * 我们若单列就会冒出一堆点不到的行);父行不在时孩子自己站出来占一行,否则它既看不见也清不掉。
1581
+ * 三个只读数只加在这次回信上,**不落清册**——清册该记的是
1582
+ * 文件里真有的事实,而"还剩几个孩子"是随盘上随时变的一个数。取数失败照抛(响亮失败,不端一份
1583
+ * 少了这栏的清单当没事发生)。
1584
+ */
1585
+ async function assembleTrashRows(deps, rows) {
1586
+ const byId = new Map(rows.map((row) => [row.id, row]));
1587
+ const foldedInto = /* @__PURE__ */ new Map();
1588
+ const kidsByParent = /* @__PURE__ */ new Map();
1589
+ for (const row of rows) {
1590
+ if (row.parentEntryId === void 0) continue;
1591
+ const parent = byId.get(row.parentEntryId);
1592
+ if (parent === void 0 || parent.parentEntryId !== void 0) continue;
1593
+ foldedInto.set(row.id, parent.id);
1594
+ const siblings = kidsByParent.get(parent.id);
1595
+ if (siblings === void 0) kidsByParent.set(parent.id, [row]);
1596
+ else siblings.push(row);
1597
+ }
1598
+ const visible = rows.filter((row) => !foldedInto.has(row.id));
1599
+ const stranded = await deps.host.subagentCounts(visible.map((row) => row.sessionId));
1600
+ return visible.map((row) => {
1601
+ const kids = kidsByParent.get(row.id);
1602
+ const left = stranded[row.sessionId];
1603
+ if (kids === void 0 && left === void 0) return row;
1604
+ return {
1605
+ ...row,
1606
+ ...kids === void 0 ? {} : {
1607
+ subagentCount: kids.length,
1608
+ subagentBytes: kids.reduce((total, kid) => total + kid.sizeBytes, 0)
1609
+ },
1610
+ ...left === void 0 ? {} : { strandedSubagents: left }
1611
+ };
1612
+ });
1044
1613
  }
1045
- /** 路由表:段名 → 一个把「方法 + 请求体」变成返回值的处理器。动词在每次调用时才取。 */
1046
- async function dispatch(method, rest, req, deps) {
1614
+ /** 路由表:段名 → 一个把「方法 + 预读好的请求体」变成返回值的处理器。动词在每次调用时才取。
1615
+ * `body` 由 {@link purityOf} 读一次交下来——同一条请求的流不能被消费两遍。 */
1616
+ async function dispatch(method, rest, body, deps) {
1047
1617
  if (rest === ROUTES.list) {
1048
1618
  requireMethod(method, "GET", ROUTES.list);
1049
- return await reconcileTrash(deps);
1619
+ return assembleTrashRows(deps, await reconcileTrash(deps));
1050
1620
  }
1051
- if (rest === ROUTES.delete) {
1052
- requireMethod(method, "POST", ROUTES.delete);
1053
- return await deleteSession(deps)(requireString(await readJsonBody(req), PARAMS.sessionId));
1621
+ if (rest === ROUTES.ping) {
1622
+ requireMethod(method, "GET", ROUTES.ping);
1623
+ return "pong";
1054
1624
  }
1055
- if (rest === ROUTES.restore) {
1056
- requireMethod(method, "POST", ROUTES.restore);
1057
- return await restoreSession(deps)(requireString(await readJsonBody(req), PARAMS.entryId));
1625
+ if (rest === ROUTES.apply) {
1626
+ requireMethod(method, "POST", ROUTES.apply);
1627
+ return await applyActions(deps, requireAction(body), requireIdArray(body));
1058
1628
  }
1059
- if (rest === ROUTES.purge) {
1060
- requireMethod(method, "POST", ROUTES.purge);
1061
- return await purgeTrash(deps)(optionalString(await readJsonBody(req), PARAMS.entryId));
1629
+ if (rest === ROUTES.empty) {
1630
+ requireMethod(method, "POST", ROUTES.empty);
1631
+ return await purgeTrash(deps)();
1062
1632
  }
1063
1633
  if (rest === ROUTES.detail) {
1064
1634
  requireMethod(method, "POST", ROUTES.detail);
1065
- return await readTrashDetail(deps)(requireString(await readJsonBody(req), PARAMS.entryId));
1635
+ return await readTrashDetail(deps)(requireString(body, PARAMS.entryId));
1066
1636
  }
1067
- throw new Error(`未知路由:${method} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
1637
+ throw routeError("not-found-route", 404, `未知路由:${method} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
1638
+ }
1639
+ async function purityOf(req, rest) {
1640
+ if (rest === ROUTES.ping) return {
1641
+ pure: true,
1642
+ body: null
1643
+ };
1644
+ if (rest === ROUTES.detail) return {
1645
+ pure: true,
1646
+ body: await readJsonBody(req)
1647
+ };
1648
+ if (rest !== ROUTES.apply) return {
1649
+ pure: false,
1650
+ body: null
1651
+ };
1652
+ const body = await readJsonBody(req);
1653
+ return {
1654
+ pure: body?.[PARAMS.action] === "count",
1655
+ body
1656
+ };
1068
1657
  }
1069
- /** 宿主 `webServer.register({kind:'prefix', path: SESSION_DELETE_API_PREFIX, handler})` 的那个 handler。 */
1070
1658
  function createApiHandler(deps) {
1071
1659
  /**
1072
1660
  * 到达序串行链(文件头 ⑤):`list`(含对账)与三个写动词一个跑完再放下一个。`tail` 由构造保证
@@ -1085,10 +1673,11 @@ function createApiHandler(deps) {
1085
1673
  }
1086
1674
  try {
1087
1675
  const url = new URL(req.url ?? "/", "http://dsh.internal");
1088
- if (!url.pathname.startsWith("/dsh-session-delete-api")) throw new Error(`未知路由:${url.pathname}`);
1676
+ if (!url.pathname.startsWith("/dsh-session-delete-api")) throw routeError("not-found-route", 404, `未知路由:${url.pathname}`);
1089
1677
  const rest = url.pathname.slice(23);
1090
- const task = () => dispatch(req.method ?? "GET", rest, req, deps);
1091
- writeOk(res, await (rest === ROUTES.detail ? task() : throughChain(task)));
1678
+ const { pure, body } = await purityOf(req, rest);
1679
+ const task = () => dispatch(req.method ?? "GET", rest, body, deps);
1680
+ writeOk(res, await (pure ? task() : throughChain(task)));
1092
1681
  } catch (error) {
1093
1682
  writeFailure(res, error);
1094
1683
  }
@@ -1165,7 +1754,7 @@ const name = "@xrn1997/dsh-session-delete";
1165
1754
  * 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
1166
1755
  *
1167
1756
  * `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
1168
- * 前缀路由,浏览器半同源 fetch 那五个端点。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
1757
+ * 前缀路由,浏览器半同源 fetch 那五个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
1169
1758
  * `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
1170
1759
  * `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
1171
1760
  */
@@ -1202,7 +1791,9 @@ function defineDomain(spec) {
1202
1791
  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}`);
1203
1792
  return spec;
1204
1793
  }
1205
- /** `TrashEntry` 的字段面(`src/trash/entry.ts` 的接口逐项)。 */
1794
+ /** `TrashEntry` 里**每条记录都必须有**的那几格(`src/trash/entry.ts` 接口的非可选字段)。
1795
+ * **可选字段不在这里**:`parentEntryId`(级联那批加的)缺席是合法的——老行没有它,缺了就是"没有父";
1796
+ * 校验器只管这几格的类型,其余键**原样带走**(`parse` 交回的就是 `raw` 本身)⇒ 新字段照旧存得住。 */
1206
1797
  const TRASH_ENTRY_FIELDS = {
1207
1798
  id: "string",
1208
1799
  sessionId: "string",
@@ -1233,18 +1824,30 @@ const trashDomainSpec = defineDomain({
1233
1824
  });
1234
1825
  /**
1235
1826
  * 清单的真实绑定:官方 storage domain → `ManifestPort`(`StorageDomainLike` 在动词层保持一种读法)。
1236
- * 域**懒开**且只开一次(`apply` 是同步的,而 `open()` 是异步的),并挂一条 `ctx.effect` 清理:
1237
- * 插件卸载时关掉句柄(句柄归调用方所有,这是 `open()` 的 JSDoc 明写的契约)。
1827
+ * 域**懒开**且成功句柄只开一次(`apply` 是同步的,而 `open()` 是异步的),并挂一条 `ctx.effect` 清理:
1828
+ * 插件卸载时关掉句柄并把 `close()` 的落地交回框架(句柄归调用方所有,这是 `open()` 的 JSDoc 明写的契约;
1829
+ * 2026-10-17 起不再 `void` 走掉——没关严的句柄会让下一份同名域撞 `already-open`)。
1830
+ *
1831
+ * **失败不缓存**:`open()` 被拒是瞬态(域被别的持有者占着、装载竞态),把第一次的 rejected promise
1832
+ * 缓存起来等于"这个会话永远删不掉"——下一次读会重新走 `open()`,成功才落下活句柄。
1238
1833
  */
1239
1834
  function createStorageManifest(ctx) {
1240
1835
  const host = ctx;
1836
+ let domain;
1241
1837
  let opening;
1242
- ctx.effect(() => () => {
1243
- opening?.then((handle) => handle.close()).catch(() => {});
1838
+ ctx.effect(() => async () => {
1839
+ await ((opening !== void 0 ? await opening.catch(() => void 0) : void 0) ?? domain)?.close();
1244
1840
  }, "session-delete.manifest");
1245
1841
  const table = async () => {
1246
- opening ??= host.storageDomain.open(trashDomainSpec);
1247
- return (await opening).table("entries");
1842
+ if (domain === void 0) {
1843
+ opening ??= host.storageDomain.open(trashDomainSpec);
1844
+ try {
1845
+ domain = await opening;
1846
+ } finally {
1847
+ opening = void 0;
1848
+ }
1849
+ }
1850
+ return domain.table("entries");
1248
1851
  };
1249
1852
  return createStorageManifest$1({
1250
1853
  readAll: async () => [...(await table()).entries()].map(([, record]) => record),