dsh-session-manager 0.6.4 → 0.7.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 CHANGED
@@ -22,10 +22,12 @@
22
22
  * The compatibility adapter normalizes service access and list snapshots.
23
23
  * Optional live-writer/coordinator internals are guarded before mutation.
24
24
  */
25
+ import { statSync } from "node:fs";
25
26
  import { lstat, mkdir, readdir, realpath, rename, rm } from "node:fs/promises";
26
27
  import { basename, dirname, join } from "node:path";
27
28
  import { randomBytes } from "node:crypto";
28
29
  import { createAnnotationStore } from "./annotation-store.js";
30
+ import { createWorkspaceArchiveStore, assertWorkspaceId } from "./workspace-archive-store.js";
29
31
  import { createDshAdapter, normalizeSessionHeader, compareSemver } from "./compat/dsh-adapter.js";
30
32
  import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
31
33
 
@@ -169,7 +171,7 @@ const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHea
169
171
 
170
172
  export function apply(ctx) {
171
173
  try {
172
- const buildId = "dsh-session-manager v0.5.2+release-17 (clearSelection-keeps-bulk-mode-on)";
174
+ const buildId = "dsh-session-manager v0.7.0 (workspace-archive)";
173
175
  if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
174
176
  else if (typeof console !== "undefined" && console?.info) console.info(buildId);
175
177
  } catch (_) { /* ignore logging failures */ }
@@ -183,6 +185,21 @@ export function apply(ctx) {
183
185
  }
184
186
  return annotationStore;
185
187
  };
188
+ let workspaceArchiveStore;
189
+ const workspaceArchives = () => {
190
+ if (!workspaceArchiveStore) {
191
+ const home = adapter.homePath;
192
+ if (typeof home === "function") {
193
+ try {
194
+ workspaceArchiveStore = createWorkspaceArchiveStore(home("dsh-session-manager"));
195
+ } catch (_) {}
196
+ }
197
+ if (!workspaceArchiveStore) {
198
+ workspaceArchiveStore = createWorkspaceArchiveStore(null, { memory: true });
199
+ }
200
+ }
201
+ return workspaceArchiveStore;
202
+ };
186
203
  const assertAnnotatable = id => {
187
204
  const registry = adapter.workspaceRegistry;
188
205
  if (adapter.getSession(id) !== undefined || registry?.headers?.has?.(id)
@@ -360,18 +377,26 @@ export function apply(ctx) {
360
377
  /** Ordered workspace projection for the client. */
361
378
  const listWorkspaces = () => {
362
379
  return ctx.workspaceRegistry.list().map((entity) => {
363
- const rawIds = Array.isArray(entity.record?.sessionIds)
364
- ? [...entity.record.sessionIds]
365
- : [...entity.sessionIds];
366
- return {
367
- id: entity.id,
368
- name: entity.title || entity.id,
369
- title: entity.title || entity.id,
370
- path: entity.path,
371
- sessionCount: rawIds.length,
372
- sessionIds: rawIds
373
- };
374
-
380
+ const rawIds = Array.isArray(entity.record?.sessionIds)
381
+ ? [...entity.record.sessionIds]
382
+ : [...entity.sessionIds];
383
+ let selfUpdatedAt = entity.updatedAt ?? entity.record?.updatedAt ?? null;
384
+ if (selfUpdatedAt === null && entity.path) {
385
+ try {
386
+ const st = statSync(entity.path);
387
+ selfUpdatedAt = st.mtimeMs;
388
+ } catch (_) {}
389
+ }
390
+ return {
391
+ id: entity.id,
392
+ name: entity.title || entity.id,
393
+ title: entity.title || entity.id,
394
+ path: entity.path,
395
+ sessionCount: rawIds.length,
396
+ sessionIds: rawIds,
397
+ updatedAt: selfUpdatedAt,
398
+ createdAt: entity.createdAt ?? entity.record?.createdAt ?? null
399
+ };
375
400
  });
376
401
  };
377
402
 
@@ -1393,8 +1418,10 @@ ${rest}`, "utf8");
1393
1418
  if (req.method === "POST" && ["/delete", "/unarchive", "/move", "/preset-migrate"].includes(path)) {
1394
1419
  assertSessionId(sessionId);
1395
1420
  let result;
1396
- if (path === "/delete") result = await mutate(sessionId, () => deleteSession(sessionId));
1397
- else if (path === "/unarchive") {
1421
+ if (path === "/delete") {
1422
+ result = await mutate(sessionId, () => deleteSession(sessionId));
1423
+ try { await workspaceArchives().removeSession(sessionId); } catch (_) {}
1424
+ } else if (path === "/unarchive") {
1398
1425
  await mutate(sessionId, () => unarchiveSession(sessionId));
1399
1426
  result = { sessionId };
1400
1427
  } else if (path === "/move") {
@@ -1408,6 +1435,38 @@ ${rest}`, "utf8");
1408
1435
  }
1409
1436
  return send(res, 200, { ok: true, result });
1410
1437
  }
1438
+ if (req.method === "POST" && path === "/workspace-archive") {
1439
+ const workspaceId = typeof body.workspaceId === "string" ? body.workspaceId.trim() : "";
1440
+ assertWorkspaceId(workspaceId);
1441
+ const sessionIds = Array.isArray(body.sessionIds) ? body.sessionIds : [];
1442
+ for (const id of sessionIds) assertSessionId(id);
1443
+ const pending = Array.isArray(body.pending) ? body.pending : [];
1444
+ for (const id of pending) assertSessionId(id);
1445
+ const record = await workspaceArchives().setArchive(workspaceId, {
1446
+ sessionIds,
1447
+ pending,
1448
+ archivedAt: typeof body.archivedAt === "number" ? body.archivedAt : Date.now()
1449
+ });
1450
+ return send(res, 200, { ok: true, result: { workspaceId, record } });
1451
+ }
1452
+ if (req.method === "POST" && path === "/workspace-unarchive") {
1453
+ const workspaceId = typeof body.workspaceId === "string" ? body.workspaceId.trim() : "";
1454
+ assertWorkspaceId(workspaceId);
1455
+ const pending = Array.isArray(body.pending) ? body.pending : [];
1456
+ for (const id of pending) assertSessionId(id);
1457
+ const retainSessionIds = body.retainSessionIds === true;
1458
+ const record = await workspaceArchives().setUnarchive(workspaceId, { pending, retainSessionIds });
1459
+ return send(res, 200, { ok: true, result: { workspaceId, record } });
1460
+ }
1461
+ if (req.method === "GET" && path === "/workspace-archives") {
1462
+ let archives = {};
1463
+ try {
1464
+ archives = await workspaceArchives().getAll();
1465
+ } catch (err) {
1466
+ return send(res, 500, { ok: false, error: safeErrorMessage(err) });
1467
+ }
1468
+ return send(res, 200, { ok: true, result: { archives } });
1469
+ }
1411
1470
  if (req.method === "POST" && path === "/batch") {
1412
1471
  // Per-action body shape (each action validates its own payload):
1413
1472
  // archive : no payload
@@ -1456,10 +1515,18 @@ ${rest}`, "utf8");
1456
1515
  } });
1457
1516
  }
1458
1517
  if (req.method === "GET" && path === "/workspaces") {
1459
- const sessionCreatedAt = Object.fromEntries(adapter.cachedSessionHeaders()
1518
+ const cachedHeaders = adapter.cachedSessionHeaders();
1519
+ const sessionCreatedAt = Object.fromEntries(cachedHeaders
1460
1520
  .filter(header => typeof header.createdAt === "number" || typeof header.createdAt === "string")
1461
1521
  .map(header => [header.id, header.createdAt]));
1462
- return send(res, 200, { ok: true, result: { workspaces: listWorkspaces(), sessionCreatedAt } });
1522
+ const sessionUpdatedAt = Object.fromEntries(cachedHeaders
1523
+ .filter(header => header && (typeof header.updatedAt === "number" || typeof header.updatedAt === "string" || typeof header.lastModified === "number" || typeof header.mtime === "number"))
1524
+ .map(header => [header.id, header.updatedAt ?? header.lastModified ?? header.mtime]));
1525
+ let workspaceArchivesMap = {};
1526
+ try {
1527
+ workspaceArchivesMap = await workspaceArchives().getAll();
1528
+ } catch (_) {}
1529
+ return send(res, 200, { ok: true, result: { workspaces: listWorkspaces(), sessionCreatedAt, sessionUpdatedAt, workspaceArchives: workspaceArchivesMap } });
1463
1530
  }
1464
1531
  if (req.method === "GET" && path === "/preset-scan") {
1465
1532
  const sessionId = url.searchParams.get("sessionId") || "";
@@ -1468,7 +1535,7 @@ ${rest}`, "utf8");
1468
1535
  return send(res, 200, { ok: true, result });
1469
1536
  }
1470
1537
  if (req.method === "GET" && path === "/update/check") {
1471
- const currentVersion = adapter.pluginVersion() || "0.6.4";
1538
+ const currentVersion = adapter.pluginVersion() || "0.7.0";
1472
1539
  let latestVersion = currentVersion;
1473
1540
  let hasUpdate = false;
1474
1541
  try {
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Workspace archive persistence.
3
+ *
4
+ * Persists workspace archive state (workspaceId, archived, archivedAt, sessionIds, pending)
5
+ * outside DSH's own registry so restarting DSH retains the archive list without modifying
6
+ * or corrupting DSH native workspace records.
7
+ * Atomic file writes with advisory file lock protect multi-process / multi-window concurrent access.
8
+ */
9
+ import * as fs from "node:fs/promises";
10
+ import { join, resolve } from "node:path";
11
+ import { randomBytes } from "node:crypto";
12
+ import { setTimeout as delay } from "node:timers/promises";
13
+ import { assertSessionId, writeTempFile } from "./session-files.js";
14
+
15
+ const fail = (message, code = "bad-request") => Object.assign(new Error(message), { code });
16
+ const isRecord = value => value !== null && typeof value === "object" && !Array.isArray(value);
17
+
18
+ export function assertWorkspaceId(id) {
19
+ if (typeof id !== "string" || !id.trim() || id.includes("\0") || id.includes("..")) {
20
+ throw fail("无效的工作区标识", "invalid-workspace-id");
21
+ }
22
+ }
23
+
24
+ export function normalizeWorkspaceArchiveRecord(record) {
25
+ if (!isRecord(record)) throw fail("工作区归档记录必须为对象");
26
+ assertWorkspaceId(record.workspaceId);
27
+ const archived = record.archived === true;
28
+ const archivedAt = typeof record.archivedAt === "number" && Number.isFinite(record.archivedAt)
29
+ ? record.archivedAt
30
+ : Date.now();
31
+ const sessionIds = Array.isArray(record.sessionIds) ? [...record.sessionIds] : [];
32
+ for (const sid of sessionIds) assertSessionId(sid);
33
+ const pending = Array.isArray(record.pending) ? [...record.pending] : [];
34
+ for (const sid of pending) assertSessionId(sid);
35
+
36
+ return {
37
+ workspaceId: record.workspaceId,
38
+ archived,
39
+ archivedAt,
40
+ sessionIds,
41
+ pending
42
+ };
43
+ }
44
+
45
+ export function createWorkspaceArchiveStore(directory, { io = fs, lockWaitMs = 3000, memory = false } = {}) {
46
+ if (memory || !directory) {
47
+ let state = { version: 1, workspaces: {} };
48
+ return {
49
+ async read() {
50
+ return structuredClone(state);
51
+ },
52
+ async get(workspaceId) {
53
+ assertWorkspaceId(workspaceId);
54
+ return state.workspaces[workspaceId] ? structuredClone(state.workspaces[workspaceId]) : null;
55
+ },
56
+ async getAll() {
57
+ return structuredClone(state.workspaces);
58
+ },
59
+ async setArchive(workspaceId, patch) {
60
+ assertWorkspaceId(workspaceId);
61
+ const record = normalizeWorkspaceArchiveRecord({ ...patch, workspaceId, archived: true });
62
+ state.workspaces[workspaceId] = record;
63
+ return structuredClone(record);
64
+ },
65
+ async setUnarchive(workspaceId, { pending = [], retainSessionIds = false } = {}) {
66
+ assertWorkspaceId(workspaceId);
67
+ const prev = state.workspaces[workspaceId] || { workspaceId, sessionIds: [] };
68
+ const hasPending = Array.isArray(pending) && pending.length > 0;
69
+ const keepRecord = hasPending || (retainSessionIds && Array.isArray(prev.sessionIds) && prev.sessionIds.length > 0);
70
+ if (keepRecord) {
71
+ const record = normalizeWorkspaceArchiveRecord({
72
+ ...prev,
73
+ workspaceId,
74
+ archived: false,
75
+ pending: hasPending ? pending : (Array.isArray(prev.pending) ? prev.pending : [])
76
+ });
77
+ state.workspaces[workspaceId] = record;
78
+ return structuredClone(record);
79
+ }
80
+ delete state.workspaces[workspaceId];
81
+ return null;
82
+ },
83
+ async removeSession(sessionId) {
84
+ let changed = false;
85
+ for (const [wsId, rec] of Object.entries(state.workspaces)) {
86
+ if (rec.sessionIds.includes(sessionId) || rec.pending.includes(sessionId)) {
87
+ rec.sessionIds = rec.sessionIds.filter(id => id !== sessionId);
88
+ rec.pending = rec.pending.filter(id => id !== sessionId);
89
+ changed = true;
90
+ }
91
+ }
92
+ return changed;
93
+ },
94
+ async clear() {
95
+ state = { version: 1, workspaces: {} };
96
+ }
97
+ };
98
+ }
99
+
100
+ const root = resolve(directory);
101
+ const path = join(root, "workspace-archives.v1.json");
102
+ const lockPath = join(root, "workspace-archives.v1.lock");
103
+
104
+ let queue = Promise.resolve();
105
+ const enqueue = operation => {
106
+ const result = queue.then(operation);
107
+ queue = result.catch(() => {});
108
+ return result;
109
+ };
110
+
111
+ const read = async () => {
112
+ let content;
113
+ try {
114
+ const parent = await io.lstat(root);
115
+ if (!parent.isDirectory() || parent.isSymbolicLink()) throw fail("工作区归档目录不能是符号链接", "workspace-archives-unavailable");
116
+ const info = await io.lstat(path);
117
+ if (!info.isFile() || info.isSymbolicLink()) throw fail("工作区归档文件必须是普通文件", "workspace-archives-unavailable");
118
+ content = await io.readFile(path, "utf8");
119
+ } catch (error) {
120
+ if (error.code === "ENOENT") return { version: 1, workspaces: {} };
121
+ throw error;
122
+ }
123
+ try {
124
+ const state = JSON.parse(content);
125
+ if (state?.version !== 1 || !isRecord(state.workspaces)) throw new Error("schema");
126
+ const normalizedWorkspaces = {};
127
+ for (const [id, item] of Object.entries(state.workspaces)) {
128
+ normalizedWorkspaces[id] = normalizeWorkspaceArchiveRecord({ ...item, workspaceId: id });
129
+ }
130
+ return { version: 1, workspaces: normalizedWorkspaces };
131
+ } catch {
132
+ throw fail("工作区归档文件损坏或版本不兼容,已停止写入;请先检查 " + path, "workspace-archives-corrupt");
133
+ }
134
+ };
135
+
136
+ const withLock = async operation => {
137
+ await io.mkdir(root, { recursive: true, mode: 0o700 });
138
+ const parent = await io.lstat(root);
139
+ if (!parent.isDirectory() || parent.isSymbolicLink()) throw fail("工作区归档目录不能是符号链接", "workspace-archives-unavailable");
140
+ const token = JSON.stringify({ pid: process.pid, token: randomBytes(12).toString("hex") });
141
+ const deadline = Date.now() + lockWaitMs;
142
+ let handle;
143
+ while (!handle) {
144
+ try {
145
+ handle = await io.open(lockPath, "wx", 0o600);
146
+ } catch (error) {
147
+ if (error.code !== "EEXIST") throw error;
148
+ if (Date.now() >= deadline) throw fail("工作区归档正由其他进程写入,请稍后重试", "workspace-archives-busy");
149
+ await delay(Math.min(25, Math.max(1, deadline - Date.now())));
150
+ }
151
+ }
152
+ try {
153
+ await handle.writeFile(token, "utf8");
154
+ return await operation();
155
+ } finally {
156
+ try { await handle.close(); } catch (_) {}
157
+ try { await io.unlink(lockPath); } catch (_) {}
158
+ }
159
+ };
160
+
161
+ const write = async state => {
162
+ const raw = JSON.stringify(state, null, 2) + "\n";
163
+ const tempPath = await writeTempFile(path, raw, io);
164
+ await io.rename(tempPath, path);
165
+ };
166
+
167
+ return {
168
+ async read() {
169
+ return enqueue(read);
170
+ },
171
+ async get(workspaceId) {
172
+ assertWorkspaceId(workspaceId);
173
+ const state = await enqueue(read);
174
+ return state.workspaces[workspaceId] ? structuredClone(state.workspaces[workspaceId]) : null;
175
+ },
176
+ async getAll() {
177
+ const state = await enqueue(read);
178
+ return structuredClone(state.workspaces);
179
+ },
180
+ async setArchive(workspaceId, patch) {
181
+ assertWorkspaceId(workspaceId);
182
+ const record = normalizeWorkspaceArchiveRecord({ ...patch, workspaceId, archived: true });
183
+ return enqueue(() => withLock(async () => {
184
+ const state = await read();
185
+ state.workspaces[workspaceId] = record;
186
+ await write(state);
187
+ return structuredClone(record);
188
+ }));
189
+ },
190
+ async setUnarchive(workspaceId, { pending = [], retainSessionIds = false } = {}) {
191
+ assertWorkspaceId(workspaceId);
192
+ return enqueue(() => withLock(async () => {
193
+ const state = await read();
194
+ const prev = state.workspaces[workspaceId] || { workspaceId, sessionIds: [] };
195
+ const hasPending = Array.isArray(pending) && pending.length > 0;
196
+ const keepRecord = hasPending || (retainSessionIds && Array.isArray(prev.sessionIds) && prev.sessionIds.length > 0);
197
+ if (keepRecord) {
198
+ const record = normalizeWorkspaceArchiveRecord({
199
+ ...prev,
200
+ workspaceId,
201
+ archived: false,
202
+ pending: hasPending ? pending : (Array.isArray(prev.pending) ? prev.pending : [])
203
+ });
204
+ state.workspaces[workspaceId] = record;
205
+ await write(state);
206
+ return structuredClone(record);
207
+ }
208
+ delete state.workspaces[workspaceId];
209
+ await write(state);
210
+ return null;
211
+ }));
212
+ },
213
+ async removeSession(sessionId) {
214
+ assertSessionId(sessionId);
215
+ return enqueue(() => withLock(async () => {
216
+ const state = await read();
217
+ let changed = false;
218
+ for (const [wsId, rec] of Object.entries(state.workspaces)) {
219
+ if (rec.sessionIds.includes(sessionId) || rec.pending.includes(sessionId)) {
220
+ rec.sessionIds = rec.sessionIds.filter(id => id !== sessionId);
221
+ rec.pending = rec.pending.filter(id => id !== sessionId);
222
+ changed = true;
223
+ }
224
+ }
225
+ if (changed) await write(state);
226
+ return changed;
227
+ }));
228
+ },
229
+ async clear() {
230
+ return enqueue(() => withLock(async () => {
231
+ await write({ version: 1, workspaces: {} });
232
+ }));
233
+ }
234
+ };
235
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-session-manager",
3
- "version": "0.6.4",
4
- "description": "DeepSeek Harness session manager: delete, archive, move across workspaces, migrate presets, favorites, review-later, search, filter, sort, priority, tags, notes, and batch actions. | DSH 会话管理:删除、归档、跨工作区移动、预设迁移、收藏、待回看、搜索、筛选、排序、优先级、标签、备注及批量操作。",
3
+ "version": "0.7.0",
4
+ "description": "All-in-one session and workspace manager for DeepSeek Harness: lifecycle control (delete, archive, move, preset migration), workspace archiving & sidebar hiding, annotations (favorites, review-later, priority, tags, notes), search, and batch actions. | 专为 DeepSeek Harness 打造的一站式会话与工作区管理插件:支持会话生命周期管理、工作区归档与隐藏、多维标注、智能筛选排序及高效批量操作。",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "dsh",
@@ -15,6 +15,8 @@
15
15
  "archive",
16
16
  "move",
17
17
  "workspace",
18
+ "workspace-archive",
19
+ "workspace-management",
18
20
  "preset",
19
21
  "agent-preset",
20
22
  "migration",
@@ -62,7 +64,7 @@
62
64
  "node": "^22.15.0 || >=24.0.0"
63
65
  },
64
66
  "scripts": {
65
- "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/session-files.js && node --check lib/annotation-store.js && node --check lib/clipboard-parser.js && node -e \"import('./lib/index.js')\"",
67
+ "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/session-files.js && node --check lib/annotation-store.js && node --check lib/workspace-archive-store.js && node --check lib/clipboard-parser.js && node -e \"import('./lib/index.js')\"",
66
68
  "test": "node scripts/run-tests.mjs",
67
69
  "check:package": "node scripts/check-package.mjs"
68
70
  }