dsh-sessions-manager 3.7.2 → 3.7.4

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.7.2",
4
+ "version": "3.7.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -58,14 +58,14 @@
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@deepseek-ai/cordis": "^4.0.1",
61
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
62
- "@deepseek-ai/dsh-workspace": "^0.1.0-rc.6",
63
- "@deepseek-ai/dsh-session-persistence-jsonl": "^0.1.0-rc.6",
64
- "@deepseek-ai/dsh-session-query": "^0.1.0-rc.6",
65
- "@deepseek-ai/dsh-storage-domain": "^0.1.0-rc.6",
66
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
67
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
68
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
61
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.6",
62
+ "@deepseek-ai/dsh-workspace": ">=0.1.0-rc.6",
63
+ "@deepseek-ai/dsh-session-persistence-jsonl": ">=0.1.0-rc.6",
64
+ "@deepseek-ai/dsh-session-query": ">=0.1.0-rc.6",
65
+ "@deepseek-ai/dsh-storage-domain": ">=0.1.0-rc.6",
66
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.6",
67
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.6",
68
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.6",
69
69
  "react": "^18.2.0"
70
70
  },
71
71
  "allowScripts": {
@@ -0,0 +1,87 @@
1
+ // 活跃会话(host 侧)追踪。
2
+ //
3
+ // 背景:0.1.7 / 0.2.0 把「当前选中哪个会话」这件事完全放到了客户端——
4
+ // uiWorkspace 持有 mainView 的会话引用,host 进程里根本不存在
5
+ // activeSession / currentSession / sessions.active 任何一个访问器(实测三个
6
+ // 探测全部落空)。而 host 又必须知道「这个会话是不是用户正在看的那个」:
7
+ // - 移动:活跃会话被写锁持有,动盘要排到 DSH 释放之后(409 / 排队)
8
+ // - 自动归档:正在看的会话不该被默默归档掉
9
+ // host 自己证明不了,只能由能看到 UI 状态的客户端心跳上报。
10
+ //
11
+ // 设计取舍:
12
+ // * 按 clientId 分桶——浏览器端与 Desktop 端可能同时连着同一个 host,各自看
13
+ // 的会话不同,只留一份会互相顶掉。
14
+ // * 只采信「新鲜」的心跳(TTL)。客户端面板关闭 / 标签页隐藏 / 进程退出后
15
+ // 心跳停止,过期数据必须失效:宁可漏判(放行移动,移动路径本身有备份+
16
+ // 回滚和写锁兜底),也不拿陈旧数据把用户早已切走的会话一直锁死。
17
+ // * 上报 null/空 = 该客户端当前没有活跃会话,删掉它的桶(而不是留旧值)。
18
+
19
+ export const DEFAULT_ACTIVE_TTL_MS = 45 * 1000
20
+ // 桶数量上限:clientId 由客户端生成,理论无限。超过就先清过期的,再清最旧的,
21
+ // 防止异常客户端(每次刷新换 id)把 Map 撑大。
22
+ export const MAX_ACTIVE_REPORTS = 32
23
+
24
+ // 与 host 侧会话 id 校验同源(src/index.js isSafeSessionId):会话 id 会参与
25
+ // 日志路径拼接,含路径分隔符的脏数据必须挡在外面。
26
+ function isUsableSessionId(value) {
27
+ return typeof value === 'string'
28
+ && value.length > 0
29
+ && value.length <= 200
30
+ && !/[\\/\0]/.test(value)
31
+ && value !== '.'
32
+ && value !== '..'
33
+ }
34
+
35
+ export function createActiveSessionTracker(options = {}) {
36
+ const ttl = Number.isFinite(options.ttlMs) ? Number(options.ttlMs) : DEFAULT_ACTIVE_TTL_MS
37
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
38
+ /** @type {Map<string, {id: string, at: number}>} */
39
+ const reports = new Map()
40
+
41
+ function trim(at) {
42
+ for (const [key, value] of reports) if (at - value.at > ttl) reports.delete(key)
43
+ while (reports.size > MAX_ACTIVE_REPORTS) {
44
+ const oldest = reports.keys().next().value
45
+ if (oldest === undefined) break
46
+ reports.delete(oldest)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Record one client's currently-open session.
52
+ * @param {string} clientId
53
+ * @param {string|null} sessionId - null clears this client's slot.
54
+ * @returns {boolean} whether it was accepted.
55
+ */
56
+ function report(clientId, sessionId, at = now()) {
57
+ const key = typeof clientId === 'string' && clientId ? clientId.slice(0, 64) : 'default'
58
+ if (sessionId === null || sessionId === undefined || sessionId === '') {
59
+ reports.delete(key)
60
+ return true
61
+ }
62
+ if (!isUsableSessionId(sessionId)) return false
63
+ reports.set(key, { id: String(sessionId), at })
64
+ trim(at)
65
+ return true
66
+ }
67
+
68
+ /**
69
+ * Freshly reported active session ids (any connected client).
70
+ * @returns {Set<string>}
71
+ */
72
+ function activeIds(at = now()) {
73
+ const out = new Set()
74
+ for (const [key, value] of reports) {
75
+ if (at - value.at > ttl) { reports.delete(key); continue }
76
+ out.add(value.id)
77
+ }
78
+ return out
79
+ }
80
+
81
+ return {
82
+ report,
83
+ activeIds,
84
+ get size() { return reports.size },
85
+ clear() { reports.clear() },
86
+ }
87
+ }
@@ -62,6 +62,9 @@ export function normalizeAutoArchiveStore(raw) {
62
62
  * @param {number} [options.now] - Reference timestamp (tests inject it).
63
63
  * @param {boolean} [options.skipStarred=true] - Keep starred sessions.
64
64
  * @param {string|null} [options.activeSessionId] - Never archive the open one.
65
+ * @param {Array<string>} [options.activeSessionIds] - Same, for several clients
66
+ * (browser + desktop can each hold their own open session). Takes precedence
67
+ * over `activeSessionId` when given.
65
68
  * @returns {string[]} Session ids to archive.
66
69
  */
67
70
  export function pickInactiveCandidates(items, options = {}) {
@@ -70,7 +73,11 @@ export function pickInactiveCandidates(items, options = {}) {
70
73
  const now = Number.isFinite(options.now) ? options.now : Date.now()
71
74
  const cutoff = now - days * DAY_MS
72
75
  const skipStarred = options.skipStarred !== false
73
- const activeId = options.activeSessionId != null ? String(options.activeSessionId) : null
76
+ const activeIds = new Set(
77
+ Array.isArray(options.activeSessionIds)
78
+ ? options.activeSessionIds.map((id) => String(id))
79
+ : options.activeSessionId != null ? [String(options.activeSessionId)] : [],
80
+ )
74
81
  const list = Array.isArray(items) ? items : []
75
82
 
76
83
  const out = []
@@ -81,7 +88,7 @@ export function pickInactiveCandidates(items, options = {}) {
81
88
  if (seen.has(id)) continue
82
89
  if (item.archived) continue
83
90
  if (skipStarred && item.starred) continue
84
- if (activeId !== null && id === activeId) continue
91
+ if (activeIds.has(id)) continue
85
92
  const updatedAt = Number(item.updatedAt)
86
93
  // No usable timestamp → cannot prove it is idle → leave it alone.
87
94
  if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue
@@ -244,15 +244,58 @@ async function postJSON(path, body) {
244
244
 
245
245
  // 打开一个会话(尤其是侧栏不渲染的子代理会话)。
246
246
  //
247
- // 只走官方公开契约(runtime 的 client api-catalog):
248
- // sessions.open(id) —— 选中会话为当前
249
- // sessions.refreshSubagents(parent) —— 刷新某父会话的直接子代理目录
250
- // sessions.openSubagent(address) —— 用「直接父 + 子」地址打开目录里的子代理
251
- // 拿不到 sessions 服务(老 runtime)时返回 false,由调用方给出降级提示,绝不
252
- // 触碰任何私有内部状态。
247
+ // 只走官方公开契约,按方法存在性分支、不写死核心版本:
248
+ // 旧核心(≤0.1.6) ctx.get('sessions') 自带
249
+ // sessions.open(id) —— 选中会话为当前
250
+ // sessions.refreshSubagents(parent) —— 刷新某父会话的直接子代理目录
251
+ // sessions.openSubagent(address) —— 用「直接父 + 子」地址打开目录里的子代理
252
+ // 新核心(0.1.7+ / 0.2.0+)sessions 退化为会话数据流服务(retain/release/
253
+ // readPage…,没有 open),切换会话的公开入口是 uiWorkspace.openSession(target):
254
+ // target 为会话 id 字符串,或 {parentSessionId, childSessionId, mode} 子代理
255
+ // 地址(与旧 openSubagent 契约同形,replaceMain 经 sessions.retain 原生支持;
256
+ // id 命中已加载目录的子代理时核心会自动解析地址)。核心侧栏点会话走的就是
257
+ // 这条路径(dsh-workspace-kit 0.1.12 起同样从 ctx.sessions.open 迁移过来)。
258
+ // 拿不到任何服务时返回 null,由调用方给出降级提示,绝不触碰私有内部状态。
253
259
  let dsmClientCtx = null
254
260
  function dsmSessionsService() {
255
- try { return dsmClientCtx && typeof dsmClientCtx.get === 'function' ? dsmClientCtx.get('sessions') : null } catch (e) { return null }
261
+ try {
262
+ if (!dsmClientCtx || typeof dsmClientCtx.get !== 'function') return null
263
+ const native = dsmClientCtx.get('sessions')
264
+ if (native && (typeof native.open === 'function' || typeof native.openSubagent === 'function')) return native
265
+ const ui = dsmClientCtx.get('uiWorkspace')
266
+ if (!ui || typeof ui.openSession !== 'function') return native
267
+ let lastOpened = null
268
+ const bridge = {
269
+ open(id) {
270
+ if (id === null || id === undefined) return
271
+ const target = String(id)
272
+ // 地址未加载的子代理会在这里被核心拒绝(unknown session),调用方
273
+ // 依赖抛错落回 openSubagent 地址路径,因此不吞异常。
274
+ ui.openSession(target)
275
+ lastOpened = target
276
+ },
277
+ openSubagent(address) {
278
+ if (address === null || address === undefined) return
279
+ if (typeof address === 'object') {
280
+ // 逐字段原样透传(parentSessionId / childSessionId / mode),新核心
281
+ // 的 retain 直接以该地址解析历史路由。
282
+ ui.openSession(address)
283
+ lastOpened = String(address.childSessionId)
284
+ } else {
285
+ bridge.open(address)
286
+ }
287
+ },
288
+ // 新核心的子代理目录由投影自动维护,无需手动刷新。
289
+ refreshSubagents() {},
290
+ }
291
+ // dsmCurrentSessionId 走 list.getSnapshot().current 校验切换结果;桥接下
292
+ // 核心未公开等价快照,以自己最近一次成功提交的目标为准。
293
+ Object.defineProperty(bridge, 'list', {
294
+ configurable: true,
295
+ get() { return { getSnapshot: () => ({ current: lastOpened }) } },
296
+ })
297
+ return bridge
298
+ } catch (e) { return null }
256
299
  }
257
300
  function dsmCurrentSessionId(svc) {
258
301
  try {
@@ -293,6 +336,73 @@ async function dsmOpenSessionById(childId, directParentId) {
293
336
  return 'failed'
294
337
  }
295
338
 
339
+ // ---- 活跃会话上报:host 判定「用户正在看哪个会话」的唯一来源 ----------------
340
+ //
341
+ // 0.1.7+ / 0.2.x 的 host 进程里没有 activeSession / currentSession /
342
+ // sessions.active 任何一个访问器(实测三个探测全部落空),它根本无从判断用户在
343
+ // 看哪个会话——但必须判断,否则两条保护形同虚设:
344
+ // * 移动:活跃会话被写锁持有,硬动盘要排队(409 / queued)
345
+ // * 自动归档:正在看的会话不该被默默归档掉
346
+ // 能看到 UI 状态的是客户端,所以由它周期性上报;host 只在心跳新鲜期内采信
347
+ // (见 src/active-session.js 的 TTL 说明:过期即失效,宁可漏判也不误判)。
348
+ //
349
+ // 探测顺序:uiWorkspace 的 selection 快照(持久化在 dsh.sessions.current,即
350
+ // 启动时恢复的那个选中项,最权威)→ uiWorkspace 的 mainView 引用(mainReference)
351
+ // → 旧核心 sessions.list.getSnapshot().current。
352
+ // 前两者是客户端服务对象上的字段,全部 try/catch 包裹:上游改名/重构只会退化成
353
+ // 「探测不到」(放行移动,移动路径本身有写锁 + 备份回滚兜底),不会抛错。
354
+ const DSM_CLIENT_ID = (() => {
355
+ try {
356
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
357
+ } catch (e) { /* 非安全上下文没有 randomUUID */ }
358
+ return `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
359
+ })()
360
+
361
+ export function dsmActiveSessionId() {
362
+ try {
363
+ if (!dsmClientCtx || typeof dsmClientCtx.get !== 'function') return null
364
+ const ui = dsmClientCtx.get('uiWorkspace')
365
+ if (ui) {
366
+ const snap = ui.selection && typeof ui.selection.getSnapshot === 'function' ? ui.selection.getSnapshot() : null
367
+ if (snap && snap.sessionId != null) return String(snap.sessionId)
368
+ const ref = ui.mainReference
369
+ if (ref && ref.sessionId != null) return String(ref.sessionId)
370
+ }
371
+ const native = dsmClientCtx.get('sessions')
372
+ if (native && native.list && typeof native.list.getSnapshot === 'function') {
373
+ const snap = native.list.getSnapshot()
374
+ if (snap && snap.current != null) return String(snap.current)
375
+ }
376
+ } catch (e) { /* 服务不可用/字段不存在 */ }
377
+ return null
378
+ }
379
+
380
+ const DSM_ACTIVE_HEARTBEAT_MS = 15000
381
+ const DSM_ACTIVE_MIN_REPORT_MS = 30000
382
+ let dsmLastActiveReport = { id: null, at: 0 }
383
+
384
+ /**
385
+ * 上报当前会话。心跳(force=false)在「值没变 + 距上次不足 30s」时跳过网络请求;
386
+ * force=true 用于移动前强制同步——用户在心跳间隔内刚切过会话时,host 手里还是旧值。
387
+ */
388
+ async function reportActiveSession(force) {
389
+ const id = dsmActiveSessionId()
390
+ const now = Date.now()
391
+ if (!force && id === dsmLastActiveReport.id && now - dsmLastActiveReport.at < DSM_ACTIVE_MIN_REPORT_MS) return
392
+ dsmLastActiveReport = { id, at: now }
393
+ try {
394
+ await postJSON('/archived-sessions/active-session', { clientId: DSM_CLIENT_ID, sessionId: id })
395
+ } catch (e) { /* host 未就绪 / 路由缺失:静默,下个心跳再试 */ }
396
+ }
397
+
398
+ // 移动类请求:先同步一次活跃会话,再发真正的移动请求。否则 host 拿旧值放行,
399
+ // 移动中的会话恰好是用户刚切过去的那个(写锁持有 → 只能排队,UI 上表现为「排队」
400
+ // 而不是「已移动」——不算错,但用户点之前本可以选别的会话)。
401
+ async function postActiveAware(path, body) {
402
+ await reportActiveSession(true).catch(() => null)
403
+ return postJSON(path, body)
404
+ }
405
+
296
406
  // Left-nav icon swap: DSH's settings shell renders a shared fallback gear for
297
407
  // every custom section and settings.section has no per-section icon field.
298
408
  // Like dsh-better-sidebar, we mark our own row by matching its visible label
@@ -1277,7 +1387,7 @@ function SessionPanel({ workspacesSvc }) {
1277
1387
  if (!targetPath) { setError('请选择已有工作区或输入新的目标目录路径'); return }
1278
1388
  setBusy(it.sessionId)
1279
1389
  setError(null)
1280
- postJSON('/archived-sessions/move', { sessionId: it.sessionId, targetPath })
1390
+ postActiveAware('/archived-sessions/move', { sessionId: it.sessionId, targetPath })
1281
1391
  .then((r) => {
1282
1392
  setBusy(null)
1283
1393
  setOpenMove(null)
@@ -1337,7 +1447,7 @@ function SessionPanel({ workspacesSvc }) {
1337
1447
  setBusy('__batch__')
1338
1448
  setError(null)
1339
1449
  setRetry(null)
1340
- postJSON('/archived-sessions/move-many', { sessionIds: ids, targetPath })
1450
+ postActiveAware('/archived-sessions/move-many', { sessionIds: ids, targetPath })
1341
1451
  .then((r) => {
1342
1452
  setBusy(null)
1343
1453
  setBatchMoveOpen(false)
@@ -2713,7 +2823,7 @@ function installSidebarSessionMenuAug() {
2713
2823
  e.preventDefault()
2714
2824
  closeSub()
2715
2825
  closeMenu()
2716
- postJSON('/archived-sessions/move', { sessionId: info.id, targetPath: w.path })
2826
+ postActiveAware('/archived-sessions/move', { sessionId: info.id, targetPath: w.path })
2717
2827
  .then((r) => {
2718
2828
  // 会话被 DSH 打开(活跃/未关闭)时服务端只登记排队:磁盘与工作区分组都没动。
2719
2829
  // 这条路径原先无条件报「移动成功」——2026-09-10 用户报的「说移动成功但还在
@@ -3458,7 +3568,7 @@ function installSidebarWorkspaceDrag() {
3458
3568
  clearVisuals()
3459
3569
  row.classList.add('dsm-drag-busy')
3460
3570
  try {
3461
- const result = await postJSON('/archived-sessions/move', { sessionId: item.sessionId, targetPath: target.path })
3571
+ const result = await postActiveAware('/archived-sessions/move', { sessionId: item.sessionId, targetPath: target.path })
3462
3572
  // 会话被 DSH 打开时服务端只登记排队:磁盘与工作区分组都没动。这条路径绝不能
3463
3573
  // 本地改行 + 报成功——那会让用户以为移动已完成,而侧栏刷新后该行又跳回原工作区
3464
3574
  //(用户 2026-09-10 报的「说移动成功但还在原工作区」就是这个)。排队时只如实说明。
@@ -3524,6 +3634,20 @@ function installSidebarWorkspaceDrag() {
3524
3634
  export function apply(ctx) {
3525
3635
  dsmClientCtx = ctx
3526
3636
  dsmLoadCapabilities()
3637
+ // 活跃会话心跳:页面隐藏时不发(用户没在看;host 侧 TTL 到期后自然失效,
3638
+ // 于是「无法证明活跃」→ 放行移动,不会把会话永久锁死)。
3639
+ const beat = () => {
3640
+ if (typeof document !== 'undefined' && document.hidden) return
3641
+ reportActiveSession(false).catch(() => {})
3642
+ }
3643
+ beat()
3644
+ const beatTimer = setInterval(beat, DSM_ACTIVE_HEARTBEAT_MS)
3645
+ const beatAc = typeof AbortController !== 'undefined' ? new AbortController() : null
3646
+ if (beatAc && typeof document !== 'undefined' && document.addEventListener) {
3647
+ // 标签页重新可见:立刻补一次(切回来后第一件事就可能是移动)。
3648
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) beat() }, { signal: beatAc.signal })
3649
+ }
3650
+ ctx.effect(() => () => { clearInterval(beatTimer); if (beatAc) beatAc.abort() })
3527
3651
  installSettingsNavIcons(ctx)
3528
3652
  installSidebarSessionMenuAug()
3529
3653
  // 侧栏增强的卸载清退挂到 cordis effect:插件停用/HMR 时 interval、
package/src/index.js CHANGED
@@ -24,6 +24,7 @@ import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.j
24
24
  import { createSessionMetaCache, fingerprintOf, persistFingerprintOf } from './session-meta-cache.js'
25
25
  import { createTitleIndexStore } from './title-persist-index.js'
26
26
  import { createEmptyScanStore } from './empty-scan-index.js'
27
+ import { createActiveSessionTracker } from './active-session.js'
27
28
  import { createPersistenceAdapter } from './compat/persistence.js'
28
29
  import { detectCapabilities, requireCapability } from './compat/capabilities.js'
29
30
  import { pathOwnsSession } from './path-guard.js'
@@ -124,7 +125,12 @@ function requireSessionId(value) {
124
125
  // few known accessors for the active id; if none is available we return null
125
126
  // and callers should treat the session as movable (the move path is
126
127
  // crash-safe via backup+rollback and re-syncs the live object afterwards).
127
- function getActiveSessionId(context) {
128
+ //
129
+ // 这些访问器只在旧核心(≤0.1.6)里存在。0.1.7+ / 0.2.x 把「当前选中哪个会话」
130
+ // 完全移到了客户端(uiWorkspace 持 mainView 引用),host 侧三个探测全部落空 →
131
+ // 恒 null。所以真正的判定入口是下面的 getActiveSessionIds(客户端心跳优先),
132
+ // 这个函数退化为旧核心的兜底探测。
133
+ function legacyActiveSessionId(context) {
128
134
  try {
129
135
  const a = context.get('activeSession')
130
136
  if (a != null) return (a && a.id != null) ? a.id : (typeof a === 'string' ? a : null)
@@ -140,6 +146,15 @@ function getActiveSessionId(context) {
140
146
  return null
141
147
  }
142
148
 
149
+ // 活跃会话判定:优先采信客户端心跳(0.1.7+ 唯一可信来源),没有心跳才退回旧核心
150
+ // 访问器探测。心跳过期/没有客户端连接 → 空集合,调用方按「无法证明活跃」处理。
151
+ function getActiveSessionIds(context, tracker) {
152
+ const reported = tracker ? tracker.activeIds() : null
153
+ if (reported && reported.size) return reported
154
+ const legacy = legacyActiveSessionId(context)
155
+ return legacy != null ? new Set([String(legacy)]) : new Set()
156
+ }
157
+
143
158
  // foldTitle(v3.6.1 删除路径的兜底折叠)随 v3.6.2 #7 的「删除路径零解码」一并
144
159
  // 移除;标题折叠逻辑的权威实现现在是 runtime 投影(src/index.js projectTitlesStatus)。
145
160
 
@@ -162,6 +177,9 @@ export function apply(ctx) {
162
177
  // T1(3.7.0):空白精判结论的持久层。只有**真解码成功**的判定落盘(unknown
163
178
  // 与失败兜底都不是结论),sz 指纹 + 14 天 TTL 双闸门防「同尺寸原地改写」。
164
179
  const emptyIndex = createEmptyScanStore({ dir: TRASH_DIR })
180
+ // 活跃会话(客户端心跳上报,见 src/active-session.js):0.1.7+ host 侧无法
181
+ // 自行判断「用户正在看哪个会话」,靠客户端周期性上报。过期即失效。
182
+ const activeTracker = createActiveSessionTracker()
165
183
  const EMPTY_VERDICT_TTL_MS = 14 * 24 * 3600 * 1000
166
184
 
167
185
  // P4:对「内存缓存未命中」的会话查持久索引,指纹一致才可信。
@@ -960,8 +978,9 @@ export function apply(ctx) {
960
978
  // merely looked at. When the host exposes no active-session accessor we
961
979
  // can't prove activeness, so we allow the move; the relocation below is
962
980
  // crash-safe (backup + rollback) and re-syncs the live object.
963
- const activeId = getActiveSessionId(ctx)
964
- if (activeId != null && String(activeId) === String(sid)) {
981
+ const activeIds = getActiveSessionIds(ctx, activeTracker)
982
+ const activeId = activeIds.values().next().value != null ? activeIds.values().next().value : null
983
+ if (activeIds.has(String(sid)) || (activeId != null && String(activeId) === String(sid))) {
965
984
  const error = new Error('会话正被 DSH 打开,暂时无法移动;请重启 DSH 后再试。')
966
985
  error.status = 409
967
986
  error.code = 'DSM_SESSION_BUSY'
@@ -1559,7 +1578,7 @@ export function apply(ctx) {
1559
1578
  const candidates = pickInactiveCandidates(items, {
1560
1579
  inactiveDays: days,
1561
1580
  skipStarred: store.settings.skipStarred,
1562
- activeSessionId: getActiveSessionId(ctx),
1581
+ activeSessionIds: [...getActiveSessionIds(ctx, activeTracker)],
1563
1582
  now,
1564
1583
  })
1565
1584
  let archived = 0
@@ -1906,6 +1925,25 @@ export function apply(ctx) {
1906
1925
  handler: async (req, res) => json(res, { ...capabilities, buildStamp: BUILD_STAMP }),
1907
1926
  }))
1908
1927
 
1928
+ // 活跃会话心跳(客户端 → host)。0.1.7+ 起 host 无从得知 UI 当前选中了哪个
1929
+ // 会话(见 src/active-session.js),只能由客户端上报;host 只在心跳新鲜期内
1930
+ // 采信,用它拦住「移动正在看的会话」和「自动归档正在看的会话」。
1931
+ // body: { clientId, sessionId|null }。sessionId 为空 = 该客户端当前无活跃会话。
1932
+ disposers.push(ctx.webServer.register({
1933
+ kind: 'exact',
1934
+ path: '/archived-sessions/active-session',
1935
+ handler: async (req, res) => {
1936
+ const body = await readJsonBody(req)
1937
+ const clientId = body && typeof body.clientId === 'string' ? body.clientId : 'default'
1938
+ const sid = body && typeof body.sessionId === 'string' && body.sessionId ? body.sessionId : null
1939
+ const accepted = activeTracker.report(clientId, sid)
1940
+ json(res, {
1941
+ ok: accepted,
1942
+ activeSessionIds: [...activeTracker.activeIds()],
1943
+ })
1944
+ },
1945
+ }))
1946
+
1909
1947
  disposers.push(ctx.webServer.register({
1910
1948
  kind: 'exact',
1911
1949
  path: '/archived-sessions/list',