@zfdx123/dsh-session-cleaner 1.0.7 → 1.0.9
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.md +5 -5
- package/client.js +117 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zfdx123/dsh-session-cleaner
|
|
2
2
|
|
|
3
|
-
给 DSH(DeepSeek Harness)补上**删除会话**的能力——从**运行中**的 web 运行时里删,不需要重启。DSH 只有「归档」:`workspace.archiveSession` 把会话 id 加进一个注册表集合,**文件仍留在磁盘上**;不存在 `session.delete`。本插件补上这个缺口:一次删除会清掉**四个表面**——live store 条目、工作区记账(归档集合与各 workspace 的会话槽)、磁盘产物目录、投影缓存行——并从三个入口暴露出来(侧边栏会话行的 ⋮ 菜单、设置页「会话清理」、一个 HTTP 路由),删除确认框复用 DSH 自己的 UI 原语,与「删除工作区」那个框同款。当前版本 1.0.
|
|
3
|
+
给 DSH(DeepSeek Harness)补上**删除会话**的能力——从**运行中**的 web 运行时里删,不需要重启。DSH 只有「归档」:`workspace.archiveSession` 把会话 id 加进一个注册表集合,**文件仍留在磁盘上**;不存在 `session.delete`。本插件补上这个缺口:一次删除会清掉**四个表面**——live store 条目、工作区记账(归档集合与各 workspace 的会话槽)、磁盘产物目录、投影缓存行——并从三个入口暴露出来(侧边栏会话行的 ⋮ 菜单、设置页「会话清理」、一个 HTTP 路由),删除确认框复用 DSH 自己的 UI 原语,与「删除工作区」那个框同款。当前版本 1.0.9,面向 DSH `^0.1.7-rc.2`。
|
|
4
4
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
@@ -124,14 +124,14 @@ await fetch('/api-ext/session.cleaner.diag', {
|
|
|
124
124
|
|
|
125
125
|
## 前置要求
|
|
126
126
|
|
|
127
|
-
- DeepSeek Harness `^0.1.
|
|
127
|
+
- DeepSeek Harness `^0.1.7-rc.2`(`engines.dsh`),且是 **web profile**:宿主侧声明 `webServer`、`workspaceRegistry`、`sessions`、`agents`、`storageDomain`,客户端侧要 `slots`/`locale`/`sessions`/`uiWorkspace`
|
|
128
128
|
- Node `^22.19.0 || >=24.0.0`
|
|
129
|
-
- peer `@deepseek-ai/cordis ^4.0.
|
|
129
|
+
- peer `@deepseek-ai/cordis ^4.0.4`;`@deepseek-ai/dsh-agent`、`@deepseek-ai/dsh-host-webserver`、`@deepseek-ai/dsh-session`、`@deepseek-ai/dsh-workspace` 为可选 peer(都声明为 `^0.1.7-rc.2`)
|
|
130
130
|
- 会话树的位置由 `DSH_HOME` 决定;插件的 `sessionsRoot()` 与宿主解析规则一致
|
|
131
131
|
|
|
132
132
|
## 已知限制
|
|
133
133
|
|
|
134
|
-
- **⋮ 菜单靠 DOM 增强。** DSH 没有给行菜单公开 Slot
|
|
134
|
+
- **⋮ 菜单靠 DOM 增强。** DSH 没有给行菜单公开 Slot,所以只能在菜单打开时往里插一项。识别方式是**语义**的:菜单里同时含「归档会话」与「分叉会话/重命名」两个文案才认定为会话行菜单,插入点是包含两者的最小元素;行取**刚按下的 ⋮ 触发器**(`aria-label` 前缀或 `rowActions` 容器,3 秒新鲜度窗口),窗口外才退回 `closest('[role="treeitem"]')`——不靠矩形距离猜测。**观察是按需武装的**:空闲时一个 `MutationObserver` 都不建;按下 ⋮(`pointerdown`,或键盘激活按钮产生的 `click`)才在 `document.body` 上开一个**只收顶层插入**(`{ childList: true }`,无 `subtree`)的观察器,菜单落位即断开、最迟 3 秒断开。菜单本身靠 `[role="menu"]` 定位——DSH 的菜单都是挂在 `body` 下的 `role="menu"` portal(取自运行中的页面),所以一次查找只读菜单里那几个条目。这条路径曾经是**常驻的 `subtree` 观察器 + 按文案遍历每个新增子树**:逐元素读 `textContent` 的代价是子树规模的平方,打开一个 5MB 日志的会话实测**单次回调 14.6 秒、主线程冻结 15.3 秒**;改成按需武装后同一场景最长任务 0.72 秒、空闲期回调 0 次。若 DSH 改了这些文案、`role` 或挂载位置,该项会静默不出现(设置页仍然可用),诊断路由会记录跳过原因。
|
|
135
135
|
- **会话 id 从 React fiber 里读。** 行的 DOM 上没有任何携带 id 的属性,所以从行元素的 `__reactFiber$*` / `__reactInternalInstance$*` 向上找 10 层,取 `memoizedProps.node.id`(或 `props.sessionId` / `props.node.sessionId`)。拿不到才退回标题反查(走 `/api/session.list` 的目录,标题**重复时跳过注入**,避免删错);目录取不到时只损失「运行中」标记,行仍可用。
|
|
136
136
|
- **正在运行(`running`)的会话拒绝删除**,不代用户中止。
|
|
137
137
|
- **删除后会在记录表里留下一个「孤儿」agent 对象(idle)。** 宿主没有对外公开的「卸载单个 agent」入口:能正常收尾的那条路(关持久化句柄、发 `agent/disposed`、把条目移出 `ctx.agents`)是 agent-loop 工厂私有的生命周期 disposer,只挂在 owner fiber 的 teardown 链上;从外面只能拿到 `scope` 或 store 条目,单独 `detach` 会留下一个「agent 还在、store 条目没了」的悬空对象(这正是早期版本要防的幽灵行)。所以本插件**不动它**:删除后该 agent 停在 `idle` 且其会话已从 store 摘除、磁盘已删,不会再跑任何一轮(会话重开也走不到它:`session.list` 只从持久化读列表,日志已不存在)。残留对象随进程结束消失,想立刻清空就重启 `dsh web`。
|
|
@@ -150,7 +150,7 @@ npm test # 上面两条依次跑
|
|
|
150
150
|
|
|
151
151
|
`test/cases.js` 是 host 用例,只操作 `mkdtemp` 出来的 scratch 目录:`deleteSession` 的第三个参数 `{ root }` 就是为此留的测试缝,**不会碰到真实会话**。
|
|
152
152
|
|
|
153
|
-
`test/client-cases.js` 是客户端用例:把 `client.js` 挂在一个极简的 `window.__ModuleLoader__` 垫片上、配一个**照抄真 React 语义**的替身(`props.children`、函数组件即时展开、hook 槽位跨渲染存活并在 setState 时重渲染),再给 `Modal`/`Button` 提供保持契约的替身(`Modal` 关闭时渲染 `null`),于是设置页可以**无浏览器、无 DOM、无框架**地被完整驱动——点删除、在确认框里点取消/删除、点组头折叠、往搜索框里打字——并断言它渲染了什么、对注入的服务做了什么。⋮ 菜单那一路另配一个迷你 DOM
|
|
153
|
+
`test/client-cases.js` 是客户端用例:把 `client.js` 挂在一个极简的 `window.__ModuleLoader__` 垫片上、配一个**照抄真 React 语义**的替身(`props.children`、函数组件即时展开、hook 槽位跨渲染存活并在 setState 时重渲染),再给 `Modal`/`Button` 提供保持契约的替身(`Modal` 关闭时渲染 `null`),于是设置页可以**无浏览器、无 DOM、无框架**地被完整驱动——点删除、在确认框里点取消/删除、点组头折叠、往搜索框里打字——并断言它渲染了什么、对注入的服务做了什么。⋮ 菜单那一路另配一个迷你 DOM(节点、属性、子节点,以及安装器真正查询的那几个选择器;`MutationObserver` 替身会记下 `observe` 的目标与参数,且只在观察期间投递变更),于是菜单项也能被真正构建出来、点进去删一次。菜单观察这一路另有五条用例钉住代价:**空闲不建观察器**、观察参数只有 `{ childList: true }`、**没过触发器的新增子树一概不碰**(哪怕它带着菜单文案、甚至挂在会话行里)、键盘打开(`click`)也武装、菜单服务完即 `disconnect`。图标用例的组件库替身分三档(完整 / 缺图标 / 模块表缺失)并记录「哪个图标组件被调用」,同时区分「React 渲染出来的 svg」与「自绘的 svg」——所以「用原生图标」和「退回自绘」两条路径都有断言。
|
|
154
154
|
|
|
155
155
|
## 许可
|
|
156
156
|
|
package/client.js
CHANGED
|
@@ -785,6 +785,15 @@ window.__ModuleLoader__.load({
|
|
|
785
785
|
const TRIGGER_PREFIXES = ['会话“', 'Session actions for ']
|
|
786
786
|
const TRIGGER_WINDOW_MS = 3000
|
|
787
787
|
|
|
788
|
+
/**
|
|
789
|
+
* How the shell names a menu. Every menu it mounts is a `role="menu"` portal
|
|
790
|
+
* that `body` takes as its own direct child (read off the running app), and
|
|
791
|
+
* that is what makes the search behind it bounded: a menu holds a handful of
|
|
792
|
+
* items, so finding the row menu inside one is a few element reads rather
|
|
793
|
+
* than a walk over whatever the page just rendered.
|
|
794
|
+
*/
|
|
795
|
+
const MENU_SELECTOR = '[role="menu"]'
|
|
796
|
+
|
|
788
797
|
/** Innermost elements whose trimmed text is exactly one of `labels`. */
|
|
789
798
|
function leafByText(root, labels) {
|
|
790
799
|
const hits = []
|
|
@@ -1080,50 +1089,123 @@ window.__ModuleLoader__.load({
|
|
|
1080
1089
|
|
|
1081
1090
|
/**
|
|
1082
1091
|
* Watch for the row menu opening and augment it. The row comes from the ⋮
|
|
1083
|
-
* trigger the user just pressed; the menu is
|
|
1092
|
+
* trigger the user just pressed; the menu is located by its role and
|
|
1093
|
+
* identified by its labels.
|
|
1094
|
+
*
|
|
1095
|
+
* ## Why the watch is armed by the press instead of always on
|
|
1096
|
+
*
|
|
1097
|
+
* This used to be a permanent `MutationObserver` on `document.body` with
|
|
1098
|
+
* `subtree: true`, handing **every** added node to {@link findSessionMenu} —
|
|
1099
|
+
* which walks the whole added subtree reading `.textContent` per element
|
|
1100
|
+
* (cost quadratic in the subtree) and then climbs six ancestors doing it
|
|
1101
|
+
* again. Rendering a session commits thousands of nodes in a handful of
|
|
1102
|
+
* mutations, so opening a large one froze the page: measured against a 5MB
|
|
1103
|
+
* session log, a single observer callback ran for 14.6 seconds of the 15.3
|
|
1104
|
+
* second main-thread block. A menu, though, can only appear as the result of
|
|
1105
|
+
* the press that opens it, so the watch is armed by that press, reads only
|
|
1106
|
+
* top-level insertions on `body` (where the shell portals its menus), and
|
|
1107
|
+
* lets go the moment the menu it was armed for has been served.
|
|
1084
1108
|
*/
|
|
1085
1109
|
function installMenuEntry(ctx, dict) {
|
|
1086
1110
|
let pendingRow = null
|
|
1087
1111
|
let pendingAt = 0
|
|
1088
1112
|
let catalog = null
|
|
1113
|
+
let catalogSettled = false
|
|
1114
|
+
let observer = null
|
|
1115
|
+
let expiry = null
|
|
1116
|
+
|
|
1117
|
+
/** Stop watching: the window closed, or the menu has been served. */
|
|
1118
|
+
const disarm = () => {
|
|
1119
|
+
if (expiry !== null) {
|
|
1120
|
+
clearTimeout(expiry)
|
|
1121
|
+
expiry = null
|
|
1122
|
+
}
|
|
1123
|
+
if (observer !== null) observer.disconnect()
|
|
1124
|
+
}
|
|
1089
1125
|
|
|
1090
|
-
|
|
1126
|
+
/**
|
|
1127
|
+
* The row whose ⋮ trigger this event belongs to, or null when the event
|
|
1128
|
+
* belongs to no row trigger. `pointerdown` carries the press itself;
|
|
1129
|
+
* `click` is what the shell's button produces for a keyboard activation
|
|
1130
|
+
* (Enter/Space) and for a programmatic one, so both arm the watch.
|
|
1131
|
+
*/
|
|
1132
|
+
const triggerRow = (event) => {
|
|
1091
1133
|
const target = event.target
|
|
1092
|
-
if (!(target instanceof Element)) return
|
|
1134
|
+
if (!(target instanceof Element)) return null
|
|
1093
1135
|
const row = target.closest('[role="treeitem"]')
|
|
1094
|
-
if (row === null) return
|
|
1136
|
+
if (row === null) return null
|
|
1095
1137
|
const trigger = target.closest('button')
|
|
1096
|
-
if (trigger === null) return
|
|
1138
|
+
if (trigger === null) return null
|
|
1097
1139
|
const name = trigger.getAttribute('aria-label') ?? ''
|
|
1098
1140
|
const isRowTrigger =
|
|
1099
1141
|
TRIGGER_PREFIXES.some((prefix) => name.startsWith(prefix)) ||
|
|
1100
1142
|
trigger.closest('[class*="rowActions"]') !== null
|
|
1101
1143
|
if (!isRowTrigger) {
|
|
1102
1144
|
report('trigger-ignored', { name: name.slice(0, 60) })
|
|
1103
|
-
return
|
|
1145
|
+
return null
|
|
1104
1146
|
}
|
|
1147
|
+
report('trigger', { name: name.slice(0, 60) })
|
|
1148
|
+
return row
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const onActivate = (event) => {
|
|
1152
|
+
const row = triggerRow(event)
|
|
1153
|
+
if (row === null) return
|
|
1105
1154
|
pendingRow = row
|
|
1106
1155
|
pendingAt = Date.now()
|
|
1107
|
-
|
|
1156
|
+
if (observer === null) {
|
|
1157
|
+
observer = new MutationObserver((mutations) => {
|
|
1158
|
+
for (const mutation of mutations) {
|
|
1159
|
+
for (const node of mutation.addedNodes) {
|
|
1160
|
+
if (node.nodeType !== 1) continue
|
|
1161
|
+
attempt(node)
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
})
|
|
1165
|
+
}
|
|
1166
|
+
observer.observe(document.body, { childList: true })
|
|
1167
|
+
if (expiry !== null) clearTimeout(expiry)
|
|
1168
|
+
expiry = setTimeout(disarm, TRIGGER_WINDOW_MS)
|
|
1169
|
+
// Node returns a Timeout; a browser returns a number. Only one of them
|
|
1170
|
+
// can hold the process open, and a watch armed by a press must not.
|
|
1171
|
+
expiry?.unref?.()
|
|
1172
|
+
// Nothing is fetched at install: the catalog is the host's whole session
|
|
1173
|
+
// list, and a page whose row menus are never opened has no use for it.
|
|
1174
|
+
// The press is the first moment it can be needed, and starting here
|
|
1175
|
+
// gives the answer time to land before the menu does.
|
|
1176
|
+
if (catalogSettled === false) loadCatalog().catch(() => {})
|
|
1108
1177
|
}
|
|
1109
1178
|
|
|
1110
1179
|
// 目录请求的序号:只有最新一次的结果允许写进 `catalog`。
|
|
1111
1180
|
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1181
|
+
// 回归:按下 ⋮ 的那次拉取与「行内 id 没解出来时」的重试会同时在飞,先发的旧响应
|
|
1182
|
+
// 可能后到并覆盖 `catalog`。已经插进去的菜单项不会被重建(标签看起来是对的),但
|
|
1183
|
+
// `catalog` 会一直陈旧到刷新页面,于是**下一个**打开的菜单会把运行中的会话显示成
|
|
1184
|
+
// 可删除。`catalogInFlight` 让同一时刻只有一次请求在飞,序号再兜一次底。
|
|
1116
1185
|
let catalogSeq = 0
|
|
1186
|
+
let catalogInFlight = null
|
|
1117
1187
|
const loadCatalog = () => {
|
|
1188
|
+
if (catalogInFlight !== null) return catalogInFlight
|
|
1118
1189
|
const seq = (catalogSeq += 1)
|
|
1119
|
-
|
|
1190
|
+
const pending = fetchCatalog().then((resolved) => {
|
|
1120
1191
|
if (catalogSeq === seq) catalog = resolved
|
|
1192
|
+
catalogSettled = true
|
|
1193
|
+
if (catalogInFlight === pending) catalogInFlight = null
|
|
1194
|
+
report('catalog', { ok: resolved !== null, known: resolved === null ? 0 : resolved.byId.size })
|
|
1121
1195
|
return resolved
|
|
1122
1196
|
})
|
|
1197
|
+
catalogInFlight = pending
|
|
1198
|
+
return pending
|
|
1123
1199
|
}
|
|
1124
1200
|
|
|
1125
1201
|
const attempt = (added) => {
|
|
1126
|
-
|
|
1202
|
+
// Only a menu is ever read. The shell mounts each one as a
|
|
1203
|
+
// `role="menu"` portal under `body`, so this admits the portal itself
|
|
1204
|
+
// and a wrapper that carries it, and nothing else the page renders.
|
|
1205
|
+
const menu = added.matches(MENU_SELECTOR) === true ? added : added.querySelector(MENU_SELECTOR)
|
|
1206
|
+
if (menu === null) return
|
|
1207
|
+
if (menu.querySelector(`[${ITEM_ATTR}]`) !== null) return
|
|
1208
|
+
const found = findSessionMenu(menu)
|
|
1127
1209
|
if (found === undefined) return
|
|
1128
1210
|
report('menu-found', { archive: (found.anchor.textContent ?? '').trim().slice(0, 40) })
|
|
1129
1211
|
const fresh = pendingRow !== null && pendingRow.isConnected && Date.now() - pendingAt <= TRIGGER_WINDOW_MS
|
|
@@ -1132,40 +1214,43 @@ window.__ModuleLoader__.load({
|
|
|
1132
1214
|
report('skip', { reason: 'no-row', fresh })
|
|
1133
1215
|
return
|
|
1134
1216
|
}
|
|
1217
|
+
// The catalog is the only source of the `running` flag, so the menu that
|
|
1218
|
+
// arrives before it does waits for it: a session must never be offered
|
|
1219
|
+
// for deletion merely because the list has not landed yet.
|
|
1220
|
+
if (catalogSettled === false) {
|
|
1221
|
+
loadCatalog()
|
|
1222
|
+
.then((resolved) => {
|
|
1223
|
+
if (found.menu.isConnected && augmentMenu(found, row, resolved, ctx, dict)) disarm()
|
|
1224
|
+
})
|
|
1225
|
+
.catch(() => {})
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1135
1228
|
if (augmentMenu(found, row, catalog, ctx, dict)) {
|
|
1136
1229
|
pendingRow = null
|
|
1230
|
+
disarm()
|
|
1137
1231
|
return
|
|
1138
1232
|
}
|
|
1233
|
+
// The row did not resolve — usually a session created after the catalog
|
|
1234
|
+
// was fetched, whose id React's fiber did not give up. One more read of
|
|
1235
|
+
// the host's list is worth it before giving up on the menu.
|
|
1139
1236
|
loadCatalog()
|
|
1140
1237
|
.then((resolved) => {
|
|
1141
|
-
if (found.menu.isConnected
|
|
1238
|
+
if (found.menu.isConnected && augmentMenu(found, row, resolved, ctx, dict)) disarm()
|
|
1142
1239
|
})
|
|
1143
1240
|
.catch(() => {})
|
|
1144
1241
|
}
|
|
1145
1242
|
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
for (const node of mutation.addedNodes) {
|
|
1149
|
-
if (node.nodeType !== 1) continue
|
|
1150
|
-
attempt(node)
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
})
|
|
1154
|
-
document.addEventListener('pointerdown', onPointerDown, true)
|
|
1155
|
-
observer.observe(document.body, { childList: true, subtree: true })
|
|
1156
|
-
loadCatalog()
|
|
1157
|
-
.then((resolved) => {
|
|
1158
|
-
report('catalog', { ok: resolved !== null, known: resolved === null ? 0 : resolved.byId.size })
|
|
1159
|
-
})
|
|
1160
|
-
.catch(() => {})
|
|
1243
|
+
document.addEventListener('pointerdown', onActivate, true)
|
|
1244
|
+
document.addEventListener('click', onActivate, true)
|
|
1161
1245
|
report('install', { ok: true })
|
|
1162
1246
|
|
|
1163
1247
|
ctx.effect(
|
|
1164
1248
|
() => () => {
|
|
1165
|
-
|
|
1166
|
-
document.removeEventListener('pointerdown',
|
|
1249
|
+
disarm()
|
|
1250
|
+
document.removeEventListener('pointerdown', onActivate, true)
|
|
1251
|
+
document.removeEventListener('click', onActivate, true)
|
|
1167
1252
|
},
|
|
1168
|
-
'session-cleaner: menu
|
|
1253
|
+
'session-cleaner: menu watcher',
|
|
1169
1254
|
)
|
|
1170
1255
|
}
|
|
1171
1256
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zfdx123/dsh-session-cleaner",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "会话清理:在运行中的 web 运行时里彻底删除 DSH 会话——实时 store 条目、工作区记录、磁盘产物与投影缓存行一并清掉,并在会话行菜单里加一个删除入口。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/zfdx123/dsh-atelier/tree/main/packages/dsh-session-cleaner#readme",
|