@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 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
- # 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, 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
+ })
@@ -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,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
@@ -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,61 @@
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.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
+ }