@xrn1997/dsh-session-delete 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +6 -5
  2. package/icon.svg +18 -18
  3. package/lib/client.js +1448 -487
  4. package/lib/index.js +1185 -455
  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,19 +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` 读清单,其余四个 `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`:清空回收站(聚合回执,**不**按条回)。 */
32
38
  const ROUTES = {
33
39
  list: "/list",
34
- delete: "/delete",
35
- restore: "/restore",
36
- purge: "/purge",
37
- /** 单条详情(读回收站目录里那条会话自己的日志)。用 POST 表达一个读:见设计稿 2026-10-15 §3。 */
38
- detail: "/detail"
40
+ /** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
41
+ ping: "/ping",
42
+ /** 单条详情(读回收站目录里那条会话自己的日志)。 */
43
+ detail: "/detail",
44
+ /** 一组 id 上的同一个动作;删除确认框那句「含 N 个子会话」也走这里(`action: 'count'`,纯读语义)。 */
45
+ apply: "/apply",
46
+ /** 清空回收站。 */
47
+ empty: "/empty"
39
48
  };
40
- /** 请求体字段名:浏览器半发、Node 半读,同名同义(`purge` 的 `entryId` 缺席 = 清空)。 */
49
+ /** 请求体字段名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。 */
41
50
  const PARAMS = {
42
- sessionId: "sessionId",
43
- entryId: "entryId"
51
+ entryId: "entryId",
52
+ action: "action",
53
+ ids: "ids"
44
54
  };
45
55
  /**
46
56
  * 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
@@ -66,10 +76,18 @@ const VERB_CODES = [
66
76
  "project-missing",
67
77
  "io"
68
78
  ];
69
- function verbError(code, message, cause) {
79
+ function verbError(code, message, cause, details) {
70
80
  const e = new Error(message);
71
81
  e.code = `sessiondelete/${code}`;
72
- 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;
73
91
  return e;
74
92
  }
75
93
  //#endregion
@@ -85,13 +103,14 @@ const HOST_ERROR_CODE = {
85
103
  WorkspaceUnknownSessionError: "not-found",
86
104
  WorkspaceActiveSessionError: "live"
87
105
  };
88
- /** 错误与它的 `cause` 链上最近的一个宿主类型化错误码(深度有界,链坏掉不递归下去)。 */
89
- function hostCodeOf(error) {
106
+ /** cause 链上第一个**宿主类型化错误**本身(不只它的名字)——它自己身上的字段还要当 `details` 用。
107
+ * 深度有界(链坏掉不递归下去);查表必须 `hasOwn`:`in` 会命中 `Object.prototype` 的键。 */
108
+ function hostErrorOf(error) {
90
109
  let current = error;
91
110
  for (let depth = 0; depth < 8; depth++) {
92
111
  if (current === null || typeof current !== "object") return void 0;
93
112
  const name = current.name;
94
- 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;
95
114
  current = current.cause;
96
115
  }
97
116
  }
@@ -105,32 +124,86 @@ function messageOf(error) {
105
124
  }
106
125
  return String(error);
107
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
+ }
108
159
  /**
109
- * 把任意抛出物折成失败信封里的**那两件**(设计稿 §6 唯一那张映射表)。
160
+ * 把任意抛出物折成失败信封里的**那三件**(设计稿 §6 唯一那张映射表)。
110
161
  *
111
- * - 已经是远程失败(`isDSHRemoteError` 认得的结构标记)⇒ 原样带出它自己的 `code` / `message`,不再包一层。
112
- * - 宿主类型化错误(自身或 `cause` 链上,见 {@link HOST_ERROR_CODE})⇒ 映射成 `live` / `not-found`。
113
- * 放在动词码之前判:动词把宿主错误包成笼统的 `io` 并把原错误挂在 cause 上(`verbError('io', …, cause)`),
114
- * 不这样看进 cause 链,§6 那两行映射就永远到不了客户端。
115
- * - 动词的 `sessiondelete/<五个码>` ⇒ 原样过桥。
116
- * - 其余(含别域码、前缀外的因、非 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 保留原文。
117
171
  *
118
- * **返回的是线上形状、不是 `Error`**:这份东西唯一的去处是 `src/api/dispatch.ts` 的失败信封,两种判别
119
- * (`isDSHRemoteError` 与 {@link HOST_ERROR_CODE})都只用到 `code` / `message`——造一个真 `Error` 没有消费者,
120
- * 而本仓的跨插件 import 红线也不许为此去引宿主的 `RemoteError` 类(实例也上不了线:信封只走这两件)。
172
+ * **返回的是线上形状、不是 `Error`**:这份东西唯一的去处是 `src/api/dispatch.ts` 的失败信封
173
+ * (以及 `verbs/apply.ts` 的逐条 `error`),而本仓的跨插件 import 红线也不许为此去引宿主的
174
+ * `RemoteError` 类(实例也上不了线:信封只走这三件)。
121
175
  */
122
176
  function toRemoteFailure(error) {
123
- if (isDSHRemoteError(error)) return {
124
- code: error.code,
125
- message: String(error.message)
126
- };
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
+ }
127
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
+ }
128
194
  const reason = typeof raw === "string" && raw.startsWith(`${DOMAIN}/`) ? raw.slice(14) : void 0;
129
195
  const fromVerb = reason !== void 0 && VERB_CODES.includes(reason) ? reason : void 0;
130
- 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";
131
203
  return {
132
204
  code: `${DOMAIN}/${code}`,
133
- message: messageOf(error)
205
+ message: messageOf(error),
206
+ ...own === void 0 ? {} : { details: own }
134
207
  };
135
208
  }
136
209
  //#endregion
@@ -272,6 +345,39 @@ function frameRanges(raw) {
272
345
  * 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
273
346
  */
274
347
  const NEWLINE = 10;
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
+ }
375
+ /**
376
+ * 逐帧走一遍日志,每条记录交给回调。
377
+ *
378
+ * 回调返回 `false` = **提前停机**(后面的帧不再解——`readSessionHeader` 只要第一行)。
379
+ * 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
380
+ */
275
381
  function walkSessionLog(raw, onRecord) {
276
382
  const stats = {
277
383
  frames: 0,
@@ -279,6 +385,7 @@ function walkSessionLog(raw, onRecord) {
279
385
  unparsedLines: 0,
280
386
  truncatedTailBytes: 0
281
387
  };
388
+ let stopped = false;
282
389
  const feed = (line) => {
283
390
  if (line === "") return;
284
391
  let parsed;
@@ -289,11 +396,12 @@ function walkSessionLog(raw, onRecord) {
289
396
  return;
290
397
  }
291
398
  stats.records += 1;
292
- onRecord(parsed !== null && typeof parsed === "object" ? parsed : {});
399
+ if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
293
400
  };
294
401
  /** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
295
402
  let carry = Buffer.alloc(0);
296
403
  for (const { start, end } of frameRanges(raw)) {
404
+ if (stopped) break;
297
405
  stats.frames += 1;
298
406
  let plain;
299
407
  try {
@@ -308,40 +416,33 @@ function walkSessionLog(raw, onRecord) {
308
416
  continue;
309
417
  }
310
418
  carry = merged.subarray(cut + 1);
311
- for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) feed(line);
419
+ for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
420
+ feed(line);
421
+ if (stopped) break;
422
+ }
312
423
  }
313
424
  stats.truncatedTailBytes = carry.length;
314
- if (carry.length > 0) feed(carry.toString("utf8"));
425
+ if (carry.length > 0 && !stopped) feed(carry.toString("utf8"));
315
426
  return stats;
316
427
  }
317
- //#endregion
318
- //#region src/trash/detail.ts
319
428
  /**
320
- * 回收站**单条详情**:从回收站目录里那条会话自己的日志读出元信息与正文(设计稿 2026-10-15 §3)。
321
- *
322
- * 三层里的策略层——编解码在 `src/log/*`,这里只回答"给谁看、给多少、算不算失败"。
323
- *
324
- * 三条硬口径,都不是口味:
325
- * ① **寻址只认清册**:`entryId` 先在清单里查,目录一律由 `trashDirOf(record.id, home)` 推 ⇒ 外部
326
- * 输入进不了文件系统寻址(不给"传个路径就读任意目录"留缝)。
327
- * ② **正文只认 `source.kind === "user"`**:`user/message` 里绝大多数是宿主注入的上下文
328
- * (本机实测一条会话:真实提问 21 条、注入 241 条),不过滤就被淹掉;注入条数照样上线
329
- * (`injectedCount`),让"为什么少了这么多条"能与页面上的数字对账。
330
- * ③ **两个闸是载荷闸,不是读盘闸**:每条 3 000 字符、累计 50 万字符(CJK 最坏 ≈ 1.5 MB UTF-8)。
331
- * 按 §1 的读数本机一条都触发不了(最大那条正文 806 KB ≈ 27 万字符、442 条消息)⇒ 它们是防异常
332
- * 膨胀的;**不设"文件太大就拒绝"**,那种阈值只会把真读得出来的正文挡在外面。
333
- *
334
- * 计数与正文**同源同判据**:`userMessageCount` 就是进了正文的提问条数、`assistantMessageCount` 就是
335
- * 有文字的回复条数——不是一套判据算两遍,否则页面上会出现"计数 421、正文 200"这种对不上账的话。
429
+ * 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
430
+ * AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
431
+ * (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
432
+ * 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
336
433
  */
337
- /** 每条文字的上限(字符)。超出即截断并置 `truncated`。 */
338
- const PER_MESSAGE_CHAR_CAP = 3e3;
339
- /** 一次回信的正文总量上限(字符)。 */
340
- const TOTAL_CHAR_CAP = 5e5;
341
- const LOG_SUFFIX = ".jsonl.zstd";
434
+ function readSessionHeader(raw) {
435
+ let header;
436
+ walkSessionLog(raw, (record) => {
437
+ if (record.type !== "session") return;
438
+ header = record;
439
+ return false;
440
+ });
441
+ return header;
442
+ }
342
443
  const DEFAULT_LIMITS = {
343
- perMessageChars: PER_MESSAGE_CHAR_CAP,
344
- totalChars: TOTAL_CHAR_CAP
444
+ perMessageChars: 3e3,
445
+ totalChars: 5e5
345
446
  };
346
447
  /** 一条记录里那些 `content[]` 段的文字拼起来(`\n` 分隔;非 text 段一律不参与)。 */
347
448
  function textOf(content) {
@@ -354,8 +455,8 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
354
455
  if (record === void 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
355
456
  const dir = trashDirOf(record.id, deps.home);
356
457
  if (!await deps.fs.exists(dir)) throw verbError("io", `回收站目录不在:${dir}`);
357
- const names = (await deps.fs.listFiles(dir)).sort();
358
- const logName = names.find((n) => n.endsWith(LOG_SUFFIX));
458
+ const names = await deps.fs.listFiles(dir);
459
+ const logName = pickSessionLog(names);
359
460
  if (logName === void 0) throw verbError("io", `回收站目录里没有会话日志:${dir}(里面有:${names.join(", ") || "空目录"})`);
360
461
  let raw;
361
462
  try {
@@ -452,405 +553,176 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
452
553
  };
453
554
  }
454
555
  //#endregion
455
- //#region src/verbs/delete-session.ts
456
- function deleteSession(deps) {
457
- return async function deleteSession(sessionId) {
458
- if (await deps.host.isLive(sessionId)) throw verbError("live", `会话正在运行:${sessionId}`);
459
- const projectDir = await deps.host.projectDirOf(sessionId);
460
- if (!projectDir) throw verbError("not-found", `宿主的持久化里没有这个会话:${sessionId}`);
461
- const workspaceId = await deps.host.workspaceOf(sessionId);
462
- const sessionDir = sessionDirOf(projectDir, sessionId, deps.home);
463
- if (!await deps.fs.exists(sessionDir)) throw verbError("not-found", `会话目录不存在:${sessionDir}`);
464
- const wasArchived = await deps.host.isArchived(sessionId);
465
- const deletedAt = deps.now();
466
- const entryId = makeEntryId(deletedAt, sessionId);
467
- const trashDir = trashDirOf(entryId, deps.home);
468
- let title;
469
- let sizeBytes;
470
- try {
471
- title = await deps.host.titleOf(sessionId);
472
- sizeBytes = await deps.fs.sizeOfDir(sessionDir);
473
- } catch (error) {
474
- throw verbError("io", `读取会话现场失败:${String(error)}`, error);
475
- }
476
- if (!wasArchived) try {
477
- await deps.host.archive(sessionId, workspaceId);
478
- } catch (error) {
479
- throw verbError("io", `归档失败:${String(error)}`, error);
480
- }
481
- if (workspaceId !== void 0) try {
482
- await deps.host.detach(sessionId, workspaceId);
483
- } catch (error) {
484
- if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
485
- throw verbError("io", `摘账本失败:${String(error)}`, error);
486
- }
487
- const entry = {
488
- id: entryId,
489
- sessionId,
490
- projectDir,
491
- workspaceId: workspaceId ?? "",
492
- wasArchived,
493
- deletedAt,
494
- title,
495
- sizeBytes
496
- };
497
- try {
498
- await deps.fs.ensureDir(`${deps.home}/session-trash`);
499
- await deps.fs.rename(sessionDir, trashDir);
500
- } catch (error) {
501
- if (workspaceId !== void 0) await deps.host.attach(sessionId, workspaceId).catch(() => {});
502
- if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
503
- throw verbError("io", `移动会话目录失败:${String(error)}`, error);
504
- }
505
- try {
506
- await deps.manifest.add(entry);
507
- } catch (error) {
508
- await deps.fs.rename(trashDir, sessionDir).catch(() => {});
509
- if (workspaceId !== void 0) await deps.host.attach(sessionId, workspaceId).catch(() => {});
510
- if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
511
- throw verbError("io", `登记回收站条目失败:${String(error)}`, error);
512
- }
513
- if (await deps.host.evictLive(sessionId) !== "unknown") await deps.host.announceRemoved(sessionId);
514
- return entry;
515
- };
516
- }
517
- //#endregion
518
- //#region src/verbs/purge-trash.ts
519
- function purgeTrash(deps) {
520
- return async function purgeTrash(entryId) {
521
- const all = await deps.manifest.list();
522
- const targets = entryId ? all.filter((e) => e.id === entryId) : all;
523
- if (entryId && targets.length === 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
524
- let freedBytes = 0;
525
- for (const entry of targets) {
526
- const dir = trashDirOf(entry.id, deps.home);
527
- if (await deps.fs.exists(dir)) {
528
- freedBytes += await deps.fs.sizeOfDir(dir);
529
- await deps.fs.removeDir(dir).catch((error) => {
530
- throw verbError("io", `删除回收站目录失败:${String(error)}`, error);
531
- });
532
- }
533
- await deps.manifest.remove(entry.id);
534
- if (await deps.host.evictLive(entry.sessionId) !== "unknown") await deps.host.announceRemoved(entry.sessionId);
535
- }
536
- return {
537
- removed: targets.length,
538
- freedBytes
539
- };
540
- };
556
+ //#region src/ports/host-port.ts
557
+ /** live store 的最小方法面判据(同 `projectionCacheOf` 的手法:按方法面判,不按 constructor.name)。 */
558
+ function liveSessionsOf(ctx) {
559
+ const store = ctx.get("sessions");
560
+ if (store === void 0 || store === null) return void 0;
561
+ if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
562
+ return store;
541
563
  }
542
- //#endregion
543
- //#region src/verbs/restore-session.ts
544
- function restoreSession(deps) {
545
- return async function restoreSession(entryId) {
546
- const entry = (await deps.manifest.list()).find((e) => e.id === entryId);
547
- if (!entry) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
548
- const trashDir = trashDirOf(entry.id, deps.home);
549
- const sessionDir = sessionDirOf(entry.projectDir, parseEntryId(entry.id).sessionDirName, deps.home);
550
- if (!await deps.fs.exists(trashDir)) throw verbError("trash-empty", `回收站条目已不在磁盘上:${trashDir}`);
551
- const projectRoot = sessionDirOf(entry.projectDir, "", deps.home).replace(/\/$/, "");
552
- if (!await deps.fs.exists(`${deps.home}/sessions`) || !await deps.fs.exists(projectRoot)) throw verbError("project-missing", `原项目目录已不存在:${projectRoot}`);
553
- await deps.fs.rename(trashDir, sessionDir).catch((error) => {
554
- throw verbError("io", `放回会话目录失败:${String(error)}`, error);
555
- });
556
- if (entry.workspaceId !== "") try {
557
- await deps.host.attach(entry.sessionId, entry.workspaceId);
558
- } catch (error) {
559
- await deps.fs.rename(sessionDir, trashDir).catch(() => {});
560
- throw verbError("io", `挂回账本失败:${String(error)}`, error);
561
- }
562
- if (!entry.wasArchived) try {
563
- await deps.host.unarchive(entry.sessionId);
564
- } catch (error) {
565
- await deps.host.archive(entry.sessionId, entry.workspaceId || void 0).catch(() => {});
566
- if (entry.workspaceId !== "") await deps.host.detach(entry.sessionId, entry.workspaceId).catch(() => {});
567
- await deps.fs.rename(sessionDir, trashDir).catch(() => {});
568
- throw verbError("io", `取消归档失败:${String(error)}`, error);
569
- }
570
- try {
571
- await deps.manifest.remove(entry.id);
572
- } catch (error) {
573
- if (!entry.wasArchived) await deps.host.archive(entry.sessionId, entry.workspaceId || void 0).catch(() => {});
574
- if (entry.workspaceId !== "") await deps.host.detach(entry.sessionId, entry.workspaceId).catch(() => {});
575
- await deps.fs.rename(sessionDir, trashDir).catch(() => {});
576
- throw verbError("io", `摘回收站条目失败:${String(error)}`, error);
577
- }
578
- await deps.host.announceRestored(entry.sessionId);
579
- return entry;
580
- };
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
+ }]);
581
570
  }
582
- //#endregion
583
- //#region src/api/dispatch.ts
584
- /** 请求体字节上限:这条通道只收几个 id,1MiB 是"够用且不给自己开一条内存放大器"的量级。 */
585
- const JSON_BODY_MAX_BYTES = 1048576;
586
- /** 回环地址的三种写法(IPv4 / IPv6 / IPv4-mapped IPv6)。 */
587
- const LOOPBACK = [
588
- "127.0.0.1",
589
- "::1",
590
- "::ffff:127.0.0.1"
591
- ];
592
- /**
593
- * 桌面壳那条路(asar `/lib/main.js` 的 `forwardWebRequest`):渲染进程从 `dsh-app://app/` 加载,
594
- * 页面里对**非静态路径**的请求(`/`、`/index.html`、`/assets/*`、`/plugins/…` 之外的一切——包括
595
- * 我们这条前缀路由)都由 `protocol.handle("dsh-app")` 转给宿主自己的 web server;转发前它把
596
- * `host` / `origin` / `cookie` / `sec-fetch-site` 四个头**删掉**、换上一枚宿主自己的 cookie,路径与
597
- * 查询串原样带走(逐字:`for (const name of ["host","origin","cookie","sec-fetch-site"]) headers.delete(name)`)。
598
- * ⇒ 桌面版打到我们这条路由上的请求是**没有 `Origin`** 的,`Referer` 则保留(同源请求照常带,
599
- * 页面就住在 `dsh-app://app/`)。壳自己在上游只放行 `origin ∈ {null, "dsh-app://app"}`。
600
- */
601
- const DESKTOP_APP_SCHEME = "dsh-app:";
602
- const DESKTOP_APP_HOST = "app";
603
571
  /**
604
- * 「这是桌面壳自己的页面吗」。**必须按 `protocol` + `host` 判,不能比 `URL.origin`**:`dsh-app:`
605
- * 不是 WHATWG 的 special scheme,`new URL('dsh-app://app/').origin` 是字符串 `"null"`(Node 实测),
606
- * 拿它跟字面量比永远为假 ⇒ 栅栏会把真机上的每一次请求都挡成 403,四个入口一个都不出现。
572
+ * 从若干根出发**逐层往下**把盘上的后代问出来,交回 `父(bare)→ 孩子` 的索引。
607
573
  *
608
- * 这个判据为什么不可能被外人伪造:`Origin` 与 `Referer` 都是 forbidden header,网页改不了,
609
- * 只能是浏览器/壳自己带上;而 `dsh-app://app` 只可能是桌面壳自己的页面。
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
+ * 而且没有身份就没法往下问,硬数进去只会让它将来被重复计。
610
582
  */
611
- function isDesktopShellUrl(value) {
612
- try {
613
- const url = new URL(value);
614
- return url.protocol === DESKTOP_APP_SCHEME && url.host === DESKTOP_APP_HOST;
615
- } catch {
616
- return false;
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;
617
612
  }
613
+ return children;
618
614
  }
619
- function writeJson(res, status, body) {
620
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
621
- res.end(JSON.stringify(body));
622
- }
623
- function writeOk(res, value) {
624
- writeJson(res, 200, {
625
- ok: true,
626
- value
627
- });
628
- }
629
- /** 失败信封:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表),值就是线上形状本身。 */
630
- function writeFailure(res, error, status = 200) {
631
- writeJson(res, status, {
632
- ok: false,
633
- error: toRemoteFailure(error)
634
- });
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;
635
631
  }
632
+ /** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
633
+ * 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。
634
+ * `projectDirOf` 与回收站对账(`src/trash/reconcile.ts` 重建条目)共用这一个字面量。 */
635
+ const NO_CWD_PROJECT_DIR = "_no-cwd";
636
636
  /**
637
- * 同源栅栏(见文件头 ④)。看 `origin` 而不只看 `referer` 的理由:恶意页
638
- * `<meta name="referrer" content="no-referrer">` 能让 Referer 缺席,但浏览器对跨源请求**总是**发 Origin。
637
+ * 宿主的**项目目录名**算法(`projectKey`)逐字镜像。
639
638
  *
640
- * 放行的三种形状,各自都是"浏览器/壳自动带上、网页改不了"的那种:
641
- * ① `Origin` 与 `Host` 同源(浏览器直接打开宿主那行 URL);② `Origin` 是桌面壳的 `dsh-app://app`
642
- * (壳自己在上游也只放行这个 origin);③ `Referer` 是桌面壳的 `dsh-app://app`(**真机那条路**:
643
- * 壳把 `Origin` 删掉了、只留下 Referer,见 {@link isDesktopShellUrl})。两者都缺席 = 本机工具 /
644
- * curl(注释即此承诺)。**恶意网页拿不到 ②③ 里的任何一个**:那两个头都是 forbidden header、
645
- * 页面伪造不了;而**写口(POST)在任何跨源形态下都必带 Origin** ⇒ 两个头全缺席就等于本机工具。
646
- * 唯一的缝是跨源 `no-cors` 的 **GET**(Referer 被 `no-referrer` 掐掉、Origin 也不带)——它只够得到
647
- * 只读的 `list`,而 no-cors 的回包是 opaque、页面读不出内容 ⇒ 不构成泄露。
639
+ * 为什么是镜像而不是 import:它在 `@deepseek-ai/dsh-session-persistence-jsonl` 里面,是**宿主包**,
640
+ * 跨插件值 import 是生态红线,它也不在本插件依赖里;宿主没有把这个名字暴露成任何服务
641
+ * (`stat()`/`list()` 的返回只有 `{header, revision, sizeBytes}`,`locate()` 的 `{kind:'jsonl', path}` 只挂在错误上)。
642
+ *
643
+ * 原文(asar `dsh/node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js`):
644
+ * `function projectKey(cwd) { … if (ch === "/" || ch === "\\" || ch === ":") { if (!separatorRun) readable += "-"; separatorRun = true; }
645
+ * else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; separatorRun = false; }
646
+ * else { readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false; }
647
+ * return \`--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--\`; }`
648
+ * 调用链:`locate(meta)` → `logPath(root, meta.cwd, meta.id, …)` → `projectDir(root, cwd)` → `projectKey(cwd)`。
649
+ * 本机实测对得上:`D:/develop/GitHub/dsh-session-delete` → `--D-develop-GitHub-dsh-session-delete--`
650
+ * (`~/.dsh/sessions` 下真有这个目录名;`D:/document/deepseek-harness/default-workspace` 同理)。
651
+ *
652
+ * **两个调用方共用这一份**:`projectDirOf`(删除时定位会话目录)与回收站对账的重建路径
653
+ * (手工放进回收站的目录,从日志 header 的 `cwd` 反推它原本属于哪个项目目录)——算法只有这一处家。
648
654
  */
649
- function isTrustedRequest(req) {
650
- if (!LOOPBACK.includes(req.socket.remoteAddress ?? "")) return false;
651
- const host = req.headers.host;
652
- const origin = req.headers.origin;
653
- if (origin !== void 0 && !isDesktopShellUrl(origin)) try {
654
- if (new URL(origin).host !== host) return false;
655
- } catch {
656
- return false;
657
- }
658
- const referer = req.headers.referer;
659
- if (referer === void 0) return true;
660
- if (isDesktopShellUrl(referer)) return true;
661
- try {
662
- return new URL(referer).host === host;
663
- } catch {
664
- return false;
655
+ function projectKey(cwd) {
656
+ let readable = "";
657
+ let separatorRun = false;
658
+ for (let i = 0; i < cwd.length; i++) {
659
+ const code = cwd.charCodeAt(i);
660
+ const ch = String.fromCharCode(code);
661
+ if (ch === "/" || ch === "\\" || ch === ":") {
662
+ if (!separatorRun) readable += "-";
663
+ separatorRun = true;
664
+ } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
665
+ readable += ch;
666
+ separatorRun = false;
667
+ } else {
668
+ readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
669
+ separatorRun = false;
670
+ }
665
671
  }
672
+ return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
666
673
  }
667
- /** 流式读 body 并解析成 JSON 对象:空体回 `null`,超限 / 非法 JSON / 不是对象一律抛(→ 失败信封)。 */
668
- async function readJsonBody(req) {
669
- const chunks = [];
670
- let size = 0;
671
- for await (const chunk of req) {
672
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
673
- size += buf.length;
674
- if (size > JSON_BODY_MAX_BYTES) throw new Error(`请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
675
- chunks.push(buf);
676
- }
677
- const text = Buffer.concat(chunks).toString("utf8").trim();
678
- if (text === "") return null;
679
- let parsed;
680
- try {
681
- parsed = JSON.parse(text);
682
- } catch (error) {
683
- throw new Error(`请求体不是合法 JSON:${error instanceof Error ? error.message : String(error)}`);
684
- }
685
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("请求体需为 JSON 对象");
686
- return parsed;
674
+ /** 投影缓存服务的最小方法面判据(按方法面判,不按 `constructor.name` 判)。 */
675
+ function projectionCacheOf(ctx) {
676
+ const cache = ctx.get("sessionProjectionCache");
677
+ if (cache === void 0 || cache === null) return void 0;
678
+ if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
679
+ return cache;
687
680
  }
688
- /** 必填的非空字符串字段(缺、空串、类型不对都是**同一句话**:说清是哪个字段)。 */
689
- function requireString(body, field) {
690
- const value = body?.[field];
691
- if (typeof value !== "string" || value === "") throw new Error(`请求体需带非空字符串字段 ${field}`);
692
- return value;
681
+ /**
682
+ * 「账本里没有这个会话」的判别串——宿主 `readSessionHeader(id)` 对未知会话抛的是**裸 `Error`**
683
+ * (没有类型、没有码),只认这一句。asar `dsh-workspace/lib/types/index.js` 原文(逐字):
684
+ * `throw new Error(\`cannot validate session '${id}': session persistence holds no such session\`)`。
685
+ *
686
+ * 为什么按消息判而不是全吞:同一个方法里 `listStoredHeaders()` / `indexHeaders()` 会抛**真存储故障**
687
+ * (读盘失败、账本不一致…),那是 `io` 该走的路,绝不能被当成「无此会话」。
688
+ * 同文件另一处近似句是有类型的那条(`cannot ${verb} session '…': live sessions and session persistence
689
+ * hold no such session`,`WorkspaceUnknownSessionError`)——`holds` 与 `hold`、有无 `live sessions and`
690
+ * 都不相同,所以这条串不会误伤它,`toRemoteFailure` 的 cause 链映射仍按 `name` 走。
691
+ */
692
+ const NO_SUCH_SESSION = "session persistence holds no such session";
693
+ /** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
694
+ function isUnknownSession(error) {
695
+ return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
693
696
  }
694
- /** `purge` 的 `entryId` 可选(缺席 = 清空),但在场就必须是像样的值——不把 `42` 当成"没带"。 */
695
- function optionalString(body, field) {
696
- const value = body?.[field];
697
- if (value === void 0) return void 0;
698
- if (typeof value !== "string" || value === "") throw new Error(`字段 ${field} 若在场必须是非空字符串`);
699
- return value;
697
+ /** 会话 id 去掉 `session-` 前缀后的形状(同一个父在两代里写法不同,见 {@link HostPort.subagentCounts})。 */
698
+ function bareSessionId(id) {
699
+ return id.startsWith("session-") ? id.slice(8) : id;
700
700
  }
701
- function requireMethod(method, want, route) {
702
- if (method !== want) throw new Error(`方法不允许:${method}(${SESSION_DELETE_API_PREFIX}${route} 需 ${want})`);
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
+ ])];
703
709
  }
704
- /** 路由表:段名 → 一个把「方法 + 请求体」变成返回值的处理器。动词在每次调用时才取。 */
705
- async function dispatch(method, rest, req, deps) {
706
- if (rest === ROUTES.list) {
707
- requireMethod(method, "GET", ROUTES.list);
708
- return await deps.manifest.list();
709
- }
710
- if (rest === ROUTES.delete) {
711
- requireMethod(method, "POST", ROUTES.delete);
712
- return await deleteSession(deps)(requireString(await readJsonBody(req), PARAMS.sessionId));
713
- }
714
- if (rest === ROUTES.restore) {
715
- requireMethod(method, "POST", ROUTES.restore);
716
- return await restoreSession(deps)(requireString(await readJsonBody(req), PARAMS.entryId));
717
- }
718
- if (rest === ROUTES.purge) {
719
- requireMethod(method, "POST", ROUTES.purge);
720
- return await purgeTrash(deps)(optionalString(await readJsonBody(req), PARAMS.entryId));
721
- }
722
- if (rest === ROUTES.detail) {
723
- requireMethod(method, "POST", ROUTES.detail);
724
- return await readTrashDetail(deps)(requireString(await readJsonBody(req), PARAMS.entryId));
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 [];
725
722
  }
726
- throw new Error(`未知路由:${method} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
727
- }
728
- /** 宿主 `webServer.register({kind:'prefix', path: SESSION_DELETE_API_PREFIX, handler})` 的那个 handler。 */
729
- function createApiHandler(deps) {
730
- return async function handle(req, res) {
731
- if (!isTrustedRequest(req)) {
732
- writeFailure(res, /* @__PURE__ */ new Error("非受信来源"), 403);
733
- return;
734
- }
735
- try {
736
- const url = new URL(req.url ?? "/", "http://dsh.internal");
737
- if (!url.pathname.startsWith("/dsh-session-delete-api")) throw new Error(`未知路由:${url.pathname}`);
738
- writeOk(res, await dispatch(req.method ?? "GET", url.pathname.slice(23), req, deps));
739
- } catch (error) {
740
- writeFailure(res, error);
741
- }
742
- };
743
- }
744
- //#endregion
745
- //#region src/ports/fs-port.ts
746
- /** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
747
- * `this` 变成 undefined)。所以端口只做转发,递归在模块作用域里自调。 */
748
- async function sizeOfDir(p) {
749
- let total = 0;
750
- for (const e of await readdir(p, { withFileTypes: true })) {
751
- const child = join(p, e.name);
752
- total += e.isDirectory() ? await sizeOfDir(child) : (await stat(child)).size;
753
- }
754
- return total;
755
- }
756
- function createFsPort() {
757
- return {
758
- async exists(p) {
759
- return existsSync(p);
760
- },
761
- async ensureDir(p) {
762
- await mkdir(p, { recursive: true });
763
- },
764
- async rename(from, to) {
765
- await rename(from, to);
766
- },
767
- async removeDir(p) {
768
- await rm(p, {
769
- recursive: true,
770
- force: true
771
- });
772
- },
773
- sizeOfDir: (p) => sizeOfDir(p),
774
- async listFiles(p) {
775
- return readdir(p);
776
- },
777
- async readFile(p) {
778
- return readFile(p);
779
- }
780
- };
781
- }
782
- //#endregion
783
- //#region src/ports/host-port.ts
784
- /** live store 的最小方法面判据(同 `projectionCacheOf` 的手法:按方法面判,不按 constructor.name)。 */
785
- function liveSessionsOf(ctx) {
786
- const store = ctx.get("sessions");
787
- if (store === void 0 || store === null) return void 0;
788
- if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
789
- return store;
790
- }
791
- /** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
792
- * 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。 */
793
- const NO_CWD_PROJECT_DIR = "_no-cwd";
794
- /**
795
- * 宿主的**项目目录名**算法(`projectKey`)逐字镜像。
796
- *
797
- * 为什么是镜像而不是 import:它在 `@deepseek-ai/dsh-session-persistence-jsonl` 里面,是**宿主包**,
798
- * 跨插件值 import 是生态红线,它也不在本插件依赖里;宿主没有把这个名字暴露成任何服务
799
- * (`stat()`/`list()` 的返回只有 `{header, revision, sizeBytes}`,`locate()` 的 `{kind:'jsonl', path}` 只挂在错误上)。
800
- *
801
- * 原文(asar `dsh/node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js`):
802
- * `function projectKey(cwd) { … if (ch === "/" || ch === "\\" || ch === ":") { if (!separatorRun) readable += "-"; separatorRun = true; }
803
- * else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; separatorRun = false; }
804
- * else { readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false; }
805
- * return \`--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--\`; }`
806
- * 调用链:`locate(meta)` → `logPath(root, meta.cwd, meta.id, …)` → `projectDir(root, cwd)` → `projectKey(cwd)`。
807
- * 本机实测对得上:`D:/develop/GitHub/dsh-session-delete` → `--D-develop-GitHub-dsh-session-delete--`
808
- * (`~/.dsh/sessions` 下真有这个目录名;`D:/document/deepseek-harness/default-workspace` 同理)。
809
- */
810
- function projectKey(cwd) {
811
- let readable = "";
812
- let separatorRun = false;
813
- for (let i = 0; i < cwd.length; i++) {
814
- const code = cwd.charCodeAt(i);
815
- const ch = String.fromCharCode(code);
816
- if (ch === "/" || ch === "\\" || ch === ":") {
817
- if (!separatorRun) readable += "-";
818
- separatorRun = true;
819
- } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
820
- readable += ch;
821
- separatorRun = false;
822
- } else {
823
- readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
824
- separatorRun = false;
825
- }
826
- }
827
- return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
828
- }
829
- /** 投影缓存服务的最小方法面判据(按方法面判,不按 `constructor.name` 判)。 */
830
- function projectionCacheOf(ctx) {
831
- const cache = ctx.get("sessionProjectionCache");
832
- if (cache === void 0 || cache === null) return void 0;
833
- if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
834
- return cache;
835
- }
836
- /**
837
- * 「账本里没有这个会话」的判别串——宿主 `readSessionHeader(id)` 对未知会话抛的是**裸 `Error`**
838
- * (没有类型、没有码),只认这一句。asar `dsh-workspace/lib/types/index.js` 原文(逐字):
839
- * `throw new Error(\`cannot validate session '${id}': session persistence holds no such session\`)`。
840
- *
841
- * 为什么按消息判而不是全吞:同一个方法里 `listStoredHeaders()` / `indexHeaders()` 会抛**真存储故障**
842
- * (读盘失败、账本不一致…),那是 `io` 该走的路,绝不能被当成「无此会话」。
843
- * 同文件另一处近似句是有类型的那条(`cannot ${verb} session '…': live sessions and session persistence
844
- * hold no such session`,`WorkspaceUnknownSessionError`)——`holds` 与 `hold`、有无 `live sessions and`
845
- * 都不相同,所以这条串不会误伤它,`toRemoteFailure` 的 cause 链映射仍按 `name` 走。
846
- */
847
- const NO_SUCH_SESSION = "session persistence holds no such session";
848
- /** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
849
- function isUnknownSession(error) {
850
- return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
851
723
  }
852
724
  /**
853
- * 宿主服务层的**真实绑定**:十二个方法各自把动词层的问题翻成宿主官方服务的调用。
725
+ * 宿主服务层的**真实绑定**:各方法把动词层的问题翻成宿主官方服务的调用。
854
726
  *
855
727
  * 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
856
728
  * 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
@@ -970,19 +842,42 @@ function createHostPort(ctx) {
970
842
  * 摘要**取自宿主自己的会话列表**(`ctx.sessionController.list()`),不自己造一行:客户端 `handleSessionAdded`
971
843
  * 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
972
844
  * 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**——恢复本身已经完成,只是要等下次重连才上屏。
845
+ *
846
+ * **单条路各拉一次表**;批量走 {@link HostPort.announceRestoredBatch} + 一次预取(见 {@link restoredSummariesOf}),
847
+ * 100 条的批量恢复用这条就是 100 遍全表扫——那是 2026-10-17 那次"恢复很慢"的头号来源。
973
848
  */
974
849
  async announceRestored(sessionId) {
850
+ const summary = (await this.restoredSummariesOf()).find((item) => item.sessionId === sessionId)?.summary;
851
+ if (summary === void 0) return;
975
852
  try {
976
- const controller = host.get("sessionController");
977
- if (controller === void 0 || controller === null || typeof controller.list !== "function") return;
978
- const listed = await controller.list();
979
- const items = Array.isArray(listed) ? listed : listed?.items;
980
- if (!Array.isArray(items)) return;
981
- const summary = items.find((item) => item?.sessionId === sessionId);
982
- if (summary === void 0) return;
983
853
  host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
984
854
  } catch {}
985
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
+ },
986
881
  /** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
987
882
  async unarchive(sessionId) {
988
883
  await registry.unarchiveSession(sessionId);
@@ -1005,6 +900,827 @@ function createHostPort(ctx) {
1005
900
  * 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,设计稿 §6)。 */
1006
901
  async archive(sessionId) {
1007
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
+ }));
944
+ }
945
+ };
946
+ }
947
+ //#endregion
948
+ //#region src/trash/reconcile.ts
949
+ /**
950
+ * 回收站清单的**对账**:以 `session-trash/` 里的条目目录为准,把清单修回一致(设计稿 2026-10-15 §9)。
951
+ *
952
+ * 手工动回收站**只需要动那一个目录**——多出来的目录在这里被重建出行、目录没了的行在这里被摘掉;
953
+ * `~/.dsh/storages/` 那份清单从此是**内部索引**,不用手碰。
954
+ *
955
+ * 五条口径(都不是口味):
956
+ * ① **每次读清单都对账**(`GET /list` 的正文就是"对账 + 读"):挂载、刷新、别处动过后的自动重读、
957
+ * 客户端启动探测全走这条 ⇒ "改完目录不用再做任何事,下一次读就对了"。代价 = 每次读多一次目录枚举
958
+ * (本机 124 条,毫秒级);**零漂移零写入**(快路直接返回原清单)。
959
+ * ② **回收站根不在 ⇒ 整套跳过、一个字节都不动**:那不是"回收站空了",是"这块地不在原处"
960
+ * (人为搬走、盘没挂上)——照常对账会把全部行的元数据一次判没。
961
+ * ③ **重建行只写"文件里真有的事实"**:`id` = 目录名、`sessionId`/`cwd` 读日志 header、
962
+ * `projectDir` 由 `cwd` 经宿主同名算法 `projectKey` 反推、`deletedAt` 从目录名的时间戳解、
963
+ * `sizeBytes` 现场量。**文件侧没有副本的三件**按缺省落:`workspaceId: ''`(恢复时跳过挂账本,
964
+ * 落在「未分组」)、`wasArchived: false`(恢复按"可见"还原)、`title: ''`(详情有日志标题兜底)
965
+ * ——这是重建的固有代价,不是可以"再读读看"的东西。
966
+ * ④ **认不出 / 读不出的目录:不动、不列**(名字不像条目、目录里没有日志、header 读不出、尺寸量不出,
967
+ * 各跳过各的)。它是"外人放进来的东西",不是用户动作失败:跳过即静默,绝不为它删目录或抛掉整次读数。
968
+ * ⑤ **幂等**:再跑一遍零写入;崩在任何一步,下一次读清单接着把它修完。
969
+ *
970
+ * 与三个写动词的**串行化不在这里**(那是传输层的事,见 `src/api/dispatch.ts` 的到达序链):
971
+ * 对账与删除/恢复/彻底删除都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩——删除刚把目录搬进
972
+ * 回收站、行还没写上时,对账抢先把这行按目录"重建"出来,随后又被真实行覆盖成降级元数据。
973
+ */
974
+ /** 目录名是不是一条条目 id(形状契约在 `entry.ts`:`<删除时刻UTC>-<原目录名>`)。 */
975
+ function isEntryId(name) {
976
+ try {
977
+ parseEntryId(name);
978
+ return true;
979
+ } catch {
980
+ return false;
981
+ }
982
+ }
983
+ /**
984
+ * 从一个手工放进回收站的目录重建清单行。**任一件事实拿不到就整体放弃这一条**(回 `undefined`):
985
+ * 半截行比"不列"更坏——恢复会照它把目录搬去一个错的地方(同 `detail.ts` 那条"半截正文"的口径)。
986
+ *
987
+ * `sessionId` 取日志 header 的 `id`(恢复之后宿主按日志 header 建索引,那才是会话身份);
988
+ * header 缺席这个字段才回落到目录名里那段原目录名。
989
+ */
990
+ async function buildEntry(deps, id) {
991
+ const dir = trashDirOf(id, deps.home);
992
+ let logName;
993
+ try {
994
+ logName = pickSessionLog(await deps.fs.listFiles(dir));
995
+ } catch {
996
+ return;
997
+ }
998
+ if (logName === void 0) return void 0;
999
+ let raw;
1000
+ try {
1001
+ raw = await deps.fs.readFile(`${dir}/${logName}`);
1002
+ } catch {
1003
+ return;
1004
+ }
1005
+ let header;
1006
+ try {
1007
+ header = readSessionHeader(raw);
1008
+ } catch {
1009
+ return;
1010
+ }
1011
+ if (header === void 0) return void 0;
1012
+ let sizeBytes;
1013
+ try {
1014
+ sizeBytes = await deps.fs.sizeOfDir(dir);
1015
+ } catch {
1016
+ return;
1017
+ }
1018
+ const { deletedAt, sessionDirName } = parseEntryId(id);
1019
+ const cwd = typeof header.cwd === "string" ? header.cwd : "";
1020
+ return {
1021
+ id,
1022
+ sessionId: typeof header.id === "string" && header.id !== "" ? header.id : sessionDirName,
1023
+ projectDir: cwd === "" ? NO_CWD_PROJECT_DIR : projectKey(cwd),
1024
+ workspaceId: "",
1025
+ wasArchived: false,
1026
+ deletedAt,
1027
+ title: "",
1028
+ sizeBytes
1029
+ };
1030
+ }
1031
+ /** 对账并回清单——**返回的就是修好之后的那份**(与 `manifest.list()` 同序:最近删除在前)。 */
1032
+ async function reconcileTrash(deps) {
1033
+ const rows = await deps.manifest.list();
1034
+ const root = `${deps.home}/session-trash`;
1035
+ if (!await deps.fs.exists(root)) return rows;
1036
+ const dirIds = /* @__PURE__ */ new Set();
1037
+ for (const name of await deps.fs.listDirs(root)) if (isEntryId(name)) dirIds.add(name);
1038
+ const rowIds = new Set(rows.map((row) => row.id));
1039
+ const drops = rows.filter((row) => !dirIds.has(row.id));
1040
+ const rebuilds = [];
1041
+ for (const id of dirIds) {
1042
+ if (rowIds.has(id)) continue;
1043
+ const entry = await buildEntry(deps, id);
1044
+ if (entry !== void 0) rebuilds.push(entry);
1045
+ }
1046
+ if (drops.length === 0 && rebuilds.length === 0) return rows;
1047
+ for (const row of drops) await deps.manifest.remove(row.id);
1048
+ for (const entry of rebuilds) await deps.manifest.add(entry);
1049
+ return await deps.manifest.list();
1050
+ }
1051
+ //#endregion
1052
+ //#region src/verbs/purge-trash.ts
1053
+ function purgeTrash(deps) {
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];
1057
+ if (entryId && targets.length === 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
1058
+ let freedBytes = 0;
1059
+ for (const entry of targets) {
1060
+ const dir = trashDirOf(entry.id, deps.home);
1061
+ if (await deps.fs.exists(dir)) {
1062
+ freedBytes += await deps.fs.sizeOfDir(dir);
1063
+ await deps.fs.removeDir(dir).catch((error) => {
1064
+ throw verbError("io", `删除回收站目录失败:${String(error)}`, error);
1065
+ });
1066
+ }
1067
+ await deps.manifest.remove(entry.id);
1068
+ const at = all.indexOf(entry);
1069
+ if (at >= 0) all.splice(at, 1);
1070
+ if (await deps.host.evictLive(entry.sessionId) !== "unknown") await deps.host.announceRemoved(entry.sessionId);
1071
+ }
1072
+ return {
1073
+ removed: targets.length,
1074
+ freedBytes
1075
+ };
1076
+ };
1077
+ }
1078
+ //#endregion
1079
+ //#region src/verbs/restore-session.ts
1080
+ /**
1081
+ * 恢复 = 把删除那次**按原路退回**,而且按组走:当年是按组一起搬进来的(父 + 它在盘上的孩子),
1082
+ * 只回父就会在回收站里留下一堆点不到的孩子行——清单只列父行,那些行既看不见也清不掉。
1083
+ *
1084
+ * 三段与删除对称:
1085
+ * ① **预检整组**(一个字节都不写):每个成员的回收站目录与项目目录都得在。缺席的判据与原来一致
1086
+ * (回收站目录不在 = `trash-empty`;原项目目录不在 = `project-missing`,且绝不静默建目录)。
1087
+ * 预检在写之前,是为了不让"父已经回到 `sessions/`、孩子因为项目目录没了而回不去"这种半组留在外面。
1088
+ * ② **逐个恢复**,顺序与删除相反:文件先回来 → 挂账本 → 还原可见性 → 摘清册行 → 通告回来。
1089
+ * ③ 中途失败把这个成员自己退回回收站(就地回滚),并把**已经恢复完的**兄弟按相反顺序一起退回,失败照抛。
1090
+ */
1091
+ function restoreSession(deps) {
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
1197
+ });
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
+ }
1209
+ } catch (error) {
1210
+ for (const undo of done.reverse()) await undo();
1211
+ throw error;
1212
+ }
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));
1361
+ } catch (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;
1378
+ }
1379
+ try {
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));
1390
+ } catch (error) {
1391
+ outcomes.push(failure(entryId, "restore", error));
1392
+ }
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;
1435
+ }
1436
+ //#endregion
1437
+ //#region src/api/dispatch.ts
1438
+ /** 请求体字节上限:这条通道只收几个 id,1MiB 是"够用且不给自己开一条内存放大器"的量级。 */
1439
+ const JSON_BODY_MAX_BYTES = 1048576;
1440
+ /** 回环地址的三种写法(IPv4 / IPv6 / IPv4-mapped IPv6)。 */
1441
+ const LOOPBACK = [
1442
+ "127.0.0.1",
1443
+ "::1",
1444
+ "::ffff:127.0.0.1"
1445
+ ];
1446
+ /**
1447
+ * 桌面壳那条路(asar `/lib/main.js` 的 `forwardWebRequest`):渲染进程从 `dsh-app://app/` 加载,
1448
+ * 页面里对**非静态路径**的请求(`/`、`/index.html`、`/assets/*`、`/plugins/…` 之外的一切——包括
1449
+ * 我们这条前缀路由)都由 `protocol.handle("dsh-app")` 转给宿主自己的 web server;转发前它把
1450
+ * `host` / `origin` / `cookie` / `sec-fetch-site` 四个头**删掉**、换上一枚宿主自己的 cookie,路径与
1451
+ * 查询串原样带走(逐字:`for (const name of ["host","origin","cookie","sec-fetch-site"]) headers.delete(name)`)。
1452
+ * ⇒ 桌面版打到我们这条路由上的请求是**没有 `Origin`** 的,`Referer` 则保留(同源请求照常带,
1453
+ * 页面就住在 `dsh-app://app/`)。壳自己在上游只放行 `origin ∈ {null, "dsh-app://app"}`。
1454
+ */
1455
+ const DESKTOP_APP_SCHEME = "dsh-app:";
1456
+ const DESKTOP_APP_HOST = "app";
1457
+ /**
1458
+ * 「这是桌面壳自己的页面吗」。**必须按 `protocol` + `host` 判,不能比 `URL.origin`**:`dsh-app:`
1459
+ * 不是 WHATWG 的 special scheme,`new URL('dsh-app://app/').origin` 是字符串 `"null"`(Node 实测),
1460
+ * 拿它跟字面量比永远为假 ⇒ 栅栏会把真机上的每一次请求都挡成 403,四个入口一个都不出现。
1461
+ *
1462
+ * 这个判据为什么不可能被外人伪造:`Origin` 与 `Referer` 都是 forbidden header,网页改不了,
1463
+ * 只能是浏览器/壳自己带上;而 `dsh-app://app` 只可能是桌面壳自己的页面。
1464
+ */
1465
+ function isDesktopShellUrl(value) {
1466
+ try {
1467
+ const url = new URL(value);
1468
+ return url.protocol === DESKTOP_APP_SCHEME && url.host === DESKTOP_APP_HOST;
1469
+ } catch {
1470
+ return false;
1471
+ }
1472
+ }
1473
+ function writeJson(res, status, body) {
1474
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
1475
+ res.end(JSON.stringify(body));
1476
+ }
1477
+ function writeOk(res, value) {
1478
+ writeJson(res, 200, {
1479
+ ok: true,
1480
+ value
1481
+ });
1482
+ }
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), {
1493
+ ok: false,
1494
+ error: toRemoteFailure(error)
1495
+ });
1496
+ }
1497
+ /**
1498
+ * 同源栅栏(见文件头 ④)。看 `origin` 而不只看 `referer` 的理由:恶意页
1499
+ * `<meta name="referrer" content="no-referrer">` 能让 Referer 缺席,但浏览器对跨源请求**总是**发 Origin。
1500
+ *
1501
+ * 放行的三种形状,各自都是"浏览器/壳自动带上、网页改不了"的那种:
1502
+ * ① `Origin` 与 `Host` 同源(浏览器直接打开宿主那行 URL);② `Origin` 是桌面壳的 `dsh-app://app`
1503
+ * (壳自己在上游也只放行这个 origin);③ `Referer` 是桌面壳的 `dsh-app://app`(**真机那条路**:
1504
+ * 壳把 `Origin` 删掉了、只留下 Referer,见 {@link isDesktopShellUrl})。两者都缺席 = 本机工具 /
1505
+ * curl(注释即此承诺)。**恶意网页拿不到 ②③ 里的任何一个**:那两个头都是 forbidden header、
1506
+ * 页面伪造不了;而**写口(POST)在任何跨源形态下都必带 Origin** ⇒ 两个头全缺席就等于本机工具。
1507
+ * 唯一的缝是跨源 `no-cors` 的 **GET**(Referer 被 `no-referrer` 掐掉、Origin 也不带)——它只够得到
1508
+ * 只读的 `list`,而 no-cors 的回包是 opaque、页面读不出内容 ⇒ 不构成泄露。
1509
+ */
1510
+ function isTrustedRequest(req) {
1511
+ if (!LOOPBACK.includes(req.socket.remoteAddress ?? "")) return false;
1512
+ const host = req.headers.host;
1513
+ const origin = req.headers.origin;
1514
+ if (origin !== void 0 && !isDesktopShellUrl(origin)) try {
1515
+ if (new URL(origin).host !== host) return false;
1516
+ } catch {
1517
+ return false;
1518
+ }
1519
+ const referer = req.headers.referer;
1520
+ if (referer === void 0) return true;
1521
+ if (isDesktopShellUrl(referer)) return true;
1522
+ try {
1523
+ return new URL(referer).host === host;
1524
+ } catch {
1525
+ return false;
1526
+ }
1527
+ }
1528
+ /** 流式读 body 并解析成 JSON 对象:空体回 `null`,超限 / 非法 JSON / 不是对象一律抛(→ 400 + 失败信封)。
1529
+ * **请求本身不对**(400)与**那次操作读不到东西**(200 + 信封)是两件事:前者是调用方没组好请求,
1530
+ * 后者是盘上没有;把前者也写成 200,代理与日志里就再也分不出这两类。 */
1531
+ async function readJsonBody(req) {
1532
+ const chunks = [];
1533
+ let size = 0;
1534
+ for await (const chunk of req) {
1535
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1536
+ size += buf.length;
1537
+ if (size > JSON_BODY_MAX_BYTES) throw routeError("bad-request", 400, `请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
1538
+ chunks.push(buf);
1539
+ }
1540
+ const text = Buffer.concat(chunks).toString("utf8").trim();
1541
+ if (text === "") return null;
1542
+ try {
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;
1546
+ } catch (error) {
1547
+ if (error instanceof SyntaxError) throw routeError("bad-request", 400, `请求体不是合法 JSON:${error.message}`);
1548
+ throw error;
1549
+ }
1550
+ }
1551
+ /** 必填的非空字符串字段(缺、空串、类型不对都是**同一句话**:说清是哪个字段)。 */
1552
+ function requireString(body, field) {
1553
+ const value = body?.[field];
1554
+ if (typeof value !== "string" || value === "") throw routeError("bad-request", 400, `请求体需带非空字符串字段 ${field}`);
1555
+ return value;
1556
+ }
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}:非空字符串组成的非空数组`);
1573
+ return value;
1574
+ }
1575
+ function requireMethod(method, want, route) {
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
+ });
1613
+ }
1614
+ /** 路由表:段名 → 一个把「方法 + 预读好的请求体」变成返回值的处理器。动词在每次调用时才取。
1615
+ * `body` 由 {@link purityOf} 读一次交下来——同一条请求的流不能被消费两遍。 */
1616
+ async function dispatch(method, rest, body, deps) {
1617
+ if (rest === ROUTES.list) {
1618
+ requireMethod(method, "GET", ROUTES.list);
1619
+ return assembleTrashRows(deps, await reconcileTrash(deps));
1620
+ }
1621
+ if (rest === ROUTES.ping) {
1622
+ requireMethod(method, "GET", ROUTES.ping);
1623
+ return "pong";
1624
+ }
1625
+ if (rest === ROUTES.apply) {
1626
+ requireMethod(method, "POST", ROUTES.apply);
1627
+ return await applyActions(deps, requireAction(body), requireIdArray(body));
1628
+ }
1629
+ if (rest === ROUTES.empty) {
1630
+ requireMethod(method, "POST", ROUTES.empty);
1631
+ return await purgeTrash(deps)();
1632
+ }
1633
+ if (rest === ROUTES.detail) {
1634
+ requireMethod(method, "POST", ROUTES.detail);
1635
+ return await readTrashDetail(deps)(requireString(body, PARAMS.entryId));
1636
+ }
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
+ };
1657
+ }
1658
+ function createApiHandler(deps) {
1659
+ /**
1660
+ * 到达序串行链(文件头 ⑤):`list`(含对账)与三个写动词一个跑完再放下一个。`tail` 由构造保证
1661
+ * **永不 reject**(每个任务自己的失败留在交回给那次请求的那个 promise 上),所以一次失败不会卡死链。
1662
+ */
1663
+ let tail = Promise.resolve();
1664
+ const throughChain = (task) => {
1665
+ const started = tail.then(task);
1666
+ tail = started.then(() => void 0, () => void 0);
1667
+ return started;
1668
+ };
1669
+ return async function handle(req, res) {
1670
+ if (!isTrustedRequest(req)) {
1671
+ writeFailure(res, /* @__PURE__ */ new Error("非受信来源"), 403);
1672
+ return;
1673
+ }
1674
+ try {
1675
+ const url = new URL(req.url ?? "/", "http://dsh.internal");
1676
+ if (!url.pathname.startsWith("/dsh-session-delete-api")) throw routeError("not-found-route", 404, `未知路由:${url.pathname}`);
1677
+ const rest = url.pathname.slice(23);
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)));
1681
+ } catch (error) {
1682
+ writeFailure(res, error);
1683
+ }
1684
+ };
1685
+ }
1686
+ //#endregion
1687
+ //#region src/ports/fs-port.ts
1688
+ /** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
1689
+ * `this` 变成 undefined)。所以端口只做转发,递归在模块作用域里自调。 */
1690
+ async function sizeOfDir(p) {
1691
+ let total = 0;
1692
+ for (const e of await readdir(p, { withFileTypes: true })) {
1693
+ const child = join(p, e.name);
1694
+ total += e.isDirectory() ? await sizeOfDir(child) : (await stat(child)).size;
1695
+ }
1696
+ return total;
1697
+ }
1698
+ function createFsPort() {
1699
+ return {
1700
+ async exists(p) {
1701
+ return existsSync(p);
1702
+ },
1703
+ async ensureDir(p) {
1704
+ await mkdir(p, { recursive: true });
1705
+ },
1706
+ async rename(from, to) {
1707
+ await rename(from, to);
1708
+ },
1709
+ async removeDir(p) {
1710
+ await rm(p, {
1711
+ recursive: true,
1712
+ force: true
1713
+ });
1714
+ },
1715
+ sizeOfDir: (p) => sizeOfDir(p),
1716
+ async listFiles(p) {
1717
+ return readdir(p);
1718
+ },
1719
+ async listDirs(p) {
1720
+ return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
1721
+ },
1722
+ async readFile(p) {
1723
+ return readFile(p);
1008
1724
  }
1009
1725
  };
1010
1726
  }
@@ -1038,7 +1754,7 @@ const name = "@xrn1997/dsh-session-delete";
1038
1754
  * 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
1039
1755
  *
1040
1756
  * `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
1041
- * 前缀路由,浏览器半同源 fetch 那五个端点。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
1757
+ * 前缀路由,浏览器半同源 fetch 那五个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
1042
1758
  * `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
1043
1759
  * `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
1044
1760
  */
@@ -1075,7 +1791,9 @@ function defineDomain(spec) {
1075
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}`);
1076
1792
  return spec;
1077
1793
  }
1078
- /** `TrashEntry` 的字段面(`src/trash/entry.ts` 的接口逐项)。 */
1794
+ /** `TrashEntry` 里**每条记录都必须有**的那几格(`src/trash/entry.ts` 接口的非可选字段)。
1795
+ * **可选字段不在这里**:`parentEntryId`(级联那批加的)缺席是合法的——老行没有它,缺了就是"没有父";
1796
+ * 校验器只管这几格的类型,其余键**原样带走**(`parse` 交回的就是 `raw` 本身)⇒ 新字段照旧存得住。 */
1079
1797
  const TRASH_ENTRY_FIELDS = {
1080
1798
  id: "string",
1081
1799
  sessionId: "string",
@@ -1106,18 +1824,30 @@ const trashDomainSpec = defineDomain({
1106
1824
  });
1107
1825
  /**
1108
1826
  * 清单的真实绑定:官方 storage domain → `ManifestPort`(`StorageDomainLike` 在动词层保持一种读法)。
1109
- * 域**懒开**且只开一次(`apply` 是同步的,而 `open()` 是异步的),并挂一条 `ctx.effect` 清理:
1110
- * 插件卸载时关掉句柄(句柄归调用方所有,这是 `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()`,成功才落下活句柄。
1111
1833
  */
1112
1834
  function createStorageManifest(ctx) {
1113
1835
  const host = ctx;
1836
+ let domain;
1114
1837
  let opening;
1115
- ctx.effect(() => () => {
1116
- opening?.then((handle) => handle.close()).catch(() => {});
1838
+ ctx.effect(() => async () => {
1839
+ await ((opening !== void 0 ? await opening.catch(() => void 0) : void 0) ?? domain)?.close();
1117
1840
  }, "session-delete.manifest");
1118
1841
  const table = async () => {
1119
- opening ??= host.storageDomain.open(trashDomainSpec);
1120
- 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");
1121
1851
  };
1122
1852
  return createStorageManifest$1({
1123
1853
  readAll: async () => [...(await table()).entries()].map(([, record]) => record),