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/lineage.js ADDED
@@ -0,0 +1,78 @@
1
+ // Lineage classification for sidebar grouping (issue #6).
2
+ //
3
+ // All inputs come from the public list() snapshot — header + sizeBytes — so
4
+ // classification costs zero log decoding, keeping the zero-decode listing
5
+ // design intact. Three kinds:
6
+ // - subagent: official header.origin === 'subagent' (with parentSession and
7
+ // a delegationDepth recursion budget, both persisted by the backend)
8
+ // - fork branch: header.parentSession set without the subagent origin
9
+ // - empty: the log holds only the generation header
10
+ //
11
+ // The backend list snapshot carries NO eventCount, so the size test below is
12
+ // only a cheap PRE-FILTER: since 0.1.3 it can no longer decide emptiness by
13
+ // itself (bigger headers + a constant lifecycle-metadata frame push every
14
+ // new log past the old baseline). The authoritative verdict comes from
15
+ // refineEmptyLineage in index.js — an event-type scan (see below). Keep the
16
+ // size test: it flags candidates cheaply and its false positives get
17
+ // corrected by the precise pass.
18
+
19
+ const EMPTY_BASE = 190
20
+
21
+ export function isEmptyLogSize(sizeBytes, cwd) {
22
+ if (!Number.isFinite(sizeBytes) || sizeBytes < 0) return false
23
+ const cwdLen = typeof cwd === 'string' ? cwd.length : 0
24
+ return sizeBytes <= EMPTY_BASE + cwdLen
25
+ }
26
+
27
+ // ---- 0.1.3 空会话判定(事件类型法)---------------------------------------
28
+ // 0.1.3 起日志结构变了:头部帧之后恒定跟一帧「生命周期元数据」
29
+ // (permission/preset / sandbox/mode / approval/policy,实测约 150B 压缩),
30
+ // 且头部新增 isSeeded / delegationDepth / agentPreset 等字段,压缩后整本
31
+ // 约 300 + cwd.length 字节——上面的体积阈值(190 + cwd,alpha.2 基线)对
32
+ // 新格式永远判不中,空白会话全部漏判。压缩体积对头部扩容/压缩器参数天生
33
+ // 敏感,不再扩阈值,改为按事件类型判定:
34
+ // 空会话 = 头部之外只出现生命周期元数据事件,没有任何内容事件。
35
+ // 解码在宿主侧进行(index.js 的 refineEmptyLineage:只对压缩体积 ≤
36
+ // EMPTY_DECODE_LIMIT 的候选调官方 inspectSession),这里只放纯函数与常量。
37
+ //
38
+ // 已知的生命周期事件(0.1.3-alpha.1 实测,会话创建时一次性写入)。'session'
39
+ // 是头部行自身——inspectSession 若把头部当事件回传,它恒存在于每本日志,
40
+ // 放行不削弱判定;任何未知类型都按内容处理(宁可漏判,不可错杀)。
41
+ export const EMPTY_DECODE_LIMIT = 8192
42
+
43
+ const LIFECYCLE_EVENT_TYPES = new Set([
44
+ 'session',
45
+ 'permission/preset',
46
+ 'sandbox/mode',
47
+ 'approval/policy',
48
+ ])
49
+
50
+ /**
51
+ * @param {Array<string|undefined>} types 事件类型序列(不含头部也可,含头部
52
+ * 也可——'session' 在放行清单里)。0 条 = 头部之外无任何事件,视为空。
53
+ * @returns {boolean}
54
+ */
55
+ export function isEmptyEventTypes(types) {
56
+ if (!Array.isArray(types)) return false
57
+ for (const t of types) {
58
+ if (typeof t !== 'string' || !LIFECYCLE_EVENT_TYPES.has(t)) return false
59
+ }
60
+ return true
61
+ }
62
+
63
+ // Returns a lineage record, or null when the session is an ordinary
64
+ // top-level, non-empty session (the overwhelmingly common case — keeps the
65
+ // sidebar-state payload small).
66
+ export function classifyLineage(header, sizeBytes) {
67
+ if (!header || typeof header !== 'object') return null
68
+ const origin = header.origin === 'subagent' ? 'subagent' : null
69
+ const parentSession = typeof header.parentSession === 'string' && header.parentSession
70
+ ? header.parentSession
71
+ : null
72
+ const delegationDepth = Number.isSafeInteger(header.delegationDepth) && header.delegationDepth > 0
73
+ ? header.delegationDepth
74
+ : 0
75
+ const empty = isEmptyLogSize(sizeBytes, header.cwd)
76
+ if (!origin && !parentSession && !empty) return null
77
+ return { origin, parentSession, delegationDepth, empty }
78
+ }
@@ -0,0 +1,126 @@
1
+ // Deferred cross-workspace moves ("排队移动").
2
+ //
3
+ // 背景:0.1.5 起会话日志是**单写者**——agent-loop 在 resume 时会话时取写所有权
4
+ // (日志旁的 session.lock 上一个 flock(2)),持有到 agent 卸载;插件既拿不到该
5
+ // 句柄,也没有任何公共 API 能释放别人的所有权(dsh-session 服务只暴露
6
+ // list/get/prepare/enter/fork/flush;`session/disposed` 只在持有它的 fiber 被
7
+ // dispose 时发出)。在占用期硬搬目录会让在写者继续往旧 inode 写、并在旧路径
8
+ // 重建空壳(2026-09-10 实测过),所以唯一安全的做法是**排队等它被释放**。
9
+ //
10
+ // 本模块只负责持久化队列本身(读写/去重/清理),执行时机由 index.js 决定:
11
+ // - 宿主释放该会话(session/disposed;0.1.5 无用户可见的关闭入口,绝大多数情况
12
+ // 就是进程退出)→ 立刻尝试
13
+ // - 插件启动后(会话尚未被打开)→ 兜底尝试
14
+ // - 周期性轻量重试(队列为空时不启动定时器)
15
+ // 写入沿用本仓库其他索引的模式:schemaVersion + 原子写(tmp → rename)。
16
+
17
+ import { readFileSync } from 'node:fs'
18
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
19
+ import { homedir } from 'node:os'
20
+ import { join } from 'node:path'
21
+
22
+ const SCHEMA_VERSION = 1
23
+ const DEFAULT_DIR = process.env.DSH_SESSIONS_MANAGER_PENDING_DIR
24
+ || process.env.DSH_SESSIONS_MANAGER_STAR_DIR
25
+ || join(homedir(), '.dsh', 'sessions-manager')
26
+ const MAX_ITEMS = 50
27
+ // 非"占用中"的失败(目标工作区消失、日志损坏…)留在队列里但计数;超过阈值就丢弃,
28
+ // 避免一个永远失败的条目让每次重试都白跑。
29
+ const MAX_ATTEMPTS = 5
30
+
31
+ function isSafeId(value) {
32
+ return typeof value === 'string' && value.length > 0 && value.length <= 200
33
+ }
34
+
35
+ function normalizeStore(raw) {
36
+ const items = []
37
+ const seen = new Set()
38
+ const source = raw && Array.isArray(raw.items) ? raw.items : []
39
+ for (const item of source) {
40
+ if (!item || !isSafeId(item.sessionId) || typeof item.targetPath !== 'string' || item.targetPath.length === 0) continue
41
+ if (seen.has(item.sessionId)) continue
42
+ seen.add(item.sessionId)
43
+ items.push({
44
+ sessionId: String(item.sessionId),
45
+ targetPath: String(item.targetPath),
46
+ queuedAt: Number.isFinite(item.queuedAt) ? Number(item.queuedAt) : Date.now(),
47
+ attempts: Number.isSafeInteger(item.attempts) && item.attempts >= 0 ? Number(item.attempts) : 0,
48
+ })
49
+ if (items.length >= MAX_ITEMS) break
50
+ }
51
+ return { schemaVersion: SCHEMA_VERSION, items }
52
+ }
53
+
54
+ /**
55
+ * @param {object} [options]
56
+ * @param {string} [options.dir] 队列所在目录(测试注入临时目录)
57
+ */
58
+ export function createPendingMoveStore(options = {}) {
59
+ const dir = options.dir || DEFAULT_DIR
60
+ const indexPath = options.indexPath || join(dir, 'pending-moves.json')
61
+ let mutation = Promise.resolve()
62
+
63
+ async function read() {
64
+ try {
65
+ return normalizeStore(JSON.parse(readFileSync(indexPath, 'utf8')))
66
+ } catch (e) {
67
+ return normalizeStore(null)
68
+ }
69
+ }
70
+
71
+ async function write(store) {
72
+ await mkdir(dir, { recursive: true })
73
+ const tmp = join(dir, `.pending-moves-${process.pid}-${Date.now()}.tmp`)
74
+ await writeFile(tmp, JSON.stringify(normalizeStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
75
+ await rename(tmp, indexPath)
76
+ }
77
+
78
+ function mutate(mutator) {
79
+ const operation = mutation.then(async () => {
80
+ const store = await read()
81
+ const result = await mutator(store)
82
+ await write(store)
83
+ return result
84
+ })
85
+ mutation = operation.catch(() => {})
86
+ return operation
87
+ }
88
+
89
+ return {
90
+ indexPath,
91
+ dir,
92
+ list: async () => (await read()).items,
93
+ has: async (sessionId) => (await read()).items.some((item) => item.sessionId === String(sessionId)),
94
+ queue: (sessionId, targetPath) => mutate((store) => {
95
+ if (!isSafeId(sessionId) || typeof targetPath !== 'string' || targetPath.length === 0) return null
96
+ const existing = store.items.find((item) => item.sessionId === String(sessionId))
97
+ if (existing) {
98
+ existing.targetPath = String(targetPath)
99
+ existing.attempts = 0
100
+ return existing
101
+ }
102
+ const item = { sessionId: String(sessionId), targetPath: String(targetPath), queuedAt: Date.now(), attempts: 0 }
103
+ store.items.push(item)
104
+ if (store.items.length > MAX_ITEMS) store.items.splice(0, store.items.length - MAX_ITEMS)
105
+ return item
106
+ }),
107
+ remove: (sessionIds) => mutate((store) => {
108
+ const drop = new Set((Array.isArray(sessionIds) ? sessionIds : [sessionIds]).filter(isSafeId).map(String))
109
+ const before = store.items.length
110
+ store.items = store.items.filter((item) => !drop.has(item.sessionId))
111
+ return before - store.items.length
112
+ }),
113
+ bumpAttempts: (sessionId) => mutate((store) => {
114
+ const item = store.items.find((entry) => entry.sessionId === String(sessionId))
115
+ if (!item) return null
116
+ item.attempts += 1
117
+ if (item.attempts >= MAX_ATTEMPTS) {
118
+ store.items = store.items.filter((entry) => entry.sessionId !== item.sessionId)
119
+ return { dropped: true, attempts: item.attempts }
120
+ }
121
+ return { dropped: false, attempts: item.attempts }
122
+ }),
123
+ }
124
+ }
125
+
126
+ export { MAX_ITEMS, MAX_ATTEMPTS, SCHEMA_VERSION, normalizeStore }
@@ -0,0 +1,42 @@
1
+ // Stale atomic-write temp sweeper.
2
+ //
3
+ // 四个状态文件写入器都用「同目录 .<name>-<pid>-<ts>.tmp → rename 到正式文件」的
4
+ // 原子写模式(star-index / auto-archive / title-persist-index / index.js 的回收站索引)。
5
+ // 若进程在 writeFile 与 rename 之间被杀(反复重启 dsm web 时很常见),临时文件就留在
6
+ // 目录里:实测 ~/.dsh/sessions-manager 累积了 153 个孤儿 .star-*.tmp(2026-09-10)。
7
+ //
8
+ // 启动时扫一遍这些目录,把「超过 maxAgeMs 没被动过」的临时文件清掉。用年龄而非 pid
9
+ // 判定:pid 会回绕,而 1 小时的宽限期远大于任何正常写入窗口,不会碰到并发中的写入。
10
+
11
+ import { readdir, rm, stat } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+
14
+ const TEMP_NAME_RE = /^\..+-\d+-\d+\.tmp$/
15
+
16
+ /**
17
+ * @param {string[]} dirs 需要清扫的目录(不存在时静默跳过)
18
+ * @param {object} [options]
19
+ * @param {number} [options.maxAgeMs] 只清理比这更旧的临时文件(默认 1 小时)
20
+ * @param {number} [options.now] 注入时钟,便于测试
21
+ * @returns {Promise<number>} 清理掉的临时文件数
22
+ */
23
+ export async function sweepStaleStateTemps(dirs, options = {}) {
24
+ const maxAgeMs = Number.isSafeInteger(options.maxAgeMs) && options.maxAgeMs >= 0 ? options.maxAgeMs : 3600_000
25
+ const now = Number.isFinite(options.now) ? options.now : Date.now()
26
+ let removed = 0
27
+ for (const dir of Array.isArray(dirs) ? dirs : []) {
28
+ if (typeof dir !== 'string' || dir.length === 0) continue
29
+ let entries
30
+ try { entries = await readdir(dir) } catch (e) { continue }
31
+ for (const name of entries) {
32
+ if (!TEMP_NAME_RE.test(name)) continue
33
+ const path = join(dir, name)
34
+ let info
35
+ try { info = await stat(path) } catch (e) { continue }
36
+ if (!info.isFile()) continue
37
+ if (now - info.mtimeMs < maxAgeMs) continue
38
+ try { await rm(path, { force: true }); removed++ } catch (e) { /* 清不掉的下次再说 */ }
39
+ }
40
+ }
41
+ return removed
42
+ }