dsh-delete-session 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DDDMUC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # dsh-delete-session
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
+ - **Real deletion**: stops a running agent (cancel + quiescence), flushes and
20
+ detaches the live session, removes every on-disk session directory (both the
21
+ raw and `session-` prefixed id), verifies through the persistence service,
22
+ then detaches the session from workspace accounting so the sidebar drops the
23
+ row instead of showing an orphan under "Ungrouped".
24
+ - **Safe by default**: the host route is loopback-only (loopback socket,
25
+ loopback Host header, same-origin check); a session still held open by DSH
26
+ is refused with a clear message instead of racing the active writer.
27
+ - **Bilingual**: the menu item and dialog follow the DSH interface language
28
+ (zh / en) when the locale service is present, with a browser-language
29
+ fallback.
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ dsh plugin --profile web add github:DDDMUC/dsh-delete-session
35
+ ```
36
+
37
+ Then restart `dsh web`. The item appears in the sidebar session `...` menu.
38
+
39
+ ## How it works
40
+
41
+ - `src/index.js` (host) registers one loopback-only route:
42
+ `POST /dsh-delete-session/delete` `{ sessionId }`.
43
+ - `src/client.js` (browser) is a classic client bundle: it watches for the
44
+ portalled session menu (`body > [role="menu"]`), walks the React fiber chain
45
+ to the session node (`props.node.id`), clones a native menu row for styling,
46
+ and opens the confirmation dialog.
47
+ - No DSH SDK imports: every service (`sessions`, `sessionPersistence`,
48
+ `agents`, `workspaceRegistry`, `webServer`) is resolved through the cordis
49
+ context at call time, and the plugin degrades to filesystem cleanup when a
50
+ service is absent.
51
+
52
+ ## Known limitations
53
+
54
+ - A session that DSH currently holds open cannot be deleted: the runtime
55
+ exposes no public way to release the write ownership, so the request is
56
+ refused with a message asking to restart DSH and retry.
57
+ - The sidebar menu injection is a DOM shim. Core UI changes to the menu markup
58
+ or the row fiber shape may require a plugin update; the menu item is simply
59
+ not injected when the contract changes, and nothing else breaks.
60
+ - Deletion is permanent. There is no trash bin and no undo.
61
+
62
+ ## License
63
+
64
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,52 @@
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
+ - **真正的删除**:停止运行中的 agent(cancel + 等待空闲)、flush 并 detach 活跃会话、
17
+ 删除磁盘上的全部会话目录(原始 ID 与 `session-` 前缀两种写法)、经持久化服务复核,
18
+ 最后从工作区记账中移除,避免侧栏残留「未分组」孤儿。
19
+ - **默认安全**:宿主路由仅限本机回环(回环 socket + 回环 Host 头 + 同源校验);
20
+ 被 DSH 打开占用中的会话会被明确拒绝,而不是与活跃写者竞争。
21
+ - **中英双语**:菜单项与对话框跟随 DSH 界面语言(存在 locale 服务时),否则按浏览器语言回退。
22
+
23
+ ## 安装
24
+
25
+ ```sh
26
+ dsh plugin --profile web add github:DDDMUC/dsh-delete-session
27
+ ```
28
+
29
+ 重启 `dsh web` 后,在侧边栏会话 `...` 菜单中即可看到「删除会话」。
30
+
31
+ ## 工作原理
32
+
33
+ - `src/index.js`(宿主):注册一条仅限回环的路由
34
+ `POST /dsh-delete-session/delete` `{ sessionId }`。
35
+ - `src/client.js`(浏览器):经典客户端 bundle——监听 portal 到 body 的会话菜单
36
+ (`body > [role="menu"]`),沿 React fiber 链读取会话节点(`props.node.id`),
37
+ 克隆原生菜单行保持样式一致,然后打开确认对话框。
38
+ - 不引入任何 DSH SDK:所有服务(`sessions`、`sessionPersistence`、`agents`、
39
+ `workspaceRegistry`、`webServer`)都在调用时通过 cordis 上下文获取;
40
+ 服务缺失时退化为纯文件系统清理。
41
+
42
+ ## 已知限制
43
+
44
+ - 被 DSH 当前打开占用的会话无法删除:运行时没有公开的写所有权释放入口,
45
+ 因此会明确拒绝并提示「重启 DSH 后再试」。
46
+ - 侧边栏菜单注入是 DOM shim。核心 UI 若调整菜单结构或行 fiber 形状,可能需要插件跟进更新;
47
+ 契约变化时插件只是不再注入菜单项,不会影响其他功能。
48
+ - 删除不可恢复:没有回收站,也没有撤销。
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,7 @@
1
+ # dsh-delete-session bundle patch: inserts the plugin row into the profile
2
+ # roster. The node half serves the /dsh-delete-session/* JSON routes; the
3
+ # browser half (exports "./client") injects the sidebar session-row menu item
4
+ # and the confirmation dialog.
5
+ - insert:
6
+ - id: dsh-delete-session
7
+ name: 'dsh-delete-session'
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "dsh-delete-session",
3
+ "version": "0.1.1",
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
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./client": "./src/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "cordis.patch.yml",
15
+ "README.md",
16
+ "README.zh.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "deepseek-harness",
21
+ "dsh",
22
+ "dsh-plugin",
23
+ "session",
24
+ "delete"
25
+ ],
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/DDDMUC/dsh-delete-session.git"
30
+ },
31
+ "dsh": {
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ },
35
+ "client": {
36
+ "inject": [
37
+ "@deepseek-ai/dsh-client-runtime"
38
+ ],
39
+ "platform": "web"
40
+ }
41
+ }
42
+ }
package/src/client.js ADDED
@@ -0,0 +1,347 @@
1
+ // dsh-delete-session - client half.
2
+ //
3
+ // DSH's sidebar session-row "..." menu (rename / fork / archive) is rendered
4
+ // by the core UI and offers no plugin slot for extra rows, so the delete item
5
+ // is injected at the DOM level:
6
+ //
7
+ // * watch for the portalled session menu (body > [role="menu"]);
8
+ // * walk the React fiber chain from that menu to the session node and read
9
+ // its real id (props.node.id) - no title matching, no conversation jump;
10
+ // * clone a native menu row so the new item keeps the core styling;
11
+ // * open a risk-consent dialog and POST /dsh-delete-session/delete.
12
+ //
13
+ // The module is a classic client bundle (client-modules protocol): it
14
+ // registers a factory with window.__ModuleLoader__ and returns apply().
15
+ // No React, no SDK imports - theme tokens and DOM only, so it survives core
16
+ // UI revisions that keep the menu contract.
17
+ window.__ModuleLoader__.load({
18
+ id: 'dsh-delete-session',
19
+ factory: () => {
20
+ const NS = 'dsh-delete-session'
21
+
22
+ const zh = {
23
+ menu: '删除会话',
24
+ title: '删除会话',
25
+ desc: '将永久删除该会话及其全部对话记录(会话日志、工作区记账与统计),此操作不可恢复。',
26
+ cancel: '取消',
27
+ confirm: '删除',
28
+ deleting: '删除中…',
29
+ ack: '我已了解,永久删除',
30
+ done: '已删除会话',
31
+ failed: '删除失败:',
32
+ busy: '该会话正被 DSH 打开,暂时无法删除;请重启 DSH 后再试。',
33
+ notFound: '未找到该会话(可能已被删除)。',
34
+ untitled: '未命名会话',
35
+ }
36
+
37
+ const en = {
38
+ menu: 'Delete session',
39
+ title: 'Delete session',
40
+ desc: 'This permanently deletes the session and all of its conversation records (log, workspace accounting and statistics). This action cannot be undone.',
41
+ cancel: 'Cancel',
42
+ confirm: 'Delete',
43
+ deleting: 'Deleting...',
44
+ ack: 'I understand - delete permanently',
45
+ done: 'Session deleted',
46
+ failed: 'Delete failed: ',
47
+ busy: 'This session is currently open in DSH and cannot be deleted yet. Restart DSH and try again.',
48
+ notFound: 'Session not found (it may already be deleted).',
49
+ untitled: 'Untitled session',
50
+ }
51
+
52
+ let localeSvc = null
53
+ let sessionsSvc = null
54
+
55
+ function browserLang() {
56
+ if (typeof navigator === 'undefined') return 'zh'
57
+ for (const tag of (navigator.languages || []).concat([navigator.language])) {
58
+ const primary = String(tag || '').toLowerCase().split('-')[0]
59
+ if (primary === 'zh' || primary === 'en') return primary
60
+ }
61
+ return 'zh'
62
+ }
63
+
64
+ function t(key) {
65
+ if (localeSvc && typeof localeSvc.translate === 'function') {
66
+ try {
67
+ const text = localeSvc.translate(NS, key)
68
+ if (typeof text === 'string' && text !== key) return text
69
+ } catch {
70
+ // fall back to the built-in dictionaries
71
+ }
72
+ }
73
+ return (browserLang() === 'en' ? en : zh)[key] || key
74
+ }
75
+
76
+ // --- fiber access ----------------------------------------------------------
77
+
78
+ function findFiber(el) {
79
+ const key = Object.keys(el).find((k) => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'))
80
+ return key ? el[key] : null
81
+ }
82
+
83
+ // Walk up from the portalled menu element to the session row component
84
+ // that carries `node.id`. A menu without such a node is not a session
85
+ // menu (settings, header dropdowns) and is left alone.
86
+ function sessionInfoFromMenu(menuEl) {
87
+ let fiber = findFiber(menuEl)
88
+ let guard = 0
89
+ while (fiber && guard++ < 300) {
90
+ const props = fiber.memoizedProps
91
+ if (props && props.node && typeof props.node.id === 'string' && props.node.id) {
92
+ const title = props.node.title || props.node.name || null
93
+ return { id: props.node.id, title: typeof title === 'string' ? title : null }
94
+ }
95
+ fiber = fiber.return
96
+ }
97
+ return null
98
+ }
99
+
100
+ // --- styles ----------------------------------------------------------------
101
+
102
+ const CSS = [
103
+ '.dsdel-backdrop{position:fixed;inset:0;z-index:2147483600;background:rgba(0,0,0,.58);display:flex;align-items:center;justify-content:center;padding:16px}',
104
+ '.dsdel-dialog{width:min(420px,92vw);border:1px solid var(--dsw-alias-border-l3,rgba(128,128,128,.3));border-radius:14px;padding:18px;box-shadow:0 16px 48px rgba(0,0,0,.32);display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary,inherit);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);-webkit-backdrop-filter:blur(18px) saturate(140%);backdrop-filter:blur(18px) saturate(140%)}',
105
+ '.dsdel-title{font-size:15px;font-weight:650;color:var(--dsw-alias-label-primary,inherit);margin:0}',
106
+ '.dsdel-text{font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary,#666);margin:0;word-break:break-all}',
107
+ '.dsdel-meta{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-tertiary,#888);margin:0;word-break:break-all}',
108
+ '.dsdel-ack{display:flex;align-items:center;gap:8px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,inherit);cursor:pointer}',
109
+ '.dsdel-ack input{width:15px;height:15px;accent-color:var(--dsw-alias-state-error-primary,#e5484d);cursor:pointer;flex:none}',
110
+ '.dsdel-error{font-size:12px;line-height:16px;color:var(--dsw-alias-state-error-primary,#e5484d);margin:0}',
111
+ '.dsdel-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:2px}',
112
+ '.dsdel-btn{appearance:none;min-height:32px;padding:0 14px;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}',
113
+ '.dsdel-btn:disabled{opacity:.5;cursor:default}',
114
+ '.dsdel-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
115
+ '.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}',
116
+ ].join('')
117
+
118
+ function ensureStyle() {
119
+ if (document.querySelector('style[data-dsh-delete-session]')) return
120
+ const style = document.createElement('style')
121
+ style.dataset.dshSessionDelete = '1'
122
+ style.textContent = CSS
123
+ document.head.appendChild(style)
124
+ }
125
+
126
+ // --- feedback --------------------------------------------------------------
127
+
128
+ function closeMenu() {
129
+ try { document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) } catch { /* ignore */ }
130
+ try { document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) } catch { /* ignore */ }
131
+ }
132
+
133
+ function toast(text, isError) {
134
+ const el = document.createElement('div')
135
+ el.className = 'dsdel-toast'
136
+ el.setAttribute('role', 'status')
137
+ el.textContent = text
138
+ if (isError) {
139
+ el.style.background = '#4A1D1D'
140
+ el.style.color = '#FFD9D9'
141
+ }
142
+ document.body.appendChild(el)
143
+ const kill = () => el.remove()
144
+ el.addEventListener('click', kill)
145
+ setTimeout(kill, 3200)
146
+ }
147
+
148
+ function refreshList() {
149
+ const svc = sessionsSvc
150
+ if (svc) {
151
+ try {
152
+ if (typeof svc.refresh === 'function') { svc.refresh(); return }
153
+ if (typeof svc.refreshList === 'function') { svc.refreshList(); return }
154
+ } catch {
155
+ // fall through to reload
156
+ }
157
+ }
158
+ try { window.location.reload() } catch { /* ignore */ }
159
+ }
160
+
161
+ // --- dialog ----------------------------------------------------------------
162
+
163
+ function openDialog(info) {
164
+ ensureStyle()
165
+ const backdrop = document.createElement('div')
166
+ backdrop.className = 'dsdel-backdrop'
167
+ const dialog = document.createElement('div')
168
+ dialog.className = 'dsdel-dialog'
169
+
170
+ const title = document.createElement('h3')
171
+ title.className = 'dsdel-title'
172
+ title.textContent = t('title')
173
+
174
+ const meta = document.createElement('p')
175
+ meta.className = 'dsdel-meta'
176
+ meta.textContent = `${info.title || t('untitled')} · ${info.id}`
177
+
178
+ const text = document.createElement('p')
179
+ text.className = 'dsdel-text'
180
+ text.textContent = t('desc')
181
+
182
+ const ack = document.createElement('label')
183
+ ack.className = 'dsdel-ack'
184
+ const checkbox = document.createElement('input')
185
+ checkbox.type = 'checkbox'
186
+ const ackText = document.createElement('span')
187
+ ackText.textContent = t('ack')
188
+ ack.appendChild(checkbox)
189
+ ack.appendChild(ackText)
190
+
191
+ const error = document.createElement('p')
192
+ error.className = 'dsdel-error'
193
+ error.style.display = 'none'
194
+
195
+ const actions = document.createElement('div')
196
+ actions.className = 'dsdel-actions'
197
+ const cancel = document.createElement('button')
198
+ cancel.type = 'button'
199
+ cancel.className = 'dsdel-btn'
200
+ cancel.textContent = t('cancel')
201
+ const confirm = document.createElement('button')
202
+ confirm.type = 'button'
203
+ confirm.className = 'dsdel-btn dsdel-danger'
204
+ confirm.textContent = t('confirm')
205
+ confirm.disabled = true
206
+ actions.appendChild(cancel)
207
+ actions.appendChild(confirm)
208
+
209
+ dialog.appendChild(title)
210
+ dialog.appendChild(meta)
211
+ dialog.appendChild(text)
212
+ dialog.appendChild(ack)
213
+ dialog.appendChild(error)
214
+ dialog.appendChild(actions)
215
+ backdrop.appendChild(dialog)
216
+ document.body.appendChild(backdrop)
217
+
218
+ let busy = false
219
+ const close = () => {
220
+ if (busy) return
221
+ backdrop.remove()
222
+ }
223
+ checkbox.addEventListener('change', () => { confirm.disabled = !checkbox.checked })
224
+ cancel.addEventListener('click', close)
225
+ backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close() })
226
+ backdrop.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
227
+
228
+ confirm.addEventListener('click', () => {
229
+ if (busy || !checkbox.checked) return
230
+ busy = true
231
+ confirm.disabled = true
232
+ cancel.disabled = true
233
+ confirm.textContent = t('deleting')
234
+ error.style.display = 'none'
235
+ fetch('/dsh-delete-session/delete', {
236
+ method: 'POST',
237
+ headers: { 'content-type': 'application/json' },
238
+ body: JSON.stringify({ sessionId: info.id }),
239
+ })
240
+ .then(async (res) => {
241
+ let data = {}
242
+ try { data = await res.json() } catch { /* keep {} */ }
243
+ if (!res.ok || !data.ok) {
244
+ const code = data && data.code
245
+ const message = code === 'busy'
246
+ ? t('busy')
247
+ : code === 'not-found'
248
+ ? t('notFound')
249
+ : (data && data.error) || `HTTP ${res.status}`
250
+ throw new Error(message)
251
+ }
252
+ backdrop.remove()
253
+ toast(t('done'))
254
+ refreshList()
255
+ })
256
+ .catch((reason) => {
257
+ busy = false
258
+ confirm.disabled = !checkbox.checked
259
+ cancel.disabled = false
260
+ confirm.textContent = t('confirm')
261
+ error.textContent = t('failed') + String((reason && reason.message) || reason)
262
+ error.style.display = 'block'
263
+ })
264
+ })
265
+ }
266
+
267
+ // --- sidebar menu injection ------------------------------------------------
268
+
269
+ 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>'
270
+
271
+ function augmentMenu(menuEl, info) {
272
+ if (menuEl.querySelector('[data-dsh-delete-session]')) return
273
+ const viewport = menuEl.querySelector('[role="presentation"]') || menuEl.firstElementChild
274
+ if (!viewport) return
275
+ const proto = menuEl.querySelector('[role="menuitem"]')
276
+ if (!proto) return
277
+ const protoWrap = proto.parentElement
278
+ const wrap = protoWrap ? protoWrap.cloneNode(false) : document.createElement('div')
279
+ if (protoWrap && protoWrap.className) wrap.className = protoWrap.className
280
+
281
+ const button = document.createElement('button')
282
+ button.type = 'button'
283
+ button.setAttribute('role', 'menuitem')
284
+ button.setAttribute('data-dsh-delete-session', '1')
285
+ if (proto.className) button.className = proto.className
286
+ button.style.color = 'var(--dsw-alias-state-error-primary,#e5484d)'
287
+
288
+ const icon = document.createElement('span')
289
+ icon.style.cssText = 'display:inline-flex;flex:none;width:16px;height:16px;align-items:center;justify-content:center'
290
+ icon.innerHTML = TRASH_ICON
291
+ const label = document.createElement('span')
292
+ label.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
293
+ label.textContent = t('menu')
294
+
295
+ button.appendChild(icon)
296
+ button.appendChild(label)
297
+ button.addEventListener('click', (e) => {
298
+ e.stopPropagation()
299
+ e.preventDefault()
300
+ closeMenu()
301
+ openDialog(info)
302
+ })
303
+ wrap.appendChild(button)
304
+ viewport.appendChild(wrap)
305
+
306
+ // The portalled menu measured its height before this row existed; nudge
307
+ // the core placement so the grown card is re-clamped on screen.
308
+ window.dispatchEvent(new Event('resize'))
309
+ }
310
+
311
+ function install() {
312
+ if (typeof document === 'undefined' || !document.body) return null
313
+ ensureStyle()
314
+ const seen = new WeakSet()
315
+ const scan = () => {
316
+ const menus = document.querySelectorAll('body > [role="menu"]')
317
+ menus.forEach((menuEl) => {
318
+ if (seen.has(menuEl)) return
319
+ let info = null
320
+ try { info = sessionInfoFromMenu(menuEl) } catch { /* ignore */ }
321
+ if (!info) return
322
+ seen.add(menuEl)
323
+ try { augmentMenu(menuEl, info) } catch { /* best-effort DOM shim */ }
324
+ })
325
+ }
326
+ scan()
327
+ const observer = new MutationObserver(scan)
328
+ observer.observe(document.body, { childList: true, subtree: false })
329
+ return () => observer.disconnect()
330
+ }
331
+
332
+ function apply(ctx) {
333
+ sessionsSvc = ctx.get('sessions') || null
334
+ if (!sessionsSvc) {
335
+ try { ctx.inject(['sessions'], (sub) => { sessionsSvc = sub.sessions }) } catch { /* optional */ }
336
+ }
337
+ localeSvc = ctx.get('locale') || null
338
+ if (!localeSvc) {
339
+ try { ctx.inject(['locale'], (sub) => { localeSvc = sub.locale }) } catch { /* optional */ }
340
+ }
341
+ const dispose = install()
342
+ if (dispose) ctx.effect(() => dispose)
343
+ }
344
+
345
+ return { apply }
346
+ },
347
+ })
package/src/index.js ADDED
@@ -0,0 +1,369 @@
1
+ // dsh-delete-session - host half.
2
+ //
3
+ // One loopback-only HTTP route serves the client menu item:
4
+ //
5
+ // POST /dsh-delete-session/delete { sessionId }
6
+ //
7
+ // Deletion pipeline, kept consistent with the live DSH services so in-memory
8
+ // state and on-disk units cannot disagree after the request returns:
9
+ //
10
+ // 1. stop a running agent (cancel + time-boxed quiescence) when one exists;
11
+ // 2. flush and detach the live session so its async dispose cannot rewrite
12
+ // the log directory after we remove it, and wait for the persistence
13
+ // retirement that session/disposed starts;
14
+ // 3. take the persistence write lease briefly: a busy lease means the
15
+ // session is still open in DSH, so the request is refused with 409
16
+ // instead of racing the active writer;
17
+ // 4. remove every on-disk session directory (raw id and session- prefix);
18
+ // 5. verify through the persistence service and a directory re-scan;
19
+ // 6. detach the session from workspace accounting so the sidebar drops the
20
+ // row instead of re-grouping an orphan under "Ungrouped".
21
+ //
22
+ // The plugin imports nothing from the DSH SDK: every service is resolved
23
+ // through the cordis context at call time, so the module loads on any profile
24
+ // and degrades to the filesystem path when a service is absent.
25
+ import fs from 'node:fs'
26
+ import os from 'node:os'
27
+ import path from 'node:path'
28
+
29
+ export const name = 'dsh-delete-session'
30
+
31
+ const ROUTE_PREFIX = '/dsh-delete-session'
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
+
34
+ class HttpError extends Error {
35
+ constructor(status, code, message) {
36
+ super(message)
37
+ this.status = status
38
+ this.code = code
39
+ }
40
+ }
41
+
42
+ // --- paths -------------------------------------------------------------------
43
+
44
+ function dshHome() {
45
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
46
+ }
47
+
48
+ function sessionsRoot() {
49
+ return path.join(dshHome(), 'sessions')
50
+ }
51
+
52
+ // A session id travels in two spellings: the raw uuid and `session-<uuid>`.
53
+ // The on-disk JSONL backend uses the prefixed form for the directory name,
54
+ // while workspace and projection rows may carry either. Clean both.
55
+ function idVariants(sessionId) {
56
+ const out = new Set([sessionId])
57
+ if (sessionId.startsWith('session-')) out.add(sessionId.slice('session-'.length))
58
+ else out.add(`session-${sessionId}`)
59
+ return [...out]
60
+ }
61
+
62
+ // Scan every workspace directory under $DSH_HOME/sessions for the session's
63
+ // directories, so the workspace-path encoding never has to be re-derived here.
64
+ function findSessionDirs(sessionId) {
65
+ const root = sessionsRoot()
66
+ let entries
67
+ try {
68
+ entries = fs.readdirSync(root, { withFileTypes: true })
69
+ } catch {
70
+ return []
71
+ }
72
+ const variants = idVariants(sessionId)
73
+ const found = []
74
+ for (const entry of entries) {
75
+ if (!entry.isDirectory()) continue
76
+ for (const variant of variants) {
77
+ const candidate = path.join(root, entry.name, variant)
78
+ try {
79
+ if (fs.statSync(candidate).isDirectory() && !found.includes(candidate)) found.push(candidate)
80
+ } catch {
81
+ // keep scanning
82
+ }
83
+ }
84
+ }
85
+ return found
86
+ }
87
+
88
+ function removeSessionDirs(sessionId) {
89
+ const dirs = findSessionDirs(sessionId)
90
+ for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true })
91
+ return dirs
92
+ }
93
+
94
+ // --- live state --------------------------------------------------------------
95
+
96
+ // Stop a running agent before its session disappears: cancel the active turn
97
+ // and wait, time-boxed, so a stuck driver never blocks the deletion.
98
+ async function quiesceAgent(ctx, sessionId) {
99
+ const agents = ctx.get('agents')
100
+ if (!agents || typeof agents.get !== 'function') return false
101
+ const agent = agents.get(sessionId)
102
+ if (!agent) return false
103
+ try {
104
+ if (typeof agent.cancel === 'function') agent.cancel({ kind: 'user' })
105
+ } catch {
106
+ // agent may already be settling
107
+ }
108
+ if (typeof agent.whenIdle === 'function') {
109
+ try {
110
+ await Promise.race([agent.whenIdle(), new Promise((resolve) => setTimeout(resolve, 10000))])
111
+ } catch {
112
+ // proceed with deletion anyway
113
+ }
114
+ }
115
+ return true
116
+ }
117
+
118
+ // Flush a live session, then detach its entered record so DSH emits
119
+ // host/session-removed and the client list drops the row. The persistence
120
+ // retirement started by session/disposed is awaited before the caller unlinks
121
+ // anything, because its final drain could otherwise recreate the directory.
122
+ async function flushAndDetach(ctx, sessionId) {
123
+ const sessions = ctx.get('sessions')
124
+ let flushed = false
125
+ let detached = false
126
+ if (sessions) {
127
+ for (const variant of idVariants(sessionId)) {
128
+ const live = typeof sessions.get === 'function' ? sessions.get(variant) : undefined
129
+ if (!live) continue
130
+ if (typeof sessions.flush === 'function') {
131
+ try {
132
+ await sessions.flush(live)
133
+ flushed = true
134
+ } catch {
135
+ // the log is removed anyway
136
+ }
137
+ }
138
+ try {
139
+ const entered = sessions.store && typeof sessions.store.get === 'function' ? sessions.store.get(variant) : undefined
140
+ if (entered && typeof entered.detach === 'function') {
141
+ entered.detach()
142
+ detached = true
143
+ }
144
+ } catch {
145
+ // best effort: the directory removal below is the authority
146
+ }
147
+ }
148
+ }
149
+ const sp = ctx.get('sessionPersistence')
150
+ try {
151
+ const retirement = sp && sp.retirements && typeof sp.retirements.get === 'function' ? sp.retirements.get(sessionId) : undefined
152
+ if (retirement && typeof retirement.then === 'function') await retirement
153
+ } catch {
154
+ // no retirement registry: nothing to wait for
155
+ }
156
+ return { flushed, detached }
157
+ }
158
+
159
+ // 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.
163
+ async function assertNoActiveWriter(ctx, sessionId) {
164
+ const sp = ctx.get('sessionPersistence')
165
+ 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 {
176
+ try {
177
+ if (handle && typeof handle.close === 'function') await handle.close()
178
+ } catch {
179
+ // lease release failure is harmless: the directory is removed next
180
+ }
181
+ }
182
+ return true
183
+ }
184
+
185
+ async function detachFromWorkspace(ctx, sessionId) {
186
+ const workspace = ctx.get('workspaceRegistry')
187
+ if (!workspace) return false
188
+ const variants = idVariants(sessionId)
189
+ let touched = false
190
+ try {
191
+ if (typeof workspace.list === 'function') {
192
+ for (const entry of workspace.list()) {
193
+ if (!entry || !Array.isArray(entry.sessionIds)) continue
194
+ if (!variants.some((variant) => entry.sessionIds.includes(variant))) continue
195
+ try {
196
+ await entry.detachSession(sessionId)
197
+ touched = true
198
+ } catch {
199
+ // keep going: the header index cleanup below still applies
200
+ }
201
+ }
202
+ }
203
+ } catch {
204
+ // registry unavailable
205
+ }
206
+ try {
207
+ if (workspace.sessionPaths && typeof workspace.sessionPaths.delete === 'function') {
208
+ for (const variant of variants) workspace.sessionPaths.delete(variant)
209
+ }
210
+ } catch {
211
+ // private map absent on this version
212
+ }
213
+ try {
214
+ if (workspace.headers && typeof workspace.headers.delete === 'function') {
215
+ for (const variant of variants) workspace.headers.delete(variant)
216
+ }
217
+ } catch {
218
+ // private map absent on this version
219
+ }
220
+ return touched
221
+ }
222
+
223
+ // --- core delete -------------------------------------------------------------
224
+
225
+ async function deleteSession(ctx, sessionId) {
226
+ if (!SESSION_ID_RE.test(sessionId)) {
227
+ throw new HttpError(400, 'invalid', 'invalid session id')
228
+ }
229
+ const stopped = await quiesceAgent(ctx, sessionId)
230
+ const { flushed, detached } = await flushAndDetach(ctx, sessionId)
231
+ await assertNoActiveWriter(ctx, sessionId)
232
+
233
+ // Remove the on-disk directories first. If the filesystem refuses, fail
234
+ // before touching workspace accounting so a half-deleted session cannot
235
+ // fall out of its group into "Ungrouped".
236
+ let removed = removeSessionDirs(sessionId)
237
+ await new Promise((resolve) => setImmediate(resolve))
238
+ removed = removed.concat(removeSessionDirs(sessionId))
239
+
240
+ const sp = ctx.get('sessionPersistence')
241
+ if (sp && typeof sp.stat === 'function') {
242
+ const after = await sp.stat(sessionId).catch(() => undefined)
243
+ if (after) {
244
+ throw new HttpError(500, 'verify-failed', 'session is still visible to the persistence service after deletion')
245
+ }
246
+ }
247
+ const leftover = findSessionDirs(sessionId)
248
+ if (leftover.length > 0) {
249
+ throw new HttpError(500, 'verify-failed', 'session directories could not be fully removed')
250
+ }
251
+
252
+ const workspaceDetached = await detachFromWorkspace(ctx, sessionId)
253
+ if (removed.length === 0 && !workspaceDetached && !flushed && !detached) {
254
+ throw new HttpError(404, 'not-found', 'session not found')
255
+ }
256
+ return { removed: removed.length, stopped, flushed, detached, workspaceDetached }
257
+ }
258
+
259
+ // --- http --------------------------------------------------------------------
260
+
261
+ function isLoopbackAddress(address) {
262
+ if (typeof address !== 'string' || address.length === 0) return false
263
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.')
264
+ }
265
+
266
+ function isLocalHostHeader(host) {
267
+ if (typeof host !== 'string' || host.length === 0) return false
268
+ const name = host.split(':')[0].replace(/^\[|\]$/g, '').toLowerCase()
269
+ return name === 'localhost' || name === '127.0.0.1' || name === '::1'
270
+ }
271
+
272
+ function sendJson(res, status, body) {
273
+ const payload = JSON.stringify(body)
274
+ res.writeHead(status, {
275
+ 'content-type': 'application/json; charset=utf-8',
276
+ 'content-length': Buffer.byteLength(payload),
277
+ })
278
+ res.end(payload)
279
+ }
280
+
281
+ function readBody(req) {
282
+ return new Promise((resolve, reject) => {
283
+ let data = ''
284
+ req.on('data', (chunk) => {
285
+ data += chunk
286
+ if (data.length > 1e6) req.destroy()
287
+ })
288
+ req.on('end', () => resolve(data))
289
+ req.on('error', reject)
290
+ req.on('aborted', () => reject(new Error('aborted')))
291
+ })
292
+ }
293
+
294
+ // Destructive local endpoint: loopback socket, loopback Host header, and a
295
+ // same-origin check when the browser sends Origin. Tunneled or LAN clients
296
+ // get 403 before any session data is touched.
297
+ function guard(req, res) {
298
+ if (!isLoopbackAddress(req.socket && req.socket.remoteAddress)) {
299
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'loopback only' })
300
+ return false
301
+ }
302
+ const host = req.headers.host
303
+ if (!isLocalHostHeader(host)) {
304
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'unexpected host' })
305
+ return false
306
+ }
307
+ const origin = req.headers.origin
308
+ if (typeof origin === 'string' && origin.length > 0) {
309
+ let originHost = null
310
+ try {
311
+ originHost = new URL(origin).host
312
+ } catch {
313
+ originHost = null
314
+ }
315
+ if (originHost !== host) {
316
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'cross-origin request' })
317
+ return false
318
+ }
319
+ }
320
+ return true
321
+ }
322
+
323
+ // --- plugin ------------------------------------------------------------------
324
+
325
+ export function apply(ctx) {
326
+ const registerRoutes = (webServer, fiber) => {
327
+ fiber.effect(() => webServer.register({
328
+ kind: 'exact',
329
+ path: `${ROUTE_PREFIX}/delete`,
330
+ handler: async (req, res) => {
331
+ if (!guard(req, res)) return
332
+ if (req.method !== 'POST') {
333
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
334
+ return
335
+ }
336
+ let body = {}
337
+ try {
338
+ const raw = await readBody(req)
339
+ if (raw) body = JSON.parse(raw)
340
+ } catch {
341
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
342
+ return
343
+ }
344
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
345
+ if (!sessionId) {
346
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionId required' })
347
+ return
348
+ }
349
+ try {
350
+ const result = await deleteSession(ctx, sessionId)
351
+ sendJson(res, 200, { ok: true, ...result })
352
+ } catch (error) {
353
+ const status = error instanceof HttpError ? error.status : 500
354
+ const code = error instanceof HttpError ? error.code : 'internal'
355
+ sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
356
+ }
357
+ },
358
+ }))
359
+ }
360
+
361
+ const webServer = ctx.get('webServer')
362
+ if (webServer) {
363
+ registerRoutes(webServer, ctx)
364
+ } else {
365
+ // A terminal-only profile never provides a web surface; register once one
366
+ // appears instead of waiting on a service that will never arrive.
367
+ ctx.inject(['webServer'], (sub) => registerRoutes(sub.webServer, sub))
368
+ }
369
+ }