@sunshaobei/dsh-session-manager 0.0.0-stage → 0.4.3-fork.1
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/LICENSE +21 -0
- package/README.md +94 -2
- package/README.zh.md +94 -0
- package/client.css +1178 -0
- package/client.js +1324 -0
- package/cordis.patch.yml +16 -0
- package/index.js +78 -0
- package/package.json +61 -4
- package/packages/session-trash-host/lib/index.js +1316 -0
- package/packages/session-trash-host/lib/types/index.d.ts +32 -0
- package/packages/session-trash-host/package.json +26 -0
|
@@ -0,0 +1,1316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Trash Host plugin
|
|
3
|
+
*
|
|
4
|
+
* Extends the running DSH instance with:
|
|
5
|
+
* - SessionPersistence.delete(id) — physical log deletion
|
|
6
|
+
* - SessionProjectionCache.remove(id) — cache record cleanup
|
|
7
|
+
* - WorkspaceRegistry.unarchiveSession(sessionId) — restore from archive
|
|
8
|
+
* - WorkspaceRegistry.permanentlyDeleteSession(sessionId) — full cleanup
|
|
9
|
+
* - WorkspaceRegistry.emptyArchivedSessions() — batch permanent delete
|
|
10
|
+
* - WorkspaceRegistry.listArchivedSessions() — recycle-bin listing
|
|
11
|
+
* - Typert RPC methods: workspace.unarchiveSession,
|
|
12
|
+
* workspace.permanentlyDeleteSession, workspace.emptyArchivedSessions,
|
|
13
|
+
* workspace.listArchivedSessions
|
|
14
|
+
*
|
|
15
|
+
* This plugin patches the existing services at init time. It is designed
|
|
16
|
+
* to be loaded via cordis.patch.yml and works with the compiled DSH packages.
|
|
17
|
+
*/
|
|
18
|
+
import { rm, readdir, rmdir, stat, readFile } from 'node:fs/promises';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
// Robust Service base class fallback for standalone testing environment
|
|
23
|
+
let ServiceClass;
|
|
24
|
+
try {
|
|
25
|
+
const cordis = await import('@deepseek-ai/cordis');
|
|
26
|
+
ServiceClass = cordis.Service;
|
|
27
|
+
} catch {
|
|
28
|
+
ServiceClass = class Service {
|
|
29
|
+
static init = Symbol.for('cordis.init');
|
|
30
|
+
constructor(ctx, name) {
|
|
31
|
+
this.ctx = ctx;
|
|
32
|
+
this.name = name;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* ------------------------------------------------------------------ */
|
|
38
|
+
/* Helper: Broadcast session state change */
|
|
39
|
+
/* ------------------------------------------------------------------ */
|
|
40
|
+
|
|
41
|
+
function notifyArchivedSessionsChanged(ctx, archivedSessionIds) {
|
|
42
|
+
try {
|
|
43
|
+
ctx.emit?.('workspace/archived-sessions-changed', { archivedSessionIds });
|
|
44
|
+
// The browser-side refresh rides the STOCK workspace broadcast: the
|
|
45
|
+
// in-box api-proxy detects the archived-set change and pushes
|
|
46
|
+
// `host/archived-sessions-changed` over the connection itself.
|
|
47
|
+
} catch (error) {
|
|
48
|
+
ctx.logger?.warn?.('session-trash-host: failed to broadcast archived sessions change: %s', error.message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ------------------------------------------------------------------ */
|
|
53
|
+
/* Dual-cohort helpers (DSH 0.1.1-rc.2 / 0.1.2-rc.1 / 0.1.5-rc.1) */
|
|
54
|
+
/* ------------------------------------------------------------------ */
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize one item from persistence.list() across host cohorts.
|
|
58
|
+
* 0.1.1/0.1.2 list() returns SessionHeader[] (the header itself); 0.1.5
|
|
59
|
+
* returns SessionPersistenceSnapshot[] ({ header, revision, ... }). Both
|
|
60
|
+
* expose the durable SessionHeader, just at different depths.
|
|
61
|
+
* @param {object|undefined} item
|
|
62
|
+
* @returns {object|undefined} the SessionHeader (with .id/.title/.cwd).
|
|
63
|
+
*/
|
|
64
|
+
function normalizeStoredHeader(item) {
|
|
65
|
+
if (!item) return undefined;
|
|
66
|
+
if (typeof item.id === 'string') return item; // older cohort: raw header
|
|
67
|
+
if (item.header && typeof item.header.id === 'string') return item.header; // 0.1.5 snapshot
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve one session's physical log path across host cohorts.
|
|
73
|
+
*
|
|
74
|
+
* Priority:
|
|
75
|
+
* 1. persistence.findLog(id) — older host service method (0.1.1/0.1.2:
|
|
76
|
+
* a path string; 0.1.5: a generation
|
|
77
|
+
* descriptor — both normalized here).
|
|
78
|
+
* 2. persistence.locate({id}) — older service API; still present on the
|
|
79
|
+
* jsonl backend in 0.1.5 ({kind, path}).
|
|
80
|
+
* 3. physical directory scan — the stable on-disk contract across every
|
|
81
|
+
* 0.1.x host: ~/.dsh/sessions/<project>/<sessionId>/session*.jsonl*.
|
|
82
|
+
* This is host-API-independent and also the
|
|
83
|
+
* only mechanism that works when the service
|
|
84
|
+
* stops forwarding locate (0.1.5).
|
|
85
|
+
* @param {object|undefined} persistence ctx.sessionPersistence
|
|
86
|
+
* @param {string} sessionId
|
|
87
|
+
* @returns {Promise<string|undefined>} resolved log path, or undefined.
|
|
88
|
+
*/
|
|
89
|
+
async function resolveSessionLogPath(persistence, sessionId) {
|
|
90
|
+
// findLog is the AUTHORITATIVE resolver on older hosts: a returned path —
|
|
91
|
+
// even when the file is already gone — means this session has a log
|
|
92
|
+
// location (permanentlyDeleteSession then keeps the workspace slot); a
|
|
93
|
+
// missing answer means the session is a ghost with no log known at all.
|
|
94
|
+
// Its answer is therefore returned verbatim. locate() is a CANDIDATE path
|
|
95
|
+
// computation that never checks existence, so a located candidate counts
|
|
96
|
+
// only when the file is actually there. The physical scan returns only
|
|
97
|
+
// files that exist.
|
|
98
|
+
try {
|
|
99
|
+
if (typeof persistence?.findLog === 'function') {
|
|
100
|
+
const found = await persistence.findLog(sessionId);
|
|
101
|
+
// 0.1.1/0.1.2 return the log path directly; 0.1.5 returns a generation
|
|
102
|
+
// descriptor ({ sourcePath, sourceVersion, currentPath }) — verified
|
|
103
|
+
// against the real 0.1.5 jsonl backend in the sandbox harness.
|
|
104
|
+
const resolved = typeof found === 'string'
|
|
105
|
+
? found
|
|
106
|
+
: (found?.currentPath ?? found?.sourcePath);
|
|
107
|
+
if (typeof resolved === 'string' && resolved) return resolved;
|
|
108
|
+
}
|
|
109
|
+
} catch { /* fall through */ }
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
if (typeof persistence?.locate === 'function') {
|
|
113
|
+
const loc = persistence.locate({ id: sessionId });
|
|
114
|
+
const candidate = loc?.path ?? (typeof loc === 'string' ? loc : undefined);
|
|
115
|
+
if (candidate && await fileExists(candidate)) return candidate;
|
|
116
|
+
}
|
|
117
|
+
} catch { /* fall through */ }
|
|
118
|
+
|
|
119
|
+
return scanSessionLogPath(sessionId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** True when the path names a regular file on disk. */
|
|
123
|
+
async function fileExists(path) {
|
|
124
|
+
if (!path) return false;
|
|
125
|
+
try {
|
|
126
|
+
return (await stat(path)).isFile();
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Physical directory scan for a session log file. Layout (all 0.1.x hosts):
|
|
134
|
+
* ~/.dsh/sessions/<workspace-path-encoded>/<sessionId>/session.jsonl
|
|
135
|
+
* ~/.dsh/sessions/<workspace-path-encoded>/<sessionId>/session.v{1..3}.jsonl
|
|
136
|
+
* plus the optional .zstd compression suffix. Recognises both the legacy
|
|
137
|
+
* plain name and the 0.1.5 versioned generations.
|
|
138
|
+
* @param {string} sessionId
|
|
139
|
+
* @returns {Promise<string|undefined>}
|
|
140
|
+
*/
|
|
141
|
+
async function scanSessionLogPath(sessionId) {
|
|
142
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
143
|
+
if (!home) return undefined;
|
|
144
|
+
const root = join(home, '.dsh', 'sessions');
|
|
145
|
+
try {
|
|
146
|
+
const projects = await readdir(root);
|
|
147
|
+
for (const project of projects) {
|
|
148
|
+
const sessionDir = join(root, project, sessionId);
|
|
149
|
+
const names = await readdir(sessionDir).catch(() => []);
|
|
150
|
+
const logName = names.find((n) => /^session(?:.v\d+)?\.jsonl(?:\.zstd)?$/.test(n));
|
|
151
|
+
if (logName) return join(sessionDir, logName);
|
|
152
|
+
}
|
|
153
|
+
} catch { /* best effort */ }
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Session ids whose event read failed and was reported once, so a broken
|
|
159
|
+
* artifact doesn't turn every recycle-bin refresh into a log flood.
|
|
160
|
+
*/
|
|
161
|
+
const readFailureReported = new Set();
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Read one stored session's events across host cohorts.
|
|
165
|
+
* 1. persistence.inspect(id) — older host (0.1.1/0.1.2),
|
|
166
|
+
* returns { events }.
|
|
167
|
+
* 2. persistence.open(id, 'read') → handle.read() → { events } — 0.1.5
|
|
168
|
+
* handle API.
|
|
169
|
+
*
|
|
170
|
+
* WHY THE ACCESS ARGUMENT IS MANDATORY (v0.4.1):
|
|
171
|
+
*
|
|
172
|
+
* `SessionPersistence.open(id, access, options)` does NOT default to read.
|
|
173
|
+
* The 0.1.5 jsonl backend branches on `access === "read"`; anything else —
|
|
174
|
+
* including `undefined` — falls through to the single-writer path
|
|
175
|
+
* (`tracker.claimWrite(id)` + a cross-process lease). Reading a session the
|
|
176
|
+
* host itself still has open (the normal case for the conversation the user
|
|
177
|
+
* just deleted, and for any session whose agent is idle but loaded) then
|
|
178
|
+
* throws SessionAlreadyOwnedError, and a session with no durable artifact
|
|
179
|
+
* yet throws NotFound. Both were swallowed below, so `listArchivedSessions`
|
|
180
|
+
* reported `turnCount: 0` and the preview route returned `messages: []`
|
|
181
|
+
* for exactly the sessions that were being viewed — while untouched
|
|
182
|
+
* sessions listed fine. A successful write open would also have claimed
|
|
183
|
+
* write ownership and taken an exclusive lease just to read.
|
|
184
|
+
*
|
|
185
|
+
* The older cohort has no `open()` at all (it is `inspect`-only), so the
|
|
186
|
+
* extra argument is inert there.
|
|
187
|
+
*
|
|
188
|
+
* @param {object|undefined} persistence ctx.sessionPersistence
|
|
189
|
+
* @param {string} sessionId
|
|
190
|
+
* @returns {Promise<Array|undefined>} the event array, or undefined.
|
|
191
|
+
*/
|
|
192
|
+
async function readSessionEvents(persistence, sessionId) {
|
|
193
|
+
let failure;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
if (typeof persistence?.inspect === 'function') {
|
|
197
|
+
const stored = await persistence.inspect(sessionId);
|
|
198
|
+
if (stored && Array.isArray(stored.events)) return stored.events;
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
failure = error;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
if (typeof persistence?.open === 'function') {
|
|
206
|
+
const handle = await persistence.open(sessionId, 'read');
|
|
207
|
+
try {
|
|
208
|
+
const result = await handle.read();
|
|
209
|
+
if (result && Array.isArray(result.events)) return result.events;
|
|
210
|
+
} finally {
|
|
211
|
+
if (typeof handle?.close === 'function') await handle.close().catch(() => {});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
failure = error;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Best effort: the caller renders a 0-turn row instead of failing the list.
|
|
219
|
+
// Report the underlying reason ONCE per session so a future contract drift
|
|
220
|
+
// is diagnosable instead of showing up as a silently empty recycle bin.
|
|
221
|
+
if (failure && !readFailureReported.has(sessionId) && readFailureReported.size < 200) {
|
|
222
|
+
readFailureReported.add(sessionId);
|
|
223
|
+
console?.warn?.(`session-trash-host: could not read events for "${sessionId}": ${failure.message ?? failure}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ------------------------------------------------------------------ */
|
|
230
|
+
/* Patch: SessionPersistence.delete(id) */
|
|
231
|
+
/* Adds physical deletion and memory index cleanup. */
|
|
232
|
+
/* ------------------------------------------------------------------ */
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Marker installed on every service method this bundle patches, carrying the
|
|
236
|
+
* owning install's token. cordis unloads a plugin fiber WITHOUT unwinding
|
|
237
|
+
* methods previously patched onto shared service objects, so a later instance
|
|
238
|
+
* must be able to (a) recognise a patch left behind by an earlier fiber,
|
|
239
|
+
* (b) replace it with one closing over the live context, and (c) on unload
|
|
240
|
+
* remove only its own methods — never a newer instance's.
|
|
241
|
+
*/
|
|
242
|
+
const TRASH_PATCH = Symbol('dsh-session-trash.patch');
|
|
243
|
+
/** Kept on our persistence.delete wrapper: the pristine function to restore. */
|
|
244
|
+
const TRASH_PATCH_ORIGINAL = Symbol('dsh-session-trash.patchOriginal');
|
|
245
|
+
/** v0.3.1's untagged wrapper marker — still detected so its (context-free,
|
|
246
|
+
* fully functional) wrapper is kept instead of nested. */
|
|
247
|
+
const PERSISTENCE_DELETE_PATCHED = Symbol('dsh-session-trash.persistenceDeletePatched');
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Patch SessionPersistence to add a durable `delete` method.
|
|
251
|
+
* @param {import('@deepseek-ai/dsh-session-persistence').SessionPersistence} persistence
|
|
252
|
+
* @returns a disposer restoring the pristine delete, or null when an existing
|
|
253
|
+
* wrapper (ours or ≤v0.3.1's, both context-free) was kept as-is.
|
|
254
|
+
*/
|
|
255
|
+
function patchSessionPersistenceDelete(persistence) {
|
|
256
|
+
// A wrapper is already installed — by this version, a newer one, or a ≤v0.3.1
|
|
257
|
+
// fiber that leaked it (PERSISTENCE_DELETE_PATCHED, kept for that detection).
|
|
258
|
+
// The wrapper touches no context, so the stale one keeps working; wrapping it
|
|
259
|
+
// again would only nest duplicate physical deletions.
|
|
260
|
+
if (persistence.delete?.[TRASH_PATCH] || persistence.delete?.[PERSISTENCE_DELETE_PATCHED]) return null;
|
|
261
|
+
const originalDelete = persistence.delete;
|
|
262
|
+
const token = Symbol('dsh-session-trash.deletePatch');
|
|
263
|
+
persistence.delete = async function deleteSession(id) {
|
|
264
|
+
if (typeof originalDelete === 'function') {
|
|
265
|
+
try {
|
|
266
|
+
await originalDelete.call(this, id);
|
|
267
|
+
} catch { /* best effort */ }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const logPath = await resolveSessionLogPath(this, id);
|
|
272
|
+
if (logPath) {
|
|
273
|
+
const sessionDir = dirname(logPath);
|
|
274
|
+
await rm(sessionDir, { recursive: true, force: true });
|
|
275
|
+
const projectDir = dirname(sessionDir);
|
|
276
|
+
try {
|
|
277
|
+
const entries = await readdir(projectDir);
|
|
278
|
+
if (entries.length === 0) await rmdir(projectDir);
|
|
279
|
+
} catch { /* best effort */ }
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
console?.warn?.('session-trash-host: physical log delete failed: %s', error.message);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Clear internal memory maps or caches if present
|
|
286
|
+
if (this.cache && typeof this.cache.delete === 'function') this.cache.delete(id);
|
|
287
|
+
if (this.byId && typeof this.byId.delete === 'function') this.byId.delete(id);
|
|
288
|
+
if (this._headers && typeof this._headers.delete === 'function') this._headers.delete(id);
|
|
289
|
+
};
|
|
290
|
+
Object.defineProperty(persistence.delete, TRASH_PATCH, { value: token });
|
|
291
|
+
Object.defineProperty(persistence.delete, TRASH_PATCH_ORIGINAL, { value: originalDelete });
|
|
292
|
+
// Restore the pristine delete on fiber unload — unless a newer install has
|
|
293
|
+
// since replaced the wrapper (token mismatch).
|
|
294
|
+
return () => {
|
|
295
|
+
if (persistence.delete?.[TRASH_PATCH] !== token) return;
|
|
296
|
+
if (originalDelete === undefined) delete persistence.delete;
|
|
297
|
+
else persistence.delete = originalDelete;
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* ------------------------------------------------------------------ */
|
|
302
|
+
/* Patch: SessionProjectionCache.remove(id) */
|
|
303
|
+
/* Adds fail-soft cache record removal. */
|
|
304
|
+
/* ------------------------------------------------------------------ */
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Patch the SessionProjectionCache to add a `remove` method.
|
|
308
|
+
* @param {import('@deepseek-ai/dsh-session-projection-cache').SessionProjectionCache} cache
|
|
309
|
+
* @returns a disposer removing our method, or null when an existing remove
|
|
310
|
+
* (foreign, or a context-free ≤v0.3.1 leftover doing the same job) was kept.
|
|
311
|
+
*/
|
|
312
|
+
function patchProjectionCacheRemove(cache) {
|
|
313
|
+
const existing = cache.remove;
|
|
314
|
+
if (typeof existing === 'function' && !existing[TRASH_PATCH]) return null;
|
|
315
|
+
const token = Symbol('dsh-session-trash.cachePatch');
|
|
316
|
+
/**
|
|
317
|
+
* Remove one session's cached checkpoint record durably (fail-soft).
|
|
318
|
+
* @param {import('@deepseek-ai/dsh-session').SessionId} id
|
|
319
|
+
* @returns {Promise<void>}
|
|
320
|
+
*/
|
|
321
|
+
cache.remove = async function remove(id) {
|
|
322
|
+
const table = this.requireTable?.();
|
|
323
|
+
if (table) {
|
|
324
|
+
try {
|
|
325
|
+
await table.delete(id);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
console?.warn?.('session-trash-host: remove(%s) projection cache failed: %s', id, error.message);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (typeof this.markClean === 'function') {
|
|
332
|
+
this.markClean(id);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
Object.defineProperty(cache.remove, TRASH_PATCH, { value: token });
|
|
336
|
+
// Remove our method on fiber unload — unless a newer install replaced it.
|
|
337
|
+
return () => {
|
|
338
|
+
if (cache.remove?.[TRASH_PATCH] === token) delete cache.remove;
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* ------------------------------------------------------------------ */
|
|
343
|
+
/* Patch: WorkspaceRegistry — unarchive / permanentlyDelete / empty */
|
|
344
|
+
/* ------------------------------------------------------------------ */
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Patch the WorkspaceRegistry with new methods.
|
|
348
|
+
*
|
|
349
|
+
* v0.3.2: methods left on the shared registry by an EARLIER fiber (uninstall
|
|
350
|
+
* + reinstall without a restart) must be replaced, not reused — their
|
|
351
|
+
* closures capture the dead context, so listArchivedSessions() silently
|
|
352
|
+
* lost turn counts while the routes (rebuilt per mount) kept working. Every
|
|
353
|
+
* installed method is tagged with this install's TRASH_PATCH token; a fresh
|
|
354
|
+
* instance overwrites whatever stale methods it finds, and the returned
|
|
355
|
+
* disposer removes only methods still carrying this install's token.
|
|
356
|
+
*
|
|
357
|
+
* @param {import('@deepseek-ai/dsh-workspace').WorkspaceRegistry} registry
|
|
358
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
359
|
+
* @returns a disposer removing every method installed here, or null when the
|
|
360
|
+
* registry is missing.
|
|
361
|
+
*/
|
|
362
|
+
function patchWorkspaceRegistry(registry, ctx) {
|
|
363
|
+
if (!registry) return null;
|
|
364
|
+
const token = Symbol('dsh-session-trash.registryPatch');
|
|
365
|
+
// Snapshot the services the patched methods need while THIS fiber is live.
|
|
366
|
+
// The methods are installed on the SHARED registry object and may outlive
|
|
367
|
+
// this fiber (a pre-v0.3.2 install could leak them); resolving services
|
|
368
|
+
// through ctx at call time would then hit the dead-fiber guard
|
|
369
|
+
// ("cannot get required service ... in inactive context") and silently
|
|
370
|
+
// zero the turn counts. ctx.get(...) is a safe non-throwing accessor, so
|
|
371
|
+
// the remaining per-call lookups below stay dynamic on purpose.
|
|
372
|
+
const persistence = ctx.sessionPersistence;
|
|
373
|
+
const runInQueue = (fn) => {
|
|
374
|
+
if (typeof registry.enqueueOperation === 'function') {
|
|
375
|
+
return registry.enqueueOperation(fn);
|
|
376
|
+
}
|
|
377
|
+
return fn();
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// ── archiveSession ────────────────────────────────────────────────
|
|
381
|
+
registry.archiveSession = function archiveSession(sessionId, title) {
|
|
382
|
+
return runInQueue(async () => {
|
|
383
|
+
const state = registry.requireState?.();
|
|
384
|
+
if (!state) return;
|
|
385
|
+
const currentArchived = state.archivedSessionIds ?? [];
|
|
386
|
+
const currentTitles = state.archivedTitles ?? {};
|
|
387
|
+
if (!currentArchived.includes(sessionId)) {
|
|
388
|
+
const newArchived = [...currentArchived, sessionId];
|
|
389
|
+
const newTitles = { ...currentTitles };
|
|
390
|
+
if (title) newTitles[sessionId] = title;
|
|
391
|
+
await registry.setState({
|
|
392
|
+
...state,
|
|
393
|
+
archivedSessionIds: newArchived,
|
|
394
|
+
archivedTitles: newTitles,
|
|
395
|
+
});
|
|
396
|
+
notifyArchivedSessionsChanged(ctx, newArchived);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// ── unarchiveSession ──────────────────────────────────────────────
|
|
402
|
+
registry.unarchiveSession = function unarchiveSession(sessionId) {
|
|
403
|
+
return runInQueue(async () => {
|
|
404
|
+
const state = registry.requireState?.();
|
|
405
|
+
if (!state || !state.archivedSessionIds.includes(sessionId)) return;
|
|
406
|
+
|
|
407
|
+
const newArchived = state.archivedSessionIds.filter((id) => id !== sessionId);
|
|
408
|
+
await registry.setState({
|
|
409
|
+
...state,
|
|
410
|
+
archivedSessionIds: newArchived,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
notifyArchivedSessionsChanged(ctx, newArchived);
|
|
414
|
+
});
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// ── permanentlyDeleteSession ──────────────────────────────────────
|
|
418
|
+
registry.permanentlyDeleteSession = async function permanentlyDeleteSession(sessionId) {
|
|
419
|
+
// Live-session guard via the agents registry (the proven published-plugin
|
|
420
|
+
// check): only an ACTIVE agent blocks deletion.
|
|
421
|
+
const agents = ctx.get('agents');
|
|
422
|
+
const agent = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined;
|
|
423
|
+
if (agent !== undefined && agent.status !== 'idle') {
|
|
424
|
+
const err = new Error(`会话正在运行中,请等待结束后再删除`);
|
|
425
|
+
err.code = 'SESSION_RUNNING';
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Physical deletion via `findLog`: scans ALL project directories directly,
|
|
430
|
+
// ignoring cwd. If NO log exists on disk (a "ghost" session — an entry in
|
|
431
|
+
// the workspace registry whose log was never materialized or already
|
|
432
|
+
// removed), `logFound` stays false.
|
|
433
|
+
let logFound = false;
|
|
434
|
+
try {
|
|
435
|
+
const logPath = await resolveSessionLogPath(persistence, sessionId);
|
|
436
|
+
if (logPath) {
|
|
437
|
+
logFound = true;
|
|
438
|
+
const sessionDir = dirname(logPath);
|
|
439
|
+
await rm(sessionDir, { recursive: true, force: true });
|
|
440
|
+
const projectDir = dirname(sessionDir);
|
|
441
|
+
try {
|
|
442
|
+
const entries = await readdir(projectDir);
|
|
443
|
+
if (entries.length === 0) await rmdir(projectDir);
|
|
444
|
+
} catch { /* best effort */ }
|
|
445
|
+
}
|
|
446
|
+
} catch (error) {
|
|
447
|
+
throw new Error(`failed to delete session log for '${sessionId}'`, { cause: error });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const cache = ctx.get('sessionProjectionCache');
|
|
451
|
+
if (cache && typeof cache.remove === 'function') {
|
|
452
|
+
await cache.remove(sessionId);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Remove the session from the LIVE sessions service (the in-memory
|
|
456
|
+
// session manager). Without this the session lingers as an "ungrouped"
|
|
457
|
+
// sidebar entry until the next restart — the log is gone, so a restart
|
|
458
|
+
// reconciles it away, but the user should see it vanish immediately.
|
|
459
|
+
try {
|
|
460
|
+
const sessions = ctx.get('sessions');
|
|
461
|
+
const session = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
|
|
462
|
+
if (session !== undefined && typeof sessions.liveEntryFor === 'function') {
|
|
463
|
+
const entry = sessions.liveEntryFor(session);
|
|
464
|
+
if (entry && typeof entry.detach === 'function') {
|
|
465
|
+
entry.detach();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
} catch {
|
|
469
|
+
// not live in the store — nothing to detach
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return runInQueue(async () => {
|
|
473
|
+
const state = registry.requireState?.();
|
|
474
|
+
if (!state) return;
|
|
475
|
+
|
|
476
|
+
let changed = false;
|
|
477
|
+
if (state.archivedSessionIds.includes(sessionId)) {
|
|
478
|
+
const newArchived = state.archivedSessionIds.filter((id) => id !== sessionId);
|
|
479
|
+
await registry.setState({
|
|
480
|
+
...state,
|
|
481
|
+
archivedSessionIds: newArchived,
|
|
482
|
+
});
|
|
483
|
+
changed = true;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// GHOST-session cleanup: only when no physical log exists should the
|
|
487
|
+
// session be detached from every workspace record — it must DISAPPEAR,
|
|
488
|
+
// not be "restored". A session WITH a log keeps its workspace slot (the
|
|
489
|
+
// deleted log makes it invisible anyway, and detaching a still-listed
|
|
490
|
+
// session would surface it under an ungrouped section).
|
|
491
|
+
if (!logFound) {
|
|
492
|
+
const entities = registry.entities;
|
|
493
|
+
if (entities) {
|
|
494
|
+
for (const entity of entities.values()) {
|
|
495
|
+
if (entity.record?.sessionIds?.includes(sessionId)) {
|
|
496
|
+
try {
|
|
497
|
+
await entity.detachSession(sessionId);
|
|
498
|
+
} catch {
|
|
499
|
+
// best effort
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (changed) {
|
|
507
|
+
const archived = registry.requireState?.()?.archivedSessionIds ?? [];
|
|
508
|
+
notifyArchivedSessionsChanged(ctx, archived);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// ── emptyArchivedSessions ─────────────────────────────────────────
|
|
514
|
+
registry.emptyArchivedSessions = async function emptyArchivedSessions() {
|
|
515
|
+
const state = registry.requireState?.();
|
|
516
|
+
if (!state || state.archivedSessionIds.length === 0) return { deletedCount: 0 };
|
|
517
|
+
|
|
518
|
+
const archivedIds = [...state.archivedSessionIds];
|
|
519
|
+
let deletedCount = 0;
|
|
520
|
+
const errors = [];
|
|
521
|
+
|
|
522
|
+
for (const sessionId of archivedIds) {
|
|
523
|
+
try {
|
|
524
|
+
await registry.permanentlyDeleteSession(sessionId);
|
|
525
|
+
deletedCount++;
|
|
526
|
+
} catch (error) {
|
|
527
|
+
errors.push({ sessionId, message: error.message });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (errors.length > 0 && deletedCount === 0) {
|
|
532
|
+
throw new Error(`failed to empty recycle bin: ${errors[0].message}`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return { deletedCount, total: archivedIds.length };
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// ── listArchivedSessions ──────────────────────────────────────────
|
|
539
|
+
registry.listArchivedSessions = async function listArchivedSessions() {
|
|
540
|
+
const state = registry.requireState?.();
|
|
541
|
+
if (!state) return [];
|
|
542
|
+
|
|
543
|
+
const archivedIds = state.archivedSessionIds ?? [];
|
|
544
|
+
if (archivedIds.length === 0) return [];
|
|
545
|
+
|
|
546
|
+
/** 提取用户消息中的真实对话内容,过滤掉系统上下文、skills 等注入内容 */
|
|
547
|
+
function extractUserText(raw) {
|
|
548
|
+
if (!raw) return '';
|
|
549
|
+
// content 是 ContentBlock[],每个 block 有 type 和 text 字段
|
|
550
|
+
if (Array.isArray(raw)) {
|
|
551
|
+
return raw
|
|
552
|
+
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
|
553
|
+
.map((part) => part.text)
|
|
554
|
+
.filter(Boolean)
|
|
555
|
+
.join('\n')
|
|
556
|
+
.trim();
|
|
557
|
+
}
|
|
558
|
+
if (typeof raw !== 'string') return '';
|
|
559
|
+
let str = raw;
|
|
560
|
+
// 提取 <USER_REQUEST> 内的内容(用户真实输入)
|
|
561
|
+
const userReqMatch = str.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/i);
|
|
562
|
+
if (userReqMatch) return userReqMatch[1].trim();
|
|
563
|
+
// 去掉已知的 XML 标签
|
|
564
|
+
str = str.replace(/<ADDITIONAL_METADATA>[\s\S]*?<\/ADDITIONAL_METADATA>/gi, '');
|
|
565
|
+
str = str.replace(/<USER_SETTINGS_CHANGE>[\s\S]*?<\/USER_SETTINGS_CHANGE>/gi, '');
|
|
566
|
+
str = str.replace(/<SKILL>[\s\S]*?<\/SKILL>/gi, '');
|
|
567
|
+
str = str.replace(/<SYSTEM_CONTEXT>[\s\S]*?<\/SYSTEM_CONTEXT>/gi, '');
|
|
568
|
+
str = str.replace(/<WORKSPACE>[\s\S]*?<\/WORKSPACE>/gi, '');
|
|
569
|
+
// 去掉所有剩余的 HTML/XML 标签
|
|
570
|
+
str = str.replace(/<[^>]+>/g, '');
|
|
571
|
+
return str.trim();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let headers = [];
|
|
575
|
+
try {
|
|
576
|
+
headers = ((await persistence?.list?.()) ?? []).map(normalizeStoredHeader).filter(Boolean);
|
|
577
|
+
} catch {
|
|
578
|
+
// Persistence list fallback
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const byId = new Map(headers.map((h) => [h.id, h]));
|
|
582
|
+
|
|
583
|
+
const workspaceBySession = new Map();
|
|
584
|
+
const entities = registry.entities;
|
|
585
|
+
if (entities) {
|
|
586
|
+
for (const entity of entities.values()) {
|
|
587
|
+
for (const sid of entity.record?.sessionIds ?? []) {
|
|
588
|
+
if (archivedIds.includes(sid) && !workspaceBySession.has(sid)) {
|
|
589
|
+
workspaceBySession.set(sid, {
|
|
590
|
+
workspacePath: entity.record.path,
|
|
591
|
+
workspaceTitle: entity.record.title,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const items = await Promise.all(
|
|
599
|
+
archivedIds.map(async (sessionId) => {
|
|
600
|
+
const header = byId.get(sessionId);
|
|
601
|
+
const ws = workspaceBySession.get(sessionId);
|
|
602
|
+
|
|
603
|
+
let archivedAt = Date.now();
|
|
604
|
+
let fileSize = 0;
|
|
605
|
+
let turnCount = 0;
|
|
606
|
+
let derivedTitle = '';
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
const logPath = await resolveSessionLogPath(persistence, sessionId);
|
|
610
|
+
if (logPath) {
|
|
611
|
+
const fileStat = await stat(logPath).catch(() => null);
|
|
612
|
+
if (fileStat) {
|
|
613
|
+
archivedAt = fileStat.mtimeMs;
|
|
614
|
+
fileSize = fileStat.size;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// 读取已解压的会话事件(DSH 默认启用 Zstd 压缩,直接 readFile 读到的是
|
|
618
|
+
// 二进制数据,无法解析为 JSON)。0.1.5 起 inspect 移除,改用
|
|
619
|
+
// open(id, 'read') → handle.read()(见 readSessionEvents)。
|
|
620
|
+
const events = await readSessionEvents(persistence, sessionId);
|
|
621
|
+
if (Array.isArray(events)) {
|
|
622
|
+
// 统计 turn/start 事件数量作为真实对话轮数
|
|
623
|
+
turnCount = events.filter((e) => e.type === 'turn/start').length;
|
|
624
|
+
// 提取第一条真实用户消息作为衍生标题(过滤掉系统上下文等非用户输入)
|
|
625
|
+
const firstUserMsg = events.find((e) => e.type === 'user/message' && e.data?.source?.kind === 'user');
|
|
626
|
+
if (firstUserMsg?.data?.content) {
|
|
627
|
+
const text = extractUserText(firstUserMsg.data.content);
|
|
628
|
+
if (text) {
|
|
629
|
+
derivedTitle = text.split('\n')[0].substring(0, 50);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
} catch {
|
|
634
|
+
// best effort fallback
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const savedTitle = state?.archivedTitles?.[sessionId];
|
|
638
|
+
const finalTitle = savedTitle || ((header?.title && header.title !== sessionId)
|
|
639
|
+
? header.title
|
|
640
|
+
: (derivedTitle || (header?.title ? header.title : '未命名会话')));
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
sessionId,
|
|
644
|
+
title: finalTitle,
|
|
645
|
+
cwd: header?.cwd,
|
|
646
|
+
workspacePath: ws?.workspacePath || ws?.cwd,
|
|
647
|
+
workspaceTitle: ws?.workspaceTitle,
|
|
648
|
+
archivedAt,
|
|
649
|
+
fileSize,
|
|
650
|
+
turnCount,
|
|
651
|
+
};
|
|
652
|
+
})
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
// 按工作区顺序排列(与左侧 sidebar 一致),每个工作区内按时间降序排列
|
|
656
|
+
const workspaceOrder = [];
|
|
657
|
+
const workspaceGrouped = new Map();
|
|
658
|
+
if (entities) {
|
|
659
|
+
for (const entity of entities.values()) {
|
|
660
|
+
const path = entity.record.path;
|
|
661
|
+
workspaceOrder.push(path);
|
|
662
|
+
workspaceGrouped.set(path, { path, title: entity.record.title, items: [] });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
for (const item of items) {
|
|
666
|
+
const key = item.workspacePath || item.cwd || '其他工作区';
|
|
667
|
+
if (!workspaceGrouped.has(key)) {
|
|
668
|
+
workspaceGrouped.set(key, { path: key, title: item.workspaceTitle || '其他项目', items: [] });
|
|
669
|
+
workspaceOrder.push(key);
|
|
670
|
+
}
|
|
671
|
+
workspaceGrouped.get(key).items.push(item);
|
|
672
|
+
}
|
|
673
|
+
const ordered = [];
|
|
674
|
+
for (const path of workspaceOrder) {
|
|
675
|
+
const group = workspaceGrouped.get(path);
|
|
676
|
+
if (!group || group.items.length === 0) continue;
|
|
677
|
+
group.items.sort((a, b) => (b.archivedAt || 0) - (a.archivedAt || 0));
|
|
678
|
+
ordered.push(...group.items);
|
|
679
|
+
}
|
|
680
|
+
return ordered;
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
// Tag every method installed above with this install's token, then hand
|
|
684
|
+
// back a disposer that removes exactly these methods on fiber unload —
|
|
685
|
+
// but only those a newer instance has not since replaced (token check).
|
|
686
|
+
const INSTALLED = [
|
|
687
|
+
'archiveSession',
|
|
688
|
+
'unarchiveSession',
|
|
689
|
+
'permanentlyDeleteSession',
|
|
690
|
+
'emptyArchivedSessions',
|
|
691
|
+
'listArchivedSessions',
|
|
692
|
+
];
|
|
693
|
+
for (const name of INSTALLED) {
|
|
694
|
+
if (typeof registry[name] === 'function') {
|
|
695
|
+
Object.defineProperty(registry[name], TRASH_PATCH, { value: token });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return () => {
|
|
699
|
+
for (const name of INSTALLED) {
|
|
700
|
+
if (registry[name]?.[TRASH_PATCH] === token) delete registry[name];
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/* ------------------------------------------------------------------ */
|
|
706
|
+
/* HTTP routes (the browser transport) */
|
|
707
|
+
/* ------------------------------------------------------------------ */
|
|
708
|
+
/*
|
|
709
|
+
* The DSH host `/api` gateway only dispatches to its BUILD-TIME unary
|
|
710
|
+
* route table (`UNARY_ROUTES`), so a plugin's runtime-registered typert
|
|
711
|
+
* endpoints are never reachable from the browser (`HTTP 404`). The
|
|
712
|
+
* supported extension path is registering routes directly on the
|
|
713
|
+
* `webServer` service — exactly what published plugins (dsh-session-delete)
|
|
714
|
+
* do. Routes live under /api/session-trash/* and non-GET requests must
|
|
715
|
+
* carry the `x-dsh-plugin: session-trash` custom header as a minimal CSRF
|
|
716
|
+
* defense (a custom header triggers a CORS preflight, which this server
|
|
717
|
+
* never answers).
|
|
718
|
+
*/
|
|
719
|
+
|
|
720
|
+
const ROUTE_PREFIX = '/api/session-trash';
|
|
721
|
+
const PLUGIN_HEADER = 'x-dsh-plugin';
|
|
722
|
+
const PLUGIN_HEADER_VALUE = 'session-trash';
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Absolute path of the browser stylesheet shipped at the bundle root.
|
|
726
|
+
* Resolved relative to this module (…/packages/session-trash-host/lib/),
|
|
727
|
+
* which works for both npm-installed bundles and `plugin add link:`
|
|
728
|
+
* development checkouts.
|
|
729
|
+
*/
|
|
730
|
+
const CLIENT_CSS_PATH = fileURLToPath(new URL('../../../client.css', import.meta.url));
|
|
731
|
+
|
|
732
|
+
function httpError(statusCode, code, message) {
|
|
733
|
+
return Object.assign(new Error(message), { statusCode, code });
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function sendJson(res, status, body) {
|
|
737
|
+
res.writeHead(status, {
|
|
738
|
+
'content-type': 'application/json; charset=utf-8',
|
|
739
|
+
'cache-control': 'no-store',
|
|
740
|
+
});
|
|
741
|
+
res.end(JSON.stringify(body));
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function readBody(req) {
|
|
745
|
+
const chunks = [];
|
|
746
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
747
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
748
|
+
if (!text.trim()) return {};
|
|
749
|
+
let data;
|
|
750
|
+
try {
|
|
751
|
+
data = JSON.parse(text);
|
|
752
|
+
} catch {
|
|
753
|
+
throw httpError(400, 'INVALID_BODY', 'request body is not valid JSON');
|
|
754
|
+
}
|
|
755
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
756
|
+
throw httpError(400, 'INVALID_BODY', 'request body must be a JSON object');
|
|
757
|
+
}
|
|
758
|
+
return data;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function ok(res, data) {
|
|
762
|
+
sendJson(res, 200, { ok: true, data });
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Wrap one route handler: CSRF header check for non-GET, then dispatch;
|
|
767
|
+
* every thrown error becomes a JSON error envelope.
|
|
768
|
+
*/
|
|
769
|
+
function guard(handler) {
|
|
770
|
+
return async (req, res) => {
|
|
771
|
+
try {
|
|
772
|
+
if (req.method !== 'GET' && req.headers[PLUGIN_HEADER] !== PLUGIN_HEADER_VALUE) {
|
|
773
|
+
throw httpError(403, 'FORBIDDEN', `missing ${PLUGIN_HEADER} header`);
|
|
774
|
+
}
|
|
775
|
+
await handler(req, res);
|
|
776
|
+
} catch (error) {
|
|
777
|
+
sendJson(res, error?.statusCode ?? 500, {
|
|
778
|
+
ok: false,
|
|
779
|
+
error: { code: error?.code ?? 'INTERNAL', message: String(error?.message ?? error) },
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Register the /api/session-trash/* routes on the webServer service.
|
|
787
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
788
|
+
* @returns a disposer that unregisters every route, or null when the host
|
|
789
|
+
* composition has no webServer (e.g. the standalone test harness).
|
|
790
|
+
*/
|
|
791
|
+
function registerRoutes(ctx) {
|
|
792
|
+
const webServer = ctx.webServer;
|
|
793
|
+
if (!webServer || typeof webServer.register !== 'function') return null;
|
|
794
|
+
|
|
795
|
+
const registry = ctx.workspaceRegistry;
|
|
796
|
+
// Snapshot the injected services while the owning fiber is ACTIVE. The
|
|
797
|
+
// route handlers below only live as long as the fiber (their disposers are
|
|
798
|
+
// wired through ctx.effect), so resolving them per-request via ctx would
|
|
799
|
+
// re-trap the dead-fiber guard on every call; the mount-time references are
|
|
800
|
+
// the stable contract.
|
|
801
|
+
const persistence = ctx.sessionPersistence;
|
|
802
|
+
const disposers = [];
|
|
803
|
+
|
|
804
|
+
/** Broadcast the current archive set after any mutation. */
|
|
805
|
+
function broadcastArchived() {
|
|
806
|
+
const archivedSessionIds = registry?.requireState?.()?.archivedSessionIds ?? [];
|
|
807
|
+
notifyArchivedSessionsChanged(ctx, archivedSessionIds);
|
|
808
|
+
return archivedSessionIds;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** All sessions: persistence headers ∪ workspace membership, with archive flag. */
|
|
812
|
+
async function allSessions() {
|
|
813
|
+
const headers = ((await persistence?.list?.()) ?? []).map(normalizeStoredHeader).filter(Boolean);
|
|
814
|
+
const archived = registry?.requireState?.()?.archivedSessionIds ?? [];
|
|
815
|
+
const byId = new Map(headers.map((h) => [h.id, h]));
|
|
816
|
+
const wsBySession = new Map();
|
|
817
|
+
const entities = registry?.entities;
|
|
818
|
+
if (entities) {
|
|
819
|
+
for (const entity of entities.values()) {
|
|
820
|
+
for (const sid of entity.record?.sessionIds ?? []) {
|
|
821
|
+
if (!wsBySession.has(sid)) {
|
|
822
|
+
wsBySession.set(sid, {
|
|
823
|
+
workspacePath: entity.record.path,
|
|
824
|
+
workspaceTitle: entity.record.title,
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
const ids = new Set([...byId.keys(), ...wsBySession.keys()]);
|
|
831
|
+
return [...ids]
|
|
832
|
+
.map((sessionId) => {
|
|
833
|
+
const header = byId.get(sessionId);
|
|
834
|
+
const ws = wsBySession.get(sessionId);
|
|
835
|
+
return {
|
|
836
|
+
sessionId,
|
|
837
|
+
title: header?.title ?? ws?.workspaceTitle ?? String(sessionId),
|
|
838
|
+
cwd: header?.cwd,
|
|
839
|
+
workspacePath: ws?.workspacePath,
|
|
840
|
+
workspaceTitle: ws?.workspaceTitle,
|
|
841
|
+
archived: archived.includes(sessionId),
|
|
842
|
+
};
|
|
843
|
+
})
|
|
844
|
+
.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? ''));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const routes = [
|
|
848
|
+
// GET list — archived sessions (the recycle bin data source).
|
|
849
|
+
{
|
|
850
|
+
kind: 'exact',
|
|
851
|
+
path: `${ROUTE_PREFIX}/list`,
|
|
852
|
+
handler: guard(async (req, res) => {
|
|
853
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'GET only' } });
|
|
854
|
+
const items = (await registry?.listArchivedSessions?.()) ?? [];
|
|
855
|
+
ok(res, { items });
|
|
856
|
+
}),
|
|
857
|
+
},
|
|
858
|
+
// GET sessions — every session with its workspace grouping + archive flag.
|
|
859
|
+
{
|
|
860
|
+
kind: 'exact',
|
|
861
|
+
path: `${ROUTE_PREFIX}/sessions`,
|
|
862
|
+
handler: guard(async (req, res) => {
|
|
863
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'GET only' } });
|
|
864
|
+
ok(res, { items: await allSessions() });
|
|
865
|
+
}),
|
|
866
|
+
},
|
|
867
|
+
// POST archive { sessionId, title } — move one session into the recycle bin.
|
|
868
|
+
{
|
|
869
|
+
kind: 'exact',
|
|
870
|
+
path: `${ROUTE_PREFIX}/archive`,
|
|
871
|
+
handler: guard(async (req, res) => {
|
|
872
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
873
|
+
const { sessionId, title } = await readBody(req);
|
|
874
|
+
if (!sessionId) throw httpError(400, 'BAD_REQUEST', 'sessionId is required');
|
|
875
|
+
const agents = ctx.get('agents');
|
|
876
|
+
const agent = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined;
|
|
877
|
+
if (agent !== undefined && agent.status !== 'idle') {
|
|
878
|
+
throw httpError(400, 'SESSION_RUNNING', '会话正在运行中,无法删除');
|
|
879
|
+
}
|
|
880
|
+
await registry?.archiveSession?.(sessionId, title);
|
|
881
|
+
ok(res, { sessionId, archivedSessionIds: broadcastArchived() });
|
|
882
|
+
}),
|
|
883
|
+
},
|
|
884
|
+
// POST unarchive { sessionId } — restore one session from the recycle bin.
|
|
885
|
+
{
|
|
886
|
+
kind: 'exact',
|
|
887
|
+
path: `${ROUTE_PREFIX}/unarchive`,
|
|
888
|
+
handler: guard(async (req, res) => {
|
|
889
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
890
|
+
const { sessionId } = await readBody(req);
|
|
891
|
+
if (!sessionId) throw httpError(400, 'BAD_REQUEST', 'sessionId is required');
|
|
892
|
+
await registry?.unarchiveSession?.(sessionId);
|
|
893
|
+
ok(res, { sessionId, archivedSessionIds: broadcastArchived() });
|
|
894
|
+
}),
|
|
895
|
+
},
|
|
896
|
+
// POST purge { sessionIds: [] } — permanently delete the given sessions.
|
|
897
|
+
{
|
|
898
|
+
kind: 'exact',
|
|
899
|
+
path: `${ROUTE_PREFIX}/purge`,
|
|
900
|
+
handler: guard(async (req, res) => {
|
|
901
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
902
|
+
const { sessionIds } = await readBody(req);
|
|
903
|
+
const ids = Array.isArray(sessionIds) ? sessionIds.filter((id) => typeof id === 'string' && id) : [];
|
|
904
|
+
if (ids.length === 0) throw httpError(400, 'BAD_REQUEST', 'sessionIds[] is required');
|
|
905
|
+
const deleted = [];
|
|
906
|
+
const errors = [];
|
|
907
|
+
for (const sessionId of ids) {
|
|
908
|
+
try {
|
|
909
|
+
await registry?.permanentlyDeleteSession?.(sessionId);
|
|
910
|
+
deleted.push(sessionId);
|
|
911
|
+
} catch (error) {
|
|
912
|
+
errors.push({ sessionId, code: error?.code ?? 'ERROR', message: error?.message ?? String(error) });
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (deleted.length > 0) broadcastArchived();
|
|
916
|
+
ok(res, { deletedCount: deleted.length, total: ids.length, errors });
|
|
917
|
+
}),
|
|
918
|
+
},
|
|
919
|
+
// GET messages — read and parse messages for session preview modal.
|
|
920
|
+
{
|
|
921
|
+
kind: 'exact',
|
|
922
|
+
path: `${ROUTE_PREFIX}/messages`,
|
|
923
|
+
handler: guard(async (req, res) => {
|
|
924
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'GET only' } });
|
|
925
|
+
if (!req.url) throw httpError(400, 'BAD_REQUEST', 'missing request URL');
|
|
926
|
+
const url = new URL(req.url, 'http://localhost');
|
|
927
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
928
|
+
if (!sessionId) throw httpError(400, 'BAD_REQUEST', 'sessionId is required');
|
|
929
|
+
|
|
930
|
+
const logPath = await resolveSessionLogPath(persistence, sessionId);
|
|
931
|
+
const messages = [];
|
|
932
|
+
|
|
933
|
+
/** 提取用户消息中的真实对话内容,过滤掉系统上下文、skills 等注入内容 */
|
|
934
|
+
function extractUserText(raw) {
|
|
935
|
+
if (!raw) return '';
|
|
936
|
+
// content 是 ContentBlock[],每个 block 有 type 和 text 字段
|
|
937
|
+
if (Array.isArray(raw)) {
|
|
938
|
+
return raw
|
|
939
|
+
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
|
940
|
+
.map((part) => part.text)
|
|
941
|
+
.filter(Boolean)
|
|
942
|
+
.join('\n')
|
|
943
|
+
.trim();
|
|
944
|
+
}
|
|
945
|
+
if (typeof raw !== 'string') return '';
|
|
946
|
+
let str = raw;
|
|
947
|
+
// 优先提取 <USER_REQUEST> 内的内容(用户真实输入)
|
|
948
|
+
const userReqMatch = str.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/i);
|
|
949
|
+
if (userReqMatch) return userReqMatch[1].trim();
|
|
950
|
+
// 去掉已知的 XML 标签
|
|
951
|
+
str = str.replace(/<ADDITIONAL_METADATA>[\s\S]*?<\/ADDITIONAL_METADATA>/gi, '');
|
|
952
|
+
str = str.replace(/<USER_SETTINGS_CHANGE>[\s\S]*?<\/USER_SETTINGS_CHANGE>/gi, '');
|
|
953
|
+
str = str.replace(/<SKILL>[\s\S]*?<\/SKILL>/gi, '');
|
|
954
|
+
str = str.replace(/<SYSTEM_CONTEXT>[\s\S]*?<\/SYSTEM_CONTEXT>/gi, '');
|
|
955
|
+
str = str.replace(/<WORKSPACE>[\s\S]*?<\/WORKSPACE>/gi, '');
|
|
956
|
+
// 去掉所有剩余的 HTML/XML 标签
|
|
957
|
+
str = str.replace(/<[^>]+>/g, '');
|
|
958
|
+
return str.trim();
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** 提取助手消息中的真实回复内容,过滤掉 reasoning 思考块、tool-call 等 */
|
|
962
|
+
function extractAssistantText(content) {
|
|
963
|
+
if (!content) return '';
|
|
964
|
+
// content 是 ContentBlock[],过滤出 type 为 'text' 的块(排除 reasoning/tool-call/tool-result)
|
|
965
|
+
if (Array.isArray(content)) {
|
|
966
|
+
return content
|
|
967
|
+
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
|
968
|
+
.map((part) => part.text)
|
|
969
|
+
.filter(Boolean)
|
|
970
|
+
.join('\n')
|
|
971
|
+
.trim();
|
|
972
|
+
}
|
|
973
|
+
// 内容为字符串:去掉 thinking 块(兼容旧格式)
|
|
974
|
+
if (typeof content === 'string') {
|
|
975
|
+
let str = content;
|
|
976
|
+
str = str.replace(/```thinking[\s\S]*?```/g, '');
|
|
977
|
+
str = str.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
|
|
978
|
+
return str.trim();
|
|
979
|
+
}
|
|
980
|
+
return '';
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function parseRole(obj) {
|
|
984
|
+
if (!obj) return null;
|
|
985
|
+
const raw = String(obj.role || obj.type || obj.kind || obj.event || '').toUpperCase();
|
|
986
|
+
if (raw.includes('USER') || raw.includes('HUMAN') || raw.includes('INPUT')) return 'user';
|
|
987
|
+
if (raw.includes('ASSISTANT') || raw.includes('PLANNER') || raw.includes('RESPONSE') || raw.includes('MODEL') || raw.includes('AGENT') || raw.includes('OUTPUT')) return 'assistant';
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function cleanText(raw) {
|
|
992
|
+
if (typeof raw !== 'string') return '';
|
|
993
|
+
let str = raw;
|
|
994
|
+
str = str.replace(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/gi, '$1');
|
|
995
|
+
str = str.replace(/<ADDITIONAL_METADATA>[\s\S]*?<\/ADDITIONAL_METADATA>/gi, '');
|
|
996
|
+
str = str.replace(/<USER_SETTINGS_CHANGE>[\s\S]*?<\/USER_SETTINGS_CHANGE>/gi, '');
|
|
997
|
+
return str.trim();
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function extractAnyText(val) {
|
|
1001
|
+
if (!val) return '';
|
|
1002
|
+
if (typeof val === 'string') return cleanText(val);
|
|
1003
|
+
if (Array.isArray(val)) {
|
|
1004
|
+
return val.map(extractAnyText).filter(Boolean).join('\n');
|
|
1005
|
+
}
|
|
1006
|
+
if (typeof val === 'object') {
|
|
1007
|
+
if (typeof val.text === 'string') return cleanText(val.text);
|
|
1008
|
+
if (val.content) return extractAnyText(val.content);
|
|
1009
|
+
if (typeof val.value === 'string') return cleanText(val.value);
|
|
1010
|
+
if (typeof val.prompt === 'string') return cleanText(val.prompt);
|
|
1011
|
+
if (typeof val.message === 'string') return cleanText(val.message);
|
|
1012
|
+
if (val.payload) return extractAnyText(val.payload);
|
|
1013
|
+
if (val.data) return extractAnyText(val.data);
|
|
1014
|
+
}
|
|
1015
|
+
return '';
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function parseContent(obj) {
|
|
1019
|
+
if (!obj) return '';
|
|
1020
|
+
return extractAnyText(obj.content ?? obj.text ?? obj.prompt ?? obj.message ?? obj.query ?? obj.payload ?? obj);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// 优先读取已解压的会话事件(DSH 默认启用 Zstd 压缩,直接 readFile 读到的是
|
|
1024
|
+
// 二进制数据)。0.1.5 起 inspect 移除,改用 open(id, 'read') → handle.read()
|
|
1025
|
+
// (见 readSessionEvents)。
|
|
1026
|
+
try {
|
|
1027
|
+
const events = await readSessionEvents(persistence, sessionId);
|
|
1028
|
+
if (Array.isArray(events)) {
|
|
1029
|
+
for (const event of events) {
|
|
1030
|
+
if (event.type === 'user/message') {
|
|
1031
|
+
// 只保留 source.kind === 'user' 的真实用户输入,
|
|
1032
|
+
// 排除 plugin 注入的上下文(skills、系统上下文、工具结果等)
|
|
1033
|
+
const sourceKind = event.data?.source?.kind;
|
|
1034
|
+
if (sourceKind && sourceKind !== 'user') continue;
|
|
1035
|
+
// 用户消息:提取 ContentBlock[] 中的 text 块,过滤掉系统上下文
|
|
1036
|
+
const content = extractUserText(event.data?.content);
|
|
1037
|
+
if (content) {
|
|
1038
|
+
messages.push({
|
|
1039
|
+
role: 'user',
|
|
1040
|
+
content,
|
|
1041
|
+
timestamp: event.createdAt || Date.now(),
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
} else if (event.type === 'assistant/message') {
|
|
1045
|
+
// 助手消息:只取 text 块,过滤掉 reasoning 思考块、tool-call 等
|
|
1046
|
+
const content = extractAssistantText(event.data?.message?.content);
|
|
1047
|
+
if (content) {
|
|
1048
|
+
messages.push({
|
|
1049
|
+
role: 'assistant',
|
|
1050
|
+
content,
|
|
1051
|
+
timestamp: event.createdAt || Date.now(),
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
} catch {
|
|
1058
|
+
// inspect 失败后回退到直接读取日志文件(兼容未压缩的日志)
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// 回退方案:直接读取日志文件(仅当事件读取未返回任何消息时)
|
|
1062
|
+
if (messages.length === 0) {
|
|
1063
|
+
// logPath 已由 resolveSessionLogPath 解析(findLog → locate → 目录扫描);
|
|
1064
|
+
// 此处只保留最后的广谱候选路径扫描(兼容活跃日志、回收站归档日志及 profiles 目录)。
|
|
1065
|
+
let resolvedLogPath = logPath;
|
|
1066
|
+
|
|
1067
|
+
if (!resolvedLogPath) {
|
|
1068
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
1069
|
+
const candidates = [
|
|
1070
|
+
join(home, '.dsh', 'profiles', 'web', 'trash', `${sessionId}.jsonl`),
|
|
1071
|
+
join(home, '.dsh', 'profiles', 'default', 'trash', `${sessionId}.jsonl`),
|
|
1072
|
+
join(home, '.dsh', 'trash', `${sessionId}.jsonl`),
|
|
1073
|
+
join(home, '.dsh', 'logs', `${sessionId}.jsonl`),
|
|
1074
|
+
join(process.cwd(), 'trash', `${sessionId}.jsonl`),
|
|
1075
|
+
];
|
|
1076
|
+
for (const p of candidates) {
|
|
1077
|
+
if (!p) continue;
|
|
1078
|
+
const exists = await stat(p).then((s) => s.isFile()).catch(() => false);
|
|
1079
|
+
if (exists) {
|
|
1080
|
+
resolvedLogPath = p;
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
if (resolvedLogPath) {
|
|
1087
|
+
try {
|
|
1088
|
+
const content = await readFile(resolvedLogPath, 'utf8');
|
|
1089
|
+
const lines = content.split('\n');
|
|
1090
|
+
for (const line of lines) {
|
|
1091
|
+
if (!line.trim()) continue;
|
|
1092
|
+
try {
|
|
1093
|
+
const obj = JSON.parse(line);
|
|
1094
|
+
const typeStr = String(obj.type || '').toUpperCase();
|
|
1095
|
+
if (typeStr.includes('SETTINGS') || typeStr.includes('NOTIFICATION')) continue;
|
|
1096
|
+
|
|
1097
|
+
const role = parseRole(obj);
|
|
1098
|
+
const text = parseContent(obj);
|
|
1099
|
+
if (role && text) {
|
|
1100
|
+
messages.push({
|
|
1101
|
+
role,
|
|
1102
|
+
content: text,
|
|
1103
|
+
timestamp: obj.timestamp || obj.time || obj.created_at || Date.now(),
|
|
1104
|
+
});
|
|
1105
|
+
} else if (Array.isArray(obj.messages)) {
|
|
1106
|
+
for (const m of obj.messages) {
|
|
1107
|
+
const r = parseRole(m);
|
|
1108
|
+
const t = parseContent(m);
|
|
1109
|
+
if (r && t) {
|
|
1110
|
+
messages.push({
|
|
1111
|
+
role: r,
|
|
1112
|
+
content: t,
|
|
1113
|
+
timestamp: m.timestamp || m.time || Date.now(),
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
} catch {}
|
|
1119
|
+
}
|
|
1120
|
+
// Fallback: 如果广谱识别未匹配到 user/assistant
|
|
1121
|
+
if (messages.length === 0) {
|
|
1122
|
+
for (const line of lines) {
|
|
1123
|
+
if (!line.trim()) continue;
|
|
1124
|
+
try {
|
|
1125
|
+
const obj = JSON.parse(line);
|
|
1126
|
+
const typeStr = String(obj.type || '').toUpperCase();
|
|
1127
|
+
if (typeStr.includes('SETTINGS') || typeStr.includes('NOTIFICATION')) continue;
|
|
1128
|
+
const text = parseContent(obj);
|
|
1129
|
+
if (text) {
|
|
1130
|
+
messages.push({
|
|
1131
|
+
role: String(obj.type || obj.role || 'system').toUpperCase().includes('USER') ? 'user' : 'assistant',
|
|
1132
|
+
content: text,
|
|
1133
|
+
timestamp: obj.timestamp || obj.time || Date.now(),
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
} catch {}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
} catch {}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
ok(res, { sessionId, messages });
|
|
1144
|
+
}),
|
|
1145
|
+
},
|
|
1146
|
+
// POST purge-workspace { workspacePath, sessionIds? } — permanently delete all archived sessions in a workspace.
|
|
1147
|
+
{
|
|
1148
|
+
kind: 'exact',
|
|
1149
|
+
path: `${ROUTE_PREFIX}/purge-workspace`,
|
|
1150
|
+
handler: guard(async (req, res) => {
|
|
1151
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
1152
|
+
const { workspacePath, sessionIds } = await readBody(req);
|
|
1153
|
+
let targetIds = Array.isArray(sessionIds) ? sessionIds : [];
|
|
1154
|
+
|
|
1155
|
+
if (targetIds.length === 0 && workspacePath) {
|
|
1156
|
+
const archived = (await registry?.listArchivedSessions?.()) ?? [];
|
|
1157
|
+
targetIds = archived.filter((item) => item.workspacePath === workspacePath || item.cwd === workspacePath).map((item) => item.sessionId);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (targetIds.length === 0) {
|
|
1161
|
+
return ok(res, { deletedCount: 0, total: 0, errors: [] });
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const deleted = [];
|
|
1165
|
+
const errors = [];
|
|
1166
|
+
for (const sessionId of targetIds) {
|
|
1167
|
+
try {
|
|
1168
|
+
await registry?.permanentlyDeleteSession?.(sessionId);
|
|
1169
|
+
deleted.push(sessionId);
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
errors.push({ sessionId, code: error?.code ?? 'ERROR', message: error?.message ?? String(error) });
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
if (deleted.length > 0) broadcastArchived();
|
|
1176
|
+
ok(res, { deletedCount: deleted.length, total: targetIds.length, errors });
|
|
1177
|
+
}),
|
|
1178
|
+
},
|
|
1179
|
+
// POST empty — permanently delete every archived session.
|
|
1180
|
+
{
|
|
1181
|
+
kind: 'exact',
|
|
1182
|
+
path: `${ROUTE_PREFIX}/empty`,
|
|
1183
|
+
handler: guard(async (req, res) => {
|
|
1184
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
1185
|
+
const result = (await registry?.emptyArchivedSessions?.()) ?? { deletedCount: 0, total: 0 };
|
|
1186
|
+
broadcastArchived();
|
|
1187
|
+
ok(res, result);
|
|
1188
|
+
}),
|
|
1189
|
+
},
|
|
1190
|
+
// GET client.css — the plugin stylesheet (light/dark adaptive design
|
|
1191
|
+
// tokens). client.js injects it as a <link rel="stylesheet">. Read
|
|
1192
|
+
// lazily per request so stylesheet edits show up after a browser
|
|
1193
|
+
// refresh without restarting the profile process.
|
|
1194
|
+
{
|
|
1195
|
+
kind: 'exact',
|
|
1196
|
+
path: `${ROUTE_PREFIX}/client.css`,
|
|
1197
|
+
handler: guard(async (req, res) => {
|
|
1198
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'GET only' } });
|
|
1199
|
+
let css;
|
|
1200
|
+
try {
|
|
1201
|
+
css = await readFile(CLIENT_CSS_PATH, 'utf8');
|
|
1202
|
+
} catch {
|
|
1203
|
+
throw httpError(404, 'CSS_NOT_FOUND', `client.css is missing at ${CLIENT_CSS_PATH}`);
|
|
1204
|
+
}
|
|
1205
|
+
res.writeHead(200, {
|
|
1206
|
+
'content-type': 'text/css; charset=utf-8',
|
|
1207
|
+
'cache-control': 'no-cache',
|
|
1208
|
+
});
|
|
1209
|
+
res.end(css);
|
|
1210
|
+
}),
|
|
1211
|
+
},
|
|
1212
|
+
];
|
|
1213
|
+
|
|
1214
|
+
for (const route of routes) {
|
|
1215
|
+
try {
|
|
1216
|
+
disposers.push(webServer.register(route));
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
// The webServer rejects duplicate paths. The only way one of OUR paths
|
|
1219
|
+
// can already be taken is a stale bundle that leaked its routes on
|
|
1220
|
+
// fiber unload (<= v0.3.0 stored the disposer where cordis never calls
|
|
1221
|
+
// it). That leaked state only clears on a profile-process restart, so
|
|
1222
|
+
// fail with an actionable message instead of a bare duplicate error.
|
|
1223
|
+
throw new Error(
|
|
1224
|
+
`session-trash-host: cannot register ${route.path}: ${error?.message ?? error}`
|
|
1225
|
+
+ ' — a route leaked by a previous instance still occupies this path;'
|
|
1226
|
+
+ ' restart dsh web to clean up',
|
|
1227
|
+
{ cause: error },
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return () => {
|
|
1232
|
+
for (const dispose of disposers) {
|
|
1233
|
+
try {
|
|
1234
|
+
dispose();
|
|
1235
|
+
} catch {
|
|
1236
|
+
// best effort
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/* ------------------------------------------------------------------ */
|
|
1243
|
+
/* SessionTrashHost — Cordis plugin entry point */
|
|
1244
|
+
/* ------------------------------------------------------------------ */
|
|
1245
|
+
|
|
1246
|
+
export class SessionTrashHost extends ServiceClass {
|
|
1247
|
+
static inject = [
|
|
1248
|
+
'sessionPersistence',
|
|
1249
|
+
'sessionProjectionCache',
|
|
1250
|
+
'workspaceRegistry',
|
|
1251
|
+
'webServer',
|
|
1252
|
+
];
|
|
1253
|
+
|
|
1254
|
+
#routesDisposer;
|
|
1255
|
+
|
|
1256
|
+
constructor(ctx) {
|
|
1257
|
+
super(ctx, 'sessionTrashHost');
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
async [ServiceClass.init || Symbol.for('cordis.init')]() {
|
|
1261
|
+
const ctx = this.ctx;
|
|
1262
|
+
|
|
1263
|
+
// v0.3.2: every service patch is now REVERSIBLE. Each installer returns a
|
|
1264
|
+
// disposer bound to this fiber, and a stale patch left behind by an
|
|
1265
|
+
// earlier fiber is detected via its TRASH_PATCH tag and replaced with one
|
|
1266
|
+
// closing over the live context. Before this, uninstalling + reinstalling
|
|
1267
|
+
// without a restart left the registry methods of the DEAD fiber in place
|
|
1268
|
+
// (the old 'typeof x !== function' guards refused to re-patch), so the
|
|
1269
|
+
// recycle-bin list kept calling listArchivedSessions() whose closure held
|
|
1270
|
+
// an inactive ctx — ctx.sessionPersistence threw inside the per-session
|
|
1271
|
+
// try/catch and every turn count silently read 0 — while the preview
|
|
1272
|
+
// (routes rebuilt per mount, snapshotting services) worked fine.
|
|
1273
|
+
const undoPatches = [];
|
|
1274
|
+
|
|
1275
|
+
const persistence = ctx.sessionPersistence;
|
|
1276
|
+
if (persistence) undoPatches.push(patchSessionPersistenceDelete(persistence));
|
|
1277
|
+
|
|
1278
|
+
const cache = ctx.get('sessionProjectionCache');
|
|
1279
|
+
if (cache) undoPatches.push(patchProjectionCacheRemove(cache));
|
|
1280
|
+
|
|
1281
|
+
const registry = ctx.workspaceRegistry;
|
|
1282
|
+
if (registry) undoPatches.push(patchWorkspaceRegistry(registry, ctx));
|
|
1283
|
+
|
|
1284
|
+
const disposeRoutes = registerRoutes(ctx);
|
|
1285
|
+
const disposeAll = () => {
|
|
1286
|
+
try { disposeRoutes?.(); } catch { /* best effort */ }
|
|
1287
|
+
for (const undo of undoPatches) {
|
|
1288
|
+
try { undo?.(); } catch { /* best effort */ }
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
// v0.3.1 fix, extended: bind BOTH the routes and the service patches to
|
|
1293
|
+
// THIS fiber's lifecycle — cordis's "register() disposer contract".
|
|
1294
|
+
// @deepseek-ai/cordis@4 never calls a class instance's dispose(); only
|
|
1295
|
+
// effects collected via ctx.effect unwind on fiber unload. Without this
|
|
1296
|
+
// any in-process reload (reinstalling the plugin without restarting dsh
|
|
1297
|
+
// web, a loader config update, a dependency service restart) leaked the
|
|
1298
|
+
// old routes bound to the now-inactive context — ctx.sessionPersistence
|
|
1299
|
+
// then threw "cannot get required service ... in inactive context" and
|
|
1300
|
+
// the reloaded instance died on webServer.register's duplicate-path
|
|
1301
|
+
// throw. disposeAll is idempotent (token checks), so the defensive
|
|
1302
|
+
// dispose() below can share it.
|
|
1303
|
+
this.#routesDisposer = disposeAll;
|
|
1304
|
+
|
|
1305
|
+
if (typeof ctx.effect === 'function') {
|
|
1306
|
+
ctx.effect(() => disposeAll, 'session-trash-host: /api/session-trash/* routes + service patches');
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
dispose() {
|
|
1311
|
+
this.#routesDisposer?.();
|
|
1312
|
+
this.#routesDisposer = undefined;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
export default SessionTrashHost;
|