@sunshaobei/dsh-session-manager 0.4.3-fork.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@
15
15
  * This plugin patches the existing services at init time. It is designed
16
16
  * to be loaded via cordis.patch.yml and works with the compiled DSH packages.
17
17
  */
18
- import { rm, readdir, rmdir, stat, readFile } from 'node:fs/promises';
18
+ import { rm, readdir, rmdir, stat, readFile, open as openFile } from 'node:fs/promises';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
 
@@ -68,6 +68,104 @@ function normalizeStoredHeader(item) {
68
68
  return undefined;
69
69
  }
70
70
 
71
+ /**
72
+ * Normalize one persistence.list() item to `{ header, sizeBytes }` across
73
+ * cohorts. `sizeBytes` is a free field of the 0.1.5+ listing snapshot;
74
+ * normalizeStoredHeader unwrapped it away, which is why the recycle bin used
75
+ * to `stat()` every log just to recover a number the header listing already
76
+ * carried.
77
+ * @param {object|undefined} item
78
+ * @returns {{header: object, sizeBytes: number|undefined}|undefined}
79
+ */
80
+ function normalizeStoredSnapshot(item) {
81
+ if (!item) return undefined;
82
+ if (item.header && typeof item.header.id === 'string') {
83
+ const size = item.sizeBytes;
84
+ return { header: item.header, sizeBytes: typeof size === 'number' ? size : undefined };
85
+ }
86
+ if (typeof item.id === 'string') return { header: item, sizeBytes: undefined };
87
+ return undefined;
88
+ }
89
+
90
+ /**
91
+ * Read the listing facts the recycle bin needs straight out of the Session
92
+ * projection cache — DSH's documented ZERO-I/O listing read
93
+ * (`SessionProjectionCache.cachedSnapshot`, "the zero-I/O listing read").
94
+ * The stock session list (`api-session-controller.projectionsFor`) reads the
95
+ * same faces; this is not a private API.
96
+ *
97
+ * WHY THIS REPLACES THE LOG READ (v0.4.4):
98
+ *
99
+ * Until v0.4.3 the list derived `turnCount` and a fallback title by opening
100
+ * every archived session's log and decoding its whole JSONL/Zstd frame
101
+ * chain (`readSessionEvents`). Measured on the reference store (569 MB, 474
102
+ * sessions, p50 416 KB compressed, max 30 MB): a 6.7 MB log (3728 frames,
103
+ * 21.4 MB plaintext, 6493 events) cost ~198 ms of decompress+parse, and the
104
+ * median band 10-19 ms — per session, per request, on the web server's own
105
+ * thread. Decoding the first frame alone (the header) measured ~1 ms, which
106
+ * is what the stock listing pays.
107
+ *
108
+ * The header carries NO title — SessionHeader has had no `title` field since
109
+ * 0.2 — so the projection cache is also the only correct title source: the
110
+ * same `title` unit DSH renders in the sidebar.
111
+ *
112
+ * TWO FACES, AND WHY BOTH ARE NEEDED:
113
+ *
114
+ * A checkpoint is bound to the Session FORMAT GENERATION that folded it.
115
+ * `cachedSnapshot` accepts only a record at the host's CURRENT generation
116
+ * (`currentLifecycleMatches`: stored formatVersion === header.version), while
117
+ * `cachedPredecessorTitle` accepts a strictly older one. That is not
118
+ * academic: on the reference store the host moved from format 3 to 4 and the
119
+ * cache still held 481 v3 records plus 39 with no generation, against only 4
120
+ * v4 ones. Reading `cachedSnapshot` alone would have rejected ~99% of the
121
+ * corpus and silently blanked nearly every title. The predecessor face is
122
+ * the one DSH ships for exactly this upgrade window.
123
+ *
124
+ * It exposes the TITLE ONLY, by design — "format normalization can change
125
+ * their current meaning" is the cache's own note about other predecessor
126
+ * projections — so a pre-upgrade `turns` count is deliberately NOT readable.
127
+ * A missing `turns` therefore means UNKNOWN, never zero: callers must not
128
+ * render it as 0.
129
+ *
130
+ * @param {object|undefined} cache ctx.sessionProjectionCache
131
+ * @param {object} header stored SessionHeader (the lifecycle identity witness)
132
+ * @returns {{title: string|undefined, turns: number|undefined}}
133
+ */
134
+ function readCachedSessionFacts(cache, header) {
135
+ const facts = { title: undefined, turns: undefined };
136
+ if (!header || !cache) return facts;
137
+
138
+ /** Fold one viewed block in, never overwriting an already-known fact. */
139
+ const take = (block) => {
140
+ const values = block?.values;
141
+ if (!values || typeof values !== 'object') return;
142
+ if (facts.title === undefined) {
143
+ const title = values.title;
144
+ if (typeof title === 'string' && title.length > 0) facts.title = title;
145
+ }
146
+ if (facts.turns === undefined) {
147
+ const turns = values.sessionStats?.turns;
148
+ if (typeof turns === 'number' && Number.isFinite(turns)) facts.turns = turns;
149
+ }
150
+ };
151
+
152
+ try {
153
+ if (typeof cache.cachedSnapshot === 'function') {
154
+ take(cache.cachedSnapshot(header, ['title', 'sessionStats']));
155
+ }
156
+ } catch {
157
+ // A cache miss or contract drift must never fail the list.
158
+ }
159
+ try {
160
+ if (typeof cache.cachedPredecessorTitle === 'function') {
161
+ take(cache.cachedPredecessorTitle(header));
162
+ }
163
+ } catch {
164
+ // Same: the row renders without the cached facts.
165
+ }
166
+ return facts;
167
+ }
168
+
71
169
  /**
72
170
  * Resolve one session's physical log path across host cohorts.
73
171
  *
@@ -339,6 +437,420 @@ function patchProjectionCacheRemove(cache) {
339
437
  };
340
438
  }
341
439
 
440
+ /* ------------------------------------------------------------------ */
441
+ /* Sub-session lineage: child enumeration and cascade deletion */
442
+ /* ------------------------------------------------------------------ */
443
+ /*
444
+ * A subagent session is a SIBLING directory of the session that spawned it
445
+ * (~/.dsh/sessions/<project>/<childId>/) and its id NEVER enters the workspace
446
+ * registry — record.sessionIds holds root sessions only. The parent link lives
447
+ * in the child's own header (`origin: "subagent"`, `parentSession`,
448
+ * `delegationDepth`) and nowhere else, so every fact below is derived from that
449
+ * header. A store that cannot be enumerated degrades to "no children" instead
450
+ * of failing the request: a missing badge beats a wrong one.
451
+ */
452
+
453
+ /** Log names inside one session directory (legacy plain + 0.1.5 generations). */
454
+ const SESSION_LOG_FILE = /^session(?:\.v\d+)?\.jsonl(?:\.zstd)?$/;
455
+
456
+ /**
457
+ * Is this session's agent working right now? The ONLY liveness witness this
458
+ * bundle trusts: `session.lock` outlives the session that wrote it, so its
459
+ * presence proves nothing, while the agents registry drops an entry once the
460
+ * session unloads. An absent entry is a session that is simply not loaded.
461
+ * @param {import('@deepseek-ai/cordis').Context} ctx
462
+ * @param {string} sessionId
463
+ * @returns {boolean}
464
+ */
465
+ function isSessionLive(ctx, sessionId) {
466
+ const agents = ctx.get('agents');
467
+ const agent = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined;
468
+ return agent !== undefined && agent.status !== 'idle';
469
+ }
470
+
471
+ /** Keep the non-empty string ids a request body may carry. */
472
+ function normalizeSessionIds(value) {
473
+ return Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : [];
474
+ }
475
+
476
+ /**
477
+ * One lineage index entry: the parent link the cascade walks, plus the header
478
+ * the display facts are read from.
479
+ * @param {object|undefined} item a listing record — the header itself, a
480
+ * 0.1.5 snapshot wrapping it, or a sessionQuery record whose `header` holds it.
481
+ * @returns {{id: string, parentSession: string|undefined, delegationDepth: number,
482
+ * header: object, sizeBytes: number|undefined, logPath: string|undefined}|undefined}
483
+ */
484
+ function toLineageEntry(item) {
485
+ const header = item?.header && typeof item.header.id === 'string' ? item.header : item;
486
+ if (!header || typeof header.id !== 'string') return undefined;
487
+ const size = typeof item.sizeBytes === 'number' ? item.sizeBytes
488
+ : (typeof header.sizeBytes === 'number' ? header.sizeBytes : undefined);
489
+ const parentSession = typeof header.parentSession === 'string' && header.parentSession
490
+ ? header.parentSession
491
+ : undefined;
492
+ // A session that names a parent is at least depth 1; 0 would claim it is a
493
+ // root session, and the panel renders this number.
494
+ const delegationDepth = Number.isFinite(header.delegationDepth) ? header.delegationDepth : (parentSession ? 1 : 0);
495
+ return {
496
+ id: header.id,
497
+ parentSession,
498
+ delegationDepth,
499
+ header,
500
+ sizeBytes: size,
501
+ // Only the physical rung knows the path up front; carrying it saves the
502
+ // size fallback a second resolution round.
503
+ logPath: typeof item.logPath === 'string' ? item.logPath : undefined,
504
+ };
505
+ }
506
+
507
+ function indexLineage(items) {
508
+ const entries = new Map();
509
+ for (const item of items) {
510
+ const entry = toLineageEntry(item);
511
+ if (entry) entries.set(entry.id, entry);
512
+ }
513
+ return entries;
514
+ }
515
+
516
+ /** The header inside an inspect()/read() result, across cohort shapes. */
517
+ function firstHeader(result) {
518
+ const candidate = result?.header ?? (Array.isArray(result?.events) ? result.events[0] : undefined);
519
+ return candidate && typeof candidate.id === 'string' ? candidate : undefined;
520
+ }
521
+
522
+ /**
523
+ * Read one session's header record. The decode rides the existing header
524
+ * convention (persistence.inspect → open(id, 'read')) because a .zstd log
525
+ * cannot be read as text; an uncompressed log is read directly, bounded to its
526
+ * first line.
527
+ * @returns {Promise<object|undefined>} the stored header record.
528
+ */
529
+ async function readSessionHeaderRecord(persistence, sessionId, logPath) {
530
+ try {
531
+ if (typeof persistence?.inspect === 'function') {
532
+ const header = firstHeader(await persistence.inspect(sessionId));
533
+ if (header) return header;
534
+ }
535
+ } catch { /* the handle API is the next rung */ }
536
+
537
+ try {
538
+ if (typeof persistence?.open === 'function') {
539
+ const handle = await persistence.open(sessionId, 'read');
540
+ try {
541
+ const header = firstHeader(await handle.read());
542
+ if (header) return header;
543
+ } finally {
544
+ if (typeof handle?.close === 'function') await handle.close().catch(() => {});
545
+ }
546
+ }
547
+ } catch { /* an uncompressed log is still readable below */ }
548
+
549
+ if (logPath.endsWith('.zstd')) return undefined;
550
+ try {
551
+ const handle = await openFile(logPath, 'r');
552
+ try {
553
+ const buffer = Buffer.alloc(64 * 1024);
554
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
555
+ const parsed = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8').split('\n', 1)[0]);
556
+ return parsed && typeof parsed === 'object' ? parsed : undefined;
557
+ } finally {
558
+ await handle.close();
559
+ }
560
+ } catch {
561
+ return undefined;
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Physical fallback rung: enumerate ~/.dsh/sessions/<project>/<sessionId>/ and
567
+ * read each session's first record. A directory whose header stays unreadable
568
+ * still ENTERS the index under its directory name — it is a session with a log
569
+ * on disk, and that is exactly what decides whether a child's parent is a live
570
+ * row or an orphan.
571
+ * @returns {Promise<Array|undefined>} listing items, or undefined when the
572
+ * session store itself cannot be listed.
573
+ */
574
+ async function scanSessionDirectories(persistence) {
575
+ const home = process.env.HOME || process.env.USERPROFILE || '';
576
+ if (!home) return undefined;
577
+ const root = join(home, '.dsh', 'sessions');
578
+ let projects;
579
+ try {
580
+ projects = await readdir(root);
581
+ } catch {
582
+ return undefined;
583
+ }
584
+ const items = [];
585
+ for (const project of projects) {
586
+ const projectDir = join(root, project);
587
+ for (const sessionId of await readdir(projectDir).catch(() => [])) {
588
+ const sessionDir = join(projectDir, sessionId);
589
+ const names = await readdir(sessionDir).catch(() => []);
590
+ const logName = names.find((name) => SESSION_LOG_FILE.test(name));
591
+ if (!logName) continue;
592
+ const logPath = join(sessionDir, logName);
593
+ const read = await readSessionHeaderRecord(persistence, sessionId, logPath);
594
+ const header = read && typeof read.id === 'string' ? read : { id: sessionId };
595
+ const sizeBytes = await stat(logPath).then((s) => s.size).catch(() => undefined);
596
+ items.push({ header, sizeBytes, logPath });
597
+ }
598
+ }
599
+ return items;
600
+ }
601
+
602
+ /**
603
+ * Index every stored session by id, walking the cohort ladder:
604
+ * sessionQuery.listSessions() → persistence.list() → physical scan. Only a rung
605
+ * that THROWS is skipped; `degraded` therefore means every rung threw, and the
606
+ * caller reports empty groups rather than guessing.
607
+ * @returns {Promise<{entries: Map<string, object>, degraded: boolean}>}
608
+ */
609
+ async function collectLineage(ctx, persistence) {
610
+ const query = ctx.get('sessionQuery');
611
+ if (typeof query?.listSessions === 'function') {
612
+ try {
613
+ const records = await query.listSessions();
614
+ if (Array.isArray(records)) return { entries: indexLineage(records), degraded: false };
615
+ } catch { /* the listing below is the next rung */ }
616
+ }
617
+ try {
618
+ const listed = await persistence?.list?.();
619
+ if (Array.isArray(listed)) return { entries: indexLineage(listed), degraded: false };
620
+ } catch { /* the physical scan below is the last rung */ }
621
+ const scanned = await scanSessionDirectories(persistence);
622
+ if (scanned) return { entries: indexLineage(scanned), degraded: false };
623
+ return { entries: new Map(), degraded: true };
624
+ }
625
+
626
+ function descendantId(value) {
627
+ if (typeof value === 'string') return value;
628
+ if (typeof value?.id === 'string') return value.id;
629
+ if (typeof value?.header?.id === 'string') return value.header.id;
630
+ return undefined;
631
+ }
632
+
633
+ /**
634
+ * Every descendant id of one session, at FULL depth, excluding the session
635
+ * itself. sessionQuery.traceSession owns the walk when it exists (it also
636
+ * answers for a missing ancestor); otherwise the header index is walked
637
+ * recursively.
638
+ * @returns {Promise<string[]>}
639
+ */
640
+ async function collectDescendantIds(ctx, persistence, sessionId) {
641
+ const query = ctx.get('sessionQuery');
642
+ if (typeof query?.traceSession === 'function') {
643
+ try {
644
+ const trace = await query.traceSession(sessionId);
645
+ if (Array.isArray(trace?.descendants)) {
646
+ const ids = trace.descendants.map(descendantId).filter((id) => id && id !== sessionId);
647
+ return [...new Set(ids)];
648
+ }
649
+ } catch { /* a partial lineage must not fail the delete: derive it below */ }
650
+ }
651
+
652
+ const { entries } = await collectLineage(ctx, persistence);
653
+ const childrenOf = new Map();
654
+ for (const entry of entries.values()) {
655
+ if (!entry.parentSession) continue;
656
+ const bucket = childrenOf.get(entry.parentSession);
657
+ if (bucket) bucket.push(entry.id); else childrenOf.set(entry.parentSession, [entry.id]);
658
+ }
659
+ const ids = [];
660
+ const seen = new Set([sessionId]);
661
+ const queue = [...(childrenOf.get(sessionId) ?? [])];
662
+ while (queue.length > 0) {
663
+ const id = queue.shift();
664
+ // A corrupt parent link that cycles back must not loop forever.
665
+ if (seen.has(id)) continue;
666
+ seen.add(id);
667
+ ids.push(id);
668
+ queue.push(...(childrenOf.get(id) ?? []));
669
+ }
670
+ return ids;
671
+ }
672
+
673
+ /** Workspace-record session ids: the second way a child's parent can resolve. */
674
+ function collectRegistrySessionIds(registry) {
675
+ const ids = new Set();
676
+ const entities = registry?.entities;
677
+ if (entities) {
678
+ for (const entity of entities.values()) {
679
+ for (const sid of entity.record?.sessionIds ?? []) ids.add(sid);
680
+ }
681
+ }
682
+ return ids;
683
+ }
684
+
685
+ /** Byte size of a session log, or undefined when no such file exists. */
686
+ async function logFileSize(path) {
687
+ if (!path) return undefined;
688
+ try {
689
+ const info = await stat(path);
690
+ return info.isFile() ? info.size : undefined;
691
+ } catch {
692
+ return undefined;
693
+ }
694
+ }
695
+
696
+ /**
697
+ * The display row for one child session.
698
+ *
699
+ * WHY A SIZE FALLBACK EXISTS: `sizeBytes` is a field of the persistence
700
+ * listing only — the kernel's `sessionQuery.listSessions()` projects
701
+ * `{ header, live, persisted }` and carries no size at all — so on a host that
702
+ * HAS sessionQuery (the normal case) every row would otherwise report
703
+ * `fileSize: null` and the panel could not show a single size. The fallback
704
+ * measures ONLY the rows this report is about to return (never the whole
705
+ * store), and a session with no log stays `null` rather than claiming 0 bytes.
706
+ */
707
+ async function childInfo(ctx, persistence, projectionCache, entry) {
708
+ const cached = readCachedSessionFacts(projectionCache, entry.header);
709
+ const headerTitle = typeof entry.header.title === 'string' && entry.header.title && entry.header.title !== entry.id
710
+ ? entry.header.title
711
+ : undefined;
712
+ const sizeBytes = typeof entry.sizeBytes === 'number'
713
+ ? entry.sizeBytes
714
+ : await logFileSize(entry.logPath ?? await resolveSessionLogPath(persistence, entry.id));
715
+ return {
716
+ id: entry.id,
717
+ title: headerTitle ?? cached.title ?? null,
718
+ fileSize: typeof sizeBytes === 'number' ? sizeBytes : null,
719
+ createdAt: typeof entry.header.createdAt === 'number' ? entry.header.createdAt : null,
720
+ delegationDepth: entry.delegationDepth,
721
+ live: isSessionLive(ctx, entry.id),
722
+ };
723
+ }
724
+
725
+ /**
726
+ * Build the /children report: sub-sessions grouped under the parent they name,
727
+ * and orphans grouped under a parent id that resolves to nothing (neither a log
728
+ * nor a workspace-registry session id) so the panel can render its "残壳" rows.
729
+ * `sessionIds` narrows the report to those parents; omitting it reports the
730
+ * whole store.
731
+ * @returns {Promise<{groups: object, orphans: object, scannedAt: number,
732
+ * sessionCount: number, degraded?: true}>}
733
+ */
734
+ async function buildChildrenReport(ctx, persistence, sessionIds) {
735
+ const { entries, degraded } = await collectLineage(ctx, persistence);
736
+ const report = { groups: {}, orphans: {}, scannedAt: Date.now(), sessionCount: entries.size };
737
+ if (degraded) return { ...report, degraded: true };
738
+
739
+ const filter = Array.isArray(sessionIds) ? new Set(normalizeSessionIds(sessionIds)) : undefined;
740
+ const registryIds = collectRegistrySessionIds(ctx.workspaceRegistry);
741
+ const projectionCache = ctx.get('sessionProjectionCache');
742
+ for (const entry of entries.values()) {
743
+ const parentId = entry.parentSession;
744
+ if (!parentId) continue;
745
+ if (filter && !filter.has(parentId)) continue;
746
+ const bucket = entries.has(parentId) || registryIds.has(parentId) ? report.groups : report.orphans;
747
+ (bucket[parentId] ??= []).push(await childInfo(ctx, persistence, projectionCache, entry));
748
+ }
749
+ for (const bucket of [report.groups, report.orphans]) {
750
+ for (const list of Object.values(bucket)) {
751
+ list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || a.id.localeCompare(b.id));
752
+ }
753
+ }
754
+ return report;
755
+ }
756
+
757
+ /** Drop one session's live entry from the sessions store, when it has one. */
758
+ function detachLiveSession(ctx, sessionId) {
759
+ try {
760
+ const sessions = ctx.get('sessions');
761
+ const session = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
762
+ if (session !== undefined && typeof sessions.liveEntryFor === 'function') {
763
+ const entry = sessions.liveEntryFor(session);
764
+ if (entry && typeof entry.detach === 'function') entry.detach();
765
+ }
766
+ } catch {
767
+ // not live in the store — nothing to detach
768
+ }
769
+ }
770
+
771
+ /**
772
+ * GHOST cleanup: a session with NO log on disk must DISAPPEAR, not come back as
773
+ * an ungrouped row, so it is detached from every workspace record that lists it.
774
+ */
775
+ async function detachFromWorkspaces(registry, sessionId) {
776
+ const entities = registry?.entities;
777
+ if (!entities) return;
778
+ for (const entity of entities.values()) {
779
+ if (entity.record?.sessionIds?.includes(sessionId)) {
780
+ try {
781
+ await entity.detachSession(sessionId);
782
+ } catch {
783
+ // best effort
784
+ }
785
+ }
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Remove one session's durable artifacts: its log directory, its projection
791
+ * cache record, its live-store entry, and — when no log existed at all — its
792
+ * workspace slots. Failures propagate: the cascade reports them per id instead
793
+ * of rolling back.
794
+ */
795
+ async function removeSessionArtifacts(ctx, persistence, projectionCache, registry, sessionId) {
796
+ const logPath = await resolveSessionLogPath(persistence, sessionId);
797
+ if (logPath) {
798
+ try {
799
+ const sessionDir = dirname(logPath);
800
+ await rm(sessionDir, { recursive: true, force: true });
801
+ const projectDir = dirname(sessionDir);
802
+ try {
803
+ const entries = await readdir(projectDir);
804
+ if (entries.length === 0) await rmdir(projectDir);
805
+ } catch { /* best effort */ }
806
+ } catch (error) {
807
+ throw new Error(`failed to delete session log for '${sessionId}'`, { cause: error });
808
+ }
809
+ }
810
+
811
+ if (typeof projectionCache?.remove === 'function') await projectionCache.remove(sessionId);
812
+
813
+ detachLiveSession(ctx, sessionId);
814
+ if (!logPath) await detachFromWorkspaces(registry, sessionId);
815
+ }
816
+
817
+ /**
818
+ * Purge rows of sessions through the registry's row-aware permanent delete.
819
+ * Every failure is reported per ROW and never rolled back, and a row that
820
+ * failed midway contributes the ids it did remove so the counts stay true.
821
+ * `errors` is an alias of `failed` kept for the released client builds (they
822
+ * read `.sessionId` and the SESSION_RUNNING `code`).
823
+ */
824
+ async function purgeSessionRows(registry, sessionIds, excludeIds) {
825
+ const rows = normalizeSessionIds(sessionIds);
826
+ const exclude = normalizeSessionIds(excludeIds);
827
+ const deleted = [];
828
+ const kept = [];
829
+ const failed = [];
830
+ const errors = [];
831
+ for (const sessionId of rows) {
832
+ try {
833
+ const result = await registry.permanentlyDeleteSession(sessionId, { excludeIds: exclude });
834
+ deleted.push(...result.deleted);
835
+ kept.push(...result.kept);
836
+ } catch (error) {
837
+ deleted.push(...(Array.isArray(error?.deleted) ? error.deleted : []));
838
+ const message = String(error?.message ?? error);
839
+ failed.push({ sessionId, message });
840
+ errors.push({ sessionId, code: error?.code ?? 'ERROR', message });
841
+ }
842
+ }
843
+ return {
844
+ deletedCount: deleted.length,
845
+ keptCount: kept.length,
846
+ deleted,
847
+ kept,
848
+ failed,
849
+ errors,
850
+ total: rows.length,
851
+ };
852
+ }
853
+
342
854
  /* ------------------------------------------------------------------ */
343
855
  /* Patch: WorkspaceRegistry — unarchive / permanentlyDelete / empty */
344
856
  /* ------------------------------------------------------------------ */
@@ -370,6 +882,13 @@ function patchWorkspaceRegistry(registry, ctx) {
370
882
  // zero the turn counts. ctx.get(...) is a safe non-throwing accessor, so
371
883
  // the remaining per-call lookups below stay dynamic on purpose.
372
884
  const persistence = ctx.sessionPersistence;
885
+ // Snapshotted exactly like persistence, and for the same reason: these
886
+ // methods live on the SHARED registry and can outlive this fiber, so a
887
+ // call-time `ctx.get('sessionProjectionCache')` would degrade to
888
+ // `undefined` on a dead context and silently zero every turn count — the
889
+ // v0.3.2 production symptom, just moved into the projection read. The
890
+ // service is in `static inject`, so it is present while this runs.
891
+ const projectionCache = ctx.get('sessionProjectionCache');
373
892
  const runInQueue = (fn) => {
374
893
  if (typeof registry.enqueueOperation === 'function') {
375
894
  return registry.enqueueOperation(fn);
@@ -377,165 +896,228 @@ function patchWorkspaceRegistry(registry, ctx) {
377
896
  return fn();
378
897
  };
379
898
 
899
+ // The registry's OWN methods, captured before this patch replaces them.
900
+ // Unwrapping TRASH_PATCH_ORIGINAL matters on an in-process re-mount
901
+ // (loader update, uninstall → install): the registry is shared, so the
902
+ // previous install's patch would otherwise be read back as "the original"
903
+ // and the patch would recurse into itself forever.
904
+ const currentArchive = registry.archiveSession;
905
+ const currentUnarchive = registry.unarchiveSession;
906
+ const originalArchive = currentArchive?.[TRASH_PATCH_ORIGINAL] ?? currentArchive;
907
+ const originalUnarchive = currentUnarchive?.[TRASH_PATCH_ORIGINAL] ?? currentUnarchive;
908
+
380
909
  // ── 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
- });
910
+ //
911
+ // TWO CALLERS, ONE ARGUMENT SLOT (v0.4.4):
912
+ //
913
+ // DSH calls `WorkspaceRegistry.archiveSession(sessionId, options)` — its
914
+ // api-workspace-controller passes `{ stopActivity: true }` or `{}`. This
915
+ // plugin's own /archive route passes a display TITLE string instead. The
916
+ // pre-0.4.4 patch treated the second argument as a title, so DSH's `{}`
917
+ // was truthy and got stored as one: the recycle bin then read that object
918
+ // back as `savedTitle`, and rendering an object as a React child throws.
919
+ // Any session archived from DSH's own hover button therefore broke the
920
+ // panel, and the `{}` entries still sit in workspace.json. Split the two
921
+ // shapes explicitly and never store a non-string.
922
+ //
923
+ // The pre-0.4.4 patch also REPLACED the registry method outright, silently
924
+ // dropping four invariants DSH maintains there: the session-existence
925
+ // check, the `workspace/session-activity` gate that refuses to archive a
926
+ // session that is still working, `stopActivity` itself (so the "stop and
927
+ // archive" confirmation stopped nothing), and the pin drop that keeps
928
+ // pinning and archival mutually exclusive. Delegate to the registry's own
929
+ // method and layer only the display facts on top.
930
+ const parseArchiveArgs = (options) => {
931
+ if (typeof options === 'string') return { title: options || undefined, dshOptions: {} };
932
+ if (options && typeof options === 'object') return { title: undefined, dshOptions: options };
933
+ return { title: undefined, dshOptions: {} };
934
+ };
935
+
936
+ /**
937
+ * Pre-0.2 cohorts ship no registry archive API at all — this plugin is what
938
+ * provides one there. The write mirrors the durable shape of
939
+ * WorkspaceRegistry.archiveSession (archive set + dropped pin) minus the
940
+ * activity gate those cohorts do not expose.
941
+ */
942
+ const archiveWithoutRegistryApi = (sessionId) => runInQueue(async () => {
943
+ const state = registry.requireState?.();
944
+ if (!state) return;
945
+ const currentArchived = state.archivedSessionIds ?? [];
946
+ if (currentArchived.includes(sessionId)) return;
947
+ const next = { ...state, archivedSessionIds: [...currentArchived, sessionId] };
948
+ if (Array.isArray(state.pinnedSessionIds)) {
949
+ next.pinnedSessionIds = state.pinnedSessionIds.filter((id) => id !== sessionId);
950
+ }
951
+ await registry.setState(next);
952
+ notifyArchivedSessionsChanged(ctx, next.archivedSessionIds);
953
+ });
954
+
955
+ /** Persist the recycle bin's display facts for one archived session. */
956
+ const writeArchivedMeta = (sessionId, title) => runInQueue(async () => {
957
+ const state = registry.requireState?.();
958
+ if (!state) return;
959
+ if (!(state.archivedSessionIds ?? []).includes(sessionId)) return;
960
+ const current = state.archivedMeta ?? {};
961
+ const previous = current[sessionId];
962
+ const resolved = title ?? (typeof previous?.title === 'string' ? previous.title : undefined);
963
+ const at = typeof previous?.at === 'number' ? previous.at : Date.now();
964
+ const next = { ...current, [sessionId]: resolved === undefined ? { at } : { at, title: resolved } };
965
+ await registry.setState({ ...state, archivedMeta: next });
966
+ notifyArchivedSessionsChanged(ctx, state.archivedSessionIds);
967
+ });
968
+
969
+ /** Drop one archived session's display facts (unarchive or purge). */
970
+ const dropArchivedMeta = (sessionId) => runInQueue(async () => {
971
+ const state = registry.requireState?.();
972
+ if (!state) return;
973
+ const current = state.archivedMeta ?? {};
974
+ if (!(sessionId in current)) return;
975
+ const next = { ...current };
976
+ delete next[sessionId];
977
+ await registry.setState({ ...state, archivedMeta: next });
978
+ notifyArchivedSessionsChanged(ctx, state.archivedSessionIds ?? []);
979
+ });
980
+
981
+ registry.archiveSession = function archiveSession(sessionId, options) {
982
+ const { title, dshOptions } = parseArchiveArgs(options);
983
+ // The registry's own method opens its own mutation-queue slot, so this
984
+ // call must stay OUTSIDE runInQueue: enqueueOperation chains on the
985
+ // current tail, and wrapping a queued call in another queued call
986
+ // deadlocks the registry.
987
+ const archive = typeof originalArchive === 'function'
988
+ ? originalArchive.call(registry, sessionId, dshOptions)
989
+ : archiveWithoutRegistryApi(sessionId);
990
+ return archive.then(() => writeArchivedMeta(sessionId, title));
399
991
  };
400
992
 
401
993
  // ── unarchiveSession ──────────────────────────────────────────────
402
994
  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,
995
+ const unarchive = typeof originalUnarchive === 'function'
996
+ ? originalUnarchive.call(registry, sessionId)
997
+ : runInQueue(async () => {
998
+ const state = registry.requireState?.();
999
+ if (!state || !(state.archivedSessionIds ?? []).includes(sessionId)) return;
1000
+ const nextArchived = state.archivedSessionIds.filter((id) => id !== sessionId);
1001
+ await registry.setState({ ...state, archivedSessionIds: nextArchived });
1002
+ notifyArchivedSessionsChanged(ctx, nextArchived);
411
1003
  });
1004
+ // Restoring used to leave the session's stored title behind forever.
1005
+ return unarchive.then(() => dropArchivedMeta(sessionId));
1006
+ };
412
1007
 
413
- notifyArchivedSessionsChanged(ctx, newArchived);
1008
+ /** Drop the deleted ids from the archive set and its display metadata. */
1009
+ const purgeArchivedSlots = (sessionIds) => runInQueue(async () => {
1010
+ const state = registry.requireState?.();
1011
+ if (!state) return false;
1012
+ const archived = state.archivedSessionIds ?? [];
1013
+ const removing = sessionIds.filter((id) => archived.includes(id));
1014
+ if (removing.length === 0) return false;
1015
+ // Drop the display facts in the SAME write: a purged session leaves no row
1016
+ // to render, and its archivedMeta entry would otherwise sit in the durable
1017
+ // record forever.
1018
+ const nextMeta = { ...(state.archivedMeta ?? {}) };
1019
+ for (const id of removing) delete nextMeta[id];
1020
+ await registry.setState({
1021
+ ...state,
1022
+ archivedSessionIds: archived.filter((id) => !removing.includes(id)),
1023
+ archivedMeta: nextMeta,
414
1024
  });
415
- };
1025
+ return true;
1026
+ });
416
1027
 
417
1028
  // ── 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') {
1029
+ //
1030
+ // A ROW, NOT AN ID (v0.5.0): a subagent session is a SIBLING directory of
1031
+ // the session that spawned it and never appears in the workspace registry
1032
+ // (record.sessionIds holds root sessions only), so deleting the parent alone
1033
+ // used to leave its whole child tree behind on disk. One row is therefore
1034
+ // "the session + ALL its descendants, minus excludeIds".
1035
+ //
1036
+ // A live descendant refuses the WHOLE row before a single file is removed,
1037
+ // and excluding a running child does NOT rescue the row: the store must
1038
+ // never be mutated underneath a running agent.
1039
+ //
1040
+ // @param {string} sessionId the row's primary session.
1041
+ // @param {{excludeIds?: string[]}} [options] descendants to spare.
1042
+ // @returns {Promise<{deleted: string[], kept: string[]}>}
1043
+ registry.permanentlyDeleteSession = async function permanentlyDeleteSession(sessionId, options) {
1044
+ // The proven published-plugin liveness check: only an ACTIVE agent blocks
1045
+ // deletion, and it names the offending session.
1046
+ if (isSessionLive(ctx, sessionId)) {
424
1047
  const err = new Error(`会话正在运行中,请等待结束后再删除`);
425
1048
  err.code = 'SESSION_RUNNING';
426
1049
  throw err;
427
1050
  }
428
1051
 
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
- }
1052
+ const exclude = new Set(normalizeSessionIds(options?.excludeIds));
1053
+ const descendants = await collectDescendantIds(ctx, persistence, sessionId);
454
1054
 
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
1055
+ const running = descendants.filter((id) => isSessionLive(ctx, id)).sort();
1056
+ if (running.length > 0) {
1057
+ const err = new Error(`会话正在运行中,请等待结束后再删除(运行中的子会话:${running.join('、')})`);
1058
+ err.code = 'SESSION_RUNNING';
1059
+ err.runningSessionIds = running;
1060
+ throw err;
470
1061
  }
471
1062
 
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 };
1063
+ const targets = [sessionId, ...descendants.filter((id) => !exclude.has(id))];
1064
+ const kept = descendants.filter((id) => exclude.has(id));
517
1065
 
518
- const archivedIds = [...state.archivedSessionIds];
519
- let deletedCount = 0;
520
- const errors = [];
521
-
522
- for (const sessionId of archivedIds) {
1066
+ const deleted = [];
1067
+ let failure;
1068
+ for (const id of targets) {
523
1069
  try {
524
- await registry.permanentlyDeleteSession(sessionId);
525
- deletedCount++;
1070
+ await removeSessionArtifacts(ctx, persistence, projectionCache, registry, id);
526
1071
  } catch (error) {
527
- errors.push({ sessionId, message: error.message });
1072
+ // NO ROLLBACK: the ids already removed stay removed, and the caller is
1073
+ // handed exactly which ones they were so its counts stay true.
1074
+ error.deleted = deleted;
1075
+ failure = error;
1076
+ break;
528
1077
  }
1078
+ deleted.push(id);
529
1079
  }
530
1080
 
531
- if (errors.length > 0 && deletedCount === 0) {
532
- throw new Error(`failed to empty recycle bin: ${errors[0].message}`);
1081
+ if (await purgeArchivedSlots(deleted)) {
1082
+ notifyArchivedSessionsChanged(ctx, registry.requireState?.()?.archivedSessionIds ?? []);
533
1083
  }
1084
+ if (failure) throw failure;
1085
+ return { deleted, kept };
1086
+ };
534
1087
 
535
- return { deletedCount, total: archivedIds.length };
1088
+ // ── emptyArchivedSessions ─────────────────────────────────────────
1089
+ registry.emptyArchivedSessions = async function emptyArchivedSessions(options) {
1090
+ const archivedIds = [...(registry.requireState?.()?.archivedSessionIds ?? [])];
1091
+ // Every row cascades to its sub-sessions exactly like /purge: emptying the
1092
+ // recycle bin must not leave orphaned child directories behind.
1093
+ const result = await purgeSessionRows(registry, archivedIds, normalizeSessionIds(options?.excludeIds));
1094
+
1095
+ if (result.deletedCount > 0) {
1096
+ notifyArchivedSessionsChanged(ctx, registry.requireState?.()?.archivedSessionIds ?? []);
1097
+ }
1098
+ if (result.failed.length > 0 && result.deletedCount === 0) {
1099
+ throw new Error(`failed to empty recycle bin: ${result.failed[0].message}`);
1100
+ }
1101
+ return result;
536
1102
  };
537
1103
 
538
1104
  // ── listArchivedSessions ──────────────────────────────────────────
1105
+ //
1106
+ // ZERO LOG I/O (v0.4.4). Until v0.4.3 every row opened its session log and
1107
+ // decoded the entire Zstd frame chain just to count `turn/start` events and
1108
+ // to derive a title from the first user message. On the reference store
1109
+ // that meant ~0.2 s of header listing plus 10-198 ms PER archived session,
1110
+ // on the web server's own thread, on every open and on every archive /
1111
+ // unarchive / purge broadcast. Every fact the panel shows now comes from
1112
+ // durable state or the projection cache:
1113
+ //
1114
+ // title archivedMeta.title → projection-cache `title` → legacy
1115
+ // archivedTitles (string entries only) → '未命名会话'
1116
+ // turnCount projection-cache `sessionStats.turns`
1117
+ // fileSize the header listing's own sizeBytes
1118
+ // archivedAt archivedMeta.at — the REAL archive time. The old value was
1119
+ // the log's mtime, i.e. last ACTIVITY, which the panel
1120
+ // labelled "最近删除" and sorted by.
539
1121
  registry.listArchivedSessions = async function listArchivedSessions() {
540
1122
  const state = registry.requireState?.();
541
1123
  if (!state) return [];
@@ -543,42 +1125,18 @@ function patchWorkspaceRegistry(registry, ctx) {
543
1125
  const archivedIds = state.archivedSessionIds ?? [];
544
1126
  if (archivedIds.length === 0) return [];
545
1127
 
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
- }
1128
+ const archivedMeta = state.archivedMeta ?? {};
1129
+ const legacyTitles = state.archivedTitles ?? {};
573
1130
 
574
- let headers = [];
1131
+ // ONE listing for the whole set. Its snapshot also carries sizeBytes —
1132
+ // the field the panel used to stat() every log to recover.
1133
+ let snapshots = [];
575
1134
  try {
576
- headers = ((await persistence?.list?.()) ?? []).map(normalizeStoredHeader).filter(Boolean);
1135
+ snapshots = ((await persistence?.list?.()) ?? []).map(normalizeStoredSnapshot).filter(Boolean);
577
1136
  } catch {
578
- // Persistence list fallback
1137
+ // Listing failed: every row still renders from state alone.
579
1138
  }
580
-
581
- const byId = new Map(headers.map((h) => [h.id, h]));
1139
+ const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot]));
582
1140
 
583
1141
  const workspaceBySession = new Map();
584
1142
  const entities = registry.entities;
@@ -595,62 +1153,56 @@ function patchWorkspaceRegistry(registry, ctx) {
595
1153
  }
596
1154
  }
597
1155
 
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
- );
1156
+ // WHY ONE PATH RESOLUTION PER ROW IS BOUNDED: the hover card shows where a
1157
+ // session actually LIVES, and that path is only knowable through the same
1158
+ // resolver ladder permanentlyDeleteSession already walks per session —
1159
+ // findLog (a service query), locate() (pure path computation, no I/O) and,
1160
+ // only when neither answers, a directory scan. It never decodes a log, so
1161
+ // the listing still OPENS nothing (inspect/open stay at zero) and the
1162
+ // store-wide header listing above is still the ONE listing.
1163
+ //
1164
+ // findLog may name a location whose file is already gone (documented cohort
1165
+ // behaviour), while this field promises a path ON DISK: one fileExists
1166
+ // stat — the predicate locate() itself goes through — settles that, and a
1167
+ // session with no log at all reports null instead of a stale location.
1168
+ const items = await Promise.all(archivedIds.map(async (sessionId) => {
1169
+ const snapshot = byId.get(sessionId);
1170
+ const header = snapshot?.header;
1171
+ const ws = workspaceBySession.get(sessionId);
1172
+ const meta = archivedMeta[sessionId];
1173
+ const cached = readCachedSessionFacts(projectionCache, header);
1174
+ // `archivedTitles` predates archivedMeta and is also where the pre-0.4.4
1175
+ // options-object bug parked its `{}` values: only strings are titles.
1176
+ const legacyTitle = typeof legacyTitles[sessionId] === 'string' ? legacyTitles[sessionId] : undefined;
1177
+
1178
+ const title = meta?.title
1179
+ || cached.title
1180
+ || legacyTitle
1181
+ || (header?.title && header.title !== sessionId ? header.title : undefined)
1182
+ || '未命名会话';
1183
+
1184
+ const resolvedLogPath = await resolveSessionLogPath(persistence, sessionId);
1185
+ const logPath = resolvedLogPath && (await fileExists(resolvedLogPath)) ? resolvedLogPath : null;
1186
+
1187
+ return {
1188
+ sessionId,
1189
+ title,
1190
+ cwd: header?.cwd,
1191
+ workspacePath: ws?.workspacePath || ws?.cwd,
1192
+ workspaceTitle: ws?.workspaceTitle,
1193
+ // Rows archived before archivedMeta existed fall back to the header's
1194
+ // createdAt: stable and monotonic without touching the disk.
1195
+ archivedAt: typeof meta?.at === 'number' ? meta.at : (header?.createdAt ?? 0),
1196
+ fileSize: snapshot?.sizeBytes ?? 0,
1197
+ // null, NOT 0, when the cache cannot serve a current-generation
1198
+ // `sessionStats` row: an unknown count and a zero count are different
1199
+ // facts, and the panel must not print "0 轮对话" over a long session
1200
+ // whose checkpoint predates a format upgrade. The client omits the
1201
+ // chip for null.
1202
+ turnCount: typeof cached.turns === 'number' ? cached.turns : null,
1203
+ logPath,
1204
+ };
1205
+ }));
654
1206
 
655
1207
  // 按工作区顺序排列(与左侧 sidebar 一致),每个工作区内按时间降序排列
656
1208
  const workspaceOrder = [];
@@ -690,9 +1242,18 @@ function patchWorkspaceRegistry(registry, ctx) {
690
1242
  'emptyArchivedSessions',
691
1243
  'listArchivedSessions',
692
1244
  ];
1245
+ // The two patched registry methods ride ON TOP of the registry's own
1246
+ // implementations, so a later install must be able to unwrap them.
1247
+ const ORIGINALS = {
1248
+ archiveSession: originalArchive,
1249
+ unarchiveSession: originalUnarchive,
1250
+ };
693
1251
  for (const name of INSTALLED) {
694
1252
  if (typeof registry[name] === 'function') {
695
1253
  Object.defineProperty(registry[name], TRASH_PATCH, { value: token });
1254
+ if (typeof ORIGINALS[name] === 'function') {
1255
+ Object.defineProperty(registry[name], TRASH_PATCH_ORIGINAL, { value: ORIGINALS[name] });
1256
+ }
696
1257
  }
697
1258
  }
698
1259
  return () => {
@@ -733,6 +1294,31 @@ function httpError(statusCode, code, message) {
733
1294
  return Object.assign(new Error(message), { statusCode, code });
734
1295
  }
735
1296
 
1297
+ /**
1298
+ * Translate a `WorkspaceRegistry.archiveSession` refusal into an HTTP error.
1299
+ *
1300
+ * The registry owns two checks this plugin no longer shadows: the
1301
+ * session-existence check and the `workspace/session-activity` gate, which
1302
+ * refuses to archive a session that any provider still reports as working.
1303
+ * Both are ordinary refusals, so without this mapping they would reach the
1304
+ * browser as an opaque 500.
1305
+ *
1306
+ * Matching on `name` (not `instanceof`) is deliberate: the registry classes
1307
+ * are not a dependency of this bundle, and both set `this.name` explicitly.
1308
+ *
1309
+ * @param {unknown} error - the thrown registry refusal.
1310
+ * @returns {Error} an httpError, or the original error when unrecognized.
1311
+ */
1312
+ function mapArchiveRefusal(error) {
1313
+ if (error?.name === 'WorkspaceActiveSessionError') {
1314
+ return httpError(409, 'SESSION_RUNNING', '会话正在运行中,请先停止后再移入回收站');
1315
+ }
1316
+ if (error?.name === 'WorkspaceUnknownSessionError') {
1317
+ return httpError(404, 'UNKNOWN_SESSION', String(error?.message ?? error));
1318
+ }
1319
+ return error;
1320
+ }
1321
+
736
1322
  function sendJson(res, status, body) {
737
1323
  res.writeHead(status, {
738
1324
  'content-type': 'application/json; charset=utf-8',
@@ -877,7 +1463,14 @@ function registerRoutes(ctx) {
877
1463
  if (agent !== undefined && agent.status !== 'idle') {
878
1464
  throw httpError(400, 'SESSION_RUNNING', '会话正在运行中,无法删除');
879
1465
  }
880
- await registry?.archiveSession?.(sessionId, title);
1466
+ try {
1467
+ // Only a string may become the durable display title: the registry
1468
+ // itself passes an OPTIONS object in this argument slot, and storing
1469
+ // that object is what corrupted archivedTitles before v0.4.4.
1470
+ await registry?.archiveSession?.(sessionId, typeof title === 'string' ? title : undefined);
1471
+ } catch (error) {
1472
+ throw mapArchiveRefusal(error);
1473
+ }
881
1474
  ok(res, { sessionId, archivedSessionIds: broadcastArchived() });
882
1475
  }),
883
1476
  },
@@ -893,27 +1486,31 @@ function registerRoutes(ctx) {
893
1486
  ok(res, { sessionId, archivedSessionIds: broadcastArchived() });
894
1487
  }),
895
1488
  },
896
- // POST purge { sessionIds: [] } — permanently delete the given sessions.
1489
+ // POST children { sessionIds? } — the sub-session rows of the given parent
1490
+ // sessions (the whole store when the list is omitted): children whose
1491
+ // parent resolves, plus the orphans whose parent id resolves to nothing.
897
1492
  {
898
1493
  kind: 'exact',
899
- path: `${ROUTE_PREFIX}/purge`,
1494
+ path: `${ROUTE_PREFIX}/children`,
900
1495
  handler: guard(async (req, res) => {
901
1496
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
902
1497
  const { sessionIds } = await readBody(req);
903
- const ids = Array.isArray(sessionIds) ? sessionIds.filter((id) => typeof id === 'string' && id) : [];
1498
+ ok(res, await buildChildrenReport(ctx, persistence, sessionIds));
1499
+ }),
1500
+ },
1501
+ // POST purge { sessionIds: [], excludeIds?: [] } — permanently delete the
1502
+ // given sessions AND their full-depth sub-session trees.
1503
+ {
1504
+ kind: 'exact',
1505
+ path: `${ROUTE_PREFIX}/purge`,
1506
+ handler: guard(async (req, res) => {
1507
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
1508
+ const { sessionIds, excludeIds } = await readBody(req);
1509
+ const ids = normalizeSessionIds(sessionIds);
904
1510
  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 });
1511
+ const result = await purgeSessionRows(registry, ids, excludeIds);
1512
+ if (result.deletedCount > 0) broadcastArchived();
1513
+ ok(res, result);
917
1514
  }),
918
1515
  },
919
1516
  // GET messages — read and parse messages for session preview modal.
@@ -1143,14 +1740,14 @@ function registerRoutes(ctx) {
1143
1740
  ok(res, { sessionId, messages });
1144
1741
  }),
1145
1742
  },
1146
- // POST purge-workspace { workspacePath, sessionIds? } — permanently delete all archived sessions in a workspace.
1743
+ // POST purge-workspace { workspacePath, sessionIds?, excludeIds? } — permanently delete all archived sessions in a workspace.
1147
1744
  {
1148
1745
  kind: 'exact',
1149
1746
  path: `${ROUTE_PREFIX}/purge-workspace`,
1150
1747
  handler: guard(async (req, res) => {
1151
1748
  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 : [];
1749
+ const { workspacePath, sessionIds, excludeIds } = await readBody(req);
1750
+ let targetIds = normalizeSessionIds(sessionIds);
1154
1751
 
1155
1752
  if (targetIds.length === 0 && workspacePath) {
1156
1753
  const archived = (await registry?.listArchivedSessions?.()) ?? [];
@@ -1158,31 +1755,22 @@ function registerRoutes(ctx) {
1158
1755
  }
1159
1756
 
1160
1757
  if (targetIds.length === 0) {
1161
- return ok(res, { deletedCount: 0, total: 0, errors: [] });
1758
+ return ok(res, { deletedCount: 0, keptCount: 0, deleted: [], kept: [], failed: [], errors: [], total: 0 });
1162
1759
  }
1163
1760
 
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 });
1761
+ const result = await purgeSessionRows(registry, targetIds, excludeIds);
1762
+ if (result.deletedCount > 0) broadcastArchived();
1763
+ ok(res, result);
1177
1764
  }),
1178
1765
  },
1179
- // POST empty — permanently delete every archived session.
1766
+ // POST empty { excludeIds? } — permanently delete every archived session.
1180
1767
  {
1181
1768
  kind: 'exact',
1182
1769
  path: `${ROUTE_PREFIX}/empty`,
1183
1770
  handler: guard(async (req, res) => {
1184
1771
  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 };
1772
+ const { excludeIds } = await readBody(req);
1773
+ const result = await registry.emptyArchivedSessions({ excludeIds: normalizeSessionIds(excludeIds) });
1186
1774
  broadcastArchived();
1187
1775
  ok(res, result);
1188
1776
  }),