dsh-sessions-manager 3.6.2 → 3.7.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
@@ -14,17 +14,21 @@ import { homedir } from 'node:os'
14
14
  import { rewriteFrame0CwdInMemory, scanZstdFrames } from './zstd-frame.js'
15
15
  import { createSessionMarkdownBuilder } from './markdown.js'
16
16
  import { createStarIndex } from './star-index.js'
17
+ import { createTagIndex } from './tag-index.js'
18
+ import { createSavedFilters } from './saved-filters.js'
17
19
  import { createPendingMoveStore } from './pending-moves.js'
20
+ import { createMoveNoticeStore } from './move-notices.js'
18
21
  import { sweepStaleStateTemps } from './state-temp-sweep.js'
19
22
  import { aggregateStorage } from './storage-stats.js'
20
23
  import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
21
24
  import { createSessionMetaCache, fingerprintOf, persistFingerprintOf } from './session-meta-cache.js'
22
25
  import { createTitleIndexStore } from './title-persist-index.js'
26
+ import { createEmptyScanStore } from './empty-scan-index.js'
23
27
  import { createPersistenceAdapter } from './compat/persistence.js'
24
28
  import { detectCapabilities, requireCapability } from './compat/capabilities.js'
25
29
  import { pathOwnsSession } from './path-guard.js'
26
30
  import { purgeSessionArtifacts, moveSessionToCwd } from './handle-era-ops.js'
27
- import { classifyLineage, isEmptyEventTypes, EMPTY_DECODE_LIMIT } from './lineage.js'
31
+ import { classifyLineage, emptyScanCandidate, isEmptyEventTypes, EMPTY_DECODE_LIMIT } from './lineage.js'
28
32
 
29
33
  // 构建指纹:build.mjs 以 define 在编译期注入;直接以源码运行(测试)时为 'dev'。
30
34
  // 用途:capabilities 带出 buildStamp,一眼判断「运行中的 host 是不是这份构建」——
@@ -36,7 +40,8 @@ export const name = 'dsh-sessions-manager'
36
40
  export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
37
41
 
38
42
  const MAX_TITLE = 80
39
- // 插件自有状态目录(星标 / 自动归档 / 标题索引共用)。与 star-index.js、auto-archive.js
43
+ // 插件自有状态目录(星标 / 标签 / 保存筛选 / 自动归档 / 标题索引共用)。与 star-index.js、
44
+ // tag-index.js、auto-archive.js
40
45
  // 的默认目录保持一致,环境变量同名,便于测试注入。
41
46
  const STATE_DIR = process.env.DSH_SESSIONS_MANAGER_STAR_DIR || join(homedir(), '.dsh', 'sessions-manager')
42
47
  // Recycle bin (回收站): normal deletes land here instead of being erased.
@@ -154,6 +159,10 @@ export function apply(ctx) {
154
159
  // 仍要全库解码。索引按同样的 (mtime, size) 指纹存解码结果,指纹没变的会话
155
160
  // 重启后也直接复用。见 src/title-persist-index.js。
156
161
  const titleIndex = createTitleIndexStore({ dir: TRASH_DIR, file: join(TRASH_DIR, 'title-index.json') })
162
+ // T1(3.7.0):空白精判结论的持久层。只有**真解码成功**的判定落盘(unknown
163
+ // 与失败兜底都不是结论),sz 指纹 + 14 天 TTL 双闸门防「同尺寸原地改写」。
164
+ const emptyIndex = createEmptyScanStore({ dir: TRASH_DIR })
165
+ const EMPTY_VERDICT_TTL_MS = 14 * 24 * 3600 * 1000
157
166
 
158
167
  // P4:对「内存缓存未命中」的会话查持久索引,指纹一致才可信。
159
168
  // 返回 Map<id, meta>;调用方应把命中条目回填 metaCache 并从 missing 里剔除。
@@ -271,8 +280,9 @@ export function apply(ctx) {
271
280
  workspaceGone: !!(cwd && !ws),
272
281
  hasWorkspace: !!cwd,
273
282
  }
274
- // sizeBytes / updatedAt 只在需要的路由(存储分析 / 自动归档)里带上:
275
- // 它们本就来自 usage,附带输出对列表渲染无益。
283
+ // sizeBytes / updatedAt 只在需要的路由里带出(存储分析 / 自动归档 / 会话
284
+ // 列表——3.7.0 起列表也要:分支组时间回退键读 updatedAt)。它们本就来自
285
+ // usage,只是 payload 体积,不引入任何解码。
276
286
  if (exposeUsage && usage) {
277
287
  if (usage.sizeById && usage.sizeById.has(key)) base.sizeBytes = usage.sizeById.get(key)
278
288
  if (usage.mtimeById && usage.mtimeById.has(key)) base.updatedAt = usage.mtimeById.get(key)
@@ -573,9 +583,18 @@ export function apply(ctx) {
573
583
  // when the session is really gone (purge, or externally removed; the latter
574
584
  // is caught by gcStars during list builds).
575
585
  const stars = createStarIndex()
586
+ // 标签(3.7.0,schema v4):与星标同型、同状态目录(tags.json)。纯用户元数据,
587
+ // rename/merge/delete 只重写标签文档,永不触碰会话本体(方案 §1.3 红线)。
588
+ const tags = createTagIndex()
589
+ // 保存的筛选(schema v1,saved-filters.json):filters 为不透明 JSON,host
590
+ // 只限条数/名称/序列化体积,绝不解析其内容。
591
+ const savedFilters = createSavedFilters()
576
592
  // 待移动队列:会话被占用(DSH 打开中)时登记,等它释放后自动完成移动。
577
593
  // 见 src/pending-moves.js 的模块注释(0.1.5 单写者锁没有公共释放 API)。
578
594
  const pendingMoves = createPendingMoveStore()
595
+ // T2:排队移动的后台终局(完成/放弃)落成持久通知,sidebar-state 带出、
596
+ // client 弹出后 ack 清除。独立于队列文件(终局与队列项同生死,挂条目上观察不到)。
597
+ const moveNotices = createMoveNoticeStore()
579
598
  // Auto-archive settings live in their own schema-v4 store, off by default.
580
599
  const autoArchive = createAutoArchiveStore()
581
600
  // 启动清扫:进程在「写临时文件 → rename」之间被杀会留下 .<name>-<pid>-<ts>.tmp
@@ -591,6 +610,17 @@ export function apply(ctx) {
591
610
  } catch (e) { /* best-effort */ }
592
611
  }
593
612
 
613
+ // 标签的会话侧引用同样要 GC:会话被外部移除(不在 list 基线里)时,
614
+ // assignments 里的键会变成永远列不出来的幽灵。与 gcStars 同挂点、同信任条件。
615
+ async function gcTags(validIds) {
616
+ try {
617
+ const store = await tags.read()
618
+ const valid = new Set(validIds.map(String))
619
+ const gone = Object.keys(store.assignments).filter((id) => !valid.has(id))
620
+ if (gone.length) await tags.removeIds(gone)
621
+ } catch (e) { /* best-effort */ }
622
+ }
623
+
594
624
  // Soft-delete one session: record it in the recycle-bin index but KEEP its
595
625
  // log in the original workspace directory. Moving the file out (and detaching
596
626
  // it from the workspace) orphaned the session into DSH's "未分组" group and
@@ -867,6 +897,10 @@ export function apply(ctx) {
867
897
  // 状态目录清理 → `ENOTEMPTY: rmdir .../state`(全量测试里约 1/4 概率偶发失败)。
868
898
  // 而且"删除成功"不该早于状态落盘:进程恰好在此刻退出就会留下指向已删会话的星标。
869
899
  try { await stars.removeIds([sid]) } catch (e) { /* 星标清理失败不阻塞删除结果 */ }
900
+ // 标签 assignments 同理(S4 教训位):清理必须 await、不得浮动写——purgeFromTrash
901
+ // 是唯一的彻底删除收口(trash/purge、trash/purge-many、到期清理都经过它),
902
+ // 在这里落盘即覆盖所有路径,且"删除成功"不早于状态落盘。
903
+ try { await tags.removeIds([sid]) } catch (e) { /* 标签清理失败不阻塞删除结果 */ }
870
904
  return { ok: true, purged: true }
871
905
  }
872
906
 
@@ -1219,11 +1253,17 @@ export function apply(ctx) {
1219
1253
  metaCache.invalidate(item.sessionId)
1220
1254
  moved++
1221
1255
  notes.push(`排队中的移动已完成:${String(item.sessionId).slice(0, 18)}…`)
1256
+ // T2:通知落盘失败不吞掉移动结果本身(队列项已删,回滚代价不值——
1257
+ // 保留 console.warn 作最后兜底,这是刻意接受的静默缺口)。
1258
+ try { await moveNotices.append({ kind: 'moved', sessionId: item.sessionId, targetPath: item.targetPath, at: Date.now() }) } catch (e) { /* best-effort */ }
1222
1259
  } catch (e) {
1223
1260
  if (isBusyError(e)) continue // 仍被占用:留在队列里,等下一次触发
1224
1261
  if (Date.now() - bootedAt < BOOT_WARMUP_MS) continue // boot 期未就绪:不记失败
1225
1262
  const bumped = await pendingMoves.bumpAttempts(item.sessionId)
1226
- if (bumped && bumped.dropped) notes.push(`排队中的移动多次失败已放弃:${String(item.sessionId).slice(0, 18)}…(${String((e && e.message) || e)})`)
1263
+ if (bumped && bumped.dropped) {
1264
+ notes.push(`排队中的移动多次失败已放弃:${String(item.sessionId).slice(0, 18)}…(${String((e && e.message) || e)})`)
1265
+ try { await moveNotices.append({ kind: 'abandoned', sessionId: item.sessionId, targetPath: item.targetPath, at: Date.now(), attempts: bumped.attempts, reason: String((e && e.message) || e).split('\n')[0] }) } catch (err) { /* best-effort */ }
1266
+ }
1227
1267
  }
1228
1268
  }
1229
1269
  if (moved > 0) { try { await reindexRegistry() } catch (e) { /* best-effort */ } }
@@ -1452,12 +1492,28 @@ export function apply(ctx) {
1452
1492
  }
1453
1493
  return { items: archivedItems, usage }
1454
1494
  }
1455
- // Annotate stars; GC only when we have a trustworthy id baseline, so a
1495
+ // Annotate stars & tags; GC only when we have a trustworthy id baseline, so a
1456
1496
  // failing sp.list() can never wipe the whole index.
1457
1497
  let starredSet = new Set()
1458
- try { starredSet = new Set((await stars.read()).starredSessionIds) } catch (e) {}
1459
- for (const it of items) it.starred = starredSet.has(String(it.sessionId))
1498
+ let tagIdsBySession = new Map()
1499
+ try {
1500
+ starredSet = new Set((await stars.read()).starredSessionIds)
1501
+ // Same one-shot read as stars: tag ids are plain metadata, no decoding.
1502
+ // Filter dangling tagIds against the live definitions so a half-deleted
1503
+ // tag can never leak into a rendered row.
1504
+ const tagStore = await tags.read()
1505
+ const knownTagIds = new Set(tagStore.tags.map((t) => t.id))
1506
+ for (const [sid, list] of Object.entries(tagStore.assignments)) {
1507
+ const kept = list.filter((id) => knownTagIds.has(id))
1508
+ if (kept.length) tagIdsBySession.set(sid, kept)
1509
+ }
1510
+ } catch (e) {}
1511
+ for (const it of items) {
1512
+ it.starred = starredSet.has(String(it.sessionId))
1513
+ it.tags = tagIdsBySession.get(String(it.sessionId)) || []
1514
+ }
1460
1515
  if (headersOk) await gcStars(ids)
1516
+ if (headersOk) await gcTags(ids)
1461
1517
  return { items, usage }
1462
1518
  }
1463
1519
 
@@ -1565,16 +1621,49 @@ export function apply(ctx) {
1565
1621
  if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
1566
1622
  }
1567
1623
  }
1568
- // 血缘分类(issue #6):结构分类(子代理 / fork 分支)来自 list snapshot
1569
- // 的 header,零解码;空白判定见下方 refineEmptyLineage(0.1.3 起体积法
1570
- // 失效,改为事件类型精判)。普通顶层非空会话不产生条目,payload 最小。
1624
+ // 血缘分类(issue #6 + T1/3.7.0):结构分类(子代理 / fork 分支)来自 list
1625
+ // snapshot 的 header,零解码。空白(empty)是**三态**:null=未知(等后台
1626
+ // refine 队列精判)、true/false=已判定(来自进程内缓存或 empty-scan 持久层,
1627
+ // sz 指纹一致 + 14 天 TTL 内才可信)。请求路径零解码承诺由此完整成立:
1628
+ // 超过解码上限的日志「必有内容」按事实直接给 false;legacy 无 sizeBytes
1629
+ // 观测 → 永不候选、恒未知(与旧行为一致)。普通会话只在真判空时建条目,
1630
+ // 「误隐藏」在结构上不可能来自体积法(该路径已整体退役)。
1571
1631
  const lineage = {}
1632
+ const refineWanted = []
1633
+ let emptyStore = null
1634
+ try { emptyStore = await emptyIndex.entries() } catch (e) { emptyStore = null }
1635
+ const nowTs = Date.now()
1572
1636
  for (const entry of entries) {
1573
- const info = classifyLineage(entry.header, entry.sizeBytes)
1574
- if (info) lineage[entry.id] = info
1637
+ const id = String(entry.id)
1638
+ const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
1639
+ const candidate = emptyScanCandidate(size)
1640
+ let verdict = null
1641
+ if (!candidate) {
1642
+ if (size !== null) verdict = false // 超出解码上限 = 必有内容(事实,不是猜测)
1643
+ } else {
1644
+ const mem = emptyScanCache.get(id)
1645
+ if (mem && mem.sizeBytes === size) {
1646
+ verdict = !!mem.empty
1647
+ } else {
1648
+ const rec = emptyStore && emptyStore[id]
1649
+ if (rec && rec.fingerprint === `sz:${size}` && nowTs - (rec.updatedAt || 0) <= EMPTY_VERDICT_TTL_MS) {
1650
+ verdict = rec.empty === 1
1651
+ emptyScanCache.set(id, { sizeBytes: size, empty: verdict })
1652
+ }
1653
+ }
1654
+ if (verdict === null && refineCapable() && !refineQueue.has(id)) refineWanted.push({ id, sizeBytes: size })
1655
+ }
1656
+ const info = classifyLineage(entry.header)
1657
+ if (info) lineage[id] = { origin: info.origin, parentSession: info.parentSession, delegationDepth: info.delegationDepth, empty: verdict }
1658
+ else if (verdict === true) lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: true }
1575
1659
  }
1576
- await refineEmptyLineage(lineage, entries)
1577
- return {
1660
+ enqueueRefine(refineWanted)
1661
+ // 缓存只保留「本轮仍是候选」的会话,防止 Map 随历史会话无限增长。
1662
+ for (const key of emptyScanCache.keys()) {
1663
+ const rec = emptyScanCache.get(key)
1664
+ if (!rec || !emptyScanCandidate(rec.sizeBytes)) emptyScanCache.delete(key)
1665
+ }
1666
+ const payload = {
1578
1667
  titles: Object.fromEntries(authorityTitleCache),
1579
1668
  trashedSessionIds: store.items.map((item) => String(item.sessionId)),
1580
1669
  purgedSessionIds: activeTombstones,
@@ -1582,77 +1671,93 @@ export function apply(ctx) {
1582
1671
  // v3.6.2:还有预热/退避重试在途 → client 缩短轮询节拍,预热一完成就把
1583
1672
  // 补齐的标题送回(含面板自动刷新)。无预热 API 时恒 false,绝不假忙。
1584
1673
  warmPending: warmPendingNow(),
1674
+ // T1:空白精判后台队列是否还有活。无精判能力(旧 runtime 无 inspect 通道)
1675
+ // 时恒 false——永久 busy 会让 client 疯轮询,这是唯一现实的假忙陷阱。
1676
+ refinePending: refinePendingNow(),
1585
1677
  }
1678
+ // T2:有未确认的排队移动终局才带出(空则省略字段——与「普通会话不产生条目」
1679
+ // 同风格的载荷最小化;旧 client 对未知字段天然忽略)。
1680
+ try {
1681
+ const noticeRows = await moveNotices.list()
1682
+ if (noticeRows.length) payload.moveNotices = noticeRows
1683
+ } catch (e) { /* 通知通道坏了不影响侧栏主数据 */ }
1684
+ return payload
1586
1685
  }
1587
1686
 
1588
- // 空会话判定缓存:id → { sizeBytes, empty }。日志没变(sizeBytes 相同)
1589
- // 就不重复解码——authority 会被侧栏高频轮询,缓存让它保持在近零成本。
1687
+ // 空会话判定缓存(进程内):id → { sizeBytes, empty }。日志没变(sizeBytes
1688
+ // 相同)就不重复解码;T1 之后它只是 emptyIndex 持久层之上的热缓存。
1590
1689
  const emptyScanCache = new Map()
1591
1690
 
1592
- // 0.1.3 空会话精判(事件类型法,见 lineage.js):体积阈值对新格式失效
1593
- // (头部扩容 + 恒存生命周期帧),改为对「压缩体积 ≤ EMPTY_DECODE_LIMIT」
1594
- // 的候选走官方 inspectSession 解码,按事件类型判定。超过上限的日志必有
1595
- // 内容,直接跳过(维持体积法/结构分类的结果)。解码失败按「非空」处理:
1596
- // 宁可漏判一个空会话,不能把有内容的会话错标成空。
1597
- // v3.6.2(分支标签延迟审计 #1 的速效半边):旧实现把全部 ≤8KB 候选**串行**
1598
- // 解码在 sidebar-state 的请求路径里——冷启动(进程内缓存全空)时零解码成本
1599
- // 的血缘数据被这串慢解码挡在 return 之前,这正是「⑂ 分支」chip 晚出/32s 台阶
1600
- // 的直接门控。现在:并行限流 4 路 + 每请求预算 REFINE_BUDGET 条(超预算的
1601
- // 小日志下一拍继续,逐拍收敛;已缓存的条目零成本照常应用,不影响正确性)。
1602
- // 彻底解绑(精判挪进后台队列)是 3.7.0 的响应契约改造,不在热修范围。
1603
- const REFINE_BUDGET = 40
1604
- function applyEmptyScan(lineage, id, empty) {
1605
- if (empty) {
1606
- // 结构分类没建条目的普通会话也要补上(0.1.3 空会话 header 无血缘字段,
1607
- // classifyLineage 对它们返回 null)。
1608
- const info = lineage[id] || (lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: false })
1609
- info.empty = true
1610
- } else if (lineage[id]) {
1611
- // 旧体积法在小日志上的假阳性(如 alpha.2 单事件日志)在这里纠正;
1612
- // 纠正后若无任何血缘结构,条目整个撤掉,维持「普通会话不产生条目」。
1613
- const info = lineage[id]
1614
- info.empty = false
1615
- if (!info.origin && !info.parentSession) delete lineage[id]
1616
- }
1691
+ // T1(3.7.0):空白精判彻底移出请求路径。v3.6.2 的「预算+限流」只是过渡——
1692
+ // 冷启动(缓存全空)时首拍仍要为精判解码,这正是「⑂ 分支」等零解码血缘被
1693
+ // 挡在 return 之前的最后一块捆绑。现在:sidebar-state 先回三态数据,未判定
1694
+ // 的候选进这条与 runWarm 同型的后台队列(REFINE_CHUNK=2、批间 setImmediate
1695
+ // 让步、成功结论落盘、失败只在进程内按「非空」兜底且**不落盘**——宁可漏判
1696
+ // 一个空白,不能把有内容的会话错标成空,也不能把失败固化成跨重启的结论)。
1697
+ const REFINE_CHUNK = 2
1698
+ const refineQueue = new Map()
1699
+ let refineRunning = false
1700
+ let refineKickTimer = null
1701
+
1702
+ function refineCapable() {
1703
+ return !!persistence && typeof persistence.inspectSession === 'function'
1617
1704
  }
1618
- async function refineEmptyLineage(lineage, entries) {
1619
- if (!persistence || typeof persistence.inspectSession !== 'function') return
1620
- const limiter = createLimiter(4)
1621
- const todo = []
1622
- for (const entry of entries) {
1623
- const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
1624
- if (size === null || size > EMPTY_DECODE_LIMIT) continue
1625
- const id = String(entry.id)
1626
- const scanned = emptyScanCache.get(id)
1627
- if (scanned && scanned.sizeBytes === size) { applyEmptyScan(lineage, id, scanned.empty); continue }
1628
- if (todo.length < REFINE_BUDGET) todo.push({ id, size })
1629
- }
1630
- await Promise.all(todo.map(({ id, size }) => limiter(async () => {
1631
- let isEmpty = false
1632
- try {
1633
- const types = []
1634
- await persistence.inspectSession(id, { onEvents: (batch) => {
1635
- for (const ev of batch || []) if (types.length < 64) types.push(ev && ev.type)
1636
- } })
1637
- isEmpty = isEmptyEventTypes(types)
1638
- } catch (e) { isEmpty = false }
1639
- emptyScanCache.set(id, { sizeBytes: size, empty: isEmpty })
1640
- applyEmptyScan(lineage, id, isEmpty)
1641
- })))
1642
- // 缓存只保留「本轮仍参与判定」的会话:已彻底删除或日志膨胀超过解码
1643
- // 上限的条目不再有用,淘汰掉防止 Map 随历史会话无限增长。
1644
- const live = new Set()
1645
- for (const entry of entries) {
1646
- const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
1647
- if (size !== null && size <= EMPTY_DECODE_LIMIT) live.add(String(entry.id))
1705
+
1706
+ function refinePendingNow() {
1707
+ return !!(refineRunning || refineQueue.size)
1708
+ }
1709
+
1710
+ function scheduleRefine() {
1711
+ if (refineKickTimer || refineRunning || !refineQueue.size) return
1712
+ refineKickTimer = setTimeout(() => { refineKickTimer = null; runRefine() }, 25)
1713
+ if (typeof refineKickTimer.unref === 'function') refineKickTimer.unref()
1714
+ }
1715
+
1716
+ function enqueueRefine(items) {
1717
+ if (!items || !items.length || !refineCapable()) return
1718
+ for (const { id, sizeBytes } of items) {
1719
+ if (emptyScanCache.has(id) && emptyScanCache.get(id).sizeBytes === sizeBytes) continue
1720
+ refineQueue.set(id, { sizeBytes })
1648
1721
  }
1649
- for (const key of emptyScanCache.keys()) {
1650
- if (!live.has(key)) emptyScanCache.delete(key)
1722
+ scheduleRefine()
1723
+ }
1724
+
1725
+ async function runRefine() {
1726
+ if (refineRunning) return
1727
+ refineRunning = true
1728
+ try {
1729
+ while (refineQueue.size) {
1730
+ const batch = [...refineQueue.entries()].slice(0, REFINE_CHUNK)
1731
+ for (const [id] of batch) refineQueue.delete(id)
1732
+ const persistBatch = {}
1733
+ for (const [id, desc] of batch) {
1734
+ try {
1735
+ const types = []
1736
+ await persistence.inspectSession(id, { onEvents: (b) => {
1737
+ for (const ev of b || []) if (types.length < 64) types.push(ev && ev.type)
1738
+ } })
1739
+ const isEmpty = isEmptyEventTypes(types)
1740
+ emptyScanCache.set(id, { sizeBytes: desc.sizeBytes, empty: isEmpty })
1741
+ persistBatch[id] = { empty: isEmpty ? 1 : 0, fingerprint: `sz:${desc.sizeBytes}`, updatedAt: Date.now() }
1742
+ } catch (e) {
1743
+ // 失败兜底:非空(止 churn),但只进内存,不落盘。
1744
+ emptyScanCache.set(id, { sizeBytes: desc.sizeBytes, empty: false })
1745
+ }
1746
+ }
1747
+ if (Object.keys(persistBatch).length) {
1748
+ try { await emptyIndex.merge(persistBatch) } catch (e) { /* 持久层只是加速器 */ }
1749
+ }
1750
+ // 批间让步:与 runWarm 同一纪律,绝不让后台精判独占宿主事件循环。
1751
+ await new Promise((resolve) => setImmediate(resolve))
1752
+ }
1753
+ } finally {
1754
+ refineRunning = false
1755
+ if (refineQueue.size) scheduleRefine()
1651
1756
  }
1652
1757
  }
1653
1758
 
1654
1759
  // 聚合一条会话的详情(磁盘占用 / 轮次·步数·消息数 / 工具统计 / fetch /
1655
- // write/edit 文件 / 血统 parent/children/subagents)。live 与持久化会话都可读。
1760
+ // write/edit 文件 / 血缘 parent/children/subagents)。live 与持久化会话都可读。
1656
1761
  // 所有统计对未知事件类型容错;fetch 与 files 做上限截断,files 用 stat 过滤
1657
1762
  // 磁盘上已不存在的路径,避免详情面板列出已删除文件。
1658
1763
  // 持久化会话走 inspectSession 分块折叠:大日志不再整本驻留内存(0.1.3-alpha
@@ -2004,6 +2109,7 @@ export function apply(ctx) {
2004
2109
  metaCache.invalidate(sid)
2005
2110
  // 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
2006
2111
  try { await titleIndex.remove([sid]) } catch (e) { /* 索引清理失败不阻塞删除结果 */ }
2112
+ try { await emptyIndex.remove([sid]) } catch (e) { /* 同上 */ }
2007
2113
  json(res, out)
2008
2114
  } catch (e) {
2009
2115
  json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
@@ -2021,7 +2127,7 @@ export function apply(ctx) {
2021
2127
  if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
2022
2128
  const results = []
2023
2129
  for (const sid of ids) {
2024
- try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); await titleIndex.remove([sid]).catch(() => {}) }
2130
+ try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); await titleIndex.remove([sid]).catch(() => {}); await emptyIndex.remove([sid]).catch(() => {}) }
2025
2131
  catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
2026
2132
  }
2027
2133
  json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
@@ -2037,7 +2143,10 @@ export function apply(ctx) {
2037
2143
  path: '/archived-sessions/sessions',
2038
2144
  handler: async (req, res) => {
2039
2145
  try {
2040
- json(res, { items: await allSessionItems(), warmPending: warmPendingNow() })
2146
+ // usage:true:updatedAt(=日志文件 mtime) 随行带出——3.7.0 分支聚拢的
2147
+ // createdAt 缺失回退键(logic.branchTimeKey)。collectUsage 本就在
2148
+ // allSessionItemsDetailed 内无条件执行,成本≈纯 payload 增加。
2149
+ json(res, { items: await allSessionItems({ usage: true }), warmPending: warmPendingNow() })
2041
2150
  } catch (e) {
2042
2151
  json(res, { error: String((e && e.message) || e) }, 500)
2043
2152
  }
@@ -2056,11 +2165,145 @@ export function apply(ctx) {
2056
2165
  if ((!ids || ids.length === 0) && body && typeof body.sessionId === 'string') {
2057
2166
  ids = isSafeSessionId(body.sessionId) ? [body.sessionId] : null
2058
2167
  }
2059
- if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionId' }, 400)
2168
+ if (!ids || ids.length === 0) return json(res, { ok: false, code: 'DSM_STAR_SESSION_INVALID', error: 'missing sessionId' }, 400)
2060
2169
  const starredSessionIds = await stars.setStarred(ids, starred)
2061
2170
  json(res, { ok: true, starredSessionIds })
2062
2171
  } catch (e) {
2063
- json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
2172
+ // 3.7.0:补 code 字段(响应形状向后兼容,只增字段)——新路由全部带 code,
2173
+ // 星标不应是例外。
2174
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2175
+ }
2176
+ },
2177
+ }))
2178
+
2179
+ // ---- Tags (标签, schema v4) — one route one action, same shape as star/set.
2180
+ // Pure user metadata: these routes never create/modify/delete a session.
2181
+ disposers.push(ctx.webServer.register({
2182
+ kind: 'exact',
2183
+ path: '/archived-sessions/tags/list',
2184
+ handler: async (req, res) => {
2185
+ try {
2186
+ const { tags: tagList, assignments } = await tags.list()
2187
+ json(res, { ok: true, tags: tagList, assignments })
2188
+ } catch (e) {
2189
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2190
+ }
2191
+ },
2192
+ }))
2193
+
2194
+ disposers.push(ctx.webServer.register({
2195
+ kind: 'exact',
2196
+ path: '/archived-sessions/tags/create',
2197
+ handler: async (req, res) => {
2198
+ try {
2199
+ const body = await readJsonBody(req)
2200
+ const tag = await tags.create(body && body.name)
2201
+ json(res, { ok: true, tag })
2202
+ } catch (e) {
2203
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2204
+ }
2205
+ },
2206
+ }))
2207
+
2208
+ disposers.push(ctx.webServer.register({
2209
+ kind: 'exact',
2210
+ path: '/archived-sessions/tags/rename',
2211
+ handler: async (req, res) => {
2212
+ try {
2213
+ const body = await readJsonBody(req)
2214
+ await tags.rename(body && body.id, body && body.name)
2215
+ json(res, { ok: true })
2216
+ } catch (e) {
2217
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2218
+ }
2219
+ },
2220
+ }))
2221
+
2222
+ disposers.push(ctx.webServer.register({
2223
+ kind: 'exact',
2224
+ path: '/archived-sessions/tags/merge',
2225
+ handler: async (req, res) => {
2226
+ try {
2227
+ const body = await readJsonBody(req)
2228
+ await tags.merge(body && body.fromId, body && body.toId)
2229
+ json(res, { ok: true })
2230
+ } catch (e) {
2231
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2232
+ }
2233
+ },
2234
+ }))
2235
+
2236
+ disposers.push(ctx.webServer.register({
2237
+ kind: 'exact',
2238
+ path: '/archived-sessions/tags/delete',
2239
+ handler: async (req, res) => {
2240
+ try {
2241
+ const body = await readJsonBody(req)
2242
+ await tags.removeTag(body && body.id)
2243
+ json(res, { ok: true })
2244
+ } catch (e) {
2245
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2246
+ }
2247
+ },
2248
+ }))
2249
+
2250
+ disposers.push(ctx.webServer.register({
2251
+ kind: 'exact',
2252
+ path: '/archived-sessions/tags/set',
2253
+ handler: async (req, res) => {
2254
+ try {
2255
+ const body = await readJsonBody(req)
2256
+ const sid = body && body.sessionId
2257
+ if (!isSafeSessionId(sid)) {
2258
+ return json(res, { ok: false, code: 'DSM_TAG_SESSION_INVALID', error: 'missing/invalid sessionId' }, 400)
2259
+ }
2260
+ const assignments = await tags.setTags(sid, body && body.tagIds)
2261
+ json(res, { ok: true, assignments })
2262
+ } catch (e) {
2263
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2264
+ }
2265
+ },
2266
+ }))
2267
+
2268
+ // ---- Saved filters (保存筛选, schema v1) --------------------------------
2269
+ disposers.push(ctx.webServer.register({
2270
+ kind: 'exact',
2271
+ path: '/archived-sessions/filters/list',
2272
+ handler: async (req, res) => {
2273
+ try {
2274
+ json(res, { ok: true, items: await savedFilters.list() })
2275
+ } catch (e) {
2276
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2277
+ }
2278
+ },
2279
+ }))
2280
+
2281
+ disposers.push(ctx.webServer.register({
2282
+ kind: 'exact',
2283
+ path: '/archived-sessions/filters/save',
2284
+ handler: async (req, res) => {
2285
+ try {
2286
+ const body = await readJsonBody(req)
2287
+ const item = await savedFilters.save(body && body.name, body && body.filters)
2288
+ json(res, { ok: true, item })
2289
+ } catch (e) {
2290
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2291
+ }
2292
+ },
2293
+ }))
2294
+
2295
+ disposers.push(ctx.webServer.register({
2296
+ kind: 'exact',
2297
+ path: '/archived-sessions/filters/delete',
2298
+ handler: async (req, res) => {
2299
+ try {
2300
+ const body = await readJsonBody(req)
2301
+ const ids = body && Array.isArray(body.ids) ? body.ids : null
2302
+ if (!ids || ids.length === 0) return json(res, { ok: false, code: 'DSM_FILTER_IDS_INVALID', error: 'missing ids' }, 400)
2303
+ const removed = await savedFilters.remove(ids)
2304
+ json(res, { ok: true, removed })
2305
+ } catch (e) {
2306
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2064
2307
  }
2065
2308
  },
2066
2309
  }))
@@ -2367,6 +2610,24 @@ export function apply(ctx) {
2367
2610
  },
2368
2611
  }))
2369
2612
 
2613
+ // T2:ack 已展示的排队移动通知(尽力清理;失败无害,client 端 localStorage
2614
+ // 已见集合负责多 tab 去重,7 天 TTL 兜底)。
2615
+ disposers.push(ctx.webServer.register({
2616
+ kind: 'exact',
2617
+ path: '/archived-sessions/pending-moves/notices/ack',
2618
+ handler: async (req, res) => {
2619
+ try {
2620
+ const body = await readJsonBody(req)
2621
+ const ids = body && Array.isArray(body.ids) ? body.ids : null
2622
+ if (!ids || !ids.length) return json(res, { ok: false, error: 'missing ids' }, 400)
2623
+ const removed = await moveNotices.ack(ids)
2624
+ json(res, { ok: true, removed })
2625
+ } catch (e) {
2626
+ json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
2627
+ }
2628
+ },
2629
+ }))
2630
+
2370
2631
  // Archive (hide) one session.
2371
2632
  disposers.push(ctx.webServer.register({
2372
2633
  kind: 'exact',
package/src/lineage.js CHANGED
@@ -8,13 +8,11 @@
8
8
  // - fork branch: header.parentSession set without the subagent origin
9
9
  // - empty: the log holds only the generation header
10
10
  //
11
- // The backend list snapshot carries NO eventCount, so the size test below is
12
- // only a cheap PRE-FILTER: since 0.1.3 it can no longer decide emptiness by
13
- // itself (bigger headers + a constant lifecycle-metadata frame push every
14
- // new log past the old baseline). The authoritative verdict comes from
15
- // refineEmptyLineage in index.js — an event-type scan (see below). Keep the
16
- // size test: it flags candidates cheaply and its false positives get
17
- // corrected by the precise pass.
11
+ // v3.7.0 (T1):体积法**不再对外发布任何空白结论**——它历史上就有假阳性
12
+ // (长 cwd 抬高阈值把有内容的小日志标空 → 侧栏误隐藏,误隐藏严格重于误闪现)。
13
+ // isEmptyLogSize 降级为纯参考实现(阈值单测仍锁着);空白与否现在只有三个来源:
14
+ // 解码精判的成功结论(true/false)、精判解码上限之外的日志(false = 必有内容)、
15
+ // 以及未知(null,交后台 refine 队列判定)。见 index.js refineEmpty.
18
16
 
19
17
  const EMPTY_BASE = 190
20
18
 
@@ -60,10 +58,11 @@ export function isEmptyEventTypes(types) {
60
58
  return true
61
59
  }
62
60
 
63
- // Returns a lineage record, or null when the session is an ordinary
64
- // top-level, non-empty session (the overwhelmingly common case — keeps the
65
- // sidebar-state payload small).
66
- export function classifyLineage(header, sizeBytes) {
61
+ // Structural lineage only (T1): a record is produced when the session is a
62
+ // subagent or a fork branch; `empty` is published as null (= not yet judged)
63
+ // and refined in the background. Ordinary top-level sessions return null —
64
+ // they only ever gain an entry once a real decode says empty:true.
65
+ export function classifyLineage(header) {
67
66
  if (!header || typeof header !== 'object') return null
68
67
  const origin = header.origin === 'subagent' ? 'subagent' : null
69
68
  const parentSession = typeof header.parentSession === 'string' && header.parentSession
@@ -72,7 +71,12 @@ export function classifyLineage(header, sizeBytes) {
72
71
  const delegationDepth = Number.isSafeInteger(header.delegationDepth) && header.delegationDepth > 0
73
72
  ? header.delegationDepth
74
73
  : 0
75
- const empty = isEmptyLogSize(sizeBytes, header.cwd)
76
- if (!origin && !parentSession && !empty) return null
77
- return { origin, parentSession, delegationDepth, empty }
74
+ if (!origin && !parentSession) return null
75
+ return { origin, parentSession, delegationDepth, empty: null }
76
+ }
77
+
78
+ // 空白精判候选:压缩体积在解码上限内的小日志。sizeBytes 缺失(legacy 列表
79
+ // 快照没有该观测)时返回 false —— 与历史行为一致:legacy 世代空白判定不生效。
80
+ export function emptyScanCandidate(sizeBytes) {
81
+ return Number.isFinite(sizeBytes) && sizeBytes <= EMPTY_DECODE_LIMIT
78
82
  }