dsh-sessions-manager 3.5.4 → 3.6.1

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
@@ -7,27 +7,38 @@
7
7
  // (workspaceRegistry + storageDomain), folds titles/dates/workspace tags from
8
8
  // session persistence, physically removes a session's log file on delete, and
9
9
  // relocates a conversation (session) between workspaces on move.
10
- import { mkdir, readFile, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
10
+ import { mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
11
11
  import { basename, dirname, isAbsolute, join } from 'node:path'
12
12
  import { readFileSync } from 'node:fs'
13
13
  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 { createPendingMoveStore } from './pending-moves.js'
18
+ import { sweepStaleStateTemps } from './state-temp-sweep.js'
17
19
  import { aggregateStorage } from './storage-stats.js'
18
20
  import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
19
- import { createSessionMetaCache, fingerprintOf, isPersistableFingerprint } from './session-meta-cache.js'
21
+ import { createSessionMetaCache, persistFingerprintOf } from './session-meta-cache.js'
20
22
  import { createTitleIndexStore } from './title-persist-index.js'
21
23
  import { createPersistenceAdapter } from './compat/persistence.js'
22
24
  import { detectCapabilities, requireCapability } from './compat/capabilities.js'
23
25
  import { pathOwnsSession } from './path-guard.js'
24
26
  import { purgeSessionArtifacts, moveSessionToCwd } from './handle-era-ops.js'
27
+ import { classifyLineage, isEmptyEventTypes, EMPTY_DECODE_LIMIT } from './lineage.js'
28
+
29
+ // 构建指纹:build.mjs 以 define 在编译期注入;直接以源码运行(测试)时为 'dev'。
30
+ // 用途:capabilities 带出 buildStamp,一眼判断「运行中的 host 是不是这份构建」——
31
+ // 插件 host 端只在宿主启动时加载一次,改完 lib 不重启宿主等于白改。
32
+ const BUILD_STAMP = typeof __BUILD_STAMP__ !== 'undefined' ? __BUILD_STAMP__ : 'dev'
25
33
 
26
34
 
27
35
  export const name = 'dsh-sessions-manager'
28
36
  export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
29
37
 
30
38
  const MAX_TITLE = 80
39
+ // 插件自有状态目录(星标 / 自动归档 / 标题索引共用)。与 star-index.js、auto-archive.js
40
+ // 的默认目录保持一致,环境变量同名,便于测试注入。
41
+ const STATE_DIR = process.env.DSH_SESSIONS_MANAGER_STAR_DIR || join(homedir(), '.dsh', 'sessions-manager')
31
42
  // Recycle bin (回收站): normal deletes land here instead of being erased.
32
43
  const TRASH_DIR = process.env.DSH_SESSIONS_MANAGER_TRASH_DIR || join(homedir(), '.dsh', 'sessions-manager-trash')
33
44
  const TRASH_INDEX = join(TRASH_DIR, 'index.json')
@@ -168,8 +179,11 @@ export function apply(ctx) {
168
179
  const stat = statsById.get(id)
169
180
  const entry = store && store[id]
170
181
  if (!stat || !entry) continue
171
- const fp = fingerprintOf(stat)
172
- if (!isPersistableFingerprint(fp)) continue
182
+ // 持久索引只认可跨重启的指纹(legacy 的文件指纹 / handle 世代的 sz 指纹,
183
+ // 见 session-meta-cache.persistFingerprintOf——issue #8 之前 handle 世代
184
+ // 从不落盘,导致每次重启都全量重新解码)。
185
+ const fp = persistFingerprintOf(stat)
186
+ if (!fp) continue
173
187
  if (fp && entry.fingerprint === fp) {
174
188
  hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
175
189
  }
@@ -177,21 +191,23 @@ export function apply(ctx) {
177
191
  return hits
178
192
  }
179
193
 
180
- // 把本批真正解码出的元数据异步回写持久索引(fire-and-forget:索引只是
181
- // 加速器,写失败不影响响应,队列内部已串行化 + 原子替换)。
182
- // ⚠️ revision 指纹(SessionHandle 世代)绝不落盘:它只在当前 service
183
- // 实例内有意义,跨进程比较无意义,误用会把陈旧数据当新鲜数据。
184
- function persistDecoded(decoded, statsById) {
194
+ // 把本批真正解码出的元数据回写持久索引(索引只是加速器:写失败不影响响应,
195
+ // 队列内部已串行化 + 原子替换)。**必须 await** —— 它是持久写入,浮动写入会在
196
+ // 调用方/测试 teardown 已开始清理状态目录时落地(`ENOTEMPTY: rmdir .../state`),
197
+ // 且"响应已返回但索引没落盘"时进程若恰好退出就会丢条目。
198
+ // 指纹用 persistFingerprintOf:legacy 文件指纹 + handle 世代 sz(sizeBytes) 指纹
199
+ // 都可跨重启;rev: 指纹仍被排除(实例内 opaque token,issue #8)。
200
+ async function persistDecoded(decoded, statsById) {
185
201
  if (!decoded || !decoded.size) return
186
202
  const batch = {}
187
203
  const now = Date.now()
188
204
  for (const [id, meta] of decoded) {
189
- const fp = fingerprintOf(statsById.get(id))
190
- if (!isPersistableFingerprint(fp)) continue
205
+ const fp = persistFingerprintOf(statsById.get(id))
206
+ if (!fp) continue
191
207
  batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
192
208
  }
193
209
  if (!Object.keys(batch).length) return
194
- titleIndex.merge(batch).catch(() => {})
210
+ try { await titleIndex.merge(batch) } catch (e) { /* 索引写失败不影响本次响应 */ }
195
211
  }
196
212
 
197
213
  // 从投影快照里抽出元数据;快照缺失/异常时返回零值 meta(调用方决定兜底)。
@@ -271,65 +287,82 @@ export function apply(ctx) {
271
287
 
272
288
  // Resolve one session's display metadata.
273
289
  //
274
- // 成本模型(issue #1):下面的解码路径会把整本 .jsonl.zstd 逐帧解压、逐行
275
- // JSON.parse,只为折叠出标题——大库上一次全表要几秒阻塞式 CPU。日志内容没变
276
- // 就意味着折叠结果不可能变(legacy 用 (mtime, size) 文件指纹;SessionHandle
277
- // 世代用官方 snapshot.revision),命中即直接复用,跳过整本解码。
290
+ // 成本模型(issue #1 → issue #8):runtime 的标题投影(readTitleSnapshots)
291
+ // 在 0.1.x 上对每条非 live 会话都是**整本日志解码**(SessionCorpus.projectMany
292
+ // → inspectPersisted)。列表构建绝不能在请求路径上同步触发它——issue #8 实测:
293
+ // 87 本日志 / 249MB 的库,一次冷启动全量投影把宿主打满 ~90s CPU。
278
294
  //
279
- // 0.1.3-alpha 兼容(避免放大官方已知的历史会话加载性能回退):
280
- // - cwd/createdAt 优先来自 list() 快照的 snapshot.header;
281
- // - 标题优先来自批量 readTitleSnapshots;
282
- // - **标题缺失绝不单独触发整本日志解码**——无标题就显示「(无标题)」。
283
- // 只有在拿不到 cwd(工作区归属失效)或 runtime 完全没有标题投影能力时
284
- // 才回退到日志解码,且该解码走 inspectSession 分块折叠,不做整本驻留。
285
- async function resolveOne(id, usage, opts = {}) {
286
- const key = String(id)
287
- const statInfo = usage ? (usage.statsById ? usage.statsById.get(key) : null)
288
- || { mtimeMs: usage.mtimeById && usage.mtimeById.get(key), size: usage.sizeById && usage.sizeById.get(key) } : null
289
- const cached = metaCache.get(key, statInfo)
290
- if (cached) return buildItem(key, cached, usage, opts.exposeUsage)
291
-
292
- let meta = { title: null, cwd: null, createdAt: null }
293
- // 第一来源:list() 返回的 SessionPersistenceSnapshot.header(0.1.3+ 官方
294
- // 契约里 header 携带 cwd/createdAt,无需任何日志读取)。
295
- if (opts.listHeader) {
296
- if (typeof opts.listHeader.cwd === 'string') meta.cwd = opts.listHeader.cwd
297
- if (opts.listHeader.createdAt != null) meta.createdAt = opts.listHeader.createdAt
298
- }
299
- if (opts.preloaded !== undefined) {
300
- const projected = metaFromSnapshot(unwrapSnapshot(opts.preloaded))
301
- if (projected.title) meta.title = projected.title
302
- if (!meta.cwd && projected.cwd) meta.cwd = projected.cwd
303
- if (!meta.createdAt && projected.createdAt) meta.createdAt = projected.createdAt
304
- } else if (typeof sq.readTitleSnapshot === 'function') {
305
- try {
306
- const projected = metaFromSnapshot(await sq.readTitleSnapshot(id))
307
- if (projected.title) meta.title = projected.title
308
- if (!meta.cwd && projected.cwd) meta.cwd = projected.cwd
309
- if (!meta.createdAt && projected.createdAt) meta.createdAt = projected.createdAt
310
- } catch (e) { /* fall through */ }
295
+ // 因此请求路径只消费两级缓存(metaCache 内存缓存 → titleIndex 持久索引),
296
+ // 缺失的会话交给后台预热队列分批补齐(见 enqueueWarm/runWarm);侧栏高频
297
+ // 轮询 + 管理面板刷新会让补齐结果自然浮现。
298
+ const EMPTY_META = { title: null, cwd: null, createdAt: null }
299
+
300
+ // —— 冷启动后台预热(issue #8)—————————————————————————————————————
301
+ // 单飞队列:请求路径发现缓存未命中的会话,把 { stat, header } 快照丢进
302
+ // warmQueue,由 runWarm 以小批次消费。每批之间 setImmediate 让步事件循环,
303
+ // 绝不独占宿主主线程;结果写 metaCache + 持久标题索引。stat 是入队时刻的
304
+ // 快照——若日志在预热期间又变了,下次请求的指纹比对会再次判未命中并重新
305
+ // 入队,天然自愈。
306
+ const WARM_CHUNK = 4
307
+ const warmQueue = new Map()
308
+ let warmRunning = false
309
+ let warmKickTimer = null
310
+
311
+ function scheduleWarm() {
312
+ if (warmKickTimer || warmRunning) return
313
+ warmKickTimer = setTimeout(() => { warmKickTimer = null; runWarm() }, 25)
314
+ // unref:预热绝不反过来把宿主进程钉住(与 autoArchive「无定时器」同一原则)。
315
+ if (typeof warmKickTimer.unref === 'function') warmKickTimer.unref()
316
+ }
317
+
318
+ function enqueueWarm(ids, statsById, entryById) {
319
+ if (!ids || !ids.length) return
320
+ for (const id of ids) {
321
+ const key = String(id)
322
+ const entry = entryById ? entryById.get(key) : null
323
+ warmQueue.set(key, {
324
+ stat: statsById ? (statsById.get(key) || null) : null,
325
+ header: (entry && entry.header) || null,
326
+ })
311
327
  }
312
- // cwd 缺失 → 工作区归属失效,值得一次解码兜底(cwd 在 header 里,通常
313
- // 快照已带回,这里只在快照缺 cwd 时发生)。runtime 完全没有标题投影能力
314
- // 时(老后端无 readTitleSnapshot),解码同时兜底标题。
315
- const projectionAvailable = typeof sq.readTitleSnapshot === 'function' || typeof sq.readTitleSnapshots === 'function'
316
- if (!meta.cwd || (!meta.title && !projectionAvailable)) {
317
- try {
318
- let foldedTitle = null
319
- const summary = await persistence.inspectSession(key, {
320
- onEvents: (events) => { if (!foldedTitle) foldedTitle = foldTitle(events) },
321
- })
322
- if (summary && summary.meta) {
323
- if (!meta.cwd) meta.cwd = summary.meta.cwd || null
324
- if (!meta.createdAt) meta.createdAt = summary.meta.createdAt || null
325
- }
326
- if (!meta.title && foldedTitle) meta.title = foldedTitle
327
- } catch (e2) { /* keep what we have */ }
328
+ scheduleWarm()
329
+ }
330
+
331
+ async function runWarm() {
332
+ if (warmRunning) return
333
+ warmRunning = true
334
+ try {
335
+ while (warmQueue.size) {
336
+ const batch = [...warmQueue.entries()].slice(0, WARM_CHUNK)
337
+ for (const [id] of batch) warmQueue.delete(id)
338
+ try {
339
+ const ids = batch.map(([id]) => id)
340
+ const snapshotById = await projectTitles(ids)
341
+ const decoded = new Map()
342
+ const statsById = new Map()
343
+ for (const [id, desc] of batch) {
344
+ const snapshot = snapshotById.has(id) ? snapshotById.get(id) : null
345
+ const meta = metaFromSnapshot(snapshot)
346
+ // cwd/createdAt 永远以最新 list header 为权威(投影的 session 头可能
347
+ // 陈旧;移动会话后尤其如此),投影只负责标题。
348
+ if (desc.header) {
349
+ if (typeof desc.header.cwd === 'string') meta.cwd = desc.header.cwd
350
+ if (desc.header.createdAt != null) meta.createdAt = desc.header.createdAt
351
+ }
352
+ metaCache.set(id, desc.stat, meta)
353
+ statsById.set(id, desc.stat)
354
+ if (desc.stat) decoded.set(id, meta)
355
+ }
356
+ await persistDecoded(decoded, statsById)
357
+ } catch (e) { /* 单批失败不影响后续批次:索引/缓存只是加速器 */ }
358
+ // 批间让步:保证宿主事件循环(RPC/心跳/其他插件)始终可调度。
359
+ await new Promise((resolve) => setImmediate(resolve))
360
+ }
361
+ } finally {
362
+ warmRunning = false
363
+ // 预热期间又有新请求入队 → 再排一轮。
364
+ if (warmQueue.size) scheduleWarm()
328
365
  }
329
- metaCache.set(key, statInfo, meta)
330
- // 本条是「真解码」出来的:交给调用方回写持久标题索引(P4 冷启动加速)。
331
- if (opts.collectDecoded && statInfo) opts.collectDecoded(key, meta)
332
- return buildItem(key, meta, usage, opts.exposeUsage)
333
366
  }
334
367
 
335
368
  // Disk usage + last-write time for every session, in one pass. Also produces
@@ -360,10 +393,13 @@ export function apply(ctx) {
360
393
  const id = entry && entry.id != null ? String(entry.id) : (header && header.id != null ? String(header.id) : null)
361
394
  if (!id) return
362
395
  // SessionHandle 世代:snapshot(header/revision/sizeBytes)是权威轻量
363
- // 观察,绝不再绕道私有磁盘路径补 stat。
396
+ // 观察,绝不再绕道私有磁盘路径补 stat。revision 进内存缓存指纹;
397
+ // sizeBytes 供 persistFingerprintOf 派生跨重启的 sz 持久指纹(issue #8)。
364
398
  if (entry && typeof entry.revision === 'string' && entry.revision) {
365
399
  if (Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
366
- statsById.set(id, { revision: entry.revision })
400
+ statsById.set(id, Number.isFinite(entry.sizeBytes)
401
+ ? { revision: entry.revision, sizeBytes: Number(entry.sizeBytes) }
402
+ : { revision: entry.revision })
367
403
  return
368
404
  }
369
405
  if (entry && Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
@@ -431,8 +467,14 @@ export function apply(ctx) {
431
467
  // when the session is really gone (purge, or externally removed; the latter
432
468
  // is caught by gcStars during list builds).
433
469
  const stars = createStarIndex()
470
+ // 待移动队列:会话被占用(DSH 打开中)时登记,等它释放后自动完成移动。
471
+ // 见 src/pending-moves.js 的模块注释(0.1.5 单写者锁没有公共释放 API)。
472
+ const pendingMoves = createPendingMoveStore()
434
473
  // Auto-archive settings live in their own schema-v4 store, off by default.
435
474
  const autoArchive = createAutoArchiveStore()
475
+ // 启动清扫:进程在「写临时文件 → rename」之间被杀会留下 .<name>-<pid>-<ts>.tmp
476
+ // 孤儿(实测一次累积 153 个)。这里把 1 小时前的旧临时文件收掉,不碰正在写的。
477
+ sweepStaleStateTemps([STATE_DIR, TRASH_DIR], {}).catch(() => {})
436
478
 
437
479
  async function gcStars(validIds) {
438
480
  try {
@@ -716,7 +758,11 @@ export function apply(ctx) {
716
758
  purged = true
717
759
  })
718
760
  if (!purged) throw new Error('彻底删除失败')
719
- stars.removeIds([sid]).catch(() => {})
761
+ // 星标清理必须 await。原先这里是浮动写入(`stars.removeIds([sid]).catch(...)`):
762
+ // 删除结果先返回、星标文件随后才落地,正好会撞上调用方(或测试 teardown)已开始的
763
+ // 状态目录清理 → `ENOTEMPTY: rmdir .../state`(全量测试里约 1/4 概率偶发失败)。
764
+ // 而且"删除成功"不该早于状态落盘:进程恰好在此刻退出就会留下指向已删会话的星标。
765
+ try { await stars.removeIds([sid]) } catch (e) { /* 星标清理失败不阻塞删除结果 */ }
720
766
  return { ok: true, purged: true }
721
767
  }
722
768
 
@@ -778,7 +824,10 @@ export function apply(ctx) {
778
824
  // crash-safe (backup + rollback) and re-syncs the live object.
779
825
  const activeId = getActiveSessionId(ctx)
780
826
  if (activeId != null && String(activeId) === String(sid)) {
781
- throw new Error('该会话当前处于打开状态,请先切换到别的会话再移动。')
827
+ const error = new Error('会话正被 DSH 打开,暂时无法移动;请重启 DSH 后再试。')
828
+ error.status = 409
829
+ error.code = 'DSM_SESSION_BUSY'
830
+ throw error
782
831
  }
783
832
  const r = await persistence.readSession(sid, 0)
784
833
  if (!r || !r.meta) throw new Error('无法读取该会话的日志')
@@ -798,6 +847,10 @@ export function apply(ctx) {
798
847
 
799
848
  const newHeader = Object.assign({}, meta, { cwd: canonical })
800
849
 
850
+ // 移动过程的非致命提示(如源目录仍有旧代日志未能清理),随成功结果一并
851
+ // 回报给客户端展示。
852
+ const moveNotes = []
853
+
801
854
  // 1) Decide relocation strategy. `sessionPersistence.create()` rejects
802
855
  // ("already exists in this backend") for ANY session the host has
803
856
  // instantiated into its in-memory `states` — and DSH instantiates *every*
@@ -850,6 +903,8 @@ export function apply(ctx) {
850
903
  await rename(stagedPath, newPath)
851
904
  destinationInstalled = true
852
905
  await unlink(backupPath)
906
+ const keptInSource = await cleanupMovedSourceDir(dirname(oldPath))
907
+ if (keptInSource.length > 0) moveNotes.push(`源目录未能清理干净(残留 ${keptInSource.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
853
908
  } catch (e) {
854
909
  try { await unlink(stagedPath) } catch (_) {}
855
910
  if (destinationInstalled) { try { await unlink(newPath) } catch (_) {} }
@@ -873,6 +928,17 @@ export function apply(ctx) {
873
928
  return null
874
929
  }
875
930
 
931
+ // 移动成功后的源目录收尾(legacy 路径):**整体移除**。目标侧已写入校验通过的
932
+ // 权威日志,源目录只要还留着任何一代日志(legacy 旧代 session.jsonl.zstd 或
933
+ // session.vN.jsonl.zstd),就会让同 id 出现在两个项目目录,后端 list/create
934
+ // 立刻报 duplicate。返回未能清理时的残留文件名(供如实回报)。
935
+ const cleanupMovedSourceDir = async (dir) => {
936
+ let entries = null
937
+ try { entries = await readdir(dir) } catch (e) { return [] }
938
+ try { await rm(dir, { recursive: true, force: true }) } catch (e) { return entries }
939
+ return []
940
+ }
941
+
876
942
  // Rewriting frame0's cwd now lives in src/zstd-frame.js so it can be
877
943
  // regression-tested directly. See that module for why frame boundaries are
878
944
  // validated by decompression and why a non-session frame0 is rejected
@@ -890,7 +956,13 @@ export function apply(ctx) {
890
956
  }
891
957
  }
892
958
  try {
893
- await moveSessionToCwd({ sp, sid, header: meta, canonical, events, inheritedEventCount: r.inheritedEventCount })
959
+ const movedHandle = await moveSessionToCwd({ sp, sid, header: meta, canonical, events, inheritedEventCount: r.inheritedEventCount })
960
+ if (movedHandle && Array.isArray(movedHandle.reclaimedSiblings) && movedHandle.reclaimedSiblings.length > 0) {
961
+ moveNotes.push(`已顺带回收 ${movedHandle.reclaimedSiblings.length} 处被取代的旧代日志副本(历史移动残留),避免同一会话在多目录重复。`)
962
+ }
963
+ if (movedHandle && movedHandle.sourceCleanup && movedHandle.sourceCleanup.cleaned === false) {
964
+ moveNotes.push(`源目录未能清理干净(残留 ${movedHandle.sourceCleanup.leftover.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
965
+ }
894
966
  } catch (e) {
895
967
  if (e && e.status) throw e
896
968
  throw new Error('移动会话日志失败:' + String((e && e.message) || e))
@@ -937,6 +1009,10 @@ export function apply(ctx) {
937
1009
  throw new Error('移动后校验失败:会话工作目录未正确更新')
938
1010
  }
939
1011
  if (backupPath) { try { await unlink(backupPath) } catch (e) {} }
1012
+ if (oldPath) {
1013
+ const keptInSource = await cleanupMovedSourceDir(dirname(oldPath))
1014
+ if (keptInSource.length > 0) moveNotes.push(`源目录未能清理干净(残留 ${keptInSource.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
1015
+ }
940
1016
  } catch (e) {
941
1017
  if (ALREADY_EXISTS_RE.test(String((e && e.message) || e))) {
942
1018
  // Collision: the session is already materialized in states (archived
@@ -990,9 +1066,109 @@ export function apply(ctx) {
990
1066
  workspaceId: target.id,
991
1067
  workspaceTitle: target.title,
992
1068
  workspacePath: canonical,
1069
+ ...(moveNotes.length > 0 ? { notes: moveNotes } : {}),
993
1070
  }
994
1071
  }
995
1072
 
1073
+ // ---- 待移动队列(活跃 / 被占用的会话)-----------------------------------
1074
+ // 0.1.5 的单写者锁由持有写句柄的进程掌控,插件既拿不到句柄也没有释放它的公共
1075
+ // API;在占用期硬搬目录会让在写者继续写旧 inode 并在旧路径重建空壳。因此占用时
1076
+ // 只登记排队,等宿主释放(session/disposed)或下次插件
1077
+ // 启动时(会话尚未被打开)自动完成。
1078
+ const QUEUE_NOTE = '已排队:该会话正被 DSH 打开(写所有权只在 DSH 退出时释放)。重启 DSH 时会自动完成,请先别打开它。'
1079
+
1080
+ function isBusyError(e) {
1081
+ if (!e) return false
1082
+ if (e.code === 'DSM_SESSION_BUSY') return true
1083
+ const text = String((e && e.message) || e)
1084
+ return /正被 DSH 打开|already owned/i.test(text)
1085
+ }
1086
+
1087
+ // 移动入口:占用 → 排队;其它错误照旧抛出。返回成功结果或排队结果。
1088
+ async function moveOrQueue(sid, targetPath) {
1089
+ try {
1090
+ return await moveOne(sid, targetPath)
1091
+ } catch (e) {
1092
+ if (!isBusyError(e)) throw e
1093
+ await pendingMoves.queue(sid, targetPath)
1094
+ return { ok: true, moved: false, queued: true, notes: [QUEUE_NOTE] }
1095
+ }
1096
+ }
1097
+
1098
+ // 启动后的「学步期」:这段时间内 moveOne 的非占用类失败只当环境未就绪,不计入
1099
+ // 「多次失败即放弃」的计数(否则 boot 竞态会把用户的排队项白白丢掉)。
1100
+ const BOOT_WARMUP_MS = 30000
1101
+ const bootedAt = Date.now()
1102
+
1103
+ let queueRunning = false
1104
+ async function runPendingMoves(reason) {
1105
+ if (queueRunning) return { ran: false, moved: 0, kept: 0 }
1106
+ queueRunning = true
1107
+ let moved = 0
1108
+ const notes = []
1109
+ try {
1110
+ const items = await pendingMoves.list()
1111
+ for (const item of items) {
1112
+ try {
1113
+ await moveOne(item.sessionId, item.targetPath)
1114
+ await pendingMoves.remove([item.sessionId])
1115
+ metaCache.invalidate(item.sessionId)
1116
+ moved++
1117
+ notes.push(`排队中的移动已完成:${String(item.sessionId).slice(0, 18)}…`)
1118
+ } catch (e) {
1119
+ if (isBusyError(e)) continue // 仍被占用:留在队列里,等下一次触发
1120
+ if (Date.now() - bootedAt < BOOT_WARMUP_MS) continue // boot 期未就绪:不记失败
1121
+ 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)})`)
1123
+ }
1124
+ }
1125
+ if (moved > 0) { try { await reindexRegistry() } catch (e) { /* best-effort */ } }
1126
+ } finally {
1127
+ queueRunning = false
1128
+ }
1129
+ if (notes.length > 0) {
1130
+ try { for (const n of notes) console.warn('[dsh-sessions-manager] ' + n) } catch (e) { /* ignore */ }
1131
+ }
1132
+ void reason
1133
+ return { ran: true, moved, kept: (await pendingMoves.list()).length }
1134
+ }
1135
+
1136
+ // 排队项的补跑时机。**关键前提**:0.1.5 的单写者所有权一经 open 就持有到**进程退出**
1137
+ // 为止——官方没有任何「关闭会话 / 释放所有权」的公开入口(`dsh-commands` 只有
1138
+ // compact/feedback/goal;宿主无 unloadSession/closeSession/evict),而浏览器一连上就会
1139
+ // 自动打开/新建会话,一旦被打开就再也搬不动。所以唯一可靠窗口是**宿主刚起来、浏览器
1140
+ // 还没连上**的那一两秒 ⇒ 启动后立刻试,并在前 30s 内密集重试;之后每 120s 兜底;
1141
+ // 会话真被释放(session/disposed)时 500ms 后再试一次。
1142
+ const queueEffect = typeof ctx.effect === 'function' ? ctx.effect.bind(ctx) : ((fn) => { fn() })
1143
+ if (typeof ctx.on === 'function') {
1144
+ queueEffect(() => ctx.on('session/disposed', (session) => {
1145
+ const id = session && session.id != null ? String(session.id) : null
1146
+ if (!id) return
1147
+ pendingMoves.has(id).then((queued) => {
1148
+ if (!queued) return
1149
+ const t = setTimeout(() => { runPendingMoves('session-disposed').catch(() => {}) }, 500)
1150
+ if (t && typeof t.unref === 'function') t.unref()
1151
+ }).catch(() => {})
1152
+ }))
1153
+ }
1154
+ queueEffect(() => {
1155
+ // 每个启动补跑定时器都要 unref:宿主自己不会闲下来无所谓,但测试里未 unref 的
1156
+ // 30s 定时器会让 node:test 多等 30s 才退出(整套测试被无谓拖长,实测见报告)。
1157
+ const timers = [0, 1000, 3000, 6000, 12000, 30000].map((ms) => {
1158
+ const t = setTimeout(() => {
1159
+ runPendingMoves(ms === 0 ? 'startup' : `startup+${ms}`).catch(() => {})
1160
+ }, ms)
1161
+ if (t && typeof t.unref === 'function') t.unref()
1162
+ return t
1163
+ })
1164
+ // 轻量周期重试:只在队列非空时干活(队列空 = 一次本地 JSON 读取)。
1165
+ const tickTimer = setInterval(() => {
1166
+ pendingMoves.list().then((items) => { if (items.length > 0) return runPendingMoves('tick') }).catch(() => {})
1167
+ }, 120000)
1168
+ if (tickTimer && typeof tickTimer.unref === 'function') tickTimer.unref()
1169
+ return () => { for (const t of timers) clearTimeout(t); clearInterval(tickTimer) }
1170
+ })
1171
+
996
1172
  // Force the host's WorkspaceRegistry to rebuild its in-memory sessionPath
997
1173
  // index from the durable persistence headers. DSH's WorkspaceEntity.sessionIds
998
1174
  // is a *getter* that filters record.sessionIds by `host.sessionPath(id) ===
@@ -1059,7 +1235,8 @@ export function apply(ctx) {
1059
1235
  // 1. sp.list() 只调一次(原先列了两遍目录)
1060
1236
  // 2. 变更令牌优先来自 list 快照:legacy 走 locate+stat,SessionHandle 世代
1061
1237
  // 直接用 snapshot.revision(无 locate 可用,也绝不绕私有路径补 stat)
1062
- // 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
1238
+ // 3. 缓存未命中的会话**绝不在请求路径上同步投影**(issue #8:runtime 的
1239
+ // readTitleSnapshots 每条都是整本解码)——入队后台预热,先以占位元数据返回
1063
1240
  async function allSessionItemsDetailed(opts = {}) {
1064
1241
  let entries = []
1065
1242
  let headersOk = false
@@ -1089,37 +1266,59 @@ export function apply(ctx) {
1089
1266
  wsByPath = {}
1090
1267
  try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
1091
1268
  const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
1092
- const items = []
1093
1269
  const usage = await collectUsage(entries)
1094
- // 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
1270
+ // 先按指纹把「缓存命中」与「需要补齐」分开。
1095
1271
  const statsById = new Map(visibleIds.map((id) => [
1096
1272
  id,
1097
1273
  (usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
1098
1274
  ]))
1099
- const { missing } = metaCache.partition(visibleIds, statsById)
1275
+ const { cached, missing } = metaCache.partition(visibleIds, statsById)
1100
1276
  // P4:missing 里先查持久标题索引(冷启动跳过整本解码),命中的回填内存缓存。
1277
+ // cwd/createdAt 回填时以最新 list header 为权威——持久条目里的 cwd 可能因
1278
+ // 移动会话而陈旧(sz 指纹对 cwd 无感知),标题才是索引的产出物。
1101
1279
  const persisted = await hydrateFromPersist(missing, statsById)
1102
- for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
1280
+ for (const [id, meta] of persisted) {
1281
+ const entry = entryById.get(id)
1282
+ const header = entry && entry.header ? entry.header : null
1283
+ metaCache.set(id, statsById.get(id), {
1284
+ title: meta.title,
1285
+ cwd: (header && typeof header.cwd === 'string' && header.cwd) ? header.cwd : meta.cwd,
1286
+ createdAt: (header && header.createdAt != null) ? header.createdAt : meta.createdAt,
1287
+ })
1288
+ }
1103
1289
  const stillMissing = missing.filter((id) => !persisted.has(id))
1104
- const snapshotById = await projectTitles(stillMissing)
1105
- const decoded = new Map()
1106
- const collectDecoded = (id, meta) => { decoded.set(id, meta) }
1107
- const CHUNK = 6
1108
- for (let i = 0; i < visibleIds.length; i += CHUNK) {
1109
- // Arrow wrapper on purpose: Array#map passes (value, index, array), and
1110
- // resolveOne's second and third arguments are fixed here.
1111
- const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => {
1290
+ // issue #8:缺失部分交后台预热(分批 + 让步),请求路径零投影、零日志解码。
1291
+ if (stillMissing.length) enqueueWarm(stillMissing, statsById, entryById)
1292
+ const items = []
1293
+ for (const id of visibleIds) {
1294
+ // metaCache 优先:persisted 命中已带 header 权威 cwd/createdAt 回填进
1295
+ // metaCache(persisted.get 里的 cwd 可能因移动会话而陈旧)。
1296
+ // 完全未命中的会话也不能丢 cwd/createdAt——它们来自 list header,零成本
1297
+ // 且是工作区归属/创建时间的权威来源;只有标题允许占位等后台预热(issue #8)。
1298
+ let meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || null
1299
+ if (!meta) {
1300
+ meta = { title: null, cwd: null, createdAt: null }
1112
1301
  const entry = entryById.get(id)
1113
- return resolveOne(id, usage, {
1114
- exposeUsage: !!(opts && opts.usage),
1115
- listHeader: entry ? entry.header : null,
1116
- preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
1117
- collectDecoded,
1118
- })
1119
- }))
1120
- for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
1302
+ const header = entry && entry.header ? entry.header : null
1303
+ if (header) {
1304
+ if (typeof header.cwd === 'string') meta.cwd = header.cwd
1305
+ if (header.createdAt != null) meta.createdAt = header.createdAt
1306
+ }
1307
+ }
1308
+ const it = buildItem(id, meta, usage, !!(opts && opts.usage))
1309
+ items.push({ ...it, archived: currentArchived.has(it.sessionId) })
1310
+ }
1311
+ if (opts && opts.onlyArchived) {
1312
+ // /archived-sessions/list 的语义:只返回归档集里仍然存在的会话,
1313
+ // 且不带 archived 布尔注记(旧路由的输出形状)。
1314
+ const archivedItems = []
1315
+ for (const it of items) {
1316
+ if (!it.archived) continue
1317
+ const { archived: _drop, ...rest } = it
1318
+ archivedItems.push(rest)
1319
+ }
1320
+ return { items: archivedItems, usage }
1121
1321
  }
1122
- persistDecoded(decoded, statsById)
1123
1322
  // Annotate stars; GC only when we have a trustworthy id baseline, so a
1124
1323
  // failing sp.list() can never wipe the whole index.
1125
1324
  let starredSet = new Set()
@@ -1207,6 +1406,7 @@ export function apply(ctx) {
1207
1406
  const activeTombstones = store.purgedSessionIds.map(String).filter((id) => !present.has(id))
1208
1407
  if (ids.length) {
1209
1408
  const usage = await collectUsage(entries)
1409
+ const entryById = new Map(entries.map((entry) => [entry.id, entry]))
1210
1410
  const statsById = new Map(ids.map((id) => [
1211
1411
  id,
1212
1412
  (usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
@@ -1214,35 +1414,91 @@ export function apply(ctx) {
1214
1414
  const { cached, missing } = metaCache.partition(ids, statsById)
1215
1415
  // P4:与列表构建共用持久标题索引,冷启动零解码。
1216
1416
  const persisted = await hydrateFromPersist(missing, statsById)
1217
- for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
1417
+ for (const [id, meta] of persisted) {
1418
+ const entry = entryById.get(id)
1419
+ const header = entry && entry.header ? entry.header : null
1420
+ metaCache.set(id, statsById.get(id), {
1421
+ title: meta.title,
1422
+ cwd: (header && typeof header.cwd === 'string' && header.cwd) ? header.cwd : meta.cwd,
1423
+ createdAt: (header && header.createdAt != null) ? header.createdAt : meta.createdAt,
1424
+ })
1425
+ }
1218
1426
  const rest = missing.filter((id) => !persisted.has(id))
1219
- const snapshotById = await projectTitles(rest)
1220
- const decoded = new Map()
1221
- const collectDecoded = (id, meta) => { decoded.set(id, meta) }
1427
+ // issue #8:缺失标题不在请求路径上同步投影(runtime 投影 = 逐条整本解码),
1428
+ // 交后台预热补齐;侧栏高频轮询会让补齐结果自然浮现。
1429
+ if (rest.length) enqueueWarm(rest, statsById, entryById)
1222
1430
  for (const id of ids) {
1223
- let meta = cached.get(id) || persisted.get(id) || null
1224
- if (!meta) {
1225
- const entry = entries.find((e) => e.id === id)
1226
- const snapshot = snapshotById.has(id)
1227
- ? snapshotById.get(id)
1228
- : (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
1229
- const next = metaFromSnapshot(snapshot)
1230
- if (entry && entry.header) {
1231
- if (!next.cwd && typeof entry.header.cwd === 'string') next.cwd = entry.header.cwd
1232
- if (!next.createdAt && entry.header.createdAt != null) next.createdAt = entry.header.createdAt
1233
- }
1234
- metaCache.set(id, statsById.get(id), next)
1235
- if (statsById.get(id)) collectDecoded(id, next)
1236
- meta = next
1237
- }
1431
+ const meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || null
1238
1432
  if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
1239
1433
  }
1240
- persistDecoded(decoded, statsById)
1241
1434
  }
1435
+ // 血缘分类(issue #6):结构分类(子代理 / fork 分支)来自 list snapshot
1436
+ // 的 header,零解码;空白判定见下方 refineEmptyLineage(0.1.3 起体积法
1437
+ // 失效,改为事件类型精判)。普通顶层非空会话不产生条目,payload 最小。
1438
+ const lineage = {}
1439
+ for (const entry of entries) {
1440
+ const info = classifyLineage(entry.header, entry.sizeBytes)
1441
+ if (info) lineage[entry.id] = info
1442
+ }
1443
+ await refineEmptyLineage(lineage, entries)
1242
1444
  return {
1243
1445
  titles: Object.fromEntries(authorityTitleCache),
1244
1446
  trashedSessionIds: store.items.map((item) => String(item.sessionId)),
1245
1447
  purgedSessionIds: activeTombstones,
1448
+ lineage,
1449
+ }
1450
+ }
1451
+
1452
+ // 空会话判定缓存:id → { sizeBytes, empty }。日志没变(sizeBytes 相同)
1453
+ // 就不重复解码——authority 会被侧栏高频轮询,缓存让它保持在近零成本。
1454
+ const emptyScanCache = new Map()
1455
+
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))
1499
+ }
1500
+ for (const key of emptyScanCache.keys()) {
1501
+ if (!live.has(key)) emptyScanCache.delete(key)
1246
1502
  }
1247
1503
  }
1248
1504
 
@@ -1393,7 +1649,7 @@ export function apply(ctx) {
1393
1649
  disposers.push(ctx.webServer.register({
1394
1650
  kind: 'exact',
1395
1651
  path: '/archived-sessions/capabilities',
1396
- handler: async (req, res) => json(res, capabilities),
1652
+ handler: async (req, res) => json(res, { ...capabilities, buildStamp: BUILD_STAMP }),
1397
1653
  }))
1398
1654
 
1399
1655
  disposers.push(ctx.webServer.register({
@@ -1423,12 +1679,13 @@ export function apply(ctx) {
1423
1679
  const idStrs = ids.map(String).filter((id) => !hidden.has(id) && (materialized.has(id) || (live && live.get(id))))
1424
1680
  wsByPath = {}
1425
1681
  try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
1426
- const items = []
1427
- const CHUNK = 6
1428
- for (let i = 0; i < idStrs.length; i += CHUNK) {
1429
- const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map((id) => resolveOne(id)))
1430
- items.push.apply(items, res2)
1431
- }
1682
+ // issue #8:归档列表也复用「缓存直读 + 后台预热」的统一管线。
1683
+ // 旧实现逐条 resolveOne(id)(无 header、无指纹)——冷启动对每条归档
1684
+ // 会话整本解码,且连进程内缓存都永远命中不了。
1685
+ const wanted = new Set(idStrs)
1686
+ const detailed = await allSessionItemsDetailed()
1687
+ const items = detailed.items.filter((it) => wanted.has(String(it.sessionId)) && it.archived)
1688
+ for (const it of items) delete it.archived
1432
1689
  json(res, { items })
1433
1690
  } catch (e) {
1434
1691
  json(res, { error: String((e && e.message) || e) }, 500)
@@ -1597,7 +1854,7 @@ export function apply(ctx) {
1597
1854
  const out = await purgeFromTrash(sid)
1598
1855
  metaCache.invalidate(sid)
1599
1856
  // 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
1600
- titleIndex.remove([sid]).catch(() => {})
1857
+ try { await titleIndex.remove([sid]) } catch (e) { /* 索引清理失败不阻塞删除结果 */ }
1601
1858
  json(res, out)
1602
1859
  } catch (e) {
1603
1860
  json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
@@ -1615,7 +1872,7 @@ export function apply(ctx) {
1615
1872
  if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
1616
1873
  const results = []
1617
1874
  for (const sid of ids) {
1618
- try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); titleIndex.remove([sid]).catch(() => {}) }
1875
+ try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); await titleIndex.remove([sid]).catch(() => {}) }
1619
1876
  catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
1620
1877
  }
1621
1878
  json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
@@ -1715,6 +1972,110 @@ export function apply(ctx) {
1715
1972
  },
1716
1973
  }))
1717
1974
 
1975
+ // 子代理血缘树(管理视图数据面):给定父会话,返回 origin==='subagent'
1976
+ // 的递归子树。DSH 原生目录(ui-subagent)只读,这里补管理能力的数据来源。
1977
+ // 只读官方 header 血缘字段(parentSession/origin/delegationDepth/createdAt),
1978
+ // 标题走既有批量投影(readTitleSnapshots),零整本日志解码。
1979
+ // live 标记 = 会话在内存里活着(正在运行或已打开):批量清理必须跳过。
1980
+ disposers.push(ctx.webServer.register({
1981
+ kind: 'exact',
1982
+ path: '/archived-sessions/lineage-tree',
1983
+ handler: async (req, res) => {
1984
+ try {
1985
+ const body = await readJsonBody(req)
1986
+ const rootId = body && typeof body.sessionId === 'string' ? body.sessionId : ''
1987
+ if (!rootId || !isSafeSessionId(rootId)) return json(res, { error: 'missing sessionId' }, 400)
1988
+ const headerById = new Map()
1989
+ const sizeById = new Map()
1990
+ // 与会话列表同源的可见性判定:回收站项目一律不参与血缘树。
1991
+ const trashedIds = new Set()
1992
+ let purgedIds = []
1993
+ const persistedIds = new Set()
1994
+ try {
1995
+ const store = await readTrashStore()
1996
+ for (const item of store.items) trashedIds.add(String(item.sessionId))
1997
+ purgedIds = store.purgedSessionIds.map(String)
1998
+ } catch (e) { /* best-effort */ }
1999
+ const addHeader = (h, sizeBytes) => {
2000
+ if (!h || h.id == null) return
2001
+ const id = String(h.id)
2002
+ // 回收站里的会话(软删除后日志已搬走)不能出现在血缘树里,否则
2003
+ // 会出现「删掉了侧栏还在」。彻底删除的墓碑在下面按「当前已不存在」
2004
+ // 统一剔除,避免压住后来同 id 重建的会话。
2005
+ if (headerById.has(id) || trashedIds.has(id)) return
2006
+ headerById.set(id, {
2007
+ parentSession: h.parentSession != null ? String(h.parentSession) : null,
2008
+ subagent: h.origin === 'subagent',
2009
+ delegationDepth: Number.isFinite(h.delegationDepth) ? h.delegationDepth : null,
2010
+ createdAt: typeof h.createdAt === 'number' ? h.createdAt : null,
2011
+ })
2012
+ if (Number.isFinite(sizeBytes)) sizeById.set(id, sizeBytes)
2013
+ }
2014
+ try {
2015
+ for (const entry of await persistence.listEntries()) {
2016
+ addHeader(entry.header, entry.sizeBytes)
2017
+ if (entry && entry.id != null) persistedIds.add(String(entry.id))
2018
+ }
2019
+ } catch (e) { /* best-effort */ }
2020
+ const liveIds = new Set()
2021
+ const live = ctx.get('sessions')
2022
+ try {
2023
+ if (live && typeof live.list === 'function') {
2024
+ live.list().forEach((s) => { liveIds.add(String(s.id)); addHeader(s.header, undefined) })
2025
+ }
2026
+ } catch (e) { /* best-effort */ }
2027
+ // 墓碑只对「持久层里确实没了」的 id 生效:同 id 会话后来重新出现时
2028
+ // 必须让位,不能永久压住新会话(与会话列表同一套规则)。
2029
+ for (const id of purgedIds) {
2030
+ if (!persistedIds.has(id)) headerById.delete(id)
2031
+ }
2032
+ // 子代理索引:parentSession → 直接子代理 id 列表(只收 origin==='subagent')。
2033
+ const kidsOf = new Map()
2034
+ for (const [id, h] of headerById) {
2035
+ if (!h.subagent || !h.parentSession) continue
2036
+ if (!kidsOf.has(h.parentSession)) kidsOf.set(h.parentSession, [])
2037
+ kidsOf.get(h.parentSession).push(id)
2038
+ }
2039
+ // 递归展开:visited 防环,节点总数封顶防病态数据。
2040
+ const MAX_NODES = 500
2041
+ let nodeCount = 0
2042
+ const build = (id, depth, visited) => {
2043
+ nodeCount++
2044
+ const h = headerById.get(id) || {}
2045
+ const childVisited = new Set(visited)
2046
+ childVisited.add(id)
2047
+ const kidIds = nodeCount >= MAX_NODES ? [] : (kidsOf.get(id) || []).filter((k) => !childVisited.has(k))
2048
+ return {
2049
+ sessionId: id,
2050
+ parentSession: h.parentSession || null,
2051
+ delegationDepth: h.delegationDepth,
2052
+ depth,
2053
+ createdAt: h.createdAt || null,
2054
+ sizeBytes: sizeById.has(id) ? sizeById.get(id) : null,
2055
+ live: liveIds.has(id),
2056
+ title: null,
2057
+ children: kidIds.map((k) => build(k, depth + 1, childVisited)),
2058
+ }
2059
+ }
2060
+ const nodes = (kidsOf.get(rootId) || []).map((k) => build(k, 1, new Set([rootId])))
2061
+ const allIds = [rootId]
2062
+ const walk = (n) => { allIds.push(n.sessionId); n.children.forEach(walk) }
2063
+ nodes.forEach(walk)
2064
+ const titles = await projectTitles(allIds)
2065
+ const titleOf = (id) => {
2066
+ const snap = titles.get(id)
2067
+ const m = snap ? metaFromSnapshot(snap) : null
2068
+ return (m && m.title) || null
2069
+ }
2070
+ const fill = (n) => { n.title = titleOf(n.sessionId); n.children.forEach(fill) }
2071
+ nodes.forEach(fill)
2072
+ json(res, { parent: { sessionId: rootId, title: titleOf(rootId) }, nodes })
2073
+ } catch (e) {
2074
+ json(res, { error: String((e && e.message) || e) }, errorStatus(e))
2075
+ }
2076
+ },
2077
+ }))
2078
+
1718
2079
  // Available target workspaces (for the move picker).
1719
2080
  disposers.push(ctx.webServer.register({
1720
2081
  kind: 'exact',
@@ -1739,7 +2100,8 @@ export function apply(ctx) {
1739
2100
  const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
1740
2101
  if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
1741
2102
  if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
1742
- const moved = await moveOne(sid, target)
2103
+ const moved = await moveOrQueue(sid, target)
2104
+ if (moved.queued) return json(res, { sessionId: sid, ...moved })
1743
2105
  // 移动会改写日志 frame0 的 cwd:元数据(cwd)已变,主动丢弃缓存条目,
1744
2106
  // 不等 mtime 指纹自然失效(Windows 上 mtime 精度较粗,指纹可能不变)。
1745
2107
  metaCache.invalidate(sid)
@@ -1756,6 +2118,77 @@ export function apply(ctx) {
1756
2118
  },
1757
2119
  }))
1758
2120
 
2121
+ // Move many conversations to one target workspace (issue #7). Sequential
2122
+ // on purpose: each move rewrites frame0 (file IO), and one bad session
2123
+ // must not block the rest. Single reindex at the end covers all moves.
2124
+ disposers.push(ctx.webServer.register({
2125
+ kind: 'exact',
2126
+ path: '/archived-sessions/move-many',
2127
+ handler: async (req, res) => {
2128
+ try {
2129
+ const body = await readJsonBody(req)
2130
+ const ids = Array.isArray(body && body.sessionIds)
2131
+ ? [...new Set(body.sessionIds.filter((x) => typeof x === 'string' && x))]
2132
+ : []
2133
+ const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
2134
+ if (!ids.length) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
2135
+ if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
2136
+ let moved = 0
2137
+ const failed = []
2138
+ const queued = []
2139
+ for (const sid of ids) {
2140
+ try {
2141
+ const r = await moveOrQueue(sid, target)
2142
+ if (r && r.queued) { queued.push(sid); continue }
2143
+ // frame0 的 cwd 被改写:与单移动同理由,主动丢缓存不等指纹失效。
2144
+ metaCache.invalidate(sid)
2145
+ moved++
2146
+ } catch (e) {
2147
+ const err = e && e.code ? { sessionId: sid, error: String((e && e.message) || e), code: e.code } : { sessionId: sid, error: String((e && e.message) || e) }
2148
+ failed.push(err)
2149
+ }
2150
+ }
2151
+ try { await reindexRegistry() } catch (e) { /* best-effort */ }
2152
+ json(res, {
2153
+ moved,
2154
+ failed,
2155
+ ...(queued.length > 0 ? { queued, notes: [`${queued.length} 个会话已排队(正被 DSH 打开),重启 DSH 后会自动完成;重启后请先别打开它们。`] } : {}),
2156
+ })
2157
+ } catch (e) {
2158
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2159
+ }
2160
+ },
2161
+ }))
2162
+
2163
+ // 待移动队列:查看 / 取消(会话被占用时排队,释放后自动完成)。
2164
+ disposers.push(ctx.webServer.register({
2165
+ kind: 'exact',
2166
+ path: '/archived-sessions/pending-moves',
2167
+ handler: async (req, res) => {
2168
+ try {
2169
+ const items = await pendingMoves.list()
2170
+ json(res, { items: items.map((i) => ({ sessionId: i.sessionId, targetPath: i.targetPath, queuedAt: i.queuedAt, attempts: i.attempts })) })
2171
+ } catch (e) {
2172
+ json(res, { error: String((e && e.message) || e) }, 500)
2173
+ }
2174
+ },
2175
+ }))
2176
+ disposers.push(ctx.webServer.register({
2177
+ kind: 'exact',
2178
+ path: '/archived-sessions/pending-moves/cancel',
2179
+ handler: async (req, res) => {
2180
+ try {
2181
+ const body = await readJsonBody(req)
2182
+ const ids = parseIds(body)
2183
+ if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
2184
+ const removed = await pendingMoves.remove(ids)
2185
+ json(res, { ok: true, removed })
2186
+ } catch (e) {
2187
+ json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
2188
+ }
2189
+ },
2190
+ }))
2191
+
1759
2192
  // Archive (hide) one session.
1760
2193
  disposers.push(ctx.webServer.register({
1761
2194
  kind: 'exact',