dsh-sessions-manager 3.6.1 → 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
- import { createSessionMetaCache, persistFingerprintOf } from './session-meta-cache.js'
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.
@@ -135,20 +140,8 @@ function getActiveSessionId(context) {
135
140
  return null
136
141
  }
137
142
 
138
- function foldTitle(events) {
139
- let found = null
140
- let firstUser = null
141
- for (const ev of events) {
142
- if (ev.type === 'session/title' && ev.data && typeof ev.data.title === 'string' && ev.data.title.length) {
143
- found = ev.data.title
144
- }
145
- if (firstUser === null && ev.type === 'user/message' && ev.data && Array.isArray(ev.data.content)) {
146
- const txt = ev.data.content.filter((b) => b && b.type === 'text').map((b) => b.text).filter(Boolean).join(' ').trim()
147
- if (txt) firstUser = txt
148
- }
149
- }
150
- return found || firstUser || null
151
- }
143
+ // foldTitle(v3.6.1 删除路径的兜底折叠)随 v3.6.2 #7 的「删除路径零解码」一并
144
+ // 移除;标题折叠逻辑的权威实现现在是 runtime 投影(src/index.js projectTitlesStatus)。
152
145
 
153
146
  export function apply(ctx) {
154
147
  const w = ctx.workspaceRegistry
@@ -166,6 +159,10 @@ export function apply(ctx) {
166
159
  // 仍要全库解码。索引按同样的 (mtime, size) 指纹存解码结果,指纹没变的会话
167
160
  // 重启后也直接复用。见 src/title-persist-index.js。
168
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
169
166
 
170
167
  // P4:对「内存缓存未命中」的会话查持久索引,指纹一致才可信。
171
168
  // 返回 Map<id, meta>;调用方应把命中条目回填 metaCache 并从 missing 里剔除。
@@ -184,7 +181,10 @@ export function apply(ctx) {
184
181
  // 从不落盘,导致每次重启都全量重新解码)。
185
182
  const fp = persistFingerprintOf(stat)
186
183
  if (!fp) continue
187
- if (fp && entry.fingerprint === fp) {
184
+ // v3.6.2 #1:title 为 null 的条目必须是「投影确实成功过」(resolved)才可信。
185
+ // v3.6.1 把投影失败当结果落盘的污染条目(null 标题、无标记)在这里被丢弃,
186
+ // 调用方按未命中处理 → 重新入队预热,索引自愈。
187
+ if (entry.fingerprint === fp && (entry.title || entry.resolved)) {
188
188
  hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
189
189
  }
190
190
  }
@@ -197,14 +197,18 @@ export function apply(ctx) {
197
197
  // 且"响应已返回但索引没落盘"时进程若恰好退出就会丢条目。
198
198
  // 指纹用 persistFingerprintOf:legacy 文件指纹 + handle 世代 sz(sizeBytes) 指纹
199
199
  // 都可跨重启;rev: 指纹仍被排除(实例内 opaque token,issue #8)。
200
+ // v3.6.2 #1:decoded 的值是 { meta, resolved } —— resolved=false(投影失败)
201
+ // 的条目绝不落盘;null 标题只有 resolved=true 才允许(否则读取端会当污染丢弃)。
200
202
  async function persistDecoded(decoded, statsById) {
201
203
  if (!decoded || !decoded.size) return
202
204
  const batch = {}
203
205
  const now = Date.now()
204
- for (const [id, meta] of decoded) {
206
+ for (const [id, pack] of decoded) {
207
+ if (!pack || !pack.resolved) continue
208
+ const meta = pack.meta
205
209
  const fp = persistFingerprintOf(statsById.get(id))
206
210
  if (!fp) continue
207
- batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
211
+ batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now, resolved: 1 }
208
212
  }
209
213
  if (!Object.keys(batch).length) return
210
214
  try { await titleIndex.merge(batch) } catch (e) { /* 索引写失败不影响本次响应 */ }
@@ -276,8 +280,9 @@ export function apply(ctx) {
276
280
  workspaceGone: !!(cwd && !ws),
277
281
  hasWorkspace: !!cwd,
278
282
  }
279
- // sizeBytes / updatedAt 只在需要的路由(存储分析 / 自动归档)里带上:
280
- // 它们本就来自 usage,附带输出对列表渲染无益。
283
+ // sizeBytes / updatedAt 只在需要的路由里带出(存储分析 / 自动归档 / 会话
284
+ // 列表——3.7.0 起列表也要:分支组时间回退键读 updatedAt)。它们本就来自
285
+ // usage,只是 payload 体积,不引入任何解码。
281
286
  if (exposeUsage && usage) {
282
287
  if (usage.sizeById && usage.sizeById.has(key)) base.sizeBytes = usage.sizeById.get(key)
283
288
  if (usage.mtimeById && usage.mtimeById.has(key)) base.updatedAt = usage.mtimeById.get(key)
@@ -308,6 +313,73 @@ export function apply(ctx) {
308
313
  let warmRunning = false
309
314
  let warmKickTimer = null
310
315
 
316
+ // v3.6.2 #1:投影失败的三态处理(issue #8 审计)。旧实现把「投影失败」和
317
+ // 「会话真没标题」混为同一个 null → 失败被写进缓存/持久索引,跨重启固化成
318
+ // 永久占位且永不重试。现在:
319
+ // - resolved(fulfilled,title 可以是 null = 事实无标题)→ 正常入缓存+落盘;
320
+ // - failed(rejected / 批量异常 / 结果缺条)→ 不缓存、不落盘,按 id 计数退避。
321
+ // 退避:15s×2^n 递增,最多 WARM_MAX_ATTEMPTS 次;此后 parked 一小时后再探一次
322
+ // (坏日志长期停摆时不至于永久静默)。日志指纹任何变化都清零计数重新武装。
323
+ const WARM_MAX_ATTEMPTS = 5
324
+ const warmFail = new Map() // id → { count, fp, nextAt }
325
+
326
+ function warmFpOf(stat) {
327
+ return fingerprintOf(stat) || persistFingerprintOf(stat)
328
+ }
329
+
330
+ function warmBlocked(id, fp) {
331
+ const rec = warmFail.get(id)
332
+ if (!rec) return 0
333
+ if (fp && rec.fp && rec.fp !== fp) { warmFail.delete(id); return 0 } // 内容变了 → 重新武装
334
+ const now = Date.now()
335
+ return rec.nextAt > now ? rec.nextAt - now : 0
336
+ }
337
+
338
+ function warmMarkFailure(id, stat, header) {
339
+ const rec = warmFail.get(id) || { count: 0, fp: null, nextAt: 0 }
340
+ rec.count += 1
341
+ rec.fp = warmFpOf(stat) || rec.fp
342
+ // 退避到点后由 scheduleWarmRetry 原样重新入队(含本轮的 stat/header 快照)。
343
+ if (stat) rec.stat = stat
344
+ if (header) rec.header = header
345
+ const backoff = rec.count >= WARM_MAX_ATTEMPTS
346
+ ? 60 * 60 * 1000
347
+ : Math.min(5 * 60 * 1000, 15 * 1000 * Math.pow(2, rec.count))
348
+ rec.nextAt = Date.now() + backoff
349
+ warmFail.set(id, rec)
350
+ }
351
+
352
+ // 队列里还有「未放弃」的活 → 侧栏/面板应缩短轮询节拍(client 消费)。
353
+ function warmPendingNow() {
354
+ if (!warmHasApi) return false
355
+ if (warmRunning || warmQueue.size) return true
356
+ for (const rec of warmFail.values()) if (rec.nextAt > Date.now()) return true
357
+ return false
358
+ }
359
+
360
+ // 退避到点的条目由一个 unref 定时器重新入队(每轮只挂最近的一个)。
361
+ let warmRetryTimer = null
362
+ function scheduleWarmRetry() {
363
+ if (warmRetryTimer) return
364
+ let soonest = 0
365
+ for (const rec of warmFail.values()) {
366
+ const wait = rec.nextAt - Date.now()
367
+ if (wait <= 0) { soonest = 1; break }
368
+ if (!soonest || wait < soonest) soonest = wait
369
+ }
370
+ if (!soonest) return
371
+ warmRetryTimer = setTimeout(() => {
372
+ warmRetryTimer = null
373
+ // 到点的条目重新入队(enqueue 时会重新校验 blocked/fp)。
374
+ const now = Date.now()
375
+ for (const [id, rec] of warmFail) {
376
+ if (rec.nextAt <= now && !warmQueue.has(id)) warmQueue.set(id, { stat: rec.stat || null, header: rec.header || null })
377
+ }
378
+ if (warmQueue.size) scheduleWarm()
379
+ }, soonest)
380
+ if (typeof warmRetryTimer.unref === 'function') warmRetryTimer.unref()
381
+ }
382
+
311
383
  function scheduleWarm() {
312
384
  if (warmKickTimer || warmRunning) return
313
385
  warmKickTimer = setTimeout(() => { warmKickTimer = null; runWarm() }, 25)
@@ -317,15 +389,50 @@ export function apply(ctx) {
317
389
 
318
390
  function enqueueWarm(ids, statsById, entryById) {
319
391
  if (!ids || !ids.length) return
392
+ // 无投影 API 的 runtime:预热注定无果,不入队(占位行为退回「列表不带标题」
393
+ // 的诚实形态,而不是让退避定时器永远转下去)。
394
+ if (!warmHasApi) return
320
395
  for (const id of ids) {
321
396
  const key = String(id)
397
+ const stat = statsById ? (statsById.get(key) || null) : null
398
+ // #9:任何指纹都算不出来的会话(live 未落盘等)既进不了内存缓存也进不了
399
+ // 持久索引——投影结果注定无处安放,每轮轮询重复入队只是白烧 CPU(churn)。
400
+ // 跳过;这类行的标题由 DSH 自己的 live 渲染保证。
401
+ const fp = warmFpOf(stat)
402
+ if (!fp) continue
403
+ // #1:仍在失败退避窗口内的条目不重复入队(runWarm 会按 blocked 跳过)。
404
+ const blockedMs = warmBlocked(key, fp)
322
405
  const entry = entryById ? entryById.get(key) : null
406
+ const rec = warmFail.get(key)
407
+ if (rec) { rec.stat = stat || rec.stat; if (entry) rec.header = entry.header || rec.header }
408
+ if (blockedMs > 0) continue
323
409
  warmQueue.set(key, {
324
- stat: statsById ? (statsById.get(key) || null) : null,
410
+ stat,
325
411
  header: (entry && entry.header) || null,
326
412
  })
327
413
  }
328
- scheduleWarm()
414
+ if (warmQueue.size) scheduleWarm()
415
+ scheduleWarmRetry()
416
+ }
417
+
418
+ // #7:软删除时标题可能还没预热出来(回收站条目落一条 title:null 的诚实记录),
419
+ // 之后任何一轮 warm 解出该会话标题就补写进回收站索引——只补空标题,
420
+ // 绝不覆盖已有值。回收站渲染端(t.title || t.sessionId)随下次拉取自然更新。
421
+ async function backfillTrashTitles(decoded) {
422
+ if (!decoded || !decoded.size) return
423
+ try {
424
+ const items = await readTrash()
425
+ const worth = items.some((t) => !t.title && decoded.get(String(t.sessionId)) && decoded.get(String(t.sessionId)).meta && decoded.get(String(t.sessionId)).meta.title)
426
+ if (!worth) return
427
+ await mutateTrash((store) => {
428
+ for (const item of store.items) {
429
+ if (item.title) continue
430
+ const pack = decoded.get(String(item.sessionId))
431
+ const t = pack && pack.meta && pack.meta.title
432
+ if (t) item.title = String(t)
433
+ }
434
+ })
435
+ } catch (e) { /* 补写失败无害:索引只是加速器 */ }
329
436
  }
330
437
 
331
438
  async function runWarm() {
@@ -337,12 +444,18 @@ export function apply(ctx) {
337
444
  for (const [id] of batch) warmQueue.delete(id)
338
445
  try {
339
446
  const ids = batch.map(([id]) => id)
340
- const snapshotById = await projectTitles(ids)
447
+ const resultById = await projectTitlesStatus(ids)
341
448
  const decoded = new Map()
342
449
  const statsById = new Map()
343
450
  for (const [id, desc] of batch) {
344
- const snapshot = snapshotById.has(id) ? snapshotById.get(id) : null
345
- const meta = metaFromSnapshot(snapshot)
451
+ const r = resultById.get(id)
452
+ if (!r || !r.resolved) {
453
+ // 投影失败:绝不写缓存/持久索引(否则失败被固化为永久占位)。
454
+ warmMarkFailure(id, desc.stat, desc.header)
455
+ continue
456
+ }
457
+ warmFail.delete(id)
458
+ const meta = metaFromSnapshot(r.snapshot || null)
346
459
  // cwd/createdAt 永远以最新 list header 为权威(投影的 session 头可能
347
460
  // 陈旧;移动会话后尤其如此),投影只负责标题。
348
461
  if (desc.header) {
@@ -351,9 +464,11 @@ export function apply(ctx) {
351
464
  }
352
465
  metaCache.set(id, desc.stat, meta)
353
466
  statsById.set(id, desc.stat)
354
- if (desc.stat) decoded.set(id, meta)
467
+ decoded.set(id, { meta, resolved: true })
355
468
  }
356
469
  await persistDecoded(decoded, statsById)
470
+ // #7:回收站条目删除时可能没有标题(软删除只记录);预热解出来后补写。
471
+ await backfillTrashTitles(decoded)
357
472
  } catch (e) { /* 单批失败不影响后续批次:索引/缓存只是加速器 */ }
358
473
  // 批间让步:保证宿主事件循环(RPC/心跳/其他插件)始终可调度。
359
474
  await new Promise((resolve) => setImmediate(resolve))
@@ -362,6 +477,7 @@ export function apply(ctx) {
362
477
  warmRunning = false
363
478
  // 预热期间又有新请求入队 → 再排一轮。
364
479
  if (warmQueue.size) scheduleWarm()
480
+ scheduleWarmRetry()
365
481
  }
366
482
  }
367
483
 
@@ -467,9 +583,18 @@ export function apply(ctx) {
467
583
  // when the session is really gone (purge, or externally removed; the latter
468
584
  // is caught by gcStars during list builds).
469
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()
470
592
  // 待移动队列:会话被占用(DSH 打开中)时登记,等它释放后自动完成移动。
471
593
  // 见 src/pending-moves.js 的模块注释(0.1.5 单写者锁没有公共释放 API)。
472
594
  const pendingMoves = createPendingMoveStore()
595
+ // T2:排队移动的后台终局(完成/放弃)落成持久通知,sidebar-state 带出、
596
+ // client 弹出后 ack 清除。独立于队列文件(终局与队列项同生死,挂条目上观察不到)。
597
+ const moveNotices = createMoveNoticeStore()
473
598
  // Auto-archive settings live in their own schema-v4 store, off by default.
474
599
  const autoArchive = createAutoArchiveStore()
475
600
  // 启动清扫:进程在「写临时文件 → rename」之间被杀会留下 .<name>-<pid>-<ts>.tmp
@@ -485,6 +610,17 @@ export function apply(ctx) {
485
610
  } catch (e) { /* best-effort */ }
486
611
  }
487
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
+
488
624
  // Soft-delete one session: record it in the recycle-bin index but KEEP its
489
625
  // log in the original workspace directory. Moving the file out (and detaching
490
626
  // it from the workspace) orphaned the session into DSH's "未分组" group and
@@ -514,25 +650,21 @@ export function apply(ctx) {
514
650
  title = header.title || (header.meta && header.meta.title) || null
515
651
  }
516
652
  if (!title) {
517
- // 标题兜底优先走单会话标题投影(快照级,不读日志);投影也没有时才
518
- // 分块解码日志折叠标题(inspectSession 分块,不整本驻留内存)。
519
- if (typeof sq.readTitleSnapshot === 'function') {
520
- try {
521
- const snap = unwrapSnapshot(await sq.readTitleSnapshot(sid))
522
- if (snap && snap.title && snap.title.title) title = String(snap.title.title)
523
- if (snap && snap.session) { if (!cwd) cwd = snap.session.cwd || null }
524
- } catch (e) { /* fall through */ }
525
- }
526
- }
527
- if (!title) {
653
+ // v3.6.2 #7:删除路径**绝不**做标题投影/分块解码——issue #8 的教训:
654
+ // 大日志上单条兜底投影也是整本解压,删除 = 秒级卡宿主。只消费现成缓存:
655
+ // metaCache(用 list 快照算指纹)→ 持久索引 → 侧栏权威缓存。都没有就存
656
+ // null(诚实占位,不再把 cwd/裸 id 冒充标题永久写进回收站),
657
+ // 后台预热解出标题后由 backfillTrashTitles 补写。
528
658
  try {
529
- let folded = null
530
- const summary = await persistence.inspectSession(sid, {
531
- onEvents: (events) => { if (!folded) folded = foldTitle(events) },
532
- })
533
- if (summary && summary.meta && !cwd) cwd = summary.meta.cwd || null
534
- title = folded
659
+ const delStat = persistenceEntry && typeof persistenceEntry.revision === 'string' && persistenceEntry.revision
660
+ ? { revision: persistenceEntry.revision, sizeBytes: Number.isFinite(persistenceEntry.sizeBytes) ? Number(persistenceEntry.sizeBytes) : undefined }
661
+ : (removedPath ? await stat(removedPath).then((st) => ({ mtimeMs: st.mtimeMs, size: st.size })) : null)
662
+ if (delStat) {
663
+ const m = metaCache.get(sid, delStat) || (await hydrateFromPersist([sid], new Map([[sid, delStat]]))).get(sid)
664
+ if (m && m.title) title = String(m.title)
665
+ }
535
666
  } catch (e) { /* best-effort */ }
667
+ if (!title) { const at = authorityTitleCache.get(sid); if (at) title = String(at) }
536
668
  }
537
669
  } catch (e) { /* best-effort */ }
538
670
  // Record in the trash index only — the log stays in its workspace dir.
@@ -544,7 +676,9 @@ export function apply(ctx) {
544
676
  const archived = await mutateArchived((list) => ({ next: null, value: list.includes(sid) })).catch(() => false)
545
677
  await mutateTrash((store) => {
546
678
  const entry = {
547
- sessionId: sid, title: title || cwd || sid, cwd: cwd || null,
679
+ // v3.6.2 #7:不再把 cwd/裸 id 冒充标题永久定格——存 null,预热解出后
680
+ // backfillTrashTitles 补写;渲染端(t.title || t.sessionId)自然降级。
681
+ sessionId: sid, title: title || null, cwd: cwd || null,
548
682
  header: header || null, originalPath: removedPath || null,
549
683
  sizeBytes: persistenceEntry && Number.isFinite(persistenceEntry.sizeBytes) ? persistenceEntry.sizeBytes : null,
550
684
  wasArchived: archived, deletedAt: Date.now(),
@@ -763,6 +897,10 @@ export function apply(ctx) {
763
897
  // 状态目录清理 → `ENOTEMPTY: rmdir .../state`(全量测试里约 1/4 概率偶发失败)。
764
898
  // 而且"删除成功"不该早于状态落盘:进程恰好在此刻退出就会留下指向已删会话的星标。
765
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) { /* 标签清理失败不阻塞删除结果 */ }
766
904
  return { ok: true, purged: true }
767
905
  }
768
906
 
@@ -1115,11 +1253,17 @@ export function apply(ctx) {
1115
1253
  metaCache.invalidate(item.sessionId)
1116
1254
  moved++
1117
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 */ }
1118
1259
  } catch (e) {
1119
1260
  if (isBusyError(e)) continue // 仍被占用:留在队列里,等下一次触发
1120
1261
  if (Date.now() - bootedAt < BOOT_WARMUP_MS) continue // boot 期未就绪:不记失败
1121
1262
  const bumped = await pendingMoves.bumpAttempts(item.sessionId)
1122
- 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
+ }
1123
1267
  }
1124
1268
  }
1125
1269
  if (moved > 0) { try { await reindexRegistry() } catch (e) { /* best-effort */ } }
@@ -1210,24 +1354,53 @@ export function apply(ctx) {
1210
1354
  })
1211
1355
  }
1212
1356
 
1213
- // 批量投影:一次调用把多条会话的标题/header 拿出来,避免逐条触发整本解码。
1214
- // 老 runtime 没有 readTitleSnapshots 时返回空 Map,调用方自然回退到逐条投影
1215
- // (功能不受影响,只是少了这层优化——插件不能假设对方的 runtime 版本)。
1216
- async function projectTitles(ids) {
1357
+ // 批量投影(v3.6.2 #1 三态版)。返回 Map<id, { resolved, snapshot|null }>:
1358
+ // resolved=true —— fulfilled 快照(title 可能为 null:会话真的没标题,
1359
+ // 这是事实,可以缓存/落盘);
1360
+ // resolved=false —— rejected / 批量异常 / 结果缺条 / 无投影 API。
1361
+ // 调用方(runWarm)只缓存 resolved 的条目;失败的进退避重试,绝不落盘——
1362
+ // 否则「一次失败」被固化为跨重启的永久占位(v3.6.1 审计 #1)。
1363
+ const warmHasApi = typeof sq.readTitleSnapshots === 'function' || typeof sq.readTitleSnapshot === 'function'
1364
+
1365
+ async function projectTitlesStatus(ids) {
1217
1366
  const out = new Map()
1367
+ const markAll = (resolved) => { for (const id of ids) out.set(String(id), { resolved, snapshot: null }) }
1218
1368
  if (!ids || !ids.length) return out
1219
- if (typeof sq.readTitleSnapshots !== 'function') return out
1220
- try {
1221
- const results = await sq.readTitleSnapshots(ids)
1222
- if (!Array.isArray(results)) return out
1223
- results.forEach((result, index) => {
1224
- const id = String(ids[index])
1225
- out.set(id, unwrapSnapshot(result))
1226
- })
1227
- } catch (e) { /* 批量失败:逐条回退 */ }
1369
+ if (!warmHasApi) { markAll(false); return out }
1370
+ if (typeof sq.readTitleSnapshots === 'function') {
1371
+ let results = null
1372
+ try { results = await sq.readTitleSnapshots(ids) } catch (e) { results = null }
1373
+ if (Array.isArray(results)) {
1374
+ results.forEach((result, index) => {
1375
+ const id = String(ids[index])
1376
+ if (result && result.status === 'fulfilled') out.set(id, { resolved: true, snapshot: unwrapSnapshot(result) })
1377
+ else if (result && result.status === 'rejected') out.set(id, { resolved: false, snapshot: null })
1378
+ else if (result) out.set(id, { resolved: true, snapshot: unwrapSnapshot(result) }) // 最老形态:直接是快照
1379
+ else out.set(id, { resolved: false, snapshot: null })
1380
+ })
1381
+ // 结果数组短于请求(runtime 行为异常)→ 缺的条目按失败处理。
1382
+ for (const id of ids) if (!out.has(String(id))) out.set(String(id), { resolved: false, snapshot: null })
1383
+ return out
1384
+ }
1385
+ // 批量形态不认识:退回逐条。
1386
+ }
1387
+ if (typeof sq.readTitleSnapshot === 'function') {
1388
+ for (const raw of ids) {
1389
+ const id = String(raw)
1390
+ try {
1391
+ const r = await sq.readTitleSnapshot(id)
1392
+ if (r && r.status === 'rejected') out.set(id, { resolved: false, snapshot: null })
1393
+ else out.set(id, { resolved: true, snapshot: unwrapSnapshot(r) })
1394
+ } catch (e) { out.set(id, { resolved: false, snapshot: null }) }
1395
+ }
1396
+ return out
1397
+ }
1398
+ markAll(false)
1228
1399
  return out
1229
1400
  }
1230
1401
 
1402
+ // 兼容旧调用点(暂无):保留名字不导出,避免误用回「失败即 null」的旧语义。
1403
+
1231
1404
  // opts.usage: expose sizeBytes + updatedAt on each item (storage analysis and
1232
1405
  // the auto-archive sweep need them; the panel list does not).
1233
1406
  //
@@ -1319,12 +1492,28 @@ export function apply(ctx) {
1319
1492
  }
1320
1493
  return { items: archivedItems, usage }
1321
1494
  }
1322
- // 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
1323
1496
  // failing sp.list() can never wipe the whole index.
1324
1497
  let starredSet = new Set()
1325
- try { starredSet = new Set((await stars.read()).starredSessionIds) } catch (e) {}
1326
- 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
+ }
1327
1515
  if (headersOk) await gcStars(ids)
1516
+ if (headersOk) await gcTags(ids)
1328
1517
  return { items, usage }
1329
1518
  }
1330
1519
 
@@ -1432,78 +1621,143 @@ export function apply(ctx) {
1432
1621
  if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
1433
1622
  }
1434
1623
  }
1435
- // 血缘分类(issue #6):结构分类(子代理 / fork 分支)来自 list snapshot
1436
- // 的 header,零解码;空白判定见下方 refineEmptyLineage(0.1.3 起体积法
1437
- // 失效,改为事件类型精判)。普通顶层非空会话不产生条目,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
+ // 「误隐藏」在结构上不可能来自体积法(该路径已整体退役)。
1438
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()
1439
1636
  for (const entry of entries) {
1440
- const info = classifyLineage(entry.header, entry.sizeBytes)
1441
- 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 }
1442
1659
  }
1443
- await refineEmptyLineage(lineage, entries)
1444
- 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 = {
1445
1667
  titles: Object.fromEntries(authorityTitleCache),
1446
1668
  trashedSessionIds: store.items.map((item) => String(item.sessionId)),
1447
1669
  purgedSessionIds: activeTombstones,
1448
1670
  lineage,
1671
+ // v3.6.2:还有预热/退避重试在途 → client 缩短轮询节拍,预热一完成就把
1672
+ // 补齐的标题送回(含面板自动刷新)。无预热 API 时恒 false,绝不假忙。
1673
+ warmPending: warmPendingNow(),
1674
+ // T1:空白精判后台队列是否还有活。无精判能力(旧 runtime 无 inspect 通道)
1675
+ // 时恒 false——永久 busy 会让 client 疯轮询,这是唯一现实的假忙陷阱。
1676
+ refinePending: refinePendingNow(),
1449
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
1450
1685
  }
1451
1686
 
1452
- // 空会话判定缓存:id → { sizeBytes, empty }。日志没变(sizeBytes 相同)
1453
- // 就不重复解码——authority 会被侧栏高频轮询,缓存让它保持在近零成本。
1687
+ // 空会话判定缓存(进程内):id → { sizeBytes, empty }。日志没变(sizeBytes
1688
+ // 相同)就不重复解码;T1 之后它只是 emptyIndex 持久层之上的热缓存。
1454
1689
  const emptyScanCache = new Map()
1455
1690
 
1456
- // 0.1.3 空会话精判(事件类型法,见 lineage.js):体积阈值对新格式失效
1457
- // (头部扩容 + 恒存生命周期帧),改为对「压缩体积 ≤ EMPTY_DECODE_LIMIT」
1458
- // 的候选走官方 inspectSession 解码,按事件类型判定。超过上限的日志必有
1459
- // 内容,直接跳过(维持体积法/结构分类的结果)。解码失败按「非空」处理:
1460
- // 宁可漏判一个空会话,不能把有内容的会话错标成空。
1461
- async function refineEmptyLineage(lineage, entries) {
1462
- if (!persistence || typeof persistence.inspectSession !== 'function') return
1463
- for (const entry of entries) {
1464
- const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
1465
- if (size === null || size > EMPTY_DECODE_LIMIT) continue
1466
- const id = String(entry.id)
1467
- let scanned = emptyScanCache.get(id)
1468
- if (!scanned || scanned.sizeBytes !== size) {
1469
- let isEmpty = false
1470
- try {
1471
- const types = []
1472
- await persistence.inspectSession(id, { onEvents: (batch) => {
1473
- for (const ev of batch || []) if (types.length < 64) types.push(ev && ev.type)
1474
- } })
1475
- isEmpty = isEmptyEventTypes(types)
1476
- } catch (e) { isEmpty = false }
1477
- scanned = { sizeBytes: size, empty: isEmpty }
1478
- emptyScanCache.set(id, scanned)
1479
- }
1480
- if (scanned.empty) {
1481
- // 结构分类没建条目的普通会话也要补上(0.1.3 空会话 header 无血缘字段,
1482
- // classifyLineage 对它们返回 null)。
1483
- const info = lineage[id] || (lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: false })
1484
- info.empty = true
1485
- } else if (lineage[id]) {
1486
- // 旧体积法在小日志上的假阳性(如 alpha.2 单事件日志)在这里纠正;
1487
- // 纠正后若无任何血缘结构,条目整个撤掉,维持「普通会话不产生条目」。
1488
- const info = lineage[id]
1489
- info.empty = false
1490
- if (!info.origin && !info.parentSession) delete lineage[id]
1491
- }
1492
- }
1493
- // 缓存只保留「本轮仍参与判定」的会话:已彻底删除或日志膨胀超过解码
1494
- // 上限的条目不再有用,淘汰掉防止 Map 随历史会话无限增长。
1495
- const live = new Set()
1496
- for (const entry of entries) {
1497
- const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
1498
- if (size !== null && size <= EMPTY_DECODE_LIMIT) live.add(String(entry.id))
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'
1704
+ }
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 })
1499
1721
  }
1500
- for (const key of emptyScanCache.keys()) {
1501
- 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()
1502
1756
  }
1503
1757
  }
1504
1758
 
1505
1759
  // 聚合一条会话的详情(磁盘占用 / 轮次·步数·消息数 / 工具统计 / fetch /
1506
- // write/edit 文件 / 血统 parent/children/subagents)。live 与持久化会话都可读。
1760
+ // write/edit 文件 / 血缘 parent/children/subagents)。live 与持久化会话都可读。
1507
1761
  // 所有统计对未知事件类型容错;fetch 与 files 做上限截断,files 用 stat 过滤
1508
1762
  // 磁盘上已不存在的路径,避免详情面板列出已删除文件。
1509
1763
  // 持久化会话走 inspectSession 分块折叠:大日志不再整本驻留内存(0.1.3-alpha
@@ -1855,6 +2109,7 @@ export function apply(ctx) {
1855
2109
  metaCache.invalidate(sid)
1856
2110
  // 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
1857
2111
  try { await titleIndex.remove([sid]) } catch (e) { /* 索引清理失败不阻塞删除结果 */ }
2112
+ try { await emptyIndex.remove([sid]) } catch (e) { /* 同上 */ }
1858
2113
  json(res, out)
1859
2114
  } catch (e) {
1860
2115
  json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
@@ -1872,7 +2127,7 @@ export function apply(ctx) {
1872
2127
  if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
1873
2128
  const results = []
1874
2129
  for (const sid of ids) {
1875
- 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(() => {}) }
1876
2131
  catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
1877
2132
  }
1878
2133
  json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
@@ -1888,7 +2143,10 @@ export function apply(ctx) {
1888
2143
  path: '/archived-sessions/sessions',
1889
2144
  handler: async (req, res) => {
1890
2145
  try {
1891
- json(res, { items: await allSessionItems() })
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() })
1892
2150
  } catch (e) {
1893
2151
  json(res, { error: String((e && e.message) || e) }, 500)
1894
2152
  }
@@ -1907,11 +2165,145 @@ export function apply(ctx) {
1907
2165
  if ((!ids || ids.length === 0) && body && typeof body.sessionId === 'string') {
1908
2166
  ids = isSafeSessionId(body.sessionId) ? [body.sessionId] : null
1909
2167
  }
1910
- 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)
1911
2169
  const starredSessionIds = await stars.setStarred(ids, starred)
1912
2170
  json(res, { ok: true, starredSessionIds })
1913
2171
  } catch (e) {
1914
- 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))
1915
2307
  }
1916
2308
  },
1917
2309
  }))
@@ -1975,7 +2367,8 @@ export function apply(ctx) {
1975
2367
  // 子代理血缘树(管理视图数据面):给定父会话,返回 origin==='subagent'
1976
2368
  // 的递归子树。DSH 原生目录(ui-subagent)只读,这里补管理能力的数据来源。
1977
2369
  // 只读官方 header 血缘字段(parentSession/origin/delegationDepth/createdAt),
1978
- // 标题走既有批量投影(readTitleSnapshots),零整本日志解码。
2370
+ // 标题走 v3.6.2 的纯缓存读取(metaCache/持久索引/权威缓存),未命中入后台
2371
+ // 预热、本轮返回 null;请求路径零解码。
1979
2372
  // live 标记 = 会话在内存里活着(正在运行或已打开):批量清理必须跳过。
1980
2373
  disposers.push(ctx.webServer.register({
1981
2374
  kind: 'exact',
@@ -2011,9 +2404,29 @@ export function apply(ctx) {
2011
2404
  })
2012
2405
  if (Number.isFinite(sizeBytes)) sizeById.set(id, sizeBytes)
2013
2406
  }
2407
+ // v3.6.2 #4:标题只读缓存(metaCache → 持久索引 → 权威缓存),未命中
2408
+ // 入后台预热、本轮返回 null。旧实现在请求路径直调 projectTitles——
2409
+ // 展开一个 N 子树的父会话 = N 次整本解码同步在请求路径上(issue #8
2410
+ // 要消灭的形态在本路由的残留)。这里顺手记下指纹与原始 header,供
2411
+ // 缓存读取与 enqueueWarm 使用。
2412
+ const lineageStats = new Map()
2413
+ const lineageHeaders = new Map()
2014
2414
  try {
2015
2415
  for (const entry of await persistence.listEntries()) {
2016
2416
  addHeader(entry.header, entry.sizeBytes)
2417
+ const eid = entry && entry.id != null ? String(entry.id) : null
2418
+ if (eid) {
2419
+ lineageHeaders.set(eid, { header: (entry && entry.header) || null })
2420
+ // 只有 handle 世代(snapshot 带 revision)才建 stats;legacy 的列表快照
2421
+ // 本就没有这些观测,其标题经 sidebarAuthority 的 authorityTitleCache
2422
+ // 兜住。刻意不在这里合成 sz-only stat——会和列表构建的 mtime:size
2423
+ // 指纹在同一索引里乒乓覆盖,引发交替重复解码。
2424
+ if (entry && typeof entry.revision === 'string' && entry.revision) {
2425
+ lineageStats.set(eid, Number.isFinite(entry.sizeBytes)
2426
+ ? { revision: entry.revision, sizeBytes: Number(entry.sizeBytes) }
2427
+ : { revision: entry.revision })
2428
+ }
2429
+ }
2017
2430
  if (entry && entry.id != null) persistedIds.add(String(entry.id))
2018
2431
  }
2019
2432
  } catch (e) { /* best-effort */ }
@@ -2061,11 +2474,19 @@ export function apply(ctx) {
2061
2474
  const allIds = [rootId]
2062
2475
  const walk = (n) => { allIds.push(n.sessionId); n.children.forEach(walk) }
2063
2476
  nodes.forEach(walk)
2064
- const titles = await projectTitles(allIds)
2477
+ // v3.6.2 #4:缓存读取 + 预热入队,请求路径零解码。#12:标题按
2478
+ // MAX_TITLE 截断,与列表输出口径一致。
2479
+ const lineageIds = allIds.map(String)
2480
+ const { cached: lineageCached, missing: lineageMissing } = metaCache.partition(lineageIds, lineageStats)
2481
+ const lineagePersisted = await hydrateFromPersist(lineageMissing, lineageStats)
2482
+ for (const [id, meta] of lineagePersisted) metaCache.set(id, lineageStats.get(id), meta)
2483
+ const lineageRest = lineageMissing.filter((id) => !lineagePersisted.has(id))
2484
+ if (lineageRest.length) enqueueWarm(lineageRest, lineageStats, lineageHeaders)
2485
+ const truncateTitle = (t) => (t ? (t.length > MAX_TITLE ? t.slice(0, MAX_TITLE) + '…' : t) : null)
2065
2486
  const titleOf = (id) => {
2066
- const snap = titles.get(id)
2067
- const m = snap ? metaFromSnapshot(snap) : null
2068
- return (m && m.title) || null
2487
+ const key = String(id)
2488
+ const m = lineageCached.get(key) || lineagePersisted.get(key)
2489
+ return truncateTitle((m && m.title) || authorityTitleCache.get(key) || null)
2069
2490
  }
2070
2491
  const fill = (n) => { n.title = titleOf(n.sessionId); n.children.forEach(fill) }
2071
2492
  nodes.forEach(fill)
@@ -2189,6 +2610,24 @@ export function apply(ctx) {
2189
2610
  },
2190
2611
  }))
2191
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
+
2192
2631
  // Archive (hide) one session.
2193
2632
  disposers.push(ctx.webServer.register({
2194
2633
  kind: 'exact',