dsh-delete-conversation 0.0.0-stage → 1.0.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 jiuling
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,58 @@
1
- # Temporary Holding Version
1
+ # dsh-delete-conversation
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
+ > DeepSeek Harness 插件:在侧边栏会话的「⋯」菜单里加一项 **删除会话**。
4
+
5
+ DSH 原生只提供「归档会话」(会话仍留在磁盘上,可随时取消归档)。装上这个插件后,
6
+ 菜单里会多出一行 **删除会话**:经二次确认,**永久删除**该会话的日志、工作区归属与投影缓存。
7
+
8
+ ## 安装
9
+
10
+ ```bash
11
+ dsh plugin add dsh-delete-conversation
12
+ ```
13
+
14
+ 安装后重启 DSH 宿主(Web 端刷新页面即可加载浏览器半边;宿主半边需要重启进程)。
15
+
16
+ ## 使用
17
+
18
+ 1. 在左侧边栏把鼠标移到任意会话行上,点右侧的「⋯」;
19
+ 2. 菜单底部选择 **删除会话**;
20
+ 3. 弹窗里确认 **永久删除**。
21
+
22
+ 删完这一行就从列表里消失了。
23
+
24
+ ## 它到底删了什么
25
+
26
+ 一次删除会清掉这个会话的全部落地痕迹:
27
+
28
+ | 位置 | 内容 |
29
+ |---|---|
30
+ | `$DSH_HOME/sessions/<项目>/<会话 id>/` | 会话事件日志(`session.v4.jsonl.zstd`) |
31
+ | `$DSH_HOME/storages/workspace.json` → `archivedSessionIds` | 该会话的归档记录 |
32
+ | `$DSH_HOME/storages/workspace.json` → `tables.workspaces.*.sessionIds` | 该会话的工作区归属 |
33
+ | `$DSH_HOME/storages/session_projcache/sessions/<会话 id>.json` | 会话投影缓存 |
34
+
35
+ 工作区那部分走的是宿主自己的 `workspaceRegistry`(`detachSession` / `unarchiveSession`),
36
+ 不是绕开它直接改文件——所以内存里的投影和磁盘上的表始终一致。
37
+
38
+ ## 安全边界
39
+
40
+ - **正在运行的会话不能删。** 它有活跃的 Agent 持有写锁,且界面还指着它。
41
+ 这种情况接口返回 `409`,弹窗里会提示你先结束它或改为归档。
42
+ - **只在同源、回环地址上的请求会被受理。** 带了 `forwarded` / `x-forwarded-for` /
43
+ `x-real-ip` 的转发请求一律 `403`,所以把这个端口暴露到公网也不会被人隔着隧道删会话。
44
+ - **每个会话 id 都按格式校验**(`session-<uuid>`),不合法直接拒。
45
+ - 删除是物理不可逆的,没有回收站。想要可恢复,请用原生「归档会话」。
46
+
47
+ ## 兼容性
48
+
49
+ - 依赖官方菜单插槽 `sidebar.workspaces.session.menu.item`(DSH 桌面端 / Web 端自带)。
50
+ 插件不碰 DOM,不劫持样式,官方改版菜单不会让它散架。
51
+ - 菜单行的样式优先复用 `@deepseek-ai/dsh-client-ui-primitives` 的 `MenuItemButton`
52
+ 与 `IconTrashOutlineRegular`;万一取不到,会退化成一个仍能加入菜单键盘遍历的
53
+ `role="menuitem"` 按钮。
54
+ - 中英文界面文案都已内置,跟随 DSH 的语言设置。
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,6 @@
1
+ # dsh-delete-conversation bundle patch layer: installable via `dsh plugin add`.
2
+ # This declares the plugin's own loader entry, inserted into the profile tree.
3
+ # `name` MUST equal the package name.
4
+ - insert:
5
+ - id: dsh-delete-conversation
6
+ name: 'dsh-delete-conversation'
package/lib/client.js ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * dsh-delete-conversation — browser half.
3
+ *
4
+ * Contributes one destructive row, "删除会话", to the Session "..." menu
5
+ * (the official `sidebar.workspaces.session.menu.item` list slot, after the
6
+ * shipped archive row) and one confirmation dialog in the frame-wide
7
+ * `shell.overlay` layer. The menu row only raises the request; the dialog
8
+ * performs it, so the destructive call never rides a menu that is closing.
9
+ */
10
+ window.__ModuleLoader__.load({
11
+ id: 'dsh-delete-conversation',
12
+ factory: (require) => {
13
+ const React = require('react');
14
+
15
+ /** This package's locale namespace, slot-id prefix, and host route. */
16
+ const NS = 'dsh-delete-conversation';
17
+ const DELETE_PATH = '/dsh-delete-conversation/delete';
18
+
19
+ /**
20
+ * ui-primitives supplies the shipped menu row's chrome. A plugin resolved
21
+ * from disk may not see it, so the row degrades to a plain `menuitem`
22
+ * button that still joins the menu's keyboard walk.
23
+ */
24
+ let primitives = null;
25
+ try {
26
+ primitives = require('@deepseek-ai/dsh-client-ui-primitives');
27
+ } catch {
28
+ primitives = null;
29
+ }
30
+ const MenuItemButton = primitives !== null && typeof primitives.MenuItemButton === 'function' ? primitives.MenuItemButton : null;
31
+ const TrashIcon = primitives !== null && typeof primitives.IconTrashOutlineRegular === 'function' ? primitives.IconTrashOutlineRegular : null;
32
+
33
+ const DICT = {
34
+ zh: {
35
+ 'menu.deleteSession': '删除会话',
36
+ 'dialog.title': '删除会话',
37
+ 'dialog.body': '将永久删除「{title}」及其全部消息记录,且无法撤销。',
38
+ 'dialog.cancel': '取消',
39
+ 'dialog.confirm': '永久删除',
40
+ 'dialog.deleting': '正在删除…'
41
+ },
42
+ en: {
43
+ 'menu.deleteSession': 'Delete session',
44
+ 'dialog.title': 'Delete session',
45
+ 'dialog.body': 'This permanently deletes "{title}" and its entire message history. It cannot be undone.',
46
+ 'dialog.cancel': 'Cancel',
47
+ 'dialog.confirm': 'Delete permanently',
48
+ 'dialog.deleting': 'Deleting…'
49
+ }
50
+ };
51
+
52
+ /**
53
+ * Minimal observable: the overlay entry lives outside the menu row that
54
+ * raises the request, so both read one shared value.
55
+ * @param initial - starting value.
56
+ * @returns get/set/subscribe accessors.
57
+ */
58
+ function createSignal(initial) {
59
+ let value = initial;
60
+ const listeners = new Set();
61
+ return {
62
+ get: () => value,
63
+ set(next) {
64
+ value = next;
65
+ for (const listener of [...listeners]) listener();
66
+ },
67
+ subscribe(listener) {
68
+ listeners.add(listener);
69
+ return () => {
70
+ listeners.delete(listener);
71
+ };
72
+ }
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Subscribe a component to one signal.
78
+ * @param signal - signal to read.
79
+ * @returns the current value, re-rendering its reader on change.
80
+ */
81
+ function useSignal(signal) {
82
+ const [value, setValue] = React.useState(signal.get);
83
+ React.useEffect(() => signal.subscribe(() => { setValue(signal.get()); }), [signal]);
84
+ return value;
85
+ }
86
+
87
+ /**
88
+ * Ask the host to delete one stored Session.
89
+ * @param sessionId - stored Session id.
90
+ * @returns the host result.
91
+ * @throws {Error} carrying the host's own message.
92
+ */
93
+ async function requestDeletion(sessionId) {
94
+ const response = await fetch(DELETE_PATH, {
95
+ method: 'POST',
96
+ headers: { 'content-type': 'application/json' },
97
+ body: JSON.stringify({ sessionId })
98
+ });
99
+ let body = null;
100
+ try {
101
+ body = await response.json();
102
+ } catch {
103
+ body = null;
104
+ }
105
+ if (!response.ok || body === null || body.ok !== true) {
106
+ throw new Error(body !== null && typeof body.error === 'string' ? body.error : `HTTP ${response.status}`);
107
+ }
108
+ return body;
109
+ }
110
+
111
+ /**
112
+ * Rebuild the sidebar list after a deletion, releasing the selection when
113
+ * the deleted Session was the one on stage.
114
+ * @param ctx - client context.
115
+ * @param sessionId - the deleted Session id.
116
+ */
117
+ function refreshAfterDeletion(ctx, sessionId) {
118
+ const sessions = ctx.get('sessions');
119
+ if (sessions === undefined) return;
120
+ try {
121
+ if (String(sessions.list.getSnapshot().current) === sessionId) ctx.sessions.clearSelection();
122
+ } catch {}
123
+ Promise.resolve(sessions.refresh()).catch(() => {});
124
+ }
125
+
126
+ /**
127
+ * The Session "..." menu row (order 500, after the shipped archive row at 400).
128
+ * @param props - owner share, the shared request signal, and the translator.
129
+ * @returns the menu row.
130
+ */
131
+ function DeleteSessionMenuItem({ sessionId, displayTitle, useMenuOpenState, requestDelete, t }) {
132
+ const [, setMenuOpen] = useMenuOpenState();
133
+ const label = t('menu.deleteSession');
134
+ const select = () => {
135
+ setMenuOpen(false);
136
+ requestDelete({ sessionId, displayTitle });
137
+ };
138
+ if (MenuItemButton !== null) {
139
+ return React.createElement(MenuItemButton, {
140
+ icon: TrashIcon === null ? undefined : React.createElement(TrashIcon, {}),
141
+ onSelect: select,
142
+ children: label
143
+ });
144
+ }
145
+ return React.createElement('button', {
146
+ type: 'button',
147
+ role: 'menuitem',
148
+ onClick: select,
149
+ style: {
150
+ display: 'flex',
151
+ alignItems: 'center',
152
+ gap: 8,
153
+ width: '100%',
154
+ padding: '6px 10px',
155
+ border: 'none',
156
+ background: 'transparent',
157
+ color: 'inherit',
158
+ font: 'inherit',
159
+ textAlign: 'left',
160
+ cursor: 'pointer'
161
+ }
162
+ }, label);
163
+ }
164
+
165
+ /**
166
+ * Confirmation dialog; the destructive call runs here, not in the menu row.
167
+ * @param props - the client context, the shared request signal, and the translator.
168
+ * @returns the dialog, or null while no request is open.
169
+ */
170
+ function DeleteSessionDialog({ ctx, signal, t }) {
171
+ const request = useSignal(signal);
172
+ const [pending, setPending] = React.useState(false);
173
+ const [error, setError] = React.useState('');
174
+ const openId = request === null ? null : request.sessionId;
175
+ React.useEffect(() => {
176
+ setPending(false);
177
+ setError('');
178
+ }, [openId]);
179
+ if (request === null) return null;
180
+ const confirm = () => {
181
+ setPending(true);
182
+ setError('');
183
+ requestDeletion(request.sessionId).then(() => {
184
+ signal.set(null);
185
+ refreshAfterDeletion(ctx, request.sessionId);
186
+ }).catch((failure) => {
187
+ setPending(false);
188
+ setError(failure instanceof Error ? failure.message : String(failure));
189
+ });
190
+ };
191
+ const cancel = () => {
192
+ if (pending) return;
193
+ signal.set(null);
194
+ };
195
+ const buttonStyle = {
196
+ padding: '6px 14px',
197
+ borderRadius: 6,
198
+ border: '1px solid var(--dsw-alias-border-divider, rgba(0, 0, 0, 0.12))',
199
+ background: 'transparent',
200
+ color: 'var(--dsw-alias-label-primary, #1a1a1a)',
201
+ font: 'inherit',
202
+ cursor: pending ? 'default' : 'pointer'
203
+ };
204
+ return React.createElement('div', {
205
+ style: {
206
+ position: 'fixed',
207
+ inset: 0,
208
+ display: 'flex',
209
+ alignItems: 'center',
210
+ justifyContent: 'center',
211
+ background: 'rgba(0, 0, 0, 0.35)',
212
+ pointerEvents: 'auto',
213
+ zIndex: 40
214
+ },
215
+ onClick: cancel
216
+ }, React.createElement('div', {
217
+ role: 'dialog',
218
+ 'aria-modal': 'true',
219
+ 'aria-label': t('dialog.title'),
220
+ onClick: (event) => { event.stopPropagation(); },
221
+ style: {
222
+ width: 'min(420px, calc(100vw - 48px))',
223
+ padding: '18px 20px 16px',
224
+ borderRadius: 12,
225
+ background: 'var(--dsw-alias-bg-elevated, #ffffff)',
226
+ boxShadow: '0 12px 32px rgba(0, 0, 0, 0.24)'
227
+ }
228
+ },
229
+ React.createElement('div', { style: { color: 'var(--dsw-alias-label-primary, #1a1a1a)', fontSize: 15, lineHeight: '22px', fontWeight: 500 } }, t('dialog.title')),
230
+ React.createElement('div', { style: { marginTop: 8, color: 'var(--dsw-alias-label-secondary, #666666)', fontSize: 13, lineHeight: '20px', wordBreak: 'break-word' } }, t('dialog.body', { title: request.displayTitle })),
231
+ error === '' ? null : React.createElement('div', { style: { marginTop: 10, color: 'var(--dsw-alias-label-danger, #d93025)', fontSize: 13, lineHeight: '20px' } }, error),
232
+ React.createElement('div', { style: { marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 8 } },
233
+ React.createElement('button', { type: 'button', onClick: cancel, disabled: pending, style: buttonStyle }, t('dialog.cancel')),
234
+ React.createElement('button', {
235
+ type: 'button',
236
+ onClick: confirm,
237
+ disabled: pending,
238
+ style: { ...buttonStyle, borderColor: 'transparent', background: 'var(--dsw-alias-label-danger, #d93025)', color: '#ffffff', opacity: pending ? 0.6 : 1 }
239
+ }, pending ? t('dialog.deleting') : t('dialog.confirm'))
240
+ )));
241
+ }
242
+
243
+ const exports = {};
244
+ exports.inject = ['slots', 'locale'];
245
+ exports.apply = function apply(ctx) {
246
+ ctx.effect(() => ctx.locale.register(NS, DICT), 'dsh-delete-conversation: dictionaries');
247
+ const t = ctx.locale.bind(NS);
248
+ const deleteRequest = createSignal(null);
249
+
250
+ ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
251
+ name: 'sidebar.workspaces.session.menu.item',
252
+ id: NS,
253
+ order: 500,
254
+ locale: NS,
255
+ inject: () => ({
256
+ requestDelete: (request) => { deleteRequest.set(request); }
257
+ })
258
+ }, function DeleteSessionMenuRow(props) {
259
+ return React.createElement(DeleteSessionMenuItem, { ...props, t });
260
+ }));
261
+
262
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
263
+ name: 'shell.overlay',
264
+ id: `${NS}.confirm`,
265
+ locale: NS,
266
+ inject: () => ({})
267
+ }, function DeleteSessionConfirm() {
268
+ return React.createElement(DeleteSessionDialog, { ctx, signal: deleteRequest, t });
269
+ }));
270
+ };
271
+ return exports;
272
+ }
273
+ });
package/lib/index.js ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * dsh-delete-conversation — host half.
3
+ *
4
+ * Permanently deletes one stored Session:
5
+ * 1. its log directory under `$DSH_HOME/sessions/<project>/<sessionId>`,
6
+ * 2. its archive / workspace membership in `workspace.json` — through the
7
+ * live `WorkspaceRegistry`, so the in-memory projection and the durable
8
+ * table stay consistent (never by rewriting the file behind its back),
9
+ * 3. its entry in the Session projection cache.
10
+ *
11
+ * A Session whose Agent is live is refused: the running Session owns its log
12
+ * and the UI still points at it; archive it or end it first.
13
+ *
14
+ * @module dsh-delete-conversation
15
+ */
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { dirname, join } from 'node:path';
19
+
20
+ /** Route the browser half calls. */
21
+ const DELETE_PATH = '/dsh-delete-conversation/delete';
22
+ /** Stored Session identity, exactly as the persistence layer spells it. */
23
+ const SESSION_ID_PATTERN = /^session-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
24
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
25
+ const SESSIONS_DIR = join(DSH_HOME, 'sessions');
26
+ const PROJCACHE_DIR = join(DSH_HOME, 'storages', 'session_projcache', 'sessions');
27
+ const WORKSPACE_FILE = join(DSH_HOME, 'storages', 'workspace.json');
28
+ const LOG_PATH = join(DSH_HOME, 'dsh-delete-conversation.log');
29
+
30
+ /**
31
+ * Append one line to the plugin log; logging never breaks the deletion.
32
+ * @param message - short event name.
33
+ * @param data - optional structured detail.
34
+ */
35
+ function log(message, data) {
36
+ try {
37
+ mkdirSync(dirname(LOG_PATH), { recursive: true });
38
+ appendFileSync(LOG_PATH, `[${new Date().toISOString()}] ${message}${data === undefined ? '' : ` ${JSON.stringify(data)}`}\n`, 'utf8');
39
+ } catch {}
40
+ }
41
+
42
+ /**
43
+ * Whether the request came from this machine's own browser tab: loopback peer,
44
+ * no forwarding headers, and an Origin matching the Host it was served from.
45
+ * @param request - incoming node request.
46
+ * @returns true when the request may mutate stored Sessions.
47
+ */
48
+ function trustedRequest(request) {
49
+ const address = request.socket.remoteAddress;
50
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false;
51
+ if (request.headers.forwarded !== undefined || request.headers['x-forwarded-for'] !== undefined || request.headers['x-real-ip'] !== undefined) return false;
52
+ const host = request.headers.host;
53
+ const origin = request.headers.origin;
54
+ if (typeof host !== 'string' || typeof origin !== 'string') return false;
55
+ try {
56
+ const parsed = new URL(origin);
57
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Send one JSON response.
65
+ * @param response - node response.
66
+ * @param status - HTTP status.
67
+ * @param value - JSON-serializable body.
68
+ */
69
+ function writeJson(response, status, value) {
70
+ response.writeHead(status, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8' });
71
+ response.end(JSON.stringify(value));
72
+ }
73
+
74
+ /**
75
+ * Read one JSON request body with a size bound; a Session id is all that crosses.
76
+ * @param request - incoming node request.
77
+ * @returns the parsed body.
78
+ */
79
+ async function readJsonBody(request) {
80
+ const chunks = [];
81
+ let size = 0;
82
+ for await (const chunk of request) {
83
+ size += chunk.length;
84
+ if (size > 8192) throw new Error('request body too large');
85
+ chunks.push(chunk);
86
+ }
87
+ if (chunks.length === 0) return {};
88
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
89
+ }
90
+
91
+ /**
92
+ * Every stored directory holding this Session's log. The directory name is the
93
+ * stored id verbatim, so no project-key re-encoding is needed here.
94
+ * @param sessionId - stored Session id.
95
+ * @returns absolute log-directory paths, possibly empty.
96
+ */
97
+ function sessionDirectories(sessionId) {
98
+ const found = [];
99
+ let entries;
100
+ try {
101
+ entries = readdirSync(SESSIONS_DIR, { withFileTypes: true });
102
+ } catch {
103
+ return found;
104
+ }
105
+ for (const entry of entries) {
106
+ if (!entry.isDirectory()) continue;
107
+ const candidate = join(SESSIONS_DIR, entry.name, sessionId);
108
+ if (existsSync(candidate)) found.push(candidate);
109
+ }
110
+ return found;
111
+ }
112
+
113
+ /**
114
+ * Workspace ids whose durable candidate account still names this Session.
115
+ * Read from the domain file because the registry's own projection filters out
116
+ * sessions whose header is already gone — exactly the residue worth clearing.
117
+ * @param sessionId - stored Session id.
118
+ * @returns workspace ids to detach from.
119
+ */
120
+ function workspaceIdsOwning(sessionId) {
121
+ let state;
122
+ try {
123
+ state = JSON.parse(readFileSync(WORKSPACE_FILE, 'utf8'));
124
+ } catch {
125
+ return [];
126
+ }
127
+ const tables = state?.tables?.workspaces;
128
+ if (tables === null || typeof tables !== 'object') return [];
129
+ return Object.entries(tables)
130
+ .filter(([, record]) => Array.isArray(record?.sessionIds) && record.sessionIds.includes(sessionId))
131
+ .map(([id]) => id);
132
+ }
133
+
134
+ /**
135
+ * Drop the Session from the workspace domain through the live registry, so the
136
+ * durable table, the display order and the in-memory entity all move together.
137
+ * @param ctx - host context.
138
+ * @param sessionId - stored Session id.
139
+ * @returns per-store outcome report.
140
+ */
141
+ async function detachFromWorkspaces(ctx, sessionId) {
142
+ const report = { detached: [], unarchived: false, errors: [] };
143
+ const registry = ctx.get('workspaceRegistry');
144
+ if (registry === undefined) {
145
+ report.errors.push('workspaceRegistry unavailable');
146
+ return report;
147
+ }
148
+ for (const workspaceId of workspaceIdsOwning(sessionId)) {
149
+ try {
150
+ const workspace = registry.get(workspaceId);
151
+ if (workspace === undefined) continue;
152
+ await workspace.detachSession(sessionId);
153
+ report.detached.push(workspaceId);
154
+ } catch (error) {
155
+ report.errors.push(`detach ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`);
156
+ }
157
+ }
158
+ try {
159
+ await registry.unarchiveSession(sessionId);
160
+ report.unarchived = true;
161
+ } catch (error) {
162
+ report.errors.push(`unarchive: ${error instanceof Error ? error.message : String(error)}`);
163
+ }
164
+ return report;
165
+ }
166
+
167
+ /**
168
+ * Whether an Agent currently owns this Session in this process.
169
+ * @param ctx - host context.
170
+ * @param sessionId - stored Session id.
171
+ * @returns true when the Session is live and must not be deleted.
172
+ */
173
+ function sessionIsLive(ctx, sessionId) {
174
+ const agents = ctx.get('agents');
175
+ if (agents === undefined) return false;
176
+ try {
177
+ return agents.get(sessionId) !== undefined;
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Permanently delete one stored Session.
185
+ * @param ctx - host context.
186
+ * @param sessionId - stored Session id.
187
+ * @returns a result describing what was removed.
188
+ * @throws {Error} when the id is malformed, the Session is live, or the log cannot be removed.
189
+ */
190
+ async function deleteSession(ctx, sessionId) {
191
+ if (!SESSION_ID_PATTERN.test(sessionId)) throw new Error('invalid session id');
192
+ if (sessionIsLive(ctx, sessionId)) {
193
+ const error = new Error('该会话正在运行,无法删除;请先归档或结束它。');
194
+ error.code = 'session-active';
195
+ throw error;
196
+ }
197
+ const directories = sessionDirectories(sessionId);
198
+ const cacheFile = join(PROJCACHE_DIR, `${sessionId}.json`);
199
+ const hadCache = existsSync(cacheFile);
200
+ if (directories.length === 0 && !hadCache) {
201
+ const residue = await detachFromWorkspaces(ctx, sessionId);
202
+ return { sessionId, directories: [], cacheRemoved: false, workspace: residue, existed: false };
203
+ }
204
+ // Physical removal first: when the log cannot go, the registry must not move either.
205
+ for (const directory of directories) {
206
+ rmSync(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
207
+ }
208
+ let cacheRemoved = false;
209
+ if (hadCache) {
210
+ try {
211
+ unlinkSync(cacheFile);
212
+ cacheRemoved = true;
213
+ } catch (error) {
214
+ log('projection cache removal failed', { sessionId, message: error instanceof Error ? error.message : String(error) });
215
+ }
216
+ }
217
+ const workspace = await detachFromWorkspaces(ctx, sessionId);
218
+ log('session deleted', { sessionId, directories, cacheRemoved, workspace });
219
+ return { sessionId, directories, cacheRemoved, workspace, existed: true };
220
+ }
221
+
222
+ export const name = 'dsh-delete-conversation';
223
+ export const inject = ['webServer'];
224
+
225
+ /**
226
+ * Host plugin body: expose one guarded route that permanently deletes a stored Session.
227
+ * @param ctx - host root context.
228
+ */
229
+ export function apply(ctx) {
230
+ ctx.effect(() => ctx.webServer.register({
231
+ kind: 'exact',
232
+ path: DELETE_PATH,
233
+ handler(request, response) {
234
+ if (request.method !== 'POST') {
235
+ writeJson(response, 405, { ok: false, error: 'method not allowed' });
236
+ return;
237
+ }
238
+ if (!trustedRequest(request)) {
239
+ writeJson(response, 403, { ok: false, error: 'deletion is limited to same-origin loopback requests' });
240
+ return;
241
+ }
242
+ void (async () => {
243
+ try {
244
+ const body = await readJsonBody(request);
245
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
246
+ const result = await deleteSession(ctx, sessionId);
247
+ writeJson(response, 200, { ok: true, ...result });
248
+ } catch (error) {
249
+ const message = error instanceof Error ? error.message : String(error);
250
+ const status = error?.code === 'session-active' ? 409 : 400;
251
+ log('deletion failed', { message });
252
+ writeJson(response, status, { ok: false, error: message });
253
+ }
254
+ })();
255
+ }
256
+ }), 'dsh-delete-conversation: delete route');
257
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "dsh-delete-conversation",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "DSH 插件:在侧边栏会话的「…」菜单里加一项「删除会话」,彻底删除该会话的日志、工作区归属与投影缓存。Adds a \"Delete session\" action to the DeepSeek Harness sidebar session menu that permanently removes the stored session.",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./client": {
12
+ "default": "./lib/client.js"
13
+ },
14
+ "./cordis.patch.yml": "./cordis.patch.yml",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "dsh": {
18
+ "bundle": {
19
+ "patch": "./cordis.patch.yml"
20
+ },
21
+ "client": {
22
+ "platform": "web",
23
+ "inject": []
24
+ }
25
+ },
26
+ "files": [
27
+ "lib",
28
+ "cordis.patch.yml",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "keywords": [
33
+ "dsh",
34
+ "dsh-plugin",
35
+ "dsh-bundle",
36
+ "deepseek-harness",
37
+ "session",
38
+ "conversation",
39
+ "delete",
40
+ "sidebar",
41
+ "menu"
42
+ ],
43
+ "license": "MIT",
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "engines": {
48
+ "node": ">=18"
49
+ },
50
+ "peerDependencies": {
51
+ "@deepseek-ai/cordis": "^4.0.1"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "@deepseek-ai/cordis": {
55
+ "optional": true
56
+ }
57
+ }
58
+ }