@ybbmm/dsh-session-delete 0.0.0-stage → 0.2.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 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,169 @@
1
- # Temporary Holding Version
1
+ # @ybbmm/dsh-session-delete
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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, after 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, states that deletion is permanent, then reports the outcome in place |
20
+
21
+ Deleting the conversation that is currently open reopens a new session in the
22
+ same workspace; the dialog stays open just long enough to acknowledge the
23
+ result.
24
+
25
+ ## What exactly is removed
26
+
27
+ | Artifact | Effect |
28
+ |---|---|
29
+ | Session log artifact (`~/.dsh/sessions/<project-key>/<session-id>/`, log + lock file) | removed |
30
+ | Projection cache record (`~/.dsh/storages/session_projcache/sessions/<id>.json`) | dropped, then re-checked against a late checkpoint write |
31
+ | Workspace accounting — membership, saved order, pin, archive mark | released |
32
+ | Live session and agent store entries | detached *before* the files are removed, so no writer re-creates the log |
33
+ | Attachments (`~/.dsh/attachments`) | **kept** — content-addressed and shared between sessions |
34
+ | Temp spill files | not touched (the host sweeps them by age) |
35
+ | Conversations forked *from* this one | **kept**; the dialog names how many still point at the deleted parent |
36
+
37
+ ## Safety
38
+
39
+ Deletion is a transaction with one irreversible step, and that step runs
40
+ before anything becomes user-visible:
41
+
42
+ 1. resolve the target (live or stored), 2. refuse while work is running,
43
+ 3. stop and detach a live session, 4. **remove the log artifact**, 5. release
44
+ workspace accounting, 6. drop the projection cache, 7. announce the removal.
45
+
46
+ - **A failed removal changes nothing.** If the log cannot be removed (typically
47
+ a writer still closing), the request fails with `session-delete/io` *before*
48
+ accounting and cache are touched, so the row is still listed and retrying is
49
+ meaningful.
50
+ - **Retries only transient failures.** `EBUSY` / `EPERM` / `EACCES` /
51
+ `ENOTEMPTY` are retried with back-off (8 attempts, ~1.8 s); anything else
52
+ fails immediately instead of burning the budget.
53
+ - **Permanent.** There is no undo and no trash. Archiving is still the
54
+ reversible option and is untouched by this plugin.
55
+ - **Refuses while the conversation is busy.** A running turn, subagent,
56
+ background job or scheduled reminder makes the Host reject the request, and
57
+ the dialog says why.
58
+ - **No double deletion.** A repeated click joins the deletion already in
59
+ flight instead of interleaving two runs.
60
+
61
+ ## Error codes
62
+
63
+ The Host answers with stable codes; the dialog localizes them.
64
+
65
+ | Code | Meaning |
66
+ |---|---|
67
+ | `session-delete/active` | The conversation still owns running work |
68
+ | `session-delete/not-found` | It was already deleted (or never existed) |
69
+ | `session-delete/io` | The log could not be removed; nothing was changed |
70
+ | `session-delete/unsupported-store` | This build's store cannot name the artifact; nothing was changed |
71
+ | `session-delete/invalid-request` | Malformed request (defensive) |
72
+
73
+ ## Install
74
+
75
+ From the plugin market: search for **Session Delete**.
76
+
77
+ Manually:
78
+
79
+ ```bash
80
+ dsh plugin add @ybbmm/dsh-session-delete
81
+ ```
82
+
83
+ From a local directory (development):
84
+
85
+ ```bash
86
+ dsh plugin add ./session-delete
87
+ ```
88
+
89
+ Restart the Harness if your profile does not hot-reload plugin bundles.
90
+
91
+ ## Compatibility
92
+
93
+ - Built and verified against DeepSeek Harness **0.2.1-rc.2** (Desktop/Web GUI,
94
+ Windows, Chinese and English UI).
95
+ - **No runtime dependencies.** The bundle imports only `node:fs/promises` and
96
+ `node:path`, and loads nothing from `@deepseek-ai/*` at runtime.
97
+ - It deliberately builds on a few host internals: the session store and
98
+ persistence services, the workspace registry, the storage-domain projection
99
+ cache, and the API Gateway's runtime *SRC* dispatch for services that carry a
100
+ `typertRemote` binding plus Remote method markers. A host release that
101
+ reshapes those may need a plugin update — pin your host version if you depend
102
+ on this.
103
+
104
+ ## How it works
105
+
106
+ - **Host half** (`index.js`) registers a `sessionDelete` service with a
107
+ `typertRemote` binding and a hand-written Remote method marker, which makes
108
+ `sessionDelete/delete` reachable through the Gateway without generated
109
+ Typert artifacts. The method implements the ordering above; the log is
110
+ located through the store's own `locate()`, which names the session directory
111
+ for the shipped JSONL backend and is honoured as-is for other layouts.
112
+ - **Browser half** (`client.js`) registers into
113
+ `sidebar.workspaces.session.menu.item`,
114
+ `sidebar.workspaces.session.row.action` and `shell.overlay`, and calls the
115
+ endpoint over the shared connection RPC. The dialog is keyed by the Session
116
+ it deletes, so one request's in-flight/error/success state never leaks into
117
+ the next.
118
+ - Styles copy the host's own geometry, renamed under a plugin prefix, and use
119
+ only `--dsw-alias-*` theme tokens, so light/dark and theme switching follow
120
+ the app.
121
+
122
+ ## Known limitations
123
+
124
+ - **No toast.** The workspace toast slot's key domain is closed to third-party
125
+ plugins; the dialog's in-place acknowledgement is the confirmation instead.
126
+ - **UI copy is English and Chinese only.**
127
+ - Deleting a conversation does not delete files the conversation *produced* in
128
+ your workspace — only the conversation itself.
129
+ - A conversation created but never written to disk has no artifact; deleting it
130
+ releases the row and reports `removed: false`, but the empty slot can linger
131
+ in the Host's in-memory pending list until the next restart.
132
+ - The search index (`dsh-session-query-sqlite`) reconciles against the
133
+ persistence listing on every query, so it drops the deleted conversation on
134
+ the next search rather than eagerly.
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ npm test # node --test: host ordering/retry/error tests + browser dialog tests
140
+ ```
141
+
142
+ The tests run against the real modules with fake Cordis contexts and a minimal
143
+ React stand-in; they need no Harness installation.
144
+
145
+ ## 中文说明
146
+
147
+ DeepSeek Harness 只能归档会话、无法真正删除,日志会一直堆积在磁盘上。这个插件补上了缺失的删除能力。
148
+
149
+ **新增内容**:会话「…」菜单里的**红色「删除对话」**、会话行悬停时的垃圾桶按钮,以及一个显示对话标题、写明"永久删除"的确认框;删除后确认框会原地显示"已删除"以及保留的分叉会话数量。删除当前正在看的对话后,会在同一工作区重新打开一个新会话。
150
+
151
+ **删除顺序是事务式的**:解析目标 → 拒绝运行中的工作 → 停并将活跃会话摘除 → **删除日志文件** → 释放工作区记账 → 删除投影缓存 → 广播移除事件。日志删除失败时会**先失败、不动记账和缓存**,所以侧边栏行仍在、可以直接重试。
152
+
153
+ **会被删掉的**:会话日志目录(日志 + 锁文件)、投影缓存记录、工作区记账(成员关系/排序/置顶/归档标记)。
154
+
155
+ **不会删的**:附件目录(按内容寻址、被多个会话共享)、临时 spill 文件、由该对话分叉出的子会话(确认框会提示数量)。
156
+
157
+ **错误码**:`active`(仍有运行中的工作)、`not-found`(已删除或不存在)、`io`(文件无法删除,未做任何改动)、`unsupported-store`(当前版本无法定位文件)、`invalid-request`(请求非法)。
158
+
159
+ **重试策略**:只对 `EBUSY`/`EPERM`/`EACCES`/`ENOTEMPTY` 这类"句柄还没关"的瞬时错误做退避重试(8 次,约 1.8 秒),其他错误立即报错,不会把时间耗在无效重试上。
160
+
161
+ **安全性**:永久删除,无回收站、不可撤销——归档依然是可逆选项。同一会话的重复点击会合并为一次删除,不会交叉执行。
162
+
163
+ **兼容性**:在 DeepSeek Harness `0.2.1-rc.2`(Desktop/Web,Windows,中英文界面)上验证;零运行时依赖。实现依赖少量宿主内部机制(会话存储/持久化服务、工作区注册表、投影缓存域、以及 API Gateway 对带 `typertRemote` 绑定的服务的运行时派发),宿主大版本变更时可能需要跟进更新。
164
+
165
+ **已知限制**:没有全局 toast(插件无法使用该槽位),确认框内的原地提示即反馈;界面文案仅中英文;从未落盘的空白会话删除后,宿主内存里的 pending 条目可能要等重启才彻底消失。
166
+
167
+ ## License
168
+
169
+ [MIT](LICENSE)
package/client.js ADDED
@@ -0,0 +1,416 @@
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 collects the request and shows the outcome.
8
+ *
9
+ * The dialog follows the shipped archive action's shape: a thin entry that
10
+ * reads the pending request, and one inner form keyed by the Session id that
11
+ * owns the in-flight, error and success state. Keying it means one request's
12
+ * outcome can never leak into the next one.
13
+ */
14
+ window.__ModuleLoader__.load({
15
+ id: '@ybbmm/dsh-session-delete',
16
+ factory(require) {
17
+ const React = require('react')
18
+ const h = React.createElement
19
+ const { Button, IconTrashOutlineRegular, MenuItemButton, Modal, Tooltip } =
20
+ require('@deepseek-ai/dsh-client-ui-primitives')
21
+
22
+ const NS = 'ybbmm.dsh-session-delete'
23
+ const PLUGIN = '@ybbmm/dsh-session-delete'
24
+
25
+ const en = {
26
+ 'menu.delete': 'Delete conversation',
27
+ 'actions.delete': 'Delete conversation',
28
+ 'dialog.title': 'Delete conversation?',
29
+ 'dialog.description':
30
+ '“{title}” will be permanently deleted. Its messages cannot be recovered.',
31
+ 'dialog.confirm': 'Delete',
32
+ 'dialog.cancel': 'Cancel',
33
+ 'dialog.close': 'Close',
34
+ 'dialog.pending': 'Deleting…',
35
+ 'dialog.done': 'Deleted. The conversation is gone from this device.',
36
+ 'dialog.children':
37
+ '{count} conversation(s) forked from this one remain and still point at the deleted parent.',
38
+ 'error.offline': 'The Harness is not connected to its Host.',
39
+ 'error.active': 'This conversation is still running work. Stop it, then delete it.',
40
+ 'error.notFound': 'This conversation no longer exists.',
41
+ 'error.io': 'The conversation could not be removed from disk. Nothing was deleted — please try again.',
42
+ 'error.unsupported': 'This build cannot locate conversation files, so nothing was deleted.',
43
+ 'error.unreadable': 'This conversation log cannot be read by this build, so nothing was deleted.',
44
+ 'error.unknown': 'The conversation could not be deleted.',
45
+ }
46
+ const zh = {
47
+ 'menu.delete': '删除对话',
48
+ 'actions.delete': '删除对话',
49
+ 'dialog.title': '删除对话?',
50
+ 'dialog.description': '“{title}” 将被永久删除,其中的消息无法恢复。',
51
+ 'dialog.confirm': '删除',
52
+ 'dialog.cancel': '取消',
53
+ 'dialog.close': '关闭',
54
+ 'dialog.pending': '正在删除…',
55
+ 'dialog.done': '已删除,该对话已从本机移除。',
56
+ 'dialog.children': '由该对话分叉出的 {count} 个会话会保留,并仍指向一个已删除的父对话。',
57
+ 'error.offline': 'Harness 尚未连接到宿主。',
58
+ 'error.active': '该对话仍有正在运行的工作,请先停止再删除。',
59
+ 'error.notFound': '该对话已不存在。',
60
+ 'error.io': '无法从磁盘移除该对话,本次未删除任何内容,请重试。',
61
+ 'error.unsupported': '当前版本无法定位对话文件,本次未删除任何内容。',
62
+ 'error.unreadable': '当前版本无法读取该对话的日志,本次未删除任何内容。',
63
+ 'error.unknown': '该对话未能删除。',
64
+ }
65
+
66
+ // The hover button and the dialog copy the host's own geometry, renamed
67
+ // under this plugin's prefix and keeping only theme-token references.
68
+ 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}.dshSessionDelete_note{margin-top:8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}'
69
+ const cssTagId = `${PLUGIN}/session-delete.css`
70
+ if (typeof document !== 'undefined' && document.querySelector(`style[data-plugin-css=${JSON.stringify(cssTagId)}]`) === null) {
71
+ const tag = document.createElement('style')
72
+ tag.dataset.plugin = PLUGIN
73
+ tag.dataset.pluginCss = cssTagId
74
+ tag.textContent = css
75
+ document.head.appendChild(tag)
76
+ }
77
+
78
+ /** One minimal observable the slot renderer turns into a selector hook. */
79
+ function createObservable(initial) {
80
+ let value = initial
81
+ const listeners = new Set()
82
+ return {
83
+ getSnapshot: () => value,
84
+ subscribe(listener) {
85
+ listeners.add(listener)
86
+ return () => {
87
+ listeners.delete(listener)
88
+ }
89
+ },
90
+ set(next) {
91
+ if (Object.is(next, value)) return
92
+ value = next
93
+ for (const listener of [...listeners]) listener()
94
+ },
95
+ }
96
+ }
97
+
98
+ /** Read an optional client service without failing when it is absent. */
99
+ function optionalService(ctx, name) {
100
+ try {
101
+ return ctx.get(name)
102
+ } catch {
103
+ return undefined
104
+ }
105
+ }
106
+
107
+ /** The Session the main view currently shows, when there is one. */
108
+ function currentSessionId(ctx) {
109
+ const uiSession = optionalService(ctx, 'uiSession')
110
+ const key = uiSession?.current?.value?.key
111
+ return typeof key === 'string' && key.length > 0 ? key : undefined
112
+ }
113
+
114
+ /** The workspace that accounts for one Session, when it is listed. */
115
+ function workspaceOf(ctx, sessionId) {
116
+ const uiWorkspace = optionalService(ctx, 'uiWorkspace')
117
+ const items = uiWorkspace?.workspaces?.list?.getSnapshot?.()?.items
118
+ if (!Array.isArray(items)) return undefined
119
+ for (const item of items) {
120
+ if (Array.isArray(item?.sessionIds) && item.sessionIds.includes(sessionId)) return item.workspaceId
121
+ }
122
+ return undefined
123
+ }
124
+
125
+ /**
126
+ * Map one Remote failure onto a localized sentence.
127
+ *
128
+ * The Host sends a stable code for every deletion refusal; anything else
129
+ * (a carrier failure, or a host older than this bundle) falls back to the
130
+ * diagnostic the Host attached, then to generic copy.
131
+ * @param t - the slot's locale function.
132
+ * @param failure - the rejection raised by the RPC call.
133
+ * @returns the sentence to show in the dialog.
134
+ */
135
+ function describeFailure(t, failure) {
136
+ const known = {
137
+ 'client/offline': 'error.offline',
138
+ 'session-delete/active': 'error.active',
139
+ 'session-delete/not-found': 'error.notFound',
140
+ 'session-delete/io': 'error.io',
141
+ 'session-delete/unsupported-store': 'error.unsupported',
142
+ 'session-delete/unreadable': 'error.unreadable',
143
+ 'session-delete/invalid-request': 'error.unknown',
144
+ }
145
+ const code = typeof failure?.code === 'string' ? failure.code : undefined
146
+ if (code !== undefined && known[code] !== undefined) return t(known[code])
147
+ const message = failure instanceof Error ? failure.message : typeof failure === 'string' ? failure : ''
148
+ return message.length > 0 ? message : t('error.unknown')
149
+ }
150
+
151
+ /** Ask the Host to delete one conversation. */
152
+ async function requestDeletion(ctx, sessionId) {
153
+ const connection = optionalService(ctx, 'connection')
154
+ if (!connection) {
155
+ const failure = new Error('The Harness is not connected to its Host.')
156
+ failure.code = 'client/offline'
157
+ throw failure
158
+ }
159
+ const result = await connection.rpc.call('/api', 'sessionDelete/delete', { args: { sessionId } })
160
+ if (!result?.ok) {
161
+ const failure = new Error(result?.error?.message ?? 'The conversation could not be deleted.')
162
+ failure.code = result?.error?.code
163
+ failure.details = result?.error?.details
164
+ throw failure
165
+ }
166
+ return result.value
167
+ }
168
+
169
+ /** The "..." menu row: ask for the confirmation. */
170
+ function DeleteSessionMenuItem({ sessionId, displayTitle, useMenuOpenState, requestDelete, t }) {
171
+ const [, setMenuOpen] = useMenuOpenState()
172
+ return h(
173
+ MenuItemButton,
174
+ {
175
+ danger: true,
176
+ separatorBefore: true,
177
+ icon: h(IconTrashOutlineRegular, { size: 14 }),
178
+ onSelect: () => {
179
+ setMenuOpen(false)
180
+ requestDelete(sessionId, displayTitle)
181
+ },
182
+ },
183
+ t('menu.delete'),
184
+ )
185
+ }
186
+
187
+ /** The hover button: ask for the confirmation. */
188
+ function DeleteSessionRowButton({ sessionId, displayTitle, requestDelete, t }) {
189
+ return h(
190
+ Tooltip,
191
+ { label: t('actions.delete'), side: 'bottom', align: 'end', delayMs: 500, portal: true },
192
+ h(
193
+ 'button',
194
+ {
195
+ type: 'button',
196
+ className: 'dshSessionDelete_iconButton',
197
+ 'aria-label': t('menu.delete'),
198
+ onClick: () => {
199
+ requestDelete(sessionId, displayTitle)
200
+ },
201
+ },
202
+ h(IconTrashOutlineRegular, { size: 14 }),
203
+ ),
204
+ )
205
+ }
206
+
207
+ /**
208
+ * One request's dialog: confirming deletes, and the dialog then reports the
209
+ * outcome instead of closing silently, so the user gets an acknowledgement.
210
+ * @param props - the request in flight and its settlement callbacks.
211
+ * @returns the dialog.
212
+ */
213
+ function DeleteConfirmForm({ request, settleDelete, confirmDelete, t }) {
214
+ const [deleting, setDeleting] = React.useState(false)
215
+ const [error, setError] = React.useState(undefined)
216
+ const [done, setDone] = React.useState(undefined)
217
+
218
+ const close = () => {
219
+ if (deleting) return
220
+ settleDelete()
221
+ }
222
+
223
+ const confirm = () => {
224
+ if (deleting || done !== undefined) return
225
+ setDeleting(true)
226
+ setError(undefined)
227
+ confirmDelete(request).then(
228
+ (outcome) => {
229
+ setDeleting(false)
230
+ const children = Array.isArray(outcome?.children) ? outcome.children.length : 0
231
+ setDone({ children })
232
+ },
233
+ (failure) => {
234
+ setDeleting(false)
235
+ setError(describeFailure(t, failure))
236
+ },
237
+ )
238
+ }
239
+
240
+ const body = []
241
+ if (deleting) {
242
+ body.push(
243
+ h(
244
+ 'div',
245
+ { role: 'status', className: 'dshSessionDelete_status', key: 'pending' },
246
+ t('dialog.pending'),
247
+ ),
248
+ )
249
+ }
250
+ if (error !== undefined) {
251
+ body.push(
252
+ h('div', { role: 'alert', className: 'dshSessionDelete_error', key: 'error' }, error),
253
+ )
254
+ }
255
+ if (done !== undefined) {
256
+ body.push(
257
+ h(
258
+ 'div',
259
+ { role: 'status', className: 'dshSessionDelete_status', key: 'done' },
260
+ t('dialog.done'),
261
+ ),
262
+ )
263
+ if (done.children > 0) {
264
+ body.push(
265
+ h(
266
+ 'div',
267
+ { className: 'dshSessionDelete_note', key: 'children' },
268
+ t('dialog.children', { count: done.children }),
269
+ ),
270
+ )
271
+ }
272
+ }
273
+
274
+ const footer =
275
+ done !== undefined
276
+ ? h(Button, { variant: 'outline', onClick: close }, t('dialog.close'))
277
+ : h(
278
+ React.Fragment,
279
+ null,
280
+ h(
281
+ Button,
282
+ { variant: 'outline', disabled: deleting, onClick: close },
283
+ t('dialog.cancel'),
284
+ ),
285
+ h(
286
+ Button,
287
+ {
288
+ variant: 'outline',
289
+ className: 'dshSessionDelete_deleteAction',
290
+ disabled: deleting,
291
+ onClick: confirm,
292
+ },
293
+ t('dialog.confirm'),
294
+ ),
295
+ )
296
+
297
+ return h(Modal, {
298
+ open: true,
299
+ onClose: close,
300
+ closeLabel: t('dialog.close'),
301
+ title: t('dialog.title'),
302
+ description: t('dialog.description', { title: request.displayTitle }),
303
+ footer,
304
+ children: body,
305
+ })
306
+ }
307
+
308
+ /**
309
+ * The `shell.overlay` entry: nothing while no confirmation is pending,
310
+ * otherwise one dialog per request, keyed by the Session it targets.
311
+ * @param props - the request hook, its settlement, and the deletion hop.
312
+ * @returns the open dialog, or null.
313
+ */
314
+ function DeleteSessionDialog({ useDeleteRequest, settleDelete, confirmDelete, t }) {
315
+ const request = useDeleteRequest((pending) => pending)
316
+ if (!request) return null
317
+ // `key` (not a child) so a new request remounts the form: its in-flight,
318
+ // error and success state belong to exactly one request.
319
+ return h(DeleteConfirmForm, {
320
+ key: request.sessionId,
321
+ request,
322
+ settleDelete,
323
+ confirmDelete,
324
+ t,
325
+ })
326
+ }
327
+
328
+ return {
329
+ inject: ['slots', 'locale'],
330
+ apply(ctx) {
331
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ybbmm.dsh-session-delete: dictionaries')
332
+
333
+ const pending = createObservable(null)
334
+
335
+ const requestDelete = (sessionId, displayTitle) => {
336
+ pending.set({
337
+ sessionId,
338
+ displayTitle,
339
+ workspaceId: workspaceOf(ctx, sessionId),
340
+ })
341
+ }
342
+ const settleDelete = () => {
343
+ pending.set(null)
344
+ }
345
+
346
+ /**
347
+ * Delete one requested conversation and, when the main view showed it,
348
+ * open a fresh session in the same workspace.
349
+ *
350
+ * The pending request stays set so the dialog can report the outcome;
351
+ * the user closes it.
352
+ * @param request - the pending request being confirmed.
353
+ * @returns the Host outcome.
354
+ * @throws the Host failure, for the dialog to localize.
355
+ */
356
+ const confirmDelete = async (request) => {
357
+ const outcome = await requestDeletion(ctx, request.sessionId)
358
+ if (currentSessionId(ctx) === request.sessionId) {
359
+ const uiWorkspace = optionalService(ctx, 'uiWorkspace')
360
+ try {
361
+ // Reopen inside the same workspace, reusing its blank session
362
+ // exactly like the sidebar's own New Session action.
363
+ uiWorkspace?.startSession(request.workspaceId)
364
+ } catch (failure) {
365
+ console.warn('ybbmm.dsh-session-delete: could not open a new session:', failure)
366
+ }
367
+ }
368
+ return outcome
369
+ }
370
+
371
+ const actionInjected = () => ({ requestDelete })
372
+ const dialogInjected = () => ({
373
+ hooks: { deleteRequest: pending },
374
+ settleDelete,
375
+ confirmDelete,
376
+ })
377
+
378
+ ctx.slots.inject('sidebar.workspaces.session.menu.item', function* () {
379
+ yield ctx.slots.register(
380
+ {
381
+ name: 'sidebar.workspaces.session.menu.item',
382
+ id: 'ybbmm.dsh-session-delete',
383
+ order: 500,
384
+ locale: NS,
385
+ inject: actionInjected,
386
+ },
387
+ DeleteSessionMenuItem,
388
+ )
389
+ })
390
+ ctx.slots.inject('sidebar.workspaces.session.row.action', function* () {
391
+ yield ctx.slots.register(
392
+ {
393
+ name: 'sidebar.workspaces.session.row.action',
394
+ id: 'ybbmm.dsh-session-delete',
395
+ order: 300,
396
+ locale: NS,
397
+ inject: actionInjected,
398
+ },
399
+ DeleteSessionRowButton,
400
+ )
401
+ })
402
+ ctx.slots.inject('shell.overlay', function* () {
403
+ yield ctx.slots.register(
404
+ {
405
+ name: 'shell.overlay',
406
+ id: 'ybbmm.dsh-session-delete',
407
+ locale: NS,
408
+ inject: dialogInjected,
409
+ },
410
+ DeleteSessionDialog,
411
+ )
412
+ })
413
+ },
414
+ }
415
+ },
416
+ })
@@ -0,0 +1,7 @@
1
+ - insert:
2
+ - id: ybbmm-dsh-session-delete
3
+ name: '@ybbmm/dsh-session-delete'
4
+ inject:
5
+ - sessions
6
+ - sessionPersistence
7
+ - workspaceRegistry
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,482 @@
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 artifact 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
+ * Ordering is transactional on purpose: every step before the log removal can
16
+ * still be abandoned without leaving a user-visible inconsistency, so the one
17
+ * irreversible step runs first among the mutating steps and everything after
18
+ * it is fail-soft. A failed deletion therefore leaves the conversation exactly
19
+ * as it was, and retrying is meaningful.
20
+ *
21
+ * The bundle deliberately has no package dependencies: a bundle installed from
22
+ * a local directory resolves its imports from that directory, so every import
23
+ * would have to be a Node built-in.
24
+ */
25
+ import { rm, stat } from 'node:fs/promises'
26
+ import { dirname } from 'node:path'
27
+
28
+ /** Cordis service key of this plugin's service. */
29
+ const SERVICE_KEY = 'sessionDelete'
30
+ /** Wire namespace the API Gateway exposes for this service. */
31
+ const NAMESPACE = 'sessionDelete'
32
+ /** Property the Typert protocol stores Remote method markers under. */
33
+ const REMOTE_METHODS = '@deepseek-ai/dsh-typert-protocol/remote-methods'
34
+ /** Shared symbol Cordis uses to bind a service value to its owning context. */
35
+ const TRACKER = Symbol.for('cordis.tracker')
36
+ /** Bounded retries for a removal a closing writer may still be holding open. */
37
+ const REMOVE_ATTEMPTS = 8
38
+ /** First back-off delay; doubled per attempt up to the ceiling. */
39
+ const REMOVE_RETRY_BASE_MS = 40
40
+ /** Ceiling of the removal back-off delay. */
41
+ const REMOVE_RETRY_MAX_MS = 400
42
+ /** How many times the projection-cache row is re-checked after a delete. */
43
+ const CACHE_ATTEMPTS = 4
44
+ /** Pause between projection-cache checks. */
45
+ const CACHE_RETRY_MS = 60
46
+ /** Errors that mean "a handle is still open", not "this will never work". */
47
+ const RETRYABLE_REMOVE_CODES = new Set([
48
+ 'EBUSY',
49
+ 'EPERM',
50
+ 'EACCES',
51
+ 'ENOTEMPTY',
52
+ 'EEXIST',
53
+ 'EMFILE',
54
+ 'ENFILE',
55
+ ])
56
+
57
+ /**
58
+ * One deletion failure with a stable code and caller-facing message.
59
+ *
60
+ * The Gateway forwards any thrown value carrying the protocol's structural
61
+ * Remote marker unchanged, so `code` crosses the wire instead of folding into
62
+ * `gateway/internal`; a plain Error would lose it.
63
+ */
64
+ class SessionDeleteError extends Error {
65
+ /**
66
+ * @param code - stable failure code the browser localizes.
67
+ * @param message - diagnostic text, also the browser's fallback copy.
68
+ * @param details - structured payload for the dialog.
69
+ */
70
+ constructor(code, message, details = {}) {
71
+ super(message)
72
+ this.name = 'SessionDeleteError'
73
+ this.code = code
74
+ this.details = details
75
+ // Cross-bundle identification: the Gateway tests this marker rather than
76
+ // `instanceof`, because the protocol package resolves separately.
77
+ this.isDSHRemoteError = true
78
+ }
79
+ }
80
+
81
+ /** Read an optional service without failing when the composition lacks it. */
82
+ function optionalService(ctx, name) {
83
+ try {
84
+ return ctx.get(name)
85
+ } catch {
86
+ return undefined
87
+ }
88
+ }
89
+
90
+ /** Pause for `ms` milliseconds. */
91
+ function delay(ms) {
92
+ return new Promise((resolve) => setTimeout(resolve, ms))
93
+ }
94
+
95
+ /** Whether a filesystem failure is worth retrying while the writer settles. */
96
+ function isRetryableRemoveFailure(error) {
97
+ const code = error?.code
98
+ return typeof code === 'string' && RETRYABLE_REMOVE_CODES.has(code)
99
+ }
100
+
101
+ /** The back-off delay before attempt `attempt` (0-based). */
102
+ function removeRetryDelay(attempt) {
103
+ return Math.min(REMOVE_RETRY_BASE_MS * 2 ** attempt, REMOVE_RETRY_MAX_MS)
104
+ }
105
+
106
+ class SessionDeleteService {
107
+ /**
108
+ * @param ctx - the owning plugin context.
109
+ */
110
+ constructor(ctx) {
111
+ this.ctx = ctx
112
+ this.name = SERVICE_KEY
113
+ /** Deletions in flight, so two clicks cannot interleave their steps. */
114
+ this.inflight = new Map()
115
+ this.typertRemote = Object.freeze({
116
+ service: this,
117
+ serviceKey: SERVICE_KEY,
118
+ namespace: NAMESPACE,
119
+ })
120
+ }
121
+
122
+ /**
123
+ * Delete one conversation.
124
+ *
125
+ * Steps run in an order where only the log removal is irreversible, and it
126
+ * runs before the accounting release: a failure there aborts with the row
127
+ * still listed and retryable.
128
+ * @param sessionId - durable Session identity.
129
+ * @returns the deleted identity, what was removed, and surviving children.
130
+ */
131
+ async delete(sessionId) {
132
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
133
+ throw new SessionDeleteError(
134
+ 'session-delete/invalid-request',
135
+ 'sessionDelete: a session id is required',
136
+ )
137
+ }
138
+ const running = this.inflight.get(sessionId)
139
+ if (running !== undefined) return running
140
+ const task = this.runDelete(sessionId)
141
+ this.inflight.set(sessionId, task)
142
+ try {
143
+ return await task
144
+ } finally {
145
+ if (this.inflight.get(sessionId) === task) this.inflight.delete(sessionId)
146
+ }
147
+ }
148
+
149
+ /**
150
+ * The deletion proper; see {@link delete} for the ordering contract.
151
+ * @param sessionId - durable Session identity.
152
+ * @returns the deletion outcome.
153
+ */
154
+ async runDelete(sessionId) {
155
+ const ctx = this.ctx
156
+
157
+ // 1. Resolve the target: a live session, otherwise a stored one.
158
+ const live = ctx.sessions.get(sessionId)
159
+ let header = live?.header
160
+ let stored = false
161
+ if (!header) {
162
+ let snapshot
163
+ try {
164
+ snapshot = await ctx.sessionPersistence.stat(sessionId)
165
+ } catch (error) {
166
+ // A log this build cannot interpret is not a missing log; refuse with
167
+ // its own code so the dialog does not claim the conversation is gone.
168
+ throw new SessionDeleteError(
169
+ 'session-delete/unreadable',
170
+ `sessionDelete: session "${sessionId}" could not be read: ${error instanceof Error ? error.message : String(error)}`,
171
+ { sessionId },
172
+ )
173
+ }
174
+ if (!snapshot?.header) {
175
+ throw new SessionDeleteError(
176
+ 'session-delete/not-found',
177
+ `sessionDelete: session "${sessionId}" was not found`,
178
+ { sessionId },
179
+ )
180
+ }
181
+ header = snapshot.header
182
+ stored = true
183
+ }
184
+
185
+ // 2. Refuse while the conversation still owns running work. The same gate
186
+ // the shipped archive action uses, so the answer matches the UI's own.
187
+ const activity = await ctx.waterfall(
188
+ 'workspace/session-activity',
189
+ { sessionId },
190
+ () => Promise.resolve([]),
191
+ )
192
+ const reported = Array.isArray(activity) ? activity : []
193
+ if (reported.length > 0) {
194
+ throw new SessionDeleteError(
195
+ 'session-delete/active',
196
+ `sessionDelete: session "${sessionId}" is still running work; stop it before deleting`,
197
+ {
198
+ sessionId,
199
+ activity: reported.map((entry) => (typeof entry?.kind === 'string' ? entry.kind : 'unknown')),
200
+ },
201
+ )
202
+ }
203
+
204
+ // 3. Children survive their parent: name them so the browser can say so.
205
+ const children = await this.listChildren(sessionId)
206
+
207
+ // 4. Stop what is left, then let a live session leave the stores so its
208
+ // writer closes. The writer itself removes its handle on
209
+ // `session/disposed`; the removal below retries through that delay.
210
+ await ctx.parallel('workspace/session-stop', { sessionId }).catch(() => {})
211
+ if (live) {
212
+ await ctx.sessions.flush(live).catch(() => {})
213
+ this.detach(sessionId)
214
+ }
215
+
216
+ // 5. Remove the stored conversation. This is the irreversible step; a
217
+ // session that never materialized has no artifact, which counts as
218
+ // success rather than failure.
219
+ const removed = await this.removeStoredLog(header, sessionId)
220
+
221
+ // 6. Release the durable workspace accounting. Fail-soft: the log is
222
+ // already gone, so a stale membership must not fail the deletion.
223
+ await this.releaseWorkspaceAccounting(sessionId)
224
+
225
+ // 7. Drop the projection-cache record so no stale hint survives. A live
226
+ // session's disposal checkpoint may land after this call, so the row is
227
+ // re-checked before the deletion reports success.
228
+ await this.removeProjectionCache(sessionId)
229
+
230
+ // 8. A live session announces its own removal through `session/disposed`;
231
+ // a stored one has no such event, so announce it here.
232
+ if (!live) ctx.emit('api-session/removed', sessionId)
233
+
234
+ return {
235
+ sessionId,
236
+ removed,
237
+ wasLive: live !== undefined,
238
+ stored,
239
+ children,
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Remove one session from the live session and agent stores.
245
+ * @param sessionId - durable Session identity.
246
+ */
247
+ detach(sessionId) {
248
+ const ctx = this.ctx
249
+ const sessions = optionalService(ctx, 'sessions')
250
+ const entry = sessions?.store?.get(sessionId)
251
+ if (entry && typeof sessions.detachEntered === 'function') {
252
+ try {
253
+ sessions.detachEntered(entry)
254
+ } catch (error) {
255
+ ctx.logger?.warn(`sessionDelete: detaching session "${sessionId}" failed: ${String(error)}`)
256
+ }
257
+ }
258
+ const agents = optionalService(ctx, 'agents')
259
+ const agentEntry = agents?.store?.get(sessionId)
260
+ if (agentEntry && typeof agents.detachEntered === 'function') {
261
+ try {
262
+ agents.detachEntered(agentEntry)
263
+ } catch (error) {
264
+ ctx.logger?.warn(`sessionDelete: detaching agent "${sessionId}" failed: ${String(error)}`)
265
+ }
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Release membership, saved order, pin and archive mark for one session.
271
+ *
272
+ * Every call is fail-soft and in a stable order: membership first, so the
273
+ * row leaves the sidebar even when a later step throws.
274
+ * @param sessionId - durable Session identity.
275
+ */
276
+ async releaseWorkspaceAccounting(sessionId) {
277
+ const ctx = this.ctx
278
+ const registry = optionalService(ctx, 'workspaceRegistry')
279
+ if (!registry) return
280
+ const attempt = async (label, run) => {
281
+ try {
282
+ await run()
283
+ } catch (error) {
284
+ ctx.logger?.warn(`sessionDelete: releasing ${label} for "${sessionId}" failed: ${String(error)}`)
285
+ }
286
+ }
287
+ for (const workspace of registry.list()) {
288
+ if (workspace.record?.sessionIds?.includes(sessionId)) {
289
+ await attempt('workspace membership', () => workspace.detachSession(sessionId))
290
+ }
291
+ }
292
+ await attempt('pin', () => registry.unpinSession(sessionId))
293
+ await attempt('archive mark', () => registry.unarchiveSession(sessionId))
294
+ }
295
+
296
+ /**
297
+ * Remove the session's stored log artifact.
298
+ *
299
+ * The store owns the on-disk layout, so its own locator names the artifact.
300
+ * The shipped JSONL backend keeps one directory per session (holding the
301
+ * log, its kernel lock file and any future session-local artifact), so the
302
+ * removal targets that directory; a backend that names a session-owned
303
+ * directory directly, or a single file, is honoured as-is. A writer that is
304
+ * still closing can recreate the artifact, so transient failures are
305
+ * retried with back-off instead of failing the whole deletion.
306
+ * @param header - the stored session header.
307
+ * @param sessionId - durable Session identity, for diagnostics.
308
+ * @returns whether a stored artifact was removed.
309
+ */
310
+ async removeStoredLog(header, sessionId) {
311
+ const persistence = this.ctx.sessionPersistence
312
+ const locate = persistence?.locate
313
+ if (typeof locate !== 'function') {
314
+ throw new SessionDeleteError(
315
+ 'session-delete/unsupported-store',
316
+ 'sessionDelete: this session store does not expose its on-disk layout',
317
+ { sessionId },
318
+ )
319
+ }
320
+ const location = locate.call(persistence, header)
321
+ if (!location?.path) return false
322
+ const target =
323
+ location.kind === 'dir' ? location.path : location.kind === 'file' ? location.path : dirname(location.path)
324
+ // A session created but never materialized has no artifact; `rm` would
325
+ // report success for a path that never existed, which would overstate the
326
+ // outcome. Nothing on disk is still a successful deletion.
327
+ if (!(await this.pathExists(target))) return false
328
+
329
+ let lastFailure
330
+ for (let attempt = 0; attempt < REMOVE_ATTEMPTS; attempt++) {
331
+ try {
332
+ await this.removeArtifact(target)
333
+ return true
334
+ } catch (error) {
335
+ lastFailure = error
336
+ // `force: true` already ignores ENOENT, so a non-transient failure
337
+ // means retrying cannot help: report it instead of burning the budget.
338
+ if (!isRetryableRemoveFailure(error)) break
339
+ if (attempt < REMOVE_ATTEMPTS - 1) await delay(removeRetryDelay(attempt))
340
+ }
341
+ }
342
+ const reason = lastFailure instanceof Error ? lastFailure.message : String(lastFailure)
343
+ throw new SessionDeleteError(
344
+ 'session-delete/io',
345
+ `sessionDelete: removing the log of session "${sessionId}" failed: ${reason}`,
346
+ { sessionId, path: target, attempts: REMOVE_ATTEMPTS },
347
+ )
348
+ }
349
+
350
+ /**
351
+ * Remove one artifact path.
352
+ *
353
+ * The single filesystem call the deletion makes, kept as its own method so
354
+ * the retry loop above stays testable without provoking real Windows sharing
355
+ * violations.
356
+ * @param target - absolute artifact path (session directory or file).
357
+ * @returns resolution once the artifact is absent.
358
+ */
359
+ removeArtifact(target) {
360
+ return rm(target, { recursive: true, force: true })
361
+ }
362
+
363
+ /**
364
+ * Whether a path still exists.
365
+ *
366
+ * A non-ENOENT failure counts as present: the caller then attempts the
367
+ * removal and reports its real outcome instead of silently skipping it.
368
+ * @param target - absolute artifact path.
369
+ * @returns whether the path exists.
370
+ */
371
+ async pathExists(target) {
372
+ try {
373
+ await stat(target)
374
+ return true
375
+ } catch (error) {
376
+ return error?.code === 'ENOENT' ? false : true
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Drop the stored projection-cache record of one session.
382
+ *
383
+ * A live session's disposal checkpoint is written asynchronously right after
384
+ * the detach, so one delete can be followed by the row reappearing. The row
385
+ * is re-checked until it stays gone; a row that survives the whole budget is
386
+ * only logged, because the log is already deleted and every reader
387
+ * re-validates a cached row against the header — a stale hint must not turn
388
+ * a successful deletion into a failure.
389
+ * @param sessionId - durable Session identity.
390
+ */
391
+ async removeProjectionCache(sessionId) {
392
+ const storageDomain = optionalService(this.ctx, 'storageDomain')
393
+ if (!storageDomain || typeof storageDomain.get !== 'function') return
394
+ let table
395
+ try {
396
+ const domain = storageDomain.get('session_projcache')
397
+ if (!domain || typeof domain.table !== 'function') return
398
+ table = domain.table('sessions')
399
+ } catch {
400
+ return
401
+ }
402
+ for (let attempt = 0; attempt < CACHE_ATTEMPTS; attempt++) {
403
+ try {
404
+ if (table.get(sessionId) === undefined) return
405
+ await table.delete(sessionId)
406
+ } catch (error) {
407
+ this.ctx.logger?.warn(
408
+ `sessionDelete: dropping the projection cache of "${sessionId}" failed: ${String(error)}`,
409
+ )
410
+ return
411
+ }
412
+ if (attempt < CACHE_ATTEMPTS - 1) await delay(CACHE_RETRY_MS)
413
+ }
414
+ try {
415
+ if (table.get(sessionId) !== undefined) {
416
+ this.ctx.logger?.warn(
417
+ `sessionDelete: projection cache row for "${sessionId}" survived deletion; it is re-validated on the next read`,
418
+ )
419
+ }
420
+ } catch {
421
+ // A closed domain cannot hold a stale row for the browser either.
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Stored sessions whose header names `sessionId` as their parent.
427
+ *
428
+ * Deleting a parent does not delete its forks; naming them lets the browser
429
+ * say so instead of leaving a silently orphaned lineage.
430
+ * @param sessionId - durable Session identity being deleted.
431
+ * @returns child session ids, or an empty list when the store cannot list.
432
+ */
433
+ async listChildren(sessionId) {
434
+ const persistence = this.ctx.sessionPersistence
435
+ if (!persistence || typeof persistence.list !== 'function') return []
436
+ try {
437
+ const snapshots = await persistence.list()
438
+ return snapshots
439
+ .map((snapshot) => snapshot?.header)
440
+ .filter((header) => header && header.id !== sessionId && header.parentSession === sessionId)
441
+ .map((header) => header.id)
442
+ } catch {
443
+ return []
444
+ }
445
+ }
446
+ }
447
+
448
+ // Declare the Remote method for the API Gateway's runtime dispatch. The
449
+ // marker format is the Typert protocol's public, versioned descriptor: a
450
+ // frozen record on the class prototype naming the exported methods. The
451
+ // `resources` reader is called with the method's positional arguments
452
+ // (`read(reader, positional)` in the Gateway), so for this one-parameter
453
+ // method the reader receives the id string itself.
454
+ Object.defineProperty(SessionDeleteService.prototype, REMOTE_METHODS, {
455
+ configurable: true,
456
+ value: Object.freeze({
457
+ version: 1,
458
+ methods: Object.freeze([
459
+ Object.freeze({
460
+ method: 'delete',
461
+ invocation: Object.freeze({ kind: 'direct' }),
462
+ resources: Object.freeze({
463
+ session: (sessionId) => (typeof sessionId === 'string' ? sessionId : undefined),
464
+ }),
465
+ }),
466
+ ]),
467
+ }),
468
+ })
469
+
470
+ /**
471
+ * Register the deletion service and let the browser reach it.
472
+ * @param ctx - the owning plugin context.
473
+ */
474
+ export function apply(ctx) {
475
+ const service = new SessionDeleteService(ctx)
476
+ // Bind the service to the calling context the way a Cordis Service does, so
477
+ // the API Gateway reaches it through the same traced proxy.
478
+ Object.defineProperty(service, TRACKER, {
479
+ value: { associate: SERVICE_KEY, property: 'ctx' },
480
+ })
481
+ ctx.reflect.provide(SERVICE_KEY, service, undefined)
482
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Session Delete",
4
+ "description": "Adds a Delete conversation action to the sidebar session menu and permanently removes the conversation from disk."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "删除会话",
4
+ "description": "在侧边栏会话菜单中新增「删除对话」,永久清除该对话的本地记录。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@ybbmm/dsh-session-delete",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.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
+ "scripts": {
30
+ "test": "node --test"
31
+ },
32
+ "exports": {
33
+ ".": "./index.js",
34
+ "./client": "./client.js",
35
+ "./locale/*.json": "./locale/*.json",
36
+ "./icon": "./icon.svg"
37
+ },
38
+ "files": [
39
+ "index.js",
40
+ "client.js",
41
+ "cordis.patch.yml",
42
+ "locale/*.json",
43
+ "icon.svg",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "dsh": {
51
+ "bundle": {
52
+ "patch": "./cordis.patch.yml"
53
+ },
54
+ "client": {
55
+ "platform": "web",
56
+ "immediately": true,
57
+ "inject": [
58
+ "@deepseek-ai/dsh-client-ui-workspace",
59
+ "@deepseek-ai/dsh-client-ui-renderer",
60
+ "@deepseek-ai/dsh-client-ui-primitives",
61
+ "@deepseek-ai/dsh-client-locale"
62
+ ]
63
+ }
64
+ }
65
+ }