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/README.en.md +16 -7
- package/README.md +16 -7
- package/lib/client.js +98 -5
- package/lib/client.js.map +2 -2
- package/lib/index.js +83 -7
- package/lib/index.js.map +3 -3
- package/package.json +9 -9
- package/src/active-session.js +87 -0
- package/src/auto-archive.js +9 -2
- package/src/client/index.jsx +135 -11
- package/src/index.js +42 -4
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.
|
|
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": "
|
|
62
|
-
"@deepseek-ai/dsh-workspace": "
|
|
63
|
-
"@deepseek-ai/dsh-session-persistence-jsonl": "
|
|
64
|
-
"@deepseek-ai/dsh-session-query": "
|
|
65
|
-
"@deepseek-ai/dsh-storage-domain": "
|
|
66
|
-
"@deepseek-ai/dsh-client-runtime": "
|
|
67
|
-
"@deepseek-ai/dsh-client-ui-slots": "
|
|
68
|
-
"@deepseek-ai/dsh-client-ui-settings": "
|
|
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
|
+
}
|
package/src/auto-archive.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
package/src/client/index.jsx
CHANGED
|
@@ -244,15 +244,58 @@ async function postJSON(path, body) {
|
|
|
244
244
|
|
|
245
245
|
// 打开一个会话(尤其是侧栏不渲染的子代理会话)。
|
|
246
246
|
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
964
|
-
|
|
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
|
-
|
|
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',
|