dsh-delete-session 0.1.3 → 0.1.5

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 CHANGED
@@ -1,68 +1,175 @@
1
1
  # dsh-delete-session
2
2
 
3
- English | [中文](README.zh.md)
4
-
5
- Delete sessions right from the DSH Web sidebar. The session row's `...` menu
6
- (rename / fork / archive) gets one more action: **Delete session**. A
7
- risk-consent dialog shows the session name and id, and confirming removes the
8
- session log, its workspace accounting and its live session state.
9
-
10
- Built for DeepSeek Harness (DSH) Web, tested on `0.1.5-rc.1`.
11
-
12
- ## Features
13
-
14
- - **Delete session** in the sidebar session `...` menu, without switching to
15
- the conversation (the session id is read from the menu's React fiber, so the
16
- action always targets the row you clicked).
17
- - **Risk-consent dialog**: shows the session title and id; the confirm button
18
- stays disabled until the acknowledgement checkbox is ticked.
19
- - **Confirm once, then skip**: tick "Don't ask again" in the dialog and later
20
- deletes run directly from the menu (a toast reports the result). Hold Shift
21
- while clicking to force the dialog again, or clear
22
- `localStorage['dsh-delete-session:skip-confirm']` to restore it permanently.
23
- - **Real deletion**: stops a running agent (cancel + quiescence), flushes and
24
- detaches the live session, removes every on-disk session directory (both the
25
- raw and `session-` prefixed id), verifies through the persistence service,
26
- then detaches the session from workspace accounting so the sidebar drops the
27
- row instead of showing an orphan under "Ungrouped".
28
- - **Safe by default**: the host route is loopback-only (loopback socket,
29
- loopback Host header, same-origin check); a session still held open by DSH
30
- is refused with a clear message instead of racing the active writer.
31
- - **Bilingual**: the menu item and dialog follow the DSH interface language
32
- (zh / en) when the locale service is present, with a browser-language
33
- fallback.
34
-
35
- ## Install
3
+ **DeepSeek Harness 会话删除插件 —— 在侧边栏会话菜单里直接删除会话。** 给 DeepSeek Harness (dsh) Web 侧边栏的会话 `...` 菜单(重命名 / 分叉 / 归档)补上「删除会话」:确认一次后可直接删除,真正删除会话日志、工作区记账与宿主内存中的会话状态。
4
+
5
+ [中文](#中文) · [English](#english)
6
+
7
+ ---
8
+
9
+ ## 中文
10
+
11
+ <div align="center">
12
+ <a href="https://raw.githubusercontent.com/DDDMUC/dsh-delete-session/main/assets/menu.jpg">
13
+ <img src="https://raw.githubusercontent.com/DDDMUC/dsh-delete-session/main/assets/menu.jpg" alt="侧边栏会话菜单里的删除会话" width="820" />
14
+ </a>
15
+ <br>
16
+ <sub>▲ 侧边栏会话菜单里的「删除会话」(红色)</sub>
17
+ </div>
18
+
19
+ ### 为什么需要它
20
+
21
+ DSH 侧边栏的会话菜单只有**重命名 / 分叉 / 归档**——归档只是把会话藏起来,**不删数据**;而插件dsh-web里的全家桶里的「会话归档管理」在设置深处,删一个会话要跳好几个界面。
22
+
23
+ 这个插件把「删除」放回你最常点的位置:会话行的 `...` 菜单。
24
+
25
+ ### 特性
26
+
27
+ - **菜单内删除** —— 侧边栏会话 `...` 菜单直接多出「删除会话」;会话 ID 从菜单的 React fiber 读取,永远作用于你点的那一行,**不会切换当前会话**
28
+ - **风险确认** —— 首次删除弹出确认对话框:显示会话标题与 ID,勾选「我已了解,永久删除」后确认按钮才可用
29
+ - **确认一次后可跳过** —— 对话框里勾选「不再询问,以后直接删除」,之后点菜单项直接删除(左下角 toast 提示);**按住 Shift 点击**可临时恢复弹窗;清除 `localStorage['dsh-delete-session:skip-confirm']` 可永久恢复
30
+ - **多选批量删除** —— 会话菜单里的「多选」进入选择模式:每行左侧出现勾选圈,点整行即勾选(不会打开会话),底部操作条显示「已选 N 个 / 取消 / 删除」;点删除**立即批量执行**(无确认弹窗、无全选、所有行都可选),完成后 toast 汇总并刷新列表;Esc 或「取消」退出
31
+ - **真正的删除** —— 停止运行中的 agent(cancel + 等待空闲)→ flush / detach 活跃会话 → 删除磁盘会话目录(原始 ID 与 `session-` 前缀两种写法)→ 持久化服务复核 → 清理工作区记账,侧栏不会残留「未分组」孤儿
32
+ - **默认安全** —— 宿主路由仅限本机回环(回环 socket + 回环 Host 头 + 同源校验);被 DSH 打开占用的会话会被明确拒绝(409),绝不与活跃写者竞争
33
+ - **中英双语** —— 菜单项与对话框跟随 DSH 界面语言(zh / en)
34
+ - **零依赖** —— 不引入任何 DSH SDK,所有服务(`sessions` / `sessionPersistence` / `agents` / `workspaceRegistry` / `webServer`)都在调用时通过 cordis 上下文获取
35
+
36
+ ### 安装
37
+
38
+ npm(推荐):
39
+
40
+ ```sh
41
+ dsh plugin --profile web add dsh-delete-session
42
+ ```
43
+
44
+ 或从 GitHub 仓库直接安装:
36
45
 
37
46
  ```sh
38
47
  dsh plugin --profile web add github:DDDMUC/dsh-delete-session
39
48
  ```
40
49
 
41
- Then restart `dsh web`. The item appears in the sidebar session `...` menu.
50
+ 重启 `dsh web` 后生效。
51
+
52
+ ### 使用
53
+
54
+ 1. 把鼠标移到侧边栏的会话行,点 `...`(会话操作)
55
+ 2. 选择底部的「删除会话」
56
+ 3. 首次:勾选「我已了解,永久删除」(想省事可同时勾选「不再询问,以后直接删除」)→ 点「删除」
57
+ 4. 之后:直接删除,左下角出现 toast 提示
58
+ 5. 批量删除:会话菜单 →「多选」→ 勾选多个会话 → 底部「删除」;Esc 或「取消」退出选择模式
59
+
60
+ <div align="center">
61
+ <a href="https://raw.githubusercontent.com/DDDMUC/dsh-delete-session/main/assets/dialog.jpg">
62
+ <img src="https://raw.githubusercontent.com/DDDMUC/dsh-delete-session/main/assets/dialog.jpg" alt="删除确认对话框" width="560" />
63
+ </a>
64
+ <br>
65
+ <sub>▲ 删除确认对话框(首次删除)</sub>
66
+ </div>
67
+
68
+ ### 工作原理
69
+
70
+ - `src/index.js`(宿主):注册一条仅限回环的路由 `POST /dsh-delete-session/delete` `{ sessionId }`,执行删除链路
71
+ - `src/client.js`(浏览器):经典客户端 bundle——监听 portal 到 body 的会话菜单(`body > [role="menu"]`),沿 React fiber 链读取会话节点(`props.node.id`),克隆原生菜单行保持样式,弹出确认对话框
72
+ - 删除链路:停 agent → flush / detach 活跃会话 → 写所有权探测(被占用 → 409)→ 删除目录(两种 ID 写法 + 复核)→ 清理工作区记账
73
+
74
+ ### 已知限制
75
+
76
+ - 被 DSH 当前打开占用的会话:删除会自动等待写所有权释放(通常几秒)后完成;极少数仍被占用的情况会提示稍后重试或重启 DSH
77
+ - 菜单注入是 DOM shim:核心 UI 若调整菜单结构或行 fiber 形状,可能不再注入菜单项(不影响其他功能)
78
+ - 删除不可恢复:没有回收站,也没有撤销
79
+ - 批量删除目前**逐条顺序执行**:空闲会话很快,被 DSH 打开过的会话每条需要等待写所有权释放(最多数秒),一次选很多时整体耗时会变长(后续版本会并发化)
80
+
81
+ ### 兼容性
82
+
83
+ - DSH `0.1.5-rc.1` 实测通过
84
+ - 宿主链路对缺少服务的旧版本会退化为纯文件系统清理
85
+
86
+ 如果这个插件帮到了你,欢迎给仓库点个 ⭐([GitHub](https://github.com/DDDMUC/dsh-delete-session))——星标是开发者继续维护的最大动力,感谢支持!
87
+
88
+ ### License
89
+
90
+ MIT
91
+
92
+ ---
93
+
94
+ ## English
95
+
96
+ <div align="center">
97
+ <a href="https://raw.githubusercontent.com/DDDMUC/dsh-delete-session/main/assets/menu.jpg">
98
+ <img src="https://raw.githubusercontent.com/DDDMUC/dsh-delete-session/main/assets/menu.jpg" alt="Delete session in the sidebar menu" width="820" />
99
+ </a>
100
+ <br>
101
+ <sub>▲ "Delete session" (red) in the sidebar session menu</sub>
102
+ </div>
103
+
104
+ ### Why you need it
105
+
106
+ The DSH sidebar session menu only offers **Rename / Fork / Archive** — archive merely hides a session and keeps every byte on disk, while the session archive manager inside the dsh-web plugin suite lives deep in Settings and takes several screens to reach.
107
+
108
+ This plugin puts Delete back where you already click: the session row's `...` menu.
109
+
110
+ ### Features
111
+
112
+ - **Delete from the menu** — a "Delete session" row appears in the sidebar session `...` menu; the session id is read from the menu's React fiber, so the action always targets the row you clicked and **never switches the active conversation**
113
+ - **Risk consent** — the first delete opens a confirmation dialog with the session title and id; the confirm button unlocks only after ticking "I understand"
114
+ - **Confirm once, then skip** — tick "Don't ask again" and later deletes run directly from the menu (a toast reports the result); **hold Shift while clicking** to force the dialog back, or clear `localStorage['dsh-delete-session:skip-confirm']` to restore it permanently
115
+ - **Multi-select batch delete** — "Select multiple" in the session menu enters selection mode: every row gets a round checkbox, clicking a row toggles it (it never opens the conversation), and a bottom bar shows "N selected / Cancel / Delete". Delete runs **immediately** (no confirm dialog, no select-all, every row selectable) and reports a toast summary; Esc or Cancel exits
116
+ - **Real deletion** — stops a running agent (cancel + quiescence), flushes and detaches the live session, removes the on-disk session directories (both the raw and `session-` prefixed id), verifies through the persistence service, then cleans workspace accounting so no orphan shows up under "Ungrouped"
117
+ - **Safe by default** — the host route is loopback-only (loopback socket, loopback Host header, same-origin check); a session still held open by DSH is refused with 409 instead of racing the active writer
118
+ - **Bilingual** — the menu item and dialog follow the DSH interface language (zh / en)
119
+ - **Zero dependencies** — no DSH SDK imports; every service (`sessions`, `sessionPersistence`, `agents`, `workspaceRegistry`, `webServer`) is resolved through the cordis context at call time
120
+
121
+ ### Install
122
+
123
+ From npm (recommended):
124
+
125
+ ```sh
126
+ dsh plugin --profile web add dsh-delete-session
127
+ ```
128
+
129
+ Or straight from the GitHub repository:
130
+
131
+ ```sh
132
+ dsh plugin --profile web add github:DDDMUC/dsh-delete-session
133
+ ```
134
+
135
+ Restart `dsh web` to apply.
136
+
137
+ ### Usage
138
+
139
+ 1. Hover a session row in the sidebar and click `...` (session actions)
140
+ 2. Pick "Delete session" at the bottom
141
+ 3. First time: tick "I understand - delete permanently" (tick "Don't ask again" too if you like) and confirm
142
+ 4. Afterwards: the session is deleted directly, with a toast
143
+ 5. Batch delete: session menu -> "Select multiple" -> tick rows -> "Delete" in the bottom bar; Esc or Cancel exits
144
+
145
+ <div align="center">
146
+ <a href="https://raw.githubusercontent.com/DDDMUC/dsh-delete-session/main/assets/dialog.jpg">
147
+ <img src="https://raw.githubusercontent.com/DDDMUC/dsh-delete-session/main/assets/dialog.jpg" alt="Delete confirmation dialog" width="560" />
148
+ </a>
149
+ <br>
150
+ <sub>▲ The confirmation dialog (first delete)</sub>
151
+ </div>
152
+
153
+ ### How it works
154
+
155
+ - `src/index.js` (host): one loopback-only route `POST /dsh-delete-session/delete` `{ sessionId }` runs the deletion pipeline
156
+ - `src/client.js` (browser): a classic client bundle — watches for the portalled session menu (`body > [role="menu"]`), walks the React fiber chain to the session node (`props.node.id`), clones a native menu row for styling, and opens the confirmation dialog
157
+ - Pipeline: stop agent → flush / detach live session → write-lease probe (busy → 409) → remove directories (both id spellings + verification) → clean workspace accounting
158
+
159
+ ### Known limitations
42
160
 
43
- ## How it works
161
+ - A session DSH currently holds open: the delete waits for the write lease to be released (usually a few seconds) and then completes; in the rare case it stays busy, the dialog asks you to retry later or restart DSH
162
+ - The menu injection is a DOM shim: core UI changes to the menu markup or row fiber shape may stop the item from being injected (nothing else breaks)
163
+ - Deletion is permanent: there is no trash bin and no undo
164
+ - Batch delete currently runs **sequentially**: idle sessions are fast, while a session DSH has opened waits for its write lease (up to a few seconds), so a large selection takes longer (a future version will parallelize it)
44
165
 
45
- - `src/index.js` (host) registers one loopback-only route:
46
- `POST /dsh-delete-session/delete` `{ sessionId }`.
47
- - `src/client.js` (browser) is a classic client bundle: it watches for the
48
- portalled session menu (`body > [role="menu"]`), walks the React fiber chain
49
- to the session node (`props.node.id`), clones a native menu row for styling,
50
- and opens the confirmation dialog.
51
- - No DSH SDK imports: every service (`sessions`, `sessionPersistence`,
52
- `agents`, `workspaceRegistry`, `webServer`) is resolved through the cordis
53
- context at call time, and the plugin degrades to filesystem cleanup when a
54
- service is absent.
166
+ ### Compatibility
55
167
 
56
- ## Known limitations
168
+ - Tested on DSH `0.1.5-rc.1`
169
+ - On older versions without the services, the host pipeline degrades to filesystem cleanup
57
170
 
58
- - A session that DSH currently holds open cannot be deleted: the runtime
59
- exposes no public way to release the write ownership, so the request is
60
- refused with a message asking to restart DSH and retry.
61
- - The sidebar menu injection is a DOM shim. Core UI changes to the menu markup
62
- or the row fiber shape may require a plugin update; the menu item is simply
63
- not injected when the contract changes, and nothing else breaks.
64
- - Deletion is permanent. There is no trash bin and no undo.
171
+ If this plugin has been helpful, a ⭐ on [GitHub](https://github.com/DDDMUC/dsh-delete-session) would mean a lot — it is the biggest motivation for the developer to keep maintaining it. Thank you!
65
172
 
66
- ## License
173
+ ### License
67
174
 
68
175
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-delete-session",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Delete sessions from the DSH Web sidebar: adds a Delete action to the session row menu with a risk-consent dialog, then removes the session log, workspace accounting and live session state.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -13,7 +13,6 @@
13
13
  "src",
14
14
  "cordis.patch.yml",
15
15
  "README.md",
16
- "README.zh.md",
17
16
  "LICENSE"
18
17
  ],
19
18
  "keywords": [
package/src/client.js CHANGED
@@ -30,9 +30,16 @@ window.__ModuleLoader__.load({
30
30
  dontAsk: '不再询问,以后直接删除',
31
31
  done: '已删除会话',
32
32
  failed: '删除失败:',
33
- busy: '该会话正被 DSH 打开,暂时无法删除;请重启 DSH 后再试。',
33
+ busy: '该会话正被 DSH 打开,删除未能完成;请稍后重试(通常几秒后即可),或重启 DSH 后再试。',
34
34
  notFound: '未找到该会话(可能已被删除)。',
35
35
  untitled: '未命名会话',
36
+ multiSelect: '多选',
37
+ cancelSelect: '取消',
38
+ selectedCount: (n) => `已选 ${n} 个`,
39
+ deleteSelected: '删除',
40
+ batchDone: (n) => `已删除 ${n} 个会话`,
41
+ batchPartial: (ok, fail) => `已删除 ${ok} 个,${fail} 个失败`,
42
+ batchFailed: '批量删除失败:',
36
43
  }
37
44
 
38
45
  const en = {
@@ -46,9 +53,16 @@ window.__ModuleLoader__.load({
46
53
  dontAsk: "Don't ask again",
47
54
  done: 'Session deleted',
48
55
  failed: 'Delete failed: ',
49
- busy: 'This session is currently open in DSH and cannot be deleted yet. Restart DSH and try again.',
56
+ busy: 'This session is currently open in DSH; the delete did not complete. Try again in a moment (usually a few seconds), or restart DSH and retry.',
50
57
  notFound: 'Session not found (it may already be deleted).',
51
58
  untitled: 'Untitled session',
59
+ multiSelect: 'Select multiple',
60
+ cancelSelect: 'Cancel',
61
+ selectedCount: (n) => `${n} selected`,
62
+ deleteSelected: 'Delete',
63
+ batchDone: (n) => `Deleted ${n} session(s)`,
64
+ batchPartial: (ok, fail) => `Deleted ${ok}, ${fail} failed`,
65
+ batchFailed: 'Batch delete failed: ',
52
66
  }
53
67
 
54
68
  let localeSvc = null
@@ -158,6 +172,14 @@ window.__ModuleLoader__.load({
158
172
  '.dsdel-btn:disabled{opacity:.5;cursor:default}',
159
173
  '.dsdel-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
160
174
  '.dsdel-toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:2147483601;background:Canvas;color:CanvasText;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgba(0,0,0,.25);max-width:min(92vw,460px);cursor:pointer}',
175
+ '.dsdel-check{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;border:1.5px solid var(--dsw-alias-border-l3,rgba(128,128,128,.5));background:transparent;flex:none;margin-right:8px;box-sizing:border-box;cursor:pointer}',
176
+ '.dsdel-check-on{background:var(--dsw-alias-state-business-primary,#4c6ef5);border-color:var(--dsw-alias-state-business-primary,#4c6ef5)}',
177
+ '.dsdel-check-on::after{content:"";width:9px;height:5px;border-left:2px solid #fff;border-bottom:2px solid #fff;transform:rotate(-45deg) translateY(-1px)}',
178
+ '.dsdel-bar{position:fixed;left:12px;bottom:12px;z-index:2147483600;display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l3,rgba(128,128,128,.3));box-shadow:0 12px 32px rgba(0,0,0,.28);color:var(--dsw-alias-label-primary,inherit);font-size:13px;background:linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),var(--dsw-alias-bg-base,#fff)}',
179
+ '.dsdel-bar-count{white-space:nowrap}',
180
+ '.dsdel-bar-btn{appearance:none;min-height:30px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:var(--dsw-alias-fill-elevated,rgba(128,128,128,.08));color:var(--dsw-alias-label-primary,inherit);border-radius:8px;font-size:13px;cursor:pointer}',
181
+ '.dsdel-bar-btn:disabled{opacity:.5;cursor:default}',
182
+ '.dsdel-bar-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
161
183
  ].join('')
162
184
 
163
185
  function ensureStyle() {
@@ -305,45 +327,222 @@ window.__ModuleLoader__.load({
305
327
  })
306
328
  }
307
329
 
330
+ // --- multi-select ----------------------------------------------------------
331
+ // Entered from the session menu ("多选"). While active, every session row
332
+ // gets a round checkbox, clicking a row toggles it instead of opening the
333
+ // conversation, and a bottom bar runs the batch delete. No select-all and
334
+ // no per-row disabling: every row is selectable and the batch deletes
335
+ // immediately (the single-session flow keeps its own consent dialog).
336
+
337
+ let selecting = false
338
+ const selected = new Set()
339
+ let bar = null
340
+ let decorateRaf = null
341
+ let rowObserver = null
342
+
343
+ function sessionIdFromRow(row) {
344
+ let fiber = findFiber(row)
345
+ let guard = 0
346
+ while (fiber && guard++ < 300) {
347
+ const props = fiber.memoizedProps
348
+ if (props && props.node && typeof props.node.id === 'string' && props.node.id) return props.node.id
349
+ fiber = fiber.return
350
+ }
351
+ return null
352
+ }
353
+
354
+ function decorateRows() {
355
+ const rows = document.querySelectorAll('[role="treeitem"]')
356
+ rows.forEach((row) => {
357
+ const id = sessionIdFromRow(row)
358
+ if (!id) return
359
+ let check = row.querySelector(':scope > .dsdel-check')
360
+ if (!check) {
361
+ check = document.createElement('span')
362
+ check.className = 'dsdel-check'
363
+ check.setAttribute('data-dsh-delete-session', 'check')
364
+ row.insertBefore(check, row.firstChild)
365
+ }
366
+ check.classList.toggle('dsdel-check-on', selected.has(id))
367
+ })
368
+ }
369
+
370
+ function scheduleDecorate() {
371
+ if (decorateRaf !== null) return
372
+ decorateRaf = requestAnimationFrame(() => {
373
+ decorateRaf = null
374
+ try { decorateRows() } catch { /* best-effort DOM shim */ }
375
+ })
376
+ }
377
+
378
+ function updateBar() {
379
+ if (!bar) return
380
+ const count = bar.querySelector('.dsdel-bar-count')
381
+ if (count) count.textContent = t('selectedCount')(selected.size)
382
+ const del = bar.querySelector('.dsdel-bar-danger')
383
+ if (del) del.disabled = selected.size === 0
384
+ }
385
+
386
+ function toggleSelect(id) {
387
+ if (selected.has(id)) selected.delete(id)
388
+ else selected.add(id)
389
+ scheduleDecorate()
390
+ updateBar()
391
+ }
392
+
393
+ function onSelectClickCapture(e) {
394
+ if (!selecting) return
395
+ const row = e.target && e.target.closest ? e.target.closest('[role="treeitem"]') : null
396
+ if (!row) return
397
+ const id = sessionIdFromRow(row)
398
+ if (!id) return
399
+ e.preventDefault()
400
+ e.stopPropagation()
401
+ toggleSelect(id)
402
+ }
403
+
404
+ function onSelectKeydown(e) {
405
+ if (e.key !== 'Escape') return
406
+ e.stopPropagation()
407
+ exitSelection()
408
+ }
409
+
410
+ function buildBar() {
411
+ const el = document.createElement('div')
412
+ el.className = 'dsdel-bar'
413
+ el.setAttribute('role', 'toolbar')
414
+ const count = document.createElement('span')
415
+ count.className = 'dsdel-bar-count'
416
+ const cancel = document.createElement('button')
417
+ cancel.type = 'button'
418
+ cancel.className = 'dsdel-bar-btn'
419
+ cancel.textContent = t('cancelSelect')
420
+ cancel.addEventListener('click', (e) => { e.stopPropagation(); exitSelection() })
421
+ const del = document.createElement('button')
422
+ del.type = 'button'
423
+ del.className = 'dsdel-bar-btn dsdel-bar-danger'
424
+ del.textContent = t('deleteSelected')
425
+ del.addEventListener('click', (e) => { e.stopPropagation(); runBatchDelete() })
426
+ el.appendChild(count)
427
+ el.appendChild(cancel)
428
+ el.appendChild(del)
429
+ document.body.appendChild(el)
430
+ return el
431
+ }
432
+
433
+ function enterSelection() {
434
+ if (selecting) return
435
+ selecting = true
436
+ selected.clear()
437
+ ensureStyle()
438
+ if (!bar) bar = buildBar()
439
+ bar.style.display = 'flex'
440
+ document.addEventListener('click', onSelectClickCapture, true)
441
+ document.addEventListener('keydown', onSelectKeydown, true)
442
+ if (typeof MutationObserver === 'function') {
443
+ rowObserver = new MutationObserver(scheduleDecorate)
444
+ rowObserver.observe(document.body, { childList: true, subtree: true })
445
+ }
446
+ scheduleDecorate()
447
+ updateBar()
448
+ }
449
+
450
+ function exitSelection() {
451
+ if (!selecting) return
452
+ selecting = false
453
+ selected.clear()
454
+ if (bar) bar.style.display = 'none'
455
+ document.removeEventListener('click', onSelectClickCapture, true)
456
+ document.removeEventListener('keydown', onSelectKeydown, true)
457
+ if (rowObserver) { rowObserver.disconnect(); rowObserver = null }
458
+ document.querySelectorAll('.dsdel-check').forEach((el) => el.remove())
459
+ }
460
+
461
+ function postDeleteMany(sessionIds) {
462
+ return fetch('/dsh-delete-session/delete-many', {
463
+ method: 'POST',
464
+ headers: { 'content-type': 'application/json' },
465
+ body: JSON.stringify({ sessionIds }),
466
+ }).then(async (res) => {
467
+ let data = {}
468
+ try { data = await res.json() } catch { /* keep {} */ }
469
+ if (!res.ok || !data.ok) throw new Error((data && data.error) || `HTTP ${res.status}`)
470
+ return data
471
+ })
472
+ }
473
+
474
+ function runBatchDelete() {
475
+ const ids = [...selected]
476
+ if (ids.length === 0) return
477
+ const del = bar ? bar.querySelector('.dsdel-bar-danger') : null
478
+ if (del) del.disabled = true
479
+ postDeleteMany(ids)
480
+ .then((data) => {
481
+ const failed = data.failed || 0
482
+ const deleted = data.deleted || 0
483
+ toast(failed > 0 ? t('batchPartial')(deleted, failed) : t('batchDone')(deleted), failed > 0)
484
+ exitSelection()
485
+ refreshList()
486
+ })
487
+ .catch((reason) => {
488
+ if (del) del.disabled = false
489
+ toast(t('batchFailed') + String((reason && reason.message) || reason), true)
490
+ })
491
+ }
492
+
308
493
  // --- sidebar menu injection ------------------------------------------------
309
494
 
310
495
  const TRASH_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/><path d="M9 7V4h6v3"/></svg>'
496
+ const SELECT_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="5" cy="7" r="2"/><circle cx="5" cy="17" r="2"/><path d="M11 7h9"/><path d="M11 17h9"/></svg>'
311
497
 
312
- function augmentMenu(menuEl, info) {
313
- if (menuEl.querySelector('[data-dsh-delete-session]')) return
314
- const viewport = menuEl.querySelector('[role="presentation"]') || menuEl.firstElementChild
315
- if (!viewport) return
498
+ // Clone a native menu row so an injected action keeps the core styling.
499
+ function buildMenuItem(menuEl, label, iconSvg, danger, onClick) {
316
500
  const proto = menuEl.querySelector('[role="menuitem"]')
317
- if (!proto) return
501
+ if (!proto) return null
318
502
  const protoWrap = proto.parentElement
319
503
  const wrap = protoWrap ? protoWrap.cloneNode(false) : document.createElement('div')
320
504
  if (protoWrap && protoWrap.className) wrap.className = protoWrap.className
321
-
322
505
  const button = document.createElement('button')
323
506
  button.type = 'button'
324
507
  button.setAttribute('role', 'menuitem')
325
- button.setAttribute('data-dsh-delete-session', '1')
326
508
  if (proto.className) button.className = proto.className
327
- button.style.color = 'var(--dsw-alias-state-error-primary,#e5484d)'
328
-
509
+ if (danger) button.style.color = 'var(--dsw-alias-state-error-primary,#e5484d)'
329
510
  const icon = document.createElement('span')
330
511
  icon.style.cssText = 'display:inline-flex;flex:none;width:16px;height:16px;align-items:center;justify-content:center'
331
- icon.innerHTML = TRASH_ICON
332
- const label = document.createElement('span')
333
- label.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
334
- label.textContent = t('menu')
335
-
512
+ icon.innerHTML = iconSvg
513
+ const text = document.createElement('span')
514
+ text.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
515
+ text.textContent = label
336
516
  button.appendChild(icon)
337
- button.appendChild(label)
517
+ button.appendChild(text)
338
518
  button.addEventListener('click', (e) => {
339
519
  e.stopPropagation()
340
520
  e.preventDefault()
341
521
  closeMenu()
522
+ onClick(e)
523
+ })
524
+ wrap.appendChild(button)
525
+ return wrap
526
+ }
527
+
528
+ function augmentMenu(menuEl, info) {
529
+ if (menuEl.querySelector('[data-dsh-delete-session="1"]')) return
530
+ const viewport = menuEl.querySelector('[role="presentation"]') || menuEl.firstElementChild
531
+ if (!viewport) return
532
+
533
+ const selectWrap = buildMenuItem(menuEl, t('multiSelect'), SELECT_ICON, false, () => enterSelection())
534
+ const deleteWrap = buildMenuItem(menuEl, t('menu'), TRASH_ICON, true, (e) => {
342
535
  if (skipConfirm() && !e.shiftKey) deleteNow(info)
343
536
  else openDialog(info)
344
537
  })
345
- wrap.appendChild(button)
346
- viewport.appendChild(wrap)
538
+ if (selectWrap) {
539
+ selectWrap.firstChild.setAttribute('data-dsh-delete-session', 'select')
540
+ viewport.appendChild(selectWrap)
541
+ }
542
+ if (deleteWrap) {
543
+ deleteWrap.firstChild.setAttribute('data-dsh-delete-session', '1')
544
+ viewport.appendChild(deleteWrap)
545
+ }
347
546
 
348
547
  // The portalled menu measured its height before this row existed; nudge
349
548
  // the core placement so the grown card is re-clamped on screen.
package/src/index.js CHANGED
@@ -30,6 +30,7 @@ export const name = 'dsh-delete-session'
30
30
 
31
31
  const ROUTE_PREFIX = '/dsh-delete-session'
32
32
  const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
33
+ const MAX_BATCH = 100
33
34
 
34
35
  class HttpError extends Error {
35
36
  constructor(status, code, message) {
@@ -157,29 +158,43 @@ async function flushAndDetach(ctx, sessionId) {
157
158
  }
158
159
 
159
160
  // Probe the persistence write lease. Holding it proves no active writer is
160
- // around; it is released immediately. A busy lease means DSH still has the
161
- // session open (the runtime offers no public way to release it), so the
162
- // request is refused and the caller can retry after restarting DSH.
161
+ // around; it is released immediately. Detaching a live session releases its
162
+ // lease asynchronously (session/disposed starts a persistence retirement), so
163
+ // the probe is retried with a short backoff before the request is refused -
164
+ // in practice the first click then succeeds without forcing anything.
165
+ const BUSY_RETRY_ATTEMPTS = 10
166
+ const BUSY_RETRY_DELAY_MS = 500
167
+
168
+ function sleep(ms) {
169
+ return new Promise((resolve) => setTimeout(resolve, ms))
170
+ }
171
+
163
172
  async function assertNoActiveWriter(ctx, sessionId) {
164
173
  const sp = ctx.get('sessionPersistence')
165
174
  if (!sp || typeof sp.open !== 'function') return false
166
- let handle = null
167
- try {
168
- handle = await sp.open(sessionId, 'write')
169
- } catch (error) {
170
- const message = String((error && error.message) || error)
171
- if (/own|busy|lock|hold|writer/i.test(message)) {
172
- throw new HttpError(409, 'busy', 'session is currently open in DSH')
173
- }
174
- return false
175
- } finally {
175
+ for (let attempt = 1; attempt <= BUSY_RETRY_ATTEMPTS; attempt += 1) {
176
+ let handle = null
177
+ let busy = false
176
178
  try {
177
- if (handle && typeof handle.close === 'function') await handle.close()
178
- } catch {
179
- // lease release failure is harmless: the directory is removed next
179
+ handle = await sp.open(sessionId, 'write')
180
+ } catch (error) {
181
+ const message = String((error && error.message) || error)
182
+ if (/own|busy|lock|hold|writer/i.test(message)) {
183
+ busy = true
184
+ } else {
185
+ return false
186
+ }
187
+ } finally {
188
+ try {
189
+ if (handle && typeof handle.close === 'function') await handle.close()
190
+ } catch {
191
+ // lease release failure is harmless: the directory is removed next
192
+ }
180
193
  }
194
+ if (!busy) return true
195
+ if (attempt < BUSY_RETRY_ATTEMPTS) await sleep(BUSY_RETRY_DELAY_MS)
181
196
  }
182
- return true
197
+ throw new HttpError(409, 'busy', 'session is currently open in DSH')
183
198
  }
184
199
 
185
200
  async function detachFromWorkspace(ctx, sessionId) {
@@ -356,6 +371,56 @@ export function apply(ctx) {
356
371
  }
357
372
  },
358
373
  }))
374
+
375
+ fiber.effect(() => webServer.register({
376
+ kind: 'exact',
377
+ path: `${ROUTE_PREFIX}/delete-many`,
378
+ handler: async (req, res) => {
379
+ if (!guard(req, res)) return
380
+ if (req.method !== 'POST') {
381
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
382
+ return
383
+ }
384
+ let body = {}
385
+ try {
386
+ const raw = await readBody(req)
387
+ if (raw) body = JSON.parse(raw)
388
+ } catch {
389
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
390
+ return
391
+ }
392
+ const sessionIds = Array.isArray(body.sessionIds)
393
+ ? [...new Set(body.sessionIds.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim()))]
394
+ : []
395
+ if (sessionIds.length === 0) {
396
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionIds required' })
397
+ return
398
+ }
399
+ if (sessionIds.length > MAX_BATCH) {
400
+ sendJson(res, 400, { ok: false, code: 'invalid', error: `too many sessions (max ${MAX_BATCH})` })
401
+ return
402
+ }
403
+ // Sequential on purpose: every item runs the same guarded pipeline
404
+ // (lease retry, directory sweep, workspace accounting), so a batch can
405
+ // never interleave two deletions of the same storage.
406
+ const results = []
407
+ for (const sessionId of sessionIds) {
408
+ try {
409
+ const result = await deleteSession(ctx, sessionId)
410
+ results.push({ sessionId, ok: true, removed: result.removed })
411
+ } catch (error) {
412
+ results.push({
413
+ sessionId,
414
+ ok: false,
415
+ code: error instanceof HttpError ? error.code : 'internal',
416
+ error: String((error && error.message) || error),
417
+ })
418
+ }
419
+ }
420
+ const deleted = results.filter((item) => item.ok).length
421
+ sendJson(res, 200, { ok: true, results, deleted, failed: results.length - deleted })
422
+ },
423
+ }))
359
424
  }
360
425
 
361
426
  const webServer = ctx.get('webServer')
package/README.zh.md DELETED
@@ -1,53 +0,0 @@
1
- # dsh-delete-session
2
-
3
- [English](README.md) | 中文
4
-
5
- 在 DSH Web 侧边栏直接删除会话:会话行的 `...` 菜单(重命名 / 分叉 / 归档)里会多出一项
6
- **删除会话**。点击后弹出风险确认对话框(显示会话名称与 ID),确认后删除会话日志、
7
- 工作区记账与宿主内存中的会话状态。
8
-
9
- 为 DeepSeek Harness(DSH)Web 构建,已在 `0.1.5-rc.1` 上实测。
10
-
11
- ## 功能
12
-
13
- - **侧边栏会话菜单里的「删除会话」**:无需切换会话(会话 ID 从菜单的 React fiber 读取,
14
- 永远作用于你点击的那一行)。
15
- - **风险确认对话框**:显示会话标题与 ID;勾选「我已了解,永久删除」前确认按钮不可用。
16
- - **确认一次后可跳过弹窗**:在对话框里勾选「不再询问,以后直接删除」,之后点菜单项会直接删除(结果以 toast 提示);按住 Shift 点击可临时强制弹窗;清除 `localStorage['dsh-delete-session:skip-confirm']` 可永久恢复弹窗。
17
- - **真正的删除**:停止运行中的 agent(cancel + 等待空闲)、flush 并 detach 活跃会话、
18
- 删除磁盘上的全部会话目录(原始 ID 与 `session-` 前缀两种写法)、经持久化服务复核,
19
- 最后从工作区记账中移除,避免侧栏残留「未分组」孤儿。
20
- - **默认安全**:宿主路由仅限本机回环(回环 socket + 回环 Host 头 + 同源校验);
21
- 被 DSH 打开占用中的会话会被明确拒绝,而不是与活跃写者竞争。
22
- - **中英双语**:菜单项与对话框跟随 DSH 界面语言(存在 locale 服务时),否则按浏览器语言回退。
23
-
24
- ## 安装
25
-
26
- ```sh
27
- dsh plugin --profile web add github:DDDMUC/dsh-delete-session
28
- ```
29
-
30
- 重启 `dsh web` 后,在侧边栏会话 `...` 菜单中即可看到「删除会话」。
31
-
32
- ## 工作原理
33
-
34
- - `src/index.js`(宿主):注册一条仅限回环的路由
35
- `POST /dsh-delete-session/delete` `{ sessionId }`。
36
- - `src/client.js`(浏览器):经典客户端 bundle——监听 portal 到 body 的会话菜单
37
- (`body > [role="menu"]`),沿 React fiber 链读取会话节点(`props.node.id`),
38
- 克隆原生菜单行保持样式一致,然后打开确认对话框。
39
- - 不引入任何 DSH SDK:所有服务(`sessions`、`sessionPersistence`、`agents`、
40
- `workspaceRegistry`、`webServer`)都在调用时通过 cordis 上下文获取;
41
- 服务缺失时退化为纯文件系统清理。
42
-
43
- ## 已知限制
44
-
45
- - 被 DSH 当前打开占用的会话无法删除:运行时没有公开的写所有权释放入口,
46
- 因此会明确拒绝并提示「重启 DSH 后再试」。
47
- - 侧边栏菜单注入是 DOM shim。核心 UI 若调整菜单结构或行 fiber 形状,可能需要插件跟进更新;
48
- 契约变化时插件只是不再注入菜单项,不会影响其他功能。
49
- - 删除不可恢复:没有回收站,也没有撤销。
50
-
51
- ## License
52
-
53
- MIT