dsh-plugin-session-tidy 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/README.md CHANGED
@@ -1,3 +1,35 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-session-tidy
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
+ 清理 DSH **会话文件**——官方只提供「归档」,磁盘上的 session 文件仍在,长年积累。
4
+
5
+ ## 功能
6
+
7
+ - **会话行右键菜单**:删除单个会话
8
+ - **侧边栏底部批量面板**:多选 → 移到 `sessions-trash` → 可再清空
9
+ - 删除是**移到回收区**(可恢复);清空才真删
10
+
11
+ ## 安全设计
12
+
13
+ - 破坏性操作有**二次确认**(按住 Ctrl 可跳过,适合批量场景)
14
+ - 只动会话文件,不碰配置/凭据
15
+
16
+ ## 安装
17
+
18
+ Junction 方式(同其它插件)+ profile 的 `cordis.patch.yml`:
19
+
20
+ ```yaml
21
+ - insert:
22
+ - id: session-tidy
23
+ name: 'dsh-plugin-session-tidy'
24
+ ```
25
+
26
+ > 本插件原名 `session-cleaner`;因 npm 上已有同名包(他人),开源版统一改名为 `dsh-plugin-session-tidy`(含内部插件 id / 路由 / 快捷键)。
27
+
28
+ ## 适配
29
+
30
+ - DSH **0.2.2-rc.1+** · 零运行时依赖
31
+ - client + host 双入口
32
+
33
+ ## License
34
+
35
+ MIT
package/lib/client.js ADDED
@@ -0,0 +1,494 @@
1
+ // dsh-plugin-session-tidy -- client 侧
2
+ //
3
+ // 入口:侧边栏底部动作区(sidebar.footer.action)
4
+ // 能力:列出会话(按工作区分组)→ 勾选 → 二次确认 → 移到回收目录 → 可再清空
5
+ //
6
+ // 样式照抄官方:只用 --dsw-* 变量,全程不用 !important,类名带 sc_ 前缀。
7
+ // 不 require 任何 Harness Client 包(官方明令)。
8
+
9
+ window.__ModuleLoader__.load({
10
+ id: 'dsh-plugin-session-tidy',
11
+ factory: (require) => {
12
+ var module = { exports: {} };
13
+ var exports = module.exports;
14
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
15
+
16
+ let react = require('react');
17
+ let reactDom = require('react-dom');
18
+
19
+ // 官方 primitive:MenuItemButton 自带宿主菜单项样式(图标位、缩进、hover、快捷键槽)。
20
+ // 据 dsh-client-ui-workspace README:Module Loader 包可经 loader 的 require 解析它,
21
+ // 但【不要打包副本、不要列为运行时依赖】。取不到就降级自绘,绝不让菜单崩。
22
+ // 快捷键(全局)与面板之间的桥:由侧边栏入口在挂载时登记
23
+ let openPanelBridge = null;
24
+
25
+ let MenuItemButton = null;
26
+ try {
27
+ const prim = require('@deepseek-ai/dsh-client-ui-primitives');
28
+ if (prim && typeof prim.MenuItemButton === 'function') {
29
+ MenuItemButton = prim.MenuItemButton;
30
+ console.log('[session-tidy] ✅ 官方 primitive 就绪:菜单项用 MenuItemButton(官方样式)');
31
+ } else {
32
+ console.log('[session-tidy] primitives 能加载但没有 MenuItemButton,降级自绘');
33
+ }
34
+ } catch (e) {
35
+ console.log('[session-tidy] MenuItemButton 取不到,降级自绘: ' + ((e && e.message) || e));
36
+ }
37
+
38
+ const NS = 'sc';
39
+ const SHORTCUT_ID = 'session-tidy.open';
40
+ const CSS_TAG_ID = 'dsh-plugin-session-tidy/client.css';
41
+
42
+ const CSS = [
43
+ '.' + NS + '_btn{',
44
+ 'box-sizing:border-box;color:var(--dsw-alias-label-tertiary);font:inherit;',
45
+ 'background:0 0;border:none;border-radius:8px;cursor:pointer;',
46
+ 'align-items:center;gap:6px;padding:4px 8px;display:inline-flex;width:100%;text-align:left}',
47
+ '.' + NS + '_btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
48
+ '.' + NS + '_mask{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9000}',
49
+ '.' + NS + '_panel{',
50
+ 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:9001;',
51
+ 'width:min(760px,92vw);max-height:82vh;display:flex;flex-direction:column;',
52
+ 'background:var(--dsw-alias-bg-base,#1b1b1f);color:var(--dsw-alias-label-primary,#e8e8ea);',
53
+ 'border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.12));border-radius:12px;',
54
+ 'box-shadow:0 12px 40px rgba(0,0,0,.5);font-size:13px;line-height:20px;overflow:hidden}',
55
+ '.' + NS + '_head{padding:14px 16px 10px;border-bottom:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.1))}',
56
+ '.' + NS + '_title{font-size:15px;font-weight:600;display:flex;justify-content:space-between;align-items:center}',
57
+ '.' + NS + '_sub{color:var(--dsw-alias-label-tertiary);font-size:12px;margin-top:4px}',
58
+ '.' + NS + '_body{overflow:auto;padding:8px 16px;flex:1;min-height:80px}',
59
+ '.' + NS + '_ws{margin:10px 0 4px;color:var(--dsw-alias-label-secondary);font-weight:600}',
60
+ '.' + NS + '_wsPath{color:var(--dsw-alias-label-tertiary);font-weight:400;font-size:12px;margin-left:6px}',
61
+ '.' + NS + '_row{display:flex;align-items:center;gap:8px;padding:3px 0}',
62
+ '.' + NS + '_row label{display:flex;align-items:center;gap:8px;flex:1;min-width:0;cursor:pointer}',
63
+ '.' + NS + '_row input{cursor:pointer}',
64
+ '.' + NS + '_id{font-family:ui-monospace,Consolas,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
65
+ '.' + NS + '_size{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);white-space:nowrap}',
66
+ '.' + NS + '_tag{font-size:11px;padding:0 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.15));color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
67
+ '.' + NS + '_lock{color:var(--dsw-alias-state-warning-primary,#d97706);font-size:11px;white-space:nowrap}',
68
+ '.' + NS + '_foot{padding:10px 16px;border-top:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.1));display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap}',
69
+ '.' + NS + '_act{box-sizing:border-box;border-radius:8px;padding:6px 12px;cursor:pointer;font:inherit;border:1px solid transparent}',
70
+ '.' + NS + '_ghost{background:0 0;border-color:var(--dsw-alias-border-secondary,rgba(255,255,255,.18));color:var(--dsw-alias-label-secondary)}',
71
+ '.' + NS + '_ghost:hover{background:var(--dsw-alias-interactive-bg-hover)}',
72
+ '.' + NS + '_danger{background:var(--dsw-alias-state-error-primary,#dc2626);color:#fff}',
73
+ '.' + NS + '_danger:disabled{opacity:.45;cursor:not-allowed}',
74
+ '.' + NS + '_confirm{background:var(--dsw-alias-state-warning-primary,#d97706);color:#fff}',
75
+ '.' + NS + '_msg{color:var(--dsw-alias-label-tertiary);font-size:12px}',
76
+ '.' + NS + '_err{color:var(--dsw-alias-state-error-primary,#dc2626);font-size:12px}',
77
+ // 会话行菜单里的条目:照官方菜单项的样子(整行可点、hover 高亮、不用 !important)
78
+ '.' + NS + '_mi{box-sizing:border-box;display:flex;align-items:center;gap:8px;width:100%;background:0 0;border:none;color:var(--dsw-alias-label-primary,#e8e8ea);font:inherit;text-align:left;padding:6px 10px;border-radius:6px;cursor:pointer}',
79
+ '.' + NS + '_mi:hover{background:var(--dsw-alias-interactive-bg-hover)}',
80
+ '.' + NS + '_miIcon{flex:0 0 auto;opacity:.75}',
81
+ // 仅在拿不到官方 MenuItemButton 时的兜底(danger 行的等价外观)
82
+ '.' + NS + '_miDanger{color:var(--dsw-alias-state-error-primary,#dc2626)}',
83
+ ].join('\n');
84
+
85
+ function injectCss() {
86
+ if (document.getElementById(CSS_TAG_ID)) return;
87
+ const tag = document.createElement('style');
88
+ tag.id = CSS_TAG_ID;
89
+ tag.setAttribute('data-plugin-css', CSS_TAG_ID);
90
+ tag.textContent = CSS;
91
+ document.head.appendChild(tag);
92
+ }
93
+
94
+ function fmtBytes(n) {
95
+ if (!n) return '0 B';
96
+ if (n < 1024) return n + ' B';
97
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
98
+ if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
99
+ return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
100
+ }
101
+ function fmtTime(ms) {
102
+ try { return new Date(ms).toLocaleString('zh-CN', { hour12: false }); } catch { return ''; }
103
+ }
104
+ const LOCK_TEXT = { active: '运行中', pinned: '已置顶', recent: '刚用过' };
105
+
106
+ async function api(path, body) {
107
+ const opt = body === undefined
108
+ ? { method: 'GET' }
109
+ : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) };
110
+ const r = await fetch(path, opt);
111
+ let j = null;
112
+ try { j = await r.json(); } catch { /* 非 JSON */ }
113
+ if (!r.ok) throw new Error((j && (j.error || j.message)) || ('HTTP ' + r.status));
114
+ return j;
115
+ }
116
+
117
+ function Panel(props) {
118
+ const onClose = props.onClose;
119
+ const initialPick = props.initialPick || '';
120
+ const initialConfirming = !!props.initialConfirming;
121
+ const [loading, setLoading] = react.useState(true);
122
+ const [err, setErr] = react.useState('');
123
+ const [data, setData] = react.useState(null);
124
+ const [picked, setPicked] = react.useState({}); // id -> true
125
+ const [confirming, setConfirming] = react.useState(false);
126
+ const [busy, setBusy] = react.useState(false);
127
+ const [note, setNote] = react.useState('');
128
+
129
+ const load = react.useCallback(() => {
130
+ setLoading(true); setErr('');
131
+ api('/session-tidy/list')
132
+ .then((d) => {
133
+ setData(d);
134
+ setPicked(initialPick ? { [initialPick]: true } : {});
135
+ setConfirming(initialConfirming);
136
+ })
137
+ .catch((e) => setErr(String(e.message || e)))
138
+ .finally(() => setLoading(false));
139
+ }, []);
140
+ react.useEffect(() => { load(); }, [load]);
141
+
142
+ const sessions = (data && data.sessions) || [];
143
+ const pickedList = sessions.filter((s) => picked[s.id]);
144
+ const pickedBytes = pickedList.reduce((n, s) => n + s.bytes, 0);
145
+
146
+ const toggle = (id) => setPicked((p) => { const q = Object.assign({}, p); if (q[id]) delete q[id]; else q[id] = true; return q; });
147
+
148
+ const groups = react.useMemo(() => {
149
+ const m = new Map();
150
+ for (const s of sessions) {
151
+ if (!m.has(s.workspace)) m.set(s.workspace, { workspace: s.workspace, path: s.workspacePath, items: [] });
152
+ m.get(s.workspace).items.push(s);
153
+ }
154
+ return Array.from(m.values());
155
+ }, [sessions]);
156
+
157
+ const pickGroup = (g, on) => setPicked((p) => {
158
+ const q = Object.assign({}, p);
159
+ for (const s of g.items) { if (on && !s.lockedBy) q[s.id] = true; else if (!on) delete q[s.id]; }
160
+ return q;
161
+ });
162
+ const pickAll = () => setPicked(() => {
163
+ const q = {};
164
+ for (const s of sessions) if (!s.lockedBy) q[s.id] = true;
165
+ return q;
166
+ });
167
+
168
+ const doDelete = async () => {
169
+ setBusy(true); setErr('');
170
+ try {
171
+ const r = await api('/session-tidy/delete', {
172
+ ids: pickedList.map((s) => ({ id: s.id, workspace: s.workspace })),
173
+ });
174
+ const moved = (r.moved || []).length;
175
+ const refused = (r.failed || []).length;
176
+ setNote('已移动 ' + moved + ' 个会话到回收目录' + (refused ? ',' + refused + ' 个被拒绝' : ''));
177
+ setConfirming(false);
178
+ load();
179
+ } catch (e) { setErr(String(e.message || e)); }
180
+ finally { setBusy(false); }
181
+ };
182
+
183
+ const doEmpty = async () => {
184
+ setBusy(true); setErr('');
185
+ try {
186
+ const r = await api('/session-tidy/empty-trash', {});
187
+ setNote('已彻底删除 ' + r.count + ' 个会话 / ' + fmtBytes(r.bytes));
188
+ load();
189
+ } catch (e) { setErr(String(e.message || e)); }
190
+ finally { setBusy(false); }
191
+ };
192
+
193
+ const rows = [];
194
+ for (const g of groups) {
195
+ rows.push(react.createElement('div', { className: NS + '_ws', key: 'ws-' + g.workspace },
196
+ g.path || g.workspace,
197
+ react.createElement('span', { className: NS + '_wsPath' }, '(' + g.items.length + ')'),
198
+ react.createElement('button', {
199
+ className: NS + '_act ' + NS + '_ghost',
200
+ style: { marginLeft: 8, padding: '1px 6px', fontSize: 11 },
201
+ onClick: () => pickGroup(g, true),
202
+ }, '选可清理的'),
203
+ react.createElement('button', {
204
+ className: NS + '_act ' + NS + '_ghost',
205
+ style: { marginLeft: 4, padding: '1px 6px', fontSize: 11 },
206
+ onClick: () => pickGroup(g, false),
207
+ }, '清空勾选')
208
+ ));
209
+ for (const s of g.items) {
210
+ const locked = !!s.lockedBy;
211
+ rows.push(react.createElement('div', { className: NS + '_row', key: s.id },
212
+ react.createElement('label', null,
213
+ react.createElement('input', {
214
+ type: 'checkbox', disabled: locked, checked: !!picked[s.id],
215
+ onChange: () => toggle(s.id),
216
+ }),
217
+ react.createElement('span', { className: NS + '_id' }, s.id),
218
+ s.title ? react.createElement('span', { className: NS + '_tag' }, s.title) : null,
219
+ s.archived ? react.createElement('span', { className: NS + '_tag' }, '已归档') : null
220
+ ),
221
+ locked ? react.createElement('span', { className: NS + '_lock' }, LOCK_TEXT[s.lockedBy] || '禁选') : null,
222
+ react.createElement('span', { className: NS + '_size' }, fmtBytes(s.bytes)),
223
+ react.createElement('span', { className: NS + '_tag' }, fmtTime(s.mtime))
224
+ ));
225
+ }
226
+ }
227
+
228
+ const trash = (data && data.trash) || { count: 0, bytes: 0 };
229
+
230
+ return reactDom.createPortal(
231
+ react.createElement(react.Fragment, null,
232
+ react.createElement('div', { className: NS + '_mask', onClick: onClose }),
233
+ react.createElement('div', { className: NS + '_panel', role: 'dialog', 'aria-label': '清理会话文件' },
234
+ react.createElement('div', { className: NS + '_head' },
235
+ react.createElement('div', { className: NS + '_title' },
236
+ react.createElement('span', null, '清理会话文件'),
237
+ react.createElement('button', { className: NS + '_act ' + NS + '_ghost', onClick: onClose }, '关闭')
238
+ ),
239
+ react.createElement('div', { className: NS + '_sub' },
240
+ loading ? '读取中…'
241
+ : data
242
+ ? '共 ' + sessions.length + ' 个会话 / ' + fmtBytes(data.totalBytes)
243
+ + ' · 可清理 ' + fmtBytes(data.deletableBytes)
244
+ + ' · 置顶 ' + (data.counts ? data.counts.pinned : 0)
245
+ + ' · 运行中 ' + (data.counts ? data.counts.active : 0)
246
+ : ''
247
+ )
248
+ ),
249
+ react.createElement('div', { className: NS + '_body' },
250
+ err ? react.createElement('div', { className: NS + '_err' }, '出错:' + err) : null,
251
+ note ? react.createElement('div', { className: NS + '_msg' }, note) : null,
252
+ !loading && !sessions.length ? react.createElement('div', { className: NS + '_msg' }, '没有可显示的会话。') : null,
253
+ rows
254
+ ),
255
+ react.createElement('div', { className: NS + '_foot' },
256
+ react.createElement('div', null,
257
+ react.createElement('div', { className: NS + '_msg' }, '已选 ' + pickedList.length + ' 个 / ' + fmtBytes(pickedBytes)),
258
+ react.createElement('div', { className: NS + '_msg' },
259
+ '回收目录:' + trash.count + ' 个 / ' + fmtBytes(trash.bytes)
260
+ + (trash.count ? '(可彻底删除)' : ''))
261
+ ),
262
+ react.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
263
+ react.createElement('button', { className: NS + '_act ' + NS + '_ghost', onClick: pickAll, disabled: busy }, '全选可清理'),
264
+ trash.count
265
+ ? react.createElement('button', { className: NS + '_act ' + NS + '_ghost', onClick: doEmpty, disabled: busy }, '清空回收目录')
266
+ : null,
267
+ confirming
268
+ ? react.createElement(react.Fragment, null,
269
+ react.createElement('button', { className: NS + '_act ' + NS + '_confirm', onClick: doDelete, disabled: busy },
270
+ busy ? '处理中…' : '确认清理 ' + pickedList.length + ' 个'),
271
+ react.createElement('button', { className: NS + '_act ' + NS + '_ghost', onClick: () => setConfirming(false), disabled: busy }, '取消')
272
+ )
273
+ : react.createElement('button', {
274
+ className: NS + '_act ' + NS + '_danger',
275
+ disabled: busy || !pickedList.length,
276
+ onClick: () => setConfirming(true),
277
+ }, '清理选中')
278
+ )
279
+ )
280
+ )
281
+ ),
282
+ document.body
283
+ );
284
+ }
285
+
286
+ function EntryButton() {
287
+ const [open, setOpen] = react.useState(false);
288
+ // 面板状态住在【常驻】的侧边栏入口里;菜单项与全局快捷键都经这座桥来开它。
289
+ // ⚠️ 菜单项自己渲染面板是错的:菜单一关组件就卸载、state 丢失 ⇒ 点了没反应。
290
+ const [pick, setPick] = react.useState('');
291
+ react.useEffect(() => {
292
+ openPanelBridge = (id) => { setPick(id || ''); setOpen(true); };
293
+ return () => { if (openPanelBridge) openPanelBridge = null; };
294
+ }, []);
295
+ return react.createElement(react.Fragment, null,
296
+ react.createElement('button', {
297
+ className: NS + '_btn',
298
+ type: 'button',
299
+ title: '清理会话文件(移到回收目录,可恢复)',
300
+ onClick: () => { setPick(''); setOpen(true); },
301
+ }, '清理会话'),
302
+ open ? react.createElement(Panel, {
303
+ onClose: () => { setOpen(false); setPick(''); },
304
+ initialPick: pick,
305
+ initialConfirming: !!pick,
306
+ }) : null
307
+ );
308
+ }
309
+
310
+ /** 垃圾桶图标(自己画,不依赖官方 primitive)。 */
311
+ function TrashIcon() {
312
+ return react.createElement('svg', {
313
+ className: NS + '_miIcon', width: 14, height: 14, viewBox: '0 0 16 16',
314
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.4,
315
+ strokeLinecap: 'round', strokeLinejoin: 'round',
316
+ }, react.createElement('path', { d: 'M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8h5.8l.6-8M6.8 7v3.2M9.2 7v3.2' }));
317
+ }
318
+
319
+ /**
320
+ * 会话行菜单里的条目(插槽 sidebar.workspaces.session.menu.item)。
321
+ * props 由插槽给:{ sessionId, displayTitle, useMenuOpenState }。
322
+ * ⚠️ useMenuOpenState 可能因官方版本差异而缺失 —— 用恒定的 fallback 保证【无条件调用】,
323
+ * 否则违反 hooks 规则会直接崩掉整个菜单。
324
+ */
325
+ // ★ 官方 MenuItemButton 的 onSelect 是 `() => void`,【不带事件对象】
326
+ // ⇒ 拿不到 e.ctrlKey。所以自己跟踪修饰键状态(按住 Ctrl 点击 = 跳过二次确认)。
327
+ //
328
+ // ⚠️ 状态必须挂在 window 上,【不能】用模块级变量:
329
+ // 插件可能被加载多份(HMR / 强刷,Console 里见过三份 client apply()),
330
+ // 而「注册监听的实例」与「渲染菜单项的实例」可能不是同一个 ——
331
+ // 模块级变量各是各的 ⇒ 按 Ctrl 那边置了位,这边读到的还是 false(实测就是这个症状)。
332
+ // 失焦时复位,否则 Alt+Tab 切走再回来状态会永远卡在按下。
333
+ function ctrlIsDown() { return window.__scCtrlDown === true; }
334
+ function trackModifiers() {
335
+ // ★ 最可靠的一路:按下鼠标那一刻的 e.ctrlKey
336
+ // (2026-10-14 修 bug:用户"按住 Ctrl 点击仍要二次确认")
337
+ const mark = (e) => { window.__scCtrlDown = !!(e && e.ctrlKey); };
338
+ const down = (e) => { if (e.key === 'Control') window.__scCtrlDown = true; };
339
+ const up = (e) => { if (e.key === 'Control') window.__scCtrlDown = false; };
340
+ const winBlur = () => { window.__scCtrlDown = false; };
341
+ document.addEventListener('mousedown', mark, true);
342
+ document.addEventListener('pointerdown', mark, true);
343
+ window.addEventListener('keydown', down, true);
344
+ window.addEventListener('keyup', up, true);
345
+ // ⚠️ window 上的 blur 监听【绝不能加 capture】:
346
+ // blur 不冒泡,但在【捕获阶段】会从 window 向下传到目标元素 ——
347
+ // 加 capture 等于在监听"任何元素失焦";而点击菜单项会让输入框失焦,
348
+ // 于是刚置上的 Ctrl 状态被立刻清成 false ⇒ 永远走二次确认(实测症状)。
349
+ // 不加 capture ⇒ 只接 window 自身的失焦,这才是我们要的语义。
350
+ window.addEventListener('blur', winBlur);
351
+ return () => {
352
+ document.removeEventListener('mousedown', mark, true);
353
+ document.removeEventListener('pointerdown', mark, true);
354
+ window.removeEventListener('keydown', down, true);
355
+ window.removeEventListener('keyup', up, true);
356
+ window.removeEventListener('blur', winBlur);
357
+ };
358
+ }
359
+
360
+ function MenuDeleteItem(props) {
361
+ const sessionId = props.sessionId;
362
+ // 一次性诊断:确认插槽到底给了哪些 props(尤其 useShortcuts 在不在)
363
+ if (!MenuDeleteItem.logged) {
364
+ MenuDeleteItem.logged = true;
365
+ console.log('[session-tidy] 菜单项 props: ' + Object.keys(props).join(', '));
366
+ }
367
+ const useMenuOpen = react.useMemo(
368
+ () => (typeof props.useMenuOpenState === 'function' ? props.useMenuOpenState : () => [false, () => {}]),
369
+ [props.useMenuOpenState]
370
+ );
371
+ const [, setMenuOpen] = useMenuOpen();
372
+ // 官方做法:shortcut 由插槽 props 的 useShortcuts 取【用户实际绑定】的键位。
373
+ // useMemo 固定引用 + 无条件调用 —— 条件调用 hook 会崩掉整个菜单。
374
+ const useSc = react.useMemo(
375
+ () => (typeof props.useShortcuts === 'function' ? props.useShortcuts : () => undefined),
376
+ [props.useShortcuts]
377
+ );
378
+ const shortcut = useSc((rows) => (rows || []).find((r) => r.id === SHORTCUT_ID));
379
+ // 一键移入回收目录 —— 不弹面板、不再要确认。
380
+ // 分层原则:移入回收目录【可恢复】⇒ 一键;彻底删除(清空回收目录)才要确认。
381
+ const [phase, setPhase] = react.useState('idle'); // idle | busy | done | failed
382
+ const [errMsg, setErrMsg] = react.useState('');
383
+ const [already, setAlready] = react.useState(false);
384
+ const onClick = async () => {
385
+ if (phase === 'busy' || phase === 'done') return; // 忽略重复点击
386
+ // 二次确认:鼠标直接点要确认(易误点);
387
+ // ★ 按住 Ctrl 再点 = 跳过确认,直接移入回收目录(2026-10-14 用户要求)。
388
+ // 删除本身是可恢复的(移到 sessions-trash 而非 unlink),所以这个捷径是安全的。
389
+ if (!ctrlIsDown() && (phase === 'idle' || phase === 'failed')) {
390
+ setErrMsg(''); setPhase('confirm'); return;
391
+ }
392
+ setPhase('busy');
393
+ try {
394
+ const r = await api('/session-tidy/delete-one', { id: sessionId });
395
+ if (r && r.ok) {
396
+ if (r.already) setAlready(true); // 文件本就不在(多半已删过)
397
+ // ★ 2026-10-14 追加:文件已移走 ⇒ 再走【官方归档】,让 host 状态立即更新。
398
+ // 动机(用户实测:"怎么还在啊"):删了文件但 host 内存状态不变 ⇒ 侧边栏照样
399
+ // 显示该会话,要重启 DSH 才干净。官方通道 uiWorkspace.archiveSession 会把
400
+ // 它移出活跃列表 ⇒ 列表【立刻】消失(host 侧随后还会把 archivedSessionIds 摘掉)。
401
+ // 顺序讲究:**先删文件、成功后才归档** —— 删除失败时不归档,会话仍留在列表里。
402
+ try {
403
+ if (typeof window.__scArchive === 'function') await window.__scArchive(sessionId);
404
+ } catch (e) {
405
+ console.warn('[session-tidy] 官方归档未成功(文件已删,列表要重启才更新): ' + ((e && e.message) || e));
406
+ }
407
+ setPhase('done');
408
+ setTimeout(() => { try { setMenuOpen(false); } catch (e) { /* 忽略 */ } }, 900);
409
+ // 不再自动刷新页面:那会打断用户正在进行的对话。
410
+ // 列表要等下次刷新才更新(会话列表来自 host 状态,删文件不会改它)。
411
+ } else {
412
+ setErrMsg((r && r.error) || '未移动');
413
+ setPhase('failed');
414
+ }
415
+ } catch (e) {
416
+ setErrMsg((e && e.message) || String(e));
417
+ setPhase('failed');
418
+ }
419
+ };
420
+ let label = '删除会话';
421
+ if (phase === 'confirm') label = '确认删除?(' + (props.displayTitle || '此会话') + ')';
422
+ else if (phase === 'busy') label = '删除中…';
423
+ else if (phase === 'done') label = already ? '该会话已清理过 ✓' : '已移入回收目录 ✓';
424
+ else if (phase === 'failed') label = '失败:' + errMsg;
425
+ const tip = '移到回收目录(可恢复;置顶与运行中的会话会被拒绝)—— 按住 Ctrl 点击可跳过二次确认';
426
+ return MenuItemButton
427
+ ? react.createElement(MenuItemButton, {
428
+ danger: phase !== 'done', // 官方危险行:error 色文字/图标 + danger hover 填充
429
+ icon: react.createElement(TrashIcon),
430
+ shortcut: shortcut,
431
+ onSelect: onClick,
432
+ }, label)
433
+ : react.createElement('button', {
434
+ type: 'button',
435
+ role: 'menuitem',
436
+ className: NS + '_mi ' + NS + '_miDanger',
437
+ title: tip,
438
+ onClick: onClick,
439
+ }, react.createElement(TrashIcon), label);
440
+ }
441
+
442
+ function apply(ctx) {
443
+ console.log('[session-tidy] client apply()');
444
+ // 修饰键跟踪(Ctrl+点击跳过确认用)
445
+ if (ctx && typeof ctx.effect === 'function') ctx.effect(trackModifiers);
446
+ else trackModifiers();
447
+ injectCss();
448
+ // ★ 把官方归档动作挂到 window —— 插槽组件的 props 里没有 ctx,拿不到服务。
449
+ // uiWorkspace.archiveSession(id) 更新 host 状态 ⇒ 侧边栏立即移除该会话。
450
+ // 能力检测:服务不在就置 null,调用方自动退化为"只删文件"。
451
+ try {
452
+ const ws = (ctx && typeof ctx.get === 'function') ? ctx.get('uiWorkspace') : null;
453
+ window.__scArchive = (ws && typeof ws.archiveSession === 'function')
454
+ ? function (id) { return ws.archiveSession(id); }
455
+ : null;
456
+ console.log('[session-tidy] 官方归档通道: ' + (window.__scArchive ? '✅ uiWorkspace.archiveSession 可用' : '⚠️ 不可用(删完列表要重启才更新)'));
457
+ } catch (e) {
458
+ window.__scArchive = null;
459
+ console.log('[session-tidy] 官方归档通道探测失败: ' + ((e && e.message) || e));
460
+ }
461
+
462
+ // 快捷键注册已移除(2026-10-13)。
463
+ // 原因:它删的是"主视图当前会话"——那正是用户正在对话的那个,方向完全错了;
464
+ // 且 'shortcuts' 服务在客户端未必可用,声明 inject 会让整个插件去等它。
465
+ // ⇒ 宁可不做,也不能删掉用户正在用的会话。
466
+ // ① 会话行的 "..." 菜单(官方 slot;插件项排在 archive(400) 之后)
467
+ try {
468
+ ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register(
469
+ { name: 'sidebar.workspaces.session.menu.item', id: 'session-tidy.delete', order: 500 },
470
+ MenuDeleteItem
471
+ ));
472
+ console.log('[session-tidy] ✅ 菜单项已注册到 sidebar.workspaces.session.menu.item');
473
+ } catch (e) {
474
+ console.error('[session-tidy] 菜单项注册失败: ' + ((e && e.message) || e));
475
+ }
476
+ // ② 侧边栏底部的批量入口
477
+ try {
478
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
479
+ { name: 'sidebar.footer.action', id: 'dsh-plugin-session-tidy', order: 90 },
480
+ EntryButton
481
+ ));
482
+ console.log('[session-tidy] ✅ 入口已注册到 sidebar.footer.action');
483
+ } catch (e) {
484
+ console.error('[session-tidy] 入口注册失败: ' + ((e && e.message) || e));
485
+ }
486
+ }
487
+
488
+ exports.apply = apply;
489
+ // 只声明 slots。曾经加过 'shortcuts'(为了注册快捷键),那会让插件去等一个
490
+ // 未必存在的客户端服务 —— 风险大于收益,已回滚(2026-10-13)。
491
+ exports.inject = ['slots'];
492
+ return module.exports;
493
+ },
494
+ });
package/lib/index.js ADDED
@@ -0,0 +1,428 @@
1
+ // dsh-plugin-session-tidy -- host 侧
2
+ //
3
+ // 为什么需要它:官方【只归档不删除】(dsh-client-ui-workspace README:
4
+ // 「会话可以归档但绝不会被删除」;dsh-session-persistence-jsonl:「seam 无删除接口」),
5
+ // 而 archiveSession 只是往注册表塞 id —— 文件仍在原地,不省空间。
6
+ //
7
+ // 删除动作【可恢复】:移到 ~/.dsh/sessions-trash/<时间戳>/,再由用户显式清空。
8
+ //
9
+ // 三条保护(用户 2026-10-13 定):
10
+ // ① 置顶会话 ← ~/.dsh/storages/workspace.json 的 global.pinnedSessionIds(只读)
11
+ // ② 正在运行的会话 ← ctx.agents.list() 的 session.id
12
+ // ③ mtime 兜底 ← RECENT_MS 内动过的一律禁选
13
+ // 前两条【前端禁选 + 后端拒绝】双重校验 —— 前端只是提示,后端才是闸门。
14
+ //
15
+ // 来源检查:只接受 loopback(远程 / tls-proxy 反代一律 403)。
16
+
17
+ import { readdir, stat, mkdir, rename, rm, readFile, writeFile } from 'node:fs/promises';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ export const name = 'session-tidy';
22
+
23
+ /** webServer 承载路由;agents 用来识别"正在运行的会话"(拿不到就退化为只靠 mtime)。 */
24
+ export const inject = ['webServer', 'agents'];
25
+
26
+ const DSH = join(homedir(), '.dsh');
27
+ const SESSIONS = join(DSH, 'sessions');
28
+ const TRASH = join(DSH, 'sessions-trash');
29
+ const WORKSPACE_JSON = join(DSH, 'storages', 'workspace.json');
30
+
31
+ /** 最近这么久内动过的会话禁选(保护正在进行的对话)。 */
32
+ const RECENT_MS = 10 * 60 * 1000;
33
+
34
+ const SESSION_FILE = 'session.v4.jsonl.zstd';
35
+
36
+ /** 只认本机回环来源:远程(含反代)一律拒绝。 */
37
+ function isLoopback(req) {
38
+ const a = req.socket && req.socket.remoteAddress;
39
+ const ok = a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1';
40
+ if (!ok) return false;
41
+ const host = String(req.headers.host || '');
42
+ const h = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
43
+ return h === '127.0.0.1' || h === 'localhost' || h === '::1';
44
+ }
45
+
46
+ function sendJson(res, code, body) {
47
+ res.statusCode = code;
48
+ res.setHeader('content-type', 'application/json; charset=utf-8');
49
+ res.setHeader('cache-control', 'no-store');
50
+ res.end(JSON.stringify(body));
51
+ }
52
+
53
+ async function readBody(req, limit = 64 * 1024) {
54
+ return new Promise((resolve, reject) => {
55
+ let d = '';
56
+ req.on('data', (chunk) => {
57
+ d += chunk;
58
+ if (d.length > limit) { reject(new Error('body too large')); req.destroy(); }
59
+ });
60
+ req.on('end', () => resolve(d));
61
+ req.on('error', reject);
62
+ });
63
+ }
64
+
65
+ async function dirBytes(dir) {
66
+ let total = 0;
67
+ async function walk(d) {
68
+ let entries;
69
+ try { entries = await readdir(d, { withFileTypes: true }); } catch { return; }
70
+ for (const e of entries) {
71
+ const p = join(d, e.name);
72
+ if (e.isDirectory()) await walk(p);
73
+ else { try { total += (await stat(p)).size; } catch { /* 忽略 */ } }
74
+ }
75
+ }
76
+ await walk(dir);
77
+ return total;
78
+ }
79
+
80
+ /** 工作区转义名 ⇒ 可读路径(--C-Users-x-Desktop-~7BA1~5BB6-- ⇒ C:\Users\x\Desktop\管家)。 */
81
+ export function decodeWorkspace(name) {
82
+ const core = name.replace(/^--/, '').replace(/--$/, '');
83
+ return core
84
+ .replace(/~([0-9A-Fa-f]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
85
+ .replace(/^([A-Za-z])-/, '$1:\\') // 盘符:C-Users ⇒ C:\Users
86
+ .replace(/-/g, '\\');
87
+ }
88
+
89
+ /**
90
+ * 读 DSH 的 workspace 注册表(**只读,绝不写**)。
91
+ * 返回 { pinned:Set, archived:Set, titles:Map<sessionId,title> },失败时返回空集合。
92
+ */
93
+ export async function readRegistry() {
94
+ const pinned = new Set();
95
+ const archived = new Set();
96
+ const titles = new Map();
97
+ try {
98
+ const j = JSON.parse(await readFile(WORKSPACE_JSON, 'utf8'));
99
+ for (const id of j?.global?.pinnedSessionIds ?? []) pinned.add(String(id));
100
+ for (const id of j?.global?.archivedSessionIds ?? []) archived.add(String(id));
101
+ for (const w of Object.values(j?.tables?.workspaces ?? {})) {
102
+ for (const sid of w?.sessionIds ?? []) if (!titles.has(String(sid))) titles.set(String(sid), String(w?.title ?? ''));
103
+ }
104
+ } catch { /* 注册表不可读 ⇒ 空保护集合,靠 mtime 兜底 */ }
105
+ return { pinned, archived, titles };
106
+ }
107
+
108
+ /**
109
+ * 正在【运行】的会话 id —— 判据是 agent.status === 'running'。
110
+ *
111
+ * ★ 2026-10-14 修 bug(用户:"没在运行的会话也被提示正在运行、拒绝删除"):
112
+ * agents.list() 返回的是【所有 live agent】(官方注释原文:All live agents, in registration
113
+ * order)—— 会话切走或结束后 agent 仍会驻留,只看"存在"必然把整屏历史会话误判成"正在运行"。
114
+ * 官方的 AgentStatus 是【二元】的:'idle' | 'running'
115
+ * (原句:export type AgentStatus = 'idle' | 'running',见 extensions/tool-cordis 的 api-catalog)
116
+ * ⇒ 只把 running 当运行;idle 的只是驻留,可以删。
117
+ * 拿不到 status 时【放行】:宁可让用户删掉已结束的会话,也不要把列表全锁死;
118
+ * 真正在写的文件还有 mtime 与 rename 失败两道兜底。
119
+ */
120
+ export function activeSessionIds(ctx) {
121
+ const out = new Set();
122
+ try {
123
+ for (const agent of ctx?.get?.('agents')?.list?.() ?? []) {
124
+ const id = agent?.session?.id ?? agent?.sessionId;
125
+ if (!id) continue;
126
+ if (agent && agent.status === 'running') out.add(String(id));
127
+ }
128
+ } catch { /* 拿不到就不保护这一维 */ }
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * 扫出所有会话(含不可删的,带标记 —— 让用户看到"为什么删不了")。
134
+ * @returns { sessions, totalBytes, deletableBytes }
135
+ */
136
+ export async function scanSessions(opts = {}) {
137
+ const now = opts.now ?? Date.now();
138
+ const pinned = opts.pinned ?? new Set();
139
+ const archived = opts.archived ?? new Set();
140
+ const active = opts.active ?? new Set();
141
+ const titles = opts.titles ?? new Map();
142
+
143
+ const out = [];
144
+ let workspaces = [];
145
+ try { workspaces = await readdir(SESSIONS, { withFileTypes: true }); } catch { return { sessions: [], totalBytes: 0, deletableBytes: 0 }; }
146
+
147
+ for (const w of workspaces) {
148
+ if (!w.isDirectory()) continue;
149
+ const wdir = join(SESSIONS, w.name);
150
+ let sessions = [];
151
+ try { sessions = await readdir(wdir, { withFileTypes: true }); } catch { continue; }
152
+ for (const s of sessions) {
153
+ if (!s.isDirectory()) continue;
154
+ const id = s.name;
155
+ const sdir = join(wdir, id);
156
+ let st;
157
+ try { st = await stat(join(sdir, SESSION_FILE)); } catch { continue; }
158
+ const bytes = await dirBytes(sdir);
159
+ const recent = now - st.mtimeMs < RECENT_MS;
160
+
161
+ // 磁盘目录名可能带 session- 前缀;注册表里的 id 也带。两种都比一次。
162
+ const bare = id.replace(/^session-/, '');
163
+ const isPinned = pinned.has(id) || pinned.has(bare) || pinned.has('session-' + bare);
164
+ const isArchived = archived.has(id) || archived.has(bare) || archived.has('session-' + bare);
165
+ const isActive = active.has(id) || active.has(bare) || active.has('session-' + bare);
166
+
167
+ let lockedBy = null;
168
+ if (isActive) lockedBy = 'active';
169
+ else if (isPinned) lockedBy = 'pinned';
170
+ else if (recent) lockedBy = 'recent';
171
+
172
+ out.push({
173
+ id,
174
+ workspace: w.name,
175
+ workspacePath: decodeWorkspace(w.name),
176
+ title: titles.get(id) || titles.get(bare) || '',
177
+ bytes,
178
+ mtime: st.mtimeMs,
179
+ archived: isArchived,
180
+ lockedBy,
181
+ });
182
+ }
183
+ }
184
+ // 已归档的排前面(用户主动归档过 ⇒ 最可能想清),同组按大小倒序
185
+ out.sort((a, b) => (Number(b.archived) - Number(a.archived)) || (b.bytes - a.bytes));
186
+ const totalBytes = out.reduce((n, x) => n + x.bytes, 0);
187
+ const deletableBytes = out.filter((x) => !x.lockedBy).reduce((n, x) => n + x.bytes, 0);
188
+ return { sessions: out, totalBytes, deletableBytes };
189
+ }
190
+
191
+ /** 回收目录现状。 */
192
+ export async function trashInfo() {
193
+ let stamps = [];
194
+ try { stamps = await readdir(TRASH, { withFileTypes: true }); } catch { return { count: 0, bytes: 0 }; }
195
+ let count = 0, bytes = 0;
196
+ for (const d of stamps) {
197
+ if (!d.isDirectory()) continue;
198
+ const p = join(TRASH, d.name);
199
+ let wsList = [];
200
+ try { wsList = await readdir(p, { withFileTypes: true }); } catch { continue; }
201
+ for (const w of wsList) {
202
+ if (!w.isDirectory()) continue;
203
+ let inner = [];
204
+ try { inner = await readdir(join(p, w.name), { withFileTypes: true }); } catch { continue; }
205
+ for (const s of inner) {
206
+ if (!s.isDirectory()) continue;
207
+ count++;
208
+ bytes += await dirBytes(join(p, w.name, s.name));
209
+ }
210
+ }
211
+ }
212
+ return { count, bytes };
213
+ }
214
+
215
+ /**
216
+ * 把指定会话移到回收目录。
217
+ * ★ 后端再次校验三条保护 —— 前端禁选只是提示。
218
+ */
219
+ export async function moveToTrash(ids, guards = {}) {
220
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
221
+ const pinned = guards.pinned ?? new Set();
222
+ const active = guards.active ?? new Set();
223
+ const moved = [];
224
+ const failed = [];
225
+
226
+ for (const it of ids) {
227
+ const id = String(it?.id ?? '');
228
+ const workspace = String(it?.workspace ?? '');
229
+ // 防路径穿越
230
+ if (!id || !workspace || /[\\/]|\.\./.test(id) || /[\\/]|\.\./.test(workspace)) {
231
+ failed.push({ id, error: '非法名字' });
232
+ continue;
233
+ }
234
+ const bare = id.replace(/^session-/, '');
235
+ const inSet = (s) => s.has(id) || s.has(bare) || s.has('session-' + bare);
236
+ if (inSet(active)) { failed.push({ id, error: '正在运行,拒绝删除' }); continue; }
237
+ if (inSet(pinned)) { failed.push({ id, error: '置顶会话,拒绝删除' }); continue; }
238
+
239
+ const src = join(SESSIONS, workspace, id);
240
+ try {
241
+ const st = await stat(src);
242
+ if (Date.now() - st.mtimeMs < RECENT_MS) { failed.push({ id, error: '最近仍活跃,拒绝删除' }); continue; }
243
+ const dstDir = join(TRASH, stamp, workspace);
244
+ await mkdir(dstDir, { recursive: true });
245
+ await rename(src, join(dstDir, id));
246
+ moved.push(id);
247
+ } catch (e) {
248
+ failed.push({ id, error: (e && e.message) || String(e) });
249
+ }
250
+ }
251
+ // ★ 文件移走了,还要把这个会话从 DSH 的 workspace 注册表里摘掉 ——
252
+ // 否则侧边栏列表照样显示它,再点还会报「找不到」(实测就是这个症状)。
253
+ let unregistered = null;
254
+ if (moved.length) unregistered = await unregisterSessions(moved);
255
+ return { moved, failed, stamp, unregistered };
256
+ }
257
+
258
+ /**
259
+ * 从 DSH 的 workspace 注册表里摘掉这些会话 id。
260
+ *
261
+ * ★ 为什么必须做(2026-10-14 实测):已删会话仍留在 workspaces[].sessionIds 里(6 个里 5 个还在),
262
+ * 而侧边栏列表读的就是这份状态 ⇒ 不摘掉的话「删了文件列表照样显示、再点还报找不到」。
263
+ *
264
+ * ★ 关于官方 archiveSession:归档是【用户的主动状态】(想留着只是不想看),所以【不用它代替删除】。
265
+ * 但 2026-10-14 起 client 侧会在【文件删除成功之后】顺手调一次归档 —— 唯一目的是让 host 侧状态
266
+ * 立即更新(否则侧边栏要重启才干净,用户实测:"怎么还在啊");紧接着本函数会把该 id 从
267
+ * archivedSessionIds 里也摘掉 ⇒ 不留下"伪归档"。
268
+ * 顺序讲究:先删文件、成功后才归档 —— 删除失败时不归档,会话仍留在列表里,语义更直白。
269
+ *
270
+ * ★ 安全措施(针对「DSH 也会写这个文件」):
271
+ * ① 写前【重读】磁盘最新内容,不拿旧副本覆盖
272
+ * ② 只改那三个数组,其余字段原样带回
273
+ * ③ 先备份 .bak-session-tidy-<时间戳>
274
+ * ④ 原子写:先写临时文件再 rename(避免写一半被读到)
275
+ * ⑤ 整段 try/catch —— 这步失败【不影响删除本身】(文件已经移走了)
276
+ *
277
+ * ★ 生效时机:DSH 内存里那份状态不会因此改变 ⇒ 【重启 DSH 后】列表才干净。
278
+ */
279
+ export async function unregisterSessions(ids) {
280
+ if (!ids || !ids.length) return { ok: false, reason: 'empty' };
281
+ const set = new Set(ids.map(String));
282
+ try {
283
+ const before = await readFile(WORKSPACE_JSON, 'utf8');
284
+ const j = JSON.parse(before);
285
+ let removed = 0;
286
+
287
+ for (const w of Object.values(j?.tables?.workspaces ?? {})) {
288
+ if (!Array.isArray(w?.sessionIds)) continue;
289
+ const next = w.sessionIds.filter((x) => !set.has(String(x)));
290
+ removed += w.sessionIds.length - next.length;
291
+ w.sessionIds = next;
292
+ }
293
+ for (const key of ['archivedSessionIds', 'pinnedSessionIds']) {
294
+ const arr = j?.global?.[key];
295
+ if (!Array.isArray(arr)) continue;
296
+ const next = arr.filter((x) => !set.has(String(x)));
297
+ removed += arr.length - next.length;
298
+ j.global[key] = next;
299
+ }
300
+ if (!removed) return { ok: true, removed: 0 };
301
+
302
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
303
+ await writeFile(WORKSPACE_JSON + '.bak-session-tidy-' + stamp, before, 'utf8').catch(() => { /* 备份失败不阻断 */ });
304
+ const tmp = WORKSPACE_JSON + '.tmp-' + Date.now();
305
+ await writeFile(tmp, JSON.stringify(j, null, 2), 'utf8');
306
+ await rename(tmp, WORKSPACE_JSON);
307
+ console.log('[session-tidy] 已从 workspace 注册表摘除 ' + removed + ' 处引用(重启 DSH 后列表生效)');
308
+ return { ok: true, removed: removed };
309
+ } catch (e) {
310
+ console.log('[session-tidy] 摘除注册表失败(不影响已删除的文件): ' + ((e && e.message) || e));
311
+ return { ok: false, error: (e && e.message) || String(e) };
312
+ }
313
+ }
314
+
315
+ /** 彻底清空回收目录。 */
316
+ export async function emptyTrash() {
317
+ const info = await trashInfo();
318
+ await rm(TRASH, { recursive: true, force: true });
319
+ return info;
320
+ }
321
+
322
+ export function apply(ctx) {
323
+ console.log('[session-tidy] apply() 开始');
324
+
325
+ const guard = (req, res) => {
326
+ if (isLoopback(req)) return true;
327
+ sendJson(res, 403, { error: 'forbidden', message: '仅允许本机访问' });
328
+ return false;
329
+ };
330
+
331
+ ctx.effect(() => ctx.webServer.register({
332
+ kind: 'exact',
333
+ path: '/session-tidy/list',
334
+ handler: async (req, res) => {
335
+ if (!guard(req, res)) return;
336
+ if (req.method !== 'GET') { sendJson(res, 405, { error: 'method' }); return; }
337
+ try {
338
+ const reg = await readRegistry();
339
+ const active = activeSessionIds(ctx);
340
+ const scan = await scanSessions({ ...reg, active });
341
+ const trash = await trashInfo();
342
+ sendJson(res, 200, {
343
+ ok: true,
344
+ ...scan,
345
+ trash,
346
+ recentMs: RECENT_MS,
347
+ counts: { pinned: reg.pinned.size, archived: reg.archived.size, active: active.size },
348
+ });
349
+ } catch (e) {
350
+ sendJson(res, 500, { ok: false, error: (e && e.message) || String(e) });
351
+ }
352
+ },
353
+ }), 'session-tidy: list');
354
+
355
+ ctx.effect(() => ctx.webServer.register({
356
+ kind: 'exact',
357
+ path: '/session-tidy/delete',
358
+ handler: async (req, res) => {
359
+ if (!guard(req, res)) return;
360
+ if (req.method !== 'POST') { sendJson(res, 405, { error: 'method' }); return; }
361
+ try {
362
+ const body = JSON.parse((await readBody(req)) || '{}');
363
+ const ids = Array.isArray(body.ids) ? body.ids : [];
364
+ if (!ids.length) { sendJson(res, 400, { ok: false, error: '没有选中任何会话' }); return; }
365
+ if (ids.length > 500) { sendJson(res, 400, { ok: false, error: '一次最多 500 个' }); return; }
366
+ const reg = await readRegistry();
367
+ const active = activeSessionIds(ctx);
368
+ const r = await moveToTrash(ids, { pinned: reg.pinned, active });
369
+ console.log('[session-tidy] 移入回收目录 ' + r.moved.length + ' 个,拒绝/失败 ' + r.failed.length);
370
+ sendJson(res, 200, { ok: true, ...r });
371
+ } catch (e) {
372
+ sendJson(res, 500, { ok: false, error: (e && e.message) || String(e) });
373
+ }
374
+ },
375
+ }), 'session-tidy: delete');
376
+
377
+ ctx.effect(() => ctx.webServer.register({
378
+ kind: 'exact',
379
+ path: '/session-tidy/empty-trash',
380
+ handler: async (req, res) => {
381
+ if (!guard(req, res)) return;
382
+ if (req.method !== 'POST') { sendJson(res, 405, { error: 'method' }); return; }
383
+ try {
384
+ const info = await emptyTrash();
385
+ console.log('[session-tidy] 清空回收目录 ' + info.count + ' 个 / ' + info.bytes + ' B');
386
+ sendJson(res, 200, { ok: true, ...info });
387
+ } catch (e) {
388
+ sendJson(res, 500, { ok: false, error: (e && e.message) || String(e) });
389
+ }
390
+ },
391
+ }), 'session-tidy: empty-trash');
392
+
393
+ // ★ 会话行菜单用:只给 sessionId(菜单插槽的 props 里没有 workspace),host 自己定位。
394
+ ctx.effect(() => ctx.webServer.register({
395
+ kind: 'exact',
396
+ path: '/session-tidy/delete-one',
397
+ handler: async (req, res) => {
398
+ if (!guard(req, res)) return;
399
+ if (req.method !== 'POST') { sendJson(res, 405, { error: 'method' }); return; }
400
+ try {
401
+ const body = JSON.parse((await readBody(req)) || '{}');
402
+ const id = String(body.id || '');
403
+ if (!id) { sendJson(res, 400, { ok: false, error: '缺少 id' }); return; }
404
+ const reg = await readRegistry();
405
+ const active = activeSessionIds(ctx);
406
+ const scan = await scanSessions({ ...reg, active });
407
+ const hit = scan.sessions.find((s) => s.id === id);
408
+ if (!hit) {
409
+ // 文件已不在 ⇒ 多半是【已经删过一次】(用户又点了一下,属正常操作)。
410
+ // 不再报 404「找不到该会话」—— 那会让人以为出错了。
411
+ sendJson(res, 200, { ok: true, already: true, id: id, moved: [], failed: [] });
412
+ return;
413
+ }
414
+ const r = await moveToTrash([{ id: hit.id, workspace: hit.workspace }], { pinned: reg.pinned, active });
415
+ if (!r.moved.length) {
416
+ sendJson(res, 200, { ok: false, error: (r.failed[0] && r.failed[0].error) || '未移动', ...r });
417
+ return;
418
+ }
419
+ console.log('[session-tidy] 菜单删除: ' + id + ' (' + hit.bytes + ' B)');
420
+ sendJson(res, 200, { ok: true, id, bytes: hit.bytes, ...r });
421
+ } catch (e) {
422
+ sendJson(res, 500, { ok: false, error: (e && e.message) || String(e) });
423
+ }
424
+ },
425
+ }), 'session-tidy: delete-one');
426
+
427
+ console.log('[session-tidy] ✅ 四条路由已注册');
428
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "dsh-plugin-session-tidy",
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 会话文件(官方只归档不删除):会话行菜单 + 侧边栏批量面板",
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
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib/index.js",
18
+ "lib/client.js"
19
+ ],
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/Fanlr1/dsh-plugins.git",
24
+ "directory": "packages/session-tidy"
25
+ },
26
+ "homepage": "https://github.com/Fanlr1/dsh-plugins/tree/main/packages/session-tidy#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/Fanlr1/dsh-plugins/issues"
29
+ },
30
+ "keywords": [
31
+ "dsh",
32
+ "dsh-plugin",
33
+ "session",
34
+ "cleanup"
35
+ ],
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
39
+ "dsh": {
40
+ "client": {
41
+ "inject": [],
42
+ "platform": "web"
43
+ }
44
+ }
45
+ }