dsh-sessions-manager 3.7.6 → 3.7.8

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.
@@ -221,6 +221,26 @@ export function noticeToastPlan(raw, seenIds, max = 2) {
221
221
  }
222
222
  }
223
223
 
224
+ // issue #18 第 1 条:把「父会话不在当前列表」的子代理从顶层行里摘出来。
225
+ //
226
+ // foldSubagents 只收「父在列表里」的子代理;其余子代理原先留在顶层平铺成
227
+ // 一行行「(无标题)」,一个多代理工作日能攒出上百行。这里把它们分成一组,
228
+ // 调用方渲染成一张可展开的卡片——不污染列表,也不丢会话。
229
+ // 输入应为 foldBranches 之后的 topList(分支聚拢不会碰子代理)。
230
+ export function foldOrphanSubagents(items, lineage) {
231
+ const list = Array.isArray(items) ? items : []
232
+ const table = lineage && typeof lineage === 'object' ? lineage : {}
233
+ const rows = []
234
+ const orphans = []
235
+ for (const it of list) {
236
+ const id = it && it.sessionId != null ? String(it.sessionId) : ''
237
+ const info = id ? table[id] : null
238
+ if (info && info.origin === 'subagent') orphans.push(it)
239
+ else rows.push(it)
240
+ }
241
+ return { rows, orphans }
242
+ }
243
+
224
244
  // —— T3(3.7.0):分支聚拢 ——
225
245
  // 把「同一父会话派生出的普通分支会话」归组,收拢到组锚(真实父行或合成头)
226
246
  // 之下。子代理已由 foldSubagents 先行摘除,本函数只处理非子代理的分支血缘。
@@ -462,3 +482,116 @@ export function filterShapeFromSaved(item) {
462
482
  if (tag === '' && f.tag !== '') degraded.push('tag')
463
483
  return { view, workspace, sort, tag, degraded }
464
484
  }
485
+
486
+ // issue #16:侧栏「展开其余 N 个会话」的 N 由上游按**数据层**计算
487
+ // (0.2.0-rc.2 `collapsedSessionRows` → 0.2.1-rc.2 `watermarkRows`),
488
+ // 看不到插件用 display:none 隐藏的回收站/空白行,于是数字虚高;点展开只是
489
+ // 把更多已隐藏行塞进 DOM,可见行数不变——按钮在动、画面不动,容易被误读成
490
+ // 「会话丢了」。
491
+ //
492
+ // 注意**不能**用「N − 本组被插件隐藏的条数」:上游的 N 数是「水位线之外
493
+ // 还没渲染的行」,隐藏数数的是「整组被藏起来的行」,两个口径不重叠——被藏的
494
+ // 行可能就在已渲染窗口内,减它属重复扣减,点开一次就翻负,进而把仍有可见行
495
+ // 的按钮藏掉(上游只有这一个展开入口 → 会话不可达)。
496
+ //
497
+ // 正确口径由 `remainingRows` 给出:还能出现几行 = 本组可见总数 − 已渲染的
498
+ // 可见行数;`overflowPlan` 只负责把它写进文案、并决定是否藏按钮。
499
+ export function remainingRows(visibleTotal, renderedVisible) {
500
+ // Number(null) === 0:null / undefined 必须当作「不知道」,不能当成 0。
501
+ if (visibleTotal == null || visibleTotal === '') return null
502
+ const total = Number(visibleTotal)
503
+ if (!Number.isFinite(total)) return null
504
+ const shown = Number(renderedVisible)
505
+ const shownCount = Number.isFinite(shown) ? Math.max(0, Math.floor(shown)) : 0
506
+ return Math.max(0, Math.max(0, Math.floor(total)) - shownCount)
507
+ }
508
+
509
+ // 折叠分组的「还能出现几行」(issue #16 的第二个分支)。
510
+ //
511
+ // 上游 `group.sessions` 只在展开时才有内容(`expanded ? [...].map(...) : []`),
512
+ // 而展开按钮恰恰只在折叠时出现 —— 也就是说「拿不到成员 id」与「需要校准计数」
513
+ // 同时发生。折叠态改用两条与展开无关的信息:
514
+ // groupCount 上游 `group.sessionCount`,组内会话总数(已过 sessionVisible)
515
+ // trashInGroup 本工作区的回收站条数(host 按 cwd 统计后下发)
516
+ // 展开后实际会出现的行数 = groupCount − trashInGroup;折叠时 DOM 里没有会话行,
517
+ // 已渲染可见数按 0 计。
518
+ //
519
+ // 任一输入拿不到(老 host 没有该字段 / group 没有 cwd)就返回 null —— 调用方
520
+ // 据此保持上游原文案,绝不猜一个可能错位的数字。
521
+ export function collapsedVisibleTotal(groupCount, trashInGroup) {
522
+ if (groupCount == null || trashInGroup == null) return null
523
+ const total = Number(groupCount)
524
+ const trash = Number(trashInGroup)
525
+ if (!Number.isFinite(total) || !Number.isFinite(trash)) return null
526
+ return Math.max(0, Math.floor(total) - Math.max(0, Math.floor(trash)))
527
+ }
528
+
529
+ // 本组已渲染的可见会话行数。`rows` 是直接子节点里会话行的描述
530
+ // ({ id, pluginHidden }),`visibleIds` 是「上游会列出且插件不会藏」的 id 集合。
531
+ // 插件隐藏的行(回收站 / 空白)与不在集合里的行(空白会话 / 上游过滤口径差异)
532
+ // 都不计入 —— 与 `remainingRows` 的「可见总数」保持同一口径。
533
+ export function countRenderedVisible(rows, visibleIds) {
534
+ if (!Array.isArray(rows) || !visibleIds || typeof visibleIds.has !== 'function') return 0
535
+ let n = 0
536
+ for (const row of rows) {
537
+ if (!row || row.pluginHidden) continue
538
+ if (row.id && visibleIds.has(row.id)) n++
539
+ }
540
+ return n
541
+ }
542
+
543
+ // 空分组判定:`group.sessions` 里「还会出现在侧栏」的条数。
544
+ // - 空白会话不计:上游 `watermarkRows` 里 `if (session.blank) return true`,
545
+ // 空白行永远保留、从不计入可展开数;插件也一律把它藏起来。
546
+ // - `hiddenIds` = 回收站 / 墓碑 / 空白血缘的行 id(与展开计数同一份口径)。
547
+ // - 返回 null 表示**不知道**(拿不到数据层,或该组还没加载出会话):调用方
548
+ // 必须按「非空」处理——宁可留一个空标题,也不能把仍有会话的组藏掉。
549
+ export function aliveSessionCount(sessions, hiddenIds) {
550
+ if (!Array.isArray(sessions) || !sessions.length) return null
551
+ let alive = 0
552
+ let seen = 0
553
+ for (const node of sessions) {
554
+ if (!node || node.id == null) continue
555
+ const id = String(node.id)
556
+ if (!id) continue
557
+ seen++
558
+ if (node.blank) continue
559
+ if (hiddenIds && typeof hiddenIds.has === 'function' && hiddenIds.has(id)) continue
560
+ alive++
561
+ }
562
+ // 一条带 id 的都没有 = 结构不认识(不是「组空」),照未知处理。
563
+ return seen === 0 ? null : alive
564
+ }
565
+
566
+ // 把「还能出现几行」落到展开按钮上:
567
+ // - `more` 未知(拿不到数据层)→ 一律不动作。DOM 兜底在数学上不成立
568
+ // (窗口内/外的隐藏数口径不同),宁可不改也不写一个看起来合理却会算错的数;
569
+ // - 文案没有数字(展开态的「收起」、或上游改成纯图标)→ 不改也不藏。无法确认
570
+ // 是不是计数文案时保持沉默;
571
+ // - 只替换第一个数字(各语言文案都把计数放在数字位,如「展开其余 12 个会话」
572
+ // /「Show 12 more」),支持千分位写法;
573
+ // - `more === 0` 才藏按钮:此时确实一行都不会再出现,留着只会让人反复点。
574
+ export function overflowPlan(text, more) {
575
+ const raw = typeof text === 'string' ? text : ''
576
+ const unknown = more == null || !Number.isFinite(Number(more))
577
+ const remaining = unknown ? 0 : Math.max(0, Math.floor(Number(more)))
578
+ const m = raw.match(/(\d[\d,]*)/)
579
+ const base = m ? Number(m[1].replace(/,/g, '')) : null
580
+ if (base === null || unknown) {
581
+ return { text: raw, hide: false, base, more: unknown ? null : remaining, changed: false }
582
+ }
583
+ if (remaining === 0) {
584
+ // base<=0 是上游没给出可信计数(理论上不会出现):不据此隐藏,避免误判。
585
+ return { text: raw, hide: base > 0, base, more: 0, changed: false }
586
+ }
587
+ if (remaining === base) {
588
+ return { text: raw, hide: false, base, more: remaining, changed: false }
589
+ }
590
+ return {
591
+ text: raw.slice(0, m.index) + String(remaining) + raw.slice(m.index + m[1].length),
592
+ hide: false,
593
+ base,
594
+ more: remaining,
595
+ changed: true,
596
+ }
597
+ }
package/src/index.js CHANGED
@@ -29,7 +29,7 @@ import { createPersistenceAdapter } from './compat/persistence.js'
29
29
  import { detectCapabilities, requireCapability } from './compat/capabilities.js'
30
30
  import { pathOwnsSession } from './path-guard.js'
31
31
  import { purgeSessionArtifacts, moveSessionToCwd } from './handle-era-ops.js'
32
- import { classifyLineage, emptyScanCandidate, isEmptyEventTypes, EMPTY_DECODE_LIMIT } from './lineage.js'
32
+ import { classifyLineage, emptyScanCandidate, isEmptyEventTypes, subagentDescendantsOf, EMPTY_DECODE_LIMIT } from './lineage.js'
33
33
 
34
34
  // 构建指纹:build.mjs 以 define 在编译期注入;直接以源码运行(测试)时为 'dev'。
35
35
  // 用途:capabilities 带出 buildStamp,一眼判断「运行中的 host 是不是这份构建」——
@@ -669,7 +669,7 @@ export function apply(ctx) {
669
669
  // it from the workspace) orphaned the session into DSH's "未分组" group and
670
670
  // made restore land in 未分组 instead of the original workspace — so we leave
671
671
  // the file where it is and let the sidebar DOM shim hide the row instead.
672
- async function deleteOne(sid) {
672
+ async function deleteOne(sid, opts = {}) {
673
673
  requireSessionId(sid)
674
674
  // Soft-delete is always allowed — including the currently-active conversation.
675
675
  // The log file stays in its original workspace dir (recorded in the 回收站
@@ -682,7 +682,9 @@ export function apply(ctx) {
682
682
  let removedPath = null
683
683
  let persistenceEntry = null
684
684
  try {
685
- const entries = await persistence.listEntries()
685
+ // 级联删除时由调用方传入同一份 listEntries 快照:一个父会话带上 N 个子代理
686
+ // 时不重复扫盘(issue #18 第 4 条)。
687
+ const entries = Array.isArray(opts.entries) ? opts.entries : await persistence.listEntries()
686
688
  const found = entries.find((entry) => entry.id === sid) || null
687
689
  persistenceEntry = found
688
690
  header = found ? found.header : null
@@ -726,6 +728,9 @@ export function apply(ctx) {
726
728
  sizeBytes: persistenceEntry && Number.isFinite(persistenceEntry.sizeBytes) ? persistenceEntry.sizeBytes : null,
727
729
  wasArchived: archived, deletedAt: Date.now(),
728
730
  }
731
+ // 跟随父会话一起回收的子代理:记住来源,恢复/彻底删除父会话时按它级联
732
+ // (用户单独删除的子代理没有这个字段,不会被父会话的恢复带着走)。
733
+ if (opts.cascadeFrom) entry.cascadeFrom = String(opts.cascadeFrom)
729
734
  const at = store.items.findIndex((t) => String(t.sessionId) === sid)
730
735
  if (at >= 0) store.items[at] = entry
731
736
  else store.items.push(entry)
@@ -734,6 +739,77 @@ export function apply(ctx) {
734
739
  return { ok: true, trashed: true }
735
740
  }
736
741
 
742
+ // issue #18 第 4 条:子代理会话的生命周期跟随父会话。
743
+ //
744
+ // 用户反馈「删除父会话,子代理会遗留在活动会话中,污染列表」——先前删除只处理
745
+ // 传入的那一个 id,子代理成了无父孤儿。这里按官方 header 的 origin/parentSession
746
+ // 收集后代(零解码,复用同一个 listEntries 快照),父会话删除时一并回收。
747
+ //
748
+ // 只走官方字段,且有步数上限与 visited 防环;任何异常都退回「只删父会话」,
749
+ // 绝不因为血缘表坏掉而拒绝删除。判定本身是纯函数(lineage.js),单测覆盖。
750
+ function subagentDescendantIdsFrom(entries, rootId) {
751
+ const headers = []
752
+ for (const entry of Array.isArray(entries) ? entries : []) {
753
+ if (entry && entry.header) headers.push(entry.header)
754
+ }
755
+ // live 会话可能还没进 listEntries 快照,但它不会有「已落盘的后代」缺失问题;
756
+ // 已持久化的子代理都在 entries 里,所以这里只走快照,避免重复扫描。
757
+ return subagentDescendantsOf(headers, rootId)
758
+ }
759
+
760
+ // 删除一个会话 +(可选)它的子代理后代。返回 cascaded 数量供 UI 如实提示。
761
+ async function deleteWithSubagents(sid, opts = {}) {
762
+ let entries = Array.isArray(opts.entries) ? opts.entries : null
763
+ if (!entries) {
764
+ try { entries = await persistence.listEntries() } catch (e) { entries = null }
765
+ }
766
+ const out = await deleteOne(sid, { entries })
767
+ const cascade = opts.cascade !== false
768
+ if (!cascade || !entries) return { ...out, cascaded: 0, cascadedIds: [] }
769
+ const kids = subagentDescendantIdsFrom(entries, sid)
770
+ // 已经在回收站里的子代理不再碰:它可能是用户**单独**删掉的,重新写一遍会把
771
+ // 它的删除时间与来源改成「跟随父会话」,此后恢复父会话就会把它带回来——
772
+ // 与「用户自己删的不会被带走」的承诺相反。跳过即保留它原来的删除语义。
773
+ let alreadyTrashed = null
774
+ try {
775
+ const store = await readTrashStore()
776
+ alreadyTrashed = new Set(store.items.map((t) => String(t.sessionId)))
777
+ } catch (e) { alreadyTrashed = null }
778
+ const cascadedIds = []
779
+ for (const kid of kids) {
780
+ if (alreadyTrashed && alreadyTrashed.has(String(kid))) continue
781
+ try {
782
+ await deleteOne(kid, { entries, cascadeFrom: sid })
783
+ metaCache.invalidate(kid)
784
+ cascadedIds.push(kid)
785
+ } catch (e) { /* 单个子代理失败不拖垮整次删除 */ }
786
+ }
787
+ return { ...out, cascaded: cascadedIds.length, cascadedIds }
788
+ }
789
+
790
+ // 与删除对称:恢复 / 彻底删除父会话时,把它连带回收的子代理一并处理。
791
+ // 只认 entry.cascadeFrom(用户单独删掉的子代理没有该字段,不会被带走),
792
+ // 且只在回收站里找——父会话恢复后子代理不会留在回收站里变成孤儿。
793
+ async function cascadeTrashChildren(sid, op) {
794
+ let kids = []
795
+ try {
796
+ const store = await readTrashStore()
797
+ kids = store.items
798
+ .filter((t) => String(t.cascadeFrom || '') === String(sid))
799
+ .map((t) => String(t.sessionId))
800
+ } catch (e) { return [] }
801
+ const results = []
802
+ for (const kid of kids) {
803
+ try {
804
+ results.push({ sessionId: kid, ok: true, ...(await op(kid)) })
805
+ metaCache.invalidate(kid)
806
+ } catch (e) {
807
+ results.push({ sessionId: kid, ok: false, error: String((e && e.message) || e) })
808
+ }
809
+ }
810
+ return results
811
+ }
812
+
737
813
  // 恢复前的底层校验(0.1.3 契约下 restoreIndexedSession 不再无条件可用):
738
814
  // 1. 底层 stored session 仍存在(live / stat / list 三级判定);
739
815
  // 2. 日志文件仍在原处(软删除不动文件,originalPath 丢失即外部破坏);
@@ -1729,6 +1805,18 @@ export function apply(ctx) {
1729
1805
  const payload = {
1730
1806
  titles: Object.fromEntries(authorityTitleCache),
1731
1807
  trashedSessionIds: store.items.map((item) => String(item.sessionId)),
1808
+ // issue #16:回收站按 cwd 的条数。折叠分组下上游把 group.sessions 收回成
1809
+ // 空数组,client 拿不到成员 id,只能用「组内会话总数 − 本组回收站条数」
1810
+ // 还原展开按钮该显示的数字;有了这份统计,那次扣减才是准确的而非估算。
1811
+ trashedCountByCwd: (() => {
1812
+ const acc = {}
1813
+ for (const item of store.items) {
1814
+ const cwd = typeof item.cwd === 'string' ? item.cwd : ''
1815
+ if (!cwd) continue
1816
+ acc[cwd] = (acc[cwd] || 0) + 1
1817
+ }
1818
+ return acc
1819
+ })(),
1732
1820
  purgedSessionIds: activeTombstones,
1733
1821
  lineage,
1734
1822
  // v3.6.2:还有预热/退避重试在途 → client 缩短轮询节拍,预热一完成就把
@@ -1795,11 +1883,30 @@ export function apply(ctx) {
1795
1883
  const persistBatch = {}
1796
1884
  for (const [id, desc] of batch) {
1797
1885
  try {
1798
- const types = []
1886
+ // issue #18 第 5 条:只收事件类型会把「插件注入上下文」的会话误判成
1887
+ // 有内容。`agent/inbox/spliced` 需要看插入项来源,这里只留判定所需的
1888
+ // 最小信息(source 对象 + removedCount),绝不把消息正文带进内存。
1889
+ const events = []
1799
1890
  await persistence.inspectSession(id, { onEvents: (b) => {
1800
- for (const ev of b || []) if (types.length < 64) types.push(ev && ev.type)
1891
+ for (const ev of b || []) {
1892
+ if (events.length >= 64) break
1893
+ if (!ev) { events.push(undefined); continue }
1894
+ if (ev.type !== 'agent/inbox/spliced') { events.push({ type: ev.type }); continue }
1895
+ const data = ev.data && typeof ev.data === 'object' ? ev.data : null
1896
+ events.push({
1897
+ type: ev.type,
1898
+ data: data
1899
+ ? {
1900
+ removedCount: data.removedCount,
1901
+ inserted: Array.isArray(data.inserted)
1902
+ ? data.inserted.map((m) => ({ source: m && typeof m === 'object' ? m.source : null }))
1903
+ : null,
1904
+ }
1905
+ : null,
1906
+ })
1907
+ }
1801
1908
  } })
1802
- const isEmpty = isEmptyEventTypes(types)
1909
+ const isEmpty = isEmptyEventTypes(events)
1803
1910
  emptyScanCache.set(id, { sizeBytes: desc.sizeBytes, empty: isEmpty })
1804
1911
  persistBatch[id] = { empty: isEmpty ? 1 : 0, fingerprint: `sz:${desc.sizeBytes}`, updatedAt: Date.now() }
1805
1912
  } catch (e) {
@@ -2072,7 +2179,9 @@ export function apply(ctx) {
2072
2179
  const body = await readJsonBody(req)
2073
2180
  const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
2074
2181
  if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
2075
- const out = await deleteOne(sid)
2182
+ // issue #18 第 4 条:默认连带子代理一起回收(cascadeSubagents:false 可保留
2183
+ // 子代理——它们在面板里会落进「未归属子代理」卡片,仍可管理)。
2184
+ const out = await deleteWithSubagents(sid, { cascade: body.cascadeSubagents !== false })
2076
2185
  // 日志被搬进回收站(文件已不在原处):丢弃缓存条目,避免下次 stat 失败
2077
2186
  // 时残留旧元数据。
2078
2187
  metaCache.invalidate(sid)
@@ -2092,11 +2201,24 @@ export function apply(ctx) {
2092
2201
  const ids = parseIds(body)
2093
2202
  if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
2094
2203
  const results = []
2204
+ // 级联与单删一致:一次快照,多个父会话的子代理不会互相重复回收。
2205
+ let cascadeEntries = null
2206
+ try { cascadeEntries = await persistence.listEntries() } catch (e) { cascadeEntries = null }
2207
+ const cascade = body.cascadeSubagents !== false
2208
+ const done = new Set()
2095
2209
  for (const sid of ids) {
2096
- try { results.push({ sessionId: sid, ok: true, ...(await deleteOne(sid)) }); metaCache.invalidate(sid) }
2097
- catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
2210
+ try {
2211
+ if (done.has(sid)) { results.push({ sessionId: sid, ok: true, trashed: true, cascaded: 0 }); continue }
2212
+ const out = cascade && cascadeEntries
2213
+ ? await deleteWithSubagents(sid, { cascade: true, entries: cascadeEntries })
2214
+ : await deleteOne(sid, { entries: cascadeEntries })
2215
+ done.add(sid)
2216
+ if (Array.isArray(out.cascadedIds)) for (const k of out.cascadedIds) done.add(k)
2217
+ results.push({ sessionId: sid, ok: true, ...out })
2218
+ metaCache.invalidate(sid)
2219
+ } catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
2098
2220
  }
2099
- json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })
2221
+ json(res, { ok: true, deleted: results.filter((r) => r.ok).length, cascaded: results.reduce((n, r) => n + (Number(r.cascaded) || 0), 0), results })
2100
2222
  } catch (e) {
2101
2223
  json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2102
2224
  }
@@ -2172,7 +2294,9 @@ export function apply(ctx) {
2172
2294
  if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
2173
2295
  const out = await restoreFromTrash(sid)
2174
2296
  metaCache.invalidate(sid)
2175
- json(res, out)
2297
+ // 连带回收的子代理一起回来,父子生命周期保持一致。
2298
+ const cascaded = await cascadeTrashChildren(sid, (kid) => restoreFromTrash(kid))
2299
+ json(res, { ...out, cascaded: cascaded.filter((r) => r.ok).length, cascadedResults: cascaded.length ? cascaded : undefined })
2176
2300
  } catch (e) {
2177
2301
  json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2178
2302
  }
@@ -2192,7 +2316,14 @@ export function apply(ctx) {
2192
2316
  // 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
2193
2317
  try { await titleIndex.remove([sid]) } catch (e) { /* 索引清理失败不阻塞删除结果 */ }
2194
2318
  try { await emptyIndex.remove([sid]) } catch (e) { /* 同上 */ }
2195
- json(res, out)
2319
+ // 连带回收的子代理一并物理删除(否则父会话消失、子代理孤零零留在回收站)。
2320
+ const cascaded = await cascadeTrashChildren(sid, async (kid) => {
2321
+ const r = await purgeFromTrash(kid)
2322
+ try { await titleIndex.remove([kid]) } catch (e) {}
2323
+ try { await emptyIndex.remove([kid]) } catch (e) {}
2324
+ return r
2325
+ })
2326
+ json(res, { ...out, cascaded: cascaded.filter((r) => r.ok).length })
2196
2327
  } catch (e) {
2197
2328
  json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
2198
2329
  }
@@ -2209,8 +2340,19 @@ export function apply(ctx) {
2209
2340
  if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
2210
2341
  const results = []
2211
2342
  for (const sid of ids) {
2212
- try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); await titleIndex.remove([sid]).catch(() => {}); await emptyIndex.remove([sid]).catch(() => {}) }
2213
- catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
2343
+ try {
2344
+ results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) })
2345
+ metaCache.invalidate(sid)
2346
+ await titleIndex.remove([sid]).catch(() => {})
2347
+ await emptyIndex.remove([sid]).catch(() => {})
2348
+ // 与单条彻底删除一致:连带回收的子代理一并清掉。
2349
+ await cascadeTrashChildren(sid, async (kid) => {
2350
+ const r = await purgeFromTrash(kid)
2351
+ try { await titleIndex.remove([kid]) } catch (e) {}
2352
+ try { await emptyIndex.remove([kid]) } catch (e) {}
2353
+ return r
2354
+ })
2355
+ } catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
2214
2356
  }
2215
2357
  json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
2216
2358
  } catch (e) {
package/src/lineage.js CHANGED
@@ -45,15 +45,52 @@ const LIFECYCLE_EVENT_TYPES = new Set([
45
45
  'approval/policy',
46
46
  ])
47
47
 
48
+ // 注入型事件:`agent/inbox/spliced` 把消息写进下一轮的 inbox。它不是用户输入,
49
+ // 但它**可能**是插件塞进来的上下文/指令(issue #18 第 5 条:openviking-memory
50
+ // 之类的记忆插件会这么干)。这种会话在界面上空空如也,先前却被算作「有内容」
51
+ // → 永远进不了「空白」分类,只能一直占着列表。
52
+ //
53
+ // 判定按「插入项来源」做,而不是按事件类型一刀切——真实用户消息同样走这个
54
+ // 事件,绝不能连人打的字一起判空:
55
+ // - 每条插入消息都带 source,且 source.form === 'instructions' 或
56
+ // source.kind 以 'plugin:' 开头 → 纯注入,算噪声;
57
+ // - 有任何一条没有 source / 是普通用户消息 → 算内容;
58
+ // - 同时带 removedCount(删了东西)→ 算内容(可能删掉了真实消息)。
59
+ function isInjectedMessage(msg) {
60
+ const src = msg && typeof msg === 'object' ? msg.source : null
61
+ if (!src || typeof src !== 'object') return false
62
+ if (src.form === 'instructions') return true
63
+ return typeof src.kind === 'string' && src.kind.startsWith('plugin:')
64
+ }
65
+
66
+ // 单条事件是否属于「不构成内容」的噪声。接受两种形状:
67
+ // - 旧形状:事件类型字符串(向后兼容,历史调用点与单测仍这样传)
68
+ // - 新形状:{ type, data } —— 只有 inbox 注入需要看 data
69
+ export function isNoiseEvent(event) {
70
+ const type = typeof event === 'string' ? event : event && typeof event === 'object' ? event.type : null
71
+ if (typeof type !== 'string') return false
72
+ if (LIFECYCLE_EVENT_TYPES.has(type)) return true
73
+ if (type !== 'agent/inbox/spliced') return false
74
+ const data = event && typeof event === 'object' ? event.data : null
75
+ if (!data || typeof data !== 'object') return false
76
+ const removed = Number(data.removedCount)
77
+ if (Number.isFinite(removed) && removed > 0) return false
78
+ // inserted 缺失/畸形一律按内容处理(宁可漏判,不可错杀)。
79
+ if (!Array.isArray(data.inserted)) return false
80
+ if (data.inserted.length === 0) return true
81
+ return data.inserted.every(isInjectedMessage)
82
+ }
83
+
48
84
  /**
49
- * @param {Array<string|undefined>} types 事件类型序列(不含头部也可,含头部
50
- * 也可——'session' 在放行清单里)。0 条 = 头部之外无任何事件,视为空。
85
+ * @param {Array<string|{type?:string,data?:object}|undefined>} events 事件序列
86
+ * (类型字符串或带 data 的事件对象;不含头部也可,含头部也可——
87
+ * 'session' 在放行清单里)。0 条 = 头部之外无任何事件,视为空。
51
88
  * @returns {boolean}
52
89
  */
53
- export function isEmptyEventTypes(types) {
54
- if (!Array.isArray(types)) return false
55
- for (const t of types) {
56
- if (typeof t !== 'string' || !LIFECYCLE_EVENT_TYPES.has(t)) return false
90
+ export function isEmptyEventTypes(events) {
91
+ if (!Array.isArray(events)) return false
92
+ for (const ev of events) {
93
+ if (!isNoiseEvent(ev)) return false
57
94
  }
58
95
  return true
59
96
  }
@@ -62,6 +99,49 @@ export function isEmptyEventTypes(types) {
62
99
  // subagent or a fork branch; `empty` is published as null (= not yet judged)
63
100
  // and refined in the background. Ordinary top-level sessions return null —
64
101
  // they only ever gain an entry once a real decode says empty:true.
102
+ export const SUBAGENT_CASCADE_LIMIT = 500
103
+
104
+ /**
105
+ * 子代理后代(issue #18 第 4 条:子代理生命周期跟随父会话)。
106
+ *
107
+ * 只认官方 header 的 `origin === 'subagent'` + `parentSession`,零解码:
108
+ * 删除父会话时由宿主把同一份 listEntries 快照传进来,一次扫描算出整棵子树。
109
+ *
110
+ * @param {Array<object>} headers 会话 header 列表(可为 null / 含畸形项)
111
+ * @param {string} rootId 父会话 id
112
+ * @param {number} [limit] 节点上限(默认 SUBAGENT_CASCADE_LIMIT,防病态血缘)
113
+ * @returns {string[]} 后代 id(广度优先,含孙代;不含 rootId 自身)
114
+ */
115
+ export function subagentDescendantsOf(headers, rootId, limit = SUBAGENT_CASCADE_LIMIT) {
116
+ const root = rootId == null ? '' : String(rootId)
117
+ if (!root) return []
118
+ const kidsOf = new Map()
119
+ for (const h of Array.isArray(headers) ? headers : []) {
120
+ if (!h || typeof h !== 'object' || h.id == null || h.origin !== 'subagent') continue
121
+ const id = String(h.id)
122
+ const parent = h.parentSession != null ? String(h.parentSession) : ''
123
+ // 自指 / 空父一律跳过:它们不构成「后代」边。
124
+ if (!parent || parent === id) continue
125
+ if (!kidsOf.has(parent)) kidsOf.set(parent, [])
126
+ kidsOf.get(parent).push(id)
127
+ }
128
+ const max = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : SUBAGENT_CASCADE_LIMIT
129
+ const out = []
130
+ const visited = new Set([root])
131
+ const queue = [root]
132
+ while (queue.length && out.length < max) {
133
+ const cur = queue.shift()
134
+ for (const kid of kidsOf.get(cur) || []) {
135
+ if (visited.has(kid)) continue
136
+ visited.add(kid)
137
+ out.push(kid)
138
+ queue.push(kid)
139
+ if (out.length >= max) break
140
+ }
141
+ }
142
+ return out
143
+ }
144
+
65
145
  export function classifyLineage(header) {
66
146
  if (!header || typeof header !== 'object') return null
67
147
  const origin = header.origin === 'subagent' ? 'subagent' : null