dsh-sessions-manager 3.4.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -16,7 +16,10 @@ import { renderSessionMarkdown } from './markdown.js'
16
16
  import { createStarIndex } from './star-index.js'
17
17
  import { aggregateStorage } from './storage-stats.js'
18
18
  import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
19
- import { createSessionMetaCache } from './session-meta-cache.js'
19
+ import { createSessionMetaCache, fingerprintOf } from './session-meta-cache.js'
20
+ import { createTitleIndexStore } from './title-persist-index.js'
21
+ import { createPersistenceAdapter } from './compat/persistence.js'
22
+ import { detectCapabilities, requireCapability } from './compat/capabilities.js'
20
23
 
21
24
 
22
25
  export const name = 'dsh-sessions-manager'
@@ -121,6 +124,8 @@ function foldTitle(events) {
121
124
  export function apply(ctx) {
122
125
  const w = ctx.workspaceRegistry
123
126
  const sp = ctx.sessionPersistence
127
+ const persistence = createPersistenceAdapter(sp)
128
+ const capabilities = detectCapabilities({ persistence: sp, workspaceRegistry: w })
124
129
  const sq = ctx.sessionQuery
125
130
  const dom = () => ctx.storageDomain.get('workspace')
126
131
  const authorityTitleCache = new Map()
@@ -128,6 +133,44 @@ export function apply(ctx) {
128
133
  // 见 src/session-meta-cache.js 的说明:列表构建原本每条会话都要整本解压日志,
129
134
  // 这个缓存让「日志没变」的会话直接跳过解码。
130
135
  const metaCache = createSessionMetaCache()
136
+ // 持久标题索引(冷启动加速):metaCache 是进程内的,重启即空——第一次列表
137
+ // 仍要全库解码。索引按同样的 (mtime, size) 指纹存解码结果,指纹没变的会话
138
+ // 重启后也直接复用。见 src/title-persist-index.js。
139
+ const titleIndex = createTitleIndexStore({ dir: TRASH_DIR, file: join(TRASH_DIR, 'title-index.json') })
140
+
141
+ // P4:对「内存缓存未命中」的会话查持久索引,指纹一致才可信。
142
+ // 返回 Map<id, meta>;调用方应把命中条目回填 metaCache 并从 missing 里剔除。
143
+ async function hydrateFromPersist(ids, statsById) {
144
+ const hits = new Map()
145
+ if (!ids || !ids.length) return hits
146
+ let store
147
+ try { store = await titleIndex.entries() } catch (e) { return hits }
148
+ for (const id of ids) {
149
+ const stat = statsById.get(id)
150
+ const entry = store && store[id]
151
+ if (!stat || !entry) continue
152
+ const fp = fingerprintOf(stat)
153
+ if (fp && entry.fingerprint === fp) {
154
+ hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
155
+ }
156
+ }
157
+ return hits
158
+ }
159
+
160
+ // 把本批真正解码出的元数据异步回写持久索引(fire-and-forget:索引只是
161
+ // 加速器,写失败不影响响应,队列内部已串行化 + 原子替换)。
162
+ function persistDecoded(decoded, statsById) {
163
+ if (!decoded || !decoded.size) return
164
+ const batch = {}
165
+ const now = Date.now()
166
+ for (const [id, meta] of decoded) {
167
+ const fp = fingerprintOf(statsById.get(id))
168
+ if (!fp) continue
169
+ batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
170
+ }
171
+ if (!Object.keys(batch).length) return
172
+ titleIndex.merge(batch).catch(() => {})
173
+ }
131
174
 
132
175
  // 从投影快照里抽出元数据;快照缺失/异常时返回零值 meta(调用方决定兜底)。
133
176
  function metaFromSnapshot(o) {
@@ -230,7 +273,7 @@ export function apply(ctx) {
230
273
  // 且结果同样会进缓存,下一次列表就不会再走一遍)。
231
274
  if (!meta.title || !meta.cwd) {
232
275
  try {
233
- const r = await sp.readFrom(id, 0)
276
+ const r = await persistence.readSession(id, 0)
234
277
  if (r.meta) {
235
278
  if (!meta.cwd) meta.cwd = r.meta.cwd || null
236
279
  if (!meta.createdAt) meta.createdAt = r.meta.createdAt || null
@@ -239,6 +282,8 @@ export function apply(ctx) {
239
282
  } catch (e2) { /* keep what we have */ }
240
283
  }
241
284
  metaCache.set(key, statInfo, meta)
285
+ // 本条是「真解码」出来的:交给调用方回写持久标题索引(P4 冷启动加速)。
286
+ if (opts.collectDecoded && statInfo) opts.collectDecoded(key, meta)
242
287
  return buildItem(key, meta, usage, opts.exposeUsage)
243
288
  }
244
289
 
@@ -254,17 +299,19 @@ export function apply(ctx) {
254
299
  async function collectUsage(preloadedHeaders) {
255
300
  const sizeById = new Map()
256
301
  const mtimeById = new Map()
257
- let headers = null
258
- if (Array.isArray(preloadedHeaders)) headers = preloadedHeaders
259
- else { try { headers = await sp.list() } catch (e) { headers = [] } }
260
- if (!Array.isArray(headers)) headers = []
302
+ let entries = null
303
+ if (Array.isArray(preloadedHeaders)) entries = preloadedHeaders
304
+ else { try { entries = await persistence.listEntries() } catch (e) { entries = [] } }
305
+ if (!Array.isArray(entries)) entries = []
261
306
  const CHUNK = 8
262
- for (let i = 0; i < headers.length; i += CHUNK) {
263
- await Promise.all(headers.slice(i, i + CHUNK).map(async (header) => {
264
- const id = header && header.id != null ? String(header.id) : null
307
+ for (let i = 0; i < entries.length; i += CHUNK) {
308
+ await Promise.all(entries.slice(i, i + CHUNK).map(async (entry) => {
309
+ const header = entry && entry.header ? entry.header : entry
310
+ const id = entry && entry.id != null ? String(entry.id) : (header && header.id != null ? String(header.id) : null)
265
311
  if (!id) return
312
+ if (entry && Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
266
313
  try {
267
- const loc = sp.locate(header)
314
+ const loc = persistence.locate(header)
268
315
  if (!loc || typeof loc.path !== 'string' || !loc.path) return
269
316
  const st = await stat(loc.path)
270
317
  if (!st) return
@@ -352,17 +399,20 @@ export function apply(ctx) {
352
399
  let cwd = null
353
400
  let title = null
354
401
  let removedPath = null
402
+ let persistenceEntry = null
355
403
  try {
356
- const headers = await sp.list()
357
- header = headers.find((h) => String(h.id) === sid) || null
404
+ const entries = await persistence.listEntries()
405
+ const found = entries.find((entry) => entry.id === sid) || null
406
+ persistenceEntry = found
407
+ header = found ? found.header : null
358
408
  if (header) {
359
- const loc = sp.locate(header)
409
+ const loc = persistence.locate(header)
360
410
  if (loc && typeof loc.path === 'string') removedPath = loc.path
361
411
  cwd = header.cwd || null
362
412
  title = header.title || (header.meta && header.meta.title) || null
363
413
  }
364
414
  if (!title) {
365
- const r = await sp.readFrom(sid, 0)
415
+ const r = await persistence.readSession(sid, 0)
366
416
  if (r && r.meta) { if (!cwd) cwd = r.meta.cwd; title = foldTitle(r.events) }
367
417
  }
368
418
  } catch (e) { /* best-effort */ }
@@ -377,7 +427,7 @@ export function apply(ctx) {
377
427
  const entry = {
378
428
  sessionId: sid, title: title || cwd || sid, cwd: cwd || null,
379
429
  header: header || null, originalPath: removedPath || null,
380
- sizeBytes: header && typeof header.size === 'number' ? header.size : null,
430
+ sizeBytes: persistenceEntry && Number.isFinite(persistenceEntry.sizeBytes) ? persistenceEntry.sizeBytes : null,
381
431
  wasArchived: archived, deletedAt: Date.now(),
382
432
  }
383
433
  const at = store.items.findIndex((t) => String(t.sessionId) === sid)
@@ -410,18 +460,24 @@ export function apply(ctx) {
410
460
  // the original workspace dir) and detach it from any workspace so DSH drops it.
411
461
  async function purgeFromTrash(sid) {
412
462
  requireSessionId(sid)
463
+ requireCapability(capabilities, 'purge')
413
464
  let purged = false
414
465
  await mutateTrash(async (store) => {
415
466
  const entry = store.items.find((t) => String(t.sessionId) === sid)
416
467
  if (!entry) { const error = new Error('回收站中找不到该会话'); error.status = 404; throw error }
417
468
  let target = null
418
469
  try {
419
- const headers = await sp.list()
420
- const current = headers.find((h) => String(h.id) === sid)
421
- const located = current && sp.locate(current)
470
+ const entries = await persistence.listEntries()
471
+ const current = entries.find((entry) => entry.id === sid)
472
+ const located = current && persistence.locate(current.header)
422
473
  if (located && typeof located.path === 'string') target = located.path
423
474
  } catch (e) {}
424
475
  if (!target && typeof entry.originalPath === 'string') target = entry.originalPath
476
+ if (!target) {
477
+ const error = new Error('无法确认该会话的物理日志位置,已停止永久删除')
478
+ error.status = 409
479
+ throw error
480
+ }
425
481
  // JSONL persistence stores logs as
426
482
  // .../<sessionId>/session.jsonl.zstd
427
483
  // Older backends may instead include the id in the filename itself.
@@ -491,6 +547,7 @@ export function apply(ctx) {
491
547
  }
492
548
 
493
549
  async function cleanupExpiredTrash() {
550
+ if (!capabilities.actions.purge.available) return 0
494
551
  const store = await readTrashStore()
495
552
  const days = store.settings.retentionDays
496
553
  if (!days) return 0
@@ -526,6 +583,7 @@ export function apply(ctx) {
526
583
  }
527
584
 
528
585
  async function moveOne(sid, targetPath) {
586
+ requireCapability(capabilities, 'move')
529
587
  // Only block the *active* conversation. ctx.sessions keeps instantiated
530
588
  // sessions alive after you switch away, so the old check (sessions.get(sid))
531
589
  // wrongly rejected every opened session — you could never move one you'd
@@ -536,7 +594,7 @@ export function apply(ctx) {
536
594
  if (activeId != null && String(activeId) === String(sid)) {
537
595
  throw new Error('该会话当前处于打开状态,请先切换到别的会话再移动。')
538
596
  }
539
- const r = await sp.readFrom(sid, 0)
597
+ const r = await persistence.readSession(sid, 0)
540
598
  if (!r || !r.meta) throw new Error('无法读取该会话的日志')
541
599
  const meta = r.meta
542
600
  const events = r.events
@@ -620,7 +678,7 @@ export function apply(ctx) {
620
678
  // disk, and WorkspaceEntity.sessionIds filters by that exact cwd. A bare
621
679
  // rename would leave frame0 pointing at the old workspace, so reindex /
622
680
  // restart would keep attributing the session to the wrong workspace.
623
- await relocateLog(meta, newHeader)
681
+ if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
624
682
  // Redirect the persistence state's cwd so future appends land in newPath.
625
683
  try {
626
684
  const st = sp.states && sp.states.get && sp.states.get(sid)
@@ -642,7 +700,7 @@ export function apply(ctx) {
642
700
 
643
701
  if (typeof sp.create !== 'function' || typeof sp.append !== 'function') {
644
702
  // No create primitive: must relocate the existing log directly.
645
- await relocateLog(meta, newHeader)
703
+ if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
646
704
  } else {
647
705
  const backupPath = oldPath ? `${oldPath}.move-backup-${Date.now()}` : null
648
706
  if (backupPath) { try { await rename(oldPath, backupPath) } catch (e) { if (e && e.code !== 'ENOENT') throw new Error('移动失败:无法备份旧的会话日志') } }
@@ -650,7 +708,7 @@ export function apply(ctx) {
650
708
  try {
651
709
  await sp.create(newHeader)
652
710
  await sp.append(sid, events)
653
- const check = await sp.readFrom(sid, 0)
711
+ const check = await persistence.readSession(sid, 0)
654
712
  if (!check || !check.meta || check.meta.cwd !== canonical) {
655
713
  throw new Error('移动后校验失败:会话工作目录未正确更新')
656
714
  }
@@ -660,7 +718,7 @@ export function apply(ctx) {
660
718
  // Collision: the session is already materialized in states (archived
661
719
  // or previously opened). Fall back to physically relocating the log.
662
720
  await restore()
663
- await relocateLog(meta, newHeader)
721
+ if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
664
722
  } else {
665
723
  await restore()
666
724
  throw new Error('移动会话日志失败:' + String((e && e.message) || e))
@@ -722,10 +780,10 @@ export function apply(ctx) {
722
780
  async function reindexRegistry() {
723
781
  const reg = w
724
782
  if (!reg || typeof reg.replaceHeaderIndex !== 'function') return false
725
- let headers = null
726
- try { headers = await sp.list() } catch (e) { headers = null }
727
- if (!headers || !Array.isArray(headers)) return false
728
- await reg.replaceHeaderIndex(headers)
783
+ let entries = null
784
+ try { entries = await persistence.listEntries() } catch (e) { entries = null }
785
+ if (!entries || !Array.isArray(entries)) return false
786
+ await reg.replaceHeaderIndex(entries.map((entry) => entry.header))
729
787
  if (typeof reg.rebuildEntities === 'function') reg.rebuildEntities()
730
788
  return true
731
789
  }
@@ -779,15 +837,15 @@ export function apply(ctx) {
779
837
  // 是元数据缓存能否跳过整本解码的前提,收益远大于成本
780
838
  // 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
781
839
  async function allSessionItems(opts = {}) {
782
- let headers = []
840
+ let entries = []
783
841
  let headersOk = false
784
842
  try {
785
- headers = await sp.list()
786
- headersOk = Array.isArray(headers)
787
- if (!headersOk) headers = []
788
- } catch (e) { headers = [] }
843
+ entries = await persistence.listEntries()
844
+ headersOk = Array.isArray(entries)
845
+ if (!headersOk) entries = []
846
+ } catch (e) { entries = [] }
789
847
  let live = ctx.get('sessions')
790
- const ids = headers.map((h) => String(h.id))
848
+ const ids = entries.map((entry) => entry.id)
791
849
  if (live) { try { live.list().forEach((s) => { const sid = String(s.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) { /* ignore */ } }
792
850
  // Exclude sessions already moved to the recycle bin (软删除): they live in
793
851
  // 回收站, not in 会话管理, so the panel won't re-list them after a delete.
@@ -801,11 +859,17 @@ export function apply(ctx) {
801
859
  try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
802
860
  const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
803
861
  const items = []
804
- const usage = await collectUsage(headers)
862
+ const usage = await collectUsage(entries)
805
863
  // 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
806
864
  const statsById = new Map(visibleIds.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
807
865
  const { cached, missing } = metaCache.partition(visibleIds, statsById)
808
- const snapshotById = await projectTitles(missing)
866
+ // P4:missing 里先查持久标题索引(冷启动跳过整本解码),命中的回填内存缓存。
867
+ const persisted = await hydrateFromPersist(missing, statsById)
868
+ for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
869
+ const stillMissing = missing.filter((id) => !persisted.has(id))
870
+ const snapshotById = await projectTitles(stillMissing)
871
+ const decoded = new Map()
872
+ const collectDecoded = (id, meta) => { decoded.set(id, meta) }
809
873
  const CHUNK = 6
810
874
  for (let i = 0; i < visibleIds.length; i += CHUNK) {
811
875
  // Arrow wrapper on purpose: Array#map passes (value, index, array), and
@@ -813,9 +877,11 @@ export function apply(ctx) {
813
877
  const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => resolveOne(id, usage, {
814
878
  exposeUsage: !!(opts && opts.usage),
815
879
  preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
880
+ collectDecoded,
816
881
  })))
817
882
  for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
818
883
  }
884
+ persistDecoded(decoded, statsById)
819
885
  // Annotate stars; GC only when we have a trustworthy id baseline, so a
820
886
  // failing sp.list() can never wipe the whole index.
821
887
  let starredSet = new Set()
@@ -878,30 +944,38 @@ export function apply(ctx) {
878
944
  // 既不会陈旧也不会回到「每次全量解码」。
879
945
  async function sidebarAuthority() {
880
946
  const ids = []
881
- let headers = []
882
- try { headers = await sp.list() } catch (e) { headers = [] }
883
- if (!Array.isArray(headers)) headers = []
884
- for (const header of headers) ids.push(String(header.id))
947
+ let entries = []
948
+ try { entries = await persistence.listEntries() } catch (e) { entries = [] }
949
+ if (!Array.isArray(entries)) entries = []
950
+ for (const entry of entries) ids.push(entry.id)
885
951
  const sessions = ctx.get('sessions')
886
952
  try { if (sessions) sessions.list().forEach((session) => { const sid = String(session.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) {}
887
953
  const store = await readTrashStore()
888
954
  if (ids.length) {
889
- const usage = await collectUsage(headers)
955
+ const usage = await collectUsage(entries)
890
956
  const statsById = new Map(ids.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
891
957
  const { cached, missing } = metaCache.partition(ids, statsById)
892
- const snapshotById = await projectTitles(missing)
958
+ // P4:与列表构建共用持久标题索引,冷启动零解码。
959
+ const persisted = await hydrateFromPersist(missing, statsById)
960
+ for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
961
+ const rest = missing.filter((id) => !persisted.has(id))
962
+ const snapshotById = await projectTitles(rest)
963
+ const decoded = new Map()
964
+ const collectDecoded = (id, meta) => { decoded.set(id, meta) }
893
965
  for (const id of ids) {
894
- let meta = cached.get(id) || null
966
+ let meta = cached.get(id) || persisted.get(id) || null
895
967
  if (!meta) {
896
968
  const snapshot = snapshotById.has(id)
897
969
  ? snapshotById.get(id)
898
970
  : (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
899
971
  const next = metaFromSnapshot(snapshot)
900
972
  metaCache.set(id, statsById.get(id), next)
973
+ if (statsById.get(id)) collectDecoded(id, next)
901
974
  meta = next
902
975
  }
903
976
  if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
904
977
  }
978
+ persistDecoded(decoded, statsById)
905
979
  }
906
980
  return {
907
981
  titles: Object.fromEntries(authorityTitleCache),
@@ -923,7 +997,7 @@ export function apply(ctx) {
923
997
  meta = (live && live.header) || null
924
998
  try { events = Array.isArray(live.events) ? [...live.events] : [] } catch (e) { events = [] }
925
999
  } else {
926
- const r = await sp.readFrom(sid, 0)
1000
+ const r = await persistence.readSession(sid, 0)
927
1001
  if (!r || !r.meta) throw new Error('找不到该会话的记录(会话不存在)')
928
1002
  meta = r.meta
929
1003
  events = Array.isArray(r.events) ? r.events : []
@@ -932,7 +1006,7 @@ export function apply(ctx) {
932
1006
  try {
933
1007
  // rc.8 的 sessionPersistence 后端没有 artifactInfo;用 locate(meta) 拿日志
934
1008
  // 文件真实路径后 stat 出字节数(磁盘占用)。
935
- const loc = sp.locate(meta)
1009
+ const loc = persistence.locate(meta)
936
1010
  if (loc && typeof loc.path === 'string' && loc.path) {
937
1011
  const st = await stat(loc.path)
938
1012
  if (st && typeof st.size === 'number') sizeBytes = st.size
@@ -1005,7 +1079,8 @@ export function apply(ctx) {
1005
1079
  const subagentSet = new Set()
1006
1080
  try {
1007
1081
  if (typeof sp.list === 'function') {
1008
- for (const h of await sp.list()) {
1082
+ for (const entry of await persistence.listEntries()) {
1083
+ const h = entry.header
1009
1084
  if (String(h.parentSession) !== String(sid)) continue
1010
1085
  if (h.origin === 'subagent') subagentSet.add(h.id); else childrenSet.add(h.id)
1011
1086
  }
@@ -1041,6 +1116,12 @@ export function apply(ctx) {
1041
1116
  }
1042
1117
  }))
1043
1118
 
1119
+ disposers.push(ctx.webServer.register({
1120
+ kind: 'exact',
1121
+ path: '/archived-sessions/capabilities',
1122
+ handler: async (req, res) => json(res, capabilities),
1123
+ }))
1124
+
1044
1125
  disposers.push(ctx.webServer.register({
1045
1126
  kind: 'exact',
1046
1127
  path: '/archived-sessions/list',
@@ -1053,8 +1134,8 @@ export function apply(ctx) {
1053
1134
  let materialized = new Set()
1054
1135
  let live = ctx.get('sessions')
1055
1136
  try {
1056
- const headers = await sp.list()
1057
- materialized = new Set(headers.map((h) => String(h.id)))
1137
+ const entries = await persistence.listEntries()
1138
+ materialized = new Set(entries.map((entry) => entry.id))
1058
1139
  } catch (e) { /* best-effort */ }
1059
1140
  const trashStore = await readTrashStore()
1060
1141
  const hidden = new Set([...trashStore.items.map((item) => String(item.sessionId)), ...trashStore.purgedSessionIds.map(String)])
@@ -1100,11 +1181,11 @@ export function apply(ctx) {
1100
1181
  const results = []
1101
1182
  for (const sid of ids) {
1102
1183
  try { results.push({ sessionId: sid, ok: true, ...(await restoreOne(sid)) }) }
1103
- catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
1184
+ catch (e) { results.push({ sessionId: sid, ok: false, code: e && e.code, error: String((e && e.message) || e) }) }
1104
1185
  }
1105
1186
  json(res, { ok: true, restored: results.filter((r) => r.ok).length, results })
1106
1187
  } catch (e) {
1107
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1188
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1108
1189
  }
1109
1190
  },
1110
1191
  }))
@@ -1143,7 +1224,7 @@ export function apply(ctx) {
1143
1224
  }
1144
1225
  json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })
1145
1226
  } catch (e) {
1146
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1227
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1147
1228
  }
1148
1229
  },
1149
1230
  }))
@@ -1188,11 +1269,19 @@ export function apply(ctx) {
1188
1269
  try {
1189
1270
  const items = await readTrash()
1190
1271
  const results = await Promise.all(items.map(async (item) => {
1191
- let exists = false
1192
- if (typeof item.originalPath === 'string') exists = await stat(item.originalPath).then(() => true).catch(() => false)
1193
- return { sessionId: item.sessionId, status: exists ? 'ok' : 'missing', originalPath: item.originalPath || null }
1272
+ if (typeof item.originalPath !== 'string' || !item.originalPath) {
1273
+ return { sessionId: item.sessionId, status: 'unverified', originalPath: null }
1274
+ }
1275
+ const exists = await stat(item.originalPath).then(() => true).catch(() => false)
1276
+ return { sessionId: item.sessionId, status: exists ? 'ok' : 'missing', originalPath: item.originalPath }
1194
1277
  }))
1195
- json(res, { ok: true, healthy: results.filter((r) => r.status === 'ok').length, missing: results.filter((r) => r.status === 'missing').length, results })
1278
+ json(res, {
1279
+ ok: true,
1280
+ healthy: results.filter((r) => r.status === 'ok').length,
1281
+ missing: results.filter((r) => r.status === 'missing').length,
1282
+ unverified: results.filter((r) => r.status === 'unverified').length,
1283
+ results,
1284
+ })
1196
1285
  } catch (e) {
1197
1286
  json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
1198
1287
  }
@@ -1211,7 +1300,7 @@ export function apply(ctx) {
1211
1300
  metaCache.invalidate(sid)
1212
1301
  json(res, out)
1213
1302
  } catch (e) {
1214
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1303
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1215
1304
  }
1216
1305
  },
1217
1306
  }))
@@ -1226,9 +1315,11 @@ export function apply(ctx) {
1226
1315
  if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
1227
1316
  const out = await purgeFromTrash(sid)
1228
1317
  metaCache.invalidate(sid)
1318
+ // 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
1319
+ titleIndex.remove([sid]).catch(() => {})
1229
1320
  json(res, out)
1230
1321
  } catch (e) {
1231
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1322
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1232
1323
  }
1233
1324
  },
1234
1325
  }))
@@ -1243,12 +1334,12 @@ export function apply(ctx) {
1243
1334
  if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
1244
1335
  const results = []
1245
1336
  for (const sid of ids) {
1246
- try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid) }
1337
+ try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); titleIndex.remove([sid]).catch(() => {}) }
1247
1338
  catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
1248
1339
  }
1249
1340
  json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
1250
1341
  } catch (e) {
1251
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1342
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1252
1343
  }
1253
1344
  },
1254
1345
  }))
@@ -1298,7 +1389,7 @@ export function apply(ctx) {
1298
1389
  const url = new URL(req.url, 'http://localhost')
1299
1390
  const sid = url.searchParams.get('sessionId')
1300
1391
  requireSessionId(sid)
1301
- const r = await sp.readFrom(sid, 0)
1392
+ const r = await persistence.readSession(sid, 0)
1302
1393
  if (!r || !r.meta) {
1303
1394
  const error = new Error('无法读取该会话的日志')
1304
1395
  error.status = 404
@@ -1365,7 +1456,7 @@ export function apply(ctx) {
1365
1456
  try { await reindexRegistry() } catch (e) { /* best-effort */ }
1366
1457
  json(res, { sessionId: sid, ...moved })
1367
1458
  } catch (e) {
1368
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1459
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1369
1460
  }
1370
1461
  },
1371
1462
  }))
@@ -1440,7 +1531,10 @@ export function apply(ctx) {
1440
1531
 
1441
1532
  // Auto-archive settings. A plain read (no patch keys) doubles as the lazy
1442
1533
  // sweep trigger — that is how the once-a-day cleanup gets a chance to run
1443
- // without a background timer.
1534
+ // without a background timer. The sweep runs in the background so opening
1535
+ // the panel never waits on it (P5, issue #1): the sweep itself already
1536
+ // reuses the metadata caches, and this keeps the settings read latency
1537
+ // independent of library size.
1444
1538
  disposers.push(ctx.webServer.register({
1445
1539
  kind: 'exact',
1446
1540
  path: '/archived-sessions/auto-archive/settings',
@@ -1450,10 +1544,20 @@ export function apply(ctx) {
1450
1544
  const patch = {}
1451
1545
  if (body && Object.prototype.hasOwnProperty.call(body, 'inactiveDays')) patch.inactiveDays = body.inactiveDays
1452
1546
  if (body && Object.prototype.hasOwnProperty.call(body, 'skipStarred')) patch.skipStarred = body.skipStarred
1453
- const settings = Object.keys(patch).length
1547
+ const isPatch = Object.keys(patch).length > 0
1548
+ const settings = isPatch
1454
1549
  ? await autoArchive.update(patch)
1455
1550
  : (await autoArchive.read()).settings
1456
- const sweep = await autoArchiveSweep()
1551
+ let sweep
1552
+ if (isPatch) {
1553
+ // 显式保存设置:保持「保存即生效」的同步 sweep(含刚启用时的首次归档)。
1554
+ sweep = await autoArchiveSweep()
1555
+ } else {
1556
+ // 面板打开的纯读取:sweep 转后台执行,打开延迟与库大小解耦
1557
+ //(P5,issue #1)。sweep 本身已复用元数据缓存 + 持久标题索引。
1558
+ void autoArchiveSweep().catch(() => {})
1559
+ sweep = { triggered: true }
1560
+ }
1457
1561
  const store = await autoArchive.read()
1458
1562
  json(res, {
1459
1563
  ok: true,
@@ -0,0 +1,137 @@
1
+ // title-persist-index.js — 会话标题/元数据的**磁盘**小索引(P4,issue #1)。
2
+ //
3
+ // metaCache(session-meta-cache.js)解决的是「进程内重复解码」;本模块解决
4
+ // 的是冷启动:插件重启后内存缓存为空,第一次列表构建仍要全库解码一次。
5
+ // 把解码出的元数据连同文件指纹原子写进一个 JSON 索引,重启后列表只需
6
+ // 一次索引读 + 指纹比对,指纹没变的会话零解码。
7
+ //
8
+ // 结构仿 trash 的原子写索引:{ schemaVersion, entries: { [sessionId]: entry } }
9
+ // entry = { title, cwd, createdAt, fingerprint, updatedAt }
10
+ // fingerprint 即 session-meta-cache.js 的 fingerprintOf(stat) 产出
11
+ // ("<mtimeMs>:<size>"),比对一致即可信任条目内容。
12
+ //
13
+ // 写入时机由调用方决定(列表构建收尾批量回写、purge 时清理),本模块只
14
+ // 提供:读取缓存、合并写入(串行化 + 原子替换)、按 id 删除。任何文件
15
+ // 损坏都按空索引处理,绝不阻塞列表构建。
16
+ //
17
+ // 单实例假设:一个进程内只 apply 一个插件实例(生产即如此),实例间的
18
+ // 内存副本不互相同步——跨「重启」以落盘内容为准(测试亦按此断言)。
19
+
20
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
21
+ import { dirname, join } from 'node:path'
22
+
23
+ export const TITLE_INDEX_SCHEMA_VERSION = 1
24
+
25
+ const MAX_ENTRIES = 20000
26
+
27
+ // 条目只保留可序列化且对列表有用的字段;指纹缺失的条目无法校验,直接丢弃——
28
+ // 宁可下次重解码,也不能把无法失效的数据当真。
29
+ export function normalizeEntry(raw) {
30
+ if (!raw || typeof raw !== 'object') return null
31
+ const title = typeof raw.title === 'string' ? raw.title : null
32
+ const cwd = typeof raw.cwd === 'string' ? raw.cwd : null
33
+ const createdAt = typeof raw.createdAt === 'number' ? raw.createdAt : null
34
+ const fingerprint = typeof raw.fingerprint === 'string' && raw.fingerprint ? raw.fingerprint : null
35
+ const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
36
+ if (!fingerprint || (!title && !cwd)) return null
37
+ return { title, cwd, createdAt, fingerprint, updatedAt }
38
+ }
39
+
40
+ export function normalizeTitleIndex(raw) {
41
+ const entries = {}
42
+ if (raw && typeof raw === 'object' && raw.entries && typeof raw.entries === 'object') {
43
+ for (const [id, entry] of Object.entries(raw.entries)) {
44
+ if (typeof id !== 'string' || !id || id.length > 200) continue
45
+ const normalized = normalizeEntry(entry)
46
+ if (normalized) entries[id] = normalized
47
+ }
48
+ }
49
+ return { schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries }
50
+ }
51
+
52
+ // 纯合并:right 覆盖 left 同 id 条目;截断到 MAX_ENTRIES(保留 updatedAt 新的)。
53
+ export function mergeEntries(left, right) {
54
+ const merged = { ...left }
55
+ for (const [id, entry] of Object.entries(right)) merged[id] = entry
56
+ const ids = Object.keys(merged)
57
+ if (ids.length > MAX_ENTRIES) {
58
+ ids.sort((a, b) => (merged[a].updatedAt || 0) - (merged[b].updatedAt || 0))
59
+ for (const id of ids.slice(0, ids.length - MAX_ENTRIES)) delete merged[id]
60
+ }
61
+ return merged
62
+ }
63
+
64
+ export function createTitleIndexStore({ dir, file }) {
65
+ let cache = null
66
+ let chain = Promise.resolve()
67
+ const path = file || join(dir, 'title-index.json')
68
+
69
+ async function readRaw() {
70
+ try {
71
+ return normalizeTitleIndex(JSON.parse(await readFile(path, 'utf8')))
72
+ } catch (e) {
73
+ return normalizeTitleIndex(null)
74
+ }
75
+ }
76
+
77
+ // 所有写操作串行化(仿 trash 的 mutate 队列),避免并发 merge 互相覆盖。
78
+ function enqueue(mutator) {
79
+ const operation = chain.then(async () => {
80
+ const store = cache || (cache = (await readRaw()).entries)
81
+ await mutator(store)
82
+ return store
83
+ })
84
+ chain = operation.catch(() => {})
85
+ return operation
86
+ }
87
+
88
+ return {
89
+ // 只读:内存优先,未加载过才落盘一次。绝不抛错。
90
+ async entries() {
91
+ if (cache) return cache
92
+ cache = (await readRaw()).entries
93
+ return cache
94
+ },
95
+ // 批量合并写入(原子替换)。失败静默:索引只是加速器,坏了下次重解码。
96
+ async merge(batch) {
97
+ const right = {}
98
+ for (const [id, entry] of Object.entries(batch || {})) {
99
+ const normalized = normalizeEntry(entry)
100
+ if (normalized) right[String(id)] = normalized
101
+ }
102
+ if (!Object.keys(right).length) return false
103
+ await enqueue(async (store) => {
104
+ const next = mergeEntries(store, right)
105
+ await mkdir(dirname(path), { recursive: true })
106
+ const tmp = join(dirname(path), `.title-index-${process.pid}-${Date.now()}.tmp`)
107
+ await writeFile(tmp, JSON.stringify({ schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries: next }), { encoding: 'utf8', mode: 0o600 })
108
+ await rename(tmp, path)
109
+ cache = next
110
+ })
111
+ return true
112
+ },
113
+ async remove(ids) {
114
+ const wanted = new Set((ids || []).map(String))
115
+ if (!wanted.size) return false
116
+ await enqueue(async (store) => {
117
+ let changed = false
118
+ for (const id of wanted) {
119
+ if (id in store) { delete store[id]; changed = true }
120
+ }
121
+ if (!changed) return
122
+ await mkdir(dirname(path), { recursive: true })
123
+ const tmp = join(dirname(path), `.title-index-${process.pid}-${Date.now()}.tmp`)
124
+ await writeFile(tmp, JSON.stringify({ schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries: store }), { encoding: 'utf8', mode: 0o600 })
125
+ await rename(tmp, path)
126
+ })
127
+ return true
128
+ },
129
+ }
130
+ }
131
+
132
+ // 判定持久条目能否当作当前日志的解码结果:指纹一致即可(与内存缓存同一标准)。
133
+ export function persistEntryUsable(entry, stat) {
134
+ const normalized = normalizeEntry(entry)
135
+ if (!normalized || !stat) return null
136
+ return normalized.fingerprint === stat.fingerprint ? normalized : null
137
+ }