dsh-sessions-manager 3.5.3 → 3.6.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
@@ -7,13 +7,15 @@
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
21
  import { createSessionMetaCache, fingerprintOf, isPersistableFingerprint } from './session-meta-cache.js'
@@ -22,12 +24,21 @@ 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')
@@ -177,11 +188,13 @@ export function apply(ctx) {
177
188
  return hits
178
189
  }
179
190
 
180
- // 把本批真正解码出的元数据异步回写持久索引(fire-and-forget:索引只是
181
- // 加速器,写失败不影响响应,队列内部已串行化 + 原子替换)。
191
+ // 把本批真正解码出的元数据回写持久索引(索引只是加速器:写失败不影响响应,
192
+ // 队列内部已串行化 + 原子替换)。**必须 await** —— 它是持久写入,浮动写入会在
193
+ // 调用方/测试 teardown 已开始清理状态目录时落地(`ENOTEMPTY: rmdir .../state`),
194
+ // 且"响应已返回但索引没落盘"时进程若恰好退出就会丢条目。
182
195
  // ⚠️ revision 指纹(SessionHandle 世代)绝不落盘:它只在当前 service
183
196
  // 实例内有意义,跨进程比较无意义,误用会把陈旧数据当新鲜数据。
184
- function persistDecoded(decoded, statsById) {
197
+ async function persistDecoded(decoded, statsById) {
185
198
  if (!decoded || !decoded.size) return
186
199
  const batch = {}
187
200
  const now = Date.now()
@@ -191,7 +204,7 @@ export function apply(ctx) {
191
204
  batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
192
205
  }
193
206
  if (!Object.keys(batch).length) return
194
- titleIndex.merge(batch).catch(() => {})
207
+ try { await titleIndex.merge(batch) } catch (e) { /* 索引写失败不影响本次响应 */ }
195
208
  }
196
209
 
197
210
  // 从投影快照里抽出元数据;快照缺失/异常时返回零值 meta(调用方决定兜底)。
@@ -431,8 +444,14 @@ export function apply(ctx) {
431
444
  // when the session is really gone (purge, or externally removed; the latter
432
445
  // is caught by gcStars during list builds).
433
446
  const stars = createStarIndex()
447
+ // 待移动队列:会话被占用(DSH 打开中)时登记,等它释放后自动完成移动。
448
+ // 见 src/pending-moves.js 的模块注释(0.1.5 单写者锁没有公共释放 API)。
449
+ const pendingMoves = createPendingMoveStore()
434
450
  // Auto-archive settings live in their own schema-v4 store, off by default.
435
451
  const autoArchive = createAutoArchiveStore()
452
+ // 启动清扫:进程在「写临时文件 → rename」之间被杀会留下 .<name>-<pid>-<ts>.tmp
453
+ // 孤儿(实测一次累积 153 个)。这里把 1 小时前的旧临时文件收掉,不碰正在写的。
454
+ sweepStaleStateTemps([STATE_DIR, TRASH_DIR], {}).catch(() => {})
436
455
 
437
456
  async function gcStars(validIds) {
438
457
  try {
@@ -554,7 +573,9 @@ export function apply(ctx) {
554
573
  const entry = store.items.find((t) => String(t.sessionId) === sid)
555
574
  let originalPath = entry && typeof entry.originalPath === 'string' ? entry.originalPath : null
556
575
  if (!originalPath && header) {
557
- const loc = persistence.locate(header)
576
+ // handle 时代官方收走了 locate,用守卫式推导(root → 目录结构 → id 归属)
577
+ // 代替:推导成功且文件在盘 = 真核验通过,而不是标注 unverified 放行。
578
+ const loc = await persistence.locateVerified(header).catch(() => null)
558
579
  if (loc && typeof loc.path === 'string') originalPath = loc.path
559
580
  }
560
581
  if (originalPath) {
@@ -714,7 +735,11 @@ export function apply(ctx) {
714
735
  purged = true
715
736
  })
716
737
  if (!purged) throw new Error('彻底删除失败')
717
- stars.removeIds([sid]).catch(() => {})
738
+ // 星标清理必须 await。原先这里是浮动写入(`stars.removeIds([sid]).catch(...)`):
739
+ // 删除结果先返回、星标文件随后才落地,正好会撞上调用方(或测试 teardown)已开始的
740
+ // 状态目录清理 → `ENOTEMPTY: rmdir .../state`(全量测试里约 1/4 概率偶发失败)。
741
+ // 而且"删除成功"不该早于状态落盘:进程恰好在此刻退出就会留下指向已删会话的星标。
742
+ try { await stars.removeIds([sid]) } catch (e) { /* 星标清理失败不阻塞删除结果 */ }
718
743
  return { ok: true, purged: true }
719
744
  }
720
745
 
@@ -776,7 +801,10 @@ export function apply(ctx) {
776
801
  // crash-safe (backup + rollback) and re-syncs the live object.
777
802
  const activeId = getActiveSessionId(ctx)
778
803
  if (activeId != null && String(activeId) === String(sid)) {
779
- throw new Error('该会话当前处于打开状态,请先切换到别的会话再移动。')
804
+ const error = new Error('会话正被 DSH 打开,暂时无法移动;请重启 DSH 后再试。')
805
+ error.status = 409
806
+ error.code = 'DSM_SESSION_BUSY'
807
+ throw error
780
808
  }
781
809
  const r = await persistence.readSession(sid, 0)
782
810
  if (!r || !r.meta) throw new Error('无法读取该会话的日志')
@@ -796,6 +824,10 @@ export function apply(ctx) {
796
824
 
797
825
  const newHeader = Object.assign({}, meta, { cwd: canonical })
798
826
 
827
+ // 移动过程的非致命提示(如源目录仍有旧代日志未能清理),随成功结果一并
828
+ // 回报给客户端展示。
829
+ const moveNotes = []
830
+
799
831
  // 1) Decide relocation strategy. `sessionPersistence.create()` rejects
800
832
  // ("already exists in this backend") for ANY session the host has
801
833
  // instantiated into its in-memory `states` — and DSH instantiates *every*
@@ -848,6 +880,8 @@ export function apply(ctx) {
848
880
  await rename(stagedPath, newPath)
849
881
  destinationInstalled = true
850
882
  await unlink(backupPath)
883
+ const keptInSource = await cleanupMovedSourceDir(dirname(oldPath))
884
+ if (keptInSource.length > 0) moveNotes.push(`源目录未能清理干净(残留 ${keptInSource.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
851
885
  } catch (e) {
852
886
  try { await unlink(stagedPath) } catch (_) {}
853
887
  if (destinationInstalled) { try { await unlink(newPath) } catch (_) {} }
@@ -871,6 +905,17 @@ export function apply(ctx) {
871
905
  return null
872
906
  }
873
907
 
908
+ // 移动成功后的源目录收尾(legacy 路径):**整体移除**。目标侧已写入校验通过的
909
+ // 权威日志,源目录只要还留着任何一代日志(legacy 旧代 session.jsonl.zstd 或
910
+ // session.vN.jsonl.zstd),就会让同 id 出现在两个项目目录,后端 list/create
911
+ // 立刻报 duplicate。返回未能清理时的残留文件名(供如实回报)。
912
+ const cleanupMovedSourceDir = async (dir) => {
913
+ let entries = null
914
+ try { entries = await readdir(dir) } catch (e) { return [] }
915
+ try { await rm(dir, { recursive: true, force: true }) } catch (e) { return entries }
916
+ return []
917
+ }
918
+
874
919
  // Rewriting frame0's cwd now lives in src/zstd-frame.js so it can be
875
920
  // regression-tested directly. See that module for why frame boundaries are
876
921
  // validated by decompression and why a non-session frame0 is rejected
@@ -888,7 +933,13 @@ export function apply(ctx) {
888
933
  }
889
934
  }
890
935
  try {
891
- await moveSessionToCwd({ sp, sid, header: meta, canonical, events, inheritedEventCount: r.inheritedEventCount })
936
+ const movedHandle = await moveSessionToCwd({ sp, sid, header: meta, canonical, events, inheritedEventCount: r.inheritedEventCount })
937
+ if (movedHandle && Array.isArray(movedHandle.reclaimedSiblings) && movedHandle.reclaimedSiblings.length > 0) {
938
+ moveNotes.push(`已顺带回收 ${movedHandle.reclaimedSiblings.length} 处被取代的旧代日志副本(历史移动残留),避免同一会话在多目录重复。`)
939
+ }
940
+ if (movedHandle && movedHandle.sourceCleanup && movedHandle.sourceCleanup.cleaned === false) {
941
+ moveNotes.push(`源目录未能清理干净(残留 ${movedHandle.sourceCleanup.leftover.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
942
+ }
892
943
  } catch (e) {
893
944
  if (e && e.status) throw e
894
945
  throw new Error('移动会话日志失败:' + String((e && e.message) || e))
@@ -935,6 +986,10 @@ export function apply(ctx) {
935
986
  throw new Error('移动后校验失败:会话工作目录未正确更新')
936
987
  }
937
988
  if (backupPath) { try { await unlink(backupPath) } catch (e) {} }
989
+ if (oldPath) {
990
+ const keptInSource = await cleanupMovedSourceDir(dirname(oldPath))
991
+ if (keptInSource.length > 0) moveNotes.push(`源目录未能清理干净(残留 ${keptInSource.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
992
+ }
938
993
  } catch (e) {
939
994
  if (ALREADY_EXISTS_RE.test(String((e && e.message) || e))) {
940
995
  // Collision: the session is already materialized in states (archived
@@ -988,9 +1043,109 @@ export function apply(ctx) {
988
1043
  workspaceId: target.id,
989
1044
  workspaceTitle: target.title,
990
1045
  workspacePath: canonical,
1046
+ ...(moveNotes.length > 0 ? { notes: moveNotes } : {}),
1047
+ }
1048
+ }
1049
+
1050
+ // ---- 待移动队列(活跃 / 被占用的会话)-----------------------------------
1051
+ // 0.1.5 的单写者锁由持有写句柄的进程掌控,插件既拿不到句柄也没有释放它的公共
1052
+ // API;在占用期硬搬目录会让在写者继续写旧 inode 并在旧路径重建空壳。因此占用时
1053
+ // 只登记排队,等宿主释放(session/disposed)或下次插件
1054
+ // 启动时(会话尚未被打开)自动完成。
1055
+ const QUEUE_NOTE = '已排队:该会话正被 DSH 打开(写所有权只在 DSH 退出时释放)。重启 DSH 时会自动完成,请先别打开它。'
1056
+
1057
+ function isBusyError(e) {
1058
+ if (!e) return false
1059
+ if (e.code === 'DSM_SESSION_BUSY') return true
1060
+ const text = String((e && e.message) || e)
1061
+ return /正被 DSH 打开|already owned/i.test(text)
1062
+ }
1063
+
1064
+ // 移动入口:占用 → 排队;其它错误照旧抛出。返回成功结果或排队结果。
1065
+ async function moveOrQueue(sid, targetPath) {
1066
+ try {
1067
+ return await moveOne(sid, targetPath)
1068
+ } catch (e) {
1069
+ if (!isBusyError(e)) throw e
1070
+ await pendingMoves.queue(sid, targetPath)
1071
+ return { ok: true, moved: false, queued: true, notes: [QUEUE_NOTE] }
991
1072
  }
992
1073
  }
993
1074
 
1075
+ // 启动后的「学步期」:这段时间内 moveOne 的非占用类失败只当环境未就绪,不计入
1076
+ // 「多次失败即放弃」的计数(否则 boot 竞态会把用户的排队项白白丢掉)。
1077
+ const BOOT_WARMUP_MS = 30000
1078
+ const bootedAt = Date.now()
1079
+
1080
+ let queueRunning = false
1081
+ async function runPendingMoves(reason) {
1082
+ if (queueRunning) return { ran: false, moved: 0, kept: 0 }
1083
+ queueRunning = true
1084
+ let moved = 0
1085
+ const notes = []
1086
+ try {
1087
+ const items = await pendingMoves.list()
1088
+ for (const item of items) {
1089
+ try {
1090
+ await moveOne(item.sessionId, item.targetPath)
1091
+ await pendingMoves.remove([item.sessionId])
1092
+ metaCache.invalidate(item.sessionId)
1093
+ moved++
1094
+ notes.push(`排队中的移动已完成:${String(item.sessionId).slice(0, 18)}…`)
1095
+ } catch (e) {
1096
+ if (isBusyError(e)) continue // 仍被占用:留在队列里,等下一次触发
1097
+ if (Date.now() - bootedAt < BOOT_WARMUP_MS) continue // boot 期未就绪:不记失败
1098
+ const bumped = await pendingMoves.bumpAttempts(item.sessionId)
1099
+ if (bumped && bumped.dropped) notes.push(`排队中的移动多次失败已放弃:${String(item.sessionId).slice(0, 18)}…(${String((e && e.message) || e)})`)
1100
+ }
1101
+ }
1102
+ if (moved > 0) { try { await reindexRegistry() } catch (e) { /* best-effort */ } }
1103
+ } finally {
1104
+ queueRunning = false
1105
+ }
1106
+ if (notes.length > 0) {
1107
+ try { for (const n of notes) console.warn('[dsh-sessions-manager] ' + n) } catch (e) { /* ignore */ }
1108
+ }
1109
+ void reason
1110
+ return { ran: true, moved, kept: (await pendingMoves.list()).length }
1111
+ }
1112
+
1113
+ // 排队项的补跑时机。**关键前提**:0.1.5 的单写者所有权一经 open 就持有到**进程退出**
1114
+ // 为止——官方没有任何「关闭会话 / 释放所有权」的公开入口(`dsh-commands` 只有
1115
+ // compact/feedback/goal;宿主无 unloadSession/closeSession/evict),而浏览器一连上就会
1116
+ // 自动打开/新建会话,一旦被打开就再也搬不动。所以唯一可靠窗口是**宿主刚起来、浏览器
1117
+ // 还没连上**的那一两秒 ⇒ 启动后立刻试,并在前 30s 内密集重试;之后每 120s 兜底;
1118
+ // 会话真被释放(session/disposed)时 500ms 后再试一次。
1119
+ const queueEffect = typeof ctx.effect === 'function' ? ctx.effect.bind(ctx) : ((fn) => { fn() })
1120
+ if (typeof ctx.on === 'function') {
1121
+ queueEffect(() => ctx.on('session/disposed', (session) => {
1122
+ const id = session && session.id != null ? String(session.id) : null
1123
+ if (!id) return
1124
+ pendingMoves.has(id).then((queued) => {
1125
+ if (!queued) return
1126
+ const t = setTimeout(() => { runPendingMoves('session-disposed').catch(() => {}) }, 500)
1127
+ if (t && typeof t.unref === 'function') t.unref()
1128
+ }).catch(() => {})
1129
+ }))
1130
+ }
1131
+ queueEffect(() => {
1132
+ // 每个启动补跑定时器都要 unref:宿主自己不会闲下来无所谓,但测试里未 unref 的
1133
+ // 30s 定时器会让 node:test 多等 30s 才退出(整套测试被无谓拖长,实测见报告)。
1134
+ const timers = [0, 1000, 3000, 6000, 12000, 30000].map((ms) => {
1135
+ const t = setTimeout(() => {
1136
+ runPendingMoves(ms === 0 ? 'startup' : `startup+${ms}`).catch(() => {})
1137
+ }, ms)
1138
+ if (t && typeof t.unref === 'function') t.unref()
1139
+ return t
1140
+ })
1141
+ // 轻量周期重试:只在队列非空时干活(队列空 = 一次本地 JSON 读取)。
1142
+ const tickTimer = setInterval(() => {
1143
+ pendingMoves.list().then((items) => { if (items.length > 0) return runPendingMoves('tick') }).catch(() => {})
1144
+ }, 120000)
1145
+ if (tickTimer && typeof tickTimer.unref === 'function') tickTimer.unref()
1146
+ return () => { for (const t of timers) clearTimeout(t); clearInterval(tickTimer) }
1147
+ })
1148
+
994
1149
  // Force the host's WorkspaceRegistry to rebuild its in-memory sessionPath
995
1150
  // index from the durable persistence headers. DSH's WorkspaceEntity.sessionIds
996
1151
  // is a *getter* that filters record.sessionIds by `host.sessionPath(id) ===
@@ -1117,7 +1272,7 @@ export function apply(ctx) {
1117
1272
  }))
1118
1273
  for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
1119
1274
  }
1120
- persistDecoded(decoded, statsById)
1275
+ await persistDecoded(decoded, statsById)
1121
1276
  // Annotate stars; GC only when we have a trustworthy id baseline, so a
1122
1277
  // failing sp.list() can never wipe the whole index.
1123
1278
  let starredSet = new Set()
@@ -1235,12 +1390,75 @@ export function apply(ctx) {
1235
1390
  }
1236
1391
  if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
1237
1392
  }
1238
- persistDecoded(decoded, statsById)
1393
+ await persistDecoded(decoded, statsById)
1239
1394
  }
1395
+ // 血缘分类(issue #6):结构分类(子代理 / fork 分支)来自 list snapshot
1396
+ // 的 header,零解码;空白判定见下方 refineEmptyLineage(0.1.3 起体积法
1397
+ // 失效,改为事件类型精判)。普通顶层非空会话不产生条目,payload 最小。
1398
+ const lineage = {}
1399
+ for (const entry of entries) {
1400
+ const info = classifyLineage(entry.header, entry.sizeBytes)
1401
+ if (info) lineage[entry.id] = info
1402
+ }
1403
+ await refineEmptyLineage(lineage, entries)
1240
1404
  return {
1241
1405
  titles: Object.fromEntries(authorityTitleCache),
1242
1406
  trashedSessionIds: store.items.map((item) => String(item.sessionId)),
1243
1407
  purgedSessionIds: activeTombstones,
1408
+ lineage,
1409
+ }
1410
+ }
1411
+
1412
+ // 空会话判定缓存:id → { sizeBytes, empty }。日志没变(sizeBytes 相同)
1413
+ // 就不重复解码——authority 会被侧栏高频轮询,缓存让它保持在近零成本。
1414
+ const emptyScanCache = new Map()
1415
+
1416
+ // 0.1.3 空会话精判(事件类型法,见 lineage.js):体积阈值对新格式失效
1417
+ // (头部扩容 + 恒存生命周期帧),改为对「压缩体积 ≤ EMPTY_DECODE_LIMIT」
1418
+ // 的候选走官方 inspectSession 解码,按事件类型判定。超过上限的日志必有
1419
+ // 内容,直接跳过(维持体积法/结构分类的结果)。解码失败按「非空」处理:
1420
+ // 宁可漏判一个空会话,不能把有内容的会话错标成空。
1421
+ async function refineEmptyLineage(lineage, entries) {
1422
+ if (!persistence || typeof persistence.inspectSession !== 'function') return
1423
+ for (const entry of entries) {
1424
+ const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
1425
+ if (size === null || size > EMPTY_DECODE_LIMIT) continue
1426
+ const id = String(entry.id)
1427
+ let scanned = emptyScanCache.get(id)
1428
+ if (!scanned || scanned.sizeBytes !== size) {
1429
+ let isEmpty = false
1430
+ try {
1431
+ const types = []
1432
+ await persistence.inspectSession(id, { onEvents: (batch) => {
1433
+ for (const ev of batch || []) if (types.length < 64) types.push(ev && ev.type)
1434
+ } })
1435
+ isEmpty = isEmptyEventTypes(types)
1436
+ } catch (e) { isEmpty = false }
1437
+ scanned = { sizeBytes: size, empty: isEmpty }
1438
+ emptyScanCache.set(id, scanned)
1439
+ }
1440
+ if (scanned.empty) {
1441
+ // 结构分类没建条目的普通会话也要补上(0.1.3 空会话 header 无血缘字段,
1442
+ // classifyLineage 对它们返回 null)。
1443
+ const info = lineage[id] || (lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: false })
1444
+ info.empty = true
1445
+ } else if (lineage[id]) {
1446
+ // 旧体积法在小日志上的假阳性(如 alpha.2 单事件日志)在这里纠正;
1447
+ // 纠正后若无任何血缘结构,条目整个撤掉,维持「普通会话不产生条目」。
1448
+ const info = lineage[id]
1449
+ info.empty = false
1450
+ if (!info.origin && !info.parentSession) delete lineage[id]
1451
+ }
1452
+ }
1453
+ // 缓存只保留「本轮仍参与判定」的会话:已彻底删除或日志膨胀超过解码
1454
+ // 上限的条目不再有用,淘汰掉防止 Map 随历史会话无限增长。
1455
+ const live = new Set()
1456
+ for (const entry of entries) {
1457
+ const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
1458
+ if (size !== null && size <= EMPTY_DECODE_LIMIT) live.add(String(entry.id))
1459
+ }
1460
+ for (const key of emptyScanCache.keys()) {
1461
+ if (!live.has(key)) emptyScanCache.delete(key)
1244
1462
  }
1245
1463
  }
1246
1464
 
@@ -1391,7 +1609,7 @@ export function apply(ctx) {
1391
1609
  disposers.push(ctx.webServer.register({
1392
1610
  kind: 'exact',
1393
1611
  path: '/archived-sessions/capabilities',
1394
- handler: async (req, res) => json(res, capabilities),
1612
+ handler: async (req, res) => json(res, { ...capabilities, buildStamp: BUILD_STAMP }),
1395
1613
  }))
1396
1614
 
1397
1615
  disposers.push(ctx.webServer.register({
@@ -1595,7 +1813,7 @@ export function apply(ctx) {
1595
1813
  const out = await purgeFromTrash(sid)
1596
1814
  metaCache.invalidate(sid)
1597
1815
  // 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
1598
- titleIndex.remove([sid]).catch(() => {})
1816
+ try { await titleIndex.remove([sid]) } catch (e) { /* 索引清理失败不阻塞删除结果 */ }
1599
1817
  json(res, out)
1600
1818
  } catch (e) {
1601
1819
  json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
@@ -1613,7 +1831,7 @@ export function apply(ctx) {
1613
1831
  if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
1614
1832
  const results = []
1615
1833
  for (const sid of ids) {
1616
- try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); titleIndex.remove([sid]).catch(() => {}) }
1834
+ try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); await titleIndex.remove([sid]).catch(() => {}) }
1617
1835
  catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
1618
1836
  }
1619
1837
  json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
@@ -1713,6 +1931,110 @@ export function apply(ctx) {
1713
1931
  },
1714
1932
  }))
1715
1933
 
1934
+ // 子代理血缘树(管理视图数据面):给定父会话,返回 origin==='subagent'
1935
+ // 的递归子树。DSH 原生目录(ui-subagent)只读,这里补管理能力的数据来源。
1936
+ // 只读官方 header 血缘字段(parentSession/origin/delegationDepth/createdAt),
1937
+ // 标题走既有批量投影(readTitleSnapshots),零整本日志解码。
1938
+ // live 标记 = 会话在内存里活着(正在运行或已打开):批量清理必须跳过。
1939
+ disposers.push(ctx.webServer.register({
1940
+ kind: 'exact',
1941
+ path: '/archived-sessions/lineage-tree',
1942
+ handler: async (req, res) => {
1943
+ try {
1944
+ const body = await readJsonBody(req)
1945
+ const rootId = body && typeof body.sessionId === 'string' ? body.sessionId : ''
1946
+ if (!rootId || !isSafeSessionId(rootId)) return json(res, { error: 'missing sessionId' }, 400)
1947
+ const headerById = new Map()
1948
+ const sizeById = new Map()
1949
+ // 与会话列表同源的可见性判定:回收站项目一律不参与血缘树。
1950
+ const trashedIds = new Set()
1951
+ let purgedIds = []
1952
+ const persistedIds = new Set()
1953
+ try {
1954
+ const store = await readTrashStore()
1955
+ for (const item of store.items) trashedIds.add(String(item.sessionId))
1956
+ purgedIds = store.purgedSessionIds.map(String)
1957
+ } catch (e) { /* best-effort */ }
1958
+ const addHeader = (h, sizeBytes) => {
1959
+ if (!h || h.id == null) return
1960
+ const id = String(h.id)
1961
+ // 回收站里的会话(软删除后日志已搬走)不能出现在血缘树里,否则
1962
+ // 会出现「删掉了侧栏还在」。彻底删除的墓碑在下面按「当前已不存在」
1963
+ // 统一剔除,避免压住后来同 id 重建的会话。
1964
+ if (headerById.has(id) || trashedIds.has(id)) return
1965
+ headerById.set(id, {
1966
+ parentSession: h.parentSession != null ? String(h.parentSession) : null,
1967
+ subagent: h.origin === 'subagent',
1968
+ delegationDepth: Number.isFinite(h.delegationDepth) ? h.delegationDepth : null,
1969
+ createdAt: typeof h.createdAt === 'number' ? h.createdAt : null,
1970
+ })
1971
+ if (Number.isFinite(sizeBytes)) sizeById.set(id, sizeBytes)
1972
+ }
1973
+ try {
1974
+ for (const entry of await persistence.listEntries()) {
1975
+ addHeader(entry.header, entry.sizeBytes)
1976
+ if (entry && entry.id != null) persistedIds.add(String(entry.id))
1977
+ }
1978
+ } catch (e) { /* best-effort */ }
1979
+ const liveIds = new Set()
1980
+ const live = ctx.get('sessions')
1981
+ try {
1982
+ if (live && typeof live.list === 'function') {
1983
+ live.list().forEach((s) => { liveIds.add(String(s.id)); addHeader(s.header, undefined) })
1984
+ }
1985
+ } catch (e) { /* best-effort */ }
1986
+ // 墓碑只对「持久层里确实没了」的 id 生效:同 id 会话后来重新出现时
1987
+ // 必须让位,不能永久压住新会话(与会话列表同一套规则)。
1988
+ for (const id of purgedIds) {
1989
+ if (!persistedIds.has(id)) headerById.delete(id)
1990
+ }
1991
+ // 子代理索引:parentSession → 直接子代理 id 列表(只收 origin==='subagent')。
1992
+ const kidsOf = new Map()
1993
+ for (const [id, h] of headerById) {
1994
+ if (!h.subagent || !h.parentSession) continue
1995
+ if (!kidsOf.has(h.parentSession)) kidsOf.set(h.parentSession, [])
1996
+ kidsOf.get(h.parentSession).push(id)
1997
+ }
1998
+ // 递归展开:visited 防环,节点总数封顶防病态数据。
1999
+ const MAX_NODES = 500
2000
+ let nodeCount = 0
2001
+ const build = (id, depth, visited) => {
2002
+ nodeCount++
2003
+ const h = headerById.get(id) || {}
2004
+ const childVisited = new Set(visited)
2005
+ childVisited.add(id)
2006
+ const kidIds = nodeCount >= MAX_NODES ? [] : (kidsOf.get(id) || []).filter((k) => !childVisited.has(k))
2007
+ return {
2008
+ sessionId: id,
2009
+ parentSession: h.parentSession || null,
2010
+ delegationDepth: h.delegationDepth,
2011
+ depth,
2012
+ createdAt: h.createdAt || null,
2013
+ sizeBytes: sizeById.has(id) ? sizeById.get(id) : null,
2014
+ live: liveIds.has(id),
2015
+ title: null,
2016
+ children: kidIds.map((k) => build(k, depth + 1, childVisited)),
2017
+ }
2018
+ }
2019
+ const nodes = (kidsOf.get(rootId) || []).map((k) => build(k, 1, new Set([rootId])))
2020
+ const allIds = [rootId]
2021
+ const walk = (n) => { allIds.push(n.sessionId); n.children.forEach(walk) }
2022
+ nodes.forEach(walk)
2023
+ const titles = await projectTitles(allIds)
2024
+ const titleOf = (id) => {
2025
+ const snap = titles.get(id)
2026
+ const m = snap ? metaFromSnapshot(snap) : null
2027
+ return (m && m.title) || null
2028
+ }
2029
+ const fill = (n) => { n.title = titleOf(n.sessionId); n.children.forEach(fill) }
2030
+ nodes.forEach(fill)
2031
+ json(res, { parent: { sessionId: rootId, title: titleOf(rootId) }, nodes })
2032
+ } catch (e) {
2033
+ json(res, { error: String((e && e.message) || e) }, errorStatus(e))
2034
+ }
2035
+ },
2036
+ }))
2037
+
1716
2038
  // Available target workspaces (for the move picker).
1717
2039
  disposers.push(ctx.webServer.register({
1718
2040
  kind: 'exact',
@@ -1737,7 +2059,8 @@ export function apply(ctx) {
1737
2059
  const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
1738
2060
  if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
1739
2061
  if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
1740
- const moved = await moveOne(sid, target)
2062
+ const moved = await moveOrQueue(sid, target)
2063
+ if (moved.queued) return json(res, { sessionId: sid, ...moved })
1741
2064
  // 移动会改写日志 frame0 的 cwd:元数据(cwd)已变,主动丢弃缓存条目,
1742
2065
  // 不等 mtime 指纹自然失效(Windows 上 mtime 精度较粗,指纹可能不变)。
1743
2066
  metaCache.invalidate(sid)
@@ -1754,6 +2077,77 @@ export function apply(ctx) {
1754
2077
  },
1755
2078
  }))
1756
2079
 
2080
+ // Move many conversations to one target workspace (issue #7). Sequential
2081
+ // on purpose: each move rewrites frame0 (file IO), and one bad session
2082
+ // must not block the rest. Single reindex at the end covers all moves.
2083
+ disposers.push(ctx.webServer.register({
2084
+ kind: 'exact',
2085
+ path: '/archived-sessions/move-many',
2086
+ handler: async (req, res) => {
2087
+ try {
2088
+ const body = await readJsonBody(req)
2089
+ const ids = Array.isArray(body && body.sessionIds)
2090
+ ? [...new Set(body.sessionIds.filter((x) => typeof x === 'string' && x))]
2091
+ : []
2092
+ const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
2093
+ if (!ids.length) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
2094
+ if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
2095
+ let moved = 0
2096
+ const failed = []
2097
+ const queued = []
2098
+ for (const sid of ids) {
2099
+ try {
2100
+ const r = await moveOrQueue(sid, target)
2101
+ if (r && r.queued) { queued.push(sid); continue }
2102
+ // frame0 的 cwd 被改写:与单移动同理由,主动丢缓存不等指纹失效。
2103
+ metaCache.invalidate(sid)
2104
+ moved++
2105
+ } catch (e) {
2106
+ const err = e && e.code ? { sessionId: sid, error: String((e && e.message) || e), code: e.code } : { sessionId: sid, error: String((e && e.message) || e) }
2107
+ failed.push(err)
2108
+ }
2109
+ }
2110
+ try { await reindexRegistry() } catch (e) { /* best-effort */ }
2111
+ json(res, {
2112
+ moved,
2113
+ failed,
2114
+ ...(queued.length > 0 ? { queued, notes: [`${queued.length} 个会话已排队(正被 DSH 打开),重启 DSH 后会自动完成;重启后请先别打开它们。`] } : {}),
2115
+ })
2116
+ } catch (e) {
2117
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2118
+ }
2119
+ },
2120
+ }))
2121
+
2122
+ // 待移动队列:查看 / 取消(会话被占用时排队,释放后自动完成)。
2123
+ disposers.push(ctx.webServer.register({
2124
+ kind: 'exact',
2125
+ path: '/archived-sessions/pending-moves',
2126
+ handler: async (req, res) => {
2127
+ try {
2128
+ const items = await pendingMoves.list()
2129
+ json(res, { items: items.map((i) => ({ sessionId: i.sessionId, targetPath: i.targetPath, queuedAt: i.queuedAt, attempts: i.attempts })) })
2130
+ } catch (e) {
2131
+ json(res, { error: String((e && e.message) || e) }, 500)
2132
+ }
2133
+ },
2134
+ }))
2135
+ disposers.push(ctx.webServer.register({
2136
+ kind: 'exact',
2137
+ path: '/archived-sessions/pending-moves/cancel',
2138
+ handler: async (req, res) => {
2139
+ try {
2140
+ const body = await readJsonBody(req)
2141
+ const ids = parseIds(body)
2142
+ if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
2143
+ const removed = await pendingMoves.remove(ids)
2144
+ json(res, { ok: true, removed })
2145
+ } catch (e) {
2146
+ json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
2147
+ }
2148
+ },
2149
+ }))
2150
+
1757
2151
  // Archive (hide) one session.
1758
2152
  disposers.push(ctx.webServer.register({
1759
2153
  kind: 'exact',