dsh-session-manager 0.6.3 → 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.
@@ -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.3",
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
  }