dsh-session-curator 0.1.1 → 0.1.2

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.
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-session-curator — host 半区。
2
+ * dsh-session-curator — host 半区入口。
3
3
  *
4
4
  * 提供三块能力:
5
5
  * 1. 会话软删除:先把会话**藏起来**(进 registry-global 的归档集合 —— DSH 里唯一能让它
@@ -7,7 +7,7 @@
7
7
  * 工作区记账里摘掉(`Workspace.detachSession`),最后把
8
8
  * `<DSH_HOME>/sessions/<slug>/<sessionId>/` 整个搬到
9
9
  * `<DSH_HOME>/storages/session-manager/trash/<sessionId>/`。可完整恢复。
10
- * ⚠️ 目录名 `session-manager` 是**故意保留的旧名**(见下面 TRASH_SUBPATH 的注释)。
10
+ * ⚠️ 目录名 `session-manager` 是**故意保留的旧名**(见 host/paths.ts)。
11
11
  * 2. 回收站管理:列出 / 恢复 / 永久删除。
12
12
  * 3. 归档会话管理:列出全部归档会话(标题、目录、体积),支持批量取消归档、置顶,
13
13
  * 以及直接删除。归档/取消归档/置顶全部走官方 `ctx.workspaceRegistry`。
@@ -15,685 +15,20 @@
15
15
  * 对客户端暴露一组挂在共享 `/api` 通道下的精确 Fetch 路由(
16
16
  * `ctx.connection.fetch.register`):信任检查与会话鉴权由 Connection 的 `/api`
17
17
  * 前缀路由先做完,所以插件自己不需要任何鉴权代码。
18
+ *
19
+ * 源码在 `src/`(TypeScript),`lib/` 是构建产物(`npm run build`),DSH 加载的是后者。
18
20
  */
19
- import { promises as fsp, existsSync, readFileSync } from "node:fs";
20
- import { homedir } from "node:os";
21
- import { dirname, join, resolve } from "node:path";
22
-
23
- export const name = "dsh-session-curator";
21
+ import { SessionManager } from "./host/session-manager.js";
22
+ import { registerRoutes } from "./host/routes.js";
23
+ import { PLUGIN_NAME } from "./host/manifest.js";
24
+ /** 插件名(DSH 的插件 id,同时也是 npm 包名)。 */
25
+ export const name = PLUGIN_NAME;
24
26
  // `connection` 承载 HTTP 面;`webServer` 是硬依赖(Connection 只在 webServer 存在时
25
27
  // 才挂载 `/api` 通道,没有它插件就不可达);`workspaceRegistry` 是归档/置顶/记账的来源。
26
28
  export const inject = ["connection", "webServer", "workspaceRegistry"];
27
-
28
- // ── DSH home 解析 ────────────────────────────────────────────────────────
29
- // 这里刻意**不 import** `@deepseek-ai/dsh-home-paths`:那个包只在 DSH 自己的
30
- // node_modules 里,而插件可能以 junction/symlink 形式挂在 profile 下 —— 此时 Node 会
31
- // 按 realpath 解析裸标识符,从插件源码目录向上找不到它,插件就加载不了。语义与那个包
32
- // 一致(`$DSH_HOME` 优先,否则 `~/.dsh`),几行就够,换来的是"插件放哪都能跑"。
33
- function resolveDshHome() {
34
- const configured = process.env.DSH_HOME;
35
- if (typeof configured === "string" && configured.trim() !== "") {
36
- const expanded = configured.trim();
37
- if (expanded === "~") return homedir();
38
- if (expanded.startsWith("~/") || expanded.startsWith("~\\")) return resolve(join(homedir(), expanded.slice(2)));
39
- return resolve(expanded);
40
- }
41
- return resolve(join(homedir(), ".dsh"));
42
- }
43
-
44
- function dshHomePath(...segments) {
45
- return join(resolveDshHome(), ...segments);
46
- }
47
-
48
- /** DSH 的共享 API 通道:唯一带 Host/Origin 信任栅栏与浏览器会话鉴权的 HTTP 面。 */
49
- const API_CHANNEL = "/api";
50
- /** 频道下属于本插件的路径前缀(`/api/session-curator/<endpoint>`)。 */
51
- const ROUTE_PREFIX = "session-curator";
52
- /** 端点只读 / 只写,一律 POST(Connection 的精确路由按方法集合匹配)。 */
53
- const ROUTE_METHODS = ["POST"];
54
- /**
55
- * 回收站根目录:`<DSH_HOME>/storages/session-manager/trash`。
56
- *
57
- * 这里的 `session-manager` 是**故意不改的旧名**:插件从 `dsh-session-manager` 改名成
58
- * `dsh-session-curator` 时,回收站里可能还躺着用户已经删掉的会话(本机就有一个)——
59
- * 改路径会把它们变成孤儿。数据目录用历史名、其余 id 用新名,是权衡后的结果。
60
- */
61
- const TRASH_SUBPATH = ["storages", "session-manager", "trash"];
62
- const META_FILE = "__trash.json";
63
- const PROJ_FILE = "__projcache.json";
64
-
65
- // ── 版本与更新检查 ───────────────────────────────────────────────────────
66
- /**
67
- * 本地版本:`lib/index.js` 的上一级就是包根(无论插件是装在 `node_modules` 里还是被
68
- * junction 指过来,`import.meta.url` 都指向真实文件),所以直接读自己的 manifest。
69
- */
70
- const LOCAL_VERSION = readLocalVersion();
71
-
72
- function readLocalVersion() {
73
- try {
74
- const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
75
- return typeof manifest.version === "string" && manifest.version !== "" ? manifest.version : null;
76
- } catch {
77
- return null;
78
- }
79
- }
80
-
81
- /**
82
- * 更新检查:问 npm registry 的 `latest` 标签。
83
- *
84
- * 为什么放在**宿主**而不是客户端直接 fetch:客户端那侧要过 CORS / 可能的 CSP 和桌面端网络
85
- * 策略,宿主本来就是普通 Node,顺便还能做超时与缓存。只读 GET,没有任何副作用。
86
- */
87
- const UPDATE_REGISTRY = "https://registry.npmjs.org";
88
- /** 10 分钟内复用上次结果:打开配置页不该每次都打 registry。 */
89
- const UPDATE_TTL_MS = 10 * 60 * 1000;
90
- const UPDATE_TIMEOUT_MS = 5000;
91
- let updateCache = null;
92
-
93
- /**
94
- * 宽松 semver 比较:只按数字段比,带 prerelease 后缀的算更低
95
- * (`0.1.1` > `0.1.1-rc.1`,`0.2.0` > `0.1.9`)。够判断"有没有新版",不引依赖。
96
- * 导出是为了让 `scripts/smoke.mjs` 直接单测它(浏览器里用不到)。
97
- */
98
- export function compareVersions(left, right) {
99
- const parse = (value) => {
100
- const [core, ...rest] = String(value).replace(/^v/, "").split("-");
101
- return { parts: core.split(".").map((part) => Number.parseInt(part, 10) || 0), prerelease: rest.length > 0 };
102
- };
103
- const a = parse(left);
104
- const b = parse(right);
105
- for (let index = 0; index < Math.max(a.parts.length, b.parts.length); index += 1) {
106
- const difference = (a.parts[index] ?? 0) - (b.parts[index] ?? 0);
107
- if (difference !== 0) return difference > 0 ? 1 : -1;
108
- }
109
- if (a.prerelease === b.prerelease) return 0;
110
- return a.prerelease ? -1 : 1;
111
- }
112
-
113
- async function checkForUpdate(force) {
114
- const now = Date.now();
115
- if (force !== true && updateCache !== null && now - updateCache.at < UPDATE_TTL_MS) return updateCache.value;
116
- const base = { current: LOCAL_VERSION, latest: null, outdated: false, checkedAt: now };
117
- let value;
118
- try {
119
- const response = await fetch(`${UPDATE_REGISTRY}/${name}/latest`, {
120
- headers: { accept: "application/json" },
121
- signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
122
- });
123
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
124
- const body = await response.json();
125
- const latest = typeof body?.version === "string" && body.version !== "" ? body.version : null;
126
- if (latest === null) throw new Error("registry 没有返回 version 字段");
127
- value = {
128
- ...base,
129
- latest,
130
- outdated: LOCAL_VERSION !== null && compareVersions(latest, LOCAL_VERSION) > 0,
131
- checkedAt: Date.now()
132
- };
133
- } catch (error) {
134
- // 离线 / 超时 / 代理不通都走这里:界面照常显示版本号,只是没有更新提示。
135
- value = { ...base, error: messageOf(error) };
136
- }
137
- updateCache = { at: Date.now(), value };
138
- return value;
139
- }
140
-
141
- // ── 文件系统小工具 ───────────────────────────────────────────────────────
142
- function trashRoot() {
143
- return dshHomePath(...TRASH_SUBPATH);
144
- }
145
-
146
- function sessionsRoot() {
147
- return dshHomePath("sessions");
148
- }
149
-
150
- function projCacheFile(sessionId) {
151
- return dshHomePath("storages", "session_projcache", "sessions", `${sessionId}.json`);
152
- }
153
-
154
- function messageOf(error) {
155
- if (error instanceof Error) return error.message;
156
- return String(error);
157
- }
158
-
159
- /** 跨卷 rename 会抛 EXDEV,退回 copy + rm。 */
160
- async function moveDir(from, to) {
161
- await fsp.mkdir(dirname(to), { recursive: true });
162
- try {
163
- await fsp.rename(from, to);
164
- return;
165
- } catch (error) {
166
- if (error?.code !== "EXDEV") throw error;
167
- }
168
- await fsp.cp(from, to, { recursive: true, force: true });
169
- await fsp.rm(from, { recursive: true, force: true });
170
- }
171
-
172
- // ── 会话管理器 ───────────────────────────────────────────────────────────
173
- class SessionManager {
174
- constructor(ctx) {
175
- this.ctx = ctx;
176
- }
177
-
178
- get registry() {
179
- return this.ctx.workspaceRegistry;
180
- }
181
-
182
- // ── 只读投影 ─────────────────────────────────────────────────────────
183
- /** <DSH_HOME>/sessions/<slug>/<sessionId> → { dir, slug } 索引。 */
184
- async scanSessionDirs() {
185
- const root = sessionsRoot();
186
- const map = new Map();
187
- let slugs;
188
- try {
189
- slugs = await fsp.readdir(root, { withFileTypes: true });
190
- } catch {
191
- return map;
192
- }
193
- for (const slug of slugs) {
194
- if (!slug.isDirectory()) continue;
195
- let ids;
196
- try {
197
- ids = await fsp.readdir(join(root, slug.name), { withFileTypes: true });
198
- } catch {
199
- continue;
200
- }
201
- for (const id of ids) {
202
- if (!id.isDirectory()) continue;
203
- if (!map.has(id.name)) map.set(id.name, { dir: join(root, slug.name, id.name), slug: slug.name });
204
- }
205
- }
206
- return map;
207
- }
208
-
209
- /** 投影缓存里的会话元数据(标题 / cwd / 创建时间)。缺文件时返回空字段。 */
210
- async readProjection(sessionId) {
211
- try {
212
- const parsed = JSON.parse(await fsp.readFile(projCacheFile(sessionId), "utf8"));
213
- const rows = parsed?.record?.rows;
214
- const identity = parsed?.record?.identity;
215
- const title = rows?.title?.val;
216
- return {
217
- title: typeof title === "string" && title !== "" ? title : null,
218
- cwd: typeof identity?.cwd === "string" ? identity.cwd : null,
219
- createdAt: typeof identity?.createdAt === "number" ? identity.createdAt : null
220
- };
221
- } catch {
222
- return { title: null, cwd: null, createdAt: null };
223
- }
224
- }
225
-
226
- /** 只统计顶层文件:会话目录里就是那几份日志。 */
227
- async dirStats(dir) {
228
- let bytes = 0;
229
- let files = 0;
230
- let entries;
231
- try {
232
- entries = await fsp.readdir(dir, { withFileTypes: true });
233
- } catch {
234
- return { bytes: 0, files: 0 };
235
- }
236
- for (const entry of entries) {
237
- if (!entry.isFile()) continue;
238
- try {
239
- const stat = await fsp.stat(join(dir, entry.name));
240
- bytes += stat.size;
241
- files += 1;
242
- } catch {
243
- // 竞态:文件刚好消失,忽略
244
- }
245
- }
246
- return { bytes, files };
247
- }
248
-
249
- findWorkspaceOf(sessionId) {
250
- for (const workspace of this.registry.list()) {
251
- const ids = workspace.sessionIds;
252
- if (Array.isArray(ids) && ids.includes(sessionId)) return workspace;
253
- }
254
- return undefined;
255
- }
256
-
257
- async describeSession(sessionId, dirs) {
258
- const info = dirs.get(sessionId);
259
- const projection = await this.readProjection(sessionId);
260
- const workspace = this.findWorkspaceOf(sessionId);
261
- const stats = info ? await this.dirStats(info.dir) : { bytes: 0, files: 0 };
262
- return {
263
- sessionId,
264
- title: projection.title ?? (workspace ? workspace.title : null) ?? sessionId,
265
- cwd: projection.cwd ?? (workspace ? workspace.path : null),
266
- createdAt: projection.createdAt ?? null,
267
- workspaceId: workspace ? String(workspace.id) : null,
268
- workspaceTitle: workspace ? workspace.title : null,
269
- slug: info ? info.slug : null,
270
- onDisk: Boolean(info),
271
- bytes: stats.bytes,
272
- files: stats.files
273
- };
274
- }
275
-
276
- async listTrash() {
277
- const root = trashRoot();
278
- let entries;
279
- try {
280
- entries = await fsp.readdir(root, { withFileTypes: true });
281
- } catch {
282
- return [];
283
- }
284
- const rows = [];
285
- for (const entry of entries) {
286
- if (!entry.isDirectory()) continue;
287
- const dir = join(root, entry.name);
288
- let meta = null;
289
- try {
290
- meta = JSON.parse(await fsp.readFile(join(dir, META_FILE), "utf8"));
291
- } catch {
292
- meta = null;
293
- }
294
- const stats = await this.dirStats(dir);
295
- rows.push({
296
- sessionId: entry.name,
297
- title: typeof meta?.title === "string" && meta.title !== "" ? meta.title : entry.name,
298
- cwd: typeof meta?.cwd === "string" ? meta.cwd : null,
299
- slug: typeof meta?.slug === "string" ? meta.slug : null,
300
- // 与 describeSession 对齐:客户端按"原来的工作区文件夹"分组要用到这两个字段。
301
- workspaceId: typeof meta?.workspaceId === "string" ? meta.workspaceId : null,
302
- workspaceTitle: typeof meta?.workspaceTitle === "string" ? meta.workspaceTitle : null,
303
- deletedAt: typeof meta?.deletedAt === "number" ? meta.deletedAt : 0,
304
- bytes: stats.bytes,
305
- files: stats.files,
306
- restorable: typeof meta?.slug === "string" && meta.slug !== ""
307
- });
308
- }
309
- rows.sort((left, right) => right.deletedAt - left.deletedAt);
310
- return rows;
311
- }
312
-
313
- /** 当前归档 / 置顶 / 回收站的完整快照,附带目录位置。 */
314
- async buildSnapshot() {
315
- const registry = this.registry;
316
- const dirs = await this.scanSessionDirs();
317
- const trashed = await this.listTrash();
318
- // 回收站条目**只**归回收站页签。删除时我们故意让 id 留在归档集合里(那是 DSH
319
- // 唯一"从所有分组面消失"的机制,见 trash()),所以这里必须把交集滤掉:
320
- // 否则同一个会话会同时出现在"已归档"和"回收站",而"已归档"那条还是没有目录的幽灵行。
321
- const inTrash = new Set(trashed.map((row) => row.sessionId));
322
- const archived = [];
323
- for (const sessionId of registry.archivedSessionIds) {
324
- // 顺带把"目录已经不在了"的 id 也滤掉:`archivedSessionIds` 是**只增不减**的记账
325
- //(DSH 自己的注释:目录没了的条目照样留在集合里),而 DSH 的侧边栏只从会话清单里
326
- // 取行 —— 这种 id 在它那边本来就不显示。列出来只会得到一条 0 B、点不动的幽灵行。
327
- if (inTrash.has(sessionId) || !dirs.has(sessionId)) continue;
328
- archived.push(await this.describeSession(sessionId, dirs));
329
- }
330
- const pinned = [];
331
- for (const sessionId of registry.pinnedSessionIds) {
332
- if (inTrash.has(sessionId) || !dirs.has(sessionId)) continue;
333
- pinned.push(await this.describeSession(sessionId, dirs));
334
- }
335
- let archivedBytes = 0;
336
- for (const row of archived) archivedBytes += row.bytes;
337
- let trashBytes = 0;
338
- for (const row of trashed) trashBytes += row.bytes;
339
- return {
340
- dshHome: dshHomePath(),
341
- version: LOCAL_VERSION,
342
- trashDir: trashRoot(),
343
- sessionsDir: sessionsRoot(),
344
- archived,
345
- pinned,
346
- trashed,
347
- totals: {
348
- archived: archived.length,
349
- archivedBytes,
350
- pinned: pinned.length,
351
- trashed: trashed.length,
352
- trashBytes
353
- }
354
- };
355
- }
356
-
357
- // ── 变更操作 ─────────────────────────────────────────────────────────
358
- snapshot() {
359
- return this.buildSnapshot();
360
- }
361
-
362
- /** 逐个执行,单项失败只记录在 results 里,不影响其余项。 */
363
- async each(ids, action) {
364
- const results = [];
365
- for (const sessionId of ids) {
366
- try {
367
- const extra = await action(sessionId);
368
- results.push({ sessionId, ok: true, ...(extra ? { steps: extra } : {}) });
369
- } catch (error) {
370
- results.push({ sessionId, ok: false, reason: messageOf(error) });
371
- }
372
- }
373
- return { results, ...(await this.buildSnapshot()) };
374
- }
375
-
376
- /** `stopActivity` 为 true 时先停掉会话正在跑的工作再归档。 */
377
- archive(ids, stopActivity) {
378
- return this.each(ids, (sessionId) => this.registry.archiveSession(sessionId, { stopActivity: Boolean(stopActivity) }));
379
- }
380
-
381
- unarchive(ids) {
382
- return this.each(ids, (sessionId) => this.registry.unarchiveSession(sessionId));
383
- }
384
-
385
- pin(ids) {
386
- return this.each(ids, (sessionId) => this.registry.pinSession(sessionId));
387
- }
388
-
389
- unpin(ids) {
390
- return this.each(ids, (sessionId) => this.registry.unpinSession(sessionId));
391
- }
392
-
393
- /** 正在跑的会话不允许删除,与官方归档同一条活动判据。 */
394
- async sessionActivity(sessionId) {
395
- try {
396
- const activity = await this.ctx.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]));
397
- return Array.isArray(activity) ? activity : [];
398
- } catch {
399
- return [];
400
- }
401
- }
402
-
403
- /**
404
- * 软删除:把会话**藏起来**(进归档集合)+ 移出工作区记账 + 搬到回收站(含投影缓存)。
405
- *
406
- * 为什么是"归档"而不是"取消归档":DSH 里**唯一**能让一个会话从所有分组面
407
- *(侧边栏的工作区分组、**未分组**、置顶)消失的机制,就是 `archivedSessionIds`
408
- * 这个 registry-global 集合 —— 它的注释原话是 "sessions hidden from every grouping
409
- * surface",而且归档**不动工作区记账**,槽位留着,所以恢复时位置不变。
410
- *
411
- * 反过来做(先 `unarchiveSession`)必然出幽灵行:取消归档会把会话重新暴露给
412
- * 客户端那份会话列表(`ctx.sessions.list`),而它的目录虽然已经被我们搬走,
413
- * 客户端 list 里那条还在 —— 侧边栏 `groupByWorkspace()` 的判定是
414
- * "在 list 里 && 不属于任何工作区 && 不在归档集合里" → 落进**未分组**。
415
- * 同时归档集合里已经没有它,等于谁也管不住这条幽灵行了。
416
- *
417
- * 原来是否归档记在 `__trash.json` 的 `wasArchived` 里,恢复时照原样还原。
418
- */
419
- async trash(ids) {
420
- await fsp.mkdir(trashRoot(), { recursive: true });
421
- const dirs = await this.scanSessionDirs();
422
- return this.each(ids, async (sessionId) => {
423
- const steps = [];
424
- if (sessionId === process.env.DSH_SESSION_ID) {
425
- throw new Error("这是当前正在使用的会话,不能删除");
426
- }
427
- const activity = await this.sessionActivity(sessionId);
428
- if (activity.length > 0) throw new Error("会话正在运行,请先停止它");
429
- const info = dirs.get(sessionId);
430
- if (!info) throw new Error("找不到会话日志目录(可能已被删除)");
431
-
432
- // 必须**趁目录还在**归档:`archiveSession` 要先通过 `sessionKnown()`
433
- //(live 会话 / 头部索引 / 持久化清单),搬走之后三者都查不到了。
434
- const wasArchived = this.registry.archivedSessionIds.includes(sessionId);
435
- if (!wasArchived) {
436
- try {
437
- await this.registry.archiveSession(sessionId);
438
- steps.push("archive");
439
- } catch (error) {
440
- // 藏不住不该阻止删除:会话可能继续留在侧边栏,但数据照样进回收站。
441
- steps.push(`archive-failed:${messageOf(error)}`);
442
- }
443
- }
444
- // 置顶的会话也要先取消置顶:否则它的 id 还留在 pinnedSessionIds 里,
445
- // 搬进回收站之后会同时出现在"已置顶"和"回收站"两个页签。
446
- if (this.registry.pinnedSessionIds.includes(sessionId)) {
447
- await this.registry.unpinSession(sessionId);
448
- steps.push("unpin");
449
- }
450
- const workspace = this.findWorkspaceOf(sessionId);
451
- if (workspace) {
452
- try {
453
- await workspace.detachSession(sessionId);
454
- steps.push("detach");
455
- } catch (error) {
456
- steps.push(`detach-failed:${messageOf(error)}`);
457
- }
458
- }
459
- const projection = await this.readProjection(sessionId);
460
-
461
- const target = join(trashRoot(), sessionId);
462
- await fsp.rm(target, { recursive: true, force: true });
463
- await moveDir(info.dir, target);
464
- steps.push("move");
465
-
466
- const cached = projCacheFile(sessionId);
467
- if (existsSync(cached)) {
468
- try {
469
- await fsp.rename(cached, join(target, PROJ_FILE));
470
- steps.push("projcache");
471
- } catch {
472
- // 缓存搬不动不影响会话本身
473
- }
474
- }
475
- await fsp.writeFile(
476
- join(target, META_FILE),
477
- JSON.stringify(
478
- {
479
- version: 1,
480
- sessionId,
481
- slug: info.slug,
482
- title: projection.title,
483
- cwd: projection.cwd,
484
- workspaceId: workspace ? String(workspace.id) : null,
485
- workspaceTitle: workspace ? workspace.title : null,
486
- // 删除前是否已经归档 —— 恢复时要照原样还原(老条目没有这个字段,
487
- // 一律按"没归档过"处理,也就是恢复成普通会话)。
488
- wasArchived,
489
- deletedAt: Date.now()
490
- },
491
- null,
492
- 2
493
- ),
494
- "utf8"
495
- );
496
- return steps;
497
- });
498
- }
499
-
500
- /**
501
- * 从回收站恢复;尽力把会话重新挂回原工作区,并把归档状态还原成删除前的样子。
502
- *
503
- * `trash()` 会把会话塞进归档集合(藏起来),所以这里必须配对处理:
504
- * - 删除前**已经归档**(`wasArchived: true`)→ 保持归档,它就回到"已归档"页签;
505
- * - 其余(含老版本写下的、没有这个字段的条目)→ 取消归档,恢复成普通会话。
506
- * 不还原的话,恢复出来的会话会莫名其妙地"还在已归档里"。
507
- */
508
- async restore(ids) {
509
- const dirs = await this.scanSessionDirs();
510
- return this.each(ids, async (sessionId) => {
511
- const steps = [];
512
- const source = join(trashRoot(), sessionId);
513
- if (!existsSync(source)) throw new Error("回收站里没有这个会话");
514
- let meta = null;
515
- try {
516
- meta = JSON.parse(await fsp.readFile(join(source, META_FILE), "utf8"));
517
- } catch {
518
- meta = null;
519
- }
520
- if (typeof meta?.slug !== "string" || meta.slug === "") {
521
- throw new Error("缺少回收站元数据,无法确定原目录");
522
- }
523
- if (dirs.has(sessionId)) throw new Error("目标会话目录已存在,请先处理同名会话");
524
-
525
- const target = join(sessionsRoot(), meta.slug, sessionId);
526
- await moveDir(source, target);
527
- steps.push("move");
528
-
529
- const cached = join(target, PROJ_FILE);
530
- if (existsSync(cached)) {
531
- try {
532
- await fsp.rename(cached, projCacheFile(sessionId));
533
- steps.push("projcache");
534
- } catch {
535
- // 同上,非关键
536
- }
537
- }
538
- try {
539
- await fsp.rm(join(target, META_FILE), { force: true });
540
- } catch {
541
- // 元数据残留无害
542
- }
543
-
544
- // 归档状态先还原,再挂回工作区:attachSession 要读得到会话头(目录已经回来了)。
545
- if (meta.wasArchived !== true && this.registry.archivedSessionIds.includes(sessionId)) {
546
- await this.registry.unarchiveSession(sessionId);
547
- steps.push("unarchive");
548
- }
549
-
550
- if (typeof meta.cwd === "string" && meta.cwd !== "") {
551
- try {
552
- const workspace = await this.registry.resolveByPath(meta.cwd);
553
- if (workspace) {
554
- await workspace.attachSession(sessionId);
555
- steps.push("attach");
556
- }
557
- } catch {
558
- // 索引要等下次启动才会收录这个会话,挂靠失败不影响恢复
559
- }
560
- }
561
- return steps;
562
- });
563
- }
564
-
565
- /**
566
- * 永久删除(不可恢复)。
567
- *
568
- * 先取消归档:`trash()` 为了让会话从侧边栏消失而把它放进了归档集合,永久删除之后
569
- * 再没人会去动这条 id —— 留在集合里就是一条**永久幽灵**(DSH 自身会一直以为它归档着,
570
- * 界面上又找不到它)。
571
- */
572
- async purge(ids) {
573
- return this.each(ids, async (sessionId) => {
574
- const target = join(trashRoot(), sessionId);
575
- if (!existsSync(target)) throw new Error("回收站里没有这个会话");
576
- const steps = [];
577
- if (this.registry.archivedSessionIds.includes(sessionId)) {
578
- await this.registry.unarchiveSession(sessionId);
579
- steps.push("unarchive");
580
- }
581
- await fsp.rm(target, { recursive: true, force: true });
582
- steps.push("purge");
583
- return steps;
584
- });
585
- }
586
-
587
- /** 更新检查:只读,结果在模块级缓存(见 checkForUpdate)。 */
588
- checkUpdate(force) {
589
- return checkForUpdate(force);
590
- }
591
- }
592
-
593
- // ── 传输 / 分发 ──────────────────────────────────────────────────────────
594
- /**
595
- * 为什么**不用** `ctx.connection.rpc.handle()`(踩过的坑,改回去之前先读):
596
- *
597
- * 它内部把路由交给 `owner.effect(() => owner.webServer.register(route))`,而那个
598
- * `owner` 是 `this.ctx` —— **Connection 服务自己的 ctx**,不是读服务的插件 ctx。
599
- * cordis 的服务调用会带 shadow(`Context[symbols.shadow]` 指向提供者作用域),所以
600
- * 服务解析从"提供者的 fiber"开始;Connection 自己的 inject 只有 `["credentials"]`,
601
- * 于是必然抛:
602
- *
603
- * cannot get property "webServer" without inject
604
- * (@deepseek-ai/dsh-client-connection/lib/index.js:656)
605
- *
606
- * 结果:插件 fiber 直接变 `failed`、一条路由都没挂上,客户端每个 POST 落到 SPA 静态
607
- * 兜底,由 `@deepseek-ai/dsh-host-frontend-static` 对非 GET/HEAD 回 **HTTP 405** ——
608
- * 就是 `transport failure for /session-manager/trash: HTTP 405`。
609
- * 注意:**在插件自己的 inject 里加 `webServer` 修不了它**,因为报错的那个 ctx 不是插件的。
610
- *
611
- * 改走同样由 Connection 公布的 `ctx.connection.fetch.register()`:精确 Fetch 路由挂在
612
- * 共享 `/api` 前缀下,`admit()`(Host/Origin/`sec-fetch-site` + 浏览器会话 cookie)与
613
- * 请求体上限都仍由 Connection 的 `/api` 前缀路由先执行,插件依旧零鉴权代码。
614
- */
615
-
616
- /** 一个频道(`/api/session-curator`):精确路由表按完整 pathname 命中,所以每个端点一条。 */
617
- const ENDPOINTS = {
618
- snapshot: (manager) => manager.snapshot(),
619
- archive: (manager, payload) => manager.archive(payload.ids, payload.stopActivity),
620
- unarchive: (manager, payload) => manager.unarchive(payload.ids),
621
- pin: (manager, payload) => manager.pin(payload.ids),
622
- unpin: (manager, payload) => manager.unpin(payload.ids),
623
- trash: (manager, payload) => manager.trash(payload.ids),
624
- restore: (manager, payload) => manager.restore(payload.ids),
625
- purge: (manager, payload) => manager.purge(payload.ids),
626
- "check-update": (manager, payload) => manager.checkUpdate(payload.force === true)
627
- };
628
-
629
- /** 只接受字符串数组;其余一律忽略,避免把宿主路径当 id 传来的越权尝试。 */
630
- function idsOf(payload) {
631
- return Array.isArray(payload.ids) ? payload.ids.filter((value) => typeof value === "string" && value !== "") : [];
632
- }
633
-
634
- /**
635
- * 一个端点处理器。返回 Connection 的固定信封:处理器自身的业务失败走
636
- * `{ok:false, error}`,端点内部的逐项失败则留在 `value.results` 里。
637
- */
638
- async function handle(manager, endpoint, payload) {
639
- const run = ENDPOINTS[endpoint];
640
- if (run === undefined) {
641
- return { ok: false, error: { code: "session-curator/unknown-endpoint", message: `unknown endpoint ${endpoint}`, details: {} } };
642
- }
643
- const safe = payload !== null && typeof payload === "object" ? payload : {};
644
- try {
645
- return { ok: true, value: await run(manager, { ...safe, ids: idsOf(safe) }) };
646
- } catch (error) {
647
- return { ok: false, error: { code: "session-curator/error", message: messageOf(error), details: {} } };
648
- }
649
- }
650
-
651
- /** `{type:'server-response', rpcId, result}` —— 与 Connection 自己回的信封逐字一致。 */
652
- function envelope(rpcId, result) {
653
- return Response.json({ type: "server-response", rpcId, result });
654
- }
655
-
656
- function badRequest(rpcId, message) {
657
- // 与 Connection 的 `invalidEnvelopeResponse` 一致:坏信封也回 200 + 错误信封,
658
- // 这样客户端读到的是人话,而不是一个传输层异常。
659
- return envelope(rpcId ?? "invalid-request", {
660
- ok: false,
661
- error: { code: "session-curator/bad-request", message, details: {} }
662
- });
663
- }
664
-
665
- /** `/api` 已在前面做完信任与鉴权,这里只校验信封、分发、回包。 */
666
- async function respond(manager, endpoint, request) {
667
- let body;
668
- try {
669
- body = await request.json();
670
- } catch {
671
- return badRequest(null, "body is not JSON");
672
- }
673
- const rpcId = body !== null && typeof body === "object" && typeof body.rpcId === "string" ? body.rpcId : null;
674
- if (rpcId === null || body.type !== "client-request" || typeof body.method !== "string") {
675
- return badRequest(rpcId, "invalid client-request message");
676
- }
677
- const target = `${ROUTE_PREFIX}/${endpoint}`;
678
- if (body.method !== target) {
679
- return badRequest(rpcId, `method ${JSON.stringify(body.method)} does not match endpoint ${JSON.stringify(target)}`);
680
- }
681
- return envelope(rpcId, await handle(manager, endpoint, body.payload));
682
- }
683
-
684
29
  export function apply(ctx) {
685
30
  const manager = new SessionManager(ctx);
686
- // 只知道"精确路由登记处"这一个面;命名避开全局 fetch。
687
- const fetchRoutes = ctx.connection.fetch;
688
- for (const endpoint of Object.keys(ENDPOINTS)) {
689
- const route = {
690
- path: `${API_CHANNEL}/${ROUTE_PREFIX}/${endpoint}`,
691
- methods: ROUTE_METHODS,
692
- requestBody: "buffered",
693
- fetch: (request) => respond(manager, endpoint, request)
694
- };
695
- // 注册挂在**插件自己的 fiber** 上(`registerFetchRoute` 用的是调用方的
696
- // `owner.effect`),所以卸载插件时路由会一起摘掉。
697
- ctx.effect(() => fetchRoutes.register(route), `dsh-session-curator: ${route.path}`);
698
- }
31
+ registerRoutes(ctx, manager);
699
32
  }
33
+ // 纯函数,给 `scripts/smoke.mjs` 直接单测(插件运行时用不到)。
34
+ export { compareVersions } from "./host/update.js";