dsh-sessions-manager 3.6.1 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lineage.js CHANGED
@@ -8,13 +8,11 @@
8
8
  // - fork branch: header.parentSession set without the subagent origin
9
9
  // - empty: the log holds only the generation header
10
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.
11
+ // v3.7.0 (T1):体积法**不再对外发布任何空白结论**——它历史上就有假阳性
12
+ // (长 cwd 抬高阈值把有内容的小日志标空 → 侧栏误隐藏,误隐藏严格重于误闪现)。
13
+ // isEmptyLogSize 降级为纯参考实现(阈值单测仍锁着);空白与否现在只有三个来源:
14
+ // 解码精判的成功结论(true/false)、精判解码上限之外的日志(false = 必有内容)、
15
+ // 以及未知(null,交后台 refine 队列判定)。见 index.js refineEmpty.
18
16
 
19
17
  const EMPTY_BASE = 190
20
18
 
@@ -60,10 +58,11 @@ export function isEmptyEventTypes(types) {
60
58
  return true
61
59
  }
62
60
 
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) {
61
+ // Structural lineage only (T1): a record is produced when the session is a
62
+ // subagent or a fork branch; `empty` is published as null (= not yet judged)
63
+ // and refined in the background. Ordinary top-level sessions return null —
64
+ // they only ever gain an entry once a real decode says empty:true.
65
+ export function classifyLineage(header) {
67
66
  if (!header || typeof header !== 'object') return null
68
67
  const origin = header.origin === 'subagent' ? 'subagent' : null
69
68
  const parentSession = typeof header.parentSession === 'string' && header.parentSession
@@ -72,7 +71,12 @@ export function classifyLineage(header, sizeBytes) {
72
71
  const delegationDepth = Number.isSafeInteger(header.delegationDepth) && header.delegationDepth > 0
73
72
  ? header.delegationDepth
74
73
  : 0
75
- const empty = isEmptyLogSize(sizeBytes, header.cwd)
76
- if (!origin && !parentSession && !empty) return null
77
- return { origin, parentSession, delegationDepth, empty }
74
+ if (!origin && !parentSession) return null
75
+ return { origin, parentSession, delegationDepth, empty: null }
76
+ }
77
+
78
+ // 空白精判候选:压缩体积在解码上限内的小日志。sizeBytes 缺失(legacy 列表
79
+ // 快照没有该观测)时返回 false —— 与历史行为一致:legacy 世代空白判定不生效。
80
+ export function emptyScanCandidate(sizeBytes) {
81
+ return Number.isFinite(sizeBytes) && sizeBytes <= EMPTY_DECODE_LIMIT
78
82
  }
@@ -0,0 +1,129 @@
1
+ // move-notices.js — 排队移动「后台终局」的持久通知(3.7.0 T2)。
2
+ //
3
+ // 背景(UX 黑洞):排队移动发起时有诚实的「已排队」提示,但**后台完成/最终放弃**
4
+ // 原先只 console.warn——用户合上页面就永远不知道结果。本模块把每次终局落成一条
5
+ // 独立通知,经 sidebar-state 带出、client 弹出、ack 后清除。
6
+ //
7
+ // 为什么是独立文件而不是 pending-moves.json 加字段:排队的两个终局(成功 remove、
8
+ // 放弃 bumpAttempts→dropped)都会把条目从 items 里删掉——挂在条目上的字段与载体
9
+ // 同生共死,恰恰在两个目标场景里都观察不到。独立文件另外两点好处:pending-moves
10
+ // 的 items 形状与 schemaVersion 保持 1 不动(旧 host 读新文件不可见,回滚零影响);
11
+ // 通知的生命周期(服务端保留到 ack + 7 天 TTL + 20 条截断)与队列互不干扰。
12
+ //
13
+ // 投递语义:**留在服务端直到 client ack**——pop 式投递会被「toast 没弹用户就关了
14
+ // 页面」直接丢通知。同浏览器多 tab 的即时去重靠 client 的 localStorage 已见集合,
15
+ // ack 只是尽力清理(ack 失败无害,TTL 兜底)。
16
+ //
17
+ // 写入沿用本仓库索引的既定纪律:schemaVersion + 串行 mutation + 原子写(tmp →
18
+ // rename)+ 0600 + 防御式归一化(坏数据逐条丢弃,坏文件当空集合)。
19
+ import { readFileSync } from 'node:fs'
20
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
21
+ import { homedir } from 'node:os'
22
+ import { join } from 'node:path'
23
+
24
+ const SCHEMA_VERSION = 1
25
+ // env 在 create 时读取而非模块加载时冻结(测试动态导入的既定模式;生产单实例两可)。
26
+ export const MAX_NOTICES = 20
27
+ export const NOTICE_MAX_AGE_MS = 7 * 24 * 3600 * 1000
28
+ const KINDS = new Set(['moved', 'abandoned'])
29
+
30
+ function isSafeId(value) {
31
+ return typeof value === 'string' && value.length > 0 && value.length <= 200
32
+ }
33
+ function str(v, max) {
34
+ return typeof v === 'string' && v ? v.slice(0, max) : null
35
+ }
36
+
37
+ export function normalizeNotice(raw) {
38
+ if (!raw || typeof raw !== 'object') return null
39
+ if (!KINDS.has(raw.kind) || !isSafeId(raw.sessionId)) return null
40
+ const at = Number.isFinite(raw.at) && raw.at > 0 ? Math.floor(raw.at) : 0
41
+ if (!at) return null
42
+ const sessionId = String(raw.sessionId)
43
+ return {
44
+ id: str(raw.id, 400) || `${sessionId}:${raw.kind}:${at}`,
45
+ kind: raw.kind,
46
+ sessionId,
47
+ targetPath: str(raw.targetPath, 500),
48
+ attempts: Number.isSafeInteger(raw.attempts) && raw.attempts >= 0 ? raw.attempts : null,
49
+ reason: str(raw.reason, 300),
50
+ at,
51
+ }
52
+ }
53
+
54
+ function normalizeStore(raw) {
55
+ const source = raw && Array.isArray(raw.items) ? raw.items : []
56
+ const seen = new Set()
57
+ const items = []
58
+ for (const entry of source) {
59
+ const n = normalizeNotice(entry)
60
+ if (!n || seen.has(n.id)) continue
61
+ seen.add(n.id)
62
+ items.push(n)
63
+ }
64
+ items.sort((a, b) => a.at - b.at)
65
+ const trimmed = items.length > MAX_NOTICES ? items.slice(items.length - MAX_NOTICES) : items
66
+ return { schemaVersion: SCHEMA_VERSION, items: trimmed }
67
+ }
68
+
69
+ /**
70
+ * @param {object} [options]
71
+ * @param {string} [options.dir] 通知文件所在目录(测试注入临时目录)
72
+ */
73
+ export function createMoveNoticeStore(options = {}) {
74
+ const dir = options.dir
75
+ || process.env.DSH_SESSIONS_MANAGER_PENDING_DIR
76
+ || process.env.DSH_SESSIONS_MANAGER_STAR_DIR
77
+ || join(homedir(), '.dsh', 'sessions-manager')
78
+ const indexPath = options.indexPath || join(dir, 'move-notices.json')
79
+ let mutation = Promise.resolve()
80
+
81
+ function read() {
82
+ try {
83
+ return normalizeStore(JSON.parse(readFileSync(indexPath, 'utf8')))
84
+ } catch (e) {
85
+ return normalizeStore(null)
86
+ }
87
+ }
88
+
89
+ async function write(store) {
90
+ await mkdir(dir, { recursive: true })
91
+ const tmp = join(dir, `.move-notices-${process.pid}-${Date.now()}.tmp`)
92
+ await writeFile(tmp, JSON.stringify(normalizeStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
93
+ await rename(tmp, indexPath)
94
+ }
95
+
96
+ function mutate(mutator) {
97
+ const operation = mutation.then(async () => {
98
+ const store = read()
99
+ const result = await mutator(store)
100
+ await write(store)
101
+ return result
102
+ })
103
+ mutation = operation.catch(() => {})
104
+ return operation
105
+ }
106
+
107
+ return {
108
+ indexPath,
109
+ // 终局落一条通知;同 id(会话+类型+时刻)幂等去重。
110
+ append: (notice) => mutate((store) => {
111
+ const n = normalizeNotice(notice)
112
+ if (!n) return null
113
+ if (store.items.some((x) => x.id === n.id)) return n
114
+ store.items.push(n)
115
+ return n
116
+ }),
117
+ // 只读列出未过期、未 ack 的通知(按时间升序)。过期项在下次写入时随归一化清掉。
118
+ async list() {
119
+ const now = Date.now()
120
+ return read().items.filter((n) => now - n.at <= NOTICE_MAX_AGE_MS)
121
+ },
122
+ ack: (ids) => mutate((store) => {
123
+ const drop = new Set((Array.isArray(ids) ? ids : []).filter((v) => typeof v === 'string' && v.length <= 400).map(String))
124
+ const before = store.items.length
125
+ store.items = store.items.filter((n) => !drop.has(n.id))
126
+ return before - store.items.length
127
+ }),
128
+ }
129
+ }
@@ -0,0 +1,208 @@
1
+ // Durable "saved filters" store (schema v1).
2
+ //
3
+ // Same discipline as src/star-index.js / src/tag-index.js: version field,
4
+ // per-entry defensive normalize, atomic write (tmp + rename at 0o600) and a
5
+ // single chained mutation queue. Filter payloads are OPAQUE JSON: the host
6
+ // never interprets them (the UI round-trips its own chip state); we only
7
+ // bound the serialized size so a corrupt entry can never bloat the state
8
+ // file. Lives in the plugin state directory next to tags.json.
9
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
10
+ import { readFileSync } from 'node:fs'
11
+ import { randomBytes } from 'node:crypto'
12
+ import { homedir } from 'node:os'
13
+ import { join } from 'node:path'
14
+
15
+ // v1 is the first saved-filters schema. Unlike tags.json (which starts at v4
16
+ // to dodge star's v3) this document lives in its own file and can begin
17
+ // counting from 1.
18
+ export const FILTER_SCHEMA_VERSION = 1
19
+
20
+ // Caps: ≤20 saved filters, ≤40 code points per name (Array.from counting),
21
+ // ≤2048 characters of serialized JSON per payload.
22
+ export const MAX_FILTERS = 20
23
+ export const MAX_FILTER_NAME = 40
24
+ export const MAX_FILTER_JSON = 2048
25
+
26
+ // Same state directory (and same env override) as the star / tag indexes.
27
+ const DEFAULT_FILTER_DIR = join(homedir(), '.dsh', 'sessions-manager')
28
+
29
+ const FILTER_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
30
+
31
+ function randomSuffix() {
32
+ const bytes = randomBytes(8)
33
+ let out = ''
34
+ for (const b of bytes) out += FILTER_ID_ALPHABET[b % FILTER_ID_ALPHABET.length]
35
+ return out
36
+ }
37
+
38
+ function isSafeFilterId(value) {
39
+ return typeof value === 'string' && value.length > 0 && value.length <= 64 && !/[\\/\0]/.test(value)
40
+ }
41
+
42
+ function filterError(message, status, code) {
43
+ const error = new Error(message)
44
+ error.status = status
45
+ if (code) error.code = code
46
+ throw error
47
+ }
48
+
49
+ function normalizeFilterName(value) {
50
+ if (typeof value !== 'string') return null
51
+ const name = value.trim()
52
+ if (!name || /[\\/\0]/.test(name)) return null
53
+ if (Array.from(name).length > MAX_FILTER_NAME) return null
54
+ return name
55
+ }
56
+
57
+ function nameKey(name) {
58
+ return name.toLocaleLowerCase()
59
+ }
60
+
61
+ // Serialize an opaque payload, or null when it cannot be stored (not JSON at
62
+ // all — e.g. undefined/functions — or beyond the size cap).
63
+ function normalizeFiltersPayload(value) {
64
+ let serialized
65
+ try {
66
+ serialized = JSON.stringify(value)
67
+ } catch {
68
+ return null
69
+ }
70
+ if (typeof serialized !== 'string' || serialized.length > MAX_FILTER_JSON) return null
71
+ try {
72
+ return JSON.parse(serialized)
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Coerce anything on disk (or nothing at all) into a valid v1 store.
80
+ * A dirty entry (bad id/name, unserializable or oversize payload, duplicate
81
+ * id / casefold-duplicate name) is dropped whole, never partially repaired.
82
+ */
83
+ export function normalizeFilterStore(raw) {
84
+ const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null
85
+ const items = []
86
+ const ids = new Set()
87
+ const names = new Set()
88
+ for (const item of (source && Array.isArray(source.items) ? source.items : [])) {
89
+ if (!item || typeof item !== 'object') continue
90
+ if (!isSafeFilterId(item.id) || ids.has(item.id)) continue
91
+ const name = normalizeFilterName(item.name)
92
+ if (name === null) continue
93
+ const key = nameKey(name)
94
+ if (names.has(key)) continue
95
+ const filters = normalizeFiltersPayload(item.filters)
96
+ if (filters === null) continue
97
+ ids.add(item.id)
98
+ names.add(key)
99
+ const createdAt = Number.isFinite(Number(item.createdAt)) ? Number(item.createdAt) : 0
100
+ items.push({ id: item.id, name, filters, createdAt })
101
+ if (items.length >= MAX_FILTERS) break
102
+ }
103
+ return { schemaVersion: FILTER_SCHEMA_VERSION, items }
104
+ }
105
+
106
+ /**
107
+ * Open the saved-filters store.
108
+ * @param {object} [options]
109
+ * @param {string} [options.dir] - Directory holding the store (tests inject a temp dir).
110
+ * @param {string} [options.indexPath] - Full store path, overriding `dir`.
111
+ */
112
+ export function createSavedFilters(options = {}) {
113
+ const dir = options.dir || process.env.DSH_SESSIONS_MANAGER_STAR_DIR || DEFAULT_FILTER_DIR
114
+ const indexPath = options.indexPath || join(dir, 'saved-filters.json')
115
+ let mutation = Promise.resolve()
116
+
117
+ async function read() {
118
+ try {
119
+ return normalizeFilterStore(JSON.parse(readFileSync(indexPath, 'utf8')))
120
+ } catch {
121
+ return normalizeFilterStore(null)
122
+ }
123
+ }
124
+
125
+ async function write(store) {
126
+ await mkdir(dir, { recursive: true })
127
+ const tmp = join(dir, `.saved-filters-${process.pid}-${Date.now()}.tmp`)
128
+ await writeFile(tmp, JSON.stringify(normalizeFilterStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
129
+ await rename(tmp, indexPath)
130
+ }
131
+
132
+ // Serialize read-modify-write cycles (same chain semantics as the star index).
133
+ function mutate(mutator) {
134
+ const operation = mutation.then(async () => {
135
+ const store = await read()
136
+ const result = await mutator(store)
137
+ await write(store)
138
+ return result
139
+ })
140
+ mutation = operation.catch(() => {})
141
+ return operation
142
+ }
143
+
144
+ function newFilterId(store) {
145
+ for (let i = 0; i < 16; i++) {
146
+ const id = `f_${randomSuffix()}`
147
+ if (!store.items.some((t) => t.id === id)) return id
148
+ }
149
+ return filterError('无法生成筛选 id(随机碰撞异常)', 500, 'DSM_FILTER_ID_COLLISION')
150
+ }
151
+
152
+ /**
153
+ * Save a named filter. `filters` is stored verbatim (opaque to the host).
154
+ * @param {string} rawName
155
+ * @param {*} filters
156
+ * @returns {Promise<{id, name, filters, createdAt}>}
157
+ * @throws 400 DSM_FILTER_NAME_INVALID | 400 DSM_FILTER_INVALID
158
+ * | 400 DSM_FILTER_TOO_LARGE | 409 DSM_FILTER_EXISTS | 409 DSM_FILTER_LIMIT
159
+ */
160
+ async function save(rawName, filters) {
161
+ const name = normalizeFilterName(rawName)
162
+ if (name === null) filterError(`筛选名无效(非空、不含斜杠、不超过 ${MAX_FILTER_NAME} 个字符)`, 400, 'DSM_FILTER_NAME_INVALID')
163
+ // Bound the opaque payload before it can ever reach disk, with honest
164
+ // codes for "not JSON at all" vs. "oversize".
165
+ let serialized
166
+ try {
167
+ serialized = JSON.stringify(filters)
168
+ } catch {
169
+ serialized = undefined
170
+ }
171
+ if (typeof serialized !== 'string') filterError('筛选条件必须是可 JSON 序列化的数据', 400, 'DSM_FILTER_INVALID')
172
+ if (serialized === 'null') filterError('筛选条件不能为 null', 400, 'DSM_FILTER_INVALID')
173
+ if (serialized.length > MAX_FILTER_JSON) filterError(`筛选条件过大(序列化后最多 ${MAX_FILTER_JSON} 字符)`, 400, 'DSM_FILTER_TOO_LARGE')
174
+ const payload = JSON.parse(serialized)
175
+ const key = nameKey(name)
176
+ return mutate((store) => {
177
+ if (store.items.some((t) => nameKey(t.name) === key)) filterError('同名筛选已存在', 409, 'DSM_FILTER_EXISTS')
178
+ if (store.items.length >= MAX_FILTERS) filterError(`保存的筛选已达上限(${MAX_FILTERS})`, 409, 'DSM_FILTER_LIMIT')
179
+ const item = { id: newFilterId(store), name, filters: payload, createdAt: Date.now() }
180
+ store.items.push(item)
181
+ return item
182
+ })
183
+ }
184
+
185
+ /**
186
+ * Delete saved filters by id (unknown ids are ignored).
187
+ * @param {string[]} ids
188
+ * @returns {Promise<number>} how many items were actually removed.
189
+ */
190
+ function remove(ids) {
191
+ const wanted = new Set((Array.isArray(ids) ? ids : []).filter(isSafeFilterId).map(String))
192
+ return mutate((store) => {
193
+ const before = store.items.length
194
+ store.items = store.items.filter((t) => !wanted.has(t.id))
195
+ return before - store.items.length
196
+ })
197
+ }
198
+
199
+ /**
200
+ * @returns {Promise<Array<{id, name, filters, createdAt}>>}
201
+ */
202
+ async function list() {
203
+ const store = await read()
204
+ return store.items
205
+ }
206
+
207
+ return { read, write, mutate, list, save, remove, indexPath, dir }
208
+ }