dsh-sessions-manager 3.4.2 → 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-sessions-manager",
3
3
  "description": "DSH 设置面板会话管理器:归档 / 恢复 / 彻底删除 / 移动到其他工作区,带工作区标签与会话日期;统一「会话管理」面板。Session manager for the DeepSeek Harness settings panel — archive / restore / permanently delete / move sessions across workspaces, with workspace tags & session dates in one unified panel.",
4
- "version": "3.4.2",
4
+ "version": "3.5.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -219,6 +219,7 @@ function installSettingsNavIcons(ctx) {
219
219
  function SessionPanel({ workspacesSvc }) {
220
220
  const [sessions, setSessions] = useState(null)
221
221
  const [workspaces, setWorkspaces] = useState([])
222
+ const [capabilities, setCapabilities] = useState(null)
222
223
  const initialPrefs = useRef(loadPanelPrefs()).current
223
224
  // 注意:'storage' 已从可持久化取值中移除(它不再是视图),旧版残留的
224
225
  // filter='storage' 会自动回落到 'all',避免落到一个已不存在的界面。
@@ -276,14 +277,16 @@ function SessionPanel({ workspacesSvc }) {
276
277
  Promise.all([
277
278
  postJSON('/archived-sessions/sessions', {}),
278
279
  postJSON('/archived-sessions/workspaces', {}),
280
+ postJSON('/archived-sessions/capabilities', {}),
279
281
  ])
280
- .then(([s, works]) => {
282
+ .then(([s, works, caps]) => {
281
283
  // Permanently-purged sessions must never re-appear here, even if DSH's
282
284
  // in-memory session index still lists them after the file was unlinked.
283
285
  const purged = dsmLoadPurged()
284
286
  const visible = (s.items || []).filter((x) => !purged.has(String(x.sessionId)))
285
287
  setSessions(visible)
286
288
  setWorkspaces(works.items || [])
289
+ setCapabilities(caps || null)
287
290
  setSelected({})
288
291
  setDelTarget(null)
289
292
  setConfirmBatch(false)
@@ -393,6 +396,9 @@ function SessionPanel({ workspacesSvc }) {
393
396
  const w = workspaces.find((x) => x.workspaceId === id)
394
397
  return w ? w.path : ''
395
398
  }
399
+ const actionCapability = (name) => (capabilities && capabilities.actions && capabilities.actions[name]) || { available: false, reason: '正在检查当前 DSH 的兼容能力…' }
400
+ const canPurge = actionCapability('purge')
401
+ const canMove = actionCapability('move')
396
402
 
397
403
  const archivedList = sessions ? sessions.filter((x) => x.archived) : []
398
404
  const activeList = sessions ? sessions.filter((x) => !x.archived) : []
@@ -473,7 +479,12 @@ function SessionPanel({ workspacesSvc }) {
473
479
  const verifyTrash = () => {
474
480
  setTrashBusy('__verify')
475
481
  postJSON('/archived-sessions/trash/verify', {})
476
- .then((r) => { setTrashBusy(null); setTrashCheck(r); showToast(r.missing ? `发现 ${r.missing} 条日志缺失` : '回收站校验通过') })
482
+ .then((r) => {
483
+ setTrashBusy(null); setTrashCheck(r)
484
+ if (r.missing) showToast(`发现 ${r.missing} 条日志缺失`)
485
+ else if (r.unverified) showToast(`${r.unverified} 条日志位置由当前 Runtime 管理,无法直接核验`)
486
+ else showToast('回收站校验通过')
487
+ })
477
488
  .catch((e) => { setTrashBusy(null); setError(String((e && e.message) || e)) })
478
489
  }
479
490
 
@@ -554,6 +565,7 @@ function SessionPanel({ workspacesSvc }) {
554
565
  }
555
566
 
556
567
  const doMove = (it) => {
568
+ if (!canMove.available) { setError(canMove.reason || '当前环境不支持跨工作区移动'); return }
557
569
  const targetPath = moveMode === 'new' ? newPath.trim() : wsPath(targetWs)
558
570
  if (!targetPath) { setError('请选择已有工作区或输入新的目标目录路径'); return }
559
571
  setBusy(it.sessionId)
@@ -591,6 +603,7 @@ function SessionPanel({ workspacesSvc }) {
591
603
  }
592
604
 
593
605
  const openMoveFor = (it) => {
606
+ if (!canMove.available) { setError(canMove.reason || '当前环境不支持跨工作区移动'); return }
594
607
  if (openMove === it.sessionId) { setOpenMove(null); return }
595
608
  setTargetWs(workspaces.length ? (targetWs || workspaces[0].workspaceId) : '')
596
609
  setMoveMode('existing')
@@ -663,15 +676,20 @@ function SessionPanel({ workspacesSvc }) {
663
676
  >⋯</button>
664
677
  {openMenu === it.sessionId && (
665
678
  <div className="more-menu" role="menu">
666
- {items.map(([id, label]) => (
679
+ {items.map(([id, label]) => {
680
+ const blocked = id === 'move' && !canMove.available
681
+ return (
667
682
  <button
668
683
  key={id}
669
684
  type="button"
670
685
  role="menuitem"
671
686
  className={'more-item' + (id === 'delete' ? ' more-item-danger' : '')}
687
+ disabled={blocked}
688
+ title={blocked ? canMove.reason : undefined}
672
689
  onClick={() => runMenu(id, it)}
673
690
  >{label}</button>
674
- ))}
691
+ )
692
+ })}
675
693
  </div>
676
694
  )}
677
695
  </div>
@@ -1034,9 +1052,10 @@ function SessionPanel({ workspacesSvc }) {
1034
1052
  </select>
1035
1053
  </div>
1036
1054
  <div className="sess-field"><label>数据检查</label><button type="button" className="archv-btn" disabled={trashBusy !== null} onClick={verifyTrash}>{trashBusy === '__verify' ? '校验中…' : '校验日志完整性'}</button></div>
1037
- <div className="sess-field"><label>永久清理</label><button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !trash.length} onClick={() => setPurgeTarget('__all')}>清空回收站</button></div>
1055
+ <div className="sess-field"><label>永久清理</label><button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !trash.length || !canPurge.available} title={!canPurge.available ? canPurge.reason : undefined} onClick={() => setPurgeTarget('__all')}>清空回收站</button></div>
1038
1056
  </div>
1039
- {trashCheck && <div className={trashCheck.missing ? 'archv-err' : 'archv-empty'} role="status">校验完成:{trashCheck.healthy} 条正常,{trashCheck.missing} 条日志缺失。</div>}
1057
+ {!canPurge.available && <div className="archv-empty" role="status">{canPurge.reason}</div>}
1058
+ {trashCheck && <div className={trashCheck.missing ? 'archv-err' : 'archv-empty'} role="status">校验完成:{trashCheck.healthy} 条正常,{trashCheck.missing} 条日志缺失,{trashCheck.unverified || 0} 条无法直接核验。</div>}
1040
1059
  {trash.length === 0 ? (
1041
1060
  <div className="dsm-trash-empty">回收站为空。删除的会话会先进入这里,可恢复或彻底删除。</div>
1042
1061
  ) : (
@@ -1047,7 +1066,7 @@ function SessionPanel({ workspacesSvc }) {
1047
1066
  <span className="dsm-trash-date">{fmtDate(t.deletedAt)}</span>
1048
1067
  <span className="dsm-trash-actions">
1049
1068
  <button type="button" className="archv-btn" disabled={trashBusy !== null} onClick={() => restoreTrash(t.sessionId)}>恢复</button>
1050
- <button type="button" className="archv-btn archv-del" disabled={trashBusy !== null} onClick={() => setPurgeTarget(t.sessionId)}>彻底删除</button>
1069
+ <button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !canPurge.available} title={!canPurge.available ? canPurge.reason : undefined} onClick={() => setPurgeTarget(t.sessionId)}>彻底删除</button>
1051
1070
  </span>
1052
1071
  </div>
1053
1072
  ))}
@@ -1169,6 +1188,14 @@ let dsmTrashIds = null
1169
1188
  let dsmServerPurgedIds = new Set()
1170
1189
  let dsmAuthoritativeTitles = new Map()
1171
1190
  let dsmTrashTick = 0
1191
+ let dsmCapabilities = null
1192
+ async function dsmLoadCapabilities() {
1193
+ try { dsmCapabilities = await postJSON('/archived-sessions/capabilities', {}) } catch (e) { /* unknown stays safely unavailable */ }
1194
+ return dsmCapabilities
1195
+ }
1196
+ function dsmActionCapability(name) {
1197
+ return dsmCapabilities && dsmCapabilities.actions && dsmCapabilities.actions[name]
1198
+ }
1172
1199
  async function dsmLoadTrashIds() {
1173
1200
  try {
1174
1201
  const r = await postJSON('/archived-sessions/sidebar-state', {})
@@ -1385,6 +1412,11 @@ function installSidebarSessionMenuAug() {
1385
1412
  const move = mk('移动会话', ICON_MOVE, false)
1386
1413
  const del = mk('删除会话', ICON_DEL, true)
1387
1414
  const mark = mk(dsmLoadManual().has(info.id) ? '标记已读' : '标记未读', ICON_UNREAD, false)
1415
+ const moveCapability = dsmActionCapability('move')
1416
+ if (!moveCapability || !moveCapability.available) {
1417
+ move.btn.disabled = true
1418
+ move.btn.title = (moveCapability && moveCapability.reason) || '正在检查当前版本的移动能力'
1419
+ }
1388
1420
  if (mark.btn.firstChild) mark.btn.firstChild.style.color = 'var(--dsw-alias-state-business-primary)'
1389
1421
  const chev = document.createElement('span')
1390
1422
  chev.style.cssText = 'margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:1'
@@ -1647,6 +1679,8 @@ function installSidebarWorkspaceDrag() {
1647
1679
  }, true)
1648
1680
 
1649
1681
  document.addEventListener('dragstart', (event) => {
1682
+ const moveCapability = dsmActionCapability('move')
1683
+ if (!moveCapability || !moveCapability.available) return
1650
1684
  const row = eventRow(event)
1651
1685
  const item = row && sessionForRow(row)
1652
1686
  if (!item || moving) return
@@ -1729,6 +1763,7 @@ function installSidebarWorkspaceDrag() {
1729
1763
  }
1730
1764
 
1731
1765
  export function apply(ctx) {
1766
+ dsmLoadCapabilities()
1732
1767
  installSettingsNavIcons(ctx)
1733
1768
  installSidebarSessionMenuAug()
1734
1769
  installSidebarStatusDots()
@@ -0,0 +1,34 @@
1
+ function action(available, reason = null) {
2
+ return { available: !!available, reason: available ? null : reason }
3
+ }
4
+
5
+ export function detectCapabilities({ persistence, workspaceRegistry }) {
6
+ const handleApi = !!(persistence && typeof persistence.open === 'function')
7
+ const legacyRead = !!(persistence && typeof persistence.readFrom === 'function')
8
+ const legacyLocate = !!(persistence && (typeof persistence.locate === 'function'
9
+ || (persistence.backend && typeof persistence.backend.locate === 'function')))
10
+ const workspaceInternals = !!(workspaceRegistry
11
+ && workspaceRegistry.headers && workspaceRegistry.sessionPaths
12
+ && typeof workspaceRegistry.replaceHeaderIndex === 'function')
13
+
14
+ return {
15
+ persistence: handleApi ? 'session-handle' : 'legacy',
16
+ actions: {
17
+ read: action(legacyRead || handleApi, '当前 DSH 未提供可识别的会话读取接口'),
18
+ archive: action(!!(workspaceRegistry && typeof workspaceRegistry.archiveSession === 'function'), '当前 DSH 未提供归档接口'),
19
+ trash: action(legacyRead || handleApi, '当前 DSH 无法读取会话,不能安全移入回收站'),
20
+ restoreTrash: action(true),
21
+ purge: action(!handleApi && legacyLocate, '当前 DSH 版本尚未提供经过验证的安全永久删除能力'),
22
+ move: action(!handleApi && legacyRead && legacyLocate && workspaceInternals, '当前 DSH 版本尚未提供经过验证的跨工作区迁移能力'),
23
+ },
24
+ }
25
+ }
26
+
27
+ export function requireCapability(capabilities, name) {
28
+ const value = capabilities && capabilities.actions && capabilities.actions[name]
29
+ if (value && value.available) return
30
+ const error = new Error((value && value.reason) || `当前环境不支持 ${name}`)
31
+ error.status = 409
32
+ error.code = 'DSM_CAPABILITY_UNAVAILABLE'
33
+ throw error
34
+ }
@@ -0,0 +1,72 @@
1
+ // Compatibility boundary for the two DSH persistence generations supported by
2
+ // dsh-sessions-manager. Business code consumes normalized headers and complete
3
+ // inspections; it never needs to know whether DSH returned a legacy header or
4
+ // a handle-era SessionPersistenceSnapshot.
5
+
6
+ function asHeader(value) {
7
+ if (!value || typeof value !== 'object') return null
8
+ const candidate = value.header && typeof value.header === 'object' ? value.header : value
9
+ return candidate.id == null ? null : candidate
10
+ }
11
+
12
+ export function normalizePersistenceEntry(value) {
13
+ const header = asHeader(value)
14
+ if (!header) return null
15
+ const snapshot = value && value.header === header ? value : null
16
+ return {
17
+ header,
18
+ snapshot,
19
+ id: String(header.id),
20
+ sizeBytes: snapshot && Number.isFinite(snapshot.sizeBytes) ? Number(snapshot.sizeBytes) : null,
21
+ eventCount: snapshot && Number.isSafeInteger(snapshot.eventCount) ? snapshot.eventCount : null,
22
+ revision: snapshot ? snapshot.revision : null,
23
+ }
24
+ }
25
+
26
+ export function normalizePersistenceList(values) {
27
+ if (!Array.isArray(values)) return []
28
+ return values.map(normalizePersistenceEntry).filter(Boolean)
29
+ }
30
+
31
+ export function createPersistenceAdapter(service) {
32
+ if (!service || typeof service.list !== 'function') throw new TypeError('sessionPersistence.list is required')
33
+
34
+ async function listEntries(options) {
35
+ return normalizePersistenceList(await service.list(options))
36
+ }
37
+
38
+ async function readSession(id, offset = 0) {
39
+ if (typeof service.readFrom === 'function') {
40
+ const result = await service.readFrom(id, offset)
41
+ return {
42
+ meta: result && result.meta ? result.meta : null,
43
+ inheritedEventCount: result && Number.isSafeInteger(result.inheritedEventCount) ? result.inheritedEventCount : 0,
44
+ events: result && Array.isArray(result.events) ? result.events : [],
45
+ }
46
+ }
47
+ if (typeof service.open !== 'function') throw new Error('当前 DSH 持久化服务不支持读取会话')
48
+ const handle = await service.open(id, 'read')
49
+ if (!handle || typeof handle.read !== 'function' || typeof handle.close !== 'function') {
50
+ try { if (handle && typeof handle.close === 'function') await handle.close() } catch {}
51
+ throw new Error('DSH 返回了无效的 SessionHandle')
52
+ }
53
+ try {
54
+ const events = await handle.read(offset)
55
+ return {
56
+ meta: handle.header || handle.meta || null,
57
+ inheritedEventCount: Number.isSafeInteger(handle.inheritedEventCount) ? handle.inheritedEventCount : 0,
58
+ events: Array.isArray(events) ? events : [...(events || [])],
59
+ }
60
+ } finally {
61
+ await handle.close()
62
+ }
63
+ }
64
+
65
+ function locate(header) {
66
+ if (typeof service.locate === 'function') return service.locate(header)
67
+ return null
68
+ }
69
+
70
+ const kind = typeof service.open === 'function' ? 'session-handle' : 'legacy'
71
+ return { kind, listEntries, readSession, locate }
72
+ }
package/src/index.js CHANGED
@@ -18,6 +18,8 @@ import { aggregateStorage } from './storage-stats.js'
18
18
  import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
19
19
  import { createSessionMetaCache, fingerprintOf } from './session-meta-cache.js'
20
20
  import { createTitleIndexStore } from './title-persist-index.js'
21
+ import { createPersistenceAdapter } from './compat/persistence.js'
22
+ import { detectCapabilities, requireCapability } from './compat/capabilities.js'
21
23
 
22
24
 
23
25
  export const name = 'dsh-sessions-manager'
@@ -122,6 +124,8 @@ function foldTitle(events) {
122
124
  export function apply(ctx) {
123
125
  const w = ctx.workspaceRegistry
124
126
  const sp = ctx.sessionPersistence
127
+ const persistence = createPersistenceAdapter(sp)
128
+ const capabilities = detectCapabilities({ persistence: sp, workspaceRegistry: w })
125
129
  const sq = ctx.sessionQuery
126
130
  const dom = () => ctx.storageDomain.get('workspace')
127
131
  const authorityTitleCache = new Map()
@@ -269,7 +273,7 @@ export function apply(ctx) {
269
273
  // 且结果同样会进缓存,下一次列表就不会再走一遍)。
270
274
  if (!meta.title || !meta.cwd) {
271
275
  try {
272
- const r = await sp.readFrom(id, 0)
276
+ const r = await persistence.readSession(id, 0)
273
277
  if (r.meta) {
274
278
  if (!meta.cwd) meta.cwd = r.meta.cwd || null
275
279
  if (!meta.createdAt) meta.createdAt = r.meta.createdAt || null
@@ -295,17 +299,19 @@ export function apply(ctx) {
295
299
  async function collectUsage(preloadedHeaders) {
296
300
  const sizeById = new Map()
297
301
  const mtimeById = new Map()
298
- let headers = null
299
- if (Array.isArray(preloadedHeaders)) headers = preloadedHeaders
300
- else { try { headers = await sp.list() } catch (e) { headers = [] } }
301
- 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 = []
302
306
  const CHUNK = 8
303
- for (let i = 0; i < headers.length; i += CHUNK) {
304
- await Promise.all(headers.slice(i, i + CHUNK).map(async (header) => {
305
- 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)
306
311
  if (!id) return
312
+ if (entry && Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
307
313
  try {
308
- const loc = sp.locate(header)
314
+ const loc = persistence.locate(header)
309
315
  if (!loc || typeof loc.path !== 'string' || !loc.path) return
310
316
  const st = await stat(loc.path)
311
317
  if (!st) return
@@ -393,17 +399,20 @@ export function apply(ctx) {
393
399
  let cwd = null
394
400
  let title = null
395
401
  let removedPath = null
402
+ let persistenceEntry = null
396
403
  try {
397
- const headers = await sp.list()
398
- 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
399
408
  if (header) {
400
- const loc = sp.locate(header)
409
+ const loc = persistence.locate(header)
401
410
  if (loc && typeof loc.path === 'string') removedPath = loc.path
402
411
  cwd = header.cwd || null
403
412
  title = header.title || (header.meta && header.meta.title) || null
404
413
  }
405
414
  if (!title) {
406
- const r = await sp.readFrom(sid, 0)
415
+ const r = await persistence.readSession(sid, 0)
407
416
  if (r && r.meta) { if (!cwd) cwd = r.meta.cwd; title = foldTitle(r.events) }
408
417
  }
409
418
  } catch (e) { /* best-effort */ }
@@ -418,7 +427,7 @@ export function apply(ctx) {
418
427
  const entry = {
419
428
  sessionId: sid, title: title || cwd || sid, cwd: cwd || null,
420
429
  header: header || null, originalPath: removedPath || null,
421
- sizeBytes: header && typeof header.size === 'number' ? header.size : null,
430
+ sizeBytes: persistenceEntry && Number.isFinite(persistenceEntry.sizeBytes) ? persistenceEntry.sizeBytes : null,
422
431
  wasArchived: archived, deletedAt: Date.now(),
423
432
  }
424
433
  const at = store.items.findIndex((t) => String(t.sessionId) === sid)
@@ -451,18 +460,24 @@ export function apply(ctx) {
451
460
  // the original workspace dir) and detach it from any workspace so DSH drops it.
452
461
  async function purgeFromTrash(sid) {
453
462
  requireSessionId(sid)
463
+ requireCapability(capabilities, 'purge')
454
464
  let purged = false
455
465
  await mutateTrash(async (store) => {
456
466
  const entry = store.items.find((t) => String(t.sessionId) === sid)
457
467
  if (!entry) { const error = new Error('回收站中找不到该会话'); error.status = 404; throw error }
458
468
  let target = null
459
469
  try {
460
- const headers = await sp.list()
461
- const current = headers.find((h) => String(h.id) === sid)
462
- 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)
463
473
  if (located && typeof located.path === 'string') target = located.path
464
474
  } catch (e) {}
465
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
+ }
466
481
  // JSONL persistence stores logs as
467
482
  // .../<sessionId>/session.jsonl.zstd
468
483
  // Older backends may instead include the id in the filename itself.
@@ -532,6 +547,7 @@ export function apply(ctx) {
532
547
  }
533
548
 
534
549
  async function cleanupExpiredTrash() {
550
+ if (!capabilities.actions.purge.available) return 0
535
551
  const store = await readTrashStore()
536
552
  const days = store.settings.retentionDays
537
553
  if (!days) return 0
@@ -567,6 +583,7 @@ export function apply(ctx) {
567
583
  }
568
584
 
569
585
  async function moveOne(sid, targetPath) {
586
+ requireCapability(capabilities, 'move')
570
587
  // Only block the *active* conversation. ctx.sessions keeps instantiated
571
588
  // sessions alive after you switch away, so the old check (sessions.get(sid))
572
589
  // wrongly rejected every opened session — you could never move one you'd
@@ -577,7 +594,7 @@ export function apply(ctx) {
577
594
  if (activeId != null && String(activeId) === String(sid)) {
578
595
  throw new Error('该会话当前处于打开状态,请先切换到别的会话再移动。')
579
596
  }
580
- const r = await sp.readFrom(sid, 0)
597
+ const r = await persistence.readSession(sid, 0)
581
598
  if (!r || !r.meta) throw new Error('无法读取该会话的日志')
582
599
  const meta = r.meta
583
600
  const events = r.events
@@ -661,7 +678,7 @@ export function apply(ctx) {
661
678
  // disk, and WorkspaceEntity.sessionIds filters by that exact cwd. A bare
662
679
  // rename would leave frame0 pointing at the old workspace, so reindex /
663
680
  // restart would keep attributing the session to the wrong workspace.
664
- await relocateLog(meta, newHeader)
681
+ if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
665
682
  // Redirect the persistence state's cwd so future appends land in newPath.
666
683
  try {
667
684
  const st = sp.states && sp.states.get && sp.states.get(sid)
@@ -683,7 +700,7 @@ export function apply(ctx) {
683
700
 
684
701
  if (typeof sp.create !== 'function' || typeof sp.append !== 'function') {
685
702
  // No create primitive: must relocate the existing log directly.
686
- await relocateLog(meta, newHeader)
703
+ if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
687
704
  } else {
688
705
  const backupPath = oldPath ? `${oldPath}.move-backup-${Date.now()}` : null
689
706
  if (backupPath) { try { await rename(oldPath, backupPath) } catch (e) { if (e && e.code !== 'ENOENT') throw new Error('移动失败:无法备份旧的会话日志') } }
@@ -691,7 +708,7 @@ export function apply(ctx) {
691
708
  try {
692
709
  await sp.create(newHeader)
693
710
  await sp.append(sid, events)
694
- const check = await sp.readFrom(sid, 0)
711
+ const check = await persistence.readSession(sid, 0)
695
712
  if (!check || !check.meta || check.meta.cwd !== canonical) {
696
713
  throw new Error('移动后校验失败:会话工作目录未正确更新')
697
714
  }
@@ -701,7 +718,7 @@ export function apply(ctx) {
701
718
  // Collision: the session is already materialized in states (archived
702
719
  // or previously opened). Fall back to physically relocating the log.
703
720
  await restore()
704
- await relocateLog(meta, newHeader)
721
+ if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
705
722
  } else {
706
723
  await restore()
707
724
  throw new Error('移动会话日志失败:' + String((e && e.message) || e))
@@ -763,10 +780,10 @@ export function apply(ctx) {
763
780
  async function reindexRegistry() {
764
781
  const reg = w
765
782
  if (!reg || typeof reg.replaceHeaderIndex !== 'function') return false
766
- let headers = null
767
- try { headers = await sp.list() } catch (e) { headers = null }
768
- if (!headers || !Array.isArray(headers)) return false
769
- 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))
770
787
  if (typeof reg.rebuildEntities === 'function') reg.rebuildEntities()
771
788
  return true
772
789
  }
@@ -820,15 +837,15 @@ export function apply(ctx) {
820
837
  // 是元数据缓存能否跳过整本解码的前提,收益远大于成本
821
838
  // 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
822
839
  async function allSessionItems(opts = {}) {
823
- let headers = []
840
+ let entries = []
824
841
  let headersOk = false
825
842
  try {
826
- headers = await sp.list()
827
- headersOk = Array.isArray(headers)
828
- if (!headersOk) headers = []
829
- } catch (e) { headers = [] }
843
+ entries = await persistence.listEntries()
844
+ headersOk = Array.isArray(entries)
845
+ if (!headersOk) entries = []
846
+ } catch (e) { entries = [] }
830
847
  let live = ctx.get('sessions')
831
- const ids = headers.map((h) => String(h.id))
848
+ const ids = entries.map((entry) => entry.id)
832
849
  if (live) { try { live.list().forEach((s) => { const sid = String(s.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) { /* ignore */ } }
833
850
  // Exclude sessions already moved to the recycle bin (软删除): they live in
834
851
  // 回收站, not in 会话管理, so the panel won't re-list them after a delete.
@@ -842,7 +859,7 @@ export function apply(ctx) {
842
859
  try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
843
860
  const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
844
861
  const items = []
845
- const usage = await collectUsage(headers)
862
+ const usage = await collectUsage(entries)
846
863
  // 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
847
864
  const statsById = new Map(visibleIds.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
848
865
  const { cached, missing } = metaCache.partition(visibleIds, statsById)
@@ -927,15 +944,15 @@ export function apply(ctx) {
927
944
  // 既不会陈旧也不会回到「每次全量解码」。
928
945
  async function sidebarAuthority() {
929
946
  const ids = []
930
- let headers = []
931
- try { headers = await sp.list() } catch (e) { headers = [] }
932
- if (!Array.isArray(headers)) headers = []
933
- 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)
934
951
  const sessions = ctx.get('sessions')
935
952
  try { if (sessions) sessions.list().forEach((session) => { const sid = String(session.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) {}
936
953
  const store = await readTrashStore()
937
954
  if (ids.length) {
938
- const usage = await collectUsage(headers)
955
+ const usage = await collectUsage(entries)
939
956
  const statsById = new Map(ids.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
940
957
  const { cached, missing } = metaCache.partition(ids, statsById)
941
958
  // P4:与列表构建共用持久标题索引,冷启动零解码。
@@ -980,7 +997,7 @@ export function apply(ctx) {
980
997
  meta = (live && live.header) || null
981
998
  try { events = Array.isArray(live.events) ? [...live.events] : [] } catch (e) { events = [] }
982
999
  } else {
983
- const r = await sp.readFrom(sid, 0)
1000
+ const r = await persistence.readSession(sid, 0)
984
1001
  if (!r || !r.meta) throw new Error('找不到该会话的记录(会话不存在)')
985
1002
  meta = r.meta
986
1003
  events = Array.isArray(r.events) ? r.events : []
@@ -989,7 +1006,7 @@ export function apply(ctx) {
989
1006
  try {
990
1007
  // rc.8 的 sessionPersistence 后端没有 artifactInfo;用 locate(meta) 拿日志
991
1008
  // 文件真实路径后 stat 出字节数(磁盘占用)。
992
- const loc = sp.locate(meta)
1009
+ const loc = persistence.locate(meta)
993
1010
  if (loc && typeof loc.path === 'string' && loc.path) {
994
1011
  const st = await stat(loc.path)
995
1012
  if (st && typeof st.size === 'number') sizeBytes = st.size
@@ -1062,7 +1079,8 @@ export function apply(ctx) {
1062
1079
  const subagentSet = new Set()
1063
1080
  try {
1064
1081
  if (typeof sp.list === 'function') {
1065
- for (const h of await sp.list()) {
1082
+ for (const entry of await persistence.listEntries()) {
1083
+ const h = entry.header
1066
1084
  if (String(h.parentSession) !== String(sid)) continue
1067
1085
  if (h.origin === 'subagent') subagentSet.add(h.id); else childrenSet.add(h.id)
1068
1086
  }
@@ -1098,6 +1116,12 @@ export function apply(ctx) {
1098
1116
  }
1099
1117
  }))
1100
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
+
1101
1125
  disposers.push(ctx.webServer.register({
1102
1126
  kind: 'exact',
1103
1127
  path: '/archived-sessions/list',
@@ -1110,8 +1134,8 @@ export function apply(ctx) {
1110
1134
  let materialized = new Set()
1111
1135
  let live = ctx.get('sessions')
1112
1136
  try {
1113
- const headers = await sp.list()
1114
- materialized = new Set(headers.map((h) => String(h.id)))
1137
+ const entries = await persistence.listEntries()
1138
+ materialized = new Set(entries.map((entry) => entry.id))
1115
1139
  } catch (e) { /* best-effort */ }
1116
1140
  const trashStore = await readTrashStore()
1117
1141
  const hidden = new Set([...trashStore.items.map((item) => String(item.sessionId)), ...trashStore.purgedSessionIds.map(String)])
@@ -1157,11 +1181,11 @@ export function apply(ctx) {
1157
1181
  const results = []
1158
1182
  for (const sid of ids) {
1159
1183
  try { results.push({ sessionId: sid, ok: true, ...(await restoreOne(sid)) }) }
1160
- 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) }) }
1161
1185
  }
1162
1186
  json(res, { ok: true, restored: results.filter((r) => r.ok).length, results })
1163
1187
  } catch (e) {
1164
- 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))
1165
1189
  }
1166
1190
  },
1167
1191
  }))
@@ -1200,7 +1224,7 @@ export function apply(ctx) {
1200
1224
  }
1201
1225
  json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })
1202
1226
  } catch (e) {
1203
- 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))
1204
1228
  }
1205
1229
  },
1206
1230
  }))
@@ -1245,11 +1269,19 @@ export function apply(ctx) {
1245
1269
  try {
1246
1270
  const items = await readTrash()
1247
1271
  const results = await Promise.all(items.map(async (item) => {
1248
- let exists = false
1249
- if (typeof item.originalPath === 'string') exists = await stat(item.originalPath).then(() => true).catch(() => false)
1250
- 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 }
1251
1277
  }))
1252
- 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
+ })
1253
1285
  } catch (e) {
1254
1286
  json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
1255
1287
  }
@@ -1268,7 +1300,7 @@ export function apply(ctx) {
1268
1300
  metaCache.invalidate(sid)
1269
1301
  json(res, out)
1270
1302
  } catch (e) {
1271
- 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))
1272
1304
  }
1273
1305
  },
1274
1306
  }))
@@ -1287,7 +1319,7 @@ export function apply(ctx) {
1287
1319
  titleIndex.remove([sid]).catch(() => {})
1288
1320
  json(res, out)
1289
1321
  } catch (e) {
1290
- 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))
1291
1323
  }
1292
1324
  },
1293
1325
  }))
@@ -1307,7 +1339,7 @@ export function apply(ctx) {
1307
1339
  }
1308
1340
  json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
1309
1341
  } catch (e) {
1310
- 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))
1311
1343
  }
1312
1344
  },
1313
1345
  }))
@@ -1357,7 +1389,7 @@ export function apply(ctx) {
1357
1389
  const url = new URL(req.url, 'http://localhost')
1358
1390
  const sid = url.searchParams.get('sessionId')
1359
1391
  requireSessionId(sid)
1360
- const r = await sp.readFrom(sid, 0)
1392
+ const r = await persistence.readSession(sid, 0)
1361
1393
  if (!r || !r.meta) {
1362
1394
  const error = new Error('无法读取该会话的日志')
1363
1395
  error.status = 404
@@ -1424,7 +1456,7 @@ export function apply(ctx) {
1424
1456
  try { await reindexRegistry() } catch (e) { /* best-effort */ }
1425
1457
  json(res, { sessionId: sid, ...moved })
1426
1458
  } catch (e) {
1427
- 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))
1428
1460
  }
1429
1461
  },
1430
1462
  }))