@xrn1997/dsh-session-delete 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +22 -13
  2. package/THIRD_PARTY_NOTICES.md +179 -0
  3. package/cordis.patch.yml +1 -1
  4. package/docs/screenshots/screenshot-01.png +0 -0
  5. package/docs/screenshots/screenshot-02.png +0 -0
  6. package/docs/screenshots/screenshot-03.png +0 -0
  7. package/icon.svg +24 -18
  8. package/lib/client.js +1439 -410
  9. package/lib/index.js +475 -244
  10. package/lib/types/api/connection-route.d.ts +1 -1
  11. package/lib/types/api/dispatch.d.ts +1 -1
  12. package/lib/types/api/route-table.d.ts +12 -7
  13. package/lib/types/client/api-base.d.ts +19 -0
  14. package/lib/types/client/config-settings.d.ts +13 -0
  15. package/lib/types/client/context-menu-actions.d.ts +47 -0
  16. package/lib/types/client/context.d.ts +3 -0
  17. package/lib/types/client/copy.d.ts +42 -7
  18. package/lib/types/client/delete-confirm.d.ts +2 -2
  19. package/lib/types/client/detail-blocks.d.ts +25 -0
  20. package/lib/types/client/index.d.ts +3 -0
  21. package/lib/types/client/maintenance-settings.d.ts +5 -4
  22. package/lib/types/client/remote.d.ts +43 -15
  23. package/lib/types/client/settings-cards.d.ts +19 -0
  24. package/lib/types/client/settings-view.d.ts +9 -0
  25. package/lib/types/client/trash-detail.d.ts +1 -1
  26. package/lib/types/client/trash-nav.d.ts +5 -1
  27. package/lib/types/client/trash-view.d.ts +34 -5
  28. package/lib/types/client/ui/MenuItemButton.d.ts +1 -1
  29. package/lib/types/client/ui/Toast.d.ts +4 -1
  30. package/lib/types/client/ui/icons.d.ts +23 -7
  31. package/lib/types/client/ui/recipe.d.ts +2 -0
  32. package/lib/types/client/ui/styles.d.ts +2 -0
  33. package/lib/types/client/use-detail-pager.d.ts +33 -0
  34. package/lib/types/config.d.ts +10 -11
  35. package/lib/types/deps.d.ts +9 -1
  36. package/lib/types/index.d.ts +8 -5
  37. package/lib/types/ports/agent-handles.d.ts +10 -2
  38. package/lib/types/ports/fs-port.d.ts +10 -0
  39. package/lib/types/ports/host-port.d.ts +4 -2
  40. package/lib/types/shared/wire.d.ts +37 -5
  41. package/lib/types/trash/attachments.d.ts +15 -2
  42. package/lib/types/verbs/config.d.ts +25 -0
  43. package/lib/types/verbs/errors.d.ts +3 -1
  44. package/lib/types/verbs/permanent-removal.d.ts +10 -5
  45. package/locale/en.json +1 -1
  46. package/locale/zh.json +6 -6
  47. package/package.json +30 -19
package/lib/index.js CHANGED
@@ -1,18 +1,17 @@
1
1
  import path, { join, resolve } from "node:path";
2
- import zlib from "node:zlib";
3
- import { existsSync } from "node:fs";
4
2
  import { mkdir, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
3
+ import zlib from "node:zlib";
5
4
  import { homedir } from "node:os";
6
5
  //#region src/shared/wire.ts
7
6
  /**
8
- * 跨半 wire 契约:前缀、九个端点、请求/响应形状与失败码。
7
+ * 跨半 wire 契约:前缀、十个端点、请求/响应形状与失败码。
9
8
  * Node 半(src/api/dispatch.ts)与浏览器半(src/client/remote.ts)共用这一份,不各写字面量。
10
9
  *
11
10
  * 构建约束:本文件零运行时依赖、无 import;浏览器半把整个文件 inline 进 bundle。
12
11
  * 信封 {ok:true,value} / {ok:false,error:{code,message,details?}} 也是两半的共同契约。
13
12
  */
14
13
  /**
15
- * 插件专属前缀(九段相对它命名)。
14
+ * 插件专属前缀(十段相对它命名)。
16
15
  *
17
16
  * 为什么必须带插件名:宿主 `connection.fetch.register` 对**重复的精确路径**直接抛错
18
17
  * (`connection: exact Fetch route "…" is already registered`),撞了就是整棵插件树 boot 失败——
@@ -20,11 +19,11 @@ import { homedir } from "node:os";
20
19
  */
21
20
  const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
22
21
  /**
23
- * 唯一的取数基础路径:宿主 `/api` + 插件前缀。九段 exact Fetch 路由、客户端探测与附件 URL
22
+ * 唯一的取数基础路径:宿主 `/api` + 插件前缀。十段 exact Fetch 路由、客户端探测与附件 URL
24
23
  * 都从它起;`SESSION_DELETE_API_PREFIX` 只作为段名的相对前缀与错误原文。
25
24
  */
26
25
  const OFFICIAL_SESSION_DELETE_API_PREFIX = `/api${SESSION_DELETE_API_PREFIX}`;
27
- /** 九个段名(相对前缀)。**每项 `{ path, method }` 两键**:某段用什么方法也是线上形状的一部分
26
+ /** 十段段名(相对前缀)。**每项 `{ path, method }` 两键**:某段用什么方法也是线上形状的一部分
28
27
  * (ADR-0003),家就在这一张表——路由记录(`src/api/route-table.ts`)逐行投影(`ROUTES.x.method`),
29
28
  * 浏览器半的 GET 段同样投影、写段把 `ROUTES.x.method` 交给 `src/client/remote.ts` 的 `post()`,
30
29
  * 两半都不逐段手写一份。
@@ -39,7 +38,8 @@ const OFFICIAL_SESSION_DELETE_API_PREFIX = `/api${SESSION_DELETE_API_PREFIX}`;
39
38
  * 这一段只负责"有多少、在哪、多大"。
40
39
  * - `attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
41
40
  * - `attachmentOrphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
42
- * - `attachmentClean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
41
+ * - `attachmentClean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。
42
+ * - `config`:三只可调项的读/写共用一段——空体读当前值,带字段写回 profile 的 patch 层。 */
43
43
  const ROUTES = {
44
44
  list: {
45
45
  path: "/list",
@@ -50,6 +50,11 @@ const ROUTES = {
50
50
  path: "/ping",
51
51
  method: "GET"
52
52
  },
53
+ /** 可调项:空体读(与 `/detail` 同法,用 POST 表达一个读);带字段写,写必须进到达序链。 */
54
+ config: {
55
+ path: "/config",
56
+ method: "POST"
57
+ },
53
58
  /** 单条详情(读回收站目录里那条会话自己的日志)。 */
54
59
  detail: {
55
60
  path: "/detail",
@@ -86,6 +91,22 @@ const ROUTES = {
86
91
  method: "POST"
87
92
  }
88
93
  };
94
+ /** 三只可调项的接受范围(闭区间整数)。Node 半的归一与浏览器半的表单共用这一格:
95
+ * 两处各写一份范围就会「表单说能存、宿主激活时说越界」。 */
96
+ const CONFIG_LIMITS = {
97
+ orphanPreviewLimit: {
98
+ min: 1,
99
+ max: 1e4
100
+ },
101
+ maxAttachmentBytes: {
102
+ min: 1,
103
+ max: 209715200
104
+ },
105
+ maxJsonRefBytes: {
106
+ min: 1,
107
+ max: 104857600
108
+ }
109
+ };
89
110
  /** 请求参数名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。
90
111
  * 附件段是 **query 参数**(GET 二进制响应,没有请求体)。 */
91
112
  const PARAMS = {
@@ -192,14 +213,21 @@ function carriedRollbackFailures(error) {
192
213
  }
193
214
  /** 原错误保留,回滚失败作为 `details.rollbackFailures` 上线;码仍按原错误的动词码,
194
215
  * 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
195
- * 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。 */
216
+ * 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。
217
+ * **原错误 details 的其余键原样保留**:孤儿清理的部分回执(`removed` / `freedBytes` /
218
+ * `failedSessionId`)就挂在它上面,整份替换会把已经发生的删除报成 0。 */
196
219
  function withRollbackFailures(error, failures) {
197
220
  const code = verbCodeOf(error) ?? "io";
198
221
  const carried = carriedRollbackFailures(error);
199
222
  const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
200
223
  const base = error instanceof Error ? error.message : String(error);
201
224
  const extra = failures.filter((failure) => !carried.includes(failure));
202
- return verbError(code, base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`, error, { rollbackFailures: merged });
225
+ const message = base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`;
226
+ const priorDetails = error?.details;
227
+ return verbError(code, message, error, {
228
+ ...priorDetails,
229
+ rollbackFailures: merged
230
+ });
203
231
  }
204
232
  //#endregion
205
233
  //#region src/remote/map-error.ts
@@ -922,6 +950,92 @@ async function memberMove(deps, presence, member, deletedAt, parentEntryId) {
922
950
  };
923
951
  }
924
952
  //#endregion
953
+ //#region src/ports/fs-port.ts
954
+ /**
955
+ * **缺席**只认 ENOENT 与 ENOTDIR("路径中间有一截不是目录"与"不存在"是同一种回答)。
956
+ *
957
+ * EACCES / EIO / ELOOP / 断挂载**不是缺席**:这些读数说明"我们看不见",不是"那里没有"。
958
+ * 破坏性动作(删目录、删附件对象、按"没有引用"清共享图)据此做决定,把读不出折成没有
959
+ * 就是数据消失;ADR-0039 是这条判据的裁决。
960
+ */
961
+ function isAbsentPath(error) {
962
+ const code = error?.code;
963
+ return code === "ENOENT" || code === "ENOTDIR";
964
+ }
965
+ /** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
966
+ * `this` 变成 undefined)。所以端口只做转发,递归在模块作用域里自调。 */
967
+ async function sizeOfDir(p) {
968
+ let total = 0;
969
+ for (const e of await readdir(p, { withFileTypes: true })) {
970
+ const child = join(p, e.name);
971
+ total += e.isDirectory() ? await sizeOfDir(child) : (await stat(child)).size;
972
+ }
973
+ return total;
974
+ }
975
+ /** 路径在不在:只有缺席两种错误回 `false`,其余读错误照抛。`exists` 与 `rename` 的目标检查共用。 */
976
+ async function pathPresent(p) {
977
+ try {
978
+ await stat(p);
979
+ return true;
980
+ } catch (error) {
981
+ if (isAbsentPath(error)) return false;
982
+ throw error;
983
+ }
984
+ }
985
+ function createFsPort() {
986
+ return {
987
+ exists: pathPresent,
988
+ async ensureDir(p) {
989
+ await mkdir(p, { recursive: true });
990
+ },
991
+ async rename(from, to) {
992
+ if (await pathPresent(to)) throw new Error(`目标已存在,拒绝覆盖:${to}`);
993
+ try {
994
+ await rename(from, to);
995
+ } catch (error) {
996
+ if (error.code === "EXDEV") throw new Error(`跨文件系统搬不动(sessions 与 session-trash 需在同一卷):${from} → ${to}`, { cause: error });
997
+ throw error;
998
+ }
999
+ },
1000
+ async removeDir(p) {
1001
+ await rm(p, {
1002
+ recursive: true,
1003
+ force: true
1004
+ });
1005
+ },
1006
+ sizeOfDir: (p) => sizeOfDir(p),
1007
+ async listFiles(p) {
1008
+ return readdir(p);
1009
+ },
1010
+ async listDirs(p) {
1011
+ const dirs = [];
1012
+ for (const entry of await readdir(p, { withFileTypes: true })) {
1013
+ if (entry.isDirectory()) {
1014
+ dirs.push(entry.name);
1015
+ continue;
1016
+ }
1017
+ if (!entry.isSymbolicLink()) continue;
1018
+ try {
1019
+ if ((await stat(join(p, entry.name))).isDirectory()) dirs.push(entry.name);
1020
+ } catch {}
1021
+ }
1022
+ return dirs;
1023
+ },
1024
+ async realpath(p) {
1025
+ return realpath(p);
1026
+ },
1027
+ async readFile(p) {
1028
+ return readFile(p);
1029
+ },
1030
+ async statFile(p) {
1031
+ return { size: (await stat(p)).size };
1032
+ },
1033
+ async removeFile(p) {
1034
+ await rm(p, { force: true });
1035
+ }
1036
+ };
1037
+ }
1038
+ //#endregion
925
1039
  //#region src/log/zstd-frames.ts
926
1040
  const MAGIC = [
927
1041
  40,
@@ -1166,10 +1280,10 @@ function isRecord(value) {
1166
1280
  function numberOf(value) {
1167
1281
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
1168
1282
  }
1169
- /** `statFile` / `readFile` / `sizeOfDir` 里只有 ENOENT 能当"不在";权限、盘挂、I/O 错误都必须露成读不出。 */
1170
- function isMissing(error) {
1171
- return error?.code === "ENOENT";
1172
- }
1283
+ /** 路径缺席的判据只有一处:`src/ports/fs-port.ts` 的 `isAbsentPath`(ENOENT / ENOTDIR)。
1284
+ * `statFile` / `readFile` / `sizeOfDir` 的读错误里,"不在"只认这两枚;权限、盘挂、I/O 错误
1285
+ * 都必须露成读不出(ADR-0039),别在这里另写一份只有 ENOENT 的抄本。 */
1286
+ const isMissing = isAbsentPath;
1173
1287
  /** 合法附件 ID → 宿主附件库里的绝对路径。`name` 不参与拼接。 */
1174
1288
  function attachmentPathOf(home, kind, id) {
1175
1289
  if (!isAttachmentId(id)) throw new Error(`附件 id 形状不对:${id}`);
@@ -1403,6 +1517,32 @@ async function collectRemainingImageRefs(deps, input) {
1403
1517
  };
1404
1518
  }
1405
1519
  /**
1520
+ * 剩余引用扫描的 fail-closed 收口(独占计划 / 执行期复核 / 孤儿扫描三处共用同一形状):扫描抛错或
1521
+ * 任一引用源读不出都回 `reason`,读得出才交引用集——「读不出」绝不折成「没有引用」(ADR-0020;
1522
+ * 缺席判据见 ADR-0039)。`reasonLabel` 只补"这次是为什么扫"的前半句,后半句是读数原文。
1523
+ */
1524
+ async function remainingRefsFailClosed(deps, excludeDirs, reasonLabel) {
1525
+ try {
1526
+ const refs = await collectRemainingImageRefs(deps, {
1527
+ sessionRoot: `${deps.home}/sessions`,
1528
+ excludeDirs
1529
+ });
1530
+ if (refs.unreadable.length > 0) return {
1531
+ ok: false,
1532
+ reason: `${reasonLabel}:${refs.unreadable[0]}`
1533
+ };
1534
+ return {
1535
+ ok: true,
1536
+ refs
1537
+ };
1538
+ } catch (error) {
1539
+ return {
1540
+ ok: false,
1541
+ reason: `${reasonLabel}:${String(error)}`
1542
+ };
1543
+ }
1544
+ }
1545
+ /**
1406
1546
  * 独占计划:候选 = 本次被删会话日志里的图片 id;剩余引用 = 活动会话 + 全部未删 `session-trash`
1407
1547
  * 目录 + storages/cache。
1408
1548
  * 剩余引用源读不出 ⇒ 整体跳过;候选日志读不出 / 附件对象 stat 失败 ⇒ 只跳过那一部分,其余照常。
@@ -1425,24 +1565,13 @@ async function planImageAttachmentRemoval(deps, removedDirs) {
1425
1565
  bytes: 0,
1426
1566
  ...skipReasons.length > 0 ? { skipped: skipReasons.join(";") } : {}
1427
1567
  };
1428
- let remaining;
1429
- try {
1430
- remaining = await collectRemainingImageRefs(deps, {
1431
- sessionRoot: `${deps.home}/sessions`,
1432
- excludeDirs: removedDirs
1433
- });
1434
- } catch (error) {
1435
- return {
1436
- entries: [],
1437
- bytes: 0,
1438
- skipped: `读不到剩余引用源,无法证明独占:${String(error)}`
1439
- };
1440
- }
1441
- if (remaining.unreadable.length > 0) return {
1568
+ const scanned = await remainingRefsFailClosed(deps, removedDirs, "读不到剩余引用源,无法证明独占");
1569
+ if (!scanned.ok) return {
1442
1570
  entries: [],
1443
1571
  bytes: 0,
1444
- skipped: `读不到剩余引用源,无法证明独占:${remaining.unreadable[0]}`
1572
+ skipped: scanned.reason
1445
1573
  };
1574
+ const remaining = scanned.refs;
1446
1575
  const entries = [];
1447
1576
  let bytes = 0;
1448
1577
  let unreadableObjects = 0;
@@ -1469,6 +1598,31 @@ async function planImageAttachmentRemoval(deps, removedDirs) {
1469
1598
  };
1470
1599
  }
1471
1600
  /**
1601
+ * 执行期复核:计划与真删对象之间隔着一整趟目录删除(秒级),这期间别的会话可能把同一张图
1602
+ * (内容寻址 ⇒ 同一个 sha256 id)再引用一次,或派生存储新写一条引用。计划是数据、会过期;
1603
+ * 删对象之前用同一套剩余引用扫描再判一次,**只删这一刻仍无引用的那些**(ADR-0041)。
1604
+ *
1605
+ * 调用时机:会话 / 回收站目录已经删掉之后、`executeImageAttachmentRemoval` 之前——那时被删的
1606
+ * 引用源已经不在了,扫到的就是真正的"剩余"。读不出照旧 fail closed:回空计划 + `skipped`,
1607
+ * 宁可留下孤儿也不删共享。空计划直接返回(不为零删付一次全 home 扫描)。
1608
+ */
1609
+ async function recheckImageRemovalExclusivity(deps, plan) {
1610
+ if (plan.entries.length === 0) return plan;
1611
+ const scanned = await remainingRefsFailClosed(deps, [], "读不到剩余引用源,无法复核独占");
1612
+ if (!scanned.ok) return {
1613
+ entries: [],
1614
+ bytes: 0,
1615
+ skipped: plan.skipped === void 0 ? scanned.reason : `${plan.skipped};${scanned.reason}`
1616
+ };
1617
+ const remaining = scanned.refs;
1618
+ const entries = plan.entries.filter((entry) => !remaining.ids.has(entry.id));
1619
+ return {
1620
+ entries,
1621
+ bytes: entries.reduce((total, entry) => total + entry.bytes, 0),
1622
+ ...plan.skipped === void 0 ? {} : { skipped: plan.skipped }
1623
+ };
1624
+ }
1625
+ /**
1472
1626
  * 逐文件删;ENOENT 由端口的 `force` 吞掉;其他错误计数并继续。
1473
1627
  *
1474
1628
  * 删除动作只认「`home` + `id` 自己推导出来的那一条路径」:计划是数据,手搭的/被改过的条目
@@ -1521,24 +1675,13 @@ const OBJECT_NAME = /^[0-9a-f]{64}$/u;
1521
1675
  * 对象列表或 stat 读不出同样整份跳过:报告宁可失败,也不给一份"少算了一些"的清理计划。
1522
1676
  */
1523
1677
  async function scanOrphanImageAttachments(deps) {
1524
- let remaining;
1525
- try {
1526
- remaining = await collectRemainingImageRefs(deps, {
1527
- sessionRoot: `${deps.home}/sessions`,
1528
- excludeDirs: []
1529
- });
1530
- } catch (error) {
1531
- return {
1532
- entries: [],
1533
- bytes: 0,
1534
- skipped: `读不到引用源,无法判定孤儿附件:${String(error)}`
1535
- };
1536
- }
1537
- if (remaining.unreadable.length > 0) return {
1678
+ const scanned = await remainingRefsFailClosed(deps, [], "读不到引用源,无法判定孤儿附件");
1679
+ if (!scanned.ok) return {
1538
1680
  entries: [],
1539
1681
  bytes: 0,
1540
- skipped: `读不到引用源,无法判定孤儿附件:${remaining.unreadable[0]}`
1682
+ skipped: scanned.reason
1541
1683
  };
1684
+ const remaining = scanned.refs;
1542
1685
  const objectsRoot = `${deps.home}/attachments/v1/objects`;
1543
1686
  const entries = [];
1544
1687
  let bytes = 0;
@@ -1597,9 +1740,9 @@ async function cleanOrphanImageAttachments(deps) {
1597
1740
  * purge / orphan-clean 的删除计划({@link planMemberRemovals})才付——executor 摘账本要用它。
1598
1741
  */
1599
1742
  /** `exists → sizeOfDir` 的唯一一条度量口径(成员目录与回收站目录共用):先问在不在,再量字节;
1600
- * ENOENT 是唯一一种当"不在"的失败,别的错误照旧响亮。别再在调用点各写一遍这段 try/catch。
1743
+ * 缺席(ENOENT / ENOTDIR)才是"不在",别的错误照旧响亮(ADR-0039)。别再在调用点各写一遍这段 try/catch。
1601
1744
  *
1602
- * **已知粒度**:`sizeOfDir` 递归里撞到的 ENOENT 与"目录本身没了"是同一个码,所以目录里某个文件
1745
+ * **已知粒度**:`sizeOfDir` 递归里撞到的缺席与"目录本身没了"是同一个码,所以目录里某个文件
1603
1746
  * 恰好在这一拍消失时,整条目录会被记成「不在」(`/list` 少算这条、删除计划里没有可删的字节,
1604
1747
  * 而清册行照旧摘——盘上留下目录等下一次 `reconcile` 重建成行,自愈、不丢数据)。复核一次
1605
1748
  * `exists(dir)` 能把两者分开,却会让「宿主正在写、文件刚被挪走」这类正常竞态把 `/list` 整个打红——
@@ -1811,15 +1954,20 @@ async function planOrphanRemoval(deps, members) {
1811
1954
  *
1812
1955
  * 顺序:整批摘归档集 → 摘有账本的成员的账本 → 逐条回收站行
1813
1956
  * (在才删目录 → 摘清册行 → 通告成员离场)→ 逐条盘上成员(删目录 → 通告成员离场)→ 逐条附件对象。
1814
- * 目录删掉就没有"退回去"这回事,中途失败**不回滚**;已经删掉的由下面的计数器如实交回。
1815
- *
1816
- * 附件清理排在**所有会话目录删完之后**:对象只在会话真的没了之后才动;单文件失败只进
1817
- * `attachments.failed`,不影响(也不回滚)会话删除的回执。
1957
+ * 目录删掉就没有"退回去"这回事,中途失败**不回滚目录**;已经删掉的由下面的计数器如实交回。
1958
+ * **归档态回滚**:`unarchive` 排在所有删除之前(免得留下"会话没了、归档集还挂着"的幽灵 id),
1959
+ * 所以中途失败时目录还在、归档位却已经摘了。失败出口把**目录还在**的那些(这一趟没删掉、也不是计划时
1960
+ * 就缺席)按删除前的读数重新归档(`isArchived` 先读过一次,不是按 `wasArchived` 猜);目录不在的跳过
1961
+ * ——那时恢复归档只会造幽灵 id。
1962
+ * 回滚自己的失败走 `details.rollbackFailures`,不让它把原错误吞掉(ADR-0040)。
1963
+ * **附件计划在执行期复核**:计划到删对象之间的窗口里新增的引用会让计划过期;删对象前用同一套
1964
+ * 剩余引用扫描重判一次,只删这一刻仍无引用的对象(ADR-0041)。
1818
1965
  *
1819
1966
  * 失败形状:
1820
1967
  * - `kind:'orphan'`(orphan-clean)⇒ `io` + `{removed, freedBytes, failedSessionId}`,不伪装成整组没动;
1821
1968
  * - `kind:'trash'`(purge)⇒ 同一个标签、不带 details;
1822
- * - orphan-clean 的 unarchive / detach 原样抛出。
1969
+ * - orphan-clean 的归档实况预读 / unarchive / detach 原样抛出;
1970
+ * - 归档态回滚不干净时,原错误带 `details.rollbackFailures`(码仍按原错误的动词码)。
1823
1971
  *
1824
1972
  * 通告只经 `member-presence`(摘活体三态 + `api-session/removed`),不在这里另写一份。
1825
1973
  */
@@ -1841,7 +1989,7 @@ async function executeRemoval(deps, plan) {
1841
1989
  } : void 0;
1842
1990
  throw verbError("io", `${label}:${String(error)}`, error, details);
1843
1991
  };
1844
- /** 退归档集 / 摘账本这一步的失败形状:purge 折成带标签的 `io`;orphan-clean 原样抛。 */
1992
+ /** 归档实况预读 / 退归档集 / 摘账本这一步的失败形状:purge 折成带标签的 `io`;orphan-clean 原样抛。 */
1845
1993
  const step = async (label, sessionId, run) => {
1846
1994
  if (plan.kind === "orphan") {
1847
1995
  await run();
@@ -1853,49 +2001,79 @@ async function executeRemoval(deps, plan) {
1853
2001
  fail(label, error, sessionId);
1854
2002
  }
1855
2003
  };
1856
- for (const sessionId of plan.unarchive) await step("取消归档失败", sessionId, () => deps.host.unarchive(sessionId));
1857
- for (const member of plan.members) {
1858
- const workspaceId = member.workspaceId;
1859
- if (workspaceId === void 0) continue;
1860
- await step("摘账本失败", member.sessionId, () => deps.host.detach(member.sessionId, workspaceId));
1861
- }
1862
- for (const row of plan.trash) {
1863
- if (row.present) {
2004
+ const archivedBefore = /* @__PURE__ */ new Set();
2005
+ for (const sessionId of plan.unarchive) await step("读取归档状态失败", sessionId, async () => {
2006
+ if (await deps.host.isArchived(sessionId)) archivedBefore.add(sessionId);
2007
+ });
2008
+ /** 走到过 `unarchive` 这一步的 id(含失败那一条):失败时按它回滚。 */
2009
+ const unarchived = [];
2010
+ /** 计划时目录还在的 id:只有这些会话还有本体可挂归档。计划时就缺席的行 / 成员也在 `unarchive`
2011
+ * 名单里(那一步顺带清幽灵 id),但没有目录可回滚。 */
2012
+ const onDisk = /* @__PURE__ */ new Set([...plan.trash.filter((row) => row.present).map((row) => row.entry.sessionId), ...plan.members.map((member) => member.sessionId)]);
2013
+ /** 目录已经真删掉的 id:没有归档态可放回去,跳过——否则会在 registry 里留下幽灵 id。 */
2014
+ const deleted = /* @__PURE__ */ new Set();
2015
+ try {
2016
+ for (const sessionId of plan.unarchive) {
2017
+ unarchived.push(sessionId);
2018
+ await step("取消归档失败", sessionId, () => deps.host.unarchive(sessionId));
2019
+ }
2020
+ for (const member of plan.members) {
2021
+ const workspaceId = member.workspaceId;
2022
+ if (workspaceId === void 0) continue;
2023
+ await step("摘账本失败", member.sessionId, () => deps.host.detach(member.sessionId, workspaceId));
2024
+ }
2025
+ for (const row of plan.trash) {
2026
+ if (row.present) {
2027
+ try {
2028
+ await deps.fs.removeDir(row.dir);
2029
+ } catch (error) {
2030
+ fail("删除回收站目录失败", error, row.entry.sessionId);
2031
+ }
2032
+ deleted.add(row.entry.sessionId);
2033
+ freedSoFar += row.bytes;
2034
+ }
1864
2035
  try {
1865
- await deps.fs.removeDir(row.dir);
2036
+ await deps.ledger.remove(row.entry.id);
1866
2037
  } catch (error) {
1867
- fail("删除回收站目录失败", error, row.entry.sessionId);
2038
+ fail("摘除回收站条目失败", error, row.entry.sessionId);
1868
2039
  }
1869
- freedSoFar += row.bytes;
2040
+ removed += 1;
2041
+ await presence.memberGone(row.entry.sessionId);
1870
2042
  }
1871
- try {
1872
- await deps.ledger.remove(row.entry.id);
1873
- } catch (error) {
1874
- fail("摘除回收站条目失败", error, row.entry.sessionId);
2043
+ const memberLabel = plan.kind === "trash" ? "删除盘上后代目录失败" : "删除会话目录失败";
2044
+ for (const member of plan.members) {
2045
+ try {
2046
+ await deps.fs.removeDir(member.dir);
2047
+ } catch (error) {
2048
+ fail(memberLabel, error, member.sessionId);
2049
+ }
2050
+ deleted.add(member.sessionId);
2051
+ removed += 1;
2052
+ freedSoFar += member.bytes;
2053
+ await presence.memberGone(member.sessionId);
1875
2054
  }
1876
- removed += 1;
1877
- await presence.memberGone(row.entry.sessionId);
1878
- }
1879
- const memberLabel = plan.kind === "trash" ? "删除盘上后代目录失败" : "删除会话目录失败";
1880
- for (const member of plan.members) {
1881
- try {
1882
- await deps.fs.removeDir(member.dir);
1883
- } catch (error) {
1884
- fail(memberLabel, error, member.sessionId);
2055
+ const attachmentPlan = await recheckImageRemovalExclusivity(deps, plan.attachments);
2056
+ const attachments = await executeImageAttachmentRemoval(deps, attachmentPlan);
2057
+ return {
2058
+ removed,
2059
+ freedBytes: plan.bytes,
2060
+ attachments: {
2061
+ ...attachments,
2062
+ ...attachmentPlan.skipped === void 0 ? {} : { skipped: attachmentPlan.skipped }
2063
+ }
2064
+ };
2065
+ } catch (error) {
2066
+ const rollbackFailures = [];
2067
+ for (const sessionId of [...unarchived].reverse()) {
2068
+ if (!archivedBefore.has(sessionId) || deleted.has(sessionId) || !onDisk.has(sessionId)) continue;
2069
+ try {
2070
+ await deps.host.archive(sessionId);
2071
+ } catch (rollbackError) {
2072
+ rollbackFailures.push(rollbackReason("恢复归档失败", rollbackError));
2073
+ }
1885
2074
  }
1886
- removed += 1;
1887
- freedSoFar += member.bytes;
1888
- await presence.memberGone(member.sessionId);
2075
+ throw rollbackFailures.length === 0 ? error : withRollbackFailures(error, rollbackFailures);
1889
2076
  }
1890
- const attachments = await executeImageAttachmentRemoval(deps, plan.attachments);
1891
- return {
1892
- removed,
1893
- freedBytes: plan.bytes,
1894
- attachments: {
1895
- ...attachments,
1896
- ...plan.attachments.skipped === void 0 ? {} : { skipped: plan.attachments.skipped }
1897
- }
1898
- };
1899
2077
  }
1900
2078
  //#endregion
1901
2079
  //#region src/verbs/orphan-subagents.ts
@@ -2002,6 +2180,7 @@ async function cleanOrphanGroup(deps, group) {
2002
2180
  //#endregion
2003
2181
  //#region src/ports/agent-handles.ts
2004
2182
  const CAPTURED_AGENT_HANDLES = Symbol.for("dsh-session-delete.captured-agent-handles");
2183
+ const CAPTURED_AGENT_WRAPPERS = Symbol.for("dsh-session-delete.captured-agent-wrappers");
2005
2184
  /** 在 `ctx.agents` 上包一层 `resume` / `create`,把返回的 agent handle 的 dispose 按会话 id 记下。
2006
2185
  * 服务缺席时走 `ctx.inject(['agents'])` **等服务挂上再装**——live profile 的插件激活顺序可能早于
2007
2186
  * `agents` 服务就绪;只在 apply 时 `get` 一次会让捕获静默缺席(真机症状:重启前在回收站的能冷恢复,
@@ -2010,14 +2189,46 @@ const CAPTURED_AGENT_HANDLES = Symbol.for("dsh-session-delete.captured-agent-han
2010
2189
  * **安装时机**:先 `get('agents')`,没装成再 `inject(['agents'])`(两者只装一次,符号表兜住)。
2011
2190
  * **symbol 表**:句柄表挂在服务实例的 {@link CAPTURED_AGENT_HANDLES} 上;同一个 agents 服务上的第二次
2012
2191
  * 安装(`get` 与 `inject` 各一次,或第二个 `createHostPort`)看见它就直接共用,不把 wrapper 套第二层。
2192
+ * **安装者拥有 wrapper**:包装记录先在闭包里留一份(`ownWrappers`,建立于 symbol 写入之前),同时挂到
2193
+ * {@link CAPTURED_AGENT_WRAPPERS};第一次成功安装时经 `ctx.effect` 注册一条卸载恢复——按 own property
2194
+ * descriptor 的身份把 `resume` / `create` 写回目标自有的原函数(`restoreTo`;代理读回的影子不写回,
2195
+ * 别人后装的 wrapper 不动),并摘掉仍归本闭包的 symbol 表。恢复是总的:一条写回失败不跳过其余记录。
2196
+ * 恢复一次即锁死:晚到的安装与晚到的 handle 落地都被 `restored` 挡住。没有 `ctx.effect`(假件 / 旧 ctx)
2197
+ * 时包装照装,只是没有恢复。
2013
2198
  * **读回判据**:cordis 的 traceable 服务代理每次读函数都会新包一层(`get` 里的 `createShadowMethod`),
2014
2199
  * 所以只能看 own property descriptor 的身份;拿 `service[name]` 读回比身份是必然的假警报。
2015
2200
  */
2016
2201
  function captureAgentHandles(ctx) {
2017
2202
  let handles = /* @__PURE__ */ new Map();
2203
+ /** 本闭包装上的包装记录:symbol 写入是 best-effort,恢复不能只靠它。 */
2204
+ let ownWrappers;
2018
2205
  let installed = false;
2206
+ /** 卸载恢复只跑一次;同时是「清理之后不再安装、不再捕获」的闸。 */
2207
+ let restored = false;
2208
+ /**
2209
+ * 卸载恢复:按记录倒序把仍是我们装的方法写回 `restoreTo`,再摘掉仍是本闭包那张表的 symbol 并清空
2210
+ * 句柄表。身份判据挡两件事:别人后装的 wrapper 不被覆盖;别人换过的 symbol 表不被误删。
2211
+ * 恢复必须走完:一条写回失败只跳过那一条,摘 symbol 与清表在 `finally` 里照做。
2212
+ */
2213
+ const restore = (service) => {
2214
+ if (restored) return;
2215
+ restored = true;
2216
+ const records = ownWrappers ?? service[CAPTURED_AGENT_WRAPPERS] ?? [];
2217
+ try {
2218
+ for (const record of [...records].reverse()) try {
2219
+ if (Object.getOwnPropertyDescriptor(service, record.name)?.value === record.wrapped) service[record.name] = record.restoreTo;
2220
+ } catch {}
2221
+ } finally {
2222
+ try {
2223
+ if (service[CAPTURED_AGENT_HANDLES] === handles) Reflect.deleteProperty(service, CAPTURED_AGENT_HANDLES);
2224
+ if (service[CAPTURED_AGENT_WRAPPERS] === records) Reflect.deleteProperty(service, CAPTURED_AGENT_WRAPPERS);
2225
+ } finally {
2226
+ handles.clear();
2227
+ }
2228
+ }
2229
+ };
2019
2230
  const install = (candidate) => {
2020
- if (installed) return;
2231
+ if (installed || restored) return;
2021
2232
  const service = candidate;
2022
2233
  if (service === void 0 || service === null) return;
2023
2234
  const existing = service[CAPTURED_AGENT_HANDLES];
@@ -2026,13 +2237,17 @@ function captureAgentHandles(ctx) {
2026
2237
  installed = true;
2027
2238
  return;
2028
2239
  }
2240
+ const wrappers = [];
2029
2241
  let wrappedCount = 0;
2030
2242
  for (const name of ["resume", "create"]) try {
2031
2243
  const original = service[name];
2032
2244
  if (typeof original !== "function") continue;
2245
+ const own = Object.getOwnPropertyDescriptor(service, name)?.value;
2246
+ const restoreTo = typeof own === "function" ? own : original;
2033
2247
  const wrapped = function(...args) {
2034
2248
  const result = Reflect.apply(original, this, args);
2035
2249
  result.then((value) => {
2250
+ if (restored) return;
2036
2251
  const handle = value;
2037
2252
  const id = handle?.agent?.id;
2038
2253
  const dispose = handle?.dispose;
@@ -2045,13 +2260,24 @@ function captureAgentHandles(ctx) {
2045
2260
  };
2046
2261
  service[name] = wrapped;
2047
2262
  if (Object.getOwnPropertyDescriptor(service, name)?.value !== wrapped) throw new Error(`agents.${name} is not writable`);
2263
+ wrappers.push({
2264
+ name,
2265
+ original,
2266
+ restoreTo,
2267
+ wrapped
2268
+ });
2048
2269
  wrappedCount += 1;
2049
2270
  } catch {}
2050
2271
  if (wrappedCount === 0) return;
2272
+ ownWrappers = wrappers;
2051
2273
  try {
2052
2274
  service[CAPTURED_AGENT_HANDLES] = handles;
2275
+ service[CAPTURED_AGENT_WRAPPERS] = wrappers;
2053
2276
  } catch {}
2054
2277
  installed = true;
2278
+ try {
2279
+ ctx.effect?.(() => () => restore(service), "session-delete.agent-handles");
2280
+ } catch {}
2055
2281
  };
2056
2282
  const service = ctx.get("agents");
2057
2283
  if (service !== void 0 && service !== null) install(service);
@@ -2079,6 +2305,21 @@ function liveSessionsOf(ctx) {
2079
2305
  if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
2080
2306
  return store;
2081
2307
  }
2308
+ /**
2309
+ * 会话 header 的**公开**读面:live `sessions.get(id).header` 优先(保留宿主原来的优先级),
2310
+ * 否则 `sessionPersistence.stat(id).header`。未知会话回 `undefined`;需要落到持久化时服务缺席 /
2311
+ * 形状不符抛错,由调用方决定是响亮失败(projectDirOf)还是降级(titleOf 的空串)。
2312
+ */
2313
+ async function headerOf(host, sessionId) {
2314
+ const store = liveSessionsOf(host);
2315
+ if (store !== void 0) try {
2316
+ const live = store.get(sessionId);
2317
+ if (live?.header !== void 0) return live.header;
2318
+ } catch {}
2319
+ const persistence = host.get("sessionPersistence");
2320
+ if (persistence === void 0 || persistence === null || typeof persistence.stat !== "function") throw new Error("session-delete: sessionPersistence 服务缺席");
2321
+ return (await persistence.stat(sessionId))?.header;
2322
+ }
2082
2323
  /** 官方的「这些父的孩子」那一支:`kind:'parent'` 的判据逐字是 `values.includes(record.header.parentSession ?? null)`。
2083
2324
  * 它只按 `parentSession` 原样匹配,**不是**子会话分类器:旧宿主(≤0.2.1-rc.5)的普通 fork 也写
2084
2325
  * `parentSession`,0.2.2-alpha.2 起才不写。子会话的判据是 `origin` 的 subagent 标记(宿主归档闸、归档
@@ -2174,15 +2415,6 @@ function projectionCacheOf(ctx) {
2174
2415
  if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
2175
2416
  return cache;
2176
2417
  }
2177
- /** 宿主 `readSessionHeader` 对未知会话抛的是裸 `Error`,只认这句;真存储故障照抛,不能折成未命中。
2178
- * 出处:asar `dsh-workspace/lib/types/index.js` 的
2179
- * `cannot validate session '${id}': session persistence holds no such session`。
2180
- * 同文件有类型的近似句是 `WorkspaceUnknownSessionError`(`hold` 无 `s`),不会误伤。 */
2181
- const NO_SUCH_SESSION = "session persistence holds no such session";
2182
- /** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
2183
- function isUnknownSession(error) {
2184
- return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
2185
- }
2186
2418
  /** 宿主会话表的一次取数(`ctx.sessionController.list()` 的两种返回形状都折成数组)。
2187
2419
  * 服务没挂 / 形状不对 / 抛错 ⇒ 空表——**这条不替宿主兜错误**:调用方拿空表的语义都是"少发一条通告",
2188
2420
  * 不是"恢复失败"。 */
@@ -2223,23 +2455,20 @@ function createHostPort(ctx) {
2223
2455
  },
2224
2456
  /**
2225
2457
  * cwd → 宿主项目目录名;没有 cwd 落到 `_no-cwd`。
2226
- * 账本里没有这个会话回 `undefined`(未命中,不是故障);真存储故障照抛。
2458
+ * header 走公开面(live `sessions` → `sessionPersistence.stat`);未知会话回 `undefined`
2459
+ * (未命中,不是故障);服务缺席/真存储故障照抛。
2227
2460
  * 未命中由 `delete-session.ts` 折成 `not-found`,真故障留 `io`——两者不能混。
2228
2461
  */
2229
2462
  async projectDirOf(sessionId) {
2230
- let header;
2231
- try {
2232
- header = await registry.readSessionHeader(sessionId);
2233
- } catch (error) {
2234
- if (isUnknownSession(error)) return void 0;
2235
- throw error;
2236
- }
2463
+ const header = await headerOf(host, sessionId);
2464
+ if (header === void 0) return void 0;
2237
2465
  return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
2238
2466
  },
2239
- /** 标题走宿主投影缓存;尽力取,取不到返回空串,绝不因此让删除失败。 */
2467
+ /** 标题走宿主投影缓存;header 来自公开的 live/持久化读面,尽力取,取不到返回空串。 */
2240
2468
  async titleOf(sessionId) {
2241
2469
  try {
2242
- const header = await registry.readSessionHeader(sessionId);
2470
+ const header = await headerOf(host, sessionId);
2471
+ if (header === void 0) return "";
2243
2472
  const cache = projectionCacheOf(host);
2244
2473
  const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
2245
2474
  return typeof title === "string" ? title : "";
@@ -2673,6 +2902,111 @@ function applyActions(deps, action, ids) {
2673
2902
  }
2674
2903
  }
2675
2904
  //#endregion
2905
+ //#region src/config.ts
2906
+ /**
2907
+ * 可调项(Config,ADR-0037):三只旋钮 + 本地 Standard Schema。
2908
+ *
2909
+ * 为什么不用 `@deepseek-ai/schemastery` 写 schema:那是宿主包,值 import 会破本仓「跨插件一律
2910
+ * `import type`」的硬约束;Cordis 的 Config 接受 Standard Schema(`@standard-schema/spec`),
2911
+ * 手写这一小段即可,不需要把宿主 schema 库搬进插件。代价是宿主只为 native Schemastery schema 投影
2912
+ * 字段表单,这枚手写的 Standard Schema 不在其列——字段表单由插件设置页的「可调项」卡提供
2913
+ * (ADR-0043),profile 的 `cordis.patch.yml` 仍是默认值与覆盖的家。
2914
+ *
2915
+ * 这里只做**默认值、归一与 schema**,不持有运行期现场:`apply` 归一一次,结果作为
2916
+ * `CoreDeps.config` 注入;读方从自己的 deps 读,不 import 模块级单例。
2917
+ * 客户端 6 秒撤销时长不在这里:它是浏览器半的产品决定,不跨半传。
2918
+ */
2919
+ const DEFAULT_CONFIG = Object.freeze({
2920
+ orphanPreviewLimit: 200,
2921
+ maxAttachmentBytes: 20971520,
2922
+ maxJsonRefBytes: 10485760
2923
+ });
2924
+ function intField(input, key, range) {
2925
+ const value = input[key];
2926
+ if (value === void 0) return DEFAULT_CONFIG[key];
2927
+ if (typeof value !== "number" || !Number.isInteger(value) || value < range.min || value > range.max) throw new Error(`配置项 ${key} 需为 ${range.min}..${range.max} 的整数`);
2928
+ return value;
2929
+ }
2930
+ /**
2931
+ * 把 patch 里的 `config` 归一成完整配置:缺省取默认值,未知键 / 非整数 / 越界都抛。
2932
+ * 抛错会让插件激活失败(响亮),而不是悄悄用半套配置跑。
2933
+ */
2934
+ function normalizeConfig(value) {
2935
+ if (value === void 0 || value === null) return DEFAULT_CONFIG;
2936
+ if (typeof value !== "object" || Array.isArray(value)) throw new Error("配置需为对象");
2937
+ const input = value;
2938
+ const known = new Set(Object.keys(CONFIG_LIMITS));
2939
+ for (const key of Object.keys(input)) if (!known.has(key)) throw new Error(`未知配置项:${key}`);
2940
+ return {
2941
+ orphanPreviewLimit: intField(input, "orphanPreviewLimit", CONFIG_LIMITS.orphanPreviewLimit),
2942
+ maxAttachmentBytes: intField(input, "maxAttachmentBytes", CONFIG_LIMITS.maxAttachmentBytes),
2943
+ maxJsonRefBytes: intField(input, "maxJsonRefBytes", CONFIG_LIMITS.maxJsonRefBytes)
2944
+ };
2945
+ }
2946
+ /**
2947
+ * 插件 Config:Cordis 接受 Standard Schema,`~standard.validate` 交回归一后的配置。
2948
+ * 宿主在激活前校验;`apply` 再归一一次(同一份事实,不会漂)。
2949
+ */
2950
+ const Config = { "~standard": {
2951
+ version: 1,
2952
+ vendor: "dsh-session-delete",
2953
+ validate(value) {
2954
+ try {
2955
+ return { value: normalizeConfig(value) };
2956
+ } catch (error) {
2957
+ return { issues: [{ message: error instanceof Error ? error.message : String(error) }] };
2958
+ }
2959
+ }
2960
+ } };
2961
+ //#endregion
2962
+ //#region src/verbs/config.ts
2963
+ /**
2964
+ * `/config` 的读/写动词:三只可调项的当前值与写回。
2965
+ *
2966
+ * 读是免费快照(`apply` 归一后的那一份),写才碰宿主:
2967
+ * - 先按 `src/config.ts` 的 `normalizeConfig` 校验(未知键 / 非整数 / 越界回 400,不拿半套值写盘);
2968
+ * - 再走宿主 `configEditor.edit(entry, change)`:完整 raw config 落进 profile 的 patch 层,
2969
+ * 由编辑器串行化、校验并让这一行重启(字段不是 volatile,重启是它给的语义)。
2970
+ * - `configEditor` / `entry` 缺席(老宿主、嵌套上下文)回 `io` 并在原文里说清替代路径——
2971
+ * 不静默假装保存成功。
2972
+ *
2973
+ * `entry` 由组合根从 `ctx.fiber.entry` 取(见 `src/index.ts`);本文件不碰 ctx。
2974
+ */
2975
+ /** `/config` 的请求体:`null` 或 `{}` 是读;只要带一个键就当写(未知键由 {@link normalizeConfig} 400 拒掉)。
2976
+ * 路由表按同一句话定纯度,所以别在别处再写一遍这个判据。 */
2977
+ function configPatchOf(body) {
2978
+ if (body === null || Object.keys(body).length === 0) return void 0;
2979
+ return body;
2980
+ }
2981
+ /** 空体 = 读当前值;带字段 = 写。请求体已经由适配器解析成对象或 null。 */
2982
+ function readOrSaveConfig(deps, body) {
2983
+ const patch = configPatchOf(body);
2984
+ if (patch === void 0) return deps.config;
2985
+ return saveConfig(deps, patch);
2986
+ }
2987
+ async function saveConfig(deps, patch) {
2988
+ let next;
2989
+ try {
2990
+ next = normalizeConfig({
2991
+ ...deps.config,
2992
+ ...patch
2993
+ });
2994
+ } catch (error) {
2995
+ throw routeError("bad-request", 400, error instanceof Error ? error.message : String(error));
2996
+ }
2997
+ const editor = deps.configEditor;
2998
+ if (editor === void 0 || deps.entry === void 0) throw verbError("io", "这一代宿主不能保存配置;请直接改 profile 的 cordis.patch.yml");
2999
+ try {
3000
+ await editor.edit(deps.entry, (current) => ({
3001
+ ...current,
3002
+ ...next
3003
+ }));
3004
+ } catch (error) {
3005
+ throw verbError("io", `配置没有保存:${error instanceof Error ? error.message : String(error)}`, error);
3006
+ }
3007
+ return next;
3008
+ }
3009
+ //#endregion
2676
3010
  //#region src/trash/detail.ts
2677
3011
  /**
2678
3012
  * 回收站**单条详情**:从回收站目录里那条会话自己的日志读出元信息与正文块(用例见 `tests/trash-detail.test.ts`)。
@@ -3440,7 +3774,7 @@ function requireIdArray(body) {
3440
3774
  return value;
3441
3775
  }
3442
3776
  /**
3443
- * 九个段名各一行。`Record<段名, AnyRouteSpec>` 是闸:`wire.ts` 加了段名而这里没加,编译不过;
3777
+ * 十个段名各一行。`Record<段名, AnyRouteSpec>` 是闸:`wire.ts` 加了段名而这里没加,编译不过;
3444
3778
  * 每行的 `slice` 又把该行的 `run` 钉在自己那一格 deps 上(ADR-0032)。
3445
3779
  */
3446
3780
  const ROUTE_TABLE = {
@@ -3462,6 +3796,15 @@ const ROUTE_TABLE = {
3462
3796
  response: "json",
3463
3797
  run: async () => "pong"
3464
3798
  },
3799
+ [ROUTES.config.path]: {
3800
+ slice: "config",
3801
+ method: ROUTES.config.method,
3802
+ body: "json",
3803
+ purity: (body) => configPatchOf(body) === void 0 ? "pure" : "chained",
3804
+ read: "none",
3805
+ response: "json",
3806
+ run: async (deps, body) => readOrSaveConfig(deps, body)
3807
+ },
3465
3808
  [ROUTES.attachment.path]: {
3466
3809
  slice: "attachment",
3467
3810
  method: ROUTES.attachment.method,
@@ -3545,7 +3888,7 @@ function routeOf(rest) {
3545
3888
  /**
3546
3889
  * /dsh-session-delete-api 的分发核心:只负责路由记录的执行。
3547
3890
  *
3548
- * 传输适配器只有 `src/api/connection-route.ts` 一个:九段注册到宿主 `connection.fetch.register`
3891
+ * 传输适配器只有 `src/api/connection-route.ts` 一个:十段注册到宿主 `connection.fetch.register`
3549
3892
  * 的 exact Fetch 路由(`Request` / `Response`),Host/Origin/浏览器认证由宿主 `/api` bridge 完成
3550
3893
  * (ADR-0033)。`Request` 的字节闸 / JSON 解析、`Response` 的头与失败信封都归适配器;
3551
3894
  * 这里只收已经解析好的请求,做查表 → 按段注入窄面 → 跑处理 → 回结果。
@@ -3605,7 +3948,7 @@ function removalOf(full) {
3605
3948
  config: full.config
3606
3949
  };
3607
3950
  }
3608
- /** 四格只建一次:收窄对象在 dispatcher 构造期固定,请求路径只做引用选择。 */
3951
+ /** 五格只建一次:收窄对象在 dispatcher 构造期固定,请求路径只做引用选择。 */
3609
3952
  function slicesOf(full) {
3610
3953
  const core = coreOf(full);
3611
3954
  return {
@@ -3615,6 +3958,11 @@ function slicesOf(full) {
3615
3958
  attachment: {
3616
3959
  ...core,
3617
3960
  attachments: full.attachments
3961
+ },
3962
+ config: {
3963
+ ...core,
3964
+ ...full.configEditor === void 0 ? {} : { configEditor: full.configEditor },
3965
+ ...full.entry === void 0 ? {} : { entry: full.entry }
3618
3966
  }
3619
3967
  };
3620
3968
  }
@@ -3665,7 +4013,7 @@ function createDispatcher(deps) {
3665
4013
  //#endregion
3666
4014
  //#region src/api/connection-route.ts
3667
4015
  /**
3668
- * 现代宿主的 exact Fetch 路由适配器:把九段注册到 `connection.fetch.register`,
4016
+ * 现代宿主的 exact Fetch 路由适配器:把十段注册到 `connection.fetch.register`,
3669
4017
  * 由宿主 `/api` bridge 做 Host/Origin/浏览器认证(ADR-0033)。
3670
4018
  *
3671
4019
  * 为什么路径必须带 `/api`:宿主 `assertFetchRoute` 用 `endpointFromPath('/api', path)` 校验,
@@ -3797,11 +4145,14 @@ function toResponse(result) {
3797
4145
  });
3798
4146
  return new Response(JSON.stringify(result.body), {
3799
4147
  status: result.status,
3800
- headers: { "content-type": "application/json; charset=utf-8" }
4148
+ headers: {
4149
+ "content-type": "application/json; charset=utf-8",
4150
+ "cache-control": "private, no-store"
4151
+ }
3801
4152
  });
3802
4153
  }
3803
4154
  /**
3804
- * 注册九段 exact Fetch 路由。返回一个组合 disposer;调用方把它挂在自己的 effect 上。
4155
+ * 注册十段 exact Fetch 路由。返回一个组合 disposer;调用方把它挂在自己的 effect 上。
3805
4156
  * `connection` 是插件的硬依赖(web UI / 桌面端都装 `client-connection`);`fetch.register`
3806
4157
  * 缺席属于宿主形状漂移,直接抛——静默返回空 disposer 会让插件「在但没有入口」。
3807
4158
  * `dispatch` 与分发器同源。
@@ -3845,131 +4196,6 @@ function registerConnectionRoutes(service, dispatch) {
3845
4196
  return disposeAll;
3846
4197
  }
3847
4198
  //#endregion
3848
- //#region src/config.ts
3849
- const DEFAULT_CONFIG = Object.freeze({
3850
- orphanPreviewLimit: 200,
3851
- maxAttachmentBytes: 20971520,
3852
- maxJsonRefBytes: 10485760
3853
- });
3854
- const LIMITS = {
3855
- orphanPreviewLimit: {
3856
- min: 1,
3857
- max: 1e4
3858
- },
3859
- maxAttachmentBytes: {
3860
- min: 1,
3861
- max: 209715200
3862
- },
3863
- maxJsonRefBytes: {
3864
- min: 1,
3865
- max: 104857600
3866
- }
3867
- };
3868
- function intField(input, key, range) {
3869
- const value = input[key];
3870
- if (value === void 0) return DEFAULT_CONFIG[key];
3871
- if (typeof value !== "number" || !Number.isInteger(value) || value < range.min || value > range.max) throw new Error(`配置项 ${key} 需为 ${range.min}..${range.max} 的整数`);
3872
- return value;
3873
- }
3874
- /**
3875
- * 把 patch 里的 `config` 归一成完整配置:缺省取默认值,未知键 / 非整数 / 越界都抛。
3876
- * 抛错会让插件激活失败(响亮),而不是悄悄用半套配置跑。
3877
- */
3878
- function normalizeConfig(value) {
3879
- if (value === void 0 || value === null) return DEFAULT_CONFIG;
3880
- if (typeof value !== "object" || Array.isArray(value)) throw new Error("配置需为对象");
3881
- const input = value;
3882
- const known = new Set(Object.keys(LIMITS));
3883
- for (const key of Object.keys(input)) if (!known.has(key)) throw new Error(`未知配置项:${key}`);
3884
- return {
3885
- orphanPreviewLimit: intField(input, "orphanPreviewLimit", LIMITS.orphanPreviewLimit),
3886
- maxAttachmentBytes: intField(input, "maxAttachmentBytes", LIMITS.maxAttachmentBytes),
3887
- maxJsonRefBytes: intField(input, "maxJsonRefBytes", LIMITS.maxJsonRefBytes)
3888
- };
3889
- }
3890
- /**
3891
- * 插件 Config:Cordis 接受 Standard Schema,`~standard.validate` 交回归一后的配置。
3892
- * 宿主在激活前校验;`apply` 再归一一次(同一份事实,不会漂)。
3893
- */
3894
- const Config = { "~standard": {
3895
- version: 1,
3896
- vendor: "dsh-session-delete",
3897
- validate(value) {
3898
- try {
3899
- return { value: normalizeConfig(value) };
3900
- } catch (error) {
3901
- return { issues: [{ message: error instanceof Error ? error.message : String(error) }] };
3902
- }
3903
- }
3904
- } };
3905
- //#endregion
3906
- //#region src/ports/fs-port.ts
3907
- /** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
3908
- * `this` 变成 undefined)。所以端口只做转发,递归在模块作用域里自调。 */
3909
- async function sizeOfDir(p) {
3910
- let total = 0;
3911
- for (const e of await readdir(p, { withFileTypes: true })) {
3912
- const child = join(p, e.name);
3913
- total += e.isDirectory() ? await sizeOfDir(child) : (await stat(child)).size;
3914
- }
3915
- return total;
3916
- }
3917
- function createFsPort() {
3918
- return {
3919
- async exists(p) {
3920
- return existsSync(p);
3921
- },
3922
- async ensureDir(p) {
3923
- await mkdir(p, { recursive: true });
3924
- },
3925
- async rename(from, to) {
3926
- if (existsSync(to)) throw new Error(`目标已存在,拒绝覆盖:${to}`);
3927
- try {
3928
- await rename(from, to);
3929
- } catch (error) {
3930
- if (error.code === "EXDEV") throw new Error(`跨文件系统搬不动(sessions 与 session-trash 需在同一卷):${from} → ${to}`, { cause: error });
3931
- throw error;
3932
- }
3933
- },
3934
- async removeDir(p) {
3935
- await rm(p, {
3936
- recursive: true,
3937
- force: true
3938
- });
3939
- },
3940
- sizeOfDir: (p) => sizeOfDir(p),
3941
- async listFiles(p) {
3942
- return readdir(p);
3943
- },
3944
- async listDirs(p) {
3945
- const dirs = [];
3946
- for (const entry of await readdir(p, { withFileTypes: true })) {
3947
- if (entry.isDirectory()) {
3948
- dirs.push(entry.name);
3949
- continue;
3950
- }
3951
- if (!entry.isSymbolicLink()) continue;
3952
- try {
3953
- if ((await stat(join(p, entry.name))).isDirectory()) dirs.push(entry.name);
3954
- } catch {}
3955
- }
3956
- return dirs;
3957
- },
3958
- async realpath(p) {
3959
- return realpath(p);
3960
- },
3961
- async readFile(p) {
3962
- return readFile(p);
3963
- },
3964
- async statFile(p) {
3965
- return { size: (await stat(p)).size };
3966
- },
3967
- async removeFile(p) {
3968
- await rm(p, { force: true });
3969
- }
3970
- };
3971
- }
3972
- //#endregion
3973
4199
  //#region src/ports/home.ts
3974
4200
  /**
3975
4201
  * 宿主数据根。官方解析器在 asar `dsh/node_modules/@deepseek-ai/dsh-home-paths/lib/index.js`:
@@ -4227,14 +4453,15 @@ const name = "@xrn1997/dsh-session-delete";
4227
4453
  * 三项都是在隔离数据根上实测注入成功、且本插件真正用到的宿主服务。**不进 inject 的**:
4228
4454
  * - `remote`:那是**浏览器半**的服务(asar `dsh-api-gateway/lib/client.js`:`super(ctx, "remote")`),
4229
4455
  * Node 半挂的是 `ctx.typertGateway`。把它写进 Node 半的 inject 会让插件永远 pending(同 `slots` 那次的形状)。
4230
- * - `sessions` / `sessionProjectionCache`:两者都按 `ctx.get(...)` **可选读**——`sessions` 只给 `evictLive`
4231
- * 用(活跃判据走 waterfall,见 src/ports/host-port.ts;服务缺席回 `unknown`),`sessionProjectionCache` 是标题的尽力读面。
4232
- * inject 一个可能没挂的服务 = 整个插件 pending。
4233
- * 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
4456
+ * - `sessions` / `sessionProjectionCache` / `sessionPersistence`:都按 `ctx.get(...)` **可选读**——
4457
+ * `sessions` 给 `evictLive` 和 live header 用(活跃判据走 waterfall,见 src/ports/host-port.ts;
4458
+ * 服务缺席回 `unknown`),`sessionProjectionCache` 是标题的投影读面,`sessionPersistence` 是
4459
+ * `projectDirOf` / `titleOf` 的公开 header 读面(workspaceRegistry 硬注入它,标准组合必有)。
4460
+ * inject 一个可能没挂的服务 = 整个插件 pending;标题取不到时 `titleOf` 返回空串,正是契约允许的降级。
4234
4461
  * - `agents`:只给 handle 捕获用(`ctx.get('agents')`;缺席时 `ctx.inject(['agents'])` 等服务就绪再装)。
4235
4462
  * 它同样可能晚于本插件激活,写进插件 inject 只会让整个插件 pending。
4236
4463
  *
4237
- * `connection` 是浏览器半取数通道的唯一承载者:九段注册到 `connection.fetch.register` 的
4464
+ * `connection` 是浏览器半取数通道的唯一承载者:十段注册到 `connection.fetch.register` 的
4238
4465
  * `/api/dsh-session-delete-api/*`,Host/Origin/浏览器认证由宿主 `/api` bridge 完成(ADR-0033)。
4239
4466
  * 支持面是 web UI 与桌面端,二者的标准组合都装 `client-connection`;服务缺席时插件停在 pending
4240
4467
  * ——可见的激活失败,而不是静默地没有入口。`webServer` 不再直接使用:exact route 仍由宿主的
@@ -4262,6 +4489,8 @@ function apply(ctx, rawConfig) {
4262
4489
  const home = resolveHome();
4263
4490
  const rawManifest = createHostStorageManifest(ctx);
4264
4491
  const host = createHostPort(ctx);
4492
+ const configEditor = ctx.get("configEditor");
4493
+ const entry = ctx.fiber?.entry;
4265
4494
  const deps = {
4266
4495
  fs,
4267
4496
  host,
@@ -4270,7 +4499,9 @@ function apply(ctx, rawConfig) {
4270
4499
  presence: createMemberPresence(host),
4271
4500
  config,
4272
4501
  attachments: createAttachmentsPort(ctx),
4273
- now: () => Date.now()
4502
+ now: () => Date.now(),
4503
+ ...configEditor === void 0 ? {} : { configEditor },
4504
+ ...entry === void 0 ? {} : { entry }
4274
4505
  };
4275
4506
  const connection = ctx.get("connection");
4276
4507
  if (connection === void 0) throw new Error("session-delete: connection 服务缺席");