@ybbmm/dsh-session-delete 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +117 -2
- package/client.js +279 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/index.js +223 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +59 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 deepseek-harness-session-delete contributors
|
|
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
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ybbmm/dsh-session-delete
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Permanently delete a conversation from the DeepSeek Harness sidebar.**
|
|
4
|
+
**在 DeepSeek Harness 侧边栏永久删除对话。**
|
|
5
|
+
|
|
6
|
+
DeepSeek Harness can archive a conversation but never delete one — the session
|
|
7
|
+
log accumulates on disk forever. This plugin adds the missing action.
|
|
8
|
+
|
|
9
|
+
> [中文说明](#中文说明) 在下方。
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What it adds
|
|
14
|
+
|
|
15
|
+
| Where | What |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Session **"…" menu** | A red **Delete conversation** row, separated from the shipped pin / rename / fork / archive rows |
|
|
18
|
+
| Session **row hover** | A trash button beside the shipped archive and pin buttons |
|
|
19
|
+
| **Confirmation dialog** | Shows the conversation title and states that deletion is permanent |
|
|
20
|
+
|
|
21
|
+
Deleting the conversation that is currently open moves the main view to a new
|
|
22
|
+
session.
|
|
23
|
+
|
|
24
|
+
## What exactly is removed
|
|
25
|
+
|
|
26
|
+
| Artifact | Effect |
|
|
27
|
+
|---|---|
|
|
28
|
+
| Session log directory (`~/.dsh/sessions/<project-key>/<session-id>/`) | removed |
|
|
29
|
+
| Projection cache record (`~/.dsh/storages/session_projcache/sessions/<id>.json`) | removed |
|
|
30
|
+
| Workspace accounting — membership, saved order, pin, archive mark | released |
|
|
31
|
+
| Live session and agent store entries | detached *first*, so no writer re-creates the log |
|
|
32
|
+
| Attachments (`~/.dsh/attachments`) | **kept** — content-addressed and shared between sessions |
|
|
33
|
+
| Temp spill files | not touched (the host sweeps them by age) |
|
|
34
|
+
|
|
35
|
+
## Safety
|
|
36
|
+
|
|
37
|
+
- **Permanent.** There is no undo and no trash. Archiving is still the
|
|
38
|
+
reversible option and is untouched by this plugin.
|
|
39
|
+
- **Refuses while the conversation is busy.** If a turn, subagent, background
|
|
40
|
+
job or scheduled reminder still belongs to it, the Host rejects the request
|
|
41
|
+
and the dialog shows why.
|
|
42
|
+
- The session is detached from the live stores *before* its files are removed,
|
|
43
|
+
so a closing writer cannot resurrect a half-deleted log.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
From the plugin market: search for **Session Delete**.
|
|
48
|
+
|
|
49
|
+
Manually:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
dsh plugin add @ybbmm/dsh-session-delete
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
From a local tarball:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm pack
|
|
59
|
+
dsh plugin add ./ybbmm-dsh-session-delete-0.1.0.tgz
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Restart the Harness if your profile does not hot-reload plugin bundles.
|
|
63
|
+
|
|
64
|
+
## Compatibility
|
|
65
|
+
|
|
66
|
+
- Built and verified against DeepSeek Harness **0.2.1-rc.2** (Desktop/Web GUI,
|
|
67
|
+
Windows, Chinese and English UI).
|
|
68
|
+
- **No runtime dependencies.** The bundle imports only `node:fs/promises` and
|
|
69
|
+
`node:path`, and loads nothing from `@deepseek-ai/*` at runtime.
|
|
70
|
+
- It deliberately builds on a few host internals: the session store and
|
|
71
|
+
persistence services, and the API Gateway's runtime *SRC* dispatch for
|
|
72
|
+
services that carry a `typertRemote` binding plus Remote method markers.
|
|
73
|
+
A host release that reshapes those may need a plugin update — pin your host
|
|
74
|
+
version if you depend on this.
|
|
75
|
+
|
|
76
|
+
## How it works
|
|
77
|
+
|
|
78
|
+
- **Host half** (`index.js`) registers a `sessionDelete` service with a
|
|
79
|
+
`typertRemote` binding and a hand-written Remote method marker, which makes
|
|
80
|
+
`sessionDelete/delete` reachable through the Gateway without generated
|
|
81
|
+
Typert artifacts. The method resolves the target (live or stored), runs the
|
|
82
|
+
activity gate, stops remaining work, detaches the live session, releases the
|
|
83
|
+
workspace accounting, removes the log directory via the store's own
|
|
84
|
+
`locate()` path, drops the projection-cache row, and announces
|
|
85
|
+
`api-session/removed` so the sidebar drops the row.
|
|
86
|
+
- **Browser half** (`client.js`) registers into
|
|
87
|
+
`sidebar.workspaces.session.menu.item`,
|
|
88
|
+
`sidebar.workspaces.session.row.action` and `shell.overlay`, and calls the
|
|
89
|
+
endpoint over the shared connection RPC.
|
|
90
|
+
- Styles copy the host's own geometry, renamed under a plugin prefix, and use
|
|
91
|
+
only `--dsw-alias-*` theme tokens, so light/dark and theme switching follow
|
|
92
|
+
the app.
|
|
93
|
+
|
|
94
|
+
## Known limitations
|
|
95
|
+
|
|
96
|
+
- **No success toast.** The workspace toast slot's key domain is closed to
|
|
97
|
+
third-party plugins, so the row disappearing is the only confirmation.
|
|
98
|
+
- **UI copy is English and Chinese only.**
|
|
99
|
+
- Deleting a conversation does not delete files the conversation *produced* in
|
|
100
|
+
your workspace — only the conversation itself.
|
|
101
|
+
|
|
102
|
+
## 中文说明
|
|
103
|
+
|
|
104
|
+
DeepSeek Harness 只能归档会话、无法真正删除,日志会一直堆积在磁盘上。这个插件补上了缺失的删除能力。
|
|
105
|
+
|
|
106
|
+
**新增内容**:会话「…」菜单里的**红色「删除对话」**、会话行悬停时的垃圾桶按钮,以及一个显示对话标题、写明"永久删除"的确认框。删除当前正在看的对话后,主视图会切换到一个新会话。
|
|
107
|
+
|
|
108
|
+
**会被删掉的**:会话日志目录、投影缓存记录、工作区记账(成员关系/排序/置顶/归档标记);活跃会话会先被摘除再删文件,避免写入器把日志复活。
|
|
109
|
+
|
|
110
|
+
**不会删的**:附件目录(按内容寻址、被多个会话共享)、临时 spill 文件。
|
|
111
|
+
|
|
112
|
+
**安全性**:永久删除,无回收站、不可撤销——归档依然是可逆选项。当对话还有运行中的回合、子代理、后台任务或定时提醒时,宿主会拒绝删除并说明原因。
|
|
113
|
+
|
|
114
|
+
**兼容性**:在 DeepSeek Harness `0.2.1-rc.2`(Desktop/Web,Windows,中英文界面)上验证;零运行时依赖。实现依赖少量宿主内部机制(会话存储/持久化服务、以及 API Gateway 对带 `typertRemote` 绑定的服务的运行时派发),宿主大版本变更时可能需要跟进更新。
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
[MIT](LICENSE)
|
package/client.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of the @ybbmm/dsh-session-delete bundle.
|
|
3
|
+
*
|
|
4
|
+
* Adds a "Delete conversation" entry to the sidebar session list: one row in
|
|
5
|
+
* the Session "..." menu, one hover button, and one confirmation dialog. The
|
|
6
|
+
* deletion itself runs on the Host through the `sessionDelete/delete` Remote
|
|
7
|
+
* endpoint; this half only collects the request and shows the outcome.
|
|
8
|
+
*/
|
|
9
|
+
window.__ModuleLoader__.load({
|
|
10
|
+
id: '@ybbmm/dsh-session-delete',
|
|
11
|
+
factory(require) {
|
|
12
|
+
const React = require('react')
|
|
13
|
+
const h = React.createElement
|
|
14
|
+
const { Button, IconTrashOutlineRegular, MenuItemButton, Modal, Tooltip } =
|
|
15
|
+
require('@deepseek-ai/dsh-client-ui-primitives')
|
|
16
|
+
|
|
17
|
+
const NS = 'ybbmm.dsh-session-delete'
|
|
18
|
+
const PLUGIN = '@ybbmm/dsh-session-delete'
|
|
19
|
+
|
|
20
|
+
const en = {
|
|
21
|
+
'menu.delete': 'Delete conversation',
|
|
22
|
+
'actions.delete': 'Delete conversation',
|
|
23
|
+
'dialog.title': 'Delete conversation?',
|
|
24
|
+
'dialog.description':
|
|
25
|
+
'“{title}” will be permanently deleted. Its messages cannot be recovered.',
|
|
26
|
+
'dialog.confirm': 'Delete',
|
|
27
|
+
'dialog.cancel': 'Cancel',
|
|
28
|
+
'dialog.close': 'Close',
|
|
29
|
+
'dialog.pending': 'Deleting…',
|
|
30
|
+
}
|
|
31
|
+
const zh = {
|
|
32
|
+
'menu.delete': '删除对话',
|
|
33
|
+
'actions.delete': '删除对话',
|
|
34
|
+
'dialog.title': '删除对话?',
|
|
35
|
+
'dialog.description': '“{title}” 将被永久删除,其中的消息无法恢复。',
|
|
36
|
+
'dialog.confirm': '删除',
|
|
37
|
+
'dialog.cancel': '取消',
|
|
38
|
+
'dialog.close': '关闭',
|
|
39
|
+
'dialog.pending': '正在删除…',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The hover button and the dialog copy the host's own geometry, renamed
|
|
43
|
+
// under this plugin's prefix and keeping only theme-token references.
|
|
44
|
+
const css = '.dshSessionDelete_iconButton{border-radius:var(--dsw-radius-xs);cursor:pointer;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;flex:none;justify-content:center;align-items:center;padding:0;display:inline-flex}.dshSessionDelete_iconButton:hover{color:var(--dsw-alias-label-primary)}.dshSessionDelete_deleteAction:not(:disabled){color:var(--dsw-alias-state-error-primary)}.dshSessionDelete_status{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}.dshSessionDelete_error{color:var(--dsw-alias-state-error-primary);margin-top:8px;font-size:12px;line-height:18px}'
|
|
45
|
+
const cssTagId = `${PLUGIN}/session-delete.css`
|
|
46
|
+
if (typeof document !== 'undefined' && document.querySelector(`style[data-plugin-css=${JSON.stringify(cssTagId)}]`) === null) {
|
|
47
|
+
const tag = document.createElement('style')
|
|
48
|
+
tag.dataset.plugin = PLUGIN
|
|
49
|
+
tag.dataset.pluginCss = cssTagId
|
|
50
|
+
tag.textContent = css
|
|
51
|
+
document.head.appendChild(tag)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One minimal observable the slot renderer turns into a selector hook. */
|
|
55
|
+
function createObservable(initial) {
|
|
56
|
+
let value = initial
|
|
57
|
+
const listeners = new Set()
|
|
58
|
+
return {
|
|
59
|
+
getSnapshot: () => value,
|
|
60
|
+
subscribe(listener) {
|
|
61
|
+
listeners.add(listener)
|
|
62
|
+
return () => {
|
|
63
|
+
listeners.delete(listener)
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
set(next) {
|
|
67
|
+
if (Object.is(next, value)) return
|
|
68
|
+
value = next
|
|
69
|
+
for (const listener of [...listeners]) listener()
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read an optional client service without failing when it is absent. */
|
|
75
|
+
function optionalService(ctx, name) {
|
|
76
|
+
try {
|
|
77
|
+
return ctx.get(name)
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The Session the main view currently shows, when there is one. */
|
|
84
|
+
function currentSessionId(ctx) {
|
|
85
|
+
const uiSession = optionalService(ctx, 'uiSession')
|
|
86
|
+
const key = uiSession?.current?.value?.key
|
|
87
|
+
return typeof key === 'string' && key.length > 0 ? key : undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Ask the Host to delete one conversation. */
|
|
91
|
+
async function requestDeletion(ctx, sessionId) {
|
|
92
|
+
const connection = optionalService(ctx, 'connection')
|
|
93
|
+
if (!connection) throw new Error('The Harness is not connected to its Host.')
|
|
94
|
+
const result = await connection.rpc.call(
|
|
95
|
+
'/api',
|
|
96
|
+
'sessionDelete/delete',
|
|
97
|
+
{ args: { sessionId } },
|
|
98
|
+
)
|
|
99
|
+
if (!result?.ok) {
|
|
100
|
+
const failure = result?.error
|
|
101
|
+
throw new Error(failure?.message ?? 'The conversation could not be deleted.')
|
|
102
|
+
}
|
|
103
|
+
return result.value
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The "..." menu row: ask for the confirmation. */
|
|
107
|
+
function DeleteSessionMenuItem({ sessionId, displayTitle, useMenuOpenState, requestDelete, t }) {
|
|
108
|
+
const [, setMenuOpen] = useMenuOpenState()
|
|
109
|
+
return h(
|
|
110
|
+
MenuItemButton,
|
|
111
|
+
{
|
|
112
|
+
danger: true,
|
|
113
|
+
separatorBefore: true,
|
|
114
|
+
icon: h(IconTrashOutlineRegular, { size: 14 }),
|
|
115
|
+
onSelect: () => {
|
|
116
|
+
setMenuOpen(false)
|
|
117
|
+
requestDelete(sessionId, displayTitle)
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
t('menu.delete'),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The hover button: ask for the confirmation. */
|
|
125
|
+
function DeleteSessionRowButton({ sessionId, displayTitle, requestDelete, t }) {
|
|
126
|
+
return h(
|
|
127
|
+
Tooltip,
|
|
128
|
+
{ label: t('actions.delete'), side: 'bottom', align: 'end', delayMs: 500, portal: true },
|
|
129
|
+
h(
|
|
130
|
+
'button',
|
|
131
|
+
{
|
|
132
|
+
type: 'button',
|
|
133
|
+
className: 'dshSessionDelete_iconButton',
|
|
134
|
+
'aria-label': t('menu.delete'),
|
|
135
|
+
onClick: () => {
|
|
136
|
+
requestDelete(sessionId, displayTitle)
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
h(IconTrashOutlineRegular, { size: 14 }),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The confirmation dialog. */
|
|
145
|
+
function DeleteSessionDialog({ useDeleteRequest, settleDelete, confirmDelete, t }) {
|
|
146
|
+
const request = useDeleteRequest((pending) => pending)
|
|
147
|
+
if (!request) return null
|
|
148
|
+
return h(
|
|
149
|
+
Modal,
|
|
150
|
+
{
|
|
151
|
+
open: true,
|
|
152
|
+
onClose: settleDelete,
|
|
153
|
+
closeLabel: t('dialog.close'),
|
|
154
|
+
title: t('dialog.title'),
|
|
155
|
+
description: t('dialog.description', { title: request.displayTitle }),
|
|
156
|
+
footer: h(
|
|
157
|
+
React.Fragment,
|
|
158
|
+
null,
|
|
159
|
+
h(
|
|
160
|
+
Button,
|
|
161
|
+
{ variant: 'outline', disabled: request.pending, onClick: settleDelete },
|
|
162
|
+
t('dialog.cancel'),
|
|
163
|
+
),
|
|
164
|
+
h(
|
|
165
|
+
Button,
|
|
166
|
+
{
|
|
167
|
+
variant: 'outline',
|
|
168
|
+
className: 'dshSessionDelete_deleteAction',
|
|
169
|
+
disabled: request.pending,
|
|
170
|
+
onClick: confirmDelete,
|
|
171
|
+
},
|
|
172
|
+
t('dialog.confirm'),
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
children: h(
|
|
176
|
+
React.Fragment,
|
|
177
|
+
null,
|
|
178
|
+
request.pending
|
|
179
|
+
? h(
|
|
180
|
+
'div',
|
|
181
|
+
{ role: 'status', className: 'dshSessionDelete_status', key: 'pending' },
|
|
182
|
+
t('dialog.pending'),
|
|
183
|
+
)
|
|
184
|
+
: null,
|
|
185
|
+
request.error
|
|
186
|
+
? h(
|
|
187
|
+
'div',
|
|
188
|
+
{ role: 'alert', className: 'dshSessionDelete_error', key: 'error' },
|
|
189
|
+
request.error,
|
|
190
|
+
)
|
|
191
|
+
: null,
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
inject: ['slots', 'locale'],
|
|
199
|
+
apply(ctx) {
|
|
200
|
+
ctx.effect(
|
|
201
|
+
() => ctx.locale.register(NS, { zh, en }),
|
|
202
|
+
'ybbmm.dsh-session-delete: dictionaries',
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const pending = createObservable(null)
|
|
206
|
+
|
|
207
|
+
const requestDelete = (sessionId, displayTitle) => {
|
|
208
|
+
pending.set({ sessionId, displayTitle, pending: false, error: undefined })
|
|
209
|
+
}
|
|
210
|
+
const settleDelete = () => {
|
|
211
|
+
pending.set(null)
|
|
212
|
+
}
|
|
213
|
+
const confirmDelete = async () => {
|
|
214
|
+
const request = pending.getSnapshot()
|
|
215
|
+
if (!request || request.pending) return
|
|
216
|
+
pending.set({ ...request, pending: true, error: undefined })
|
|
217
|
+
try {
|
|
218
|
+
await requestDeletion(ctx, request.sessionId)
|
|
219
|
+
pending.set(null)
|
|
220
|
+
if (currentSessionId(ctx) === request.sessionId) {
|
|
221
|
+
const uiWorkspace = optionalService(ctx, 'uiWorkspace')
|
|
222
|
+
try {
|
|
223
|
+
uiWorkspace?.startSession()
|
|
224
|
+
} catch (error) {
|
|
225
|
+
console.warn('ybbmm.dsh-session-delete: could not open a new session:', error)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
230
|
+
pending.set({ ...request, pending: false, error: message })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const actionInjected = () => ({ requestDelete })
|
|
235
|
+
const dialogInjected = () => ({
|
|
236
|
+
hooks: { deleteRequest: pending },
|
|
237
|
+
settleDelete,
|
|
238
|
+
confirmDelete,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
ctx.slots.inject('sidebar.workspaces.session.menu.item', function* () {
|
|
242
|
+
yield ctx.slots.register(
|
|
243
|
+
{
|
|
244
|
+
name: 'sidebar.workspaces.session.menu.item',
|
|
245
|
+
id: 'ybbmm.dsh-session-delete',
|
|
246
|
+
order: 500,
|
|
247
|
+
locale: NS,
|
|
248
|
+
inject: actionInjected,
|
|
249
|
+
},
|
|
250
|
+
DeleteSessionMenuItem,
|
|
251
|
+
)
|
|
252
|
+
})
|
|
253
|
+
ctx.slots.inject('sidebar.workspaces.session.row.action', function* () {
|
|
254
|
+
yield ctx.slots.register(
|
|
255
|
+
{
|
|
256
|
+
name: 'sidebar.workspaces.session.row.action',
|
|
257
|
+
id: 'ybbmm.dsh-session-delete',
|
|
258
|
+
order: 300,
|
|
259
|
+
locale: NS,
|
|
260
|
+
inject: actionInjected,
|
|
261
|
+
},
|
|
262
|
+
DeleteSessionRowButton,
|
|
263
|
+
)
|
|
264
|
+
})
|
|
265
|
+
ctx.slots.inject('shell.overlay', function* () {
|
|
266
|
+
yield ctx.slots.register(
|
|
267
|
+
{
|
|
268
|
+
name: 'shell.overlay',
|
|
269
|
+
id: 'ybbmm.dsh-session-delete',
|
|
270
|
+
locale: NS,
|
|
271
|
+
inject: dialogInjected,
|
|
272
|
+
},
|
|
273
|
+
DeleteSessionDialog,
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
})
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Delete conversation">
|
|
2
|
+
<path d="M4 7h16" />
|
|
3
|
+
<path d="M10 11v6" />
|
|
4
|
+
<path d="M14 11v6" />
|
|
5
|
+
<path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12" />
|
|
6
|
+
<path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
7
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the session-delete bundle.
|
|
3
|
+
*
|
|
4
|
+
* Owns one operation: permanently delete a stored conversation. The session's
|
|
5
|
+
* durable log directory goes away, its workspace accounting (membership, pin,
|
|
6
|
+
* archive) is released, its projection-cache record is dropped, and the
|
|
7
|
+
* browser is told the row is gone.
|
|
8
|
+
*
|
|
9
|
+
* The browser half calls `sessionDelete/delete` over the shared Remote
|
|
10
|
+
* carrier. The endpoint is reachable through the API Gateway's runtime "SRC"
|
|
11
|
+
* dispatch: the Gateway looks for a live Service that carries a
|
|
12
|
+
* `typertRemote` binding plus Remote method markers, so this bundle needs no
|
|
13
|
+
* generated Typert artifact.
|
|
14
|
+
*
|
|
15
|
+
* The bundle deliberately has no package dependencies: a bundle installed from
|
|
16
|
+
* a local directory resolves its imports from that directory, so every import
|
|
17
|
+
* would have to be a Node built-in.
|
|
18
|
+
*/
|
|
19
|
+
import { rm } from 'node:fs/promises'
|
|
20
|
+
import { dirname } from 'node:path'
|
|
21
|
+
|
|
22
|
+
/** Cordis service key of this plugin's service. */
|
|
23
|
+
const SERVICE_KEY = 'sessionDelete'
|
|
24
|
+
/** Wire namespace the API Gateway exposes for this service. */
|
|
25
|
+
const NAMESPACE = 'sessionDelete'
|
|
26
|
+
/** Property the Typert protocol stores Remote method markers under. */
|
|
27
|
+
const REMOTE_METHODS = '@deepseek-ai/dsh-typert-protocol/remote-methods'
|
|
28
|
+
/** Shared symbol Cordis uses to bind a service value to its owning context. */
|
|
29
|
+
const TRACKER = Symbol.for('cordis.tracker')
|
|
30
|
+
/** Milliseconds between removal attempts while a writer settles. */
|
|
31
|
+
const REMOVE_RETRY_MS = 60
|
|
32
|
+
/** How many times the removal is repeated. */
|
|
33
|
+
const REMOVE_ATTEMPTS = 4
|
|
34
|
+
|
|
35
|
+
/** Read an optional service without failing when the composition lacks it. */
|
|
36
|
+
function optionalService(ctx, name) {
|
|
37
|
+
try {
|
|
38
|
+
return ctx.get(name)
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pause for `ms` milliseconds. */
|
|
45
|
+
function delay(ms) {
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class SessionDeleteService {
|
|
50
|
+
/**
|
|
51
|
+
* @param ctx - the owning plugin context.
|
|
52
|
+
*/
|
|
53
|
+
constructor(ctx) {
|
|
54
|
+
this.ctx = ctx
|
|
55
|
+
this.name = SERVICE_KEY
|
|
56
|
+
this.typertRemote = Object.freeze({
|
|
57
|
+
service: this,
|
|
58
|
+
serviceKey: SERVICE_KEY,
|
|
59
|
+
namespace: NAMESPACE,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Delete one conversation.
|
|
65
|
+
* @param sessionId - durable Session identity.
|
|
66
|
+
* @returns the deleted identity and whether a stored log was removed.
|
|
67
|
+
*/
|
|
68
|
+
async delete(sessionId) {
|
|
69
|
+
const ctx = this.ctx
|
|
70
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
71
|
+
throw new Error('sessionDelete: a session id is required')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 1. Resolve the target: a live session, otherwise a stored one.
|
|
75
|
+
const live = ctx.sessions.get(sessionId)
|
|
76
|
+
let header = live?.header
|
|
77
|
+
if (!header) {
|
|
78
|
+
const snapshot = await ctx.sessionPersistence.stat(sessionId)
|
|
79
|
+
if (!snapshot) throw new Error(`sessionDelete: session "${sessionId}" was not found`)
|
|
80
|
+
header = snapshot.header
|
|
81
|
+
if (!header) throw new Error(`sessionDelete: session "${sessionId}" carries no stored header`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. Refuse while the conversation still owns running work.
|
|
85
|
+
const activity = await ctx.waterfall(
|
|
86
|
+
'workspace/session-activity',
|
|
87
|
+
{ sessionId },
|
|
88
|
+
() => Promise.resolve([]),
|
|
89
|
+
)
|
|
90
|
+
if (Array.isArray(activity) && activity.length > 0) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`sessionDelete: session "${sessionId}" is still running work; stop it before deleting`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 3. Stop what is left, then let a live session leave the stores so its
|
|
97
|
+
// writer closes and the row leaves the list.
|
|
98
|
+
await ctx.parallel('workspace/session-stop', { sessionId }).catch(() => {})
|
|
99
|
+
if (live) {
|
|
100
|
+
await ctx.sessions.flush(live).catch(() => {})
|
|
101
|
+
this.detach(sessionId)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 4. Release the durable workspace accounting.
|
|
105
|
+
const registry = optionalService(ctx, 'workspaceRegistry')
|
|
106
|
+
if (registry) {
|
|
107
|
+
await registry.unpinSession(sessionId).catch(() => {})
|
|
108
|
+
await registry.unarchiveSession(sessionId).catch(() => {})
|
|
109
|
+
for (const workspace of registry.list()) {
|
|
110
|
+
if (workspace.record?.sessionIds?.includes(sessionId)) {
|
|
111
|
+
await workspace.detachSession(sessionId).catch(() => {})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 5. Remove the stored conversation.
|
|
117
|
+
const removed = await this.removeStoredLog(header)
|
|
118
|
+
|
|
119
|
+
// 6. Drop the projection-cache record so no stale hint survives.
|
|
120
|
+
await this.removeProjectionCache(sessionId)
|
|
121
|
+
|
|
122
|
+
// 7. A live session announced its own removal through `session/disposed`;
|
|
123
|
+
// a stored one has no such event, so announce it here.
|
|
124
|
+
if (!live) ctx.emit('api-session/removed', sessionId)
|
|
125
|
+
|
|
126
|
+
return { sessionId, removed }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Remove one session from the live session and agent stores.
|
|
131
|
+
* @param sessionId - durable Session identity.
|
|
132
|
+
*/
|
|
133
|
+
detach(sessionId) {
|
|
134
|
+
const ctx = this.ctx
|
|
135
|
+
const sessions = optionalService(ctx, 'sessions')
|
|
136
|
+
const entry = sessions?.store?.get(sessionId)
|
|
137
|
+
if (entry && typeof sessions.detachEntered === 'function') {
|
|
138
|
+
try {
|
|
139
|
+
sessions.detachEntered(entry)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
ctx.logger?.warn(`sessionDelete: detaching session "${sessionId}" failed: ${String(error)}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const agents = optionalService(ctx, 'agents')
|
|
145
|
+
const agentEntry = agents?.store?.get(sessionId)
|
|
146
|
+
if (agentEntry && typeof agents.detachEntered === 'function') {
|
|
147
|
+
try {
|
|
148
|
+
agents.detachEntered(agentEntry)
|
|
149
|
+
} catch (error) {
|
|
150
|
+
ctx.logger?.warn(`sessionDelete: detaching agent "${sessionId}" failed: ${String(error)}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Remove the session's stored log directory.
|
|
157
|
+
*
|
|
158
|
+
* The store owns the on-disk layout, so its own locator names the artifact
|
|
159
|
+
* and the session directory is that artifact's parent. A writer that is
|
|
160
|
+
* still closing can append once more and recreate the file, so the removal
|
|
161
|
+
* is repeated briefly.
|
|
162
|
+
* @param header - the stored session header.
|
|
163
|
+
* @returns whether a stored log was removed.
|
|
164
|
+
*/
|
|
165
|
+
async removeStoredLog(header) {
|
|
166
|
+
const persistence = this.ctx.sessionPersistence
|
|
167
|
+
const locate = persistence?.locate
|
|
168
|
+
if (typeof locate !== 'function') {
|
|
169
|
+
throw new Error('sessionDelete: this session store does not expose its on-disk layout')
|
|
170
|
+
}
|
|
171
|
+
const location = locate.call(persistence, header)
|
|
172
|
+
if (!location?.path) return false
|
|
173
|
+
const directory = dirname(location.path)
|
|
174
|
+
for (let attempt = 0; attempt < REMOVE_ATTEMPTS; attempt++) {
|
|
175
|
+
await rm(directory, { recursive: true, force: true })
|
|
176
|
+
if (attempt < REMOVE_ATTEMPTS - 1) await delay(REMOVE_RETRY_MS)
|
|
177
|
+
}
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Drop the stored projection-cache record of one session.
|
|
183
|
+
* @param sessionId - durable Session identity.
|
|
184
|
+
*/
|
|
185
|
+
async removeProjectionCache(sessionId) {
|
|
186
|
+
const storageDomain = optionalService(this.ctx, 'storageDomain')
|
|
187
|
+
if (!storageDomain || typeof storageDomain.get !== 'function') return
|
|
188
|
+
const domain = storageDomain.get('session_projcache')
|
|
189
|
+
if (!domain || typeof domain.table !== 'function') return
|
|
190
|
+
await domain.table('sessions').delete(sessionId).catch(() => {})
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Declare the Remote method for the API Gateway's runtime dispatch. The
|
|
195
|
+
// marker format is the Typert protocol's public, versioned descriptor: a
|
|
196
|
+
// frozen record on the class prototype naming the exported methods.
|
|
197
|
+
Object.defineProperty(SessionDeleteService.prototype, REMOTE_METHODS, {
|
|
198
|
+
configurable: true,
|
|
199
|
+
value: Object.freeze({
|
|
200
|
+
version: 1,
|
|
201
|
+
methods: Object.freeze([
|
|
202
|
+
Object.freeze({
|
|
203
|
+
method: 'delete',
|
|
204
|
+
invocation: Object.freeze({ kind: 'direct' }),
|
|
205
|
+
resources: Object.freeze({ session: (request) => request?.sessionId }),
|
|
206
|
+
}),
|
|
207
|
+
]),
|
|
208
|
+
}),
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Register the deletion service and let the browser reach it.
|
|
213
|
+
* @param ctx - the owning plugin context.
|
|
214
|
+
*/
|
|
215
|
+
export function apply(ctx) {
|
|
216
|
+
const service = new SessionDeleteService(ctx)
|
|
217
|
+
// Bind the service to the calling context the way a Cordis Service does, so
|
|
218
|
+
// the API Gateway reaches it through the same traced proxy.
|
|
219
|
+
Object.defineProperty(service, TRACKER, {
|
|
220
|
+
value: { associate: SERVICE_KEY, property: 'ctx' },
|
|
221
|
+
})
|
|
222
|
+
ctx.reflect.provide(SERVICE_KEY, service, undefined)
|
|
223
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ybbmm/dsh-session-delete",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Permanently delete a DeepSeek Harness conversation from the sidebar session menu: removes the session log, its projection cache and its workspace accounting. | 在 DeepSeek Harness 侧边栏永久删除对话,清除会话日志、投影缓存与工作区记账。",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "jack-001-a",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jack-001-a/deepseek-harness-session-delete.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/jack-001-a/deepseek-harness-session-delete#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/jack-001-a/deepseek-harness-session-delete/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dsh-plugin",
|
|
18
|
+
"deepseek-harness",
|
|
19
|
+
"dsh",
|
|
20
|
+
"session",
|
|
21
|
+
"conversation",
|
|
22
|
+
"delete",
|
|
23
|
+
"sidebar",
|
|
24
|
+
"plugin"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./index.js",
|
|
31
|
+
"./client": "./client.js",
|
|
32
|
+
"./locale/*.json": "./locale/*.json",
|
|
33
|
+
"./icon": "./icon.svg"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"index.js",
|
|
37
|
+
"client.js",
|
|
38
|
+
"cordis.patch.yml",
|
|
39
|
+
"locale/*.json",
|
|
40
|
+
"icon.svg",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dsh": {
|
|
48
|
+
"bundle": {
|
|
49
|
+
"patch": "./cordis.patch.yml"
|
|
50
|
+
},
|
|
51
|
+
"client": {
|
|
52
|
+
"platform": "web",
|
|
53
|
+
"immediately": true,
|
|
54
|
+
"inject": [
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-workspace",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
57
|
+
"@deepseek-ai/dsh-client-locale"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|