dsh-plugin-tool-management 0.10.0 → 0.12.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 (77) hide show
  1. package/CHANGELOG.md +100 -1
  2. package/README.md +67 -50
  3. package/README_EN.md +61 -38
  4. package/cordis.patch.yml +10 -1
  5. package/docs/images/1-EN.png +0 -0
  6. package/docs/images/1.png +0 -0
  7. package/docs/images/2-EN.png +0 -0
  8. package/docs/images/2.png +0 -0
  9. package/docs/images/3-EN.png +0 -0
  10. package/docs/images/3.png +0 -0
  11. package/docs/images/4-EN.png +0 -0
  12. package/docs/images/4.png +0 -0
  13. package/docs/images/5-EN.png +0 -0
  14. package/docs/images/5.png +0 -0
  15. package/docs/images/6-EN.png +0 -0
  16. package/docs/images/6.png +0 -0
  17. package/docs/images/7-EN.png +0 -0
  18. package/docs/images/7.png +0 -0
  19. package/docs/images/8-EN.png +0 -0
  20. package/docs/images/8.png +0 -0
  21. package/docs/update.md +132 -12
  22. package/lib/client.js +2826 -547
  23. package/lib/compat/patch-dialect.js +173 -0
  24. package/lib/compat/preset-reach.js +1 -10
  25. package/lib/compat/probe.js +205 -24
  26. package/lib/compat/runtime-notes.js +25 -0
  27. package/lib/context-inject.js +83 -9
  28. package/lib/host-names.js +12 -0
  29. package/lib/http-fence.js +35 -15
  30. package/lib/hub.js +28 -2
  31. package/lib/imports/parsers.js +15 -9
  32. package/lib/imports/upload.js +43 -4
  33. package/lib/index.js +927 -3897
  34. package/lib/mcp/loader-token.js +238 -0
  35. package/lib/mcp/manager.js +1769 -0
  36. package/lib/mcp/override-blocks.js +10 -3
  37. package/lib/mcp/patch-yaml.js +351 -0
  38. package/lib/mcp/secret-guard.js +145 -0
  39. package/lib/{rules → memories}/archive-engine.js +1 -1
  40. package/lib/{rules → memories}/archive.js +1 -1
  41. package/lib/memories/constants.js +128 -0
  42. package/lib/memories/index-io.js +330 -0
  43. package/lib/memories/projection.js +280 -0
  44. package/lib/memories/service.js +686 -0
  45. package/lib/memories/snapshot.js +672 -0
  46. package/lib/ops/candidates.js +64 -0
  47. package/lib/ops/compat.js +226 -0
  48. package/lib/ops/ctx.js +9 -0
  49. package/lib/ops/memory.js +678 -0
  50. package/lib/ops/prompts.js +107 -0
  51. package/lib/ops/scene-records.js +460 -0
  52. package/lib/ops/scene-sync.js +17 -0
  53. package/lib/ops/sessions.js +603 -0
  54. package/lib/ops/trash.js +140 -0
  55. package/lib/paths.js +103 -0
  56. package/lib/prompts/preset-id.js +49 -0
  57. package/lib/{agents-md → prompts}/service.js +1 -1
  58. package/lib/request-gate.js +320 -0
  59. package/lib/scene-prompt-sync.js +4 -4
  60. package/lib/scenes/candidates.js +344 -0
  61. package/lib/{history → sessions}/bridge.js +124 -36
  62. package/lib/sessions/history.js +323 -0
  63. package/lib/{history → sessions}/tombstone.js +1 -1
  64. package/lib/{history → sessions}/workspace.js +151 -50
  65. package/lib/skills/core.js +74 -39
  66. package/lib/skills/readonly-discovery.js +4 -1
  67. package/lib/skills/service.js +98 -13
  68. package/lib/subagents/service.js +199 -55
  69. package/lib/tools/deps.js +8 -0
  70. package/lib/tools/mcp.js +110 -0
  71. package/lib/tools/memory.js +87 -0
  72. package/lib/tools/prompt.js +70 -0
  73. package/lib/tools/skills.js +139 -0
  74. package/lib/tools/subagent.js +40 -0
  75. package/package.json +13 -10
  76. package/lib/agents-md/preset-id.js +0 -49
  77. package/lib/rules/service.js +0 -3078
@@ -1,9 +1,27 @@
1
1
  import { lstat, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
- import { CapabilityRefusalError, createHistoryBridge, requiredSessionCapabilities } from "./bridge.js";
2
+ import { CapabilityRefusalError, createSessionsBridge, requiredSessionCapabilities } from "./bridge.js";
3
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { hubPath } from "../hub.js";
5
5
  import { sessionDir } from "@deepseek-ai/dsh-spill-local";
6
6
  import { trackTombstone } from "./tombstone.js";
7
+ import { noteRuntime } from "../compat/runtime-notes.js";
8
+ /**
9
+ * A3-1:宿主补上了原生删除入口时上报一次(**不切换**,理由见 deleteSessionCore)。
10
+ *
11
+ * 上报本身就够用:它说明「本插件适配的那个缺口已经被官方补上了」,人工核对过原生语义
12
+ * (级联 / spill / 记账 / 缓存行是否都覆盖)之后才谈切换。
13
+ *
14
+ * @param entry - 实际看到的是哪个原生入口(单删 / 批删),写进详情便于判断。
15
+ */
16
+ function reportNativeDeleteAvailable(entry) {
17
+ noteRuntime({
18
+ id: "workspace.delete-native",
19
+ label: "宿主原生删除入口",
20
+ kind: "delete",
21
+ fallback: "inform-only",
22
+ detail: `宿主已提供原生 ${entry}:本插件仍走自有完整序列(级联子会话 + spill + 记账 + 缓存行),尚未切换到原生入口。`,
23
+ });
24
+ }
7
25
  //#region lib/types/index.js
8
26
  /**
9
27
  * 独立归档门面:消费当前宿主服务,不注册或替换 workspaceRegistry。
@@ -13,7 +31,7 @@ import { trackTombstone } from "./tombstone.js";
13
31
  * ——全量 grep `archivedSessionDetails` / `deleteArchivedSessions` / `unarchiveSessions`
14
32
  * 均不存在,`@michengai/dsh-archive-manager` 也未安装。因此下面所有
15
33
  * `typeof this.registry.X === "function"` 的委托分支在当前环境**都是死代码**,
16
- * 实际执行路径永远是 history/bridge.js 的私有适配(依赖官方声明为 private 的方法,
34
+ * 实际执行路径永远是 sessions/bridge.ts 的私有适配(依赖官方声明为 private 的方法,
17
35
  * 并直接改 registry.headers / sessionPaths / invalidSessionPaths)。
18
36
  * 所以「不再替换服务、不再与外部插件争注册」成立,但**不能由此推出耦合下降或零侵入**。
19
37
  * 另:改为门面后新增的墓碑 indexHeader 守卫只覆盖本门面的调用;宿主自身的
@@ -128,6 +146,7 @@ const deletedSchema = {
128
146
  },
129
147
  };
130
148
  const archivedBatchTargetSchema = {
149
+ // 校验通过后原样返回记录,调用方按 scope 判别式收窄(宿主返回值校验器,故返回 any)。
131
150
  parse(value) {
132
151
  if (typeof value !== "object" || value === null || Array.isArray(value))
133
152
  throw new TypeError("target must be an object");
@@ -229,7 +248,14 @@ function defaultWorkspaceSnapshotFile() {
229
248
  /**
230
249
  * 路径比较键:工作区的唯一性在宿主侧是 realpath 字符串相等(dsh-workspace
231
250
  * `realpathNormalize`),Windows 盘符大小写与分隔符拼写却可能不同,因此比较前
232
- * 先归一化。仅用于**分组与快照索引**,不参与任何写入或归属判定。
251
+ * 先归一化。
252
+ *
253
+ * ⚠ 它不是"只用于分组":它决定**哪些会话被挂回哪条登记** —— `registerWorkspace` 会调
254
+ * `attachKnownSessions`,后者按这个键挑选会话并写宿主记账(`sessionIds`);
255
+ * `ensureWorkspaceAccounting` 也拿它当去重键后 `host.create` + 挂回。
256
+ * 而且它比宿主更宽:宿主 `realpathNormalize` 只是 `fs.realpath`(**不做大小写归一**),
257
+ * 本函数在 Windows 上 `toLowerCase()` ⇒ `C:\Proj` 与 `c:\proj` 在插件里同组、在宿主里
258
+ * 可以是两条登记。挂回前应以宿主 `resolveByPath` 实际返回的 id 复核,而不是按归一字符串匹配。
233
259
  * @param {string} path - 原始路径。
234
260
  * @returns {string} 归一化后的比较键(空路径返回空串)。
235
261
  */
@@ -263,6 +289,16 @@ function workspaceBaseName(path) {
263
289
  // Standalone facade over the existing registry. Never instantiate/register a
264
290
  // second WorkspaceRegistry, and never open another copy of its storage domain.
265
291
  var ArchiveWorkspaceRegistry = class {
292
+ /** 宿主注入的上下文(服务读取按动态面处理,见 `HostContext`)。 */
293
+ ctx;
294
+ /** 宿主注册表实例;构造期由入参收窄,随后被 bridge 收窄后的实例覆盖。 */
295
+ registry;
296
+ /** sessions/bridge.ts 的适配句柄(能力门禁 + 私有接口包装)。 */
297
+ bridge;
298
+ /** 账本写入串行链(保证读-改-写不互相覆盖)。 */
299
+ ledgerTail;
300
+ /** 删除中的会话 id 集合(防 SUBAGENT 级联自环),首次用到才建。 */
301
+ deleting;
266
302
  /** 本进程内已物理删除的会话;阻止父类把 stale list() 重新编入索引。 */
267
303
  deletedSessionIds = /* @__PURE__ */ new Set();
268
304
  /** 墓碑插入顺序,用于在上限处淘汰最旧项。 */
@@ -272,8 +308,8 @@ var ArchiveWorkspaceRegistry = class {
272
308
  /** 被删生命周期的日志身份(createdAt/cwd):冷复用探针区分“同 id 新会话”与 stale list() 的依据。 */
273
309
  deletedIdentities = /* @__PURE__ */ new Map();
274
310
  /** 会话 → 归档时刻(epoch ms);保留期自动删除的基线。 */
275
- archivedAtMap = /* @__PURE__ */ new Map();
276
- archivedAtFile = null;
311
+ archivedAtMap = new Map();
312
+ archivedAtFile;
277
313
  archivedAtLoaded = false;
278
314
  /**
279
315
  * 「见过的登记」快照:归一化路径 → { path, title, id, at }。
@@ -282,8 +318,8 @@ var ArchiveWorkspaceRegistry = class {
282
318
  * 使按目录重建的分组仍能显示用户命名,并能区分「曾经登记过、现在被移除」
283
319
  * 与「从未登记过的目录」。只读辅助信息,不参与任何归属判定或写入。
284
320
  */
285
- workspaceSnapshotMap = /* @__PURE__ */ new Map();
286
- workspaceSnapshotFile = null;
321
+ workspaceSnapshotMap = new Map();
322
+ workspaceSnapshotFile;
287
323
  workspaceSnapshotLoaded = false;
288
324
  /** 快照上限:足够覆盖常见工作区数,又避免长驻进程无限增长。 */
289
325
  workspaceSnapshotLimit = 200;
@@ -293,7 +329,11 @@ var ArchiveWorkspaceRegistry = class {
293
329
  */
294
330
  ready = Promise.resolve();
295
331
  constructor(ctx, registry, config = {}) {
332
+ // 注入的 `sessionPersistence` 不在官方 `Context` 声明里(index.ts 的 inject 列表里有它),
333
+ // 因此这里按本文件的使用面收窄(其余成员仍走官方 `Context`)。
296
334
  this.ctx = ctx;
335
+ // 入参按「官方声明可见的公开面」声明(四个索引 Map 在官方 `WorkspaceRegistry` 里是 private,
336
+ // 运行时存在且由 bridge 的形状检查兜底),因此此处收窄为本文件实际使用的注册表面。
297
337
  this.registry = registry;
298
338
  this.archivedAtFile = config.archivedAtFile || defaultArchivedAtFile();
299
339
  this.workspaceSnapshotFile = config.workspaceSnapshotFile || defaultWorkspaceSnapshotFile();
@@ -301,7 +341,8 @@ var ArchiveWorkspaceRegistry = class {
301
341
  this.ready = Promise.resolve(config.ready).then(() => undefined, () => undefined);
302
342
  }
303
343
  this.ledgerTail = Promise.resolve();
304
- this.bridge = createHistoryBridge(ctx, registry, (id, at) => this.recordArchive(id, at));
344
+ this.bridge = createSessionsBridge(ctx, registry, (id, at) => this.recordArchive(id, at));
345
+ // bridge 收窄后的注册表同样只声明运行时形状,这里还原为本文件的注册表面。
305
346
  this.registry = this.bridge.registry;
306
347
  this.bridge.observe();
307
348
  }
@@ -366,6 +407,7 @@ var ArchiveWorkspaceRegistry = class {
366
407
  this.archivedAtLoaded = true;
367
408
  try {
368
409
  const raw = await readFile(this.archivedAtFile, "utf8");
410
+ // 账本文件由本文件写出(id → epoch ms);非有限值在下面逐个跳过。
369
411
  const obj = JSON.parse(raw);
370
412
  if (obj && typeof obj === "object") {
371
413
  for (const [k, v] of Object.entries(obj)) {
@@ -514,6 +556,7 @@ var ArchiveWorkspaceRegistry = class {
514
556
  cwd = header && typeof header.cwd === "string" && header.cwd ? header.cwd : undefined;
515
557
  }
516
558
  catch (error) {
559
+ // 宿主/文件系统抛出的错误按动态形状读 message(strict 下 catch 变量为 unknown)。
517
560
  result.skipped.push({ sessionId, reason: `读不到会话头部:${String(error?.message ?? error)}` });
518
561
  continue;
519
562
  }
@@ -541,6 +584,7 @@ var ArchiveWorkspaceRegistry = class {
541
584
  result.registered.push(record);
542
585
  }
543
586
  catch (error) {
587
+ // 宿主 realpath 拒绝的原始错误按动态形状读 message。
544
588
  entry.failure = `无法解析工作区目录「${cwd}」:${String(error?.message ?? error)}`;
545
589
  }
546
590
  }
@@ -560,6 +604,7 @@ var ArchiveWorkspaceRegistry = class {
560
604
  result.attached.push(sessionId);
561
605
  }
562
606
  catch (error) {
607
+ // 宿主 attachSession 拒绝原因按动态形状读 message。
563
608
  result.skipped.push({ sessionId, reason: String(error?.message ?? error) });
564
609
  }
565
610
  }
@@ -596,6 +641,7 @@ var ArchiveWorkspaceRegistry = class {
596
641
  const created = entity === undefined;
597
642
  if (created)
598
643
  entity = await host.create(target, typeof title === "string" && title.trim() ? title.trim() : undefined);
644
+ // 解析不到即走上面的 create 分支,此后 entity 必定已定义(类型层无法表达该不变式)。
599
645
  const record = {
600
646
  id: String(entity.id),
601
647
  path: String(entity.path ?? target),
@@ -633,6 +679,7 @@ var ArchiveWorkspaceRegistry = class {
633
679
  attached.push(sessionId);
634
680
  }
635
681
  catch (error) {
682
+ // 宿主 attachSession 拒绝原因按动态形状读 message。
636
683
  attachSkipped.push({ sessionId, reason: String(error?.message ?? error) });
637
684
  }
638
685
  }
@@ -894,35 +941,22 @@ var ArchiveWorkspaceRegistry = class {
894
941
  * 后续目标并把成功、并发消失和失败分别返回给客户端。
895
942
  */
896
943
  async deleteArchivedSessions(target) {
897
- if (typeof this.registry.deleteArchivedSessions === "function") {
898
- const result = await this.registry.deleteArchivedSessions(target);
899
- await this.reconcileArchiveLedger();
900
- return result;
901
- }
944
+ // 与单删同一条纪律(A3-1):宿主补上原生批量入口也**先不切换** —— 只上报。
945
+ if (typeof this.registry.deleteArchivedSessions === "function")
946
+ reportNativeDeleteAvailable("deleteArchivedSessions");
902
947
  // 适配路径(无宿主原生批量入口):在任何会话被删除之前确认整条链路可用。
903
948
  this.bridge.checkWorkspace("delete");
904
- if (typeof this.registry.deleteSession === "function") {
905
- const requestedSessionIds = this.archivedSessionIdsForTarget(target);
906
- const result = { requestedSessionIds, deletedSessionIds: [], skippedSessionIds: [], failures: [] };
907
- for (const id of requestedSessionIds) {
908
- try {
909
- await this.deleteSession(id);
910
- result.deletedSessionIds.push(id);
911
- }
912
- catch (error) {
913
- result.failures.push({ sessionId: id, message: String(error) });
914
- }
915
- }
916
- return result;
917
- }
918
949
  return this.enqueueOperation(async () => {
919
950
  const requestedSessionIds = this.archivedSessionIdsForTarget(target);
951
+ // 批首取一次权威快照:批内每一条删除都要一次全库 `listStoredHeaders()`(含逐会话
952
+ // 读首行),这批复用同一份。取不到就退回逐条取 —— 快照失败不该废掉整批。
953
+ const storedIndex = await this.storedHeaderIndex().catch(() => undefined);
920
954
  const deletedSessionIds = [];
921
955
  const skippedSessionIds = [];
922
956
  const failures = [];
923
957
  for (const sessionId of requestedSessionIds) {
924
958
  try {
925
- await this.deleteSessionCore(sessionId);
959
+ await this.deleteSessionCore(sessionId, storedIndex);
926
960
  deletedSessionIds.push(sessionId);
927
961
  }
928
962
  catch (error) {
@@ -963,6 +997,11 @@ var ArchiveWorkspaceRegistry = class {
963
997
  // 等待删除期间可能已进入的写回观察到墓碑并完成补删,再允许
964
998
  // 同 id 的未来新生命周期写入缓存。
965
999
  await projCache.whenIdle?.();
1000
+ // 预留位:宿主**没有** `clearTombstone` 入口(全官方树 grep 零命中),
1001
+ // 所以这里命中的是本插件 `bridge` guard 的墓碑(blocked.delete)——
1002
+ // "已删会话会不会被误复活" 100% 取决于插件自己的两份内存墓碑
1003
+ //(workspace 侧 FIFO-4096 + guard.blocked 无上限),插件重载即全清。
1004
+ // 将来宿主补了 delete+whenIdle 时 bridge 会提前 return,这行才退化成对宿主对象的空转。
966
1005
  projCache.clearTombstone?.(sessionId);
967
1006
  }
968
1007
  await this.cleanSpill(sessionId);
@@ -1010,26 +1049,30 @@ var ArchiveWorkspaceRegistry = class {
1010
1049
  * @throws {@link ArchiveUnknownSessionError} 会话未知时抛出。
1011
1050
  */
1012
1051
  async deleteSession(sessionId) {
1013
- if (typeof this.registry.deleteSession === "function") {
1014
- const result = await this.registry.deleteSession(sessionId);
1015
- await this.reconcileArchiveLedger();
1016
- return result;
1017
- }
1052
+ if (typeof this.registry.deleteSession === "function")
1053
+ reportNativeDeleteAvailable("deleteSession");
1018
1054
  return this.enqueueOperation(() => this.deleteSessionCore(sessionId));
1019
1055
  }
1020
- /** 串行化后的删除主体(级联路径复用:它已持有操作链,绝不能再入队)。 */
1021
- async deleteSessionCore(sessionId) {
1056
+ /** 串行化后的删除主体(级联路径复用:它已持有操作链,绝不能再入队)。
1057
+ * @param storedIndex - 可选的批首 `id → header` 快照,见 {@link storedHeaderIndex}。 */
1058
+ async deleteSessionCore(sessionId, storedIndex) {
1059
+ // A3-1(第 3 版改向):宿主补上原生 `deleteSession` 时**先不切换** —— 原生分支跳过本插件
1060
+ // 的级联删子会话 / spill 清理 / 记账清理,等于行为静默缩水;而"切过去的代码"今天不可达,
1061
+ // 写成即等于它会在官方补上入口的那次升级上首次运行,且无从事前验证。保持自有完整序列
1062
+ //(=今天的能力),只上报一次「宿主已提供原生入口」,切换留到人工核对过原生语义之后。
1022
1063
  if (typeof this.registry.deleteSession === "function")
1023
- return this.registry.deleteSession(sessionId);
1064
+ reportNativeDeleteAvailable("deleteSession");
1024
1065
  if (this.deleting?.has(sessionId))
1025
1066
  throw new Error(`cyclic subagent lineage at "${sessionId}"`);
1026
1067
  this.deleting ??= new Set();
1027
1068
  this.deleting.add(sessionId);
1028
1069
  try {
1029
- return await this.deleteLocalSession(sessionId);
1070
+ return await this.deleteLocalSession(sessionId, storedIndex);
1030
1071
  }
1031
1072
  catch (error) {
1032
1073
  // Failed deletion must not permanently suppress a still-live session.
1074
+ // (`clearTombstone` 是**预留位**:宿主无此入口,实际命中的是本插件 bridge guard
1075
+ // 的墓碑 —— 说明见 `cleanupUnknownArchivedSession` 里那一处。)
1033
1076
  this.getProjectionCache()?.clearTombstone?.(sessionId);
1034
1077
  throw error;
1035
1078
  }
@@ -1037,8 +1080,8 @@ var ArchiveWorkspaceRegistry = class {
1037
1080
  this.deleting.delete(sessionId);
1038
1081
  }
1039
1082
  }
1040
- async deleteLocalSession(sessionId) {
1041
- if (!(await this.sessionKnown(sessionId)))
1083
+ async deleteLocalSession(sessionId, storedIndex) {
1084
+ if (!(await this.sessionKnown(sessionId, storedIndex)))
1042
1085
  throw new ArchiveUnknownSessionError(sessionId);
1043
1086
  const sessions = this.ctx.get("sessions");
1044
1087
  // 快速失败:删掉的顺序(flush → detach → 清缓存 → 删目录 → 清记账)里,
@@ -1052,6 +1095,16 @@ var ArchiveWorkspaceRegistry = class {
1052
1095
  const sessionRefusals = this.bridge.refusalsFor("delete", requiredSessionCapabilities(live !== void 0));
1053
1096
  if (sessionRefusals.length > 0)
1054
1097
  throw new CapabilityRefusalError("delete", sessionRefusals);
1098
+ // A3-2:转录清理(`removeTranscriptDirectory`)还要 `readSessionHeader` 与
1099
+ // `persistence.locate()`,而它们排在「装屏障 → flush → detach → 清缓存行」**之后**。
1100
+ // 缺任何一个,失败点都落在中段,留下"转录还在、记账已清"的半删态 —— 与上面
1101
+ // `sessions.get` 同一条纪律:破坏性步骤之前先把实现检查完。
1102
+ const headerRefusals = this.bridge.refusalsFor("delete", ["workspace.read-header"]);
1103
+ if (headerRefusals.length > 0)
1104
+ throw new CapabilityRefusalError("delete", headerRefusals);
1105
+ const persistence = this.ctx.get("sessionPersistence");
1106
+ if (persistence === void 0 || typeof persistence.locate !== "function")
1107
+ throw new Error("宿主持久化后端未暴露 locate()(官方接口已变动);操作在改动任何数据前停止,请更新本插件");
1055
1108
  // 先记录被删生命周期的日志身份:目录删除后头部不可再读,
1056
1109
  // 冷复用探针(sessionKnown 墓碑分支)靠它区分同 id 的新生命周期。
1057
1110
  const deletedHeader = this.headers.get(sessionId) ?? live?.header;
@@ -1061,8 +1114,13 @@ var ArchiveWorkspaceRegistry = class {
1061
1114
  // 持久化屏障先行:不能有未落盘的转录写入与目录删除竞争
1062
1115
  //(持久化后端按批关闭句柄,flush 过的会话不再持有打开的文件)。
1063
1116
  await sessions.flush(live);
1064
- // 从存储分离;`session/disposed` 同步触发,驱动浏览器端的
1065
- // `host/session-removed` 帧并启动投影缓存的最终写后落盘。
1117
+ // 从存储分离;`session/disposed` 同步触发(payload 是 **Session 对象**),
1118
+ // 由官方 `dsh-api-session-controller` 降级成 `api-session/removed`(payload 已降为
1119
+ // **SessionId 字符串**)再转发到浏览器,并启动投影缓存的最终写后落盘。
1120
+ // 名字别写成 `host/session-removed` —— 那个帧名在整个已安装树里不存在
1121
+ //(`host/…` 是 cordis 服务/槽位名的前缀,不是线上帧命名空间)。
1122
+ // 注意 `api-session/removed` 也是"发布回滚"与"作用域剪枝"发出的同一事件
1123
+ //(官方 `dsh-session/lib/types/index.d.ts`),所以收到它**不能**等同于"用户删了会话"。
1066
1124
  const entry = sessions.liveEntryFor(live);
1067
1125
  sessions.detachEntered(entry);
1068
1126
  }
@@ -1074,7 +1132,7 @@ var ArchiveWorkspaceRegistry = class {
1074
1132
  await projCache?.whenIdle?.();
1075
1133
  if (projCache !== void 0)
1076
1134
  await projCache.delete(sessionId);
1077
- await this.deleteDescendants(sessionId);
1135
+ await this.deleteDescendants(sessionId, storedIndex);
1078
1136
  await this.cleanSpill(sessionId);
1079
1137
  await this.removeTranscriptDirectory(sessionId);
1080
1138
  // 只有物理工件删除成功后才能提交记账清理;否则批量目标会因归档标记
@@ -1116,6 +1174,7 @@ var ArchiveWorkspaceRegistry = class {
1116
1174
  this.deletedSessionOrder.splice(idx, 1);
1117
1175
  // workspace 与 projection-cache 共同描述同一删除生命周期;新生命周期
1118
1176
  // 被接纳时必须同步撤销两处墓碑,否则缓存写入仍会永久被拦截。
1177
+ // 右侧那处同样是**预留位**(宿主无 `clearTombstone`,命中的是 bridge guard 的墓碑)。
1119
1178
  this.getProjectionCache()?.clearTombstone?.(sessionId);
1120
1179
  }
1121
1180
  forgetIndexedSession(sessionId) {
@@ -1129,7 +1188,7 @@ var ArchiveWorkspaceRegistry = class {
1129
1188
  * 已删除会话对归档/删除入口都视为未知。实时复用同一 id 时撤墓碑,
1130
1189
  * 避免挡住新会话。
1131
1190
  */
1132
- async sessionKnown(id) {
1191
+ async sessionKnown(id, storedIndex) {
1133
1192
  if (this.ctx.get("sessions")?.get(id) !== void 0) {
1134
1193
  this.clearTombstone(id);
1135
1194
  return true;
@@ -1137,7 +1196,10 @@ var ArchiveWorkspaceRegistry = class {
1137
1196
  if (this.deletedSessionIds.has(id))
1138
1197
  return this.coldReuseKnown(id);
1139
1198
  // Refresh from authoritative persistence, not the host's stale header cache.
1140
- const header = (await this.listStoredHeaders()).find((item) => item.id === id);
1199
+ // 批量删除会传入**批首快照**(同一批里 k 次全库 list() 是纯浪费);不传就是逐条取新鲜数据。
1200
+ const header = storedIndex !== void 0
1201
+ ? storedIndex.get(id)
1202
+ : (await this.listStoredHeaders()).find((item) => item.id === id);
1141
1203
  if (!header)
1142
1204
  return false;
1143
1205
  await this.indexHeader(header);
@@ -1186,6 +1248,19 @@ var ArchiveWorkspaceRegistry = class {
1186
1248
  async listStoredHeaders() {
1187
1249
  return (await this.ctx.sessionPersistence.list()).map((item) => item.header ?? item);
1188
1250
  }
1251
+ /**
1252
+ * `id → header` 索引,供**一次批量操作内部**复用:`listStoredHeaders()` 每次都是
1253
+ * 全库遍历 + 逐会话读首行,批量里逐条调用就是 O(k·n)。
1254
+ *
1255
+ * 只在批内有效 —— 它不随删除自动剔除,也不代表"此刻磁盘上的最新";需要后者做判据的
1256
+ * 地方(`coldReuseKnown` 的同 id 新生命周期比对)仍然自己取新鲜数据。
1257
+ */
1258
+ async storedHeaderIndex() {
1259
+ const index = new Map();
1260
+ for (const header of await this.listStoredHeaders())
1261
+ index.set(header.id, header);
1262
+ return index;
1263
+ }
1189
1264
  async indexHeaders(items) {
1190
1265
  for (const item of items)
1191
1266
  await this.indexHeader(item.header ?? item);
@@ -1236,6 +1311,7 @@ var ArchiveWorkspaceRegistry = class {
1236
1311
  stat = await lstat(path);
1237
1312
  }
1238
1313
  catch (error) {
1314
+ // Node 文件系统错误的 code 按动态形状读取(此处只关心 ENOENT)。
1239
1315
  if (error?.code === "ENOENT")
1240
1316
  continue; // Already removed on an earlier attempt.
1241
1317
  throw error;
@@ -1277,15 +1353,37 @@ var ArchiveWorkspaceRegistry = class {
1277
1353
  sessionIds: current.sessionIds.filter((id) => id !== sessionId),
1278
1354
  updatedAt: /* @__PURE__ */ new Date().toISOString(),
1279
1355
  }));
1356
+ // 实体快照的形状由宿主 `WorkspaceEntity` 决定(`record` 是私有字段,运行时存在)。
1280
1357
  const entity = this.entities.get(workspaceId);
1281
- if (entity !== void 0)
1358
+ if (entity === void 0)
1359
+ continue;
1360
+ // A3-3:官方把 `record` 改名或改成只读访问器时,这一行会**静默无效** —— 界面读到旧
1361
+ // 快照(会话计数不动),看起来却"什么都没发生"。写完立刻读回,没写进去就上报;
1362
+ // 仍然照写(不因为探测而少做一次更新)。
1363
+ let applied = false;
1364
+ try {
1282
1365
  entity.record = next;
1366
+ applied = entity.record === next;
1367
+ }
1368
+ catch {
1369
+ applied = false;
1370
+ }
1371
+ if (!applied) {
1372
+ noteRuntime({
1373
+ id: "workspace.entity-record",
1374
+ label: "工作区实体快照更新",
1375
+ kind: "write",
1376
+ fallback: "inform-only",
1377
+ detail: "工作区实体的 record 字段写不进去(宿主 WorkspaceEntity 形状可能已变):实体快照可能停留在旧值,界面上的会话计数可能不刷新。",
1378
+ });
1379
+ }
1283
1380
  }
1284
1381
  }
1285
1382
  /** 尽力而为的级联删除:删除 `sessionId` 的 SUBAGENT 子会话。
1286
1383
  * 仅头部标记 `origin: "subagent"` 的会话参与:单凭 `parentSession` 有歧义
1287
- *(fork 分支也携带它),而 fork 分支是独立的用户会话,绝不能被级联删除。 */
1288
- async deleteDescendants(sessionId) {
1384
+ *(fork 分支也携带它),而 fork 分支是独立的用户会话,绝不能被级联删除。
1385
+ * @param storedIndex - 可选的批首 `id → header` 快照,见 {@link storedHeaderIndex}。 */
1386
+ async deleteDescendants(sessionId, storedIndex) {
1289
1387
  try {
1290
1388
  const descendants = [];
1291
1389
  const sessions = this.ctx.get("sessions");
@@ -1295,7 +1393,10 @@ var ArchiveWorkspaceRegistry = class {
1295
1393
  session.header.origin === "subagent")
1296
1394
  descendants.push(session.id);
1297
1395
  }
1298
- for (const header of await this.listStoredHeaders()) {
1396
+ const headers = storedIndex !== void 0
1397
+ ? [...storedIndex.values()]
1398
+ : await this.listStoredHeaders();
1399
+ for (const header of headers) {
1299
1400
  if (header.parentSession === sessionId &&
1300
1401
  header.origin === "subagent" &&
1301
1402
  !descendants.includes(header.id))
@@ -1303,9 +1404,9 @@ var ArchiveWorkspaceRegistry = class {
1303
1404
  }
1304
1405
  for (const childId of descendants) {
1305
1406
  try {
1306
- if (!(await this.sessionKnown(childId)))
1407
+ if (!(await this.sessionKnown(childId, storedIndex)))
1307
1408
  continue;
1308
- await this.deleteSessionCore(childId);
1409
+ await this.deleteSessionCore(childId, storedIndex);
1309
1410
  }
1310
1411
  catch (error) {
1311
1412
  if (error instanceof ArchiveUnknownSessionError)