dsh-sessions-manager 3.5.0 → 3.5.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-sessions-manager",
3
3
  "description": "DSH 设置面板会话管理器:归档 / 恢复 / 彻底删除 / 移动到其他工作区,带工作区标签与会话日期;统一「会话管理」面板。Session manager for the DeepSeek Harness settings panel — archive / restore / permanently delete / move sessions across workspaces, with workspace tags & session dates in one unified panel.",
4
- "version": "3.5.0",
4
+ "version": "3.5.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import React, { useEffect, useMemo, useRef, useState } from 'react'
14
- import { canDropOnWorkspace, dotStateFor, sessionForNodes, starredOf, workspaceForNodes } from './logic.js'
14
+ import { authoritativeTitleForFirstPaint, canDropOnWorkspace, dotStateFor, sessionForNodes, starredOf, workspaceForNodes } from './logic.js'
15
15
 
16
16
  export const inject = ['slots']
17
17
 
@@ -1129,7 +1129,7 @@ const SIDEBAR_AUG_CSS = `
1129
1129
  .dsm-sub-cur{font-size:11px;color:var(--dsw-alias-state-business-primary);flex:none;margin-left:8px}
1130
1130
  .dsm-dot{position:absolute;left:6px;top:50%;transform:translateY(-50%);width:8px;height:8px;border-radius:50%;box-sizing:border-box;cursor:pointer;pointer-events:auto;z-index:1}
1131
1131
  .dsm-dot-unread-manual{background:var(--dsw-alias-state-business-primary)}
1132
- .dsm-dot-waiting{background:var(--dsw-alias-state-warning-primary)}
1132
+ .dsm-dot-waiting{background:var(--dsw-alias-state-warn-primary,#F59E0B)}
1133
1133
  .dsm-dot-unread{background:var(--dsw-alias-state-success-primary)}
1134
1134
  .dsm-drag-source{opacity:.48}
1135
1135
  .dsm-drop-target{position:relative;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)!important;outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px;border-radius:8px}
@@ -1465,7 +1465,7 @@ function installSidebarSessionMenuAug() {
1465
1465
  // Left-side per-session status dot (DOM shim). The color is driven by DSH's
1466
1466
  // REAL session status, read from the row's own StateDot ([data-state]):
1467
1467
  // 手动标记未读(蓝) = 用户在 ⋯ 菜单或点击圆点手动标记,localStorage 持久化(最高优先级)
1468
- // 工作中(黄) = DSH state 'running'
1468
+ // 工作中(黄) = DSH state 'ongoing'(旧版兼容 'running')
1469
1469
  // 需用户反馈(琥珀) = DSH state 'warning'(有追问需用户反馈)
1470
1470
  // 完成后未读(绿) = DSH state 'done' 且用户尚未读过(read 集合)
1471
1471
  // 完成已读(不显示) = DSH state 'done' 且已读过(read 集合持久化)
@@ -1503,7 +1503,7 @@ function installSidebarStatusDots() {
1503
1503
  }
1504
1504
  // Dot colors per the user's scheme. We read DSH's REAL session status from the
1505
1505
  // row's own StateDot (its [data-state] attr) and recolor it:
1506
- // running → 黄 工作中
1506
+ // ongoing/running → 黄 工作中
1507
1507
  // warning → 琥珀 有追问需用户反馈
1508
1508
  // done → 绿 完成后未读(读过则不再显示)
1509
1509
  // error → 红 出错/需关注(与 DSH 自带 StateDot 一致)
@@ -1511,11 +1511,12 @@ function installSidebarStatusDots() {
1511
1511
  const COLOR = {
1512
1512
  manual: 'var(--dsw-alias-state-business-primary)', // 蓝 手动标记未读
1513
1513
  running: '#EAB308', // 黄 工作中 (DSH running)
1514
- feedback: 'var(--dsw-alias-state-warning-primary)', // 琥珀 需用户反馈 (DSH warning)
1514
+ feedback: 'var(--dsw-alias-state-warn-primary, #F59E0B)', // 琥珀 需用户反馈 (DSH warning)
1515
1515
  done: 'var(--dsw-alias-state-success-primary)', // 绿 完成后未读 (DSH done)
1516
1516
  error: 'var(--dsw-alias-state-error-primary)', // 红 出错/需关注 (DSH error)
1517
1517
  }
1518
1518
  const manualUnread = dsmLoadManual()
1519
+ const titleInitializedRows = new WeakSet()
1519
1520
  const paint = () => {
1520
1521
  // Recompute the active session on every paint so the active row is never
1521
1522
  // shown from a stale `curActive` (the click that changes aria-selected
@@ -1549,12 +1550,20 @@ function installSidebarStatusDots() {
1549
1550
  // Session is opened. Paint the latest log-folded title from the host
1550
1551
  // authority without materializing the Session or changing its log.
1551
1552
  const authoritativeTitle = dsmAuthoritativeTitles.get(id)
1552
- if (authoritativeTitle) {
1553
+ if (!titleInitializedRows.has(row)) {
1553
1554
  const node = rowNode(row) || {}
1554
1555
  const expected = new Set([node.title, node.displayTitle, node.name].filter((value) => typeof value === 'string'))
1555
1556
  const spans = [...row.children].filter((el) => el.tagName === 'SPAN' && !el.querySelector('[data-state]') && (el.textContent || '').trim())
1556
1557
  const titleEl = spans.find((el) => expected.has((el.textContent || '').trim())) || spans[0]
1557
- if (titleEl && titleEl.textContent !== authoritativeTitle) titleEl.textContent = authoritativeTitle
1558
+ if (titleEl) {
1559
+ const correction = authoritativeTitleForFirstPaint({
1560
+ firstPaint: true,
1561
+ rendered: titleEl.textContent || '',
1562
+ authoritative: authoritativeTitle || '',
1563
+ })
1564
+ if (correction) titleEl.textContent = correction
1565
+ titleInitializedRows.add(row)
1566
+ }
1558
1567
  }
1559
1568
  // Read DSH's REAL session status from the row's own StateDot and recolor
1560
1569
  // it with the user's scheme (we also hide DSH's dot so only ours shows).
@@ -10,6 +10,7 @@
10
10
  export function dotStateFor({ manualUnread = false, dataState = '', isActive = false } = {}) {
11
11
  if (manualUnread) return 'manual'
12
12
  switch (dataState) {
13
+ case 'ongoing':
13
14
  case 'running': return 'running'
14
15
  case 'warning': return 'feedback'
15
16
  case 'error': return 'error'
@@ -18,6 +19,13 @@ export function dotStateFor({ manualUnread = false, dataState = '', isActive = f
18
19
  }
19
20
  }
20
21
 
22
+ // 权威标题只修正新出现 DOM 行的冷态标题。行一旦完成首次 paint,后续文本
23
+ // 变化属于 DSH Core 的实时更新(例如重命名),插件不得再用轮询快照覆盖。
24
+ export function authoritativeTitleForFirstPaint({ firstPaint = false, rendered = '', authoritative = '' } = {}) {
25
+ if (!firstPaint || !authoritative || rendered === authoritative) return null
26
+ return authoritative
27
+ }
28
+
21
29
  // 拖拽迁移前置校验:同工作区拦截(workspacePath 相等即拒绝)。
22
30
  // 无 workspacePath 的会话(如侧栏 live 行尚未同步)放行,由 host 最终裁决。
23
31
  export function canDropOnWorkspace(item, target) {
package/src/index.js CHANGED
@@ -7,11 +7,11 @@
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, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
10
+ import { mkdir, readFile, realpath, rename, 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
- import { rewriteFrame0Cwd } from './zstd-frame.js'
14
+ import { rewriteFrame0CwdInMemory, scanZstdFrames } from './zstd-frame.js'
15
15
  import { renderSessionMarkdown } from './markdown.js'
16
16
  import { createStarIndex } from './star-index.js'
17
17
  import { aggregateStorage } from './storage-stats.js'
@@ -637,15 +637,36 @@ export function apply(ctx) {
637
637
  const newPath = locatePath(newHeaderObj)
638
638
  if (!oldPath || !newPath || oldPath === newPath) return false
639
639
  const backupPath = `${oldPath}.move-backup-${Date.now()}`
640
+ const stagedPath = `${newPath}.move-stage-${process.pid}-${Date.now()}`
641
+ let destinationInstalled = false
640
642
  try {
641
643
  // Ensure the destination project directory exists (rename does not
642
644
  // create it). Without this, the rename silently no-ops on ENOENT and
643
645
  // the log stays put while workspace.json is wrongly updated.
644
646
  await mkdir(dirname(newPath), { recursive: true })
645
- await rename(oldPath, backupPath) // current log to safety
646
- await rewriteFrame0Cwd(backupPath, canonical) // frame0 cwd -> newPath
647
- await rename(backupPath, newPath) // relocate to the new workspace dir
647
+ try {
648
+ await stat(newPath)
649
+ throw new Error('移动失败:目标位置已存在同名会话日志')
650
+ } catch (e) {
651
+ if (e && e.code !== 'ENOENT') throw e
652
+ }
653
+ await rename(oldPath, backupPath) // keep the original byte-identical until verification succeeds
654
+ const original = await readFile(backupPath)
655
+ const originalFrames = scanZstdFrames(original).frames
656
+ if (originalFrames.length === 0) throw new Error('移动前校验失败:会话日志没有完整 zstd 帧')
657
+ const rewritten = rewriteFrame0CwdInMemory(original, canonical)
658
+ const rewrittenFrames = scanZstdFrames(rewritten).frames
659
+ if (rewrittenFrames.length !== originalFrames.length) throw new Error('移动后校验失败:会话日志帧数发生变化')
660
+ const originalTail = original.subarray(originalFrames[0].end)
661
+ const rewrittenTail = rewritten.subarray(rewrittenFrames[0].end)
662
+ if (!originalTail.equals(rewrittenTail)) throw new Error('移动后校验失败:会话事件内容发生变化')
663
+ await writeFile(stagedPath, rewritten, { mode: 0o600 })
664
+ await rename(stagedPath, newPath)
665
+ destinationInstalled = true
666
+ await unlink(backupPath)
648
667
  } catch (e) {
668
+ try { await unlink(stagedPath) } catch (_) {}
669
+ if (destinationInstalled) { try { await unlink(newPath) } catch (_) {} }
649
670
  try { await rename(backupPath, oldPath) } catch (_) {}
650
671
  if (e && e.code !== 'ENOENT') throw e
651
672
  return false
package/src/zstd-frame.js CHANGED
@@ -20,33 +20,71 @@ export const ZSTD_MAGIC = 0xFD2FB528
20
20
  const CHECKSUM_OPTS = { params: { [zlib.constants.ZSTD_c_checksumFlag]: 1 } }
21
21
 
22
22
  /**
23
- * Locate real zstd frame boundaries in a concatenated-frame buffer.
24
- *
25
- * Scanning for the 4-byte magic alone produces FALSE POSITIVES: the same byte
26
- * sequence can occur inside compressed data. Every candidate is therefore
27
- * validated by attempting decompression; only offsets that decode are kept.
23
+ * Parse complete concatenated Zstandard frames without decompressing them.
24
+ * Invalid complete structure rejects; EOF inside the final frame is reported
25
+ * as torn rather than guessed from magic bytes occurring in compressed data.
28
26
  *
29
27
  * @param {Buffer} buf
30
- * @returns {number[]} ascending offsets of real frame starts
28
+ * @param {number} maxFrames
29
+ * @returns {{frames: Array<{start:number,end:number}>, tornStart?: number}}
31
30
  */
32
- export function findZstdFrameStarts(buf) {
33
- const starts = []
34
- for (let i = 0; i + 4 <= buf.length; i++) {
35
- if (buf.readUInt32LE(i) !== ZSTD_MAGIC) continue
36
- try {
37
- // Two checks are needed, not just one:
38
- // - a magic inside compressed data fails to decode and throws
39
- // - a BARE 4-byte magic at the very end of the buffer decodes to an
40
- // EMPTY result without throwing, so non-empty output is required too
41
- // Every real frame carries at least one JSON line, so neither case can
42
- // be a genuine frame start.
43
- const out = zlib.zstdDecompressSync(buf.subarray(i, i + Math.min(buf.length - i, 1000000)))
44
- if (out.length > 0) starts.push(i)
45
- } catch (_) {
46
- // Not a real frame boundary — the magic bytes occurred inside compressed data.
31
+ export function scanZstdFrames(buf, maxFrames = Number.POSITIVE_INFINITY) {
32
+ const frames = []
33
+ let offset = 0
34
+ while (offset < buf.length) {
35
+ const start = offset
36
+ if (buf.length - offset < 4) return { frames, tornStart: start }
37
+ if (buf.readUInt32LE(offset) !== ZSTD_MAGIC) {
38
+ throw new Error(`会话日志格式异常(字节 ${offset} 的 zstd magic 无效)`)
39
+ }
40
+ offset += 4
41
+ if (offset === buf.length) return { frames, tornStart: start }
42
+
43
+ const descriptor = buf.readUInt8(offset++)
44
+ if ((descriptor & 0x18) !== 0) throw new Error(`会话日志格式异常(字节 ${offset - 1} 使用保留帧头位)`)
45
+ const contentSizeFlag = descriptor >>> 6
46
+ const singleSegment = (descriptor & 0x20) !== 0
47
+ const checksum = (descriptor & 0x04) !== 0
48
+ const dictionaryFlag = descriptor & 0x03
49
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
50
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
51
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
52
+ if (buf.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
53
+ offset += remainingHeaderBytes
54
+
55
+ for (;;) {
56
+ if (buf.length - offset < 3) return { frames, tornStart: start }
57
+ const blockHeader = buf.readUIntLE(offset, 3)
58
+ offset += 3
59
+ const lastBlock = (blockHeader & 1) !== 0
60
+ const blockType = (blockHeader >>> 1) & 0x03
61
+ const blockSize = blockHeader >>> 3
62
+ if (blockType === 0x03) throw new Error(`会话日志格式异常(字节 ${offset - 3} 使用保留块类型)`)
63
+ const payloadBytes = blockType === 0x01 ? 1 : blockSize
64
+ if (buf.length - offset < payloadBytes) return { frames, tornStart: start }
65
+ offset += payloadBytes
66
+ if (lastBlock) break
67
+ }
68
+ if (checksum) {
69
+ if (buf.length - offset < 4) return { frames, tornStart: start }
70
+ offset += 4
47
71
  }
72
+ frames.push({ start, end: offset })
73
+ if (frames.length === maxFrames) return { frames }
48
74
  }
49
- return starts
75
+ return { frames }
76
+ }
77
+
78
+ /** Backward-compatible frame-start view used by diagnostics and tests. */
79
+ export function findZstdFrameStarts(buf) {
80
+ return scanZstdFrames(buf).frames.map((frame) => frame.start)
81
+ }
82
+
83
+ function firstFrame(buf) {
84
+ const scan = scanZstdFrames(buf, 1)
85
+ const frame = scan.frames[0]
86
+ if (!frame) throw new Error('会话日志格式异常(无完整 zstd 帧)')
87
+ return frame
50
88
  }
51
89
 
52
90
  /**
@@ -64,10 +102,9 @@ export function findZstdFrameStarts(buf) {
64
102
  */
65
103
  export function rewriteFrame0Cwd(filePath, newCwd) {
66
104
  const buf = readFileSync(filePath)
67
- const starts = findZstdFrameStarts(buf)
68
- if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
69
- const end0 = starts.length > 1 ? starts[1] : buf.length
70
- const frame0 = buf.subarray(starts[0], end0)
105
+ const frame = firstFrame(buf)
106
+ const end0 = frame.end
107
+ const frame0 = buf.subarray(frame.start, end0)
71
108
  const text = zlib.zstdDecompressSync(frame0).toString('utf8')
72
109
  const nl = text.indexOf('\n')
73
110
  const line = nl >= 0 ? text.slice(0, nl) : text
@@ -91,10 +128,9 @@ export function rewriteFrame0Cwd(filePath, newCwd) {
91
128
  * @returns {Buffer} rewritten log
92
129
  */
93
130
  export function rewriteFrame0CwdInMemory(buf, newCwd) {
94
- const starts = findZstdFrameStarts(buf)
95
- if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
96
- const end0 = starts.length > 1 ? starts[1] : buf.length
97
- const frame0 = buf.subarray(starts[0], end0)
131
+ const frame = firstFrame(buf)
132
+ const end0 = frame.end
133
+ const frame0 = buf.subarray(frame.start, end0)
98
134
  const text = zlib.zstdDecompressSync(frame0).toString('utf8')
99
135
  const nl = text.indexOf('\n')
100
136
  const line = nl >= 0 ? text.slice(0, nl) : text
@@ -128,10 +164,8 @@ export function buildSessionLog(header, events = []) {
128
164
  * @returns {{obj: object, lineCount: number}}
129
165
  */
130
166
  export function readFrame0(buf) {
131
- const starts = findZstdFrameStarts(buf)
132
- if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
133
- const end0 = starts.length > 1 ? starts[1] : buf.length
134
- const text = zlib.zstdDecompressSync(buf.subarray(starts[0], end0)).toString('utf8')
167
+ const frame = firstFrame(buf)
168
+ const text = zlib.zstdDecompressSync(buf.subarray(frame.start, frame.end)).toString('utf8')
135
169
  const lines = text.split('\n').filter((l) => l.length > 0)
136
170
  return { obj: JSON.parse(lines[0]), lineCount: lines.length }
137
171
  }