dsh-session-curator 0.1.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.
package/lib/index.js ADDED
@@ -0,0 +1,556 @@
1
+ /**
2
+ * dsh-session-curator — host 半区。
3
+ *
4
+ * 提供三块能力:
5
+ * 1. 会话软删除:把 `<DSH_HOME>/sessions/<slug>/<sessionId>/` 整个搬到
6
+ * `<DSH_HOME>/storages/session-manager/trash/<sessionId>/`,并从工作区记账里
7
+ * 摘掉(`Workspace.detachSession`)。可完整恢复。
8
+ * ⚠️ 目录名 `session-manager` 是**故意保留的旧名**(见下面 TRASH_SUBPATH 的注释)。
9
+ * 2. 回收站管理:列出 / 恢复 / 永久删除。
10
+ * 3. 归档会话管理:列出全部归档会话(标题、目录、体积),支持批量取消归档、置顶,
11
+ * 以及直接删除。归档/取消归档/置顶全部走官方 `ctx.workspaceRegistry`。
12
+ *
13
+ * 对客户端暴露一组挂在共享 `/api` 通道下的精确 Fetch 路由(
14
+ * `ctx.connection.fetch.register`):信任检查与会话鉴权由 Connection 的 `/api`
15
+ * 前缀路由先做完,所以插件自己不需要任何鉴权代码。
16
+ */
17
+ import { promises as fsp, existsSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join, resolve } from "node:path";
20
+
21
+ export const name = "dsh-session-curator";
22
+ // `connection` 承载 HTTP 面;`webServer` 是硬依赖(Connection 只在 webServer 存在时
23
+ // 才挂载 `/api` 通道,没有它插件就不可达);`workspaceRegistry` 是归档/置顶/记账的来源。
24
+ export const inject = ["connection", "webServer", "workspaceRegistry"];
25
+
26
+ // ── DSH home 解析 ────────────────────────────────────────────────────────
27
+ // 这里刻意**不 import** `@deepseek-ai/dsh-home-paths`:那个包只在 DSH 自己的
28
+ // node_modules 里,而插件可能以 junction/symlink 形式挂在 profile 下 —— 此时 Node 会
29
+ // 按 realpath 解析裸标识符,从插件源码目录向上找不到它,插件就加载不了。语义与那个包
30
+ // 一致(`$DSH_HOME` 优先,否则 `~/.dsh`),几行就够,换来的是"插件放哪都能跑"。
31
+ function resolveDshHome() {
32
+ const configured = process.env.DSH_HOME;
33
+ if (typeof configured === "string" && configured.trim() !== "") {
34
+ const expanded = configured.trim();
35
+ if (expanded === "~") return homedir();
36
+ if (expanded.startsWith("~/") || expanded.startsWith("~\\")) return resolve(join(homedir(), expanded.slice(2)));
37
+ return resolve(expanded);
38
+ }
39
+ return resolve(join(homedir(), ".dsh"));
40
+ }
41
+
42
+ function dshHomePath(...segments) {
43
+ return join(resolveDshHome(), ...segments);
44
+ }
45
+
46
+ /** DSH 的共享 API 通道:唯一带 Host/Origin 信任栅栏与浏览器会话鉴权的 HTTP 面。 */
47
+ const API_CHANNEL = "/api";
48
+ /** 频道下属于本插件的路径前缀(`/api/session-curator/<endpoint>`)。 */
49
+ const ROUTE_PREFIX = "session-curator";
50
+ /** 端点只读 / 只写,一律 POST(Connection 的精确路由按方法集合匹配)。 */
51
+ const ROUTE_METHODS = ["POST"];
52
+ /**
53
+ * 回收站根目录:`<DSH_HOME>/storages/session-manager/trash`。
54
+ *
55
+ * 这里的 `session-manager` 是**故意不改的旧名**:插件从 `dsh-session-manager` 改名成
56
+ * `dsh-session-curator` 时,回收站里可能还躺着用户已经删掉的会话(本机就有一个)——
57
+ * 改路径会把它们变成孤儿。数据目录用历史名、其余 id 用新名,是权衡后的结果。
58
+ */
59
+ const TRASH_SUBPATH = ["storages", "session-manager", "trash"];
60
+ const META_FILE = "__trash.json";
61
+ const PROJ_FILE = "__projcache.json";
62
+
63
+ // ── 文件系统小工具 ───────────────────────────────────────────────────────
64
+ function trashRoot() {
65
+ return dshHomePath(...TRASH_SUBPATH);
66
+ }
67
+
68
+ function sessionsRoot() {
69
+ return dshHomePath("sessions");
70
+ }
71
+
72
+ function projCacheFile(sessionId) {
73
+ return dshHomePath("storages", "session_projcache", "sessions", `${sessionId}.json`);
74
+ }
75
+
76
+ function messageOf(error) {
77
+ if (error instanceof Error) return error.message;
78
+ return String(error);
79
+ }
80
+
81
+ /** 跨卷 rename 会抛 EXDEV,退回 copy + rm。 */
82
+ async function moveDir(from, to) {
83
+ await fsp.mkdir(dirname(to), { recursive: true });
84
+ try {
85
+ await fsp.rename(from, to);
86
+ return;
87
+ } catch (error) {
88
+ if (error?.code !== "EXDEV") throw error;
89
+ }
90
+ await fsp.cp(from, to, { recursive: true, force: true });
91
+ await fsp.rm(from, { recursive: true, force: true });
92
+ }
93
+
94
+ // ── 会话管理器 ───────────────────────────────────────────────────────────
95
+ class SessionManager {
96
+ constructor(ctx) {
97
+ this.ctx = ctx;
98
+ }
99
+
100
+ get registry() {
101
+ return this.ctx.workspaceRegistry;
102
+ }
103
+
104
+ // ── 只读投影 ─────────────────────────────────────────────────────────
105
+ /** <DSH_HOME>/sessions/<slug>/<sessionId> → { dir, slug } 索引。 */
106
+ async scanSessionDirs() {
107
+ const root = sessionsRoot();
108
+ const map = new Map();
109
+ let slugs;
110
+ try {
111
+ slugs = await fsp.readdir(root, { withFileTypes: true });
112
+ } catch {
113
+ return map;
114
+ }
115
+ for (const slug of slugs) {
116
+ if (!slug.isDirectory()) continue;
117
+ let ids;
118
+ try {
119
+ ids = await fsp.readdir(join(root, slug.name), { withFileTypes: true });
120
+ } catch {
121
+ continue;
122
+ }
123
+ for (const id of ids) {
124
+ if (!id.isDirectory()) continue;
125
+ if (!map.has(id.name)) map.set(id.name, { dir: join(root, slug.name, id.name), slug: slug.name });
126
+ }
127
+ }
128
+ return map;
129
+ }
130
+
131
+ /** 投影缓存里的会话元数据(标题 / cwd / 创建时间)。缺文件时返回空字段。 */
132
+ async readProjection(sessionId) {
133
+ try {
134
+ const parsed = JSON.parse(await fsp.readFile(projCacheFile(sessionId), "utf8"));
135
+ const rows = parsed?.record?.rows;
136
+ const identity = parsed?.record?.identity;
137
+ const title = rows?.title?.val;
138
+ return {
139
+ title: typeof title === "string" && title !== "" ? title : null,
140
+ cwd: typeof identity?.cwd === "string" ? identity.cwd : null,
141
+ createdAt: typeof identity?.createdAt === "number" ? identity.createdAt : null
142
+ };
143
+ } catch {
144
+ return { title: null, cwd: null, createdAt: null };
145
+ }
146
+ }
147
+
148
+ /** 只统计顶层文件:会话目录里就是那几份日志。 */
149
+ async dirStats(dir) {
150
+ let bytes = 0;
151
+ let files = 0;
152
+ let entries;
153
+ try {
154
+ entries = await fsp.readdir(dir, { withFileTypes: true });
155
+ } catch {
156
+ return { bytes: 0, files: 0 };
157
+ }
158
+ for (const entry of entries) {
159
+ if (!entry.isFile()) continue;
160
+ try {
161
+ const stat = await fsp.stat(join(dir, entry.name));
162
+ bytes += stat.size;
163
+ files += 1;
164
+ } catch {
165
+ // 竞态:文件刚好消失,忽略
166
+ }
167
+ }
168
+ return { bytes, files };
169
+ }
170
+
171
+ findWorkspaceOf(sessionId) {
172
+ for (const workspace of this.registry.list()) {
173
+ const ids = workspace.sessionIds;
174
+ if (Array.isArray(ids) && ids.includes(sessionId)) return workspace;
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ async describeSession(sessionId, dirs) {
180
+ const info = dirs.get(sessionId);
181
+ const projection = await this.readProjection(sessionId);
182
+ const workspace = this.findWorkspaceOf(sessionId);
183
+ const stats = info ? await this.dirStats(info.dir) : { bytes: 0, files: 0 };
184
+ return {
185
+ sessionId,
186
+ title: projection.title ?? (workspace ? workspace.title : null) ?? sessionId,
187
+ cwd: projection.cwd ?? (workspace ? workspace.path : null),
188
+ createdAt: projection.createdAt ?? null,
189
+ workspaceId: workspace ? String(workspace.id) : null,
190
+ workspaceTitle: workspace ? workspace.title : null,
191
+ slug: info ? info.slug : null,
192
+ onDisk: Boolean(info),
193
+ bytes: stats.bytes,
194
+ files: stats.files
195
+ };
196
+ }
197
+
198
+ async listTrash() {
199
+ const root = trashRoot();
200
+ let entries;
201
+ try {
202
+ entries = await fsp.readdir(root, { withFileTypes: true });
203
+ } catch {
204
+ return [];
205
+ }
206
+ const rows = [];
207
+ for (const entry of entries) {
208
+ if (!entry.isDirectory()) continue;
209
+ const dir = join(root, entry.name);
210
+ let meta = null;
211
+ try {
212
+ meta = JSON.parse(await fsp.readFile(join(dir, META_FILE), "utf8"));
213
+ } catch {
214
+ meta = null;
215
+ }
216
+ const stats = await this.dirStats(dir);
217
+ rows.push({
218
+ sessionId: entry.name,
219
+ title: typeof meta?.title === "string" && meta.title !== "" ? meta.title : entry.name,
220
+ cwd: typeof meta?.cwd === "string" ? meta.cwd : null,
221
+ slug: typeof meta?.slug === "string" ? meta.slug : null,
222
+ // 与 describeSession 对齐:客户端按"原来的工作区文件夹"分组要用到这两个字段。
223
+ workspaceId: typeof meta?.workspaceId === "string" ? meta.workspaceId : null,
224
+ workspaceTitle: typeof meta?.workspaceTitle === "string" ? meta.workspaceTitle : null,
225
+ deletedAt: typeof meta?.deletedAt === "number" ? meta.deletedAt : 0,
226
+ bytes: stats.bytes,
227
+ files: stats.files,
228
+ restorable: typeof meta?.slug === "string" && meta.slug !== ""
229
+ });
230
+ }
231
+ rows.sort((left, right) => right.deletedAt - left.deletedAt);
232
+ return rows;
233
+ }
234
+
235
+ /** 当前归档 / 置顶 / 回收站的完整快照,附带目录位置。 */
236
+ async buildSnapshot() {
237
+ const registry = this.registry;
238
+ const dirs = await this.scanSessionDirs();
239
+ const archived = [];
240
+ for (const sessionId of registry.archivedSessionIds) {
241
+ archived.push(await this.describeSession(sessionId, dirs));
242
+ }
243
+ const pinned = [];
244
+ for (const sessionId of registry.pinnedSessionIds) {
245
+ pinned.push(await this.describeSession(sessionId, dirs));
246
+ }
247
+ const trashed = await this.listTrash();
248
+ let archivedBytes = 0;
249
+ for (const row of archived) archivedBytes += row.bytes;
250
+ let trashBytes = 0;
251
+ for (const row of trashed) trashBytes += row.bytes;
252
+ return {
253
+ dshHome: dshHomePath(),
254
+ trashDir: trashRoot(),
255
+ sessionsDir: sessionsRoot(),
256
+ archived,
257
+ pinned,
258
+ trashed,
259
+ totals: {
260
+ archived: archived.length,
261
+ archivedBytes,
262
+ pinned: pinned.length,
263
+ trashed: trashed.length,
264
+ trashBytes
265
+ }
266
+ };
267
+ }
268
+
269
+ // ── 变更操作 ─────────────────────────────────────────────────────────
270
+ snapshot() {
271
+ return this.buildSnapshot();
272
+ }
273
+
274
+ /** 逐个执行,单项失败只记录在 results 里,不影响其余项。 */
275
+ async each(ids, action) {
276
+ const results = [];
277
+ for (const sessionId of ids) {
278
+ try {
279
+ const extra = await action(sessionId);
280
+ results.push({ sessionId, ok: true, ...(extra ? { steps: extra } : {}) });
281
+ } catch (error) {
282
+ results.push({ sessionId, ok: false, reason: messageOf(error) });
283
+ }
284
+ }
285
+ return { results, ...(await this.buildSnapshot()) };
286
+ }
287
+
288
+ /** `stopActivity` 为 true 时先停掉会话正在跑的工作再归档。 */
289
+ archive(ids, stopActivity) {
290
+ return this.each(ids, (sessionId) => this.registry.archiveSession(sessionId, { stopActivity: Boolean(stopActivity) }));
291
+ }
292
+
293
+ unarchive(ids) {
294
+ return this.each(ids, (sessionId) => this.registry.unarchiveSession(sessionId));
295
+ }
296
+
297
+ pin(ids) {
298
+ return this.each(ids, (sessionId) => this.registry.pinSession(sessionId));
299
+ }
300
+
301
+ unpin(ids) {
302
+ return this.each(ids, (sessionId) => this.registry.unpinSession(sessionId));
303
+ }
304
+
305
+ /** 正在跑的会话不允许删除,与官方归档同一条活动判据。 */
306
+ async sessionActivity(sessionId) {
307
+ try {
308
+ const activity = await this.ctx.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]));
309
+ return Array.isArray(activity) ? activity : [];
310
+ } catch {
311
+ return [];
312
+ }
313
+ }
314
+
315
+ /**
316
+ * 软删除:移出工作区记账 + 搬到回收站(含投影缓存)。
317
+ * 归档中的会话先取消归档,这样恢复后它能回到原来的位置。
318
+ */
319
+ async trash(ids) {
320
+ await fsp.mkdir(trashRoot(), { recursive: true });
321
+ const dirs = await this.scanSessionDirs();
322
+ return this.each(ids, async (sessionId) => {
323
+ const steps = [];
324
+ if (sessionId === process.env.DSH_SESSION_ID) {
325
+ throw new Error("这是当前正在使用的会话,不能删除");
326
+ }
327
+ const activity = await this.sessionActivity(sessionId);
328
+ if (activity.length > 0) throw new Error("会话正在运行,请先停止它");
329
+ const info = dirs.get(sessionId);
330
+ if (!info) throw new Error("找不到会话日志目录(可能已被删除)");
331
+
332
+ if (this.registry.archivedSessionIds.includes(sessionId)) {
333
+ await this.registry.unarchiveSession(sessionId);
334
+ steps.push("unarchive");
335
+ }
336
+ // 置顶的会话也要先取消置顶:否则它的 id 还留在 pinnedSessionIds 里,
337
+ // 搬进回收站之后会同时出现在"已置顶"和"回收站"两个页签。
338
+ if (this.registry.pinnedSessionIds.includes(sessionId)) {
339
+ await this.registry.unpinSession(sessionId);
340
+ steps.push("unpin");
341
+ }
342
+ const workspace = this.findWorkspaceOf(sessionId);
343
+ if (workspace) {
344
+ try {
345
+ await workspace.detachSession(sessionId);
346
+ steps.push("detach");
347
+ } catch (error) {
348
+ steps.push(`detach-failed:${messageOf(error)}`);
349
+ }
350
+ }
351
+ const projection = await this.readProjection(sessionId);
352
+
353
+ const target = join(trashRoot(), sessionId);
354
+ await fsp.rm(target, { recursive: true, force: true });
355
+ await moveDir(info.dir, target);
356
+ steps.push("move");
357
+
358
+ const cached = projCacheFile(sessionId);
359
+ if (existsSync(cached)) {
360
+ try {
361
+ await fsp.rename(cached, join(target, PROJ_FILE));
362
+ steps.push("projcache");
363
+ } catch {
364
+ // 缓存搬不动不影响会话本身
365
+ }
366
+ }
367
+ await fsp.writeFile(
368
+ join(target, META_FILE),
369
+ JSON.stringify(
370
+ {
371
+ version: 1,
372
+ sessionId,
373
+ slug: info.slug,
374
+ title: projection.title,
375
+ cwd: projection.cwd,
376
+ workspaceId: workspace ? String(workspace.id) : null,
377
+ workspaceTitle: workspace ? workspace.title : null,
378
+ deletedAt: Date.now()
379
+ },
380
+ null,
381
+ 2
382
+ ),
383
+ "utf8"
384
+ );
385
+ return steps;
386
+ });
387
+ }
388
+
389
+ /** 从回收站恢复;尽力把会话重新挂回原工作区。 */
390
+ async restore(ids) {
391
+ const dirs = await this.scanSessionDirs();
392
+ return this.each(ids, async (sessionId) => {
393
+ const steps = [];
394
+ const source = join(trashRoot(), sessionId);
395
+ if (!existsSync(source)) throw new Error("回收站里没有这个会话");
396
+ let meta = null;
397
+ try {
398
+ meta = JSON.parse(await fsp.readFile(join(source, META_FILE), "utf8"));
399
+ } catch {
400
+ meta = null;
401
+ }
402
+ if (typeof meta?.slug !== "string" || meta.slug === "") {
403
+ throw new Error("缺少回收站元数据,无法确定原目录");
404
+ }
405
+ if (dirs.has(sessionId)) throw new Error("目标会话目录已存在,请先处理同名会话");
406
+
407
+ const target = join(sessionsRoot(), meta.slug, sessionId);
408
+ await moveDir(source, target);
409
+ steps.push("move");
410
+
411
+ const cached = join(target, PROJ_FILE);
412
+ if (existsSync(cached)) {
413
+ try {
414
+ await fsp.rename(cached, projCacheFile(sessionId));
415
+ steps.push("projcache");
416
+ } catch {
417
+ // 同上,非关键
418
+ }
419
+ }
420
+ try {
421
+ await fsp.rm(join(target, META_FILE), { force: true });
422
+ } catch {
423
+ // 元数据残留无害
424
+ }
425
+
426
+ if (typeof meta.cwd === "string" && meta.cwd !== "") {
427
+ try {
428
+ const workspace = await this.registry.resolveByPath(meta.cwd);
429
+ if (workspace) {
430
+ await workspace.attachSession(sessionId);
431
+ steps.push("attach");
432
+ }
433
+ } catch {
434
+ // 索引要等下次启动才会收录这个会话,挂靠失败不影响恢复
435
+ }
436
+ }
437
+ return steps;
438
+ });
439
+ }
440
+
441
+ /** 永久删除(不可恢复)。 */
442
+ async purge(ids) {
443
+ return this.each(ids, async (sessionId) => {
444
+ const target = join(trashRoot(), sessionId);
445
+ if (!existsSync(target)) throw new Error("回收站里没有这个会话");
446
+ await fsp.rm(target, { recursive: true, force: true });
447
+ });
448
+ }
449
+ }
450
+
451
+ // ── 传输 / 分发 ──────────────────────────────────────────────────────────
452
+ /**
453
+ * 为什么**不用** `ctx.connection.rpc.handle()`(踩过的坑,改回去之前先读):
454
+ *
455
+ * 它内部把路由交给 `owner.effect(() => owner.webServer.register(route))`,而那个
456
+ * `owner` 是 `this.ctx` —— **Connection 服务自己的 ctx**,不是读服务的插件 ctx。
457
+ * cordis 的服务调用会带 shadow(`Context[symbols.shadow]` 指向提供者作用域),所以
458
+ * 服务解析从"提供者的 fiber"开始;Connection 自己的 inject 只有 `["credentials"]`,
459
+ * 于是必然抛:
460
+ *
461
+ * cannot get property "webServer" without inject
462
+ * (@deepseek-ai/dsh-client-connection/lib/index.js:656)
463
+ *
464
+ * 结果:插件 fiber 直接变 `failed`、一条路由都没挂上,客户端每个 POST 落到 SPA 静态
465
+ * 兜底,由 `@deepseek-ai/dsh-host-frontend-static` 对非 GET/HEAD 回 **HTTP 405** ——
466
+ * 就是 `transport failure for /session-manager/trash: HTTP 405`。
467
+ * 注意:**在插件自己的 inject 里加 `webServer` 修不了它**,因为报错的那个 ctx 不是插件的。
468
+ *
469
+ * 改走同样由 Connection 公布的 `ctx.connection.fetch.register()`:精确 Fetch 路由挂在
470
+ * 共享 `/api` 前缀下,`admit()`(Host/Origin/`sec-fetch-site` + 浏览器会话 cookie)与
471
+ * 请求体上限都仍由 Connection 的 `/api` 前缀路由先执行,插件依旧零鉴权代码。
472
+ */
473
+
474
+ /** 一个频道(`/api/session-curator`):精确路由表按完整 pathname 命中,所以每个端点一条。 */
475
+ const ENDPOINTS = {
476
+ snapshot: (manager) => manager.snapshot(),
477
+ archive: (manager, payload) => manager.archive(payload.ids, payload.stopActivity),
478
+ unarchive: (manager, payload) => manager.unarchive(payload.ids),
479
+ pin: (manager, payload) => manager.pin(payload.ids),
480
+ unpin: (manager, payload) => manager.unpin(payload.ids),
481
+ trash: (manager, payload) => manager.trash(payload.ids),
482
+ restore: (manager, payload) => manager.restore(payload.ids),
483
+ purge: (manager, payload) => manager.purge(payload.ids)
484
+ };
485
+
486
+ /** 只接受字符串数组;其余一律忽略,避免把宿主路径当 id 传来的越权尝试。 */
487
+ function idsOf(payload) {
488
+ return Array.isArray(payload.ids) ? payload.ids.filter((value) => typeof value === "string" && value !== "") : [];
489
+ }
490
+
491
+ /**
492
+ * 一个端点处理器。返回 Connection 的固定信封:处理器自身的业务失败走
493
+ * `{ok:false, error}`,端点内部的逐项失败则留在 `value.results` 里。
494
+ */
495
+ async function handle(manager, endpoint, payload) {
496
+ const run = ENDPOINTS[endpoint];
497
+ if (run === undefined) {
498
+ return { ok: false, error: { code: "session-curator/unknown-endpoint", message: `unknown endpoint ${endpoint}`, details: {} } };
499
+ }
500
+ const safe = payload !== null && typeof payload === "object" ? payload : {};
501
+ try {
502
+ return { ok: true, value: await run(manager, { ...safe, ids: idsOf(safe) }) };
503
+ } catch (error) {
504
+ return { ok: false, error: { code: "session-curator/error", message: messageOf(error), details: {} } };
505
+ }
506
+ }
507
+
508
+ /** `{type:'server-response', rpcId, result}` —— 与 Connection 自己回的信封逐字一致。 */
509
+ function envelope(rpcId, result) {
510
+ return Response.json({ type: "server-response", rpcId, result });
511
+ }
512
+
513
+ function badRequest(rpcId, message) {
514
+ // 与 Connection 的 `invalidEnvelopeResponse` 一致:坏信封也回 200 + 错误信封,
515
+ // 这样客户端读到的是人话,而不是一个传输层异常。
516
+ return envelope(rpcId ?? "invalid-request", {
517
+ ok: false,
518
+ error: { code: "session-curator/bad-request", message, details: {} }
519
+ });
520
+ }
521
+
522
+ /** `/api` 已在前面做完信任与鉴权,这里只校验信封、分发、回包。 */
523
+ async function respond(manager, endpoint, request) {
524
+ let body;
525
+ try {
526
+ body = await request.json();
527
+ } catch {
528
+ return badRequest(null, "body is not JSON");
529
+ }
530
+ const rpcId = body !== null && typeof body === "object" && typeof body.rpcId === "string" ? body.rpcId : null;
531
+ if (rpcId === null || body.type !== "client-request" || typeof body.method !== "string") {
532
+ return badRequest(rpcId, "invalid client-request message");
533
+ }
534
+ const target = `${ROUTE_PREFIX}/${endpoint}`;
535
+ if (body.method !== target) {
536
+ return badRequest(rpcId, `method ${JSON.stringify(body.method)} does not match endpoint ${JSON.stringify(target)}`);
537
+ }
538
+ return envelope(rpcId, await handle(manager, endpoint, body.payload));
539
+ }
540
+
541
+ export function apply(ctx) {
542
+ const manager = new SessionManager(ctx);
543
+ // 只知道"精确路由登记处"这一个面;命名避开全局 fetch。
544
+ const fetchRoutes = ctx.connection.fetch;
545
+ for (const endpoint of Object.keys(ENDPOINTS)) {
546
+ const route = {
547
+ path: `${API_CHANNEL}/${ROUTE_PREFIX}/${endpoint}`,
548
+ methods: ROUTE_METHODS,
549
+ requestBody: "buffered",
550
+ fetch: (request) => respond(manager, endpoint, request)
551
+ };
552
+ // 注册挂在**插件自己的 fiber** 上(`registerFetchRoute` 用的是调用方的
553
+ // `owner.effect`),所以卸载插件时路由会一起摘掉。
554
+ ctx.effect(() => fetchRoutes.register(route), `dsh-session-curator: ${route.path}`);
555
+ }
556
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Session manager",
4
+ "description": "Soft-delete sessions to a trash and manage archived or pinned ones, grouped by their original workspace folder."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "会话管理",
4
+ "description": "删除会话(软删除到回收站)并管理已归档 / 已置顶的会话,按原来的工作区文件夹分组。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,118 @@
1
+ {
2
+ "name": "dsh-session-curator",
3
+ "version": "0.1.0",
4
+ "description": "DeepSeek Harness plugin: soft-delete sessions to a recoverable trash and manage archived/pinned ones, grouped by their original workspace folder. | DSH 插件:会话软删除到可恢复的回收站,并按原来的工作区文件夹管理已归档 / 已置顶会话。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "icon": "./icon.svg",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Motues/dsh-session-curator.git"
11
+ },
12
+ "homepage": "https://github.com/Motues/dsh-session-curator#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/Motues/dsh-session-curator/issues"
15
+ },
16
+ "exports": {
17
+ ".": "./lib/index.js",
18
+ "./client": "./lib/client.js",
19
+ "./locale/*.json": "./locale/*.json",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "dsh": {
23
+ "client": {
24
+ "platform": "web",
25
+ "inject": [
26
+ "@deepseek-ai/dsh-client-locale",
27
+ "@deepseek-ai/dsh-client-ui-layout",
28
+ "@deepseek-ai/dsh-client-ui-settings",
29
+ "@deepseek-ai/dsh-client-ui-sidebar",
30
+ "@deepseek-ai/dsh-client-ui-workspace"
31
+ ]
32
+ },
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ }
36
+ },
37
+ "keywords": [
38
+ "dsh",
39
+ "dsh-plugin",
40
+ "deepseek-harness",
41
+ "deepseek-harness-plugin",
42
+ "session",
43
+ "session-management",
44
+ "curator",
45
+ "archive",
46
+ "trash",
47
+ "soft-delete",
48
+ "workspace",
49
+ "i18n"
50
+ ],
51
+ "license": "MIT",
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "engines": {
56
+ "node": ">=20",
57
+ "dsh": ">=0.2.0-rc.2 <0.2.1-0"
58
+ },
59
+ "peerDependencies": {
60
+ "@deepseek-ai/cordis": "^4.0.4",
61
+ "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
62
+ "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
63
+ "@deepseek-ai/dsh-client-ui-layout": "^0.2.0-rc.2",
64
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.2",
65
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
66
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.2",
67
+ "@deepseek-ai/dsh-client-ui-workspace": "^0.2.0-rc.2",
68
+ "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
69
+ "@deepseek-ai/dsh-workspace": "^0.2.0-rc.2"
70
+ },
71
+ "peerDependenciesMeta": {
72
+ "@deepseek-ai/cordis": {
73
+ "optional": true
74
+ },
75
+ "@deepseek-ai/dsh-client-connection": {
76
+ "optional": true
77
+ },
78
+ "@deepseek-ai/dsh-client-locale": {
79
+ "optional": true
80
+ },
81
+ "@deepseek-ai/dsh-client-ui-layout": {
82
+ "optional": true
83
+ },
84
+ "@deepseek-ai/dsh-client-ui-primitives": {
85
+ "optional": true
86
+ },
87
+ "@deepseek-ai/dsh-client-ui-settings": {
88
+ "optional": true
89
+ },
90
+ "@deepseek-ai/dsh-client-ui-sidebar": {
91
+ "optional": true
92
+ },
93
+ "@deepseek-ai/dsh-client-ui-workspace": {
94
+ "optional": true
95
+ },
96
+ "@deepseek-ai/dsh-host-webserver": {
97
+ "optional": true
98
+ },
99
+ "@deepseek-ai/dsh-workspace": {
100
+ "optional": true
101
+ }
102
+ },
103
+ "scripts": {
104
+ "test": "node scripts/client.mjs && node scripts/smoke.mjs && node scripts/activation.mjs",
105
+ "check": "node --check lib/index.js && node --check lib/client.js && node --check scripts/client.mjs",
106
+ "prepublishOnly": "npm test"
107
+ },
108
+ "files": [
109
+ "lib",
110
+ "locale",
111
+ "scripts",
112
+ "cordis.patch.yml",
113
+ "icon.svg",
114
+ "LICENSE",
115
+ "README.md",
116
+ "README.zh-CN.md"
117
+ ]
118
+ }