@xrn1997/dsh-session-delete 0.2.2 → 0.3.1

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 (60) hide show
  1. package/README.md +10 -28
  2. package/lib/client.js +2012 -1117
  3. package/lib/index.js +1271 -893
  4. package/lib/types/api/dispatch.d.ts +2 -2
  5. package/lib/types/api/route-table.d.ts +1 -1
  6. package/lib/types/api/trash-rows.d.ts +13 -6
  7. package/lib/types/client/batch-bar.d.ts +7 -6
  8. package/lib/types/client/batch-confirm.d.ts +6 -4
  9. package/lib/types/client/batch-menu-item.d.ts +2 -2
  10. package/lib/types/client/batch-select.d.ts +7 -50
  11. package/lib/types/client/batch-selection-store.d.ts +30 -0
  12. package/lib/types/client/busy-gate.d.ts +6 -0
  13. package/lib/types/client/copy.d.ts +4 -1
  14. package/lib/types/client/delete-confirm.d.ts +2 -0
  15. package/lib/types/client/host-rows.d.ts +50 -0
  16. package/lib/types/client/main-view.d.ts +42 -0
  17. package/lib/types/client/orphan-maintenance.d.ts +27 -0
  18. package/lib/types/client/receipt.d.ts +73 -0
  19. package/lib/types/client/remote.d.ts +25 -19
  20. package/lib/types/client/store.d.ts +48 -0
  21. package/lib/types/client/trash-nav.d.ts +30 -0
  22. package/lib/types/client/trash-panel.d.ts +1 -1
  23. package/lib/types/client/trash-store.d.ts +2 -0
  24. package/lib/types/client/ui/icons.d.ts +3 -3
  25. package/lib/types/client/undo-toast.d.ts +30 -6
  26. package/lib/types/client/use-column-resize.d.ts +2 -2
  27. package/lib/types/client/use-panel-width.d.ts +1 -1
  28. package/lib/types/client/use-trash-panel-controller.d.ts +42 -0
  29. package/lib/types/client/use-trash-query.d.ts +20 -9
  30. package/lib/types/client/write-seats.d.ts +52 -0
  31. package/lib/types/client/write-settlement.d.ts +72 -0
  32. package/lib/types/client/write.d.ts +51 -0
  33. package/lib/types/deps.d.ts +14 -0
  34. package/lib/types/index.d.ts +7 -13
  35. package/lib/types/log/zstd-frames.d.ts +1 -1
  36. package/lib/types/ports/agent-handles.d.ts +25 -0
  37. package/lib/types/ports/home.d.ts +19 -0
  38. package/lib/types/ports/host-port.d.ts +60 -44
  39. package/lib/types/remote/map-error.d.ts +2 -2
  40. package/lib/types/shared/wire.d.ts +18 -9
  41. package/lib/types/trash/detail.d.ts +6 -3
  42. package/lib/types/trash/entry.d.ts +4 -4
  43. package/lib/types/trash/groups.d.ts +17 -6
  44. package/lib/types/trash/ledger.d.ts +20 -0
  45. package/lib/types/trash/manifest.d.ts +4 -2
  46. package/lib/types/trash/reconcile.d.ts +6 -5
  47. package/lib/types/trash/storage-manifest.d.ts +12 -0
  48. package/lib/types/verbs/batch-carried.d.ts +20 -0
  49. package/lib/types/verbs/batch.d.ts +1 -1
  50. package/lib/types/verbs/delete-session.d.ts +11 -13
  51. package/lib/types/verbs/errors.d.ts +2 -6
  52. package/lib/types/verbs/live-admission.d.ts +13 -0
  53. package/lib/types/verbs/member-journal.d.ts +26 -6
  54. package/lib/types/verbs/member-presence.d.ts +67 -0
  55. package/lib/types/verbs/move-group.d.ts +20 -0
  56. package/lib/types/verbs/orphan-subagents.d.ts +21 -40
  57. package/lib/types/verbs/permanent-removal.d.ts +114 -0
  58. package/lib/types/verbs/purge-trash.d.ts +21 -9
  59. package/lib/types/verbs/restore-session.d.ts +14 -12
  60. package/package.json +3 -2
package/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { homedir } from "node:os";
2
- import { join, resolve } from "node:path";
3
1
  import zlib from "node:zlib";
4
2
  import { existsSync } from "node:fs";
5
3
  import { mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+ import { homedir } from "node:os";
6
6
  //#region src/shared/wire.ts
7
7
  /**
8
8
  * 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、六个端点段、请求字段名**——
@@ -32,7 +32,7 @@ const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
32
32
  * 一次 HTTP 带一组 id、逐条回结果;单条就是长度为 1 的批量,仓里不再留第二条执行路径。
33
33
  * - `GET /list`:**对账并读**清单(`session-trash/` 里的目录是准)。
34
34
  * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链)——客户端探测用它,不用一次全表读。
35
- * - `POST /detail`:单条详情(用 POST 表达一个读:见设计稿 2026-10-15 §3)。
35
+ * - `POST /detail`:单条详情(用 POST 表达一个读:判据就写在 `src/api/route-table.ts` 那一行的上方)。
36
36
  * - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
37
37
  * - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
38
38
  * - `GET /orphans`:无主子会话的扫描报告(纯读,2026-10-19)。**清理不在这里**——它是 `/apply` 的
@@ -69,10 +69,10 @@ function isDSHRemoteError(value) {
69
69
  const candidate = value;
70
70
  return candidate.isDSHRemoteError === true && typeof candidate.code === "string";
71
71
  }
72
- /** 失败码的域。与宿主官方域(`workspace/*`、`session/*`…)不重名,见设计稿 §6。 */
72
+ /** 失败码的域。与宿主官方域(`workspace/*`、`session/*`…)不重名,见本文件。 */
73
73
  const SESSION_DELETE_ERROR_DOMAIN = "sessiondelete";
74
- /** 五个**动词码**(设计稿 §6 那张表)。`io` 是唯一兜底。 */
75
- const VERB_CODES$1 = [
74
+ /** 五个**动词码**(本文件那张表)。`io` 是唯一兜底。 */
75
+ const VERB_CODES = [
76
76
  "live",
77
77
  "not-found",
78
78
  "trash-empty",
@@ -89,14 +89,31 @@ const SESSION_DELETE_CODES = {
89
89
  "not-found-route": "sessiondelete/not-found-route",
90
90
  "bad-request": "sessiondelete/bad-request"
91
91
  };
92
+ /** 失败码的前缀。读码只走下面两条,别在调用点各写一份字面量。 */
93
+ const CODE_PREFIX = `${SESSION_DELETE_ERROR_DOMAIN}/`;
94
+ /** `error.code` 是不是本仓的失败形状(`sessiondelete/<后缀>`)。**只认前缀**:后缀在不在册是
95
+ * {@link verbCodeOf} 的问题。任何要「已经是本仓的码就原样对待」的调用点都问这一句,不在调用点
96
+ * 再写一份前缀字面量。 */
97
+ function isSessionDeleteCodeError(error) {
98
+ const raw = error?.code;
99
+ return typeof raw === "string" && raw.startsWith(CODE_PREFIX);
100
+ }
101
+ /** `error.code` 折回那个动词词:前缀与后缀两件都成立才交回(后缀要在 {@link VERB_CODES} 册上)。
102
+ * 路由自检码、别域的码、非字符串 code 一律 `undefined`。 */
103
+ function verbCodeOf(error) {
104
+ const raw = error?.code;
105
+ if (typeof raw !== "string" || !raw.startsWith(CODE_PREFIX)) return void 0;
106
+ const suffix = raw.slice(CODE_PREFIX.length);
107
+ return VERB_CODES.includes(suffix) ? suffix : void 0;
108
+ }
92
109
  /** 回收站目录名(相对宿主数据根)。Node 半拼路径,浏览器半在页脚念它——同一个字面量。 */
93
110
  const TRASH_DIR_NAME = "session-trash";
94
111
  //#endregion
95
112
  //#region src/remote/map-error.ts
96
- /** 插件专属远程域名(设计稿 §6):与宿主官方域(`workspace/*`、`session/*`、`gateway/*`…)不重名。
113
+ /** 插件专属远程域名(src/shared/wire.ts):与宿主官方域(`workspace/*`、`session/*`、`gateway/*`…)不重名。
97
114
  * 字面量的家在 `shared/wire.ts`(ADR-0003)——浏览器半也按同一个域认码。 */
98
115
  const DOMAIN = SESSION_DELETE_ERROR_DOMAIN;
99
- /** 宿主两条类型化错误 → 我们的码(设计稿 §6 的两行映射)。
116
+ /** 宿主两条类型化错误 → 我们的码(本文件的两行映射)。
100
117
  * 类不可 import(跨插件值 import 是红线;且宿主包与运行中的宿主不同代)⇒ 按官方构造器写死的 `this.name` 认。
101
118
  * 证据:asar `dsh/node_modules/@deepseek-ai/dsh-workspace/lib/index.js` 原文
102
119
  * `var WorkspaceUnknownSessionError = class extends Error { …; this.name = "WorkspaceUnknownSessionError"; }`
@@ -117,7 +134,7 @@ function hostErrorOf(error) {
117
134
  }
118
135
  }
119
136
  /** 上屏原文:`Error` 取 `.message`;**带 `message` 字符串的非 Error 抛出物也照取**(否则会退化成
120
- * `[object Object]`,把设计稿 §6「`message` 是上屏的原文」这条许诺落空);其余一律 `String()`。 */
137
+ * `[object Object]`,把 src/shared/wire.ts「`message` 是上屏的原文」这条许诺落空);其余一律 `String()`。 */
121
138
  function messageOf(error) {
122
139
  if (error instanceof Error) return error.message;
123
140
  if (typeof error === "object" && error !== null) {
@@ -159,7 +176,7 @@ function detailsOf(error) {
159
176
  return ownDetailsOf(error);
160
177
  }
161
178
  /**
162
- * 把任意抛出物折成失败信封里的**那三件**(设计稿 §6 唯一那张映射表)。
179
+ * 把任意抛出物折成失败信封里的**那三件**(本文件唯一那张映射表)。
163
180
  *
164
181
  * - 已经是远程失败(`isDSHRemoteError` 认得的结构标记)⇒ 原样带出它自己的 `code` / `message`,
165
182
  * 挂着的 `details` 一起过桥,不再包一层。
@@ -167,7 +184,7 @@ function detailsOf(error) {
167
184
  * 把它们折成 `sessiondelete/io` 就是把宿主的判断抹成我们的,客户端拿到的 code 从此不能说真话。
168
185
  * - 宿主类型化错误(自身或 `cause` 链上,见 {@link hostErrorOf})⇒ 映射成 `live` / `not-found`,
169
186
  * 并把它自己身上的结构化字段带成 `details`。这一条排在动词码之前:动词会把宿主错误包成笼统的 `io`
170
- * 并把原错误挂在 cause 上(`verbError('io', …, cause)`),不看进 cause 链,§6 那两行映射永远到不了客户端。
187
+ * 并把原错误挂在 cause 上(`verbError('io', …, cause)`),不看进 cause 链,本文件那两行映射永远到不了客户端。
171
188
  * - 动词的 `sessiondelete/<五个码>` ⇒ 原样过桥(`details` 同样带着)。
172
189
  * - 其余(别域的怪 code、前缀外的因、非 Error 抛出物)⇒ `io`,message 保留原文。
173
190
  *
@@ -185,7 +202,7 @@ function toRemoteFailure(error) {
185
202
  };
186
203
  }
187
204
  const raw = error?.code;
188
- if (typeof raw === "string" && OFFICIAL_CODE.test(raw) && !raw.startsWith(`${DOMAIN}/`)) {
205
+ if (typeof raw === "string" && OFFICIAL_CODE.test(raw) && !isSessionDeleteCodeError(error)) {
189
206
  const details = detailsOf(error);
190
207
  return {
191
208
  code: raw,
@@ -193,8 +210,7 @@ function toRemoteFailure(error) {
193
210
  ...details === void 0 ? {} : { details }
194
211
  };
195
212
  }
196
- const reason = typeof raw === "string" && raw.startsWith(`${DOMAIN}/`) ? raw.slice(14) : void 0;
197
- const fromVerb = reason !== void 0 && VERB_CODES$1.includes(reason) ? reason : void 0;
213
+ const fromVerb = verbCodeOf(error);
198
214
  const host = hostErrorOf(error);
199
215
  const own = detailsOf(error) ?? (host === void 0 ? void 0 : detailsOf(host));
200
216
  if (host === void 0 && fromVerb === void 0 && typeof raw === "string" && raw.includes("/")) return {
@@ -210,12 +226,8 @@ function toRemoteFailure(error) {
210
226
  }
211
227
  //#endregion
212
228
  //#region src/verbs/errors.ts
213
- /** 五个码 / 三个路由自检码的**家都在 `shared/wire.ts`**(ADR-0003):浏览器半也要认它们(比如
214
- * `not-found` 在界面上是中性提示而不是失败),所以它们属于两半共同的契约,不属于 Node 半。
215
- * 这里只转出旧名,免得调用点全改一遍。 */
216
- const VERB_CODES = VERB_CODES$1;
217
- /** `sessiondelete/` 这个前缀——拆码用它,别在下面几处各写一遍字面量。 */
218
- const VERB_CODE_PREFIX = `${SESSION_DELETE_ERROR_DOMAIN}/`;
229
+ /** 失败的**结构与措辞**。码本身、码的前缀读法与动词词表都住在 `shared/wire.ts`(ADR-0003):
230
+ * 浏览器半也要认它们,它们属于两半共同的契约,不属于 Node 半。这里只做动词抛错与回滚措辞。 */
219
231
  function verbError(code, message, cause, details) {
220
232
  const e = new Error(message);
221
233
  e.code = SESSION_DELETE_CODES[code];
@@ -245,9 +257,7 @@ function carriedRollbackFailures(error) {
245
257
  * 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
246
258
  * 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。 */
247
259
  function withRollbackFailures(error, failures) {
248
- const raw = error?.code;
249
- const reason = typeof raw === "string" && raw.startsWith(VERB_CODE_PREFIX) ? raw.slice(VERB_CODE_PREFIX.length) : void 0;
250
- const code = reason !== void 0 && VERB_CODES.includes(reason) ? reason : "io";
260
+ const code = verbCodeOf(error) ?? "io";
251
261
  const carried = carriedRollbackFailures(error);
252
262
  const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
253
263
  const base = error instanceof Error ? error.message : String(error);
@@ -255,6 +265,76 @@ function withRollbackFailures(error, failures) {
255
265
  return verbError(code, base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`, error, { rollbackFailures: merged });
256
266
  }
257
267
  //#endregion
268
+ //#region src/ports/agent-handles.ts
269
+ const CAPTURED_AGENT_HANDLES = Symbol.for("dsh-session-delete.captured-agent-handles");
270
+ /** 在 `ctx.agents` 上包一层 `resume` / `create`,把返回的 agent handle 的 dispose 按会话 id 记下。
271
+ * 服务缺席时走 `ctx.inject(['agents'])` **等服务挂上再装**——live profile 的插件激活顺序可能早于
272
+ * `agents` 服务就绪;只在 apply 时 `get` 一次会让捕获静默缺席(真机症状:重启前在回收站的能冷恢复,
273
+ * 刚删除的会话恢复后旧 agent 拦住 prompt)。形状不符 / 属性不可写都只是**不捕获**,不影响插件激活。
274
+ *
275
+ * **安装时机**:先 `get('agents')`,没装成再 `inject(['agents'])`(两者只装一次,符号表兜住)。
276
+ * **symbol 表**:句柄表挂在服务实例的 {@link CAPTURED_AGENT_HANDLES} 上;同一个 agents 服务上的第二次
277
+ * 安装(`get` 与 `inject` 各一次,或第二个 `createHostPort`)看见它就直接共用,不把 wrapper 套第二层。
278
+ * **读回判据**:cordis 的 traceable 服务代理每次读函数都会新包一层(`get` 里的 `createShadowMethod`),
279
+ * 所以只能看 own property descriptor 的身份;拿 `service[name]` 读回比身份是必然的假警报。
280
+ */
281
+ function captureAgentHandles(ctx) {
282
+ let handles = /* @__PURE__ */ new Map();
283
+ let installed = false;
284
+ const install = (candidate) => {
285
+ if (installed) return;
286
+ const service = candidate;
287
+ if (service === void 0 || service === null) return;
288
+ const existing = service[CAPTURED_AGENT_HANDLES];
289
+ if (existing !== void 0) {
290
+ handles = existing;
291
+ installed = true;
292
+ return;
293
+ }
294
+ let wrappedCount = 0;
295
+ for (const name of ["resume", "create"]) try {
296
+ const original = service[name];
297
+ if (typeof original !== "function") continue;
298
+ const wrapped = function(...args) {
299
+ const result = Reflect.apply(original, this, args);
300
+ result.then((value) => {
301
+ const handle = value;
302
+ const id = handle?.agent?.id;
303
+ if (typeof id !== "string" || id.length === 0 || typeof handle?.dispose !== "function") return;
304
+ handles.set(id, async () => {
305
+ await handle.dispose();
306
+ });
307
+ }, () => void 0);
308
+ return result;
309
+ };
310
+ service[name] = wrapped;
311
+ if (Object.getOwnPropertyDescriptor(service, name)?.value !== wrapped) throw new Error(`agents.${name} is not writable`);
312
+ wrappedCount += 1;
313
+ } catch {}
314
+ if (wrappedCount === 0) return;
315
+ try {
316
+ service[CAPTURED_AGENT_HANDLES] = handles;
317
+ } catch {}
318
+ installed = true;
319
+ };
320
+ const service = ctx.get("agents");
321
+ if (service !== void 0 && service !== null) install(service);
322
+ if (!installed && typeof ctx.inject === "function") try {
323
+ ctx.inject(["agents"], (child) => {
324
+ install(child.get("agents"));
325
+ });
326
+ } catch {}
327
+ return { async dispose(sessionId) {
328
+ const dispose = handles.get(sessionId);
329
+ if (dispose === void 0) return;
330
+ try {
331
+ await dispose();
332
+ } finally {
333
+ handles.delete(sessionId);
334
+ }
335
+ } };
336
+ }
337
+ //#endregion
258
338
  //#region src/ports/host-port.ts
259
339
  /** live store 的最小方法面判据(同 `projectionCacheOf` 的手法:按方法面判,不按 constructor.name)。 */
260
340
  function liveSessionsOf(ctx) {
@@ -279,7 +359,7 @@ async function filterChildrenOf(sessionQuery, values) {
279
359
  *
280
360
  * 停下来的条件与代价:**层数**决定宿主调用次数,行数不参与(一层一次,问空即停)。同一条记录只收一次
281
361
  * (`seen` 连着根一起记),所以数据里真出现环时结果是"少数一条",不是把清单读挂死。
282
- * 交回的 id 没有 `header.id` 的记录**整条跳过**——身份冒充不起(同 {@link HostPort.subagentMembers} 那条),
362
+ * 交回的 id 没有 `header.id` 的记录**整条跳过**——身份冒充不起(同 {@link HostCorpusPort.subagentMembersOf} 那条),
283
363
  * 而且没有身份就没法往下问,硬数进去只会让它将来被重复计。
284
364
  */
285
365
  async function descendantsByParent(sessionQuery, roots) {
@@ -337,7 +417,7 @@ function toMember(d) {
337
417
  projectDir: d.projectDir
338
418
  };
339
419
  }
340
- /** `subagentCounts` / `subagentMembers` / `subagentMembersBatch` 共用的一次 BFS。
420
+ /** {@link HostCorpusPort.subagentMembersOf} 的那一次 BFS。
341
421
  * 空清单在调用宿主之前就返回(不为一张空表扫全库)。 */
342
422
  async function membersFor(sessionQuery, parentIds) {
343
423
  const wanted = [];
@@ -413,7 +493,7 @@ const NO_SUCH_SESSION = "session persistence holds no such session";
413
493
  function isUnknownSession(error) {
414
494
  return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
415
495
  }
416
- /** 会话 id 去掉 `session-` 前缀后的形状(同一个父在两代里写法不同,见 {@link HostPort.subagentCounts})。 */
496
+ /** 会话 id 去掉 `session-` 前缀后的形状(同一个父在两代里写法不同,见 {@link HostCorpusPort.subagentMembersOf})。 */
417
497
  function bareSessionId(id) {
418
498
  return id.startsWith("session-") ? id.slice(8) : id;
419
499
  }
@@ -453,11 +533,12 @@ function isWorkspaceMissingError(error) {
453
533
  *
454
534
  * 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
455
535
  * 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
456
- * `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(设计稿 §6 的两行映射)。
536
+ * `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(src/remote/map-error.ts 的两行映射)。
457
537
  */
458
538
  function createHostPort(ctx) {
459
539
  const host = ctx;
460
540
  const registry = host.workspaceRegistry;
541
+ const agentHandles = captureAgentHandles(host);
461
542
  return {
462
543
  /** 账本里记着这个会话的工作区:`registry.list()` 的实体 `sessionIds`(该 getter 已按 canonical cwd 过滤过)。 */
463
544
  async workspaceOf(sessionId) {
@@ -473,7 +554,7 @@ function createHostPort(ctx) {
473
554
  *
474
555
  * 「账本里没有这个会话」= **未命中**,回 `undefined`(不是抛):这正是本方法声明的类型,
475
556
  * 也是 `delete-session.ts` 已经写好的那条分支 `if (!projectDir) throw
476
- * verbError('not-found', …)` 的入口。若让它裸抛,那句话永远为假 ⇒ 设计稿 §6 的 `not-found`
557
+ * verbError('not-found', …)` 的入口。若让它裸抛,那句话永远为假 ⇒ src/shared/wire.ts 的 `not-found`
477
558
  * (「会话目录或账本条目不存在」)在这条最自然的路径上拿不到码,客户端只会看到 `sessiondelete/io`。
478
559
  *
479
560
  * **只吞这一种**:其余错误(真存储故障)照抛,让 `toRemoteFailure` 兜成 `io` 而不是假 `not-found`。
@@ -505,7 +586,7 @@ function createHostPort(ctx) {
505
586
  }
506
587
  },
507
588
  /**
508
- * 活跃判据 = 宿主官方那一条(设计稿 §5):`workspace/session-activity` waterfall 问一次,
589
+ * 活跃判据 = 宿主官方那一条(本文件):`workspace/session-activity` waterfall 问一次,
509
590
  * **空数组 = 无活动**;与宿主自己的 `archiveSession` 准入同源,不自创第二套。
510
591
  * asar 原文:`const activity = await this.ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]));
511
592
  * if (activity.length > 0) throw new WorkspaceActiveSessionError(sessionId, activity);`
@@ -518,31 +599,39 @@ function createHostPort(ctx) {
518
599
  return registry.archivedSessionIds.includes(sessionId);
519
600
  },
520
601
  /**
521
- * 从 `ctx.sessions`(live store)里摘掉一个会话。
602
+ * 从 `ctx.sessions`(live store)里摘掉会话,并把它的 **agent 一起关掉**(有捕获 handle 时)。
522
603
  *
523
604
  * **为什么需要**:会话列表读面是并集——`sessionQuery.listSessions()` = 磁盘上的持久化 ∪ `ctx.sessions` 里的活体
524
- * (见设计稿 §4 末)。搬走文件只清掉一侧;本机运行期打开过的会话是活体,列表照样把它交出来,
525
- * 于是"删了还在"(真机实测:文件已进回收站、账本已摘,侧栏仍留着那一行)。
605
+ * (见本文件的「通告」一段)。搬走文件只清掉一侧;本机运行期打开过的会话是活体,列表照样把它交出来。
606
+ *
607
+ * **agent 也必须关**:`ctx.agents` 没有 per-agent dispose;宿主 `AgentService.resume` / `create` 返回的
608
+ * `AgentHandle` 带宿主自己的外层 teardown(关写句柄、摘 agent/session),但 `SessionController` 只取
609
+ * `.agent`、把 handle 丢掉。agent handle 捕获(`src/ports/agent-handles.ts`)在 `ctx.agents` 上包一层,
610
+ * 按会话 id 留住 handle;这里先 `entry.detach()`(摘 session、发 `session/disposed`),再
611
+ * `await agentHandles.dispose(sessionId)`。
612
+ * **只摘 session 的后果**:恢复后 `SessionController.resolve` 会先命中那具旧 agent,prompt 被收下、
613
+ * 日志不再增长,直到宿主重启(2026-10-21 真机复现)。
526
614
  *
527
- * **这是未声明为契约的内部入口**(设计稿 §4 末记了这次偏离):`liveEntryFor` / `entry.detach()` 是 store
528
- * 给**会话 owner**(agent 的 effect 链)用的,宿主自己的控制器 `createOrAdopt` 只取 `agents.create()`
529
- * 返回的 `.agent`、把带 `dispose` 的 handle 丢掉了,`ctx.agents` 也没有 `liveEntryFor` 那样的入口
530
- * (会话 store 有、agent store 没有,逐成员面核过)⇒ 只能摘到"会话"这一层,上面那具 agent 摘不到,
531
- * 它之后若走到 `flush(session)` 会撞 `liveEntryFor` 抛 `session "…" is not live in this store`。
615
+ * **捕获不到 handle 的 agent**(插件激活前已启动的配置 agent,或 `ctx.agents` 形状变化)只能摘 session;
616
+ * 这是已知的残留限制,不拿归档当兜底(ADR-0008)。
532
617
  *
533
- * **所以判据是三态**:读不到 store / 形状不符(旧版宿主)/ detach 抛 ⇒ `unknown`(**不通告**,
618
+ * **所以判据是三态**:读不到 store / 形状不符(旧版宿主)/ detach 或 dispose 抛 ⇒ `unknown`(**不通告**,
534
619
  * 因为无法断言宿主列表已经干净);内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。
535
620
  * 任何一步都不抛:删除的持久化部分已经完成,这里不该把它翻成失败。
536
621
  */
537
622
  async evictLive(sessionId) {
538
623
  try {
539
624
  const store = liveSessionsOf(host);
625
+ const session = store?.get(sessionId);
540
626
  if (store === void 0) return "unknown";
541
- const session = store.get(sessionId);
542
- if (session === void 0 || session === null) return "not-live";
627
+ if (session === void 0 || session === null) {
628
+ await agentHandles.dispose(sessionId);
629
+ return "not-live";
630
+ }
543
631
  const entry = store.liveEntryFor(session);
544
632
  if (entry === void 0 || entry === null || typeof entry.detach !== "function") return "unknown";
545
633
  entry.detach();
634
+ await agentHandles.dispose(sessionId);
546
635
  return "evicted";
547
636
  } catch {
548
637
  return "unknown";
@@ -569,41 +658,26 @@ function createHostPort(ctx) {
569
658
  } catch {}
570
659
  },
571
660
  /**
572
- * 通告客户端"这个会话回来了"。
661
+ * 通告客户端"这组会话回来了":**拉一次**宿主自己的会话列表,按 id 逐条发 `api-session/added`。
573
662
  *
574
663
  * 摘要**取自宿主自己的会话列表**(`ctx.sessionController.list()`),不自己造一行:客户端 `handleSessionAdded`
575
664
  * 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
576
665
  * 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**。
577
666
  *
578
- * **这一条单条路是删除组回滚用的**:成员已经收过 `api-session/removed`,退回 `sessions/` 之后要补一条
579
- * "added",否则客户端那一行要等到重连才回来。正常恢复走 {@link announceRestoredBatch} + 一次预取——
580
- * 100 条的批量用单条路就是 100 遍全表扫,那正是 2026-10-17 那次"恢复很慢"的头号来源。
667
+ * **取数从 interface 收进了 implementation**:单条就是长度为 1 的批,批量恢复与删除组回滚走同一条路。
668
+ * 一次预取由这里保证——100 条一组的批量若按条拉表就是 100 遍全表扫,那正是 2026-10-17 那次"恢复很慢"的头号来源。
581
669
  */
582
- async announceRestored(sessionId) {
583
- const summary = (await this.restoredSummariesOf()).find((item) => item.sessionId === sessionId)?.summary;
584
- if (summary === void 0) return;
585
- try {
586
- host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
587
- } catch {}
588
- },
589
- /** 整批预取一次会话表(`[{ sessionId, summary }]`):通告的路径与单条一致,**拉表从每条一次抬成整批一次**。 */
590
- async restoredSummariesOf() {
591
- const out = [];
670
+ async announceRestored(sessionIds) {
671
+ if (sessionIds.length === 0) return;
672
+ const summaries = [];
592
673
  for (const item of await controllerItems(host)) {
593
674
  const sessionId = item?.sessionId;
594
- if (typeof sessionId === "string") out.push({
675
+ if (typeof sessionId === "string") summaries.push({
595
676
  sessionId,
596
677
  summary: item
597
678
  });
598
679
  }
599
- return out;
600
- },
601
- /** 一组会话的通告(2026-10-17 批量重构):`known` 给了就**一次都不拉表**,只按它筛。
602
- * 与 {@link announceRestored} 同一判据、同一事件、同一载荷形状,只是取数由整批共享。 */
603
- async announceRestoredBatch(ids, known) {
604
- if (ids.length === 0) return;
605
- const summaries = known ?? await this.restoredSummariesOf();
606
- for (const id of ids) {
680
+ for (const id of sessionIds) {
607
681
  const summary = summaries.find((item) => item.sessionId === id)?.summary;
608
682
  if (summary === void 0) continue;
609
683
  try {
@@ -630,13 +704,13 @@ function createHostPort(ctx) {
630
704
  /** 确保归档:官方 `archiveSession(sessionId)`。归档是 registry **全局**的,与工作区账目无关
631
705
  * (JSDoc 原文 "its workspace accounting — or lack of one — is irrelevant"),所以 `workspaceId` 不用
632
706
  * ——未分组的会话没有工作区可传,签名里它是可选的。
633
- * 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,设计稿 §6)。 */
707
+ * 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,src/shared/wire.ts)。 */
634
708
  async archive(sessionId) {
635
709
  await registry.archiveSession(sessionId);
636
710
  },
637
711
  async listSessions() {
638
712
  const sessionQuery = host.get("sessionQuery");
639
- if (typeof sessionQuery?.listSessions !== "function") return void 0;
713
+ if (typeof sessionQuery?.listSessions !== "function") return { available: false };
640
714
  const records = await sessionQuery.listSessions();
641
715
  const out = [];
642
716
  for (const record of records) {
@@ -651,7 +725,10 @@ function createHostPort(ctx) {
651
725
  subagent: record.header?.origin === "subagent"
652
726
  });
653
727
  }
654
- return out;
728
+ return {
729
+ available: true,
730
+ value: out
731
+ };
655
732
  },
656
733
  /**
657
734
  * 官方 `sessionQuery.filterSessions([{kind:'parent', values}])`,不自扫 `~/.dsh/sessions`。
@@ -664,32 +741,26 @@ function createHostPort(ctx) {
664
741
  * (`session-ac07f2bb-…`)、它的子会话写的 `parentSession` 也带,但 v3 那一代的 id 不带 ⇒
665
742
  * 同一个父有两种写法。所以问的时候把一个 id 的两种形状都塞进 `values`,回来按去前缀后的形状归堆,
666
743
  * 最后按**调用方给的那个写法**把键交回去(调用方拿 `entry.sessionId` 直接查得到)。
667
- */
668
- async subagentCounts(parentIds) {
669
- const sessionQuery = host.get("sessionQuery");
670
- if (typeof sessionQuery?.filterSessions !== "function") return {};
671
- const members = await membersFor(sessionQuery, parentIds);
672
- const counts = {};
673
- for (const [id, list] of Object.entries(members)) if (list.length > 0) counts[id] = list.length;
674
- return counts;
675
- },
676
- /**
744
+ *
677
745
  * 删除要一起带走**整个组**,先得把他们点出来:身份取宿主自己记录里的 `header.id`,项目目录由
678
746
  * `header.cwd` 走同一个 `projectKey`(与 {@link HostPort.projectDirOf} 同一根算法,不另起一套)。
679
747
  * 交回的是**全部后代**——孙代跟着父一起进回收站(issue #1),顺序是逐层往下问出来的(近的先)。
680
748
  * `header.id` 缺席的后代直接跳过——不拿目录名冒充身份(冒充了会把一个来路不明的目录搬进回收站)。
681
- * 服务没挂回 `[]` ⇒ 删除照旧只管父,不因此失败(父的删除本来就已经是完整的一件事)。
749
+ *
750
+ * **缺席显式化**(见 {@link CorpusRead}):`sessionQuery` 没挂,或它的 `filterSessions` 缺席 / 不是函数
751
+ * ⇒ `{ available: false }`,不拿空表冒充"盘上没有后代"——写侧据此决定降级还是响亮失败,不再靠"空表"
752
+ * 兼表两种意思。
753
+ * **先判服务、再判空入参**:语料本来就看不见时,"没有后代"这句话不该被说出口(哪怕入参是空的);
754
+ * 服务在场时空入参由 `membersFor` 的短路保证零宿主调用。
682
755
  */
683
- async subagentMembers(sessionId) {
684
- const sessionQuery = host.get("sessionQuery");
685
- if (typeof sessionQuery?.filterSessions !== "function") return [];
686
- return ((await membersFor(sessionQuery, [sessionId]))[sessionId] ?? []).map(toMember);
687
- },
688
- async subagentMembersBatch(parentIds) {
756
+ async subagentMembersOf(parentIds) {
689
757
  const sessionQuery = host.get("sessionQuery");
690
- if (typeof sessionQuery?.filterSessions !== "function") return {};
758
+ if (typeof sessionQuery?.filterSessions !== "function") return { available: false };
691
759
  const members = await membersFor(sessionQuery, parentIds);
692
- return Object.fromEntries(Object.entries(members).map(([id, list]) => [id, list.map(toMember)]));
760
+ return {
761
+ available: true,
762
+ value: Object.fromEntries(Object.entries(members).map(([id, list]) => [id, list.map(toMember)]))
763
+ };
693
764
  }
694
765
  };
695
766
  }
@@ -737,14 +808,63 @@ function groupOf(rows, id) {
737
808
  return kidsOf(rows, id);
738
809
  }
739
810
  /**
740
- * 「这条是被**同批前一条**带走的,还是整批开始前就没有?」
811
+ * 「这条是被**同批前一条**带走的,还是整批开始前就没有,还是仍在?」
741
812
  *
742
- * 幂等语义只属于前者:后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它)必须照样响亮报
813
+ * 幂等语义只属于 `carried`:后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它)必须照样响亮报
743
814
  * `trash-empty`,不能被"已被带走"这句话掩盖成成功。**所以判据是快照的原始形状,不是当前形状**:
744
- * 原始快照里认得出(它自己、它的父、或它的孩子任一在),而当前快照里已经空了 ⇒ 同批带走的。
815
+ * 原始快照里认得出(它自己、或指向它的孩子任一在),而当前快照里已经空了 ⇒ 同批带走的。
816
+ */
817
+ function claimOf(original, current, id) {
818
+ if (groupOf(original, id).length === 0) return { verdict: "absent" };
819
+ const group = groupOf(current, id);
820
+ return group.length === 0 ? { verdict: "carried" } : {
821
+ verdict: "fresh",
822
+ group
823
+ };
824
+ }
825
+ //#endregion
826
+ //#region src/verbs/batch-carried.ts
827
+ /**
828
+ * **同批已带走集**(「已被同批前一条带走」的 id)的唯一实现(ADR-0002 的内部实现)。
829
+ *
830
+ * 三个动作曾各有一套机制:delete 用动词回吐的 `movedSessionIds`,restore/purge 用
831
+ * `original / current` 两份快照 + 旧 boolean 谓词 + `shrink`。这里把判据收成 `claim(id)` + `take(id)`;
832
+ * 两种 id 域各一个 adapter:
833
+ * - entry 适配:restore / purge 的条目 id,`claim` 是三态(fresh / carried / absent),组语义仍读
834
+ * `trash/groups.ts`;
835
+ * - session 适配:delete 的会话 id,`claim` 只有 fresh / carried,集合由动词结果长出。
836
+ *
837
+ * entry 适配的三态判据读 `groups.claimOf`;session 适配没有原始清册视图,只记 `take` 过的会话 id
838
+ * (因此只有 fresh / carried 两态)。这里只做 id 域适配,不另写组语义。
839
+ * 两域的 `claim` 形状不同:entry 交 `GroupClaim`(只有 fresh 带组),session 只交 verdict;
840
+ * 当前只读视图(`rows`)只挂在 entry 域上:只有 purge 要它——盘上后代要按清册里还在的 id 排除。
745
841
  */
746
- function takenByEarlierInBatch(original, current, id) {
747
- return groupOf(original, id).length > 0 && groupOf(current, id).length === 0;
842
+ /** 恢复 / 彻底删除:原始快照认得、当前视图已经空了 ⇒ 同批前一条带走的。 */
843
+ function createEntryCarried(original) {
844
+ let current = [...original];
845
+ return {
846
+ claim(id) {
847
+ return claimOf(original, current, id);
848
+ },
849
+ take(id) {
850
+ const gone = new Set(groupOf(current, id).map((row) => row.id));
851
+ if (gone.size === 0) return;
852
+ current = current.filter((row) => !gone.has(row.id));
853
+ },
854
+ rows: () => current
855
+ };
856
+ }
857
+ /** 删除:会话 id 域;`take` 只收这次真的搬走的成员(父 + 盘上的孩子)。 */
858
+ function createSessionCarried() {
859
+ const carried = /* @__PURE__ */ new Set();
860
+ return {
861
+ claim(id) {
862
+ return carried.has(bareSessionId(id)) ? { verdict: "carried" } : { verdict: "fresh" };
863
+ },
864
+ take(id) {
865
+ carried.add(bareSessionId(id));
866
+ }
867
+ };
748
868
  }
749
869
  //#endregion
750
870
  //#region src/trash/entry.ts
@@ -807,397 +927,182 @@ function trashDirOf(entryId, home) {
807
927
  return `${home}/${TRASH_DIR_NAME}/${entryId}`;
808
928
  }
809
929
  //#endregion
810
- //#region src/log/zstd-frames.ts
811
- const MAGIC = [
812
- 40,
813
- 181,
814
- 47,
815
- 253
816
- ];
817
- /** magic(4) + Frame_Header_Descriptor(1) 之后第一个字节的偏移。 */
818
- const AFTER_DESCRIPTOR = 5;
819
- const BLOCK_HEADER_BYTES = 3;
820
- const BLOCK_LAST = 1;
821
- const BLOCK_TYPE_SHIFT = 1;
822
- const BLOCK_SIZE_SHIFT = 3;
823
- const BLOCK_SIZE_MASK = 2097151;
824
- const BLOCK_RAW = 0;
825
- const BLOCK_RLE = 1;
826
- const BLOCK_COMRESSED = 2;
827
- /** 规范(RFC 8878 表 4)里 3 是 **Reserved**,不是"未压缩块":见到就抛,别照 Raw 吞块体。 */
828
- const BLOCK_RESERVED = 3;
829
- /** descriptor 各字段的取值 → 字段字节数(zstd 帧头的两张尺寸表)。 */
830
- const DID_SIZES = [
831
- 0,
832
- 1,
833
- 2,
834
- 4
835
- ];
836
- const FCS_SIZES_SINGLE = [
837
- 1,
838
- 2,
839
- 4,
840
- 8
841
- ];
842
- const FCS_SIZES_MULTI = [
843
- 0,
844
- 2,
845
- 4,
846
- 8
847
- ];
848
- /** 实测的帧尾长度(校验和位置;bit5 没置位也照样带,见文件头注)。 */
849
- const FRAME_TAIL_BYTES = 4;
850
- function isMagicAt(raw, at) {
851
- return MAGIC.every((byte, i) => raw[at + i] === byte);
930
+ //#region src/verbs/member-journal.ts
931
+ /**
932
+ * 一次成员移动 = 一串**自带反向动作的步骤**(ADR-0002)。
933
+ *
934
+ * 为什么需要它:`delete-session.ts` 的 `moveOneIn`/`undoOneIn` 与 `restore-session.ts` 的 `restoreOne`
935
+ * (含它的反向闭包)互为镜像,各自手写了三到四段嵌套 `try/catch`:迁移前全仓 `rollbackReason(` **22 处**、
936
+ * `withRollbackFailures(` **8 处**。那不是"回滚值得自己的候选",而是**同一条正反动作对被写了两遍**。
937
+ *
938
+ * 这里独占四件事,三个动词各自只声明"这次要做哪几步、每步怎么退":
939
+ * ① **相反顺序**退化(最后做成的先退);
940
+ * ② 一步失败时把**已经做成的**那些退回去;
941
+ * ③ 失败收集:某一步退不干净时**不让原错误独走**(`details.rollbackFailures`,见 `verbs/errors.ts`);
942
+ * ④ **非原子步骤**:`undoOnFailure` 让"这一步自己失败了"也跑它的反向动作(例如"取消归档"半做完了,
943
+ * 得先把会话重新藏回去)。
944
+ *
945
+ * 边界:它只服务"成员在 `sessions/` 与 `session-trash/` 之间搬"这一件事。做成通用撤销框架就会吸进
946
+ * 不属于它的知识(谁该通告、通告几次、哪些失败算成功)。
947
+ */
948
+ /** 已经把某个前缀做成了的 journal——`undo()` 就退那一个前缀。 */
949
+ function createJournal(steps) {
950
+ const done = [];
951
+ const undoAll = async (alsoFailing) => {
952
+ const failures = [];
953
+ const restored = /* @__PURE__ */ new Map();
954
+ const runUndo = async (step) => {
955
+ if (step.undo === void 0) return;
956
+ const result = await step.undo();
957
+ failures.push(...result.failures);
958
+ restored.set(step.key, result.restored);
959
+ };
960
+ if (alsoFailing?.undo !== void 0) await runUndo(alsoFailing);
961
+ for (const step of [...done].reverse()) await runUndo(step);
962
+ return {
963
+ failures,
964
+ restored: (key) => restored.get(key) ?? false
965
+ };
966
+ };
967
+ return {
968
+ async run() {
969
+ for (const step of steps) {
970
+ try {
971
+ await step.run();
972
+ } catch (error) {
973
+ const undo = await undoAll(step.undoOnFailure === true ? step : void 0);
974
+ const base = journalFailure(step, error);
975
+ throw undo.failures.length > 0 ? withRollbackFailures(base, undo.failures) : base;
976
+ }
977
+ done.push(step);
978
+ }
979
+ },
980
+ undo() {
981
+ return undoAll();
982
+ }
983
+ };
852
984
  }
853
- function readUint24LE(raw, at) {
854
- return raw[at] | raw[at + 1] << 8 | raw[at + 2] << 16;
985
+ /** 跑一步反向动作并把它自己的失败收成一条原因(不抛:这些失败由调用方汇总成 `rollbackFailures`)。 */
986
+ async function attempt(label, run) {
987
+ try {
988
+ await run();
989
+ return [];
990
+ } catch (error) {
991
+ return [rollbackReason(label, error)];
992
+ }
855
993
  }
856
- /** 块体之后有没有那 4 字节尾巴:只有"跳过它之后仍是帧头或到 EOF"才认。 */
857
- function withFrameTail(raw, at) {
858
- const afterTail = at + FRAME_TAIL_BYTES;
859
- if (afterTail <= raw.length && (afterTail === raw.length || isMagicAt(raw, afterTail))) return afterTail;
860
- return at;
994
+ /** 一步反向动作的完整结果:主写是否回到原样 + 失败串。附属写失败只进 `failures`,不推翻 `restored`。 */
995
+ async function stepUndo(label, run) {
996
+ const failures = await attempt(label, run);
997
+ return {
998
+ restored: failures.length === 0,
999
+ failures
1000
+ };
861
1001
  }
862
1002
  /**
863
- * 把 `raw` 切成帧区间。
864
- * @throws 起始处不是 magic、或某帧算到的末位既不是下一帧帧头也不是 EOF——**一律抛**,
865
- * 绝不"尽力往下解":切错帧的后果是把半截压缩体当解压输入,那时解出来的东西不可信。
1003
+ * **组级回滚**:把已经做成的成员按相反顺序退回;某一步退不干净时把失败并进原错误的
1004
+ * `details.rollbackFailures`,绝不让原错误独走(2026-10-19 `f2a7757`;那些失败句子的合并与措辞住
1005
+ * `src/verbs/errors.ts`,这里只是唯一的调用方)。删除与恢复两个按组走的动词共用这一处,
1006
+ * "相反顺序 + 失败合并"不再各写一遍。
866
1007
  */
867
- function frameRanges(raw) {
868
- const ranges = [];
869
- let at = 0;
870
- while (at < raw.length) {
871
- if (!isMagicAt(raw, at)) throw new Error(`zstd 帧头缺席:偏移 ${at} 处不是 magic(前面第 ${ranges.length} 帧的边界算歪了?)`);
872
- const descriptor = raw[at + 4];
873
- if (descriptor === void 0) throw new Error(`zstd 帧头不完整:偏移 ${at} 之后不足 ${AFTER_DESCRIPTOR} 字节`);
874
- const fcsFlag = descriptor & 3;
875
- const singleSegment = descriptor >> 2 & 1;
876
- const didFlag = descriptor >> 6 & 3;
877
- let cursor = at + AFTER_DESCRIPTOR;
878
- if (!singleSegment) cursor += 1;
879
- cursor += DID_SIZES[didFlag];
880
- cursor += (singleSegment ? FCS_SIZES_SINGLE : FCS_SIZES_MULTI)[fcsFlag];
881
- for (;;) {
882
- const blockHeader = readUint24LE(raw, cursor);
883
- cursor += BLOCK_HEADER_BYTES;
884
- if (cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
885
- const blockType = blockHeader >> BLOCK_TYPE_SHIFT & 3;
886
- const blockSize = blockHeader >> BLOCK_SIZE_SHIFT & BLOCK_SIZE_MASK;
887
- if (blockType === BLOCK_RESERVED) throw new Error(`zstd 保留块类型:帧起于 ${at},偏移 ${cursor - BLOCK_HEADER_BYTES} 的块头 type=3(规范 Reserved,对面解不开)`);
888
- if (blockType === BLOCK_RAW || blockType === BLOCK_COMRESSED) cursor += blockSize;
889
- else if (blockType === BLOCK_RLE) cursor += 1;
890
- if (blockHeader & BLOCK_LAST) break;
891
- }
892
- cursor = withFrameTail(raw, cursor);
893
- if (cursor !== raw.length && !isMagicAt(raw, cursor)) throw new Error(`zstd 帧边界算歪了:帧起于 ${at},算到 ${cursor},那里既不是下一帧帧头也不是 EOF`);
894
- ranges.push({
895
- start: at,
896
- end: cursor
897
- });
898
- at = cursor;
1008
+ async function rollbackJournals(journals, error) {
1009
+ const failures = [];
1010
+ for (const journal of [...journals].reverse()) {
1011
+ const undo = await journal.undo();
1012
+ failures.push(...undo.failures);
899
1013
  }
900
- return ranges;
1014
+ if (failures.length > 0) throw withRollbackFailures(error, failures);
1015
+ throw error;
1016
+ }
1017
+ /**
1018
+ * 这一步的失败形状:已经是动词码(`verbError` 的输出)就原样带,否则折成 `sessiondelete/io`。
1019
+ * 原文用 `String(error)` 而不是 `.message`——那 22 处既有措辞就是这么拼的(`Error: boom` 也在里面),
1020
+ * 换一种拼法会让上屏原文跟着变。
1021
+ */
1022
+ function journalFailure(step, error) {
1023
+ if (isSessionDeleteCodeError(error)) return error;
1024
+ return verbError("io", `${step.label}:${String(error)}`, error);
901
1025
  }
902
1026
  //#endregion
903
- //#region src/log/session-log.ts
1027
+ //#region src/verbs/live-admission.ts
1028
+ /** 逐个问,任一在跑就抛 `live`。名单的挑选(哪些成员算这一组)归调用方,这里只按传入顺序问完。 */
1029
+ async function assertNoLive(host, sessionIds) {
1030
+ for (const sessionId of sessionIds) if (await host.isLive(sessionId)) throw verbError("live", `会话正在运行:${sessionId}`);
1031
+ }
1032
+ //#endregion
1033
+ //#region src/verbs/member-presence.ts
1034
+ function createMemberPresence(host) {
1035
+ return {
1036
+ async memberGone(sessionId, facts = {}) {
1037
+ if (facts.backInTrash === false) return;
1038
+ if (await host.evictLive(sessionId) !== "unknown") await host.announceRemoved(sessionId);
1039
+ },
1040
+ async memberBack(sessionId, facts) {
1041
+ if (!facts.diskBack || !facts.workspaceBack) return;
1042
+ await host.announceRestored([sessionId]);
1043
+ }
1044
+ };
1045
+ }
904
1046
  /**
905
- * 会话日志(`session.vN.jsonl.zstd`)的**逐帧读法**:帧边界交给 `zstd-frames.ts`,这一层只做
906
- * 「帧 → 明文 → 一行一条 JSON」的解码,并把每条记录原样交给回调。
1047
+ * 给一次成员移动的 journal 补上「在场那条尾巴」:run 完全成功之后离场、undo 之后按事实归位或离场。
907
1048
  *
908
- * **策略不在这里**(哪些 `type` 算正文、截断到多少字、算不算失败)——那是 `src/trash/detail.ts` 的事。
909
- * 这一层只懂编解码,所以它能被单独验穿。
1049
+ * 为什么是个工厂:删除与恢复此前各手拼一个匿名 journal,形状逐字相同(`journal.run` 加上
1050
+ * `presence.memberGone`/`journal.undo` 加上 `presence.memberBack`),只差键 → 事实的映射。
1051
+ * 两处各写一遍,就是「补在哪一拍」这条顺序又有第二个家。
910
1052
  *
911
- * 两条实测决定的口径:
912
- * ① **必须逐帧**:Node 的 `zstdDecompressSync` 与 `createZstdDecompress()` 都只给第一帧(见
913
- * `zstd-frames.ts` 头注);明文留在手里会一次装下整条日志(本机 125 条合计 280.7 MB),
914
- * 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
915
- * ② **一行的余量跨帧带走**(2026-10-16 改,原来靠"跳过解不开的行"兜):写作侧是一条条 append,
916
- * 实测本机 686 个 `.jsonl.zstd` / 92 421 帧**没有一帧切在行中间**(连最后一帧都整行收尾),
917
- * 所以旧口径当时并不丢东西;但"逐帧 `split('\n')`"一旦遇上切行就是**静默少一条记录**(半个头
918
- * 加半个身,两半都解不开 JSON),而少掉的那条正是页面上要跟账的正文。带上余量之后这条不变量
919
- * 不必再成立,顺带把**跨帧的多字节字符**也解对了——拼接在字节层做,`toString('utf8')` 只看整行。
920
- * 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
921
- */
922
- const NEWLINE = 10;
923
- /**
924
- * 宿主给每一代日志的词法(逐字取自 `@deepseek-ai/dsh-session-format@0.2.0-rc.2` 的
925
- * `CANONICAL_LOG_FILENAME`,剥掉压缩后缀那段由我们自己在正则里补上 `.zstd`):
926
- * 裸名 `session.jsonl` 是**第 0 代**,往后是 `session.vN`;`.v0`、前导零、大写、临时名都不是规范名。
1053
+ * 边界:`memberGone` 的 `backInTrash:false` 早退与 `memberBack` 的两件齐备真值表仍归 `createMemberPresence`;
1054
+ * 这里只把事实原样递过去,不替它判断。
927
1055
  */
928
- const CANONICAL_ZSTD_LOG = /^session(?:\.v([1-9][0-9]*))?\.jsonl\.zstd$/u;
1056
+ function withPresenceTail(presence, journal, tail) {
1057
+ const announce = async (undo) => {
1058
+ const fact = tail.onUndone(undo);
1059
+ if (fact.kind === "leave") {
1060
+ await presence.memberGone(tail.sessionId, { backInTrash: fact.backInTrash });
1061
+ return;
1062
+ }
1063
+ await presence.memberBack(tail.sessionId, {
1064
+ diskBack: fact.diskBack,
1065
+ workspaceBack: fact.workspaceBack
1066
+ });
1067
+ };
1068
+ return {
1069
+ async run() {
1070
+ await journal.run();
1071
+ if (tail.onDone === "leave") await presence.memberGone(tail.sessionId);
1072
+ },
1073
+ async undo() {
1074
+ const undo = await journal.undo();
1075
+ await announce(undo);
1076
+ return undo;
1077
+ }
1078
+ };
1079
+ }
1080
+ //#endregion
1081
+ //#region src/verbs/move-group.ts
929
1082
  /**
930
- * 同一个会话目录里**可以并存多代日志**(宿主换代时把老代留在原地当历史语料,本机实测 7 个目录如此),
931
- * 而新代是老代的**迁移后超集**(实测:`firstTs` 与开头那条提问完全相同、记录更多、末尾更晚)
932
- * ⇒ 要读的是版本号最大的那一代,读旧代 = 读到一份截了尾巴的快照;把几代拼起来 = 重复计数。
1083
+ * 组级成员移动:组根先跑(拿到 `undefined`),跟随者按序跑并拿到根结果;任一失败就把**已经做成**的
1084
+ * 按相反顺序退回。
933
1085
  *
934
- * 明文(无 `.zstd`)不在此列:本插件的读法逐帧解 zstd,明文得走另一条解码路。
1086
+ * 这里拥有顺序、整组回滚与**组根结果的交回**:根先跑,跟随者按序拿到根结果;调用方直接拿
1087
+ * 返回的根结果,不按位置索引结果数组。跟随者自己的结果不向上交回——当前调用方不需要。
1088
+ * 单成员的步骤与反向动作仍归 `member-journal.ts`;这条归属的裁决见 ADR-0002。
935
1089
  */
936
- function pickSessionLog(names) {
937
- let best;
938
- let bestVersion = -1;
939
- for (const name of names) {
940
- const match = CANONICAL_ZSTD_LOG.exec(name);
941
- if (match === null) continue;
942
- const version = match[1] === void 0 ? 0 : Number(match[1]);
943
- if (version > bestVersion) {
944
- bestVersion = version;
945
- best = name;
946
- }
947
- }
948
- return best;
949
- }
950
- /**
951
- * 逐帧走一遍日志,每条记录交给回调。
952
- *
953
- * 回调返回 `false` = **提前停机**(后面的帧不再解——`readSessionHeader` 只要第一行)。
954
- * 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
955
- */
956
- function walkSessionLog(raw, onRecord) {
957
- const stats = {
958
- frames: 0,
959
- records: 0,
960
- unparsedLines: 0,
961
- truncatedTailBytes: 0
962
- };
963
- let stopped = false;
964
- const feed = (line) => {
965
- if (line === "") return;
966
- let parsed;
967
- try {
968
- parsed = JSON.parse(line);
969
- } catch {
970
- stats.unparsedLines += 1;
971
- return;
972
- }
973
- stats.records += 1;
974
- if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
975
- };
976
- /** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
977
- let carry = Buffer.alloc(0);
978
- for (const { start, end } of frameRanges(raw)) {
979
- if (stopped) break;
980
- stats.frames += 1;
981
- let plain;
982
- try {
983
- plain = zlib.zstdDecompressSync(raw.subarray(start, end));
984
- } catch (error) {
985
- throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`);
986
- }
987
- const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
988
- const cut = merged.lastIndexOf(NEWLINE);
989
- if (cut === -1) {
990
- carry = merged;
991
- continue;
992
- }
993
- carry = merged.subarray(cut + 1);
994
- for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
995
- feed(line);
996
- if (stopped) break;
997
- }
998
- }
999
- stats.truncatedTailBytes = carry.length;
1000
- if (carry.length > 0 && !stopped) feed(carry.toString("utf8"));
1001
- return stats;
1002
- }
1003
- /**
1004
- * 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
1005
- * AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
1006
- * (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
1007
- * 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
1008
- */
1009
- function readSessionHeader(raw) {
1010
- let header;
1011
- walkSessionLog(raw, (record) => {
1012
- if (record.type !== "session") return;
1013
- header = record;
1014
- return false;
1015
- });
1016
- return header;
1017
- }
1018
- //#endregion
1019
- //#region src/trash/reconcile.ts
1020
- /**
1021
- * 回收站清单的**对账**:以 `session-trash/` 里的条目目录为准,把清单修回一致(设计稿 2026-10-15 §9)。
1022
- *
1023
- * 手工动回收站**只需要动那一个目录**——多出来的目录在这里被重建出行、目录没了的行在这里被摘掉;
1024
- * `~/.dsh/storages/` 那份清单从此是**内部索引**,不用手碰。
1025
- *
1026
- * 五条口径(都不是口味):
1027
- * ① **每次读清单都对账**(`GET /list` 的正文就是"对账 + 读"):挂载、刷新、别处动过后的自动重读、
1028
- * 客户端启动探测全走这条 ⇒ "改完目录不用再做任何事,下一次读就对了"。代价 = 每次读多一次目录枚举
1029
- * (本机 124 条,毫秒级);**零漂移零写入**(快路直接返回原清单)。
1030
- * ② **回收站根不在 ⇒ 整套跳过、一个字节都不动**:那不是"回收站空了",是"这块地不在原处"
1031
- * (人为搬走、盘没挂上)——照常对账会把全部行的元数据一次判没。
1032
- * ③ **重建行只写"文件里真有的事实"**:`id` = 目录名、`sessionId`/`cwd` 读日志 header、
1033
- * `projectDir` 由 `cwd` 经宿主同名算法 `projectKey` 反推、`deletedAt` 从目录名的时间戳解、
1034
- * `sizeBytes` 现场量。**文件侧没有副本的三件**按缺省落:`workspaceId: ''`(恢复时跳过挂账本,
1035
- * 落在「未分组」)、`wasArchived: false`(恢复按"可见"还原)、`title: ''`(详情有日志标题兜底)
1036
- * ——这是重建的固有代价,不是可以"再读读看"的东西。
1037
- * ④ **认不出 / 读不出的目录:不动、不列**(名字不像条目、目录里没有日志、header 读不出、尺寸量不出,
1038
- * 各跳过各的)。它是"外人放进来的东西",不是用户动作失败:跳过即静默,绝不为它删目录或抛掉整次读数。
1039
- * ⑤ **幂等**:再跑一遍零写入;崩在任何一步,下一次读清单接着把它修完。
1040
- *
1041
- * 与三个写动词的**串行化不在这里**(那是传输层的事,见 `src/api/dispatch.ts` 的到达序链):
1042
- * 对账与删除/恢复/彻底删除都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩——删除刚把目录搬进
1043
- * 回收站、行还没写上时,对账抢先把这行按目录"重建"出来,随后又被真实行覆盖成降级元数据。
1044
- */
1045
- /** 目录名是不是一条条目 id(形状契约在 `entry.ts`:`<删除时刻UTC>-<原目录名>`;
1046
- * 原目录名还必须能安全地拼回磁盘路径——带分隔符的名字仍按"外人"跳过,不让一次对账读崩)。 */
1047
- function isEntryId(name) {
1048
- try {
1049
- return isSinglePathSegment(parseEntryId(name).sessionDirName);
1050
- } catch {
1051
- return false;
1052
- }
1053
- }
1054
- /**
1055
- * 从一个手工放进回收站的目录重建清单行。**任一件事实拿不到就整体放弃这一条**(回 `undefined`):
1056
- * 半截行比"不列"更坏——恢复会照它把目录搬去一个错的地方(同 `detail.ts` 那条"半截正文"的口径)。
1057
- *
1058
- * `sessionId` 取日志 header 的 `id`(恢复之后宿主按日志 header 建索引,那才是会话身份);
1059
- * header 缺席这个字段才回落到目录名里那段原目录名。
1060
- */
1061
- async function buildEntry(deps, id) {
1062
- const dir = trashDirOf(id, deps.home);
1063
- let logName;
1064
- try {
1065
- logName = pickSessionLog(await deps.fs.listFiles(dir));
1066
- } catch {
1067
- return;
1068
- }
1069
- if (logName === void 0) return void 0;
1070
- let raw;
1071
- try {
1072
- raw = await deps.fs.readFile(`${dir}/${logName}`);
1073
- } catch {
1074
- return;
1075
- }
1076
- let header;
1077
- try {
1078
- header = readSessionHeader(raw);
1079
- } catch {
1080
- return;
1081
- }
1082
- if (header === void 0) return void 0;
1083
- let sizeBytes;
1084
- try {
1085
- sizeBytes = await deps.fs.sizeOfDir(dir);
1086
- } catch {
1087
- return;
1088
- }
1089
- const { deletedAt, sessionDirName } = parseEntryId(id);
1090
- const cwd = typeof header.cwd === "string" ? header.cwd : "";
1091
- return {
1092
- id,
1093
- sessionId: typeof header.id === "string" && header.id !== "" ? header.id : sessionDirName,
1094
- projectDir: cwd === "" ? NO_CWD_PROJECT_DIR : projectKey(cwd),
1095
- workspaceId: "",
1096
- wasArchived: false,
1097
- deletedAt,
1098
- title: "",
1099
- sizeBytes
1100
- };
1101
- }
1102
- /** 对账并回清单——**返回的就是修好之后的那份**(与 `manifest.list()` 同序:最近删除在前)。 */
1103
- async function reconcileTrash(deps) {
1104
- const rows = await deps.manifest.list();
1105
- const root = `${deps.home}/${TRASH_DIR_NAME}`;
1106
- if (!await deps.fs.exists(root)) return rows;
1107
- const dirIds = /* @__PURE__ */ new Set();
1108
- for (const name of await deps.fs.listDirs(root)) if (isEntryId(name)) dirIds.add(name);
1109
- const rowIds = new Set(rows.map((row) => row.id));
1110
- const drops = rows.filter((row) => !dirIds.has(row.id));
1111
- const rebuilds = [];
1112
- for (const id of dirIds) {
1113
- if (rowIds.has(id)) continue;
1114
- const entry = await buildEntry(deps, id);
1115
- if (entry !== void 0) rebuilds.push(entry);
1116
- }
1117
- if (drops.length === 0 && rebuilds.length === 0) return rows;
1118
- for (const row of drops) await deps.manifest.remove(row.id);
1119
- for (const entry of rebuilds) await deps.manifest.add(entry);
1120
- return await deps.manifest.list();
1121
- }
1122
- //#endregion
1123
- //#region src/verbs/member-journal.ts
1124
- /**
1125
- * 一次成员移动 = 一串**自带反向动作的步骤**(ADR-0002)。
1126
- *
1127
- * 为什么需要它:`delete-session.ts` 的 `moveOneIn`/`undoOneIn` 与 `restore-session.ts` 的 `restoreOne`
1128
- * (含它的反向闭包)互为镜像,各自手写了三到四段嵌套 `try/catch`:全仓 `rollbackReason(` **22 处**、
1129
- * `withRollbackFailures(` **8 处**。那不是"回滚值得自己的候选",而是**同一条正反动作对被写了两遍**。
1130
- *
1131
- * 这里独占四件事,三个动词各自只声明"这次要做哪几步、每步怎么退":
1132
- * ① **相反顺序**退化(最后做成的先退);
1133
- * ② 一步失败时把**已经做成的**那些退回去;
1134
- * ③ 失败收集:某一步退不干净时**不让原错误独走**(`details.rollbackFailures`,见 `verbs/errors.ts`);
1135
- * ④ **非原子步骤**:`undoOnFailure` 让"这一步自己失败了"也跑它的反向动作(例如"取消归档"半做完了,
1136
- * 得先把会话重新藏回去)。
1137
- *
1138
- * 边界:它只服务"成员在 `sessions/` 与 `session-trash/` 之间搬"这一件事。做成通用撤销框架就会吸进
1139
- * 不属于它的知识(谁该通告、通告几次、哪些失败算成功)。
1140
- */
1141
- /** 已经把某个前缀做成了的 journal——`undo()` 就退那一个前缀。 */
1142
- function createJournal(steps) {
1143
- const done = [];
1144
- const undoAll = async (alsoFailing) => {
1145
- const failures = [];
1146
- if (alsoFailing?.undo !== void 0) failures.push(...await alsoFailing.undo());
1147
- for (const step of [...done].reverse()) {
1148
- if (step.undo === void 0) continue;
1149
- failures.push(...await step.undo());
1150
- }
1151
- return failures;
1152
- };
1153
- return {
1154
- async run() {
1155
- for (const step of steps) {
1156
- try {
1157
- await step.run();
1158
- } catch (error) {
1159
- const failures = await undoAll(step.undoOnFailure === true ? step : void 0);
1160
- const base = journalFailure(step, error);
1161
- throw failures.length > 0 ? withRollbackFailures(base, failures) : base;
1162
- }
1163
- done.push(step);
1164
- }
1165
- },
1166
- undo() {
1167
- return undoAll();
1168
- }
1090
+ async function moveGroup({ root, followers, move }) {
1091
+ const journals = [];
1092
+ const run = async (member, rootResult) => {
1093
+ const prepared = await move(member, rootResult);
1094
+ await prepared.journal.run();
1095
+ journals.push(prepared.journal);
1096
+ return prepared.result;
1169
1097
  };
1170
- }
1171
- /** 跑一步反向动作并把它自己的失败收成一条原因(不抛:这些失败由调用方汇总成 `rollbackFailures`)。 */
1172
- async function attempt(label, run) {
1173
1098
  try {
1174
- await run();
1175
- return [];
1099
+ const rootResult = await run(root, void 0);
1100
+ for (const follower of followers) await run(follower, rootResult);
1101
+ return rootResult;
1176
1102
  } catch (error) {
1177
- return [rollbackReason(label, error)];
1103
+ return await rollbackJournals(journals, error);
1178
1104
  }
1179
1105
  }
1180
- /**
1181
- * **组级回滚**:把已经做成的成员按相反顺序退回;某一步退不干净时把失败并进原错误的
1182
- * `details.rollbackFailures`,绝不让原错误独走(设计稿第十七批)。删除与恢复两个按组走的动词
1183
- * 共用这一处——"相反顺序 + 失败合并"不再各写一遍。
1184
- */
1185
- async function rollbackJournals(journals, error) {
1186
- const failures = [];
1187
- for (const journal of [...journals].reverse()) failures.push(...await journal.undo());
1188
- if (failures.length > 0) throw withRollbackFailures(error, failures);
1189
- throw error;
1190
- }
1191
- /**
1192
- * 这一步的失败形状:已经是动词码(`verbError` 的输出)就原样带,否则折成 `sessiondelete/io`。
1193
- * 原文用 `String(error)` 而不是 `.message`——那 22 处既有措辞就是这么拼的(`Error: boom` 也在里面),
1194
- * 换一种拼法会让上屏原文跟着变。
1195
- */
1196
- function journalFailure(step, error) {
1197
- const raw = error?.code;
1198
- if (typeof raw === "string" && raw.startsWith(`sessiondelete/`)) return error;
1199
- return verbError("io", `${step.label}:${String(error)}`, error);
1200
- }
1201
1106
  //#endregion
1202
1107
  //#region src/verbs/delete-session.ts
1203
1108
  /**
@@ -1205,65 +1110,69 @@ function journalFailure(step, error) {
1205
1110
  * 整排藏了 ⇒ 那些目录既看不见也继续占着字节(本机实测 41 条父留了 320 个目录 / 74.6 MB)。
1206
1111
  * 所以这里把父在盘上的孩子一起搬进来,各自成目录、各自一行,清册用 `parentEntryId` 记它是谁的孩子。
1207
1112
  *
1208
- * 三段,顺序承重:
1113
+ * 三段,顺序承重(成员循环与整组回滚的实现住 `verbs/move-group.ts`):
1209
1114
  * ① **先点组、先验活体**(一个字节都不写)。任一成员在跑就整组拒绝——不替用户终止正在跑的活,
1210
1115
  * 也绝不允许"父已经搬进回收站、孩子因为在跑而搬不动"这种半套。
1211
1116
  * ② **逐个搬**:每个成员是一份 `journal`(`verbs/member-journal.ts`),搬不动时 journal 自己把
1212
1117
  * 这个成员已经做成的步骤退回去。
1213
1118
  * ③ **组级回滚**:某一个成员搬不动时,把**已经搬进去的**那些按相反顺序退回原处(清册行跟着摘、
1214
- * 账本跟着挂、可见性跟着还原),失败照抛——不留"半组在回收站、半组在原地"的世界。
1119
+ * 账本跟着挂),失败照抛——不留"半组在回收站、半组在原地"的世界。
1215
1120
  *
1216
- * interface 只有**一个 id**:批量的性质不在这里(同批"已被前一条带走"的判据归 `verbs/batch.ts`)。
1121
+ * interface 只有**一个 id**:批量的性质不在这里(同批已带走集归 `verbs/batch-carried.ts`,
1122
+ * 组语义归 `trash/groups.ts`)。
1123
+ * 结果带回这次真的搬走的整组(`movedSessionIds`)——它是动词交回的内部数据,不再靠回调借用动词的手。
1217
1124
  */
1218
1125
  function deleteSession(deps) {
1126
+ const presence = createMemberPresence(deps.host);
1219
1127
  return async function deleteSession(sessionId) {
1220
- const kids = await deps.host.subagentMembers(sessionId);
1128
+ const read = await deps.host.subagentMembersOf([sessionId]);
1129
+ const kids = read.available ? read.value[sessionId] ?? [] : [];
1221
1130
  const parentProjectDir = await deps.host.projectDirOf(sessionId);
1222
1131
  if (parentProjectDir === void 0) throw verbError("not-found", `宿主的持久化里没有这个会话:${sessionId}`);
1223
- const candidates = [{
1224
- sessionId,
1225
- projectDir: parentProjectDir
1226
- }, ...kids.map((k) => ({
1227
- sessionId: k.sessionId,
1228
- projectDir: k.projectDir
1229
- }))];
1230
- for (const member of candidates) if (await deps.host.isLive(member.sessionId)) throw verbError("live", `会话正在运行:${member.sessionId}`);
1132
+ await assertNoLive(deps.host, [sessionId, ...kids.map((kid) => kid.sessionId)]);
1231
1133
  const parentDir = sessionDirOf(parentProjectDir, sessionId, deps.home);
1232
1134
  if (!await deps.fs.exists(parentDir)) throw verbError("not-found", `会话目录不存在:${parentDir}`);
1233
- const members = [{
1135
+ const parent = {
1234
1136
  sessionId,
1235
1137
  projectDir: parentProjectDir
1236
- }];
1138
+ };
1139
+ const members = [parent];
1237
1140
  for (const kid of kids) if (await deps.fs.exists(sessionDirOf(kid.projectDir, kid.sessionId, deps.home))) members.push({
1238
1141
  sessionId: kid.sessionId,
1239
1142
  projectDir: kid.projectDir
1240
1143
  });
1241
1144
  const deletedAt = deps.now();
1242
- const done = [];
1243
- let parentEntry;
1244
- try {
1245
- for (const member of members) {
1246
- const isParent = member.sessionId === sessionId;
1247
- const move = await memberMove(deps, member, deletedAt, isParent ? void 0 : parentEntry.id);
1248
- if (isParent) parentEntry = move.entry;
1249
- await move.journal.run();
1250
- done.push(move.journal);
1251
- }
1252
- } catch (error) {
1253
- await rollbackJournals(done, error);
1254
- }
1255
- return parentEntry;
1145
+ return {
1146
+ entry: await moveGroup({
1147
+ root: parent,
1148
+ followers: members.slice(1),
1149
+ move: async (member, rootResult) => {
1150
+ const parentEntryId = rootResult?.id;
1151
+ const move = await memberMove(deps, presence, member, deletedAt, parentEntryId);
1152
+ return {
1153
+ journal: move.journal,
1154
+ result: move.entry
1155
+ };
1156
+ }
1157
+ }),
1158
+ movedSessionIds: members.map((member) => member.sessionId)
1159
+ };
1256
1160
  };
1257
1161
  }
1258
1162
  /**
1259
- * 一个成员要做的步骤(顺序承重,照设计稿 §4 那五触点):
1260
- * 读现场(两件纯读)→ 归档 → 摘账本 → 搬目录 → 写清册行。
1163
+ * 一个成员要做的步骤(顺序承重,那四步就列在下面一行):
1164
+ * 读现场(两件纯读)→ 摘账本 → 搬目录 → 写清册行。搬完之后由 `memberGone` 把活体(session + agent)
1165
+ * 真正关掉;**删除不碰归档**——回收站与归档是两套状态,归档只在用户自己归档过时保留。
1166
+ *
1167
+ * **已知窗口**:摘账本之后、`memberGone` 之前的这一拍,活体会短暂无主(客户端可能把它归入「未分组」)。
1168
+ * 这是 ADR-0008 去掉归档后的已知代价;`memberGone` 不能提前到文件/清册写成功之前,否则一次失败回滚
1169
+ * 会留下"文件在、agent 已关"的半个世界。窗口与可选修法登记在 ADR-0008 的「边界」。
1261
1170
  *
1262
1171
  * 每一步的反向动作都是"把这件写退回原样";**清册行是最后一件写**,它失败时 journal 会把目录搬回去、
1263
- * 账本挂回去、可见性还原。清册行的反向动作带一道闸:**回收站目录还没搬回来就不摘那一行**——
1264
- * 留着行至少元数据还在(与旧实现"目录退不回就不摘行"同一条判据)。
1172
+ * 账本挂回去。清册行的反向动作带一道闸:**回收站目录还没搬回来就不摘那一行**——留着行至少元数据还在
1173
+ * (与旧实现"目录退不回就不摘行"同一条判据)。
1265
1174
  */
1266
- async function memberMove(deps, member, deletedAt, parentEntryId) {
1175
+ async function memberMove(deps, presence, member, deletedAt, parentEntryId) {
1267
1176
  const { sessionId, projectDir } = member;
1268
1177
  const workspaceId = await deps.host.workspaceOf(sessionId);
1269
1178
  const sessionDir = sessionDirOf(projectDir, sessionId, deps.home);
@@ -1282,120 +1191,342 @@ async function memberMove(deps, member, deletedAt, parentEntryId) {
1282
1191
  };
1283
1192
  /** 目录真的搬进回收站了吗——决定那一步的反向动作有没有东西可退。 */
1284
1193
  let moved = false;
1285
- /** 回滚的三件写是否都真正退回原样:目录、账本、可见性。`announceRestored` 只在这三件都成立时发。 */
1286
- let movedBack = false;
1287
- let workspaceBack = workspaceId === void 0;
1288
- let visibleBack = wasArchived;
1289
- const journal = createJournal([
1290
- {
1291
- label: "读取会话现场失败",
1292
- run: async () => {
1293
- entry.title = await deps.host.titleOf(sessionId);
1294
- entry.sizeBytes = await deps.fs.sizeOfDir(sessionDir);
1295
- }
1296
- },
1297
- ...wasArchived ? [] : [{
1298
- label: "归档失败",
1299
- run: async () => {
1300
- await deps.host.archive(sessionId, workspaceId);
1301
- },
1302
- undo: async () => {
1303
- return await attempt("可见性还原失败", async () => {
1304
- await deps.host.unarchive(sessionId);
1305
- visibleBack = true;
1306
- });
1307
- }
1308
- }],
1309
- ...workspaceId === void 0 ? [] : [{
1310
- label: "摘账本失败",
1311
- run: async () => {
1312
- await deps.host.detach(sessionId, workspaceId);
1313
- },
1314
- undo: async () => {
1315
- return await attempt("账本挂回失败", async () => {
1316
- await deps.host.attach(sessionId, workspaceId);
1317
- workspaceBack = true;
1318
- });
1319
- }
1320
- }],
1321
- {
1322
- label: "移动会话目录失败",
1323
- undoOnFailure: true,
1324
- run: async () => {
1325
- await deps.fs.ensureDir(`${deps.home}/${TRASH_DIR_NAME}`);
1326
- await deps.fs.rename(sessionDir, trashDir);
1327
- moved = true;
1328
- try {
1329
- await deps.manifest.add(entry);
1330
- } catch (error) {
1331
- throw verbError("io", `登记回收站条目失败:${String(error)}`, error);
1194
+ return {
1195
+ entry,
1196
+ journal: withPresenceTail(presence, createJournal([
1197
+ {
1198
+ key: "read",
1199
+ label: "读取会话现场失败",
1200
+ run: async () => {
1201
+ entry.title = await deps.host.titleOf(sessionId);
1202
+ entry.sizeBytes = await deps.fs.sizeOfDir(sessionDir);
1332
1203
  }
1333
1204
  },
1334
- undo: async () => {
1335
- if (!moved) return [];
1336
- const failures = [...await attempt("目录搬回失败", async () => {
1337
- await deps.fs.rename(trashDir, sessionDir);
1338
- movedBack = true;
1339
- })];
1340
- if (failures.length === 0) failures.push(...await attempt("清册行摘除失败", () => deps.manifest.remove(entry.id)));
1341
- return failures;
1205
+ ...workspaceId === void 0 ? [] : [{
1206
+ key: "workspace",
1207
+ label: "摘账本失败",
1208
+ run: async () => {
1209
+ await deps.host.detach(sessionId, workspaceId);
1210
+ },
1211
+ undo: () => stepUndo("账本挂回失败", () => deps.host.attach(sessionId, workspaceId))
1212
+ }],
1213
+ {
1214
+ key: "dir",
1215
+ label: "移动会话目录失败",
1216
+ undoOnFailure: true,
1217
+ run: async () => {
1218
+ await deps.fs.ensureDir(`${deps.home}/${TRASH_DIR_NAME}`);
1219
+ await deps.fs.rename(sessionDir, trashDir);
1220
+ moved = true;
1221
+ try {
1222
+ await deps.ledger.add(entry);
1223
+ } catch (error) {
1224
+ throw verbError("io", `登记回收站条目失败:${String(error)}`, error);
1225
+ }
1226
+ },
1227
+ undo: async () => {
1228
+ if (!moved) return {
1229
+ restored: true,
1230
+ failures: []
1231
+ };
1232
+ const directory = await stepUndo("目录搬回失败", () => deps.fs.rename(trashDir, sessionDir));
1233
+ const failures = [...directory.failures];
1234
+ if (directory.restored) failures.push(...await attempt("清册行摘除失败", () => deps.ledger.remove(entry.id)));
1235
+ return {
1236
+ restored: directory.restored,
1237
+ failures
1238
+ };
1239
+ }
1342
1240
  }
1241
+ ]), {
1242
+ sessionId,
1243
+ onDone: "leave",
1244
+ /** 组级回滚:先按相反顺序退步骤,再判「目录与账本两件写都回去了吗」。
1245
+ * 这一行此前已经收过 `api-session/removed`;不发「回来了」它就会在客户端消失到重连为止。
1246
+ * 「退没退成」由 journal 的逐步结果交出来,这里只读键,不自己累计布尔。 */
1247
+ onUndone: (undo) => ({
1248
+ kind: "return",
1249
+ diskBack: undo.restored("dir"),
1250
+ workspaceBack: workspaceId === void 0 ? true : undo.restored("workspace")
1251
+ })
1252
+ })
1253
+ };
1254
+ }
1255
+ //#endregion
1256
+ //#region src/verbs/permanent-removal.ts
1257
+ /**
1258
+ * 永久删除的**测量、计划与执行**:一次永久删除(`/list` 的只读读数或 purge / orphan-clean 的删除计划)
1259
+ * 之前要记下的事实(目录、字节、账本归属),以及拿到计划之后按它动盘的那一步。计划函数**只读**——
1260
+ * 写不写由执行器决定,但活体准入在这里就做掉,调用方拿到计划才允许动盘。
1261
+ */
1262
+ /** `exists` 与 `sizeOfDir` 之间目录消失(ENOENT)是唯一一种当"不在"的失败:盘没挂、权限、I/O 错误
1263
+ * 都不许伪装成"目录没了",那会让 `/list` 静默少算释放量、让删除计划少一条该删的目录。 */
1264
+ function isMissing(error) {
1265
+ return error?.code === "ENOENT";
1266
+ }
1267
+ /** `exists → sizeOfDir` 的唯一一条度量口径(成员目录与回收站目录共用):先问在不在,再量字节;
1268
+ * ENOENT 是唯一一种当"不在"的失败,别的错误照旧响亮。别再在调用点各写一遍这段 try/catch。
1269
+ *
1270
+ * **已知粒度**:`sizeOfDir` 递归里撞到的 ENOENT 与"目录本身没了"是同一个码,所以目录里某个文件
1271
+ * 恰好在这一拍消失时,整条目录会被记成「不在」(`/list` 少算这条、删除计划里没有可删的字节,
1272
+ * 而清册行照旧摘——盘上留下目录等下一次 `reconcile` 重建成行,自愈、不丢数据)。复核一次
1273
+ * `exists(dir)` 能把两者分开,却会让「宿主正在写、文件刚被挪走」这类正常竞态把 `/list` 整个打红——
1274
+ * 这里选自愈不选响亮。 */
1275
+ async function probeDir(deps, dir) {
1276
+ if (!await deps.fs.exists(dir)) return {
1277
+ present: false,
1278
+ bytes: 0
1279
+ };
1280
+ try {
1281
+ return {
1282
+ present: true,
1283
+ bytes: await deps.fs.sizeOfDir(dir)
1284
+ };
1285
+ } catch (error) {
1286
+ if (isMissing(error)) return {
1287
+ present: false,
1288
+ bytes: 0
1289
+ };
1290
+ throw error;
1291
+ }
1292
+ }
1293
+ /** 成员的盘上目录与字节(扫描、清单装配与 purge 共用这一条路径/存在性/度量口径;
1294
+ * 目录缺席回 `undefined`,不拿一份"记录还在、盘上已经没了"的旧读数冒充盘上残留)。 */
1295
+ async function measureMemberDir(deps, member) {
1296
+ const dir = sessionDirOf(member.projectDir, member.sessionId, deps.home);
1297
+ const probe = await probeDir(deps, dir);
1298
+ return probe.present ? {
1299
+ dir,
1300
+ bytes: probe.bytes
1301
+ } : void 0;
1302
+ }
1303
+ /** 把一组成员折成"删之前要记下的计划";目录不在的成员跳过(记录与盘不一致是那条成员自己的事)。
1304
+ * `purge` 的盘上后代、`orphan-clean` 的整组与 `/list` 的盘上残留都走这一处,别再各写一遍
1305
+ * measure + workspaceOf。 */
1306
+ async function planMemberRemovals(deps, members) {
1307
+ const plans = [];
1308
+ for (const member of members) {
1309
+ const measured = await measureMemberDir(deps, member);
1310
+ if (measured === void 0) continue;
1311
+ const workspaceId = await deps.host.workspaceOf(member.sessionId);
1312
+ plans.push({
1313
+ sessionId: member.sessionId,
1314
+ ...measured,
1315
+ ...workspaceId === void 0 ? {} : { workspaceId }
1316
+ });
1317
+ }
1318
+ return plans;
1319
+ }
1320
+ /** 量一条回收站行;目录不在回 `present: false` / 0 字节(目录不在不等于清册行不用摘)。 */
1321
+ async function measureTrashRow(deps, entry) {
1322
+ const dir = trashDirOf(entry.id, deps.home);
1323
+ const probe = await probeDir(deps, dir);
1324
+ return {
1325
+ entry,
1326
+ dir,
1327
+ present: probe.present,
1328
+ bytes: probe.bytes
1329
+ };
1330
+ }
1331
+ /**
1332
+ * 盘上后代的选择判据(`/list` 与 purge 的删除计划共用这一份):`subagentMembersOf` 一次问全部父,
1333
+ * 再按 `bareSessionId` **排除全部清册 id**(跟着父进来的孩子不按盘上残留再算一遍),同一个 bare id 只认
1334
+ * **遍历顺序里第一次**出现的那条(同一个会话在两代宿主里的写法不同)。
1335
+ *
1336
+ * 语料看不见(`available: false`)⇒ 每个根都按空成员收:`/list` 与 purge 只许静默降级,
1337
+ * 盘上残留留给 `GET /orphans` 兜底。
1338
+ *
1339
+ * 结果按传入父 id 的原序分组、组内保持宿主交回的顺序:全局去重需要顺序,实测那一步可以并行。
1340
+ *
1341
+ * **单行读数和单行回执可能差一条**:同一个 bare id 若同时挂在两个组根下,全局去重只把它算给遍历
1342
+ * 顺序里第一个根;而对某个根单独 purge 时它仍会算进那一组 ⇒ 那条的 `releaseBytes` 与对它 purge 的
1343
+ * `freedBytes` 在共享后代上可以不等(全部可见行之和与 `/empty` 的回执仍相等)。数据里有环或两代
1344
+ * 写法混杂才会撞上,不为它多开一条口径。
1345
+ */
1346
+ async function selectStrandedMembers(host, manifest, parentIds) {
1347
+ const manifestIds = new Set(manifest.map((row) => bareSessionId(row.sessionId)));
1348
+ const read = await host.subagentMembersOf(parentIds);
1349
+ const seen = /* @__PURE__ */ new Set();
1350
+ return parentIds.map((parentId) => {
1351
+ const wanted = [];
1352
+ if (!read.available) return wanted;
1353
+ for (const member of read.value[parentId] ?? []) {
1354
+ const bare = bareSessionId(member.sessionId);
1355
+ if (manifestIds.has(bare) || seen.has(bare)) continue;
1356
+ seen.add(bare);
1357
+ wanted.push(member);
1358
+ }
1359
+ return wanted;
1360
+ });
1361
+ }
1362
+ /**
1363
+ * `/list` 的只读计划:一行 = 一组。释放量与 purge 用同一条口径——回收站目录与成员目录**现测**,
1364
+ * 不用清册里记的 `sizeBytes`(那是删除那一刻的事实,盘上早就变了)。
1365
+ *
1366
+ * 盘上后代经 `selectStrandedMembers` 选出(排除全部清册 id、bare id 全局只算一次);目录不在的成员
1367
+ * 不进 `members`(与 `planMemberRemovals` 同口径)。
1368
+ */
1369
+ async function planVisibleTrash(deps, rows, groups) {
1370
+ const candidates = await selectStrandedMembers(deps.host, rows, groups.map((group) => group.root.sessionId));
1371
+ return await Promise.all(groups.map(async ({ root, kids }, index) => {
1372
+ const rootRow = await measureTrashRow(deps, root);
1373
+ const kidRows = await Promise.all(kids.map((kid) => measureTrashRow(deps, kid)));
1374
+ const members = await planMemberRemovals(deps, candidates[index] ?? []);
1375
+ const subagentBytes = kidRows.reduce((total, row) => total + row.bytes, 0);
1376
+ const strandedBytes = members.reduce((total, member) => total + member.bytes, 0);
1377
+ return {
1378
+ root,
1379
+ kids,
1380
+ trash: [rootRow, ...kidRows],
1381
+ members,
1382
+ releaseBytes: rootRow.bytes + subagentBytes + strandedBytes,
1383
+ subagentBytes,
1384
+ strandedBytes,
1385
+ strandedCount: members.length
1386
+ };
1387
+ }));
1388
+ }
1389
+ function totalBytes(items) {
1390
+ return items.reduce((total, item) => total + item.bytes, 0);
1391
+ }
1392
+ /**
1393
+ * purge 的删除计划:`targets` 由调用方按组语义(`trash/groups.ts`)选好,这里只量、只筛、只查活体。
1394
+ * **计划函数就是执行前的闸**——调用方拿到计划才允许写,所以准入必须发生在任何写之前。
1395
+ *
1396
+ * - `trash` 按 targets 原序逐个实测(目录缺席的行 `present:false` / 0 字节,但清册行仍要摘)。
1397
+ * - 盘上后代经 `selectStrandedMembers` 选出(一次问全部 targets、排除全部清册 id、bare id 全局只计划一次)。
1398
+ * - `members` 只收盘上还有目录的成员(目录缺席没有可删的字节);`unarchive` = targets + 这些成员:
1399
+ * 只摘这一次真要碰的 id。
1400
+ * - 活体准入在量目录与任何写之前逐个查 targets 与**全部枚举到的**盘上后代;任一在跑就整条拒绝、
1401
+ * 零副作用。没有跳过它的开关——计划就是执行前的闸(ADR-0006)。
1402
+ * - `sessionQuery` 缺席时端口回 `{ available: false }` ⇒ members 为空、不抛(盘上残留留给 `GET /orphans` 兜底)。
1403
+ */
1404
+ async function planTrashRemoval(deps, rows, targets) {
1405
+ const candidates = (await selectStrandedMembers(deps.host, rows, targets.map((entry) => entry.sessionId))).flat();
1406
+ await assertNoLive(deps.host, [...targets.map((entry) => entry.sessionId), ...candidates.map((member) => member.sessionId)]);
1407
+ const trash = await Promise.all(targets.map((entry) => measureTrashRow(deps, entry)));
1408
+ const members = await planMemberRemovals(deps, candidates);
1409
+ return {
1410
+ kind: "trash",
1411
+ trash,
1412
+ members,
1413
+ unarchive: [...targets.map((entry) => entry.sessionId), ...members.map((member) => member.sessionId)],
1414
+ bytes: totalBytes(trash) + totalBytes(members)
1415
+ };
1416
+ }
1417
+ /**
1418
+ * orphan-clean 的删除计划:成员是调用方扫出来的传递闭包(`orphanGroupsOf`)。
1419
+ *
1420
+ * - `members` 只收盘上还有目录的成员(目录缺席没有可删的字节),`bytes` 只算这些实测值。
1421
+ * - `unarchive` 覆盖**全部传入成员**(含目录缺席):orphan-clean 在删目录之前整批摘归档集,
1422
+ * 目录缺席的 id 也可能还挂在归档集里;重试时不会再长出一条幽灵 id。
1423
+ * - live 准入覆盖**全部传入成员**(含目录缺席):它们都在这一组的可见面里,任一在跑就整组拒绝。
1424
+ * - 回执由 `kind:'orphan'` 派生:删目录阶段失败时执行器要交回 `{removed, freedBytes, failedSessionId}`。
1425
+ */
1426
+ async function planOrphanRemoval(deps, members) {
1427
+ await assertNoLive(deps.host, members.map((member) => member.sessionId));
1428
+ const present = await planMemberRemovals(deps, members);
1429
+ return {
1430
+ kind: "orphan",
1431
+ trash: [],
1432
+ members: present,
1433
+ unarchive: members.map((member) => member.sessionId),
1434
+ bytes: totalBytes(present)
1435
+ };
1436
+ }
1437
+ /**
1438
+ * 按计划执行一次永久删除:只写、不再量、不再判活体(计划就是执行前的闸)。回执的字节念计划
1439
+ * (`plan.bytes`),执行期不再累加一遍。
1440
+ *
1441
+ * 顺序(与迁移前的 purge / orphan-clean 一致):整批摘归档集 → 摘有账本的成员的账本 → 逐条回收站行
1442
+ * (在才删目录 → 摘清册行 → 通告成员离场)→ 逐条盘上成员(删目录 → 通告成员离场)。目录删掉就没有
1443
+ * "退回去"这回事,中途失败**不回滚**;已经删掉的由下面的计数器如实交回。
1444
+ *
1445
+ * 失败形状沿用调用方原来的措辞与结构:
1446
+ * - `kind:'orphan'`(orphan-clean)⇒ `io` + `{removed, freedBytes, failedSessionId}`,不伪装成整组没动;
1447
+ * - `kind:'trash'`(purge)⇒ 同一个标签、不带 details。
1448
+ * - orphan-clean 的 unarchive / detach 在旧实现里不包装,这里原样抛出(不改变现有错误形状)。
1449
+ *
1450
+ * 通告只经 `member-presence`(摘活体三态 + `api-session/removed`),不在这里另写一份。
1451
+ */
1452
+ async function executeRemoval(deps, plan) {
1453
+ const presence = createMemberPresence(deps.host);
1454
+ let removed = 0;
1455
+ /** 删到这一刻已经腾出的**进度**:只喂 `orphan` 的部分回执;成功回执念计划的合计(`plan.bytes`)。 */
1456
+ let freedSoFar = 0;
1457
+ /** 删除途中失败的唯一出口:标签沿用旧措辞;部分回执只属于 `orphan`(由计划的 `kind` 派生)。
1458
+ * 已经是本仓动词码的错误原样抛(与 `member-journal.ts` 的 `journalFailure` 同一条判据)——
1459
+ * `not-found` / `live` 之类不许在这里被折成 `io`。 */
1460
+ const fail = (label, error, failedSessionId) => {
1461
+ if (isSessionDeleteCodeError(error)) throw error;
1462
+ const details = plan.kind === "orphan" ? {
1463
+ removed,
1464
+ freedBytes: freedSoFar,
1465
+ failedSessionId
1466
+ } : void 0;
1467
+ throw verbError("io", `${label}:${String(error)}`, error, details);
1468
+ };
1469
+ /** 退归档集 / 摘账本这一步的失败形状:purge 折成带标签的 `io`;orphan-clean 原样抛。 */
1470
+ const step = async (label, sessionId, run) => {
1471
+ if (plan.kind === "orphan") {
1472
+ await run();
1473
+ return;
1474
+ }
1475
+ try {
1476
+ await run();
1477
+ } catch (error) {
1478
+ fail(label, error, sessionId);
1343
1479
  }
1344
- ]);
1345
- return {
1346
- entry,
1347
- journal: {
1348
- async run() {
1349
- await journal.run();
1350
- if (await deps.host.evictLive(sessionId) !== "unknown") await deps.host.announceRemoved(sessionId);
1351
- },
1352
- /** 组级回滚:先把成员自己的步骤按相反顺序退回,再在**三件写都真正回去**时补一条"已恢复"通告。
1353
- * 这一行此前已经收过 `api-session/removed`;不发"回来了"它就会在客户端消失到重连为止。
1354
- * 目录没退回、账本没挂回、或可见性没还原 ⇒ 不发——那会是假话(同 restore 侧 `movedBack` 的判据)。 */
1355
- async undo() {
1356
- const failures = await journal.undo();
1357
- if (movedBack && workspaceBack && visibleBack) await deps.host.announceRestored(sessionId).catch(() => {});
1358
- return failures;
1480
+ };
1481
+ for (const sessionId of plan.unarchive) await step("取消归档失败", sessionId, () => deps.host.unarchive(sessionId));
1482
+ for (const member of plan.members) {
1483
+ const workspaceId = member.workspaceId;
1484
+ if (workspaceId === void 0) continue;
1485
+ await step("摘账本失败", member.sessionId, () => deps.host.detach(member.sessionId, workspaceId));
1486
+ }
1487
+ for (const row of plan.trash) {
1488
+ if (row.present) {
1489
+ try {
1490
+ await deps.fs.removeDir(row.dir);
1491
+ } catch (error) {
1492
+ fail("删除回收站目录失败", error, row.entry.sessionId);
1359
1493
  }
1494
+ freedSoFar += row.bytes;
1495
+ }
1496
+ try {
1497
+ await deps.ledger.remove(row.entry.id);
1498
+ } catch (error) {
1499
+ fail("删除回收站目录失败", error, row.entry.sessionId);
1500
+ }
1501
+ removed += 1;
1502
+ await presence.memberGone(row.entry.sessionId);
1503
+ }
1504
+ const memberLabel = plan.kind === "trash" ? "删除盘上后代目录失败" : "删除会话目录失败";
1505
+ for (const member of plan.members) {
1506
+ try {
1507
+ await deps.fs.removeDir(member.dir);
1508
+ } catch (error) {
1509
+ fail(memberLabel, error, member.sessionId);
1360
1510
  }
1511
+ removed += 1;
1512
+ freedSoFar += member.bytes;
1513
+ await presence.memberGone(member.sessionId);
1514
+ }
1515
+ return {
1516
+ removed,
1517
+ freedBytes: plan.bytes
1361
1518
  };
1362
1519
  }
1363
1520
  //#endregion
1364
1521
  //#region src/verbs/orphan-subagents.ts
1365
- /** 成员的盘上目录与字节(扫描、清单装配与 purge 共用这一条路径/存在性/度量口径;
1366
- * 目录缺席回 `undefined`,不拿一份"记录还在、盘上已经没了"的旧读数冒充盘上残留)。 */
1367
- async function measureMemberDir(deps, member) {
1368
- const dir = sessionDirOf(member.projectDir, member.sessionId, deps.home);
1369
- if (!await deps.fs.exists(dir)) return void 0;
1370
- return {
1371
- dir,
1372
- bytes: await deps.fs.sizeOfDir(dir)
1373
- };
1374
- }
1375
- /** 把一组成员折成"删之前要记下的计划";目录不在的成员跳过(记录与盘不一致是那条成员自己的事)。
1376
- * `purge` 的盘上后代与 `orphan-clean` 的整组都走这一处,别再各写一遍 measure + workspaceOf。 */
1377
- async function planMemberRemovals(deps, members) {
1378
- const plans = [];
1379
- for (const member of members) {
1380
- const measured = await measureMemberDir(deps, member);
1381
- if (measured === void 0) continue;
1382
- const workspaceId = await deps.host.workspaceOf(member.sessionId);
1383
- plans.push({
1384
- sessionId: member.sessionId,
1385
- ...measured,
1386
- ...workspaceId === void 0 ? {} : { workspaceId }
1387
- });
1388
- }
1389
- return plans;
1390
- }
1391
1522
  /**
1392
1523
  * 无主子会话 = `origin:"subagent"`、父**既不在当前语料(live ∪ persisted)也不在回收站清册里**的会话。
1393
1524
  *
1394
1525
  * 为什么清册里的父不算孤儿:那种是 **stranded**——父还在回收站里等着,孩子本该跟着父一起恢复或一起
1395
- * 彻底删除(10-15 稿 §11 的组语义),把它的根当孤儿删掉就是**替用户做了一个没人点过的决定**。孤儿只
1526
+ * 彻底删除(组语义见 `src/trash/groups.ts`),把它的根当孤儿删掉就是**替用户做了一个没人点过的决定**。孤儿只
1396
1527
  * 有一种来路:父被彻底删掉了(或被手工从盘上清掉了),清册里再也没有这一行。
1397
1528
  *
1398
- * 根按**传递闭包**收全部后代(与 `HostPort.subagentCounts` 同一口径:孩子与孙代都算,深度不设上限),
1529
+ * 根按**传递闭包**收全部后代(与 `HostCorpusPort.subagentMembersOf` 同一口径:孩子与孙代都算,深度不设上限),
1399
1530
  * 同一条记录只归一组(`claimed`)——数据里真出现环时结果是"少数一条",不是把一个会话删两遍。
1400
1531
  * 归堆与去重**两边都去 `session-` 前缀**:同一个父在两代宿主里写法不同(见 `bareSessionId` 的来历)。
1401
1532
  */
@@ -1437,16 +1568,19 @@ function orphanGroupsOf(corpus, trashSessionIds) {
1437
1568
  }
1438
1569
  return groups;
1439
1570
  }
1440
- /** 纯读:调用方应先走一次 `/list` 对账(面板的 refresh 就是这样);本函数不写清单。
1441
- * 写动作 `orphan-clean` 会自己先 reconcile,避免拿一份过期清册判"父没了"。
1571
+ /** **本函数是纯扫描,不对账、不写清册行**(`/orphans` 声明为 pure,一次扫描不能顺手写行)。清册默认读
1572
+ * `ledger.raw()`;写路径(`orphan-clean`)可以在调它之前先 `ledger.list()` 对账,并把交回的行递进来,
1573
+ * 免得同一份索引再读一遍——否则刚被人手工搬进 `session-trash/` 的父目录还没有行,会被当成"父没了"
1574
+ * 直接删盘。
1442
1575
  *
1443
- * `listSessions()` 回 `undefined` 时**必须抛**:那是宿主的会话语料服务没挂,不是"这个家没有孤儿"。
1444
- * 拿 undefined 当空语料 ⇒ 报告说"零条孤儿"、界面上一句"没有要清理的",而盘上真有几百 MB——那是假话,
1445
- * 所以这里按 `io` 响亮失败(AGENTS.md:`undefined` 意味着服务缺席,扫描方不许冒充读数)。 */
1446
- async function loadOrphanGroups(deps) {
1447
- const corpus = await deps.host.listSessions();
1448
- if (corpus === void 0) throw verbError("io", "无主子会话扫描需要宿主的 sessionQuery.listSessions");
1449
- const groups = orphanGroupsOf(corpus, (await deps.manifest.list()).map((entry) => entry.sessionId));
1576
+ * `listSessions()` 回 `{ available: false }` 时**必须抛**:那是宿主的会话语料服务没挂,不是"这个家没有孤儿"。
1577
+ * 拿缺席当空语料 ⇒ 报告说"零条孤儿"、界面上一句"没有要清理的",而盘上真有几百 MB——那是假话,
1578
+ * 所以这里按 `io` 响亮失败(AGENTS.md:`available: false` 意味着服务缺席,扫描方不许冒充读数)。 */
1579
+ async function loadOrphanGroups(deps, trash) {
1580
+ const read = await deps.host.listSessions();
1581
+ if (!read.available) throw verbError("io", "无主子会话扫描需要宿主的 sessionQuery.listSessions");
1582
+ const corpus = read.value;
1583
+ const groups = orphanGroupsOf(corpus, (trash ?? await deps.ledger.raw()).map((entry) => entry.sessionId));
1450
1584
  const out = [];
1451
1585
  for (const group of groups) {
1452
1586
  const members = [];
@@ -1495,124 +1629,44 @@ function reportOrphans(groups) {
1495
1629
  };
1496
1630
  }
1497
1631
  /**
1498
- * 清一组:先**整组 live 准入(零写)**,再收集目录/字节/账本归属,再 unarchive → detach → 删目录。
1499
- * 任何 preflight 失败都在第一件写之前抛出;删目录到一半失败时**已删目录不回滚**,失败错误的
1500
- * `details` 带 `{ removed, freedBytes, failedSessionId }` 作为部分回执(不伪装成整组没动)。
1501
- * 删目录之后照 purge 的规矩摘活体并如实通告。不碰投影缓存与附件。
1632
+ * 清一组:`planOrphanRemoval`(只读计划,整组 live 准入在一切写之前)→ `executeRemoval`(只写)。
1633
+ * 量目录、摘归档集与账本、删目录与成员通告的顺序和失败形状都住 `verbs/permanent-removal.ts`;
1634
+ * 这里只把这一组接上去,别在这再长出一段手写步骤(purge 走的是同一条路)。
1502
1635
  *
1503
1636
  * 顺序与删除那一半是**对偶**的,但不是照抄:
1504
- * - 删除要先把会话藏起来(归档)再摘账本,免得中间那一拍它掉进「未分组」;这里目录是**删掉**不是搬走,
1505
- * 藏不藏都不影响可见性 ⇒ 归档集反而是**在写之前整批摘掉**的:失败时目录还在,下一次还能重试,
1637
+ * - 删除那一半不再用归档藏会话(ADR-0008):它摘账本、搬目录,最后才摘活体。这里目录是**删掉**
1638
+ * 不是搬走,藏不藏都不影响可见性 ⇒ 归档集反而**在写之前整批摘掉**:失败时目录还在,下一次还能重试,
1506
1639
  * 不会留下"会话没了但还在归档集里挂着"的幽灵 id。
1507
- * - `unarchive` 对未归档的会话是官方幂等 no-op(`registry.unarchiveSession`),所以不需要删除侧那次
1640
+ * - `unarchive` 对未归档的会话是官方幂等 no-op(`registry.unarchiveSession`),所以这里不需要那次
1508
1641
  * `wasArchived` 读——这里没有"还原到删除前"的义务。
1509
1642
  * - 摘账本只在账本真记着它时做(`workspaceOf` 未命中 = 本来就是「未分组」,不是故障)。
1510
- * - `evictLive` 的三态照 `HostPort.evictLive` 的契约:`unknown` 时**不通告**(发了就是假话),
1511
- * `evicted` / `not-live` 才发 `api-session/removed`。冷会话没有活体可摘,通告照样必须发。
1512
1643
  */
1513
1644
  async function cleanOrphanGroup(deps, group) {
1514
- for (const member of group.members) if (await deps.host.isLive(member.sessionId)) throw verbError("live", `会话正在运行:${member.sessionId}`);
1515
- const plans = await planMemberRemovals(deps, group.members);
1516
- for (const member of group.members) await deps.host.unarchive(member.sessionId);
1517
- for (const plan of plans) if (plan.workspaceId !== void 0) await deps.host.detach(plan.sessionId, plan.workspaceId);
1518
- let removed = 0;
1519
- let freedBytes = 0;
1520
- for (const plan of plans) {
1521
- try {
1522
- await deps.fs.removeDir(plan.dir);
1523
- } catch (error) {
1524
- throw verbError("io", `删除会话目录失败:${String(error)}`, error, {
1525
- removed,
1526
- freedBytes,
1527
- failedSessionId: plan.sessionId
1528
- });
1529
- }
1530
- removed += 1;
1531
- freedBytes += plan.bytes;
1532
- if (await deps.host.evictLive(plan.sessionId) !== "unknown") await deps.host.announceRemoved(plan.sessionId);
1533
- }
1534
- return {
1535
- removed,
1536
- freedBytes
1537
- };
1645
+ return await executeRemoval(deps, await planOrphanRemoval(deps, group.members));
1538
1646
  }
1539
1647
  //#endregion
1540
1648
  //#region src/verbs/purge-trash.ts
1541
1649
  /**
1542
1650
  * 彻底删除:从回收站里**真的**抹掉一条(或整组、或整站),并连带那些"还留在盘上、没被带走"的后代。
1543
1651
  *
1544
- * 与另外两个动词的关系(ADR-0002):它的 interface 只有"一个条目 id(或缺席 = 清空)",
1545
- * 批量的快照与"已被同批前一条带走"的判据不在这里(归 `verbs/batch.ts`)。
1652
+ * 与另外两个动词的关系(ADR-0002):它的 interface 是"这一组目标 + 一份只读清册快照
1653
+ *(targets 缺席 = 清空)";目标由 `verbs/batch-carried.ts` 按组语义算好,批量的"已被同批前一条
1654
+ * 带走"判据不在这里。`requestedId` 是唯一的例外参数,但它**不参与任何判据**:只是让
1655
+ * `trash-empty` 的原文点得出用户点的是哪一条(原文要上屏,点名承重),批量语义仍在批量层。
1656
+ *
1657
+ * **这条动词只做两件事:拿目标、把计划交给执行器**。量目录、筛盘上后代、活体准入与"先退归档集、
1658
+ * 再摘账本、最后删目录"的顺序都住 `verbs/permanent-removal.ts`——计划只读,执行器只写,计划就是
1659
+ * 执行前的闸(别在这里再长出一段手写步骤)。
1546
1660
  *
1547
- * **这条动词不做回滚**:目录删掉就没有"退回去"这回事(设计稿第十七批:删到一半失败时已删目录不回滚;
1661
+ * **这条动词不做回滚**:目录删掉就没有"退回去"这回事(2026-10-19 `f2a7757`:删到一半失败时已删目录不回滚;
1548
1662
  * 带部分计数的 `details` 回执只属于 `orphan-clean`,purge 的 `removed` / `freedBytes` 仍只在成功回执里给)。
1549
- * 所以它的 journal 只有正向步骤、没有 `undo`——顺序仍由 journal 统一表达,不再是手写的四段 `try/catch`。
1663
+ * 对应地,`kind:'trash'` 的计划失败时执行器只抛那一步的 `io` 与原文、不带 details。
1550
1664
  */
1551
1665
  function purgeTrash(deps) {
1552
- return async function purgeTrash(entryId, snapshot) {
1553
- const all = snapshot ?? await deps.manifest.list();
1554
- const targets = entryId === void 0 ? all : groupOf(all, entryId);
1555
- if (entryId !== void 0 && targets.length === 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
1556
- const manifestIds = new Set(targets.map((entry) => bareSessionId(entry.sessionId)));
1557
- const membersByParent = await deps.host.subagentMembersBatch(targets.map((entry) => entry.sessionId));
1558
- const seenDisk = /* @__PURE__ */ new Set();
1559
- /** 先只做准入与去重(全部是读),整组通过后再量目录/账本——任一成员在跑时一件计划都还没做。 */
1560
- const candidates = [];
1561
- for (const entry of targets) for (const member of membersByParent[entry.sessionId] ?? []) {
1562
- const bare = bareSessionId(member.sessionId);
1563
- if (manifestIds.has(bare) || seenDisk.has(bare)) continue;
1564
- seenDisk.add(bare);
1565
- if (await deps.host.isLive(member.sessionId)) throw verbError("live", `会话正在运行:${member.sessionId}`);
1566
- candidates.push(member);
1567
- }
1568
- const diskPlans = await planMemberRemovals(deps, candidates);
1569
- let freedBytes = 0;
1570
- let removed = 0;
1571
- await createJournal([
1572
- {
1573
- label: "取消归档失败",
1574
- run: async () => {
1575
- for (const entry of targets) await deps.host.unarchive(entry.sessionId);
1576
- for (const plan of diskPlans) await deps.host.unarchive(plan.sessionId);
1577
- }
1578
- },
1579
- {
1580
- label: "摘账本失败",
1581
- run: async () => {
1582
- for (const plan of diskPlans) if (plan.workspaceId !== void 0) await deps.host.detach(plan.sessionId, plan.workspaceId);
1583
- }
1584
- },
1585
- {
1586
- label: "删除回收站目录失败",
1587
- run: async () => {
1588
- for (const entry of targets) {
1589
- const dir = trashDirOf(entry.id, deps.home);
1590
- if (await deps.fs.exists(dir)) {
1591
- freedBytes += await deps.fs.sizeOfDir(dir);
1592
- await deps.fs.removeDir(dir);
1593
- }
1594
- await deps.manifest.remove(entry.id);
1595
- removed += 1;
1596
- if (await deps.host.evictLive(entry.sessionId) !== "unknown") await deps.host.announceRemoved(entry.sessionId);
1597
- }
1598
- }
1599
- },
1600
- {
1601
- label: "删除盘上后代目录失败",
1602
- run: async () => {
1603
- for (const plan of diskPlans) {
1604
- await deps.fs.removeDir(plan.dir);
1605
- removed += 1;
1606
- freedBytes += plan.bytes;
1607
- if (await deps.host.evictLive(plan.sessionId) !== "unknown") await deps.host.announceRemoved(plan.sessionId);
1608
- }
1609
- }
1610
- }
1611
- ]).run();
1612
- return {
1613
- removed,
1614
- freedBytes
1615
- };
1666
+ return async function purgeTrash(targets, rows, requestedId) {
1667
+ const selected = targets ?? rows;
1668
+ if (targets !== void 0 && selected.length === 0) throw verbError("trash-empty", requestedId === void 0 ? "回收站里没有这一条" : `回收站里没有这一条:${requestedId}`);
1669
+ return await executeRemoval(deps, await planTrashRemoval(deps, rows, selected));
1616
1670
  };
1617
1671
  }
1618
1672
  //#endregion
@@ -1621,45 +1675,45 @@ function purgeTrash(deps) {
1621
1675
  * 恢复 = 把删除那次**按原路退回**,而且按组走:当年是按组一起搬进来的(父 + 它在盘上的孩子),
1622
1676
  * 只回父就会在回收站里留下一堆点不到的孩子行——清单只列父行,那些行既看不见也清不掉。
1623
1677
  *
1624
- * 三段与删除对称:
1678
+ * 三段与删除对称(成员循环与整组回滚的实现住 `verbs/move-group.ts`):
1625
1679
  * ① **预检整组**(一个字节都不写):每个成员的回收站目录与项目目录都得在。缺席的判据与原来一致
1626
1680
  * (回收站目录不在 = `trash-empty`;原项目目录不在 = `project-missing`,且绝不静默建目录)。
1627
1681
  * 预检在写之前,是为了不让"父已经回到 `sessions/`、孩子因为项目目录没了而回不去"这种半组留在外面。
1628
1682
  * **工作区账本不在这条硬前提里**:宿主的「删除工作区」只删注册、保留日志,记下的工作区没了就按
1629
1683
  * 「未分组」收(判据在 {@link attachableWorkspaceId}),照旧回盘——恢复是加法,不该因为一个可选的账目而失败。
1630
- * ② **逐个恢复**,顺序与删除相反:文件先回来 → 挂账本 → 还原可见性 → 摘清册行。
1684
+ * ② **逐个恢复**,顺序与删除相反:文件先回来 → 挂账本 →(旧清单迁移)取消归档 → 摘清册行。
1631
1685
  * ③ 中途失败时 journal 把这个成员自己退回回收站;**已经恢复完的**兄弟由整组 catch 按相反顺序一起退回。
1632
1686
  *
1633
1687
  * **通告不在这里**:一次批量只该拉一遍宿主会话表、只发一次 `api-session/added`(2026-10-17 那次
1634
- * "恢复很慢"就是逐条发)。通告归 `verbs/batch.ts`(ADR-0002),所以这个动词的 interface 只有
1635
- * "一个条目 id 进、回收站那一行出",没有批量专用的开关。
1688
+ * "恢复很慢"就是逐条发)。通告归 `verbs/batch.ts`(ADR-0002),所以这个动词的 interface 是
1689
+ * "一个条目 id + 这一组进、回收站那一行出",没有批量专用的开关。
1636
1690
  */
1637
1691
  function restoreSession(deps) {
1638
- return async function restoreSession(entryId, snapshot) {
1639
- const all = snapshot ?? await deps.manifest.list();
1640
- const target = all.find((e) => e.id === entryId);
1641
- if (!target) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
1642
- const group = groupOf(all, entryId);
1692
+ const presence = createMemberPresence(deps.host);
1693
+ return async function restoreSession(entryId, group) {
1694
+ const [root, ...followers] = group;
1695
+ const target = group.find((e) => e.id === entryId);
1696
+ if (root === void 0 || target === void 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
1643
1697
  for (const entry of group) {
1644
1698
  const trashDir = trashDirOf(entry.id, deps.home);
1645
1699
  if (!await deps.fs.exists(trashDir)) throw verbError("trash-empty", `回收站条目已不在磁盘上:${trashDir}`);
1646
1700
  const projectRoot = sessionDirOf(entry.projectDir, "", deps.home).replace(/\/$/, "");
1647
1701
  if (!await deps.fs.exists(`${deps.home}/sessions`) || !await deps.fs.exists(projectRoot)) throw verbError("project-missing", `原项目目录已不存在:${projectRoot}`);
1648
1702
  }
1649
- const done = [];
1650
- try {
1651
- for (const entry of group) {
1652
- const move = await memberRestore(deps, entry);
1653
- await move.journal.run();
1654
- done.push(move.journal);
1703
+ await moveGroup({
1704
+ root,
1705
+ followers,
1706
+ move: async (entry) => {
1707
+ return {
1708
+ journal: (await memberRestore(deps, presence, entry)).journal,
1709
+ result: void 0
1710
+ };
1655
1711
  }
1656
- } catch (error) {
1657
- await rollbackJournals(done, error);
1658
- }
1712
+ });
1659
1713
  return target;
1660
1714
  };
1661
1715
  }
1662
- /** 放回哪儿:用**删除时记进 entryId 的那段原目录名**,不从 `sessionId` 现推(设计稿 §3)——
1716
+ /** 放回哪儿:用**删除时记进 entryId 的那段原目录名**,不从 `sessionId` 现推(CONTEXT.md 的「条目」条)——
1663
1717
  * 因此不依赖宿主当时的目录命名约定(v3 无前缀 / v4 带 `session-` 前缀)。 */
1664
1718
  function restoreTargetDir(deps, entry) {
1665
1719
  return sessionDirOf(entry.projectDir, parseEntryId(entry.id).sessionDirName, deps.home);
@@ -1685,32 +1739,34 @@ async function isWorkspaceMissing(host, entry) {
1685
1739
  return entry.workspaceId !== "" && await attachableWorkspaceId(host, entry) === void 0;
1686
1740
  }
1687
1741
  /**
1688
- * 一个成员的恢复步骤(顺序与删除相反):放回目录 → 挂账本 → 还原可见性 → 摘清册行。
1742
+ * 一个成员的恢复步骤(顺序与删除相反):放回目录 → 挂账本 →(旧清单迁移)取消归档 → 摘清册行。
1689
1743
  *
1690
1744
  * 账本那一步有个**只认端口那句**的兜底:查过之后、挂之前账本被删掉(宿主的「删除工作区」与这次恢复
1691
1745
  * 撞上)⇒ 按未分组收、不回滚;**别的**失败是真故障,照旧抛(吞掉它就等于把一次存储故障伪装成"未分组")。
1692
1746
  */
1693
- async function memberRestore(deps, entry) {
1747
+ async function memberRestore(deps, presence, entry) {
1694
1748
  const trashDir = trashDirOf(entry.id, deps.home);
1695
1749
  const sessionDir = restoreTargetDir(deps, entry);
1696
1750
  const workspaceId = await attachableWorkspaceId(deps.host, entry);
1751
+ /**
1752
+ * **旧清单迁移**:2026-10-21 之前的删除用归档来藏会话,所以那些条目在盘上仍是归档态。
1753
+ * 新删除不再碰归档(回收站与归档解耦)⇒ `wasArchived:false` 且当前**仍**归档的,只可能是旧条目;
1754
+ * 恢复时取消归档。`wasArchived:true` 是用户删除前自己归档的,保持归档态不动。
1755
+ */
1756
+ const legacyArchived = !entry.wasArchived && await deps.host.isArchived(entry.sessionId);
1697
1757
  /** 真挂上了账本才记下那个 id——回滚里"摘账本"那一步只对挂过的成员存在。 */
1698
1758
  let attached = false;
1699
- /** 组级回滚时,目录真的退回回收站了才通告「已移除」(否则那条通告会骗客户端删掉一行还在盘上的会话)。 */
1700
- let movedBack = false;
1701
- const journal = createJournal([
1759
+ return { journal: withPresenceTail(presence, createJournal([
1702
1760
  {
1761
+ key: "dir",
1703
1762
  label: "放回会话目录失败",
1704
1763
  run: async () => {
1705
1764
  await deps.fs.rename(trashDir, sessionDir);
1706
1765
  },
1707
- undo: async () => {
1708
- const failures = await attempt("目录退回回收站失败", () => deps.fs.rename(sessionDir, trashDir));
1709
- if (failures.length === 0) movedBack = true;
1710
- return failures;
1711
- }
1766
+ undo: () => stepUndo("目录退回回收站失败", () => deps.fs.rename(sessionDir, trashDir))
1712
1767
  },
1713
1768
  ...workspaceId === void 0 ? [] : [{
1769
+ key: "workspace",
1714
1770
  label: "挂回账本失败",
1715
1771
  run: async () => {
1716
1772
  try {
@@ -1720,34 +1776,38 @@ async function memberRestore(deps, entry) {
1720
1776
  if (!isWorkspaceMissingError(error) || await deps.host.workspaceExists(workspaceId)) throw error;
1721
1777
  }
1722
1778
  },
1723
- undo: async () => attached ? attempt("摘账本失败", () => deps.host.detach(entry.sessionId, workspaceId)) : []
1779
+ undo: async () => {
1780
+ if (!attached) return {
1781
+ restored: true,
1782
+ failures: []
1783
+ };
1784
+ return stepUndo("摘账本失败", () => deps.host.detach(entry.sessionId, workspaceId));
1785
+ }
1724
1786
  }],
1725
- ...entry.wasArchived ? [] : [{
1787
+ ...!legacyArchived ? [] : [{
1788
+ key: "archive",
1726
1789
  label: "取消归档失败",
1727
1790
  undoOnFailure: true,
1728
1791
  run: async () => {
1729
1792
  await deps.host.unarchive(entry.sessionId);
1730
1793
  },
1731
- undo: () => attempt("重新归档失败", () => deps.host.archive(entry.sessionId, workspaceId))
1794
+ undo: () => stepUndo("重新归档失败", () => deps.host.archive(entry.sessionId, workspaceId))
1732
1795
  }],
1733
1796
  {
1797
+ key: "manifest",
1734
1798
  label: "摘回收站条目失败",
1735
1799
  run: async () => {
1736
- await deps.manifest.remove(entry.id);
1800
+ await deps.ledger.remove(entry.id);
1737
1801
  },
1738
- undo: () => attempt("清册行写回失败", () => deps.manifest.add(entry))
1739
- }
1740
- ]);
1741
- return { journal: {
1742
- run: () => journal.run(),
1743
- async undo() {
1744
- const failures = await journal.undo();
1745
- if (movedBack) {
1746
- if (await deps.host.evictLive(entry.sessionId).catch(() => "unknown") !== "unknown") await deps.host.announceRemoved(entry.sessionId).catch(() => {});
1747
- }
1748
- return failures;
1802
+ undo: () => stepUndo("清册行写回失败", () => deps.ledger.add(entry))
1749
1803
  }
1750
- } };
1804
+ ]), {
1805
+ sessionId: entry.sessionId,
1806
+ onUndone: (undo) => ({
1807
+ kind: "leave",
1808
+ backInTrash: undo.restored("dir")
1809
+ })
1810
+ }) };
1751
1811
  }
1752
1812
  //#endregion
1753
1813
  //#region src/verbs/batch.ts
@@ -1760,16 +1820,18 @@ async function memberRestore(deps, entry) {
1760
1820
  * interface 上(`onMembers` / `snapshot` / `suppressAnnounce`),于是每个动词都得知道"我是第几个、
1761
1821
  * 前面的兄弟带走了谁",同一个事实还有三种机制(回调、调用方 `taken` 集合、动词就地收缩数组)。
1762
1822
  *
1763
- * 这一层独占四件(每条批量语义都在这里):
1823
+ * 这一层拥有三件(每条批量语义都在这里):
1764
1824
  * ① **去重保序**:输入顺序就是界面"清单从上往下"的读感;
1765
- * ② **清册快照**:整批只读一次"最初的样子",用来判断"已被同批前一条带走";
1766
- * ③ **逐条信封**:一条失败不传染下一条,失败按 `map-error` 那张表折成 `{code,message,details?}`;
1767
- * ④ **一批一次通告**:恢复的 `api-session/added` 攒到批尾、配一次预取的摘要表发(逐条发 = 每条一次
1768
- * 宿主全表扫,那正是 2026-10-17 "恢复很慢"的头号来源)。
1825
+ * ② **逐条信封**:一条失败不传染下一条,失败按 `map-error` 那张表折成 `{code,message,details?}`;
1826
+ * ③ **一批一次通告**:恢复的 `api-session/added` 攒到批尾、调一次 `announceRestored(ids)`(预取与逐条
1827
+ * 发送都在 adapter 里;逐条调 = 每条一次宿主全表扫,那正是 2026-10-17 "恢复很慢"的头号来源)。
1828
+ *
1829
+ * **同批已带走集**(整批只读一次"最初的样子")住 `src/verbs/batch-carried.ts`;这里消费它的
1830
+ * `claim` / `take`,purge 另取 entry 域的 `rows()` 作为当前只读清册。
1769
1831
  *
1770
1832
  * 「已被同批前一条带走」是**幂等成功**,不是失败:用户要的结果本来就已经达成了(`delete`/`restore`
1771
- * 回 `null`;`purge` 回 `{removed:0,freedBytes:0}`)。判据读 `trash/groups.ts`(ADR-0001),
1772
- * 与 `/list` 的装配、与三个动词同一条。
1833
+ * 回 `null`;`purge` 回 `{removed:0,freedBytes:0}`)。判据仍归 `trash/groups.ts`(ADR-0001,
1834
+ * 由 `batch-carried.ts` 调用),同批已带走集只决定"这一条还该不该交给动词"。
1773
1835
  */
1774
1836
  function failure(id, action, error) {
1775
1837
  return {
@@ -1812,63 +1874,56 @@ async function forEachId(action, ids, run) {
1812
1874
  }
1813
1875
  return outcomes;
1814
1876
  }
1815
- /** 这一组的行从"当前快照"里摘掉(父行带走孩子后,后到的孩子 id 要认得出"已经不在了")。 */
1816
- function shrink(current, rows) {
1817
- for (const row of rows) {
1818
- const at = current.indexOf(row);
1819
- if (at >= 0) current.splice(at, 1);
1820
- }
1821
- }
1822
1877
  async function applyDelete(deps, ids) {
1823
1878
  const run = deleteSession(deps);
1824
- const carried = /* @__PURE__ */ new Set();
1879
+ const carried = createSessionCarried();
1825
1880
  return forEachId("delete", ids, async (sessionId) => {
1826
- const root = bareSessionId(sessionId);
1827
- if (carried.has(root)) return null;
1828
- const entry = await run(sessionId);
1829
- for (const row of groupOf(await deps.manifest.list(), entry.id)) if (bareSessionId(row.sessionId) !== root) carried.add(bareSessionId(row.sessionId));
1881
+ if (carried.claim(sessionId).verdict === "carried") return null;
1882
+ const { entry, movedSessionIds } = await run(sessionId);
1883
+ for (const movedId of movedSessionIds) carried.take(movedId);
1830
1884
  return entry;
1831
1885
  });
1832
1886
  }
1833
- /** 「这条是不是被同批前一条带走了」:原始快照里认得出、当前快照里已经空了。
1834
- * **收缩归这一层**(动词只拿只读快照)——`apply` 整批只读一次清册,`takenByEarlierInBatch` 不比它多读。 */
1887
+ /** 「这条是不是被同批前一条带走了」与视图收缩都归 `batch-carried.ts`;`apply` 整批只读一次清册,
1888
+ * 动词只拿它交回的只读组与当前清册。 */
1835
1889
  async function applyRestore(deps, ids) {
1836
- const original = await deps.manifest.list();
1837
- const current = [...original];
1890
+ const carried = createEntryCarried(await deps.ledger.list());
1838
1891
  const run = restoreSession(deps);
1839
1892
  const restored = [];
1840
1893
  const outcomes = await forEachId("restore", ids, async (entryId) => {
1841
- if (takenByEarlierInBatch(original, current, entryId)) return null;
1842
- const group = groupOf(current, entryId);
1843
- const entry = await run(entryId, current);
1894
+ const claim = carried.claim(entryId);
1895
+ if (claim.verdict === "carried") return null;
1896
+ const group = claim.verdict === "fresh" ? claim.group : [];
1897
+ const entry = await run(entryId, group);
1898
+ carried.take(entryId);
1844
1899
  restored.push(...group.map((row) => row.sessionId));
1845
- shrink(current, group);
1846
1900
  return entry;
1847
1901
  });
1848
- if (restored.length > 0) await deps.host.announceRestoredBatch(restored, await deps.host.restoredSummariesOf());
1902
+ if (restored.length > 0) await deps.host.announceRestored(restored);
1849
1903
  return outcomes;
1850
1904
  }
1851
1905
  async function applyPurge(deps, ids) {
1852
- const original = await deps.manifest.list();
1853
- const current = [...original];
1906
+ const carried = createEntryCarried(await deps.ledger.list());
1854
1907
  const run = purgeTrash(deps);
1855
1908
  return forEachId("purge", ids, async (entryId) => {
1856
- if (takenByEarlierInBatch(original, current, entryId)) return {
1909
+ const claim = carried.claim(entryId);
1910
+ if (claim.verdict === "carried") return {
1857
1911
  removed: 0,
1858
1912
  freedBytes: 0
1859
1913
  };
1860
- const group = groupOf(current, entryId);
1861
- const stats = await run(entryId, current);
1862
- shrink(current, group);
1914
+ const group = claim.verdict === "fresh" ? claim.group : [];
1915
+ const stats = await run(group, carried.rows(), entryId);
1916
+ carried.take(entryId);
1863
1917
  return stats;
1864
1918
  });
1865
1919
  }
1866
- /** 删除确认框那句「含 N 个子会话」:一次问完整批(`subagentCounts` 本来就是批量口)。
1867
- * 宿主口缺席时它回 `{}`(这里折成 0);真读失败则整次调用失败,浏览器半按"读不到"把整句不说。 */
1920
+ /** 删除确认框那句「含 N 个子会话」:一次问完整批(`subagentMembersOf` 本来就是批量口)。
1921
+ * 宿主口缺席(`available:false`)时按 0(这里不再有"空表"这一步);真读失败则整次调用失败,
1922
+ * 浏览器半按"读不到"把整句不说。 */
1868
1923
  async function applyCount(deps, ids) {
1869
1924
  const wanted = unique(ids);
1870
- const counts = await deps.host.subagentCounts(wanted);
1871
- return wanted.map((id) => success(id, "count", counts[id] ?? 0));
1925
+ const read = await deps.host.subagentMembersOf(wanted);
1926
+ return wanted.map((id) => success(id, "count", read.available ? read.value[id]?.length ?? 0 : 0));
1872
1927
  }
1873
1928
  /**
1874
1929
  * 清一组无主子会话(父已被彻底删掉、孩子还留在 `sessions/` 里的那些)。id 是**根会话 id**——
@@ -1876,39 +1931,238 @@ async function applyCount(deps, ids) {
1876
1931
  * (父又被人放回回收站、组已被上一个请求带走,或另一处已经把目录清掉),不是"用户打错了一个 id"。
1877
1932
  * 根 id 两边都去 `session-` 前缀再比:报告原样回传是正常路径,外部调用带另一种写法也认。
1878
1933
  *
1879
- * **这是写动作,排在到达序链上**,而且**先对账再扫**(2026-10-19):判"父没了"用的是清册,而刚被人
1880
- * 手工搬进 `session-trash/` 的那一格目录此刻可能还没有对应行——不对账就会把"父还在回收站里等着"的
1881
- * 一组当成孤儿**直接删盘**,替用户做了一个没人点过的决定(那种组该跟着父恢复或彻底删除)。
1882
- * 对账写在动词这一侧而不是只写在路由里,为的是 `apply` 的批量入口只有一个:链上的排队与这份快照
1883
- * 谁也别想只占一半。
1934
+ * **这是写动作,排在到达序链上**,而且**先对账再扫**:`loadOrphanGroups` 本身只扫描、不写清册;
1935
+ * 这里先把 `ledger.list()` 对账后的行交给它——刚被人手工搬进 `session-trash/` 的那一格目录此刻可能还
1936
+ * 没有对应行,拿 raw 索引扫就会把"父还在回收站里等着"的一组当成孤儿**直接删盘**,替用户做了一个没人
1937
+ * 点过的决定(那种组该跟着父恢复或彻底删除)。
1938
+ */
1939
+ async function applyOrphanClean(deps, ids) {
1940
+ const groups = await loadOrphanGroups(deps, await deps.ledger.list());
1941
+ const byRoot = new Map(groups.map((group) => [bareSessionId(group.root.sessionId), group]));
1942
+ return forEachId("orphan-clean", ids, async (id) => {
1943
+ const group = byRoot.get(bareSessionId(id));
1944
+ if (group === void 0) throw verbError("not-found", `不再是可清理的无主子会话:${id}`);
1945
+ return await cleanOrphanGroup(deps, group);
1946
+ });
1947
+ }
1948
+ function applyActions(deps, action, ids) {
1949
+ switch (action) {
1950
+ case "delete": return applyDelete(deps, ids);
1951
+ case "restore": return applyRestore(deps, ids);
1952
+ case "purge": return applyPurge(deps, ids);
1953
+ case "count": return applyCount(deps, ids);
1954
+ case "orphan-clean": return applyOrphanClean(deps, ids);
1955
+ }
1956
+ }
1957
+ //#endregion
1958
+ //#region src/log/zstd-frames.ts
1959
+ const MAGIC = [
1960
+ 40,
1961
+ 181,
1962
+ 47,
1963
+ 253
1964
+ ];
1965
+ /** magic(4) + Frame_Header_Descriptor(1) 之后第一个字节的偏移。 */
1966
+ const AFTER_DESCRIPTOR = 5;
1967
+ const BLOCK_HEADER_BYTES = 3;
1968
+ const BLOCK_LAST = 1;
1969
+ const BLOCK_TYPE_SHIFT = 1;
1970
+ const BLOCK_SIZE_SHIFT = 3;
1971
+ const BLOCK_SIZE_MASK = 2097151;
1972
+ const BLOCK_RAW = 0;
1973
+ const BLOCK_RLE = 1;
1974
+ const BLOCK_COMRESSED = 2;
1975
+ /** 规范(RFC 8878 表 4)里 3 是 **Reserved**,不是"未压缩块":见到就抛,别照 Raw 吞块体。 */
1976
+ const BLOCK_RESERVED = 3;
1977
+ /** descriptor 各字段的取值 → 字段字节数(zstd 帧头的两张尺寸表)。 */
1978
+ const DID_SIZES = [
1979
+ 0,
1980
+ 1,
1981
+ 2,
1982
+ 4
1983
+ ];
1984
+ const FCS_SIZES_SINGLE = [
1985
+ 1,
1986
+ 2,
1987
+ 4,
1988
+ 8
1989
+ ];
1990
+ const FCS_SIZES_MULTI = [
1991
+ 0,
1992
+ 2,
1993
+ 4,
1994
+ 8
1995
+ ];
1996
+ /** 实测的帧尾长度(校验和位置;bit5 没置位也照样带,见文件头注)。 */
1997
+ const FRAME_TAIL_BYTES = 4;
1998
+ function isMagicAt(raw, at) {
1999
+ return MAGIC.every((byte, i) => raw[at + i] === byte);
2000
+ }
2001
+ function readUint24LE(raw, at) {
2002
+ return raw[at] | raw[at + 1] << 8 | raw[at + 2] << 16;
2003
+ }
2004
+ /** 块体之后有没有那 4 字节尾巴:只有"跳过它之后仍是帧头或到 EOF"才认。 */
2005
+ function withFrameTail(raw, at) {
2006
+ const afterTail = at + FRAME_TAIL_BYTES;
2007
+ if (afterTail <= raw.length && (afterTail === raw.length || isMagicAt(raw, afterTail))) return afterTail;
2008
+ return at;
2009
+ }
2010
+ /**
2011
+ * 把 `raw` 切成帧区间。
2012
+ * @throws 起始处不是 magic、或某帧算到的末位既不是下一帧帧头也不是 EOF——**一律抛**,
2013
+ * 绝不"尽力往下解":切错帧的后果是把半截压缩体当解压输入,那时解出来的东西不可信。
2014
+ */
2015
+ function frameRanges(raw) {
2016
+ const ranges = [];
2017
+ let at = 0;
2018
+ while (at < raw.length) {
2019
+ if (!isMagicAt(raw, at)) throw new Error(`zstd 帧头缺席:偏移 ${at} 处不是 magic(前面第 ${ranges.length} 帧的边界算歪了?)`);
2020
+ const descriptor = raw[at + 4];
2021
+ if (descriptor === void 0) throw new Error(`zstd 帧头不完整:偏移 ${at} 之后不足 ${AFTER_DESCRIPTOR} 字节`);
2022
+ const fcsFlag = descriptor & 3;
2023
+ const singleSegment = descriptor >> 2 & 1;
2024
+ const didFlag = descriptor >> 6 & 3;
2025
+ let cursor = at + AFTER_DESCRIPTOR;
2026
+ if (!singleSegment) cursor += 1;
2027
+ cursor += DID_SIZES[didFlag];
2028
+ cursor += (singleSegment ? FCS_SIZES_SINGLE : FCS_SIZES_MULTI)[fcsFlag];
2029
+ for (;;) {
2030
+ const blockHeader = readUint24LE(raw, cursor);
2031
+ cursor += BLOCK_HEADER_BYTES;
2032
+ if (cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
2033
+ const blockType = blockHeader >> BLOCK_TYPE_SHIFT & 3;
2034
+ const blockSize = blockHeader >> BLOCK_SIZE_SHIFT & BLOCK_SIZE_MASK;
2035
+ if (blockType === BLOCK_RESERVED) throw new Error(`zstd 保留块类型:帧起于 ${at},偏移 ${cursor - BLOCK_HEADER_BYTES} 的块头 type=3(规范 Reserved,对面解不开)`);
2036
+ if (blockType === BLOCK_RAW || blockType === BLOCK_COMRESSED) cursor += blockSize;
2037
+ else if (blockType === BLOCK_RLE) cursor += 1;
2038
+ if (blockHeader & BLOCK_LAST) break;
2039
+ }
2040
+ cursor = withFrameTail(raw, cursor);
2041
+ if (cursor !== raw.length && !isMagicAt(raw, cursor)) throw new Error(`zstd 帧边界算歪了:帧起于 ${at},算到 ${cursor},那里既不是下一帧帧头也不是 EOF`);
2042
+ ranges.push({
2043
+ start: at,
2044
+ end: cursor
2045
+ });
2046
+ at = cursor;
2047
+ }
2048
+ return ranges;
2049
+ }
2050
+ //#endregion
2051
+ //#region src/log/session-log.ts
2052
+ /**
2053
+ * 会话日志(`session.vN.jsonl.zstd`)的**逐帧读法**:帧边界交给 `zstd-frames.ts`,这一层只做
2054
+ * 「帧 → 明文 → 一行一条 JSON」的解码,并把每条记录原样交给回调。
2055
+ *
2056
+ * **策略不在这里**(哪些 `type` 算正文、截断到多少字、算不算失败)——那是 `src/trash/detail.ts` 的事。
2057
+ * 这一层只懂编解码,所以它能被单独验穿。
2058
+ *
2059
+ * 两条实测决定的口径:
2060
+ * ① **必须逐帧**:Node 的 `zstdDecompressSync` 与 `createZstdDecompress()` 都只给第一帧(见
2061
+ * `zstd-frames.ts` 头注);明文留在手里会一次装下整条日志(本机 125 条合计 280.7 MB),
2062
+ * 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
2063
+ * ② **一行的余量跨帧带走**(2026-10-16 改,原来靠"跳过解不开的行"兜):写作侧是一条条 append,
2064
+ * 本机实测**没有一帧切在行中间**(连最后一帧都整行收尾;绝对帧数随盘上会话数摆动,那份读数
2065
+ * 不在这里钉,来历留在 git 历史),
2066
+ * 所以旧口径当时并不丢东西;但"逐帧 `split('\n')`"一旦遇上切行就是**静默少一条记录**(半个头
2067
+ * 加半个身,两半都解不开 JSON),而少掉的那条正是页面上要跟账的正文。带上余量之后这条不变量
2068
+ * 不必再成立,顺带把**跨帧的多字节字符**也解对了——拼接在字节层做,`toString('utf8')` 只看整行。
2069
+ * 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
2070
+ */
2071
+ const NEWLINE = 10;
2072
+ /**
2073
+ * 宿主给每一代日志的词法(逐字取自 `@deepseek-ai/dsh-session-format@0.2.0-rc.2` 的
2074
+ * `CANONICAL_LOG_FILENAME`,剥掉压缩后缀那段由我们自己在正则里补上 `.zstd`):
2075
+ * 裸名 `session.jsonl` 是**第 0 代**,往后是 `session.vN`;`.v0`、前导零、大写、临时名都不是规范名。
2076
+ */
2077
+ const CANONICAL_ZSTD_LOG = /^session(?:\.v([1-9][0-9]*))?\.jsonl\.zstd$/u;
2078
+ /**
2079
+ * 同一个会话目录里**可以并存多代日志**(宿主换代时把老代留在原地当历史语料,本机实测 7 个目录如此),
2080
+ * 而新代是老代的**迁移后超集**(实测:`firstTs` 与开头那条提问完全相同、记录更多、末尾更晚)
2081
+ * ⇒ 要读的是版本号最大的那一代,读旧代 = 读到一份截了尾巴的快照;把几代拼起来 = 重复计数。
2082
+ *
2083
+ * 明文(无 `.zstd`)不在此列:本插件的读法逐帧解 zstd,明文得走另一条解码路。
2084
+ */
2085
+ function pickSessionLog(names) {
2086
+ let best;
2087
+ let bestVersion = -1;
2088
+ for (const name of names) {
2089
+ const match = CANONICAL_ZSTD_LOG.exec(name);
2090
+ if (match === null) continue;
2091
+ const version = match[1] === void 0 ? 0 : Number(match[1]);
2092
+ if (version > bestVersion) {
2093
+ bestVersion = version;
2094
+ best = name;
2095
+ }
2096
+ }
2097
+ return best;
2098
+ }
2099
+ /**
2100
+ * 逐帧走一遍日志,每条记录交给回调。
2101
+ *
2102
+ * 回调返回 `false` = **提前停机**(后面的帧不再解——`readSessionHeader` 只要第一行)。
2103
+ * 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
1884
2104
  */
1885
- async function applyOrphanClean(deps, ids) {
1886
- await reconcileTrash(deps);
1887
- const groups = await loadOrphanGroups(deps);
1888
- const byRoot = new Map(groups.map((group) => [bareSessionId(group.root.sessionId), group]));
1889
- const outcomes = [];
1890
- for (const id of unique(ids)) {
1891
- const group = byRoot.get(bareSessionId(id));
1892
- if (group === void 0) {
1893
- outcomes.push(failure(id, "orphan-clean", verbError("not-found", `不再是可清理的无主子会话:${id}`)));
1894
- continue;
2105
+ function walkSessionLog(raw, onRecord) {
2106
+ const stats = {
2107
+ frames: 0,
2108
+ records: 0,
2109
+ unparsedLines: 0,
2110
+ truncatedTailBytes: 0
2111
+ };
2112
+ let stopped = false;
2113
+ const feed = (line) => {
2114
+ if (line === "") return;
2115
+ let parsed;
2116
+ try {
2117
+ parsed = JSON.parse(line);
2118
+ } catch {
2119
+ stats.unparsedLines += 1;
2120
+ return;
1895
2121
  }
2122
+ stats.records += 1;
2123
+ if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
2124
+ };
2125
+ /** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
2126
+ let carry = Buffer.alloc(0);
2127
+ for (const { start, end } of frameRanges(raw)) {
2128
+ if (stopped) break;
2129
+ stats.frames += 1;
2130
+ let plain;
1896
2131
  try {
1897
- outcomes.push(success(id, "orphan-clean", await cleanOrphanGroup(deps, group)));
2132
+ plain = zlib.zstdDecompressSync(raw.subarray(start, end));
1898
2133
  } catch (error) {
1899
- outcomes.push(failure(id, "orphan-clean", error));
2134
+ throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`);
2135
+ }
2136
+ const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
2137
+ const cut = merged.lastIndexOf(NEWLINE);
2138
+ if (cut === -1) {
2139
+ carry = merged;
2140
+ continue;
2141
+ }
2142
+ carry = merged.subarray(cut + 1);
2143
+ for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
2144
+ feed(line);
2145
+ if (stopped) break;
1900
2146
  }
1901
2147
  }
1902
- return outcomes;
2148
+ stats.truncatedTailBytes = carry.length;
2149
+ if (carry.length > 0 && !stopped) feed(carry.toString("utf8"));
2150
+ return stats;
1903
2151
  }
1904
- function applyActions(deps, action, ids) {
1905
- switch (action) {
1906
- case "delete": return applyDelete(deps, ids);
1907
- case "restore": return applyRestore(deps, ids);
1908
- case "purge": return applyPurge(deps, ids);
1909
- case "count": return applyCount(deps, ids);
1910
- case "orphan-clean": return applyOrphanClean(deps, ids);
1911
- }
2152
+ /**
2153
+ * 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
2154
+ * AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
2155
+ * (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
2156
+ * 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
2157
+ */
2158
+ function readSessionHeader(raw) {
2159
+ let header;
2160
+ walkSessionLog(raw, (record) => {
2161
+ if (record.type !== "session") return;
2162
+ header = record;
2163
+ return false;
2164
+ });
2165
+ return header;
1912
2166
  }
1913
2167
  const DEFAULT_LIMITS = {
1914
2168
  perMessageChars: 3e3,
@@ -1921,7 +2175,7 @@ function textOf(content) {
1921
2175
  }
1922
2176
  function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
1923
2177
  return async function readTrashDetail(entryId) {
1924
- const record = (await deps.manifest.list()).find((e) => e.id === entryId);
2178
+ const record = (await deps.ledger.raw()).find((e) => e.id === entryId);
1925
2179
  if (record === void 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
1926
2180
  const dir = trashDirOf(record.id, deps.home);
1927
2181
  if (!await deps.fs.exists(dir)) throw verbError("io", `回收站目录不在:${dir}`);
@@ -2028,7 +2282,7 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
2028
2282
  * 清册那一行 → 线上那一行。**显式字面量、不用 `...row`**:线上形状多一个必填字段时,这里编译不过,
2029
2283
  * 于是"线上多了一格却没人填"不可能悄悄发生(`...row` 会把它带过去,看上去一切正常)。
2030
2284
  */
2031
- function toWire(row, readouts = {}) {
2285
+ function toWire(row, readouts) {
2032
2286
  return {
2033
2287
  id: row.id,
2034
2288
  sessionId: row.sessionId,
@@ -2038,7 +2292,7 @@ function toWire(row, readouts = {}) {
2038
2292
  deletedAt: row.deletedAt,
2039
2293
  title: row.title,
2040
2294
  sizeBytes: row.sizeBytes,
2041
- releaseBytes: row.sizeBytes + (readouts.subagentBytes ?? 0) + (readouts.strandedBytes ?? 0),
2295
+ releaseBytes: readouts.releaseBytes,
2042
2296
  ...row.parentEntryId === void 0 ? {} : { parentEntryId: row.parentEntryId },
2043
2297
  ...readouts.subagentCount === void 0 ? {} : { subagentCount: readouts.subagentCount },
2044
2298
  ...readouts.subagentBytes === void 0 ? {} : { subagentBytes: readouts.subagentBytes },
@@ -2052,35 +2306,25 @@ function toWire(row, readouts = {}) {
2052
2306
  * 我们若单列就会冒出一堆点不到的行);父行不在时孩子自己站出来占一行,否则它既看不见也清不掉。
2053
2307
  * 这些派生值只加在这次回信上,**不落清册**——清册该记的是文件里真有的事实,而"还剩几个孩子"
2054
2308
  * "工作区还在不在"是随盘上随时变的读数。取数失败照抛(响亮失败,不端一份少了这栏的清单当没事发生)。
2309
+ *
2310
+ * 一组要释放多少、盘上还剩谁**只读计划一处算**(`planVisibleTrash`,与 purge 同一条口径);
2311
+ * 这里不再自己枚举盘上后代,也不碰 `isLive`(活体准入只属于写动词)。
2055
2312
  */
2056
2313
  async function assembleTrashRows(deps, rows) {
2057
2314
  const groups = visibleGroups(rows);
2058
- const visible = groups.map((group) => group.root);
2059
- const [membersByParent, missingByRowId] = await Promise.all([deps.host.subagentMembersBatch(visible.map((row) => row.sessionId)), Promise.all(visible.map(async (row) => [row.id, await isWorkspaceMissing(deps.host, row)]))]);
2315
+ const [planned, missingByRowId] = await Promise.all([planVisibleTrash(deps, rows, groups), Promise.all(groups.map(async ({ root }) => [root.id, await isWorkspaceMissing(deps.host, root)]))]);
2060
2316
  const gone = new Map(missingByRowId);
2061
- return await Promise.all(groups.map(async ({ root, kids }) => {
2062
- const kidsInGroup = new Set(kids.map((kid) => bareSessionId(kid.sessionId)));
2063
- let count = 0;
2064
- let bytes = 0;
2065
- for (const member of membersByParent[root.sessionId] ?? []) {
2066
- if (kidsInGroup.has(bareSessionId(member.sessionId))) continue;
2067
- const measured = await measureMemberDir(deps, member);
2068
- if (measured === void 0) continue;
2069
- count += 1;
2070
- bytes += measured.bytes;
2071
- }
2072
- const missing = gone.get(root.id) === true;
2073
- return toWire(root, {
2074
- ...kids.length === 0 ? {} : {
2075
- subagentCount: kids.length,
2076
- subagentBytes: kids.reduce((total, kid) => total + kid.sizeBytes, 0)
2077
- },
2078
- ...count === 0 ? {} : {
2079
- strandedSubagents: count,
2080
- strandedBytes: bytes
2081
- },
2082
- ...missing ? { workspaceMissing: true } : {}
2083
- });
2317
+ return planned.map(({ root, kids, releaseBytes, subagentBytes, strandedCount, strandedBytes }) => toWire(root, {
2318
+ releaseBytes,
2319
+ ...kids.length === 0 ? {} : {
2320
+ subagentCount: kids.length,
2321
+ subagentBytes
2322
+ },
2323
+ ...strandedCount === 0 ? {} : {
2324
+ strandedSubagents: strandedCount,
2325
+ strandedBytes
2326
+ },
2327
+ ...gone.get(root.id) === true ? { workspaceMissing: true } : {}
2084
2328
  }));
2085
2329
  }
2086
2330
  //#endregion
@@ -2132,7 +2376,7 @@ const ROUTE_TABLE = {
2132
2376
  method: "GET",
2133
2377
  body: "none",
2134
2378
  purity: "chained",
2135
- run: async (deps) => assembleTrashRows(deps, await reconcileTrash(deps))
2379
+ run: async (deps) => assembleTrashRows(deps, await deps.ledger.list())
2136
2380
  },
2137
2381
  [ROUTES.ping]: {
2138
2382
  method: "GET",
@@ -2157,7 +2401,7 @@ const ROUTE_TABLE = {
2157
2401
  body: "none",
2158
2402
  purity: "chained",
2159
2403
  run: async (deps) => {
2160
- const rows = await reconcileTrash(deps);
2404
+ const rows = await deps.ledger.list();
2161
2405
  return await purgeTrash(deps)(void 0, rows);
2162
2406
  }
2163
2407
  },
@@ -2224,7 +2468,7 @@ function writeOk(res, value) {
2224
2468
  * 未知路由 404、栅栏拒 403(它们不是"一次办成的操作",是"这条请求不归我办/你没资格办")。
2225
2469
  * **操作失败**仍回 200 + 信封。浏览器半先解信封再看状态,所以 4xx 上带的信封照样读得出原文;
2226
2470
  * `code` 一律折成 `sessiondelete/io`,那三个路由标签(`bad-request` / `bad-method` / `not-found-route`)
2227
- * 是**抛出处给读代码的人看的站点名**,不许冒充 §6 的五个动词码上客户端。 */
2471
+ * 是**抛出处给读代码的人看的站点名**,不许冒充 `src/shared/wire.ts` 的五个动词码上客户端。 */
2228
2472
  function writeFailure(res, error, status) {
2229
2473
  const carried = error?.httpStatus;
2230
2474
  writeJson(res, status ?? (typeof carried === "number" ? carried : 200), {
@@ -2370,8 +2614,154 @@ function createFsPort() {
2370
2614
  };
2371
2615
  }
2372
2616
  //#endregion
2617
+ //#region src/ports/home.ts
2618
+ /**
2619
+ * 宿主数据根。官方解析器在 asar `dsh/node_modules/@deepseek-ai/dsh-home-paths/lib/index.js`:
2620
+ * `resolveDshHome(configured, env)` 的优先级 = 显式配置 > `$DSH_HOME`(**空白视为未设**,免得非法覆盖把 home
2621
+ * 解析成当前工作目录)> `~/.dsh`,并 `resolve()` 规范化、展开 `~/`。
2622
+ *
2623
+ * **只镜像后两级,且这与平台的同款设施等价**——"显式配置"那一级对插件**不可得**,三条实证:
2624
+ * ① 平台自己的 helper `dshHomePath(...segments)` 逐字是 `join(resolveDshHome(), ...segments)`,
2625
+ * **同样不传 `configured`**;
2626
+ * ② `ctx.profileContext` 按 launcher 原文只装「profile 位置 / 启动 bundle 名 / 调用覆盖 / 遥测开关」,
2627
+ * 里面没有数据根;
2628
+ * ③ 宿主自己的服务也不是"问出来"的——它们各自声明 `Config.dshHome`,由**装配那一行的人**填。
2629
+ * ⇒ 后果与边界(判据在 `src/verbs/delete-session.ts` 与 `src/verbs/restore-session.ts` 的预检):显式配置过的
2630
+ * profile 上我们算出的根与宿主不一致 ⇒ 文件探测落空 ⇒ 删除/恢复**响亮拒绝、零副作用**(`not-found` /
2631
+ * `trash-empty` / `project-missing`);**不会**动到别的树(sessions 与回收站同源 ⇒ 同卷)。
2632
+ * 本机两条路径都因此对得上:桌面版走 `~/.dsh`(实测 profile 在其下),隔离探针靠 `$DSH_HOME`。
2633
+ *
2634
+ * `homeDir` 只用于测试注入假主目录;默认值仍是 `homedir()`,行为与搬家前一致。
2635
+ */
2636
+ function resolveHome(env = process.env, homeDir = homedir()) {
2637
+ const fromEnv = env.DSH_HOME;
2638
+ const raw = fromEnv !== void 0 && fromEnv.trim().length > 0 ? fromEnv : join(homeDir, ".dsh");
2639
+ if (raw === "~") return homeDir;
2640
+ if (raw.startsWith("~/") || raw.startsWith("~\\")) return resolve(join(homeDir, raw.slice(2)));
2641
+ return resolve(raw);
2642
+ }
2643
+ //#endregion
2644
+ //#region src/trash/reconcile.ts
2645
+ /**
2646
+ * 回收站清单的**对账**:以 `session-trash/` 里的条目目录为准,把清单修回一致(CONTEXT.md 的「对账」条)。
2647
+ *
2648
+ * 手工动回收站**只需要动那一个目录**——多出来的目录在这里被重建出行、目录没了的行在这里被摘掉;
2649
+ * `~/.dsh/storages/` 那份清单从此是**内部索引**,不用手碰。
2650
+ *
2651
+ * 五条口径(都不是口味):
2652
+ * ① **每次真相读都对账**(`src/trash/ledger.ts` 的 `list()`;`GET /list` 的正文就是"对账 + 读"):挂载、
2653
+ * 刷新、别处动过后的自动重读、客户端挂载后的首次列表读全走这条 ⇒ "改完目录不用再做任何事,下一次读就对了"。
2654
+ * pure 读面(`/orphans`、`/detail`)走 `raw()`,不对账——对账会写行,必须排在到达序链上。
2655
+ * 代价 = 每次真相读多一次目录枚举(本机 124 条,毫秒级);**零漂移零写入**(快路直接返回原清单)。
2656
+ * ② **回收站根不在 ⇒ 整套跳过、一个字节都不动**:那不是"回收站空了",是"这块地不在原处"
2657
+ * (人为搬走、盘没挂上)——照常对账会把全部行的元数据一次判没。
2658
+ * ③ **重建行只写"文件里真有的事实"**:`id` = 目录名、`sessionId`/`cwd` 读日志 header、
2659
+ * `projectDir` 由 `cwd` 经宿主同名算法 `projectKey` 反推、`deletedAt` 从目录名的时间戳解、
2660
+ * `sizeBytes` 现场量。**文件侧没有副本的三件**按缺省落:`workspaceId: ''`(恢复时跳过挂账本,
2661
+ * 落在「未分组」)、`wasArchived: false`(文件侧没有归档事实;旧条目恢复走迁移支)、`title: ''`(详情有日志标题兜底)
2662
+ * ——这是重建的固有代价,不是可以"再读读看"的东西。
2663
+ * ④ **认不出 / 读不出的目录:不动、不列**(名字不像条目、目录里没有日志、header 读不出、尺寸量不出,
2664
+ * 各跳过各的)。它是"外人放进来的东西",不是用户动作失败:跳过即静默,绝不为它删目录或抛掉整次读数。
2665
+ * ⑤ **幂等**:再跑一遍零写入;崩在任何一步,下一次读清单接着把它修完。
2666
+ *
2667
+ * 与三个写动词的**串行化不在这里**(那是传输层的事,见 `src/api/dispatch.ts` 的到达序链):
2668
+ * 对账与删除/恢复/彻底删除都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩——删除刚把目录搬进
2669
+ * 回收站、行还没写上时,对账抢先把这行按目录"重建"出来,随后又被真实行覆盖成降级元数据。
2670
+ */
2671
+ /** 目录名是不是一条条目 id(形状契约在 `entry.ts`:`<删除时刻UTC>-<原目录名>`;
2672
+ * 原目录名还必须能安全地拼回磁盘路径——带分隔符的名字仍按"外人"跳过,不让一次对账读崩)。 */
2673
+ function isEntryId(name) {
2674
+ try {
2675
+ return isSinglePathSegment(parseEntryId(name).sessionDirName);
2676
+ } catch {
2677
+ return false;
2678
+ }
2679
+ }
2680
+ /**
2681
+ * 从一个手工放进回收站的目录重建清单行。**任一件事实拿不到就整体放弃这一条**(回 `undefined`):
2682
+ * 半截行比"不列"更坏——恢复会照它把目录搬去一个错的地方(同 `detail.ts` 那条"半截正文"的口径)。
2683
+ *
2684
+ * `sessionId` 取日志 header 的 `id`(恢复之后宿主按日志 header 建索引,那才是会话身份);
2685
+ * header 缺席这个字段才回落到目录名里那段原目录名。
2686
+ */
2687
+ async function buildEntry(deps, id) {
2688
+ const dir = trashDirOf(id, deps.home);
2689
+ let logName;
2690
+ try {
2691
+ logName = pickSessionLog(await deps.fs.listFiles(dir));
2692
+ } catch {
2693
+ return;
2694
+ }
2695
+ if (logName === void 0) return void 0;
2696
+ let raw;
2697
+ try {
2698
+ raw = await deps.fs.readFile(`${dir}/${logName}`);
2699
+ } catch {
2700
+ return;
2701
+ }
2702
+ let header;
2703
+ try {
2704
+ header = readSessionHeader(raw);
2705
+ } catch {
2706
+ return;
2707
+ }
2708
+ if (header === void 0) return void 0;
2709
+ let sizeBytes;
2710
+ try {
2711
+ sizeBytes = await deps.fs.sizeOfDir(dir);
2712
+ } catch {
2713
+ return;
2714
+ }
2715
+ const { deletedAt, sessionDirName } = parseEntryId(id);
2716
+ const cwd = typeof header.cwd === "string" ? header.cwd : "";
2717
+ return {
2718
+ id,
2719
+ sessionId: typeof header.id === "string" && header.id !== "" ? header.id : sessionDirName,
2720
+ projectDir: cwd === "" ? NO_CWD_PROJECT_DIR : projectKey(cwd),
2721
+ workspaceId: "",
2722
+ wasArchived: false,
2723
+ deletedAt,
2724
+ title: "",
2725
+ sizeBytes
2726
+ };
2727
+ }
2728
+ /** 对账并回清单——**返回的就是修好之后的那份**(与 `manifest.list()` 同序:最近删除在前)。 */
2729
+ async function reconcileTrash(deps) {
2730
+ const rows = await deps.manifest.list();
2731
+ const root = `${deps.home}/${TRASH_DIR_NAME}`;
2732
+ if (!await deps.fs.exists(root)) return rows;
2733
+ const dirIds = /* @__PURE__ */ new Set();
2734
+ for (const name of await deps.fs.listDirs(root)) if (isEntryId(name)) dirIds.add(name);
2735
+ const rowIds = new Set(rows.map((row) => row.id));
2736
+ const drops = rows.filter((row) => !dirIds.has(row.id));
2737
+ const rebuilds = [];
2738
+ for (const id of dirIds) {
2739
+ if (rowIds.has(id)) continue;
2740
+ const entry = await buildEntry(deps, id);
2741
+ if (entry !== void 0) rebuilds.push(entry);
2742
+ }
2743
+ if (drops.length === 0 && rebuilds.length === 0) return rows;
2744
+ for (const row of drops) await deps.manifest.remove(row.id);
2745
+ for (const entry of rebuilds) await deps.manifest.add(entry);
2746
+ return await deps.manifest.list();
2747
+ }
2748
+ //#endregion
2749
+ //#region src/trash/ledger.ts
2750
+ function createTrashLedger(raw, fs, home) {
2751
+ return {
2752
+ list: () => reconcileTrash({
2753
+ fs,
2754
+ manifest: raw,
2755
+ home
2756
+ }),
2757
+ raw: () => raw.list(),
2758
+ add: (entry) => raw.add(entry),
2759
+ remove: (id) => raw.remove(id)
2760
+ };
2761
+ }
2762
+ //#endregion
2373
2763
  //#region src/trash/manifest.ts
2374
- function createStorageManifest$1(domain) {
2764
+ function createStorageManifest(domain) {
2375
2765
  return {
2376
2766
  async list() {
2377
2767
  return (await domain.readAll()).sort((a, b) => b.deletedAt - a.deletedAt);
@@ -2385,31 +2775,8 @@ function createStorageManifest$1(domain) {
2385
2775
  };
2386
2776
  }
2387
2777
  //#endregion
2388
- //#region src/index.ts
2389
- const name = "@xrn1997/dsh-session-delete";
2390
- /**
2391
- * 插件依赖:**只写字符串数组**(隔离数据根上实测:写成 `{ required, optional }` 会让插件停在
2392
- * `pending (waiting for services: required, optional)`)。
2393
- *
2394
- * 三项都是在隔离数据根上实测注入成功、且本插件真正用到的宿主服务。三个**故意不写**的:
2395
- * - `remote`:那是**浏览器半**的服务(asar `dsh-api-gateway/lib/client.js`:`super(ctx, "remote")`),
2396
- * Node 半挂的是 `ctx.typertGateway`。把它写进 Node 半的 inject 会让插件永远 pending(同 `slots` 那次的形状)。
2397
- * - `sessions` / `sessionProjectionCache`:两者都按 `ctx.get(...)` **可选读**——`sessions` 只给 `evictLive`
2398
- * 用(活跃判据走 waterfall,见设计稿 §5;服务缺席回 `unknown`),`sessionProjectionCache` 是标题的尽力读面。
2399
- * inject 一个可能没挂的服务 = 整个插件 pending。
2400
- * 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
2401
- *
2402
- * `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
2403
- * 前缀路由,浏览器半同源 fetch 那六个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
2404
- * `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
2405
- * `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
2406
- */
2407
- const inject = [
2408
- "workspaceRegistry",
2409
- "storageDomain",
2410
- "webServer"
2411
- ];
2412
- /** 本插件自己的域(设计稿 §3:介质落在 `~/.dsh/storages/`)。
2778
+ //#region src/trash/storage-manifest.ts
2779
+ /** 本插件自己的域(CONTEXT.md 的「清册」条:介质落在 `~/.dsh/storages/`)。
2413
2780
  *
2414
2781
  * **名字必须带插件专属前缀**:domain 名是 `storageDomain` facility 里的**全局键**,而**一个名字在
2415
2782
  * 一个 facility 里只能 `open()` 一次**(句柄归调用方所有)——别人先占了同名域,本插件会**在激活时**
@@ -2464,7 +2831,7 @@ const trashDomainSpec = defineDomain({
2464
2831
  * **失败不缓存**:`open()` 被拒是瞬态(域被别的持有者占着、装载竞态),把第一次的 rejected promise
2465
2832
  * 缓存起来等于"这个会话永远删不掉"——下一次读会重新走 `open()`,成功才落下活句柄。
2466
2833
  */
2467
- function createStorageManifest(ctx) {
2834
+ function createHostStorageManifest(ctx) {
2468
2835
  const host = ctx;
2469
2836
  let domain;
2470
2837
  let opening;
@@ -2482,7 +2849,7 @@ function createStorageManifest(ctx) {
2482
2849
  }
2483
2850
  return domain.table("entries");
2484
2851
  };
2485
- return createStorageManifest$1({
2852
+ return createStorageManifest({
2486
2853
  readAll: async () => [...(await table()).entries()].map(([, record]) => record),
2487
2854
  put: async (record) => {
2488
2855
  await (await table()).put(record.id, record);
@@ -2492,28 +2859,36 @@ function createStorageManifest(ctx) {
2492
2859
  }
2493
2860
  });
2494
2861
  }
2862
+ //#endregion
2863
+ //#region src/index.ts
2864
+ const name = "@xrn1997/dsh-session-delete";
2495
2865
  /**
2496
- * 宿主数据根。官方解析器在 asar `dsh/node_modules/@deepseek-ai/dsh-home-paths/lib/index.js`:
2497
- * `resolveDshHome(configured, env)` 的优先级 = 显式配置 > `$DSH_HOME`(**空白视为未设**,免得非法覆盖把 home
2498
- * 解析成当前工作目录)> `~/.dsh`,并 `resolve()` 规范化、展开 `~/`。
2866
+ * 插件依赖:**只写字符串数组**(隔离数据根上实测:写成 `{ required, optional }` 会让插件停在
2867
+ * `pending (waiting for services: required, optional)`)。
2499
2868
  *
2500
- * **只镜像后两级,且这与平台的同款设施等价**——"显式配置"那一级对插件**不可得**,三条实证:
2501
- * ① 平台自己的 helper `dshHomePath(...segments)` 逐字是 `join(resolveDshHome(), ...segments)`,
2502
- * **同样不传 `configured`**;
2503
- * ② `ctx.profileContext` 按 launcher 原文只装「profile 位置 / 启动 bundle 名 / 调用覆盖 / 遥测开关」,
2504
- * 里面没有数据根;
2505
- * ③ 宿主自己的服务也不是"问出来"的——它们各自声明 `Config.dshHome`,由**装配那一行的人**填。
2506
- * ⇒ 后果与边界(设计稿 §5):显式配置过的 profile 上我们算出的根与宿主不一致 ⇒ 文件探测落空 ⇒
2507
- * 删除/恢复以 `not-found` **响亮拒绝、零副作用**;**不会**动到别的树(sessions 与回收站同源 ⇒ 同卷)。
2508
- * 本机两条路径都因此对得上:桌面版走 `~/.dsh`(实测 profile 在其下),隔离探针靠 `$DSH_HOME`。
2869
+ * 三项都是在隔离数据根上实测注入成功、且本插件真正用到的宿主服务。**不进 inject 的**:
2870
+ * - `remote`:那是**浏览器半**的服务(asar `dsh-api-gateway/lib/client.js`:`super(ctx, "remote")`),
2871
+ * Node 半挂的是 `ctx.typertGateway`。把它写进 Node 半的 inject 会让插件永远 pending(同 `slots` 那次的形状)。
2872
+ * - `sessions` / `sessionProjectionCache`:两者都按 `ctx.get(...)` **可选读**——`sessions` 只给 `evictLive`
2873
+ * 用(活跃判据走 waterfall,见 src/ports/host-port.ts;服务缺席回 `unknown`),`sessionProjectionCache` 是标题的尽力读面。
2874
+ * inject 一个可能没挂的服务 = 整个插件 pending。
2875
+ * 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
2876
+ * - `agents`:只给 handle 捕获用(`ctx.get('agents')`;缺席时 `ctx.inject(['agents'])` 等服务就绪再装)。
2877
+ * 它同样可能晚于本插件激活,写进插件 inject 只会让整个插件 pending。
2878
+ *
2879
+ * `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
2880
+ * 前缀路由,浏览器半同源 fetch 那六个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
2881
+ * `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
2882
+ * `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
2883
+ *
2884
+ * 清册的三层各有各的家:raw 绑定 `src/trash/storage-manifest.ts`、读面(对账 + 读)`src/trash/ledger.ts`、
2885
+ * 数据根 `src/ports/home.ts`。这里只把它们接起来,任何一层都不在组合根里长判据。
2509
2886
  */
2510
- function resolveHome(env = process.env) {
2511
- const fromEnv = env.DSH_HOME;
2512
- const raw = fromEnv !== void 0 && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), ".dsh");
2513
- if (raw === "~") return homedir();
2514
- if (raw.startsWith("~/") || raw.startsWith("~\\")) return resolve(join(homedir(), raw.slice(2)));
2515
- return resolve(raw);
2516
- }
2887
+ const inject = [
2888
+ "workspaceRegistry",
2889
+ "storageDomain",
2890
+ "webServer"
2891
+ ];
2517
2892
  /**
2518
2893
  * 装配顺序:**清单 → 端口 → 动词依赖 → 前缀路由**(`DeleteDeps` 就是前两者的合一)。
2519
2894
  *
@@ -2522,11 +2897,14 @@ function resolveHome(env = process.env) {
2522
2897
  * 在 `register` 里直接抛(`webserver: duplicate prefix route "…"`),响亮失败而不是悄悄覆盖。
2523
2898
  */
2524
2899
  function apply(ctx) {
2900
+ const fs = createFsPort();
2901
+ const home = resolveHome();
2902
+ const rawManifest = createHostStorageManifest(ctx);
2525
2903
  const deps = {
2526
- fs: createFsPort(),
2904
+ fs,
2527
2905
  host: createHostPort(ctx),
2528
- manifest: createStorageManifest(ctx),
2529
- home: resolveHome(),
2906
+ ledger: createTrashLedger(rawManifest, fs, home),
2907
+ home,
2530
2908
  now: () => Date.now()
2531
2909
  };
2532
2910
  const webServer = ctx.webServer;
@@ -2542,4 +2920,4 @@ function apply(ctx) {
2542
2920
  }, "session-delete: /dsh-session-delete-api routes");
2543
2921
  }
2544
2922
  //#endregion
2545
- export { apply, createStorageManifest, inject, name };
2923
+ export { apply, inject, name };