@ybbmm/dsh-session-delete 0.1.0 → 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.
Files changed (4) hide show
  1. package/README.md +83 -32
  2. package/client.js +217 -80
  3. package/index.js +482 -223
  4. package/package.json +5 -1
package/README.md CHANGED
@@ -14,33 +14,61 @@ log accumulates on disk forever. This plugin adds the missing action.
14
14
 
15
15
  | Where | What |
16
16
  |---|---|
17
- | Session **"…" menu** | A red **Delete conversation** row, separated from the shipped pin / rename / fork / archive rows |
17
+ | Session **"…" menu** | A red **Delete conversation** row, after the shipped pin / rename / fork / archive rows |
18
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 |
19
+ | **Confirmation dialog** | Shows the conversation title, states that deletion is permanent, then reports the outcome in place |
20
20
 
21
- Deleting the conversation that is currently open moves the main view to a new
22
- session.
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.
23
24
 
24
25
  ## What exactly is removed
25
26
 
26
27
  | Artifact | Effect |
27
28
  |---|---|
28
- | Session log directory (`~/.dsh/sessions/<project-key>/<session-id>/`) | removed |
29
- | Projection cache record (`~/.dsh/storages/session_projcache/sessions/<id>.json`) | removed |
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 |
30
31
  | 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
+ | Live session and agent store entries | detached *before* the files are removed, so no writer re-creates the log |
32
33
  | Attachments (`~/.dsh/attachments`) | **kept** — content-addressed and shared between sessions |
33
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 |
34
36
 
35
37
  ## Safety
36
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.
37
53
  - **Permanent.** There is no undo and no trash. Archiving is still the
38
54
  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.
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) |
44
72
 
45
73
  ## Install
46
74
 
@@ -52,11 +80,10 @@ Manually:
52
80
  dsh plugin add @ybbmm/dsh-session-delete
53
81
  ```
54
82
 
55
- From a local tarball:
83
+ From a local directory (development):
56
84
 
57
85
  ```bash
58
- npm pack
59
- dsh plugin add ./ybbmm-dsh-session-delete-0.1.0.tgz
86
+ dsh plugin add ./session-delete
60
87
  ```
61
88
 
62
89
  Restart the Harness if your profile does not hot-reload plugin bundles.
@@ -68,50 +95,74 @@ Restart the Harness if your profile does not hot-reload plugin bundles.
68
95
  - **No runtime dependencies.** The bundle imports only `node:fs/promises` and
69
96
  `node:path`, and loads nothing from `@deepseek-ai/*` at runtime.
70
97
  - 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.
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.
75
103
 
76
104
  ## How it works
77
105
 
78
106
  - **Host half** (`index.js`) registers a `sessionDelete` service with a
79
107
  `typertRemote` binding and a hand-written Remote method marker, which makes
80
108
  `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.
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.
86
112
  - **Browser half** (`client.js`) registers into
87
113
  `sidebar.workspaces.session.menu.item`,
88
114
  `sidebar.workspaces.session.row.action` and `shell.overlay`, and calls the
89
- endpoint over the shared connection RPC.
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.
90
118
  - Styles copy the host's own geometry, renamed under a plugin prefix, and use
91
119
  only `--dsw-alias-*` theme tokens, so light/dark and theme switching follow
92
120
  the app.
93
121
 
94
122
  ## Known limitations
95
123
 
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.
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.
98
126
  - **UI copy is English and Chinese only.**
99
127
  - Deleting a conversation does not delete files the conversation *produced* in
100
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.
101
144
 
102
145
  ## 中文说明
103
146
 
104
147
  DeepSeek Harness 只能归档会话、无法真正删除,日志会一直堆积在磁盘上。这个插件补上了缺失的删除能力。
105
148
 
106
- **新增内容**:会话「…」菜单里的**红色「删除对话」**、会话行悬停时的垃圾桶按钮,以及一个显示对话标题、写明"永久删除"的确认框。删除当前正在看的对话后,主视图会切换到一个新会话。
149
+ **新增内容**:会话「…」菜单里的**红色「删除对话」**、会话行悬停时的垃圾桶按钮,以及一个显示对话标题、写明"永久删除"的确认框;删除后确认框会原地显示"已删除"以及保留的分叉会话数量。删除当前正在看的对话后,会在同一工作区重新打开一个新会话。
150
+
151
+ **删除顺序是事务式的**:解析目标 → 拒绝运行中的工作 → 停并将活跃会话摘除 → **删除日志文件** → 释放工作区记账 → 删除投影缓存 → 广播移除事件。日志删除失败时会**先失败、不动记账和缓存**,所以侧边栏行仍在、可以直接重试。
152
+
153
+ **会被删掉的**:会话日志目录(日志 + 锁文件)、投影缓存记录、工作区记账(成员关系/排序/置顶/归档标记)。
154
+
155
+ **不会删的**:附件目录(按内容寻址、被多个会话共享)、临时 spill 文件、由该对话分叉出的子会话(确认框会提示数量)。
156
+
157
+ **错误码**:`active`(仍有运行中的工作)、`not-found`(已删除或不存在)、`io`(文件无法删除,未做任何改动)、`unsupported-store`(当前版本无法定位文件)、`invalid-request`(请求非法)。
107
158
 
108
- **会被删掉的**:会话日志目录、投影缓存记录、工作区记账(成员关系/排序/置顶/归档标记);活跃会话会先被摘除再删文件,避免写入器把日志复活。
159
+ **重试策略**:只对 `EBUSY`/`EPERM`/`EACCES`/`ENOTEMPTY` 这类"句柄还没关"的瞬时错误做退避重试(8 次,约 1.8 秒),其他错误立即报错,不会把时间耗在无效重试上。
109
160
 
110
- **不会删的**:附件目录(按内容寻址、被多个会话共享)、临时 spill 文件。
161
+ **安全性**:永久删除,无回收站、不可撤销——归档依然是可逆选项。同一会话的重复点击会合并为一次删除,不会交叉执行。
111
162
 
112
- **安全性**:永久删除,无回收站、不可撤销——归档依然是可逆选项。当对话还有运行中的回合、子代理、后台任务或定时提醒时,宿主会拒绝删除并说明原因。
163
+ **兼容性**:在 DeepSeek Harness `0.2.1-rc.2`(Desktop/Web,Windows,中英文界面)上验证;零运行时依赖。实现依赖少量宿主内部机制(会话存储/持久化服务、工作区注册表、投影缓存域、以及 API Gateway 对带 `typertRemote` 绑定的服务的运行时派发),宿主大版本变更时可能需要跟进更新。
113
164
 
114
- **兼容性**:在 DeepSeek Harness `0.2.1-rc.2`(Desktop/Web,Windows,中英文界面)上验证;零运行时依赖。实现依赖少量宿主内部机制(会话存储/持久化服务、以及 API Gateway 对带 `typertRemote` 绑定的服务的运行时派发),宿主大版本变更时可能需要跟进更新。
165
+ **已知限制**:没有全局 toast(插件无法使用该槽位),确认框内的原地提示即反馈;界面文案仅中英文;从未落盘的空白会话删除后,宿主内存里的 pending 条目可能要等重启才彻底消失。
115
166
 
116
167
  ## License
117
168
 
package/client.js CHANGED
@@ -4,7 +4,12 @@
4
4
  * Adds a "Delete conversation" entry to the sidebar session list: one row in
5
5
  * the Session "..." menu, one hover button, and one confirmation dialog. The
6
6
  * deletion itself runs on the Host through the `sessionDelete/delete` Remote
7
- * endpoint; this half only collects the request and shows the outcome.
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.
8
13
  */
9
14
  window.__ModuleLoader__.load({
10
15
  id: '@ybbmm/dsh-session-delete',
@@ -27,6 +32,16 @@ window.__ModuleLoader__.load({
27
32
  'dialog.cancel': 'Cancel',
28
33
  'dialog.close': 'Close',
29
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.',
30
45
  }
31
46
  const zh = {
32
47
  'menu.delete': '删除对话',
@@ -37,11 +52,20 @@ window.__ModuleLoader__.load({
37
52
  'dialog.cancel': '取消',
38
53
  'dialog.close': '关闭',
39
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': '该对话未能删除。',
40
64
  }
41
65
 
42
66
  // The hover button and the dialog copy the host's own geometry, renamed
43
67
  // 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}'
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)}'
45
69
  const cssTagId = `${PLUGIN}/session-delete.css`
46
70
  if (typeof document !== 'undefined' && document.querySelector(`style[data-plugin-css=${JSON.stringify(cssTagId)}]`) === null) {
47
71
  const tag = document.createElement('style')
@@ -87,18 +111,57 @@ window.__ModuleLoader__.load({
87
111
  return typeof key === 'string' && key.length > 0 ? key : undefined
88
112
  }
89
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
+
90
151
  /** Ask the Host to delete one conversation. */
91
152
  async function requestDeletion(ctx, sessionId) {
92
153
  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
- )
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 } })
99
160
  if (!result?.ok) {
100
- const failure = result?.error
101
- throw new Error(failure?.message ?? 'The conversation could not be deleted.')
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
102
165
  }
103
166
  return result.value
104
167
  }
@@ -141,94 +204,168 @@ window.__ModuleLoader__.load({
141
204
  )
142
205
  }
143
206
 
144
- /** The confirmation dialog. */
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
+ */
145
314
  function DeleteSessionDialog({ useDeleteRequest, settleDelete, confirmDelete, t }) {
146
315
  const request = useDeleteRequest((pending) => pending)
147
316
  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
- )
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
+ })
195
326
  }
196
327
 
197
328
  return {
198
329
  inject: ['slots', 'locale'],
199
330
  apply(ctx) {
200
- ctx.effect(
201
- () => ctx.locale.register(NS, { zh, en }),
202
- 'ybbmm.dsh-session-delete: dictionaries',
203
- )
331
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ybbmm.dsh-session-delete: dictionaries')
204
332
 
205
333
  const pending = createObservable(null)
206
334
 
207
335
  const requestDelete = (sessionId, displayTitle) => {
208
- pending.set({ sessionId, displayTitle, pending: false, error: undefined })
336
+ pending.set({
337
+ sessionId,
338
+ displayTitle,
339
+ workspaceId: workspaceOf(ctx, sessionId),
340
+ })
209
341
  }
210
342
  const settleDelete = () => {
211
343
  pending.set(null)
212
344
  }
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
- }
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)
227
366
  }
228
- } catch (error) {
229
- const message = error instanceof Error ? error.message : String(error)
230
- pending.set({ ...request, pending: false, error: message })
231
367
  }
368
+ return outcome
232
369
  }
233
370
 
234
371
  const actionInjected = () => ({ requestDelete })
package/index.js CHANGED
@@ -1,223 +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 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
- }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ybbmm/dsh-session-delete",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
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
6
  "license": "MIT",
@@ -26,6 +26,9 @@
26
26
  "engines": {
27
27
  "node": ">=22"
28
28
  },
29
+ "scripts": {
30
+ "test": "node --test"
31
+ },
29
32
  "exports": {
30
33
  ".": "./index.js",
31
34
  "./client": "./client.js",
@@ -54,6 +57,7 @@
54
57
  "inject": [
55
58
  "@deepseek-ai/dsh-client-ui-workspace",
56
59
  "@deepseek-ai/dsh-client-ui-renderer",
60
+ "@deepseek-ai/dsh-client-ui-primitives",
57
61
  "@deepseek-ai/dsh-client-locale"
58
62
  ]
59
63
  }