dsh-plugin-tool-management 0.5.1 → 0.6.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 (43) hide show
  1. package/README.md +300 -238
  2. package/README_EN.md +300 -304
  3. package/cordis.patch.yml +9 -55
  4. package/docs/Changelog.md +108 -481
  5. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  6. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  7. package/docs/images/2MCP.png +0 -0
  8. package/docs/images/2MCP_en.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  10. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  12. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  14. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  16. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  18. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  20. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  21. package/docs/update.md +54 -0
  22. package/lib/agents-md/preset-id.js +49 -0
  23. package/lib/agents-md/service.js +138 -56
  24. package/lib/client.js +4142 -3560
  25. package/lib/compat/probe.js +665 -0
  26. package/lib/history/bridge.js +293 -0
  27. package/lib/history/workspace.js +498 -52
  28. package/lib/http-fence.js +73 -0
  29. package/lib/index.js +511 -126
  30. package/lib/rules/provider.js +3 -3
  31. package/lib/rules/service.js +264 -30
  32. package/lib/scene-prompt-sync.js +112 -0
  33. package/lib/skills/core.js +112 -35
  34. package/lib/skills/service.js +6 -1
  35. package/package.json +5 -3
  36. package/screenshots.json +8 -7
  37. package/docs/images/MCP.png +0 -0
  38. package/docs/images//344/274/232/350/257/235.png +0 -0
  39. package/docs/images//345/234/272/346/231/257.png +0 -0
  40. package/docs/images//345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  41. package/docs/images//346/212/200/350/203/275.png +0 -0
  42. package/docs/images//346/217/220/347/244/272/350/257/215.png +0 -0
  43. package/docs/images//350/256/260/345/277/206.png +0 -0
@@ -1,12 +1,24 @@
1
- import { lstat, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { lstat, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { CapabilityRefusalError, createHistoryBridge, requiredSessionCapabilities } from "./bridge.js";
2
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
- import { createRequire } from "node:module";
4
- import { WorkspaceRegistry } from "@deepseek-ai/dsh-workspace";
4
+ import { hubPath } from "../hub.js";
5
5
  import { sessionDir } from "@deepseek-ai/dsh-spill-local";
6
6
  import { trackTombstone } from "./tombstone.js";
7
7
  //#region lib/types/index.js
8
8
  /**
9
- * dsh-archive-manager 宿主侧归档会话管理。
9
+ * 独立归档门面:消费当前宿主服务,不注册或替换 workspaceRegistry。
10
+ * 外部服务有原生接口时优先委托;官方缺口由受检查的兼容层补齐。
11
+ *
12
+ * 实测边界(2026-09-14,对抗式审查):现装宿主**没有任何**原生归档扩展接口
13
+ * ——全量 grep `archivedSessionDetails` / `deleteArchivedSessions` / `unarchiveSessions`
14
+ * 均不存在,`@michengai/dsh-archive-manager` 也未安装。因此下面所有
15
+ * `typeof this.registry.X === "function"` 的委托分支在当前环境**都是死代码**,
16
+ * 实际执行路径永远是 history/bridge.js 的私有适配(依赖官方声明为 private 的方法,
17
+ * 并直接改 registry.headers / sessionPaths / invalidSessionPaths)。
18
+ * 所以「不再替换服务、不再与外部插件争注册」成立,但**不能由此推出耦合下降或零侵入**。
19
+ * 另:改为门面后新增的墓碑 indexHeader 守卫只覆盖本门面的调用;宿主自身的
20
+ * WorkspaceRegistry.indexHeaders()(dsh-workspace index.js:706/735/466)不再经过它,
21
+ * 「阻止 stale list 把已删会话编回索引」的保护范围是门面内部而非宿主全量。
10
22
  *
11
23
  * `deleteSession(sessionId)` 的顺序即语义:校验已知会话;实时会话先
12
24
  * flush 再 detach;等待投影缓存写入完成;移除归档标记和工作区记账;
@@ -17,15 +29,10 @@ import { trackTombstone } from "./tombstone.js";
17
29
  * 记账和头部索引,以便再次调用同一入口完成删除。SUBAGENT 级联跨多个转录
18
30
  * 工件,无法组成事务:子会话可能已先删除,重试会跳过它们并继续处理仍保留的
19
31
  * 父会话。
20
- * 物理删除成功后必须遗忘父类内存索引(headers / sessionPaths /
21
- * invalidSessionPaths)并打上删除墓碑:父类 sessionKnown 以 headers.has
22
- * 短路,indexHeaders 只增不减,stale list() 否则会把已删 id 救活,
23
- * 进而被 archiveSession 重新写回 archivedSessionIds。
24
- * 墓碑同时记录被删生命周期的日志身份(createdAt/cwd):其他进程以同 id
25
- * 重建并落盘新会话时(冷复用),sessionKnown 的探针以身份差异区分新旧
26
- * 生命周期并撤掉墓碑,避免已重建的会话在本进程永久 UNKNOWN_SESSION。
27
- * 单会话与批量恢复/删除方法均通过 Typert Remote 暴露给浏览器,并注册到
28
- * typert.local,避免生产环境只靠 SRC 扫描时 404。
32
+ * 物理删除成功后清理宿主索引(headers / sessionPaths / invalidSessionPaths),
33
+ * 本门面的墓碑阻止重试时误认旧生命周期,并允许不同创建时间的同 id 新会话。
34
+ * 方法只通过插件自己的 History HTTP ops 暴露,不注册同名宿主 RPC。
35
+ * 兼容层不拦截官方的 indexHeader,因此不承诺修正外部后端返回陈旧列表的行为。
29
36
  */
30
37
  /**
31
38
  * Compatibility adapter for the official JSONL backend's session-owned layout.
@@ -210,25 +217,52 @@ const archivedSessionMetadataSchema = {
210
217
  * 折叠进 dsh-plugin-tool-management 的归档工作区注册表。
211
218
  * 原始实现来自 @michengai/dsh-archive-manager(Apache-2.0),剥离了
212
219
  * Typert Remote 暴露层——本插件客户端经 HTTP API 调用,不依赖 typert 远程。
220
+ * 账本默认位置已从「插件目录 data/」改为 hub 根 `$DSH_HOME/tool-management/`:
221
+ * npm 安装下插件目录会被 `dsh plugin update` 整体替换,账本放那里等于升级即丢。
213
222
  */
214
- function pluginRootDir() {
215
- try {
216
- const require = createRequire(import.meta.url);
217
- return dirname(require.resolve("../../package.json"));
218
- }
219
- catch {
220
- return process.cwd();
221
- }
222
- }
223
223
  function defaultArchivedAtFile() {
224
- return join(pluginRootDir(), "data", "history-archived-at.json");
224
+ return hubPath("history-archived-at.json");
225
+ }
226
+ function defaultWorkspaceSnapshotFile() {
227
+ return hubPath("history-workspaces.json");
228
+ }
229
+ /**
230
+ * 路径比较键:工作区的唯一性在宿主侧是 realpath 字符串相等(dsh-workspace
231
+ * `realpathNormalize`),Windows 盘符大小写与分隔符拼写却可能不同,因此比较前
232
+ * 先归一化。仅用于**分组与快照索引**,不参与任何写入或归属判定。
233
+ * @param {string} path - 原始路径。
234
+ * @returns {string} 归一化后的比较键(空路径返回空串)。
235
+ */
236
+ function workspacePathKey(path) {
237
+ const raw = String(path ?? "").trim();
238
+ if (!raw)
239
+ return "";
240
+ const windows = /^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith("\\\\");
241
+ let out = windows ? raw.replace(/\//g, "\\") : raw;
242
+ while (out.length > 1 && (out.endsWith("\\") || out.endsWith("/")))
243
+ out = out.slice(0, -1);
244
+ return windows ? out.toLowerCase() : out;
225
245
  }
226
- var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
227
- static inject = [
228
- "storageDomain",
229
- "sessionPersistence",
230
- "sessionProjectionCache",
231
- ];
246
+ /**
247
+ * 展示用目录名(与宿主 `defaultWorkspaceTitle` 同义:末段,无末段则用根拼写)。
248
+ * @param {string} path - 原始路径。
249
+ * @returns {string} 非空展示名。
250
+ */
251
+ function workspaceBaseName(path) {
252
+ const raw = String(path ?? "").trim();
253
+ if (!raw)
254
+ return "";
255
+ const trimmed = raw.replace(/[\\/]+$/, "");
256
+ const index = Math.max(trimmed.lastIndexOf("\\"), trimmed.lastIndexOf("/"));
257
+ const segment = index >= 0 ? trimmed.slice(index + 1) : trimmed;
258
+ if (segment)
259
+ return segment;
260
+ const root = trimmed.match(/^(?:[A-Za-z]:[\\/]|[\\/]{1,2})/);
261
+ return root ? root[0] : trimmed || raw;
262
+ }
263
+ // Standalone facade over the existing registry. Never instantiate/register a
264
+ // second WorkspaceRegistry, and never open another copy of its storage domain.
265
+ var ArchiveWorkspaceRegistry = class {
232
266
  /** 本进程内已物理删除的会话;阻止父类把 stale list() 重新编入索引。 */
233
267
  deletedSessionIds = /* @__PURE__ */ new Set();
234
268
  /** 墓碑插入顺序,用于在上限处淘汰最旧项。 */
@@ -241,11 +275,92 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
241
275
  archivedAtMap = /* @__PURE__ */ new Map();
242
276
  archivedAtFile = null;
243
277
  archivedAtLoaded = false;
244
- constructor(ctx, config) {
245
- super(ctx);
246
- this.archivedAtFile = (config && config.archivedAtFile) || defaultArchivedAtFile();
278
+ /**
279
+ * 「见过的登记」快照:归一化路径 → { path, title, id, at }。
280
+ * 宿主删除工作区是硬删、不留墓碑(dsh-workspace `deleteKnown`),登记一没,
281
+ * 归档会话的归属就只剩会话自己的 cwd。本表把**活着时见过**的登记标题留下来,
282
+ * 使按目录重建的分组仍能显示用户命名,并能区分「曾经登记过、现在被移除」
283
+ * 与「从未登记过的目录」。只读辅助信息,不参与任何归属判定或写入。
284
+ */
285
+ workspaceSnapshotMap = /* @__PURE__ */ new Map();
286
+ workspaceSnapshotFile = null;
287
+ workspaceSnapshotLoaded = false;
288
+ /** 快照上限:足够覆盖常见工作区数,又避免长驻进程无限增长。 */
289
+ workspaceSnapshotLimit = 200;
290
+ /**
291
+ * 启动期一次性迁移(插件目录 → hub)的完成信号。两个账本都是**首次用到才读盘**,
292
+ * 但"首次用到"可能与迁移并发,因此读盘前先等它落定;未提供时立即就绪。
293
+ */
294
+ ready = Promise.resolve();
295
+ constructor(ctx, registry, config = {}) {
296
+ this.ctx = ctx;
297
+ this.registry = registry;
298
+ this.archivedAtFile = config.archivedAtFile || defaultArchivedAtFile();
299
+ this.workspaceSnapshotFile = config.workspaceSnapshotFile || defaultWorkspaceSnapshotFile();
300
+ if (config.ready && typeof config.ready.then === "function") {
301
+ this.ready = Promise.resolve(config.ready).then(() => undefined, () => undefined);
302
+ }
303
+ this.ledgerTail = Promise.resolve();
304
+ this.bridge = createHistoryBridge(ctx, registry, (id, at) => this.recordArchive(id, at));
305
+ this.registry = this.bridge.registry;
306
+ this.bridge.observe();
307
+ }
308
+ async dispose() { await this.bridge.dispose(); await this.ledgerTail; }
309
+ get headers() { return this.registry.headers; }
310
+ get sessionPaths() { return this.registry.sessionPaths; }
311
+ get invalidSessionPaths() { return this.registry.invalidSessionPaths; }
312
+ get entities() { return this.registry.entities; }
313
+ get archivedSessionIds() { return this.registry.archivedSessionIds; }
314
+ requireState() { return this.bridge.state(); }
315
+ requireTable() { return this.bridge.table(); }
316
+ setState(state) { return this.bridge.setState(state); }
317
+ enqueueOperation(fn) { return this.bridge.enqueue(fn); }
318
+ readSessionHeader(id) { return this.bridge.readHeader(id); }
319
+ getProjectionCache() { return this.bridge.cache(); }
320
+ /** 宿主能力评估快照(供 HTTP compat-status op 与设置页使用)。 */
321
+ capabilities(force = false) { return this.bridge.capabilities(force); }
322
+ /** 指定能力 id 中不可用的那些;不抛错,供只读路径降级。 */
323
+ capabilityRefusals(ids) { return this.bridge.refusalsFor("read", ids); }
324
+ recordArchive(id, at) {
325
+ const task = this.ledgerTail.then(async () => {
326
+ await this.ensureArchivedAtLoaded();
327
+ if (at === null)
328
+ this.archivedAtMap.delete(id);
329
+ else
330
+ this.archivedAtMap.set(id, at);
331
+ await this.saveArchivedAt();
332
+ });
333
+ this.ledgerTail = task.catch(() => { });
334
+ return task;
335
+ }
336
+ reconcileArchiveLedger() {
337
+ const task = this.ledgerTail.then(() => this.reconcileArchiveLedgerCore());
338
+ this.ledgerTail = task.catch(() => { });
339
+ return task;
340
+ }
341
+ async reconcileArchiveLedgerCore() {
342
+ await this.ensureArchivedAtLoaded();
343
+ const ids = new Set(this.registry.archivedSessionIds);
344
+ let changed = false;
345
+ for (const id of this.archivedAtMap.keys()) {
346
+ if (!ids.has(id)) {
347
+ this.archivedAtMap.delete(id);
348
+ changed = true;
349
+ }
350
+ }
351
+ for (const id of ids) {
352
+ // Existing entries without a timestamp must get a full retention window,
353
+ // never be expired using their potentially much older creation time.
354
+ if (!this.archivedAtMap.has(id)) {
355
+ this.archivedAtMap.set(id, this.registry.archivedAt?.(id) ?? Date.now());
356
+ changed = true;
357
+ }
358
+ }
359
+ if (changed)
360
+ await this.saveArchivedAt();
247
361
  }
248
362
  async ensureArchivedAtLoaded() {
363
+ await this.ready;
249
364
  if (this.archivedAtLoaded)
250
365
  return;
251
366
  this.archivedAtLoaded = true;
@@ -268,6 +383,7 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
268
383
  const obj = {};
269
384
  for (const [k, v] of this.archivedAtMap)
270
385
  obj[k] = v;
386
+ await mkdir(dirname(this.archivedAtFile), { recursive: true });
271
387
  await writeFile(this.archivedAtFile, JSON.stringify(obj), "utf8");
272
388
  }
273
389
  catch (e) {
@@ -278,15 +394,259 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
278
394
  return this.archivedAtMap.get(sessionId);
279
395
  }
280
396
  /**
281
- * 归档一个会话并记录归档时刻(保留期自动删除的基线)。覆盖父类
282
- * `archiveSession`:官方 UI 的「归档会话」菜单与本插件 HTTP op 都经此入口,
283
- * 因此所有归档路径的 archivedAt 都被捕获。
397
+ * 读取「见过的登记」快照(首次调用读盘)。键为 `workspacePathKey` 归一化路径。
398
+ * @returns {Promise<Map<string, { path: string, title: string, id?: string, at: number }>>} 快照副本。
399
+ */
400
+ async workspaceSnapshot() {
401
+ await this.ensureWorkspaceSnapshotLoaded();
402
+ const out = /* @__PURE__ */ new Map();
403
+ for (const [key, record] of this.workspaceSnapshotMap)
404
+ out.set(key, { ...record });
405
+ return out;
406
+ }
407
+ async ensureWorkspaceSnapshotLoaded() {
408
+ await this.ready;
409
+ if (this.workspaceSnapshotLoaded)
410
+ return;
411
+ this.workspaceSnapshotLoaded = true;
412
+ try {
413
+ const raw = await readFile(this.workspaceSnapshotFile, "utf8");
414
+ const parsed = JSON.parse(raw);
415
+ const items = parsed && typeof parsed === "object" && parsed.items && typeof parsed.items === "object" ? parsed.items : parsed;
416
+ if (items && typeof items === "object") {
417
+ for (const [key, value] of Object.entries(items)) {
418
+ if (!value || typeof value !== "object")
419
+ continue;
420
+ const path = typeof value.path === "string" ? value.path : "";
421
+ const title = typeof value.title === "string" ? value.title : "";
422
+ if (!path || !title)
423
+ continue;
424
+ this.workspaceSnapshotMap.set(key, {
425
+ path,
426
+ title,
427
+ ...(typeof value.id === "string" ? { id: value.id } : {}),
428
+ at: Number.isFinite(value.at) ? Number(value.at) : 0,
429
+ });
430
+ }
431
+ }
432
+ }
433
+ catch (e) {
434
+ /* 缺失文件视为空表 */
435
+ }
436
+ }
437
+ async saveWorkspaceSnapshot() {
438
+ try {
439
+ const items = {};
440
+ for (const [key, value] of this.workspaceSnapshotMap)
441
+ items[key] = value;
442
+ await mkdir(dirname(this.workspaceSnapshotFile), { recursive: true });
443
+ await writeFile(this.workspaceSnapshotFile, JSON.stringify({ v: 1, items }), "utf8");
444
+ }
445
+ catch (error) {
446
+ this.ctx.logger?.warn?.(`history: could not persist workspace snapshot: ${String(error)}`);
447
+ }
448
+ }
449
+ /**
450
+ * 记下当前活着的登记(路径 / 标题)。内容未变化时不写盘;超出上限按 `at` FIFO 淘汰。
451
+ * 由 history-list 每次读取时顺手调用,因此覆盖的是「插件见过它活着」的登记。
452
+ * @param {Array<{ id?: string, path?: string, title?: string }>} records - 当前活登记快照。
453
+ */
454
+ async rememberWorkspaces(records) {
455
+ const list = Array.isArray(records) ? records : [];
456
+ if (!list.length)
457
+ return;
458
+ const task = this.ledgerTail.then(async () => {
459
+ await this.ensureWorkspaceSnapshotLoaded();
460
+ const at = Date.now();
461
+ let changed = false;
462
+ for (const record of list) {
463
+ const path = record && typeof record.path === "string" ? record.path : "";
464
+ if (!path)
465
+ continue;
466
+ const key = workspacePathKey(path);
467
+ if (!key)
468
+ continue;
469
+ const title = record && typeof record.title === "string" && record.title ? record.title : workspaceBaseName(path) || path;
470
+ const id = record && typeof record.id === "string" ? record.id : undefined;
471
+ const previous = this.workspaceSnapshotMap.get(key);
472
+ if (previous && previous.path === path && previous.title === title && previous.id === id)
473
+ continue;
474
+ this.workspaceSnapshotMap.set(key, { path, title, ...(id === undefined ? {} : { id }), at });
475
+ changed = true;
476
+ }
477
+ if (!changed)
478
+ return;
479
+ if (this.workspaceSnapshotMap.size > this.workspaceSnapshotLimit) {
480
+ const ordered = [...this.workspaceSnapshotMap.entries()].sort((a, b) => (a[1].at || 0) - (b[1].at || 0));
481
+ for (const [key] of ordered.slice(0, this.workspaceSnapshotMap.size - this.workspaceSnapshotLimit))
482
+ this.workspaceSnapshotMap.delete(key);
483
+ }
484
+ await this.saveWorkspaceSnapshot();
485
+ });
486
+ this.ledgerTail = task.catch(() => { });
487
+ return task;
488
+ }
489
+ /**
490
+ * 恢复归档时把**工作区归属一起恢复**。宿主归档只把会话放进全局归档集合,
491
+ * 登记的 `sessionIds` 记账位保持不动 —— 所以登记还在时恢复即自动归位。但登记被删除后
492
+ * 再重建的那条记录 `sessionIds` 是空的,恢复出来的会话在宿主侧就落进「未分组」。
493
+ * 这里按会话 header 的 `cwd` 找到(必要时重建)对应登记,并把会话挂回其记账位:
494
+ * 全部走宿主自己的方法(`resolveByPath` / `create` / 实体的 `attachSession`);
495
+ * 目录已不存在时不凭空登记,只如实上报跳过原因。
496
+ * @param {string[]} sessionIds - 要恢复归属的会话。
497
+ * @returns {Promise<{ registered: Array<{ path: string, title: string, created: boolean }>, attached: string[], skipped: Array<{ sessionId: string, reason: string }> }>} 归属恢复结果。
498
+ */
499
+ async ensureWorkspaceAccounting(sessionIds) {
500
+ const ids = [...new Set((Array.isArray(sessionIds) ? sessionIds : []).map((id) => String(id ?? "").trim()).filter(Boolean))];
501
+ const result = { registered: [], attached: [], skipped: [] };
502
+ if (!ids.length)
503
+ return result;
504
+ const host = this.registry;
505
+ if (typeof host.resolveByPath !== "function" || typeof host.create !== "function") {
506
+ throw new Error("宿主未提供工作区登记入口(workspaceRegistry.resolveByPath / create),本次未做任何修改");
507
+ }
508
+ /** 同一目录只解析/登记一次;值为 { entity, record } 或 { failure }。 */
509
+ const byPath = /* @__PURE__ */ new Map();
510
+ for (const sessionId of ids) {
511
+ let cwd;
512
+ try {
513
+ const header = await this.readSessionHeader(sessionId);
514
+ cwd = header && typeof header.cwd === "string" && header.cwd ? header.cwd : undefined;
515
+ }
516
+ catch (error) {
517
+ result.skipped.push({ sessionId, reason: `读不到会话头部:${String(error?.message ?? error)}` });
518
+ continue;
519
+ }
520
+ if (cwd === undefined) {
521
+ result.skipped.push({ sessionId, reason: "会话头部没有工作目录(cwd),无法恢复工作区归属" });
522
+ continue;
523
+ }
524
+ const key = workspacePathKey(cwd);
525
+ let entry = byPath.get(key);
526
+ if (entry === undefined) {
527
+ entry = {};
528
+ byPath.set(key, entry);
529
+ try {
530
+ let entity = await host.resolveByPath(cwd);
531
+ const created = entity === undefined;
532
+ if (created)
533
+ entity = await host.create(cwd);
534
+ entry.entity = entity;
535
+ const record = {
536
+ path: String(entity?.path ?? cwd),
537
+ title: String(entity?.title ?? "") || workspaceBaseName(cwd) || cwd,
538
+ created,
539
+ };
540
+ entry.record = record;
541
+ result.registered.push(record);
542
+ }
543
+ catch (error) {
544
+ entry.failure = `无法解析工作区目录「${cwd}」:${String(error?.message ?? error)}`;
545
+ }
546
+ }
547
+ if (entry.failure !== undefined) {
548
+ result.skipped.push({ sessionId, reason: entry.failure });
549
+ continue;
550
+ }
551
+ if (typeof entry.entity?.attachSession !== "function") {
552
+ result.skipped.push({ sessionId, reason: "宿主登记对象没有 attachSession,无法把会话挂回该工作区" });
553
+ continue;
554
+ }
555
+ try {
556
+ const already = Array.isArray(entry.entity.sessionIds) && entry.entity.sessionIds.includes(sessionId);
557
+ await entry.entity.attachSession(sessionId);
558
+ // 只在**真的挂上**时上报:正常恢复(登记里本来就有这个会话)不该弹提示。
559
+ if (!already)
560
+ result.attached.push(sessionId);
561
+ }
562
+ catch (error) {
563
+ result.skipped.push({ sessionId, reason: String(error?.message ?? error) });
564
+ }
565
+ }
566
+ // 这次新建的登记记进快照:组标题与「已移除 / 未登记」判定都靠它。
567
+ const fresh = result.registered.filter((record) => record.created).map((record) => ({ path: record.path, title: record.title }));
568
+ if (fresh.length)
569
+ await this.rememberWorkspaces(fresh);
570
+ return result;
571
+ }
572
+ /**
573
+ * 为一个**已存在**的目录重新创建宿主工作区登记(用户显式点击才走这里),
574
+ * 并把**该目录下已知的会话挂回**这条登记 —— 登记被删除时它的 `sessionIds`
575
+ * 记账一起没了,不挂回的话这些会话在宿主侧仍然显示为「未分组」
576
+ * (已恢复的会话不会再有第二次「恢复」动作,只能靠这里补)。
577
+ * 只调用宿主自身的 resolveByPath / create / attachSession:同路径已有登记时
578
+ * 原样复用、不重复创建;目录不存在时宿主 realpath 直接拒绝,因此不会凭空登记
579
+ * 一个不存在的路径。不移动、不删除任何文件与会话。
580
+ * @param {string} path - 要登记的目录路径。
581
+ * @param {string} [title] - 可选展示标题;缺省由宿主取目录名。
582
+ * @returns {Promise<{ id: string, title: string, path: string, created: boolean, attached: string[], attachSkipped: Array<{ sessionId: string, reason: string }> }>} 登记与挂回结果。
583
+ */
584
+ async registerWorkspace(path, title) {
585
+ const target = String(path ?? "").trim();
586
+ if (!target)
587
+ throw new Error("workspace path is required");
588
+ const host = this.registry;
589
+ if (typeof host.resolveByPath !== "function" || typeof host.create !== "function") {
590
+ throw new Error("宿主未提供工作区登记入口(workspaceRegistry.resolveByPath / create),本次未做任何修改");
591
+ }
592
+ // 这一条走宿主自己的方法(ctx.workspaceRegistry 就是宿主服务本体),
593
+ // 因此不需要 bridge 的归档适配能力门禁;真正的守卫是宿主 create 自己的
594
+ // realpath + isDirectory 校验,以及下面的 resolveByPath 幂等判断。
595
+ let entity = await host.resolveByPath(target);
596
+ const created = entity === undefined;
597
+ if (created)
598
+ entity = await host.create(target, typeof title === "string" && title.trim() ? title.trim() : undefined);
599
+ const record = {
600
+ id: String(entity.id),
601
+ path: String(entity.path ?? target),
602
+ title: String(entity.title ?? "") || workspaceBaseName(target) || target,
603
+ };
604
+ await this.rememberWorkspaces([record]);
605
+ const { attached, attachSkipped } = await this.attachKnownSessions(entity, record.path);
606
+ return { ...record, created, attached, attachSkipped };
607
+ }
608
+ /**
609
+ * 把宿主索引里**属于该目录**的已知会话挂回这条登记(幂等;已在册的不重复上报)。
610
+ * 只信 `registry.sessionPaths`(宿主按真实目录建的规范路径索引),不猜路径。
611
+ * @param {{ sessionIds?: string[], attachSession?: (id: string) => Promise<void> }} entity - 宿主登记对象。
612
+ * @param {string} path - 该登记的规范路径。
613
+ * @returns {Promise<{ attached: string[], attachSkipped: Array<{ sessionId: string, reason: string }> }>} 挂回结果。
614
+ */
615
+ async attachKnownSessions(entity, path) {
616
+ const attached = [];
617
+ const attachSkipped = [];
618
+ const sessionPaths = this.registry?.sessionPaths;
619
+ if (!(sessionPaths instanceof Map) || typeof entity?.attachSession !== "function")
620
+ return { attached, attachSkipped };
621
+ const key = workspacePathKey(path);
622
+ const candidates = [];
623
+ for (const [sessionId, sessionPath] of sessionPaths) {
624
+ if (typeof sessionPath !== "string" || workspacePathKey(sessionPath) !== key)
625
+ continue;
626
+ if (Array.isArray(entity.sessionIds) && entity.sessionIds.includes(sessionId))
627
+ continue;
628
+ candidates.push(String(sessionId));
629
+ }
630
+ for (const sessionId of candidates) {
631
+ try {
632
+ await entity.attachSession(sessionId);
633
+ attached.push(sessionId);
634
+ }
635
+ catch (error) {
636
+ attachSkipped.push({ sessionId, reason: String(error?.message ?? error) });
637
+ }
638
+ }
639
+ return { attached, attachSkipped };
640
+ }
641
+ /**
642
+ * 归档委托宿主。官方 UI 不经过本方法,而由 domain/changed 事件
643
+ * 同步归档时刻;重复归档不重置保留期。
284
644
  */
285
645
  async archiveSession(sessionId) {
286
- await super.archiveSession(sessionId);
287
- await this.ensureArchivedAtLoaded();
288
- this.archivedAtMap.set(sessionId, Date.now());
289
- await this.saveArchivedAt();
646
+ // 委托宿主原生入口;宿主没有原生入口时走本门面的适配路径(能力探测决定)。
647
+ this.bridge.checkWorkspace("archive");
648
+ await this.registry.archiveSession(sessionId);
649
+ await this.reconcileArchiveLedger();
290
650
  }
291
651
  /**
292
652
  * 列出已归档会话的展示元数据:sessionId / createdAt / cwd / title / archivedAt。
@@ -294,7 +654,11 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
294
654
  * 任何一步失败只降级为缺字段,不阻断列表。
295
655
  */
296
656
  async archivedSessionDetails() {
297
- await this.ensureArchivedAtLoaded();
657
+ await this.reconcileArchiveLedger();
658
+ if (typeof this.registry.archivedSessionDetails === "function") {
659
+ const result = await this.registry.archivedSessionDetails();
660
+ return { ...result, items: result.items.map((item) => ({ ...item, archivedAt: item.archivedAt ?? this.archivedAtMap.get(item.sessionId) })) };
661
+ }
298
662
  const items = [];
299
663
  for (const sessionId of [...new Set(this.requireState().archivedSessionIds)]) {
300
664
  const entry = { sessionId, archivedAt: this.archivedAtMap.get(sessionId) };
@@ -424,6 +788,12 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
424
788
  * @returns 更新后的完整归档集合。
425
789
  */
426
790
  async unarchiveSession(sessionId) {
791
+ this.bridge.checkWorkspace("unarchive");
792
+ if (typeof this.registry.unarchiveSession === "function") {
793
+ const result = await this.registry.unarchiveSession(sessionId);
794
+ await this.reconcileArchiveLedger();
795
+ return result;
796
+ }
427
797
  return this.enqueueOperation(async () => {
428
798
  if (!(await this.sessionKnown(sessionId)))
429
799
  throw new ArchiveUnknownSessionError(sessionId);
@@ -446,6 +816,12 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
446
816
  * 再执行单次状态写入,因此未知会话不会导致项目只归档一部分。
447
817
  */
448
818
  async archiveWorkspaceSessions(workspaceId) {
819
+ this.bridge.checkWorkspace("batch");
820
+ if (typeof this.registry.archiveWorkspaceSessions === "function") {
821
+ const result = await this.registry.archiveWorkspaceSessions(workspaceId);
822
+ await this.reconcileArchiveLedger();
823
+ return result;
824
+ }
449
825
  return this.enqueueOperation(async () => {
450
826
  workspaceId = workspaceIdSchema.parse(workspaceId);
451
827
  const workspace = this.requireTable().get(workspaceId);
@@ -482,6 +858,17 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
482
858
  * 目标全部来自已归档集合,因此即使日志已被外部移除,也会清掉陈旧归档标记。
483
859
  */
484
860
  async unarchiveSessions(target) {
861
+ if (typeof this.registry.unarchiveSessions === "function") {
862
+ const result = await this.registry.unarchiveSessions(target);
863
+ await this.reconcileArchiveLedger();
864
+ return result;
865
+ }
866
+ if (typeof this.registry.unarchiveSession === "function") {
867
+ const ids = this.archivedSessionIdsForTarget(target);
868
+ for (const id of ids)
869
+ await this.unarchiveSession(id);
870
+ return { unarchivedSessionIds: ids, archivedSessionIds: [...this.registry.archivedSessionIds] };
871
+ }
485
872
  return this.enqueueOperation(async () => {
486
873
  const unarchivedSessionIds = this.archivedSessionIdsForTarget(target);
487
874
  if (unarchivedSessionIds.length === 0)
@@ -507,6 +894,27 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
507
894
  * 后续目标并把成功、并发消失和失败分别返回给客户端。
508
895
  */
509
896
  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
+ }
902
+ // 适配路径(无宿主原生批量入口):在任何会话被删除之前确认整条链路可用。
903
+ 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
+ }
510
918
  return this.enqueueOperation(async () => {
511
919
  const requestedSessionIds = this.archivedSessionIdsForTarget(target);
512
920
  const deletedSessionIds = [];
@@ -547,7 +955,8 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
547
955
  * 归档标记最后清除,前序可失败步骤出错时批量入口仍能再次命中。
548
956
  */
549
957
  async cleanupUnknownArchivedSession(sessionId) {
550
- const projCache = this.ctx.get("sessionProjectionCache");
958
+ await this.bridge.beginDelete(sessionId);
959
+ const projCache = this.getProjectionCache();
551
960
  await projCache?.whenIdle?.();
552
961
  if (projCache !== void 0) {
553
962
  await projCache.delete(sessionId);
@@ -565,9 +974,7 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
565
974
  archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId),
566
975
  });
567
976
  }
568
- await this.ensureArchivedAtLoaded();
569
- if (this.archivedAtMap.delete(sessionId))
570
- await this.saveArchivedAt();
977
+ await this.recordArchive(sessionId, null);
571
978
  }
572
979
  /** 以归档集合顺序解析批量目标,避免依赖浏览器尚未加载完整的摘要投影。 */
573
980
  archivedSessionIdsForTarget(target) {
@@ -603,17 +1010,53 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
603
1010
  * @throws {@link ArchiveUnknownSessionError} 会话未知时抛出。
604
1011
  */
605
1012
  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
+ }
606
1018
  return this.enqueueOperation(() => this.deleteSessionCore(sessionId));
607
1019
  }
608
1020
  /** 串行化后的删除主体(级联路径复用:它已持有操作链,绝不能再入队)。 */
609
1021
  async deleteSessionCore(sessionId) {
1022
+ if (typeof this.registry.deleteSession === "function")
1023
+ return this.registry.deleteSession(sessionId);
1024
+ if (this.deleting?.has(sessionId))
1025
+ throw new Error(`cyclic subagent lineage at "${sessionId}"`);
1026
+ this.deleting ??= new Set();
1027
+ this.deleting.add(sessionId);
1028
+ try {
1029
+ return await this.deleteLocalSession(sessionId);
1030
+ }
1031
+ catch (error) {
1032
+ // Failed deletion must not permanently suppress a still-live session.
1033
+ this.getProjectionCache()?.clearTombstone?.(sessionId);
1034
+ throw error;
1035
+ }
1036
+ finally {
1037
+ this.deleting.delete(sessionId);
1038
+ }
1039
+ }
1040
+ async deleteLocalSession(sessionId) {
610
1041
  if (!(await this.sessionKnown(sessionId)))
611
1042
  throw new ArchiveUnknownSessionError(sessionId);
612
1043
  const sessions = this.ctx.get("sessions");
1044
+ // 快速失败:删掉的顺序(flush → detach → 清缓存 → 删目录 → 清记账)里,
1045
+ // 实时会话接口一旦缺失,失败点会落在「转录已删但记账还在」的中段。
1046
+ // 这里在任何破坏性步骤(含 beginDelete 装的写屏障)之前先把实现检查完。
1047
+ if (sessions !== void 0 && typeof sessions.get !== "function")
1048
+ throw new Error("宿主会话实现缺少 get(官方接口已变动);操作在改动任何数据前停止,请更新本插件");
613
1049
  const live = sessions?.get(sessionId);
1050
+ // 能力门禁(在任何破坏性步骤之前):实时分支需要 flush/liveEntryFor/detachEntered,
1051
+ // 冷分支需要 enter/announce。缺失即在此停止,失败点不会落到链路中段。
1052
+ const sessionRefusals = this.bridge.refusalsFor("delete", requiredSessionCapabilities(live !== void 0));
1053
+ if (sessionRefusals.length > 0)
1054
+ throw new CapabilityRefusalError("delete", sessionRefusals);
614
1055
  // 先记录被删生命周期的日志身份:目录删除后头部不可再读,
615
1056
  // 冷复用探针(sessionKnown 墓碑分支)靠它区分同 id 的新生命周期。
616
1057
  const deletedHeader = this.headers.get(sessionId) ?? live?.header;
1058
+ // Install the barrier BEFORE flush/detach can schedule a final cache put.
1059
+ await this.bridge.beginDelete(sessionId, deletedHeader);
617
1060
  if (live !== void 0) {
618
1061
  // 持久化屏障先行:不能有未落盘的转录写入与目录删除竞争
619
1062
  //(持久化后端按批关闭句柄,flush 过的会话不再持有打开的文件)。
@@ -625,7 +1068,7 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
625
1068
  }
626
1069
  else if (sessions !== void 0)
627
1070
  await this.publishColdSessionRemoval(sessionId, sessions);
628
- const projCache = this.ctx.get("sessionProjectionCache");
1071
+ const projCache = this.getProjectionCache();
629
1072
  // dispose 的写后落盘必须先于缓存行删除完成,
630
1073
  // 否则该行会在删除之后被写回(复活)。
631
1074
  await projCache?.whenIdle?.();
@@ -646,9 +1089,7 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
646
1089
  await this.removeFromWorkspaceAccounts(sessionId);
647
1090
  // 物理删除已成功:此时再清父类索引。后续记账失败时保留索引,便于重试。
648
1091
  this.forgetIndexedSession(sessionId);
649
- await this.ensureArchivedAtLoaded();
650
- if (this.archivedAtMap.delete(sessionId))
651
- await this.saveArchivedAt();
1092
+ await this.recordArchive(sessionId, null);
652
1093
  if (deletedHeader !== void 0)
653
1094
  this.deletedIdentities.set(sessionId, headerIdentity(deletedHeader));
654
1095
  this.publishDeletedSession(sessionId);
@@ -675,7 +1116,7 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
675
1116
  this.deletedSessionOrder.splice(idx, 1);
676
1117
  // workspace 与 projection-cache 共同描述同一删除生命周期;新生命周期
677
1118
  // 被接纳时必须同步撤销两处墓碑,否则缓存写入仍会永久被拦截。
678
- this.ctx.get("sessionProjectionCache")?.clearTombstone?.(sessionId);
1119
+ this.getProjectionCache()?.clearTombstone?.(sessionId);
679
1120
  }
680
1121
  forgetIndexedSession(sessionId) {
681
1122
  for (const evicted of trackTombstone(this.deletedSessionIds, this.deletedSessionOrder, sessionId, this.deletedSessionTombstoneLimit))
@@ -695,7 +1136,12 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
695
1136
  }
696
1137
  if (this.deletedSessionIds.has(id))
697
1138
  return this.coldReuseKnown(id);
698
- return super.sessionKnown(id);
1139
+ // Refresh from authoritative persistence, not the host's stale header cache.
1140
+ const header = (await this.listStoredHeaders()).find((item) => item.id === id);
1141
+ if (!header)
1142
+ return false;
1143
+ await this.indexHeader(header);
1144
+ return true;
699
1145
  }
700
1146
  /**
701
1147
  * 墓碑分支的冷复用探针:其他进程以同 id 重建并落盘的新会话(日志身份
@@ -734,7 +1180,7 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
734
1180
  async indexHeader(header) {
735
1181
  if (this.deletedSessionIds.has(header.id))
736
1182
  return;
737
- return super.indexHeader(header);
1183
+ return this.registry.indexHeader(header);
738
1184
  }
739
1185
  /** 统一旧版头部数组与 0.1.3 的持久化快照,供父类索引和本插件枚举共用。 */
740
1186
  async listStoredHeaders() {
@@ -889,4 +1335,4 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
889
1335
  }
890
1336
  };
891
1337
  //#endregion
892
- export { ArchiveWorkspaceRegistry, ArchiveWorkspaceRegistry as default };
1338
+ export { ArchiveWorkspaceRegistry, ArchiveWorkspaceRegistry as default, workspaceBaseName, workspacePathKey };