dsh-plugin-tool-management 0.10.0 → 0.11.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 (74) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/README.md +64 -49
  3. package/README_EN.md +58 -37
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +105 -12
  21. package/lib/client.js +2763 -546
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -20
  24. package/lib/context-inject.js +11 -0
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +28 -2
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +804 -3887
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +10 -3
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/{rules → memories}/archive-engine.js +1 -1
  37. package/lib/{rules → memories}/archive.js +1 -1
  38. package/lib/memories/constants.js +128 -0
  39. package/lib/memories/index-io.js +330 -0
  40. package/lib/memories/projection.js +280 -0
  41. package/lib/memories/service.js +686 -0
  42. package/lib/memories/snapshot.js +672 -0
  43. package/lib/ops/candidates.js +64 -0
  44. package/lib/ops/compat.js +136 -0
  45. package/lib/ops/ctx.js +9 -0
  46. package/lib/ops/memory.js +678 -0
  47. package/lib/ops/prompts.js +107 -0
  48. package/lib/ops/scene-records.js +460 -0
  49. package/lib/ops/scene-sync.js +17 -0
  50. package/lib/ops/sessions.js +603 -0
  51. package/lib/ops/trash.js +140 -0
  52. package/lib/paths.js +103 -0
  53. package/lib/prompts/preset-id.js +49 -0
  54. package/lib/{agents-md → prompts}/service.js +1 -1
  55. package/lib/request-gate.js +320 -0
  56. package/lib/scene-prompt-sync.js +4 -4
  57. package/lib/scenes/candidates.js +344 -0
  58. package/lib/{history → sessions}/bridge.js +15 -5
  59. package/lib/sessions/history.js +323 -0
  60. package/lib/{history → sessions}/tombstone.js +1 -1
  61. package/lib/{history → sessions}/workspace.js +92 -24
  62. package/lib/skills/core.js +74 -39
  63. package/lib/skills/readonly-discovery.js +4 -1
  64. package/lib/skills/service.js +98 -13
  65. package/lib/subagents/service.js +199 -55
  66. package/lib/tools/deps.js +8 -0
  67. package/lib/tools/mcp.js +110 -0
  68. package/lib/tools/memory.js +87 -0
  69. package/lib/tools/prompt.js +70 -0
  70. package/lib/tools/skills.js +139 -0
  71. package/lib/tools/subagent.js +40 -0
  72. package/package.json +7 -7
  73. package/lib/agents-md/preset-id.js +0 -49
  74. package/lib/rules/service.js +0 -3078
@@ -0,0 +1,323 @@
1
+ // 归档会话域(原 index.ts apply() 闭包内六段,2026-09-19 抽出)。
2
+ //
3
+ // 这一域管的是「归档会话的外围」:侧车账本的落点与搬迁、保留期设置、到期清扫、
4
+ // 批量操作目标的校验、归档列表的分组视图。会话本身的归档 / 恢复 / 删除走宿主
5
+ // 注册表(./workspace.ts 的 ArchiveWorkspaceRegistry),这里只做围绕它的记账与投影。
6
+ //
7
+ // 为什么抽出来:这些内容此前散在 apply() 闭包的六个不相邻段落里(1041 / 1093 /
8
+ // 1152 / 1172 / 1683 / 1711),彼此靠闭包隐式共享 `sessions` 实例与侧车路径。抽成
9
+ // 工厂后私有状态(实例、目录存在性缓存)收在内部,只经返回对象的七个出口暴露;
10
+ // 对外依赖(config / pluginRoot / message / ctx)改为显式入参。
11
+ //
12
+ // 一处必须守住的语义:侧车三件套放 hub 根(`$DSH_HOME/tool-management/`)而**不是**
13
+ // 插件目录 —— npm 安装下 `dsh plugin update` 会整体替换插件目录,账本一丢,保留期
14
+ // 基线就从 archivedAt 退回 createdAt,归档会话会被提前清掉。旧位置启动时一次性搬入。
15
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
16
+ import { dirname, join } from 'node:path';
17
+ import { hubPath, hubRoot, relocateEntries } from '../hub.js';
18
+ import { ArchiveWorkspaceRegistry as SessionsService, workspaceBaseName, workspacePathKey } from './workspace.js';
19
+ /**
20
+ * 建归档会话域。副作用(构造注册表实例、首次清扫、挂周期定时器)都在工厂内完成 ——
21
+ * 它们原本就写在 apply() 的同一位置,语义是「这个服务自己的启动逻辑」,不是组装层的接线。
22
+ */
23
+ export function createHistoryDomain(deps) {
24
+ const { ctx, config, pluginRoot, message } = deps;
25
+ // ---------- independent history facade ----------
26
+ // Keep the active workspaceRegistry/sessionProjectionCache instances intact.
27
+ // Native archive extensions are delegated by capability, not package name.
28
+ // Official gaps use a checked adapter (sessions/bridge.ts); no service or
29
+ // storage-domain replacement. Retention uses the existing sidecar ledger.
30
+ // 历史侧车三件套(归档时刻账本 / 保留期设置 / 工作区登记快照)统一放 hub 根
31
+ // (`$DSH_HOME/tool-management/`),**不放插件目录**:npm 安装下插件目录会被
32
+ // `dsh plugin update` 整体替换,放那里等于"升级即丢账本"——账本一丢,保留期基线
33
+ // 就从 archivedAt 退回 createdAt,归档会话会被提前清掉(v0.4 把提示词预设
34
+ // 搬进 hub 是同一条理由,见上方注释;这三个是当时漏掉的)。
35
+ // 旧位置(插件目录 data/)在启动时一次性搬入:只搬不删、绝不覆盖、失败下次再试。
36
+ const historyArchivedAtFile = String(config?.archivedAtFile || hubPath('history-archived-at.json'));
37
+ const historyWorkspaceSnapshotFile = String(config?.workspaceSnapshotFile || hubPath('history-workspaces.json'));
38
+ const historyRetentionPath = String(config?.historyRetentionPath || hubPath('history-retention.json'));
39
+ const historySidecarFiles = new Set(['history-archived-at.json', 'history-retention.json', 'history-workspaces.json']);
40
+ const historyStandby = relocateEntries(join(pluginRoot, 'data'), hubRoot(), (name) => historySidecarFiles.has(name))
41
+ .catch(() => 0);
42
+ const sweepIntervalMs = Number(config?.sweepIntervalMs || 0) || 6 * 60 * 60 * 1000;
43
+ let sessions;
44
+ ctx.effect(() => {
45
+ const registry = ctx.get('workspaceRegistry');
46
+ if (registry && typeof registry.archiveSession === 'function') {
47
+ sessions = new SessionsService(ctx, registry, {
48
+ archivedAtFile: historyArchivedAtFile,
49
+ workspaceSnapshotFile: historyWorkspaceSnapshotFile,
50
+ ready: historyStandby,
51
+ });
52
+ }
53
+ return async () => { const current = sessions; sessions = undefined; await current?.dispose(); };
54
+ }, 'dsh-plugin-tool-management: independent history');
55
+ function getSessionsRegistry() {
56
+ return sessions;
57
+ }
58
+ /**
59
+ * 恢复归档后的**归属恢复**(best-effort,绝不阻断恢复本身):按会话 cwd 找到或重建
60
+ * 工作区登记,并把会话挂回该登记的 `sessionIds`。恢复成功但归属恢复失败时返回 undefined,
61
+ * 由客户端按"会话已恢复、工作区归属未恢复"如实呈现。
62
+ */
63
+ async function restoreWorkspaceAccounting(sessionIds) {
64
+ const registry = getSessionsRegistry();
65
+ if (!registry || typeof registry.ensureWorkspaceAccounting !== 'function' || !sessionIds.length)
66
+ return undefined;
67
+ try {
68
+ return await registry.ensureWorkspaceAccounting(sessionIds);
69
+ }
70
+ catch (e) {
71
+ ctx.logger?.warn?.(`history: workspace accounting after restore failed: ${message(e)}`);
72
+ return undefined;
73
+ }
74
+ }
75
+ async function readHistoryRetention() {
76
+ try {
77
+ await historyStandby;
78
+ const raw = await readFile(historyRetentionPath, 'utf8');
79
+ const obj = JSON.parse(raw);
80
+ const days = Number((obj && obj.retentionDays) ?? 0);
81
+ const updatedAt = Number((obj && obj.updatedAt) ?? 0);
82
+ return {
83
+ retentionDays: Number.isFinite(days) && days >= 0 ? days : 0,
84
+ updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0,
85
+ };
86
+ }
87
+ catch {
88
+ return { retentionDays: 0, updatedAt: 0 };
89
+ }
90
+ }
91
+ async function writeHistoryRetention(retentionDays) {
92
+ await historyStandby;
93
+ const dir = dirname(historyRetentionPath);
94
+ await mkdir(dir, { recursive: true });
95
+ // updatedAt = 修改时刻:每次改保留期,已归档会话的到期基线重置为此时刻。
96
+ await writeFile(historyRetentionPath, JSON.stringify({ retentionDays, updatedAt: Date.now() }), 'utf8');
97
+ }
98
+ /**
99
+ * 计算到期应删的归档会话。基线 = max(archivedAt(账本)?? createdAt(元数据),
100
+ * updatedAt(最近一次修改保留期的时刻))。改保留期即重置倒计时:到期时刻从
101
+ * 修改时刻起按新天数重新计算;updatedAt 缺失(旧配置)时退回归档时刻语义。
102
+ * retentionDays <= 0 表示永久不删除,返回空集。纯函数:便于测试。
103
+ */
104
+ /** 一天的毫秒数(保留期按"天"配置,比较按毫秒)。 */
105
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
106
+ function expiredArchivedIds(items, retentionDays, now, updatedAt = 0) {
107
+ if (!(retentionDays > 0))
108
+ return [];
109
+ const cutoff = now - retentionDays * MS_PER_DAY;
110
+ const out = [];
111
+ for (const it of items) {
112
+ const archived = (typeof it.archivedAt === 'number' && Number.isFinite(it.archivedAt))
113
+ ? it.archivedAt
114
+ : (typeof it.createdAt === 'number' && Number.isFinite(it.createdAt) ? it.createdAt : undefined);
115
+ if (archived === undefined)
116
+ continue;
117
+ const baseline = updatedAt > 0 ? Math.max(archived, updatedAt) : archived;
118
+ if (baseline <= cutoff)
119
+ out.push(it.sessionId);
120
+ }
121
+ return out;
122
+ }
123
+ async function sweepHistory() {
124
+ const registry = getSessionsRegistry();
125
+ if (!registry)
126
+ return { swept: [] };
127
+ const { retentionDays, updatedAt } = await readHistoryRetention();
128
+ if (!(retentionDays > 0))
129
+ return { swept: [] };
130
+ try {
131
+ const details = (typeof registry.archivedSessionDetails === 'function')
132
+ ? (await registry.archivedSessionDetails()).items
133
+ : (await registry.archivedSessionMetadata()).items.map((i) => ({ sessionId: i.sessionId, createdAt: i.createdAt, archivedAt: registry.archivedAt?.(i.sessionId) }));
134
+ const expired = expiredArchivedIds(details, retentionDays, Date.now(), updatedAt);
135
+ if (expired.length === 0)
136
+ return { swept: [] };
137
+ const res = await registry.deleteArchivedSessions({ scope: 'sessions', sessionIds: expired });
138
+ return { swept: res.deletedSessionIds || [] };
139
+ }
140
+ catch (e) {
141
+ console.error('[dsh-plugin-tool-management] history sweep failed:', message(e));
142
+ return { swept: [] };
143
+ }
144
+ }
145
+ // 启动时扫一次,再周期复跑。fake-ctx 测试里 ctx.effect 立即调用并 dispose,
146
+ // interval 未提供时退化为不挂钟(不阻塞测试)。
147
+ try {
148
+ void sweepHistory();
149
+ }
150
+ catch { /* 非致命 */ }
151
+ try {
152
+ ctx.effect(() => {
153
+ const timer = ctx;
154
+ const fn = typeof timer.interval === 'function' ? timer.interval : (typeof timer.setInterval === 'function' ? timer.setInterval : undefined);
155
+ if (!fn)
156
+ return () => { };
157
+ return fn(() => { void sweepHistory(); }, sweepIntervalMs);
158
+ }, 'dsh-plugin-tool-management: history sweep');
159
+ }
160
+ catch { /* timer 缺失时静默 */ }
161
+ // 批量操作目标校验:scope 白名单;sessions 需非空 sessionIds;workspace 需
162
+ // workspaceId。与 ArchiveWorkspaceRegistry.archivedBatchTargetSchema 语义一致。
163
+ function parseHistoryBatchTarget(raw) {
164
+ const t = raw && typeof raw === 'object' ? raw : null;
165
+ if (!t)
166
+ return { ok: false, error: '缺少 target' };
167
+ const scope = t.scope;
168
+ if (scope !== 'all' && scope !== 'ungrouped' && scope !== 'sessions' && scope !== 'workspace') {
169
+ return { ok: false, error: 'target.scope 不合法' };
170
+ }
171
+ if (scope === 'sessions') {
172
+ if (!Array.isArray(t.sessionIds) || t.sessionIds.length === 0 ||
173
+ t.sessionIds.some((id) => typeof id !== 'string' || !String(id).trim())) {
174
+ return { ok: false, error: 'scope=sessions 需要非空的 sessionIds 数组' };
175
+ }
176
+ }
177
+ if (scope === 'workspace' && !(typeof t.workspaceId === 'string' && String(t.workspaceId).trim())) {
178
+ return { ok: false, error: 'scope=workspace 需要 workspaceId' };
179
+ }
180
+ return {
181
+ ok: true,
182
+ target: {
183
+ scope,
184
+ ...(scope === 'sessions' ? { sessionIds: t.sessionIds.map((s) => String(s).trim()) } : {}),
185
+ ...(scope === 'workspace' ? { workspaceId: String(t.workspaceId).trim() } : {}),
186
+ },
187
+ };
188
+ }
189
+ /**
190
+ * 归档会话的归属解析(三级,宿主侧算好再给客户端):
191
+ * 1. 活登记的 sessionIds 命中 → 组 = 该登记;
192
+ * 2. 未命中但规范路径命中某活登记 → 组 = 该登记(会话不在其记账里,属正常);
193
+ * 3. 没有任何活登记 → 按会话目录重建分组(`path:<归一化路径>`),
194
+ * 目录仍在则可一键重新登记(写回宿主),目录已不在则退到 header 里的原始 cwd。
195
+ * 宿主删除工作区是硬删、不留墓碑,登记一没,`workspaceId` 就再也查不到;但会话目录
196
+ * (`registry.sessionPaths` / header.cwd)还在,所以归属可以按目录重建、并在目录回来时
197
+ * 自动并回同一组。除插件自己 `data/` 下的「见过的登记」快照外,本函数不写任何宿主状态。
198
+ * 任何一步失败都降级为「不分组」,绝不阻断列表。
199
+ */
200
+ async function buildHistoryGroups(registry, items) {
201
+ const groups = [];
202
+ const workspaces = {};
203
+ try {
204
+ const groupsById = new Map();
205
+ const liveByPath = new Map();
206
+ const owned = new Map();
207
+ const liveRecords = [];
208
+ const table = typeof registry.requireTable === 'function' ? registry.requireTable() : undefined;
209
+ const state = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
210
+ if (table) {
211
+ const ids = state && Array.isArray(state.workspaceIds) && state.workspaceIds.length
212
+ ? state.workspaceIds
213
+ : [...table.entries()].map(([id]) => id);
214
+ for (const wid of ids) {
215
+ const rec = table.get(wid);
216
+ if (!rec || !Array.isArray(rec.sessionIds))
217
+ continue;
218
+ const path = typeof rec.path === 'string' && rec.path ? rec.path : undefined;
219
+ const title = (rec.title && String(rec.title)) || (path ? workspaceBaseName(path) : String(wid));
220
+ for (const sid of rec.sessionIds)
221
+ if (!owned.has(sid))
222
+ owned.set(sid, wid);
223
+ if (path) {
224
+ liveByPath.set(workspacePathKey(path), wid);
225
+ liveRecords.push({ id: String(wid), path, title });
226
+ }
227
+ const view = { id: 'ws:' + wid, title, kind: 'live', registered: true, ...(path ? { path } : {}) };
228
+ groupsById.set(view.id, view);
229
+ groups.push(view);
230
+ workspaces[wid] = { title, ...(path ? { path } : {}) };
231
+ }
232
+ }
233
+ // 「见过的登记」快照:登记被删后仍能给出原命名,并区分「已移除」与「从未登记」。
234
+ const snapshot = typeof registry.workspaceSnapshot === 'function' ? await registry.workspaceSnapshot() : undefined;
235
+ if (liveRecords.length && typeof registry.rememberWorkspaces === 'function') {
236
+ try {
237
+ await registry.rememberWorkspaces(liveRecords);
238
+ }
239
+ catch { /* 快照写失败不影响分组 */ }
240
+ }
241
+ const sessionPaths = registry.sessionPaths instanceof Map ? registry.sessionPaths : undefined;
242
+ const headers = registry.headers instanceof Map ? registry.headers : undefined;
243
+ // 「目录真实存在」检查缓存:canRegister 从「宿主 sessionPaths 命中」放宽为
244
+ // 「目录存在」时,同一目录只 stat 一次;结果按归一化键缓存,避免每次刷新重复探盘。
245
+ const dirExistsCache = new Map();
246
+ async function directoryExists(p) {
247
+ const k = workspacePathKey(p);
248
+ const hit = dirExistsCache.get(k);
249
+ if (hit !== undefined)
250
+ return hit;
251
+ let ok = false;
252
+ try {
253
+ ok = (await stat(p)).isDirectory();
254
+ }
255
+ catch {
256
+ ok = false;
257
+ }
258
+ dirExistsCache.set(k, ok);
259
+ return ok;
260
+ }
261
+ for (const it of items) {
262
+ const sid = it.sessionId;
263
+ let wid = owned.get(sid);
264
+ const canonical = sessionPaths ? sessionPaths.get(sid) : undefined;
265
+ if (wid === undefined && typeof canonical === 'string' && canonical) {
266
+ const byPath = liveByPath.get(workspacePathKey(canonical));
267
+ if (byPath !== undefined)
268
+ wid = byPath;
269
+ }
270
+ if (wid !== undefined) {
271
+ it.workspaceId = wid;
272
+ it.groupId = 'ws:' + wid;
273
+ continue;
274
+ }
275
+ let path = typeof canonical === 'string' && canonical ? canonical : undefined;
276
+ if (path === undefined && it.cwd)
277
+ path = it.cwd;
278
+ if (path === undefined && headers) {
279
+ const cwd = headers.get(sid)?.cwd;
280
+ if (typeof cwd === 'string' && cwd)
281
+ path = cwd;
282
+ }
283
+ if (!path)
284
+ continue;
285
+ const key = 'path:' + workspacePathKey(path);
286
+ it.groupId = key;
287
+ const known = snapshot ? snapshot.get(workspacePathKey(path)) : undefined;
288
+ const existing = groupsById.get(key);
289
+ if (existing) {
290
+ // 同目录的多个会话:宿主索引命中,或磁盘目录真实存在,即可重新登记。
291
+ if (canonical !== undefined || (await directoryExists(path))) {
292
+ existing.canRegister = true;
293
+ existing.dirMissing = false;
294
+ }
295
+ continue;
296
+ }
297
+ const canRegister = canonical !== undefined || (await directoryExists(path));
298
+ const view = {
299
+ id: key,
300
+ title: known?.title || workspaceBaseName(path) || path,
301
+ kind: 'detached',
302
+ registered: known !== undefined,
303
+ canRegister,
304
+ dirMissing: !canRegister,
305
+ path: known?.path || path,
306
+ };
307
+ groupsById.set(key, view);
308
+ groups.push(view);
309
+ }
310
+ }
311
+ catch { /* 分组是增强:任何异常都退回扁平列表 */ }
312
+ return { groups, workspaces };
313
+ }
314
+ return {
315
+ getSessionsRegistry,
316
+ restoreWorkspaceAccounting,
317
+ readHistoryRetention,
318
+ writeHistoryRetention,
319
+ sweepHistory,
320
+ parseHistoryBatchTarget,
321
+ buildHistoryGroups,
322
+ };
323
+ }
@@ -1,7 +1,7 @@
1
1
  //#region lib/types/tombstone.js
2
2
  /**
3
3
  * 通用墓碑簿记:登记一个已删除 id,并按 FIFO 在上限处淘汰最旧项。
4
- * 工作区注册表(lib/history/workspace.js)在用;淘汰策略集中在这里,避免各处拷贝分叉。
4
+ * 工作区注册表(lib/sessions/workspace.js)在用;淘汰策略集中在这里,避免各处拷贝分叉。
5
5
  */
6
6
  /**
7
7
  * 登记已删除 id 并执行上限淘汰。
@@ -1,5 +1,5 @@
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";
@@ -13,7 +13,7 @@ import { trackTombstone } from "./tombstone.js";
13
13
  * ——全量 grep `archivedSessionDetails` / `deleteArchivedSessions` / `unarchiveSessions`
14
14
  * 均不存在,`@michengai/dsh-archive-manager` 也未安装。因此下面所有
15
15
  * `typeof this.registry.X === "function"` 的委托分支在当前环境**都是死代码**,
16
- * 实际执行路径永远是 history/bridge.js 的私有适配(依赖官方声明为 private 的方法,
16
+ * 实际执行路径永远是 sessions/bridge.ts 的私有适配(依赖官方声明为 private 的方法,
17
17
  * 并直接改 registry.headers / sessionPaths / invalidSessionPaths)。
18
18
  * 所以「不再替换服务、不再与外部插件争注册」成立,但**不能由此推出耦合下降或零侵入**。
19
19
  * 另:改为门面后新增的墓碑 indexHeader 守卫只覆盖本门面的调用;宿主自身的
@@ -128,6 +128,7 @@ const deletedSchema = {
128
128
  },
129
129
  };
130
130
  const archivedBatchTargetSchema = {
131
+ // 校验通过后原样返回记录,调用方按 scope 判别式收窄(宿主返回值校验器,故返回 any)。
131
132
  parse(value) {
132
133
  if (typeof value !== "object" || value === null || Array.isArray(value))
133
134
  throw new TypeError("target must be an object");
@@ -229,7 +230,14 @@ function defaultWorkspaceSnapshotFile() {
229
230
  /**
230
231
  * 路径比较键:工作区的唯一性在宿主侧是 realpath 字符串相等(dsh-workspace
231
232
  * `realpathNormalize`),Windows 盘符大小写与分隔符拼写却可能不同,因此比较前
232
- * 先归一化。仅用于**分组与快照索引**,不参与任何写入或归属判定。
233
+ * 先归一化。
234
+ *
235
+ * ⚠ 它不是"只用于分组":它决定**哪些会话被挂回哪条登记** —— `registerWorkspace` 会调
236
+ * `attachKnownSessions`,后者按这个键挑选会话并写宿主记账(`sessionIds`);
237
+ * `ensureWorkspaceAccounting` 也拿它当去重键后 `host.create` + 挂回。
238
+ * 而且它比宿主更宽:宿主 `realpathNormalize` 只是 `fs.realpath`(**不做大小写归一**),
239
+ * 本函数在 Windows 上 `toLowerCase()` ⇒ `C:\Proj` 与 `c:\proj` 在插件里同组、在宿主里
240
+ * 可以是两条登记。挂回前应以宿主 `resolveByPath` 实际返回的 id 复核,而不是按归一字符串匹配。
233
241
  * @param {string} path - 原始路径。
234
242
  * @returns {string} 归一化后的比较键(空路径返回空串)。
235
243
  */
@@ -263,6 +271,16 @@ function workspaceBaseName(path) {
263
271
  // Standalone facade over the existing registry. Never instantiate/register a
264
272
  // second WorkspaceRegistry, and never open another copy of its storage domain.
265
273
  var ArchiveWorkspaceRegistry = class {
274
+ /** 宿主注入的上下文(服务读取按动态面处理,见 `HostContext`)。 */
275
+ ctx;
276
+ /** 宿主注册表实例;构造期由入参收窄,随后被 bridge 收窄后的实例覆盖。 */
277
+ registry;
278
+ /** sessions/bridge.ts 的适配句柄(能力门禁 + 私有接口包装)。 */
279
+ bridge;
280
+ /** 账本写入串行链(保证读-改-写不互相覆盖)。 */
281
+ ledgerTail;
282
+ /** 删除中的会话 id 集合(防 SUBAGENT 级联自环),首次用到才建。 */
283
+ deleting;
266
284
  /** 本进程内已物理删除的会话;阻止父类把 stale list() 重新编入索引。 */
267
285
  deletedSessionIds = /* @__PURE__ */ new Set();
268
286
  /** 墓碑插入顺序,用于在上限处淘汰最旧项。 */
@@ -272,8 +290,8 @@ var ArchiveWorkspaceRegistry = class {
272
290
  /** 被删生命周期的日志身份(createdAt/cwd):冷复用探针区分“同 id 新会话”与 stale list() 的依据。 */
273
291
  deletedIdentities = /* @__PURE__ */ new Map();
274
292
  /** 会话 → 归档时刻(epoch ms);保留期自动删除的基线。 */
275
- archivedAtMap = /* @__PURE__ */ new Map();
276
- archivedAtFile = null;
293
+ archivedAtMap = new Map();
294
+ archivedAtFile;
277
295
  archivedAtLoaded = false;
278
296
  /**
279
297
  * 「见过的登记」快照:归一化路径 → { path, title, id, at }。
@@ -282,8 +300,8 @@ var ArchiveWorkspaceRegistry = class {
282
300
  * 使按目录重建的分组仍能显示用户命名,并能区分「曾经登记过、现在被移除」
283
301
  * 与「从未登记过的目录」。只读辅助信息,不参与任何归属判定或写入。
284
302
  */
285
- workspaceSnapshotMap = /* @__PURE__ */ new Map();
286
- workspaceSnapshotFile = null;
303
+ workspaceSnapshotMap = new Map();
304
+ workspaceSnapshotFile;
287
305
  workspaceSnapshotLoaded = false;
288
306
  /** 快照上限:足够覆盖常见工作区数,又避免长驻进程无限增长。 */
289
307
  workspaceSnapshotLimit = 200;
@@ -293,7 +311,11 @@ var ArchiveWorkspaceRegistry = class {
293
311
  */
294
312
  ready = Promise.resolve();
295
313
  constructor(ctx, registry, config = {}) {
314
+ // 注入的 `sessionPersistence` 不在官方 `Context` 声明里(index.ts 的 inject 列表里有它),
315
+ // 因此这里按本文件的使用面收窄(其余成员仍走官方 `Context`)。
296
316
  this.ctx = ctx;
317
+ // 入参按「官方声明可见的公开面」声明(四个索引 Map 在官方 `WorkspaceRegistry` 里是 private,
318
+ // 运行时存在且由 bridge 的形状检查兜底),因此此处收窄为本文件实际使用的注册表面。
297
319
  this.registry = registry;
298
320
  this.archivedAtFile = config.archivedAtFile || defaultArchivedAtFile();
299
321
  this.workspaceSnapshotFile = config.workspaceSnapshotFile || defaultWorkspaceSnapshotFile();
@@ -301,7 +323,8 @@ var ArchiveWorkspaceRegistry = class {
301
323
  this.ready = Promise.resolve(config.ready).then(() => undefined, () => undefined);
302
324
  }
303
325
  this.ledgerTail = Promise.resolve();
304
- this.bridge = createHistoryBridge(ctx, registry, (id, at) => this.recordArchive(id, at));
326
+ this.bridge = createSessionsBridge(ctx, registry, (id, at) => this.recordArchive(id, at));
327
+ // bridge 收窄后的注册表同样只声明运行时形状,这里还原为本文件的注册表面。
305
328
  this.registry = this.bridge.registry;
306
329
  this.bridge.observe();
307
330
  }
@@ -366,6 +389,7 @@ var ArchiveWorkspaceRegistry = class {
366
389
  this.archivedAtLoaded = true;
367
390
  try {
368
391
  const raw = await readFile(this.archivedAtFile, "utf8");
392
+ // 账本文件由本文件写出(id → epoch ms);非有限值在下面逐个跳过。
369
393
  const obj = JSON.parse(raw);
370
394
  if (obj && typeof obj === "object") {
371
395
  for (const [k, v] of Object.entries(obj)) {
@@ -514,6 +538,7 @@ var ArchiveWorkspaceRegistry = class {
514
538
  cwd = header && typeof header.cwd === "string" && header.cwd ? header.cwd : undefined;
515
539
  }
516
540
  catch (error) {
541
+ // 宿主/文件系统抛出的错误按动态形状读 message(strict 下 catch 变量为 unknown)。
517
542
  result.skipped.push({ sessionId, reason: `读不到会话头部:${String(error?.message ?? error)}` });
518
543
  continue;
519
544
  }
@@ -541,6 +566,7 @@ var ArchiveWorkspaceRegistry = class {
541
566
  result.registered.push(record);
542
567
  }
543
568
  catch (error) {
569
+ // 宿主 realpath 拒绝的原始错误按动态形状读 message。
544
570
  entry.failure = `无法解析工作区目录「${cwd}」:${String(error?.message ?? error)}`;
545
571
  }
546
572
  }
@@ -560,6 +586,7 @@ var ArchiveWorkspaceRegistry = class {
560
586
  result.attached.push(sessionId);
561
587
  }
562
588
  catch (error) {
589
+ // 宿主 attachSession 拒绝原因按动态形状读 message。
563
590
  result.skipped.push({ sessionId, reason: String(error?.message ?? error) });
564
591
  }
565
592
  }
@@ -596,6 +623,7 @@ var ArchiveWorkspaceRegistry = class {
596
623
  const created = entity === undefined;
597
624
  if (created)
598
625
  entity = await host.create(target, typeof title === "string" && title.trim() ? title.trim() : undefined);
626
+ // 解析不到即走上面的 create 分支,此后 entity 必定已定义(类型层无法表达该不变式)。
599
627
  const record = {
600
628
  id: String(entity.id),
601
629
  path: String(entity.path ?? target),
@@ -633,6 +661,7 @@ var ArchiveWorkspaceRegistry = class {
633
661
  attached.push(sessionId);
634
662
  }
635
663
  catch (error) {
664
+ // 宿主 attachSession 拒绝原因按动态形状读 message。
636
665
  attachSkipped.push({ sessionId, reason: String(error?.message ?? error) });
637
666
  }
638
667
  }
@@ -917,12 +946,15 @@ var ArchiveWorkspaceRegistry = class {
917
946
  }
918
947
  return this.enqueueOperation(async () => {
919
948
  const requestedSessionIds = this.archivedSessionIdsForTarget(target);
949
+ // 批首取一次权威快照:批内每一条删除都要一次全库 `listStoredHeaders()`(含逐会话
950
+ // 读首行),这批复用同一份。取不到就退回逐条取 —— 快照失败不该废掉整批。
951
+ const storedIndex = await this.storedHeaderIndex().catch(() => undefined);
920
952
  const deletedSessionIds = [];
921
953
  const skippedSessionIds = [];
922
954
  const failures = [];
923
955
  for (const sessionId of requestedSessionIds) {
924
956
  try {
925
- await this.deleteSessionCore(sessionId);
957
+ await this.deleteSessionCore(sessionId, storedIndex);
926
958
  deletedSessionIds.push(sessionId);
927
959
  }
928
960
  catch (error) {
@@ -963,6 +995,11 @@ var ArchiveWorkspaceRegistry = class {
963
995
  // 等待删除期间可能已进入的写回观察到墓碑并完成补删,再允许
964
996
  // 同 id 的未来新生命周期写入缓存。
965
997
  await projCache.whenIdle?.();
998
+ // 预留位:宿主**没有** `clearTombstone` 入口(全官方树 grep 零命中),
999
+ // 所以这里命中的是本插件 `bridge` guard 的墓碑(blocked.delete)——
1000
+ // "已删会话会不会被误复活" 100% 取决于插件自己的两份内存墓碑
1001
+ //(workspace 侧 FIFO-4096 + guard.blocked 无上限),插件重载即全清。
1002
+ // 将来宿主补了 delete+whenIdle 时 bridge 会提前 return,这行才退化成对宿主对象的空转。
966
1003
  projCache.clearTombstone?.(sessionId);
967
1004
  }
968
1005
  await this.cleanSpill(sessionId);
@@ -1017,8 +1054,9 @@ var ArchiveWorkspaceRegistry = class {
1017
1054
  }
1018
1055
  return this.enqueueOperation(() => this.deleteSessionCore(sessionId));
1019
1056
  }
1020
- /** 串行化后的删除主体(级联路径复用:它已持有操作链,绝不能再入队)。 */
1021
- async deleteSessionCore(sessionId) {
1057
+ /** 串行化后的删除主体(级联路径复用:它已持有操作链,绝不能再入队)。
1058
+ * @param storedIndex - 可选的批首 `id → header` 快照,见 {@link storedHeaderIndex}。 */
1059
+ async deleteSessionCore(sessionId, storedIndex) {
1022
1060
  if (typeof this.registry.deleteSession === "function")
1023
1061
  return this.registry.deleteSession(sessionId);
1024
1062
  if (this.deleting?.has(sessionId))
@@ -1026,10 +1064,12 @@ var ArchiveWorkspaceRegistry = class {
1026
1064
  this.deleting ??= new Set();
1027
1065
  this.deleting.add(sessionId);
1028
1066
  try {
1029
- return await this.deleteLocalSession(sessionId);
1067
+ return await this.deleteLocalSession(sessionId, storedIndex);
1030
1068
  }
1031
1069
  catch (error) {
1032
1070
  // Failed deletion must not permanently suppress a still-live session.
1071
+ // (`clearTombstone` 是**预留位**:宿主无此入口,实际命中的是本插件 bridge guard
1072
+ // 的墓碑 —— 说明见 `cleanupUnknownArchivedSession` 里那一处。)
1033
1073
  this.getProjectionCache()?.clearTombstone?.(sessionId);
1034
1074
  throw error;
1035
1075
  }
@@ -1037,8 +1077,8 @@ var ArchiveWorkspaceRegistry = class {
1037
1077
  this.deleting.delete(sessionId);
1038
1078
  }
1039
1079
  }
1040
- async deleteLocalSession(sessionId) {
1041
- if (!(await this.sessionKnown(sessionId)))
1080
+ async deleteLocalSession(sessionId, storedIndex) {
1081
+ if (!(await this.sessionKnown(sessionId, storedIndex)))
1042
1082
  throw new ArchiveUnknownSessionError(sessionId);
1043
1083
  const sessions = this.ctx.get("sessions");
1044
1084
  // 快速失败:删掉的顺序(flush → detach → 清缓存 → 删目录 → 清记账)里,
@@ -1061,8 +1101,13 @@ var ArchiveWorkspaceRegistry = class {
1061
1101
  // 持久化屏障先行:不能有未落盘的转录写入与目录删除竞争
1062
1102
  //(持久化后端按批关闭句柄,flush 过的会话不再持有打开的文件)。
1063
1103
  await sessions.flush(live);
1064
- // 从存储分离;`session/disposed` 同步触发,驱动浏览器端的
1065
- // `host/session-removed` 帧并启动投影缓存的最终写后落盘。
1104
+ // 从存储分离;`session/disposed` 同步触发(payload 是 **Session 对象**),
1105
+ // 由官方 `dsh-api-session-controller` 降级成 `api-session/removed`(payload 已降为
1106
+ // **SessionId 字符串**)再转发到浏览器,并启动投影缓存的最终写后落盘。
1107
+ // 名字别写成 `host/session-removed` —— 那个帧名在整个已安装树里不存在
1108
+ //(`host/…` 是 cordis 服务/槽位名的前缀,不是线上帧命名空间)。
1109
+ // 注意 `api-session/removed` 也是"发布回滚"与"作用域剪枝"发出的同一事件
1110
+ //(官方 `dsh-session/lib/types/index.d.ts`),所以收到它**不能**等同于"用户删了会话"。
1066
1111
  const entry = sessions.liveEntryFor(live);
1067
1112
  sessions.detachEntered(entry);
1068
1113
  }
@@ -1074,7 +1119,7 @@ var ArchiveWorkspaceRegistry = class {
1074
1119
  await projCache?.whenIdle?.();
1075
1120
  if (projCache !== void 0)
1076
1121
  await projCache.delete(sessionId);
1077
- await this.deleteDescendants(sessionId);
1122
+ await this.deleteDescendants(sessionId, storedIndex);
1078
1123
  await this.cleanSpill(sessionId);
1079
1124
  await this.removeTranscriptDirectory(sessionId);
1080
1125
  // 只有物理工件删除成功后才能提交记账清理;否则批量目标会因归档标记
@@ -1116,6 +1161,7 @@ var ArchiveWorkspaceRegistry = class {
1116
1161
  this.deletedSessionOrder.splice(idx, 1);
1117
1162
  // workspace 与 projection-cache 共同描述同一删除生命周期;新生命周期
1118
1163
  // 被接纳时必须同步撤销两处墓碑,否则缓存写入仍会永久被拦截。
1164
+ // 右侧那处同样是**预留位**(宿主无 `clearTombstone`,命中的是 bridge guard 的墓碑)。
1119
1165
  this.getProjectionCache()?.clearTombstone?.(sessionId);
1120
1166
  }
1121
1167
  forgetIndexedSession(sessionId) {
@@ -1129,7 +1175,7 @@ var ArchiveWorkspaceRegistry = class {
1129
1175
  * 已删除会话对归档/删除入口都视为未知。实时复用同一 id 时撤墓碑,
1130
1176
  * 避免挡住新会话。
1131
1177
  */
1132
- async sessionKnown(id) {
1178
+ async sessionKnown(id, storedIndex) {
1133
1179
  if (this.ctx.get("sessions")?.get(id) !== void 0) {
1134
1180
  this.clearTombstone(id);
1135
1181
  return true;
@@ -1137,7 +1183,10 @@ var ArchiveWorkspaceRegistry = class {
1137
1183
  if (this.deletedSessionIds.has(id))
1138
1184
  return this.coldReuseKnown(id);
1139
1185
  // Refresh from authoritative persistence, not the host's stale header cache.
1140
- const header = (await this.listStoredHeaders()).find((item) => item.id === id);
1186
+ // 批量删除会传入**批首快照**(同一批里 k 次全库 list() 是纯浪费);不传就是逐条取新鲜数据。
1187
+ const header = storedIndex !== void 0
1188
+ ? storedIndex.get(id)
1189
+ : (await this.listStoredHeaders()).find((item) => item.id === id);
1141
1190
  if (!header)
1142
1191
  return false;
1143
1192
  await this.indexHeader(header);
@@ -1186,6 +1235,19 @@ var ArchiveWorkspaceRegistry = class {
1186
1235
  async listStoredHeaders() {
1187
1236
  return (await this.ctx.sessionPersistence.list()).map((item) => item.header ?? item);
1188
1237
  }
1238
+ /**
1239
+ * `id → header` 索引,供**一次批量操作内部**复用:`listStoredHeaders()` 每次都是
1240
+ * 全库遍历 + 逐会话读首行,批量里逐条调用就是 O(k·n)。
1241
+ *
1242
+ * 只在批内有效 —— 它不随删除自动剔除,也不代表"此刻磁盘上的最新";需要后者做判据的
1243
+ * 地方(`coldReuseKnown` 的同 id 新生命周期比对)仍然自己取新鲜数据。
1244
+ */
1245
+ async storedHeaderIndex() {
1246
+ const index = new Map();
1247
+ for (const header of await this.listStoredHeaders())
1248
+ index.set(header.id, header);
1249
+ return index;
1250
+ }
1189
1251
  async indexHeaders(items) {
1190
1252
  for (const item of items)
1191
1253
  await this.indexHeader(item.header ?? item);
@@ -1236,6 +1298,7 @@ var ArchiveWorkspaceRegistry = class {
1236
1298
  stat = await lstat(path);
1237
1299
  }
1238
1300
  catch (error) {
1301
+ // Node 文件系统错误的 code 按动态形状读取(此处只关心 ENOENT)。
1239
1302
  if (error?.code === "ENOENT")
1240
1303
  continue; // Already removed on an earlier attempt.
1241
1304
  throw error;
@@ -1277,6 +1340,7 @@ var ArchiveWorkspaceRegistry = class {
1277
1340
  sessionIds: current.sessionIds.filter((id) => id !== sessionId),
1278
1341
  updatedAt: /* @__PURE__ */ new Date().toISOString(),
1279
1342
  }));
1343
+ // 实体快照的形状由宿主 `WorkspaceEntity` 决定(`record` 是私有字段,运行时存在)。
1280
1344
  const entity = this.entities.get(workspaceId);
1281
1345
  if (entity !== void 0)
1282
1346
  entity.record = next;
@@ -1284,8 +1348,9 @@ var ArchiveWorkspaceRegistry = class {
1284
1348
  }
1285
1349
  /** 尽力而为的级联删除:删除 `sessionId` 的 SUBAGENT 子会话。
1286
1350
  * 仅头部标记 `origin: "subagent"` 的会话参与:单凭 `parentSession` 有歧义
1287
- *(fork 分支也携带它),而 fork 分支是独立的用户会话,绝不能被级联删除。 */
1288
- async deleteDescendants(sessionId) {
1351
+ *(fork 分支也携带它),而 fork 分支是独立的用户会话,绝不能被级联删除。
1352
+ * @param storedIndex - 可选的批首 `id → header` 快照,见 {@link storedHeaderIndex}。 */
1353
+ async deleteDescendants(sessionId, storedIndex) {
1289
1354
  try {
1290
1355
  const descendants = [];
1291
1356
  const sessions = this.ctx.get("sessions");
@@ -1295,7 +1360,10 @@ var ArchiveWorkspaceRegistry = class {
1295
1360
  session.header.origin === "subagent")
1296
1361
  descendants.push(session.id);
1297
1362
  }
1298
- for (const header of await this.listStoredHeaders()) {
1363
+ const headers = storedIndex !== void 0
1364
+ ? [...storedIndex.values()]
1365
+ : await this.listStoredHeaders();
1366
+ for (const header of headers) {
1299
1367
  if (header.parentSession === sessionId &&
1300
1368
  header.origin === "subagent" &&
1301
1369
  !descendants.includes(header.id))
@@ -1303,9 +1371,9 @@ var ArchiveWorkspaceRegistry = class {
1303
1371
  }
1304
1372
  for (const childId of descendants) {
1305
1373
  try {
1306
- if (!(await this.sessionKnown(childId)))
1374
+ if (!(await this.sessionKnown(childId, storedIndex)))
1307
1375
  continue;
1308
- await this.deleteSessionCore(childId);
1376
+ await this.deleteSessionCore(childId, storedIndex);
1309
1377
  }
1310
1378
  catch (error) {
1311
1379
  if (error instanceof ArchiveUnknownSessionError)