@zfdx123/dsh-session-cleaner 1.0.7 → 1.0.8

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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/client.js +117 -32
  3. 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.0,面向 DSH `^0.1.6-alpha.1`。
3
+ 给 DSH(DeepSeek Harness)补上**删除会话**的能力——从**运行中**的 web 运行时里删,不需要重启。DSH 只有「归档」:`workspace.archiveSession` 把会话 id 加进一个注册表集合,**文件仍留在磁盘上**;不存在 `session.delete`。本插件补上这个缺口:一次删除会清掉**四个表面**——live store 条目、工作区记账(归档集合与各 workspace 的会话槽)、磁盘产物目录、投影缓存行——并从三个入口暴露出来(侧边栏会话行的 ⋮ 菜单、设置页「会话清理」、一个 HTTP 路由),删除确认框复用 DSH 自己的 UI 原语,与「删除工作区」那个框同款。当前版本 1.0.8,面向 DSH `^0.1.7-rc.2`。
4
4
 
5
5
  ## 安装
6
6
 
@@ -131,7 +131,7 @@ await fetch('/api-ext/session.cleaner.diag', {
131
131
 
132
132
  ## 已知限制
133
133
 
134
- - **⋮ 菜单靠 DOM 增强。** DSH 没有给行菜单公开 Slot,所以只能在菜单打开时往里插一项。识别方式是**语义**的:同一处新增(或从它向上 6 层)同时含「归档会话」与「分叉会话/重命名」两个文案才认定为会话行菜单,插入点是包含两者的最小元素;行优先取**刚点击的 ⋮ 触发器**(`aria-label` 前缀或 `rowActions` 容器,3 秒新鲜度窗口),窗口外才退回 `closest('[role="treeitem"]')`——不靠矩形距离猜测。若 DSH 改了这些文案或结构,该项会静默不出现(设置页仍然可用),诊断路由会记录跳过原因。
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(节点、属性、子节点,以及安装器真正查询的那几个选择器),于是菜单项也能被真正构建出来、点进去删一次。图标用例的组件库替身分三档(完整 / 缺图标 / 模块表缺失)并记录「哪个图标组件被调用」,同时区分「React 渲染出来的 svg」与「自绘的 svg」——所以「用原生图标」和「退回自绘」两条路径都有断言。
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 identified by its labels.
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
- const onPointerDown = (event) => {
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
- report('trigger', { name: name.slice(0, 60) })
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
- // 回归:启动时一次拉取(下面 1142 行)与「行内 id 没解出来时」的重试
1113
- // (1124 行)会同时在飞,先发的旧响应可能后到并覆盖 `catalog`。已经插进去的
1114
- // 菜单项不会被重建(标签看起来是对的),但 `catalog` 会一直陈旧到刷新页面,
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
- return fetchCatalog().then((resolved) => {
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
- const found = findSessionMenu(added)
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) augmentMenu(found, row, resolved, ctx, dict)
1238
+ if (found.menu.isConnected && augmentMenu(found, row, resolved, ctx, dict)) disarm()
1142
1239
  })
1143
1240
  .catch(() => {})
1144
1241
  }
1145
1242
 
1146
- const observer = new MutationObserver((mutations) => {
1147
- for (const mutation of mutations) {
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
- observer.disconnect()
1166
- document.removeEventListener('pointerdown', onPointerDown, true)
1249
+ disarm()
1250
+ document.removeEventListener('pointerdown', onActivate, true)
1251
+ document.removeEventListener('click', onActivate, true)
1167
1252
  },
1168
- 'session-cleaner: menu observer',
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.7",
3
+ "version": "1.0.8",
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",