@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.
@@ -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;