@itsyuyi/dsh-session-manager 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 itsyuyi
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 ADDED
@@ -0,0 +1,85 @@
1
+ # @itsyuyi/dsh-session-manager
2
+
3
+ DeepSeek Harness (DSH) plugin for **workspace session management** — currently: a durable **“Delete session”** action, right in the sidebar session row menu, with a host-styled confirmation dialog.
4
+
5
+ 为 DeepSeek Harness 提供**工作区会话管理**能力——当前版本:在侧栏会话行菜单中增加可**彻底删除会话**的操作,配宿主风格确认弹窗。
6
+
7
+ ## Features / 功能
8
+
9
+ - **“Delete session” menu row** appended to each session's `…` menu (after the shipped pin / rename / fork / archive rows), styled exactly like the host's destructive menu rows (theme tokens only, light & dark).
10
+ 每个会话“…”菜单末尾追加红色“删除会话”项,样式与宿主内置菜单行一致(仅用主题 token,自动适配明暗主题)。
11
+ - **Host-styled confirmation dialog** rendered in the `shell.overlay` slot: Escape / mask click dismiss, Cancel focused on open, in-flight and error states live inside the dialog.
12
+ 宿主同款确认弹窗:Esc/点遮罩关闭、打开即聚焦“取消”、删除中与错误状态都在弹窗内展示。
13
+ - **Durable deletion** — removes the session log artifact and detaches it from every workspace account (archive/pin sets included). The sidebar refreshes automatically; the search index reconciles itself on the next search.
14
+ 彻底删除——移除会话日志工件并从所有工作区登记(含归档/置顶集合)中摘除。侧栏自动刷新,搜索索引下次搜索时自动同步。
15
+ - **Safety guard** — refuses to delete a session with actual running work (a turn in flight, owned jobs, live subagents, active schedules), using the exact same `workspace/session-activity` admission the host's own archive flow uses. Idle sessions — even currently open ones — are deletable.
16
+ 安全保护——复用宿主归档同款的活跃判定:只拒绝**真正在运行**的会话(回合/任务/子代理/日程),空闲会话(即使打开着)可删。
17
+ - **Localized** — UI copy follows the host language (中文 / English), live-switching.
18
+ 界面文案跟随宿主语言,实时切换。
19
+ - **Verification tool** — `verify-session.mjs` checks on disk whether a session is truly gone (artifact directory + workspace registry), by session id **or title**.
20
+ 附带核对脚本:按 id 或标题确认会话是否已从系统目录真正删除。
21
+
22
+ ## Requirements / 环境要求
23
+
24
+ - DeepSeek Harness (web surface), DSH `0.1.7` generation (`@deepseek-ai/cordis` 4.x)
25
+ - Node `^22.19.0 || >=24.0.0`
26
+
27
+ ## Install / 安装
28
+
29
+ In any DSH agent session, ask the agent (or use the plugin manager) to install from npm:
30
+ 在任意 DSH 会话里让 Agent 执行(或通过插件管理器)从 npm 安装:
31
+
32
+ ```
33
+ plugin_manager install_bundle target: @itsyuyi/dsh-session-manager
34
+ ```
35
+
36
+ Or install from a local clone / 或从本地克隆安装:
37
+
38
+ ```bash
39
+ git clone https://github.com/itsyuyi/dsh-session-manager.git
40
+ # then: plugin_manager install_bundle target: /absolute/path/to/dsh-session-manager
41
+ ```
42
+
43
+ Then **refresh the browser page** once to load the client module.
44
+ 安装后**刷新一次页面**以加载客户端模块。
45
+
46
+ Uninstall / 卸载:`plugin_manager remove_bundle @itsyuyi/dsh-session-manager`
47
+
48
+ ## Usage / 使用
49
+
50
+ 1. Hover a session row in the sidebar → click `…` → **Delete session / 删除会话**.
51
+ 2. Confirm in the dialog. Done — the row disappears immediately.
52
+ 弹窗确认后即删除,侧栏行立即消失。
53
+
54
+ If the session is actually running, the dialog tells you to stop it first (this includes the session you are chatting in while it is mid-turn).
55
+ 若会话正在运行,弹窗会提示先停止(包括你正在对话中的会话)。
56
+
57
+ ### Verify deletion / 核对删除结果
58
+
59
+ ```bash
60
+ node verify-session.mjs # list every known session with on-disk status
61
+ node verify-session.mjs "Some title" # check one session by title or id
62
+ ```
63
+
64
+ “Deleted” means: artifact directory under `~/.dsh/sessions/` gone **and** no reference left in `workspace.json`. Projection-cache leftovers are derived data and never read again (harmless).
65
+ “已删除”判定:`~/.dsh/sessions/` 下工件目录消失 **且** `workspace.json` 无引用。投影缓存残留属派生数据,不会再被读取(无害)。
66
+
67
+ ## How it works / 工作原理
68
+
69
+ | Half | Mechanism |
70
+ |---|---|
71
+ | Host (`index.js`) | Registers `POST /plugins/dsh-session-manager/delete-session` on the host web server. Deletion = activity check (`workspace/session-activity` waterfall) → unarchive/unpin → `detachSession` from every workspace → `rm -rf` the session's JSONL artifact directory (traversal-proof: only ever removes existing directory names under the persistence root). |
72
+ | Client (`client.js`) | Registers a menu row in `sidebar.workspaces.session.menu.item` and a confirmation dialog in `shell.overlay`, sharing one request store. Styling copied from the host Modal/Menu/Button rules under a `dsm-` prefix using only `--dsw-*` theme tokens. |
73
+
74
+ The SQLite session-search index needs no write: it reconciles against the persistence listing on the next search and drops vanished sessions automatically.
75
+
76
+ ## Notes / 说明
77
+
78
+ - Replacing the plugin's **host** code requires restarting `dsh web` (Node ESM module cache); client code reloads on page refresh.
79
+ 修改插件 Host 端代码需重启 `dsh web` 才能重载;Client 端刷新页面即可。
80
+ - Deleting a session is **irreversible** — prefer the built-in *Archive* when you may come back.
81
+ 删除**不可恢复**——想留后路请用内置“归档”。
82
+
83
+ ## License
84
+
85
+ MIT © 2026 itsyuyi
package/client.js ADDED
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Client half of @itsyuyi/dsh-session-manager.
3
+ *
4
+ * Two cooperating slot entries share one module-scoped request store:
5
+ *
6
+ * 1. `sidebar.workspaces.session.menu.item` — a destructive "Delete session"
7
+ * row (after the shipped pin/rename/fork/archive rows). Clicking it closes
8
+ * the menu and raises a delete request; it never deletes inline.
9
+ * 2. `shell.overlay` — a centered confirmation dialog rendered only while a
10
+ * request is pending. It mirrors the host's archive-confirm dialog: Escape
11
+ * and mask click dismiss, the Cancel button is focused on open, the
12
+ * in-flight and error states live and die with the request, and a refused
13
+ * live session shows the localized reason inside the dialog.
14
+ *
15
+ * Localization reads `document.documentElement.lang`, which the host locale
16
+ * plugin keeps in sync with the active locale ("zh-CN" for Chinese, "en"
17
+ * otherwise); a MutationObserver re-renders on a live language switch. This
18
+ * avoids depending on `ctx.locale` being injected into this module.
19
+ *
20
+ * Styling copies the host Modal / Button / dialog-status rules under the
21
+ * `dsm-` prefix and references only `--dsw-*` theme tokens, so light and dark
22
+ * themes and the macOS title-bar clearance all follow the host. The stylesheet
23
+ * is inserted once in `apply` and removed on unload.
24
+ */
25
+ window.__ModuleLoader__.load({
26
+ id: '@itsyuyi/dsh-session-manager',
27
+ factory(require) {
28
+ const React = require('react');
29
+ const h = React.createElement;
30
+
31
+ const ROUTE = '/plugins/dsh-session-manager/delete-session';
32
+
33
+ /* ---------------------------------------------------------------- */
34
+ /* Localization */
35
+ /* ---------------------------------------------------------------- */
36
+
37
+ const DICTS = {
38
+ zh: {
39
+ menuLabel: '删除会话',
40
+ title: '删除会话',
41
+ desc: '确定要删除会话“{title}”吗?会话记录将被永久移除,此操作不可恢复。',
42
+ close: '关闭',
43
+ cancel: '取消',
44
+ confirm: '删除',
45
+ pending: '正在删除…',
46
+ live: '会话正在运行(有进行中的回合或任务),请等待结束或先停止,再删除。',
47
+ failed: '删除失败,请稍后重试。',
48
+ },
49
+ en: {
50
+ menuLabel: 'Delete session',
51
+ title: 'Delete session',
52
+ desc: 'Delete session “{title}”? Its history will be permanently removed. This cannot be undone.',
53
+ close: 'Close',
54
+ cancel: 'Cancel',
55
+ confirm: 'Delete',
56
+ pending: 'Deleting…',
57
+ live: 'Session is running (a turn or task is in progress); wait for it to finish or stop it first.',
58
+ failed: 'Delete failed, please retry.',
59
+ },
60
+ };
61
+
62
+ function activeLang(ctx) {
63
+ if (typeof document !== 'undefined' && document.documentElement && document.documentElement.lang)
64
+ return document.documentElement.lang;
65
+ try {
66
+ const a = ctx && ctx.locale && ctx.locale.getLocale && ctx.locale.getLocale().active;
67
+ if (a) return String(a);
68
+ } catch {
69
+ /* no locale service — fall through */
70
+ }
71
+ return 'en';
72
+ }
73
+
74
+ function dictForLang(lang) {
75
+ return String(lang || '').toLowerCase().startsWith('zh') ? DICTS.zh : DICTS.en;
76
+ }
77
+
78
+ /** Reactive dictionary: follows `document.documentElement.lang` live. */
79
+ function useDict(ctx) {
80
+ const [lang, setLang] = React.useState(() => activeLang(ctx));
81
+ React.useEffect(() => {
82
+ if (typeof document === 'undefined' || !document.documentElement) return undefined;
83
+ const el = document.documentElement;
84
+ const sync = () => setLang(activeLang(ctx));
85
+ sync();
86
+ let observer;
87
+ try {
88
+ observer = new MutationObserver(sync);
89
+ observer.observe(el, { attributes: true, attributeFilter: ['lang'] });
90
+ } catch {
91
+ observer = undefined;
92
+ }
93
+ let unsubLocale;
94
+ try {
95
+ unsubLocale = ctx && ctx.locale && ctx.locale.subscribe ? ctx.locale.subscribe(sync) : undefined;
96
+ } catch {
97
+ unsubLocale = undefined;
98
+ }
99
+ return () => {
100
+ if (observer) observer.disconnect();
101
+ if (typeof unsubLocale === 'function') unsubLocale();
102
+ };
103
+ }, [ctx]);
104
+ return dictForLang(lang);
105
+ }
106
+
107
+ /* ---------------------------------------------------------------- */
108
+ /* Shared request store (module-scoped, plain pub/sub) */
109
+ /* ---------------------------------------------------------------- */
110
+
111
+ function createDeleteStore() {
112
+ let pending = null; // { sessionId, displayTitle } | null
113
+ const listeners = new Set();
114
+ const emit = () => {
115
+ for (const fn of listeners) fn();
116
+ };
117
+ return {
118
+ getSnapshot: () => pending,
119
+ subscribe(fn) {
120
+ listeners.add(fn);
121
+ return () => {
122
+ listeners.delete(fn);
123
+ };
124
+ },
125
+ request(p) {
126
+ pending = p;
127
+ emit();
128
+ },
129
+ clear() {
130
+ if (pending === null) return;
131
+ pending = null;
132
+ emit();
133
+ },
134
+ };
135
+ }
136
+
137
+ function usePending(store) {
138
+ const [pending, setPending] = React.useState(store.getSnapshot);
139
+ React.useEffect(() => store.subscribe(() => setPending(store.getSnapshot())), [store]);
140
+ return pending;
141
+ }
142
+
143
+ /* ---------------------------------------------------------------- */
144
+ /* Stylesheet (host Modal + Button + dialog-status rules, dsm-) */
145
+ /* ---------------------------------------------------------------- */
146
+
147
+ const CSS = [
148
+ /* Menu row (host Menu.module.css .item + .danger) */
149
+ '.dsm-separator{height:.5px;margin:3px 2px;background:var(--dsw-alias-border-l2);flex:none;}',
150
+ '.dsm-item{display:flex;align-items:center;gap:6px;width:100%;min-height:34px;padding:6px 8px;border:none;border-radius:8px;background:transparent;cursor:pointer;font-size:13px;line-height:20px;font-family:inherit;color:var(--dsw-alias-state-error-primary);text-align:left;}',
151
+ '.dsm-item:hover:not(:disabled),.dsm-item:focus-visible:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger);outline:none;}',
152
+ '.dsm-item:disabled{opacity:.4;cursor:not-allowed;}',
153
+ '.dsm-icon{display:inline-flex;flex:none;width:14px;height:14px;align-items:center;justify-content:center;color:var(--dsw-alias-state-error-primary);}',
154
+ '.dsm-icon svg{width:14px;height:14px;}',
155
+ '.dsm-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
156
+ /* Modal (host Modal.module.css) */
157
+ '.dsm-modalRoot{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:max(24px,var(--dsh-frame-top-clearance,24px)) 24px;}',
158
+ '.dsm-mask{position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur);}',
159
+ '.dsm-dialog{position:relative;z-index:1;display:flex;flex-direction:column;gap:20px;width:min(380px,100%);padding:0 0 24px;overflow:hidden;border:0;border-radius:24px;background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent);}',
160
+ '.dsm-content{display:flex;flex-direction:column;width:100%;}',
161
+ '.dsm-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:22px 14px 12px 24px;}',
162
+ '.dsm-title{margin:0;font-size:16px;line-height:24px;font-weight:500;color:var(--dsw-alias-label-primary);}',
163
+ '.dsm-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:8px;background:transparent;cursor:pointer;color:var(--dsw-alias-label-secondary);}',
164
+ '.dsm-close:hover{background:var(--dsw-alias-interactive-bg-hover);}',
165
+ '.dsm-close svg{width:14px;height:14px;}',
166
+ '.dsm-description{margin:0;padding:0 24px;font-size:14px;line-height:22px;font-weight:400;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere;}',
167
+ '.dsm-body{display:flex;flex-direction:column;min-width:0;margin-top:20px;padding:0 24px;gap:8px;}',
168
+ '.dsm-body:empty{display:none;}',
169
+ '.dsm-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:0 24px;}',
170
+ /* Buttons (host Button.module.css: outline + geometry) */
171
+ '.dsm-btn{display:inline-flex;align-items:center;justify-content:center;gap:4px;border:none;border-radius:18px;cursor:pointer;font-size:14px;line-height:22px;height:36px;padding:0 14px;font-family:inherit;color:var(--dsw-alias-label-primary);background:transparent;}',
172
+ '.dsm-btn:disabled{cursor:not-allowed;opacity:.4;}',
173
+ '.dsm-outline{border:.5px solid var(--dsw-alias-border-l3);background:transparent;}',
174
+ '.dsm-outline:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}',
175
+ '.dsm-deleteAction:not(:disabled){color:var(--dsw-alias-state-error-primary);}',
176
+ /* Dialog status + error (host workspace dialog) */
177
+ '.dsm-status{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;}',
178
+ '.dsm-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;}',
179
+ ].join('\n');
180
+
181
+ /* ---------------------------------------------------------------- */
182
+ /* Icons */
183
+ /* ---------------------------------------------------------------- */
184
+
185
+ function TrashIcon() {
186
+ return h(
187
+ 'svg',
188
+ { viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true', focusable: 'false' },
189
+ h('path', {
190
+ d: 'M2.75 4.25h10.5M6.25 4.25V3.4a1.15 1.15 0 0 1 1.15-1.15h1.2a1.15 1.15 0 0 1 1.15 1.15v.85M4.5 4.25l.42 7.98c.03.6.53 1.07 1.13 1.07h3.9c.6 0 1.1-.47 1.13-1.07l.42-7.98M6.6 6.75v4.25M9.4 6.75v4.25',
191
+ stroke: 'currentColor',
192
+ strokeWidth: 1.2,
193
+ strokeLinecap: 'round',
194
+ strokeLinejoin: 'round',
195
+ }),
196
+ );
197
+ }
198
+
199
+ function CloseIcon() {
200
+ return h(
201
+ 'svg',
202
+ { viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true', focusable: 'false' },
203
+ h('path', {
204
+ d: 'M3 3l8 8M11 3l-8 8',
205
+ stroke: 'currentColor',
206
+ strokeWidth: 1.3,
207
+ strokeLinecap: 'round',
208
+ }),
209
+ );
210
+ }
211
+
212
+ /* ---------------------------------------------------------------- */
213
+ /* Menu item */
214
+ /* ---------------------------------------------------------------- */
215
+
216
+ function createMenuItem(ctx, store) {
217
+ return function DeleteSessionMenuItem(props) {
218
+ const { sessionId, displayTitle, useMenuOpenState } = props;
219
+ const [, setMenuOpen] = useMenuOpenState();
220
+ const t = useDict(ctx);
221
+ return h(
222
+ React.Fragment,
223
+ null,
224
+ h('div', { className: 'dsm-separator', 'aria-hidden': 'true' }),
225
+ h(
226
+ 'button',
227
+ {
228
+ type: 'button',
229
+ role: 'menuitem',
230
+ className: 'dsm-item',
231
+ title: t.menuLabel,
232
+ onClick: () => {
233
+ setMenuOpen(false);
234
+ store.request({ sessionId, displayTitle: displayTitle || sessionId });
235
+ },
236
+ },
237
+ h('span', { className: 'dsm-icon' }, h(TrashIcon)),
238
+ h('span', { className: 'dsm-label' }, t.menuLabel),
239
+ ),
240
+ );
241
+ };
242
+ }
243
+
244
+ /* ---------------------------------------------------------------- */
245
+ /* Confirmation dialog (shell.overlay) */
246
+ /* ---------------------------------------------------------------- */
247
+
248
+ function createDialog(ctx, store) {
249
+ return function DeleteSessionDialog() {
250
+ const pending = usePending(store);
251
+ const t = useDict(ctx);
252
+ if (!pending) return null;
253
+ // Keyed by session so in-flight/error state dies with its request.
254
+ return h(DeleteDialogForm, { key: pending.sessionId, request: pending, store, t });
255
+ };
256
+ }
257
+
258
+ function DeleteDialogForm({ request, store, t }) {
259
+ const [busy, setBusy] = React.useState(false);
260
+ const [error, setError] = React.useState(null);
261
+ const cancelRef = React.useRef(null);
262
+
263
+ // Dismiss the dialog; ignored while a delete is in flight.
264
+ const requestClose = React.useCallback(() => {
265
+ if (busy) return;
266
+ store.clear();
267
+ }, [busy, store]);
268
+
269
+ const confirm = React.useCallback(() => {
270
+ setBusy(true);
271
+ setError(null);
272
+ (async () => {
273
+ try {
274
+ const res = await fetch(ROUTE, {
275
+ method: 'POST',
276
+ credentials: 'same-origin',
277
+ headers: { 'content-type': 'application/json' },
278
+ body: JSON.stringify({ sessionId: request.sessionId }),
279
+ });
280
+ const body = await res.json().catch(() => ({}));
281
+ if (res.ok && body && body.ok === true) {
282
+ store.clear();
283
+ return;
284
+ }
285
+ const code = body && body.error;
286
+ setError(code === 'session-live' ? t.live : body && body.message ? String(body.message) : t.failed);
287
+ } catch {
288
+ setError(t.failed);
289
+ } finally {
290
+ setBusy(false);
291
+ }
292
+ })();
293
+ }, [request.sessionId, store, t]);
294
+
295
+ // Escape dismisses (unless busy).
296
+ React.useEffect(() => {
297
+ const onKey = (e) => {
298
+ if (e.key !== 'Escape') return;
299
+ if (busy) return;
300
+ e.stopPropagation();
301
+ store.clear();
302
+ };
303
+ document.addEventListener('keydown', onKey);
304
+ return () => document.removeEventListener('keydown', onKey);
305
+ }, [busy, store]);
306
+
307
+ // Focus the Cancel button on open.
308
+ React.useEffect(() => {
309
+ if (cancelRef.current) cancelRef.current.focus();
310
+ }, []);
311
+
312
+ const description = t.desc.replace('{title}', request.displayTitle || request.sessionId);
313
+
314
+ return h(
315
+ 'div',
316
+ { className: 'dsm-modalRoot', role: 'presentation' },
317
+ h('div', { className: 'dsm-mask', 'aria-hidden': 'true', onClick: requestClose }),
318
+ h(
319
+ 'div',
320
+ { className: 'dsm-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': t.title },
321
+ h(
322
+ 'div',
323
+ { className: 'dsm-content' },
324
+ h(
325
+ 'div',
326
+ { className: 'dsm-header' },
327
+ h('h2', { className: 'dsm-title' }, t.title),
328
+ h(
329
+ 'button',
330
+ { type: 'button', className: 'dsm-close', 'aria-label': t.close, onClick: requestClose, disabled: busy },
331
+ h(CloseIcon),
332
+ ),
333
+ ),
334
+ h('p', { className: 'dsm-description' }, description),
335
+ h(
336
+ 'div',
337
+ { className: 'dsm-body' },
338
+ busy ? h('div', { className: 'dsm-status', role: 'status' }, t.pending) : null,
339
+ error ? h('div', { className: 'dsm-error', role: 'alert' }, error) : null,
340
+ ),
341
+ ),
342
+ h(
343
+ 'div',
344
+ { className: 'dsm-footer' },
345
+ h(
346
+ 'button',
347
+ { type: 'button', className: 'dsm-btn dsm-outline', ref: cancelRef, disabled: busy, onClick: requestClose },
348
+ t.cancel,
349
+ ),
350
+ h(
351
+ 'button',
352
+ {
353
+ type: 'button',
354
+ className: 'dsm-btn dsm-outline dsm-deleteAction',
355
+ disabled: busy,
356
+ onClick: confirm,
357
+ },
358
+ t.confirm,
359
+ ),
360
+ ),
361
+ ),
362
+ );
363
+ }
364
+
365
+ /* ---------------------------------------------------------------- */
366
+ /* Module face */
367
+ /* ---------------------------------------------------------------- */
368
+
369
+ return {
370
+ inject: ['slots'],
371
+ apply(ctx) {
372
+ // One stylesheet for both entries, removed on unload.
373
+ ctx.effect(() => {
374
+ if (typeof document === 'undefined' || !document.head) return () => {};
375
+ const el = document.createElement('style');
376
+ el.setAttribute('data-dsh-session-manager', '');
377
+ el.textContent = CSS;
378
+ document.head.appendChild(el);
379
+ return () => {
380
+ el.remove();
381
+ };
382
+ }, 'dsh-session-manager: styles');
383
+
384
+ const store = createDeleteStore();
385
+
386
+ ctx.slots.inject('sidebar.workspaces.session.menu.item', () =>
387
+ ctx.slots.register(
388
+ { name: 'sidebar.workspaces.session.menu.item', id: 'dsh-session-manager.delete', order: 900 },
389
+ createMenuItem(ctx, store),
390
+ ),
391
+ );
392
+
393
+ ctx.slots.inject('shell.overlay', () =>
394
+ ctx.slots.register(
395
+ { name: 'shell.overlay', id: 'dsh-session-manager.delete-confirm', order: 100 },
396
+ createDialog(ctx, store),
397
+ ),
398
+ );
399
+ },
400
+ };
401
+ },
402
+ });
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-session-manager
3
+ name: '@itsyuyi/dsh-session-manager'
package/icon.svg ADDED
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#5B7CFA"/>
5
+ <stop offset="1" stop-color="#3A56C4"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="2" y="2" width="60" height="60" rx="14" fill="url(#g)"/>
9
+ <g fill="none" stroke="#FFFFFF" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round">
10
+ <path d="M18 24h28"/>
11
+ <path d="M26.5 24v-4.2a2.3 2.3 0 0 1 2.3-2.3h6.4a2.3 2.3 0 0 1 2.3 2.3V24"/>
12
+ <path d="M21.5 24l1.4 20.4A4 4 0 0 0 26.9 48h10.2a4 4 0 0 0 4-3.6L42.5 24"/>
13
+ <path d="M28 31v10"/>
14
+ <path d="M36 31v10"/>
15
+ </g>
16
+ </svg>
package/index.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Host half of @itsyuyi/dsh-session-manager.
3
+ *
4
+ * Durable session deletion for workspace session management:
5
+ * 1. refuse while the session is live in this host (an open Session or Agent),
6
+ * 2. clean the workspace registry (unarchive, unpin, detach from every
7
+ * workspace account),
8
+ * 3. remove the session's JSONL artifact directory from the persistence root.
9
+ *
10
+ * The SQLite search index reconciles itself: once `sessionPersistence.list()`
11
+ * no longer observes the artifact, the next search drops the stale row, so no
12
+ * index write is needed here. The projection cache is a derived, id-keyed
13
+ * cache; an orphaned entry is never read again after the log is gone.
14
+ */
15
+ import { readdir, rm, stat as fsStat } from 'node:fs/promises';
16
+ import { join, resolve, sep } from 'node:path';
17
+
18
+ const ROUTE = '/plugins/dsh-session-manager/delete-session';
19
+ const MAX_BODY_BYTES = 64 * 1024;
20
+ /**
21
+ * Accepted session ids: the runtime's `session-<uuid>` / `session-<n>` shape.
22
+ * The hex-and-dash body rejects path separators and dots outright; artifact
23
+ * removal is additionally traversal-proof because it only ever matches names
24
+ * that already exist as directories under the persistence root.
25
+ */
26
+ const SESSION_ID_PATTERN = /^session-[0-9a-fA-F-]{1,128}$/;
27
+
28
+ export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence'];
29
+
30
+ export function apply(ctx) {
31
+ ctx.effect(
32
+ () =>
33
+ ctx.webServer.register({
34
+ kind: 'exact',
35
+ path: ROUTE,
36
+ handler: (req, res) => serveDeleteSession(ctx, req, res),
37
+ }),
38
+ 'dsh-session-manager: delete-session route',
39
+ );
40
+ }
41
+
42
+ /* ------------------------------------------------------------------ */
43
+ /* HTTP plumbing (same shape the shipped plugin routes use) */
44
+ /* ------------------------------------------------------------------ */
45
+
46
+ async function serveDeleteSession(ctx, req, res) {
47
+ if (req.method !== 'POST') return jsonError(res, 405, 'method-not-allowed');
48
+ if (!String(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json'))
49
+ return jsonError(res, 415, 'json-required');
50
+ if (!sameOrigin(req)) return jsonError(res, 403, 'origin-rejected');
51
+
52
+ let body;
53
+ try {
54
+ body = JSON.parse(await readBody(req));
55
+ } catch {
56
+ return jsonError(res, 400, 'invalid-request');
57
+ }
58
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : '';
59
+ if (!SESSION_ID_PATTERN.test(sessionId)) return jsonError(res, 400, 'invalid-session-id');
60
+
61
+ try {
62
+ const result = await deleteSession(ctx, sessionId);
63
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
64
+ res.end(JSON.stringify(result));
65
+ } catch (error) {
66
+ jsonError(res, 500, 'delete-failed', error instanceof Error ? error.message : String(error));
67
+ }
68
+ }
69
+
70
+ function sameOrigin(req) {
71
+ const origin = req.headers.origin;
72
+ const host = req.headers.host;
73
+ if (origin !== undefined && host !== undefined && origin !== `http://${host}` && origin !== `https://${host}`)
74
+ return false;
75
+ return true;
76
+ }
77
+
78
+ async function readBody(req) {
79
+ const chunks = [];
80
+ let bytes = 0;
81
+ for await (const chunk of req) {
82
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
83
+ bytes += buffer.byteLength;
84
+ if (bytes > MAX_BODY_BYTES) throw new Error('request too large');
85
+ chunks.push(buffer);
86
+ }
87
+ return Buffer.concat(chunks).toString('utf8');
88
+ }
89
+
90
+ function jsonError(res, status, code, message) {
91
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
92
+ res.end(JSON.stringify(message === undefined ? { ok: false, error: code } : { ok: false, error: code, message }));
93
+ }
94
+
95
+ /* ------------------------------------------------------------------ */
96
+ /* Deletion */
97
+ /* ------------------------------------------------------------------ */
98
+
99
+ async function deleteSession(ctx, sessionId) {
100
+ // 1. Refuse only sessions with ACTUAL running work — a turn in flight, an
101
+ // owned job, a live subagent, or an active schedule. This is exactly the
102
+ // host's archive-admission criterion (`workspace/session-activity`
103
+ // waterfall): an idle session that merely happens to be open/loaded stays
104
+ // deletable. The event bus is global, so dispatching from this plugin's
105
+ // context reaches the same turn/job/subagent/schedule listeners the
106
+ // registry asks; a non-empty result means "stop it first".
107
+ const activity = await ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]));
108
+ const families = Array.isArray(activity) ? activity.map((a) => (a && a.kind) || 'unknown') : [];
109
+ if (families.length > 0) return { ok: false, error: 'session-live', activity: families, guard: 'activity-v2' };
110
+
111
+ // 2. Registry cleanup: clear the registry-global archive/pin sets, then
112
+ // detach from every workspace account that lists the session. All three
113
+ // are idempotent for unknown ids.
114
+ let detached = 0;
115
+ const registry = ctx.workspaceRegistry;
116
+ if (registry) {
117
+ try {
118
+ await registry.unarchiveSession(sessionId);
119
+ } catch {
120
+ /* not archived / unknown — nothing to clear */
121
+ }
122
+ try {
123
+ await registry.unpinSession(sessionId);
124
+ } catch {
125
+ /* not pinned / unknown — nothing to clear */
126
+ }
127
+ for (const workspace of registry.list()) {
128
+ if (!workspace.sessionIds?.includes(sessionId)) continue;
129
+ await workspace.detachSession(sessionId);
130
+ detached += 1;
131
+ }
132
+ }
133
+
134
+ // 3. Artifact removal under the persistence root.
135
+ const removed = await removeArtifacts(ctx, sessionId);
136
+
137
+ return { ok: true, detached, removedArtifact: removed > 0, removedDirs: removed, guard: 'activity-v2' };
138
+ }
139
+
140
+ /**
141
+ * Remove the session's artifact directory. The directory name under each
142
+ * project directory is `encodeSegment(sessionId)`, which is the identity for
143
+ * the runtime's `session-<hex>` ids; scanning the root's project directories
144
+ * for that exact name keeps this independent of the header cwd encoding.
145
+ * @returns the number of removed directories (0 or 1 in practice).
146
+ */
147
+ async function removeArtifacts(ctx, sessionId) {
148
+ const root = ctx.sessionPersistence?.config?.root;
149
+ if (typeof root !== 'string' || root.length === 0) return 0;
150
+
151
+ const names = new Set([sessionId, encodeSegment(sessionId)]);
152
+ let projects;
153
+ try {
154
+ projects = await readdir(root, { withFileTypes: true });
155
+ } catch {
156
+ return 0;
157
+ }
158
+
159
+ let removed = 0;
160
+ for (const project of projects) {
161
+ if (!project.isDirectory()) continue;
162
+ const projectPath = join(root, project.name);
163
+ let entries;
164
+ try {
165
+ entries = await readdir(projectPath, { withFileTypes: true });
166
+ } catch {
167
+ continue;
168
+ }
169
+ for (const entry of entries) {
170
+ if (!entry.isDirectory() || !names.has(entry.name)) continue;
171
+ const target = resolve(join(projectPath, entry.name));
172
+ // Containment guard: never escape the persistence root.
173
+ if (!target.startsWith(resolve(root) + sep)) continue;
174
+ await rm(target, { recursive: true, force: true });
175
+ removed += 1;
176
+ }
177
+ }
178
+ return removed;
179
+ }
180
+
181
+ /** Exact copy of the JSONL backend's segment encoder (safe chars pass through). */
182
+ function encodeSegment(raw) {
183
+ if (raw.length === 0) throw new Error('cannot encode an empty path segment');
184
+ if (raw === '.') return '~002E';
185
+ if (raw === '..') return '~002E~002E';
186
+ let out = '';
187
+ for (let i = 0; i < raw.length; i++) {
188
+ const code = raw.charCodeAt(i);
189
+ const ch = String.fromCharCode(code);
190
+ if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
191
+ else out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
192
+ }
193
+ return out;
194
+ }
package/locale/en.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "Session Manager",
3
+ "description": "Adds a durable “Delete session” action to the workspace session row menu."
4
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "会话管理",
3
+ "description": "为 DeepSeek Harness 侧栏会话菜单添加“删除会话”:弹窗确认、彻底清除日志与工作区登记。"
4
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@itsyuyi/dsh-session-manager",
3
+ "version": "1.0.0",
4
+ "description": "DeepSeek Harness plugin: workspace session management — a durable “Delete session” action with a host-styled confirmation dialog, right in the sidebar session menu.",
5
+ "license": "MIT",
6
+ "author": "itsyuyi <itsyuyi@126.com>",
7
+ "type": "module",
8
+ "main": "./index.js",
9
+ "exports": {
10
+ ".": "./index.js",
11
+ "./client": "./client.js",
12
+ "./package.json": "./package.json",
13
+ "./locale/*.json": "./locale/*.json"
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "client.js",
18
+ "verify-session.mjs",
19
+ "cordis.patch.yml",
20
+ "locale/*.json",
21
+ "icon.svg",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "icon": "./icon.svg",
26
+ "meta": {
27
+ "title": "Session Manager",
28
+ "description": "Adds a durable “Delete session” action to the workspace session row menu."
29
+ },
30
+ "keywords": [
31
+ "deepseek",
32
+ "deepseek-harness",
33
+ "dsh",
34
+ "dsh-plugin",
35
+ "session-management",
36
+ "session-delete",
37
+ "workspace",
38
+ "sidebar"
39
+ ],
40
+ "engines": {
41
+ "node": "^22.19.0 || >=24.0.0"
42
+ },
43
+ "peerDependencies": {
44
+ "@deepseek-ai/cordis": ">=4.0.0 <5",
45
+ "react": ">=18.2.0 <19"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/itsyuyi/dsh-session-manager.git"
50
+ },
51
+ "homepage": "https://github.com/itsyuyi/dsh-session-manager#readme",
52
+ "bugs": {
53
+ "url": "https://github.com/itsyuyi/dsh-session-manager/issues"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "dsh": {
59
+ "bundle": {
60
+ "patch": "./cordis.patch.yml"
61
+ },
62
+ "client": {
63
+ "platform": "web",
64
+ "immediately": true,
65
+ "inject": [
66
+ "@deepseek-ai/dsh-client-ui-workspace"
67
+ ]
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * verify-session.mjs — 核对某个会话是否已从系统目录中真正删除。
4
+ *
5
+ * 用法:
6
+ * node verify-session.mjs # 列出所有已知会话及各自在磁盘上的存在情况
7
+ * node verify-session.mjs <id或标题片段> # 核对匹配的会话,给出"是否已删除"的结论
8
+ *
9
+ * 检查的位置(删除会话时,插件会清理 1 和 2):
10
+ * 1) 会话工件目录 ~/.dsh/sessions/<项目>/session-<id>/ ← 主数据(rm -rf)
11
+ * 2) 工作区登记 ~/.dsh/storages/workspace.json ← 从 sessionIds/归档/置顶摘除
12
+ * 3) 投影缓存 session_projcache.json + session_projcache/sessions/<id>.json
13
+ * ← 派生缓存,删除后可能残留孤儿(无害,不会再被读取)
14
+ * (搜索索引 SQLite 会在下次搜索时自动 reconcile,无需手动核对)
15
+ *
16
+ * 判定"已删除"只看 1 和 2(权威来源);投影缓存仅供参考。
17
+ * 重要:当 <参数> 既不是任何已知 id、也匹配不到任何标题时,脚本报"未找到",
18
+ * 绝不据此推断"已删除"(避免假阴性)。
19
+ */
20
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { homedir } from 'node:os';
23
+
24
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
25
+ const SESSIONS_ROOT = process.env.DSH_SESSIONS_ROOT || join(DSH_HOME, 'sessions');
26
+ const WORKSPACE_JSON = join(DSH_HOME, 'storages', 'workspace.json');
27
+ const PROJCACHE_JSON = join(DSH_HOME, 'storages', 'session_projcache.json');
28
+ const PROJCACHE_DIR = join(DSH_HOME, 'storages', 'session_projcache', 'sessions');
29
+
30
+ /** 与 JSONL 后端一致的段编码(对 UUID 形态的 id 是恒等)。 */
31
+ function encodeSegment(raw) {
32
+ if (raw === '.') return '~002E';
33
+ if (raw === '..') return '~002E~002E';
34
+ let out = '';
35
+ for (let i = 0; i < raw.length; i++) {
36
+ const code = raw.charCodeAt(i);
37
+ const ch = String.fromCharCode(code);
38
+ if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
39
+ else out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function readJson(path) {
45
+ try {
46
+ return JSON.parse(readFileSync(path, 'utf8'));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /** 扫描会话工件根目录,返回该 id 对应的现存目录(通常 0 或 1 个)。 */
53
+ function findArtifactDirs(id) {
54
+ const names = new Set([id, encodeSegment(id)]);
55
+ const found = [];
56
+ if (!existsSync(SESSIONS_ROOT)) return found;
57
+ for (const proj of readdirSync(SESSIONS_ROOT, { withFileTypes: true })) {
58
+ if (!proj.isDirectory()) continue;
59
+ const pdir = join(SESSIONS_ROOT, proj.name);
60
+ let entries;
61
+ try {
62
+ entries = readdirSync(pdir, { withFileTypes: true });
63
+ } catch {
64
+ continue;
65
+ }
66
+ for (const e of entries) {
67
+ if (e.isDirectory() && names.has(e.name)) found.push(join(pdir, e.name));
68
+ }
69
+ }
70
+ return found;
71
+ }
72
+
73
+ /** 逐会话投影缓存文件的 id 列表(session-*.json)。 */
74
+ function perSessionCacheIds() {
75
+ if (!existsSync(PROJCACHE_DIR)) return [];
76
+ return readdirSync(PROJCACHE_DIR)
77
+ .filter((f) => f.startsWith('session-') && f.endsWith('.json'))
78
+ .map((f) => f.slice(0, -'.json'.length));
79
+ }
80
+
81
+ /**
82
+ * 标题 → id 映射。合并两处来源:
83
+ * - 聚合缓存 session_projcache.json: tables.sessions.<id>.rows.title.val
84
+ * - 逐会话缓存 sessions/<id>.json: record.rows.title.val(通常更新、覆盖新会话)
85
+ */
86
+ function loadTitles() {
87
+ const map = new Map();
88
+ const agg = readJson(PROJCACHE_JSON);
89
+ for (const [id, entry] of Object.entries(agg?.tables?.sessions || {})) {
90
+ const t = entry?.rows?.title?.val;
91
+ if (typeof t === 'string' && t) map.set(id, t);
92
+ }
93
+ if (existsSync(PROJCACHE_DIR)) {
94
+ for (const f of readdirSync(PROJCACHE_DIR)) {
95
+ if (!f.startsWith('session-') || !f.endsWith('.json')) continue;
96
+ const id = f.slice(0, -'.json'.length);
97
+ const e = readJson(join(PROJCACHE_DIR, f));
98
+ const t = e?.record?.rows?.title?.val;
99
+ if (typeof t === 'string' && t) map.set(id, t); // 逐会话较新,覆盖聚合值
100
+ }
101
+ }
102
+ return map;
103
+ }
104
+
105
+ /** 该 id 在 workspace.json 里还被哪些地方引用。 */
106
+ function workspaceRefs(id) {
107
+ const w = readJson(WORKSPACE_JSON);
108
+ if (!w) return [];
109
+ const g = w.global || {};
110
+ const refs = [];
111
+ if ((g.archivedSessionIds || []).includes(id)) refs.push('归档集');
112
+ if ((g.pinnedSessionIds || []).includes(id)) refs.push('置顶集');
113
+ for (const [wid, ws] of Object.entries(w.tables?.workspaces || {})) {
114
+ if ((ws.sessionIds || []).includes(id)) refs.push(`工作区「${ws.title || wid}」`);
115
+ }
116
+ return refs;
117
+ }
118
+
119
+ /** 投影缓存中是否仍有该 id(聚合表 或 逐会话文件),仅供参照。 */
120
+ function inProjcache(id) {
121
+ const agg = readJson(PROJCACHE_JSON);
122
+ if ((agg?.tables?.sessions || {})[id]) return true;
123
+ return existsSync(join(PROJCACHE_DIR, `${id}.json`));
124
+ }
125
+
126
+ /** 收集所有出现过的会话 id(工件目录 ∪ workspace.json ∪ 两处投影缓存)。 */
127
+ function allSessionIds() {
128
+ const ids = new Set();
129
+ if (existsSync(SESSIONS_ROOT)) {
130
+ for (const proj of readdirSync(SESSIONS_ROOT, { withFileTypes: true })) {
131
+ if (!proj.isDirectory()) continue;
132
+ let entries;
133
+ try {
134
+ entries = readdirSync(join(SESSIONS_ROOT, proj.name), { withFileTypes: true });
135
+ } catch {
136
+ continue;
137
+ }
138
+ for (const e of entries) if (e.isDirectory() && e.name.startsWith('session-')) ids.add(e.name);
139
+ }
140
+ }
141
+ const w = readJson(WORKSPACE_JSON);
142
+ for (const id of w?.global?.archivedSessionIds || []) ids.add(id);
143
+ for (const id of w?.global?.pinnedSessionIds || []) ids.add(id);
144
+ for (const ws of Object.values(w?.tables?.workspaces || {}))
145
+ for (const id of ws.sessionIds || []) ids.add(id);
146
+ for (const id of Object.keys(readJson(PROJCACHE_JSON)?.tables?.sessions || {})) ids.add(id);
147
+ for (const id of perSessionCacheIds()) ids.add(id);
148
+ return [...ids];
149
+ }
150
+
151
+ function statusOf(id) {
152
+ const dirs = findArtifactDirs(id);
153
+ const wrefs = workspaceRefs(id);
154
+ const proj = inProjcache(id);
155
+ // "已删除" = 工件目录不存在 且 workspace.json 无任何引用(投影缓存孤儿不算数)
156
+ const deleted = dirs.length === 0 && wrefs.length === 0;
157
+ return { dirs, wrefs, proj, deleted };
158
+ }
159
+
160
+ function reportOne(id, titles) {
161
+ const s = statusOf(id);
162
+ console.log(`\n会话:${id}`);
163
+ if (titles.get(id)) console.log(`标题:${titles.get(id)}`);
164
+ console.log(` 1) 工件目录 ~/.dsh/sessions/…/${id}/ : ${s.dirs.length ? '❌ 仍存在 → ' + s.dirs.join(', ') : '✅ 不存在'}`);
165
+ console.log(` 2) 工作区登记 workspace.json : ${s.wrefs.length ? '❌ 仍被引用 → ' + s.wrefs.join('、') : '✅ 无引用'}`);
166
+ console.log(` 3) 投影缓存 session_projcache(.json|/sessions/) : ${s.proj ? '⚠️ 仍有缓存条目(派生数据,无害,不影响删除结论)' : '✅ 无'}`);
167
+ console.log(` 结论:${s.deleted ? '✅ 已从系统目录中删除(主数据与登记均已清除)' : '⛔ 仍然存在,尚未删除(见上)'}`);
168
+ }
169
+
170
+ const arg = process.argv[2];
171
+ const titles = loadTitles();
172
+
173
+ if (!arg) {
174
+ const ids = allSessionIds().sort();
175
+ console.log(`共 ${ids.length} 个会话(标题取自投影缓存,新会话可能暂缺):\n`);
176
+ console.log('状态 会话id 工件 登记 缓存 标题');
177
+ for (const id of ids) {
178
+ const s = statusOf(id);
179
+ const flag = s.deleted ? '已删' : '存在';
180
+ console.log(
181
+ `${flag} ${id.padEnd(44)} ${s.dirs.length ? '有' : '— '} ${s.wrefs.length ? '有' : '— '} ${s.proj ? '有' : '— '} ${titles.get(id) || ''}`,
182
+ );
183
+ }
184
+ console.log('\n提示:node verify-session.mjs <id或标题片段> 可核对单个会话。');
185
+ } else {
186
+ const ids = allSessionIds();
187
+ // 1) 先按 id 精确/子串匹配
188
+ let matches = ids.filter((id) => id === arg || id.includes(arg));
189
+ // 2) 再按标题片段匹配(合并两处缓存的标题)
190
+ if (matches.length === 0) {
191
+ matches = [...titles.entries()].filter(([, t]) => t.includes(arg)).map(([id]) => id);
192
+ }
193
+ matches = [...new Set(matches)];
194
+
195
+ if (matches.length === 0) {
196
+ // 关键:匹配不到 ≠ 已删除。明确报"未找到",绝不给出删除结论。
197
+ console.log(`\n⚠️ 未找到与「${arg}」匹配的会话。`);
198
+ console.log(' 它既不是任何已知会话 id,也没有任何会话的标题包含这段文字。');
199
+ console.log(' 可能原因:会话名不完全一致、标题投影尚未落盘,或该会话本就不存在。');
200
+ console.log(' ⛔ 这不代表"已删除"。请改用:');
201
+ console.log(' · node verify-session.mjs # 看完整列表与标题,找到确切 id');
202
+ console.log(' · node verify-session.mjs <完整id> # 用 id 精确核对');
203
+ process.exit(0);
204
+ }
205
+
206
+ for (const id of matches) reportOne(id, titles);
207
+ if (matches.length > 1) console.log(`\n(匹配到 ${matches.length} 个会话)`);
208
+ }