@lijian-ui/dsh-session-cleaner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,41 @@
1
+ # @lijian-ui/dsh-session-cleaner
2
+
3
+ English | [简体中文](./README.md)
4
+
5
+ > Session management for dsh: list (with titles), click-to-preview, real delete (with confirm), archive group & restore. 为 DeepSeek Harness 提供会话管理:列表(含标题)/ 点击预览 / 真删除(二次确认)/ 归档分组与恢复。
6
+
7
+ The official dsh only has "archive" (no delete), and archived sessions are hidden from every view with no way back (files still occupy disk). This plugin fixes both, and adds **identifiability** (titles + content preview) so you never delete the wrong session by accident:
8
+
9
+ - **Session list with titles** — scans `~/.dsh/sessions` on the file layer; shows the session title (extracted from the log's `session/title` event), time, size and status; grouped into 全部 (All) / 已归档 (Archived) / 游离 (Orphan)
10
+ - **Click to preview** — clicking a row opens a preview dialog (last 12 messages, user/assistant bubbles); delete is also available inside the preview
11
+ - **Real delete** — removes the session directory + projection cache (projcache) + workspace state in one pass; a **confirm dialog** clearly warns "permanent, not recoverable"
12
+ - **Archive group** — archiving goes through the official `workspace.archiveSession` RPC (**instant hide in the official list**); restore/delete write `workspace.json` directly (the official UI syncs after a dsh restart)
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ # Development (link)
18
+ dsh plugin --profile web add ./extensions/session-cleaner
19
+ # Or from npm once published
20
+ dsh plugin --profile web add @lijian-ui/dsh-session-cleaner
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ Open Settings → **会话管理** (Session Manager) after starting dsh:
26
+
27
+ - Top: session count and total disk usage
28
+ - Tabs: 全部 (All) / 已归档 (Archived) / 游离 (Orphan)
29
+ - Each row: **click** → preview dialog (title, ID, created time, size + recent conversation); row buttons: **归档** (Archive — hides in the official list instantly) / **恢复** (Restore) / **删除** (Delete — confirm dialog, real delete)
30
+ - The preview dialog also has a "删除此会话" (Delete this session) entry
31
+
32
+ ## Known boundaries
33
+
34
+ - **Archive**: goes through the official API — the official sidebar hides it **instantly** (no restart)
35
+ - **Restore / Delete**: the official API has no such operations, so the plugin works on the file layer directly — the **official sidebar syncs after a dsh restart** (the plugin page itself is always accurate)
36
+ - **Delete is permanent** (no trash); the confirm dialog warns about it explicitly
37
+ - Session logs are **multi-frame zstd**; the plugin decompresses every frame to read titles and content
38
+
39
+ ## License
40
+
41
+ MIT © lijian-ui
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @lijian-ui/dsh-session-cleaner
2
+
3
+ > 为 DeepSeek Harness (dsh) 提供会话管理:列表(含标题)/ 点击预览 / 真删除(二次确认)/ 归档分组与恢复。
4
+ > Session management for dsh: list (with titles), click-to-preview, real delete (with confirm), archive group & restore.
5
+
6
+ 官方 dsh 只有"归档"没有"删除",且归档后会话从所有视图隐藏、无恢复入口(会话记录仍占磁盘)。本插件修复这两个问题,并补上会话的**可识别性**(标题 + 内容预览),避免误删:
7
+
8
+ - **会话列表(带标题)**:文件层扫描 `~/.dsh/sessions`,显示会话标题(从日志 `session/title` 事件提取)、时间、大小、状态;分组:全部 / 已归档 / 游离
9
+ - **点击预览**:点击会话行弹出内容预览(最近 12 条对话,用户/助手气泡),预览内可直接删除
10
+ - **真删除**:删除会话目录 + 投影缓存(projcache)+ workspace 状态三处清理;**二次确认弹窗**,明确"永久删除、不可恢复"
11
+ - **归档分组**:归档走官方 `workspace.archiveSession` RPC(**官方列表即时隐藏**);恢复/删除读写官方 `workspace.json`(官方 UI 重启后同步)
12
+
13
+ ## 安装
14
+
15
+ ```bash
16
+ # 开发期(link 联调)
17
+ dsh plugin --profile web add ./extensions/session-cleaner
18
+ # 或发布后
19
+ dsh plugin --profile web add @lijian-ui/dsh-session-cleaner
20
+ ```
21
+
22
+ ## 使用
23
+
24
+ 启动 dsh 后打开设置 → **会话管理**:
25
+
26
+ - 顶部:会话总数与总占用
27
+ - 标签页:全部 / 已归档 / 游离
28
+ - 每行:**点击** → 预览弹窗(标题、ID、创建时间、大小 + 最近对话);行内按钮:**归档**(官方列表即时隐藏)/ **恢复**(移出归档)/ **删除**(二次确认,真删除)
29
+ - 预览弹窗底部也有「删除此会话」入口
30
+
31
+ ## 边界说明
32
+
33
+ - **归档**:走官方 API,官方侧边栏**即时**隐藏(无需重启)
34
+ - **恢复 / 删除**:官方无对应 API,插件直接操作文件层,**官方侧边栏在 dsh 重启后同步**(插件页面本身即时准确)
35
+ - **删除**为**永久删除**(不经过回收站),插件会在确认框明确警告
36
+ - 会话日志是**多帧 zstd** 格式,插件按帧完整解压读取标题与内容
37
+
38
+ ## 许可
39
+
40
+ MIT © lijian-ui
@@ -0,0 +1,15 @@
1
+ # @lijian-ui/dsh-session-cleaner bundle patch — SINGLE entry.
2
+ #
3
+ # One plugin (`@lijian-ui/dsh-session-cleaner`) provides the session
4
+ # management UI + host RPC. It ships ZERO config (no instances needed):
5
+ # sessions live in the official `~/.dsh/sessions` tree and the plugin
6
+ # manages them directly on the file layer.
7
+ #
8
+ # This entry's package (package.json) also declares dsh.client → the same
9
+ # package carries the browser half (lib/client.js), which client-modules
10
+ # discovers from this entry's name.
11
+
12
+ - insert:
13
+ - id: session-cleaner
14
+ name: '@lijian-ui/dsh-session-cleaner'
15
+ config: {}
package/lib/client.js ADDED
@@ -0,0 +1,644 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@lijian-ui/dsh-session-cleaner",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ //#region src/client/SessionManagerSection.ts
9
+ /**
10
+ * 会话管理设置页(client half)
11
+ * ------------------------------------------------------------------
12
+ * 分组:全部 / 已归档 / 游离(无 workspace 归属)。
13
+ * 操作:归档(移入已归档分组)、恢复(移出)、删除(真删除 + 二次确认弹窗)。
14
+ * 样式走 dsh 设计 token(var(--dsw-alias-*))自动适配明暗主题。
15
+ */
16
+ const page = {
17
+ display: "flex",
18
+ flexDirection: "column",
19
+ gap: 12,
20
+ padding: "4px 0 24px",
21
+ width: "100%",
22
+ minHeight: 400
23
+ };
24
+ const header = {
25
+ display: "flex",
26
+ alignItems: "center",
27
+ justifyContent: "space-between",
28
+ padding: "14px 0",
29
+ flexShrink: 0,
30
+ borderBottom: "1px solid var(--dsw-alias-border-l2, #eee)"
31
+ };
32
+ const title = {
33
+ fontSize: 17,
34
+ fontWeight: 500,
35
+ color: "var(--dsw-alias-label-primary, inherit)",
36
+ margin: 0,
37
+ whiteSpace: "nowrap"
38
+ };
39
+ const countBadge = {
40
+ display: "inline-flex",
41
+ alignItems: "center",
42
+ justifyContent: "center",
43
+ minWidth: 22,
44
+ height: 22,
45
+ padding: "0 7px",
46
+ borderRadius: 999,
47
+ background: "var(--dsw-alias-bg-layer-3, #eee)",
48
+ color: "var(--dsw-alias-label-secondary, #555)",
49
+ fontSize: 11,
50
+ fontWeight: 500,
51
+ marginLeft: 8
52
+ };
53
+ const toolbar = {
54
+ display: "flex",
55
+ alignItems: "center",
56
+ gap: 8
57
+ };
58
+ const btnGhost = {
59
+ display: "inline-flex",
60
+ alignItems: "center",
61
+ gap: 6,
62
+ height: 30,
63
+ padding: "0 12px",
64
+ background: "transparent",
65
+ color: "var(--dsw-alias-label-primary, inherit)",
66
+ border: "1px solid var(--dsw-alias-border-l2, #ddd)",
67
+ borderRadius: 6,
68
+ fontSize: 12,
69
+ cursor: "pointer"
70
+ };
71
+ const btnDanger = {
72
+ display: "inline-flex",
73
+ alignItems: "center",
74
+ gap: 6,
75
+ height: 26,
76
+ padding: "0 10px",
77
+ background: "transparent",
78
+ color: "var(--dsw-alias-state-error-primary, #e24b4a)",
79
+ border: "1px solid var(--dsw-alias-state-error-primary, #e24b4a)",
80
+ borderRadius: 6,
81
+ fontSize: 12,
82
+ cursor: "pointer"
83
+ };
84
+ const btnDangerSolid = {
85
+ display: "inline-flex",
86
+ alignItems: "center",
87
+ gap: 6,
88
+ height: 30,
89
+ padding: "0 14px",
90
+ background: "var(--dsw-alias-state-error-primary, #e24b4a)",
91
+ color: "#fff",
92
+ border: "none",
93
+ borderRadius: 6,
94
+ fontSize: 12,
95
+ fontWeight: 500,
96
+ cursor: "pointer"
97
+ };
98
+ const tabs = {
99
+ display: "flex",
100
+ gap: 4,
101
+ borderBottom: "1px solid var(--dsw-alias-border-l2, #eee)",
102
+ paddingBottom: 0
103
+ };
104
+ const tabStyle = {
105
+ padding: "8px 14px",
106
+ fontSize: 13,
107
+ color: "var(--dsw-alias-label-secondary, #666)",
108
+ background: "transparent",
109
+ border: "none",
110
+ borderBottom: "2px solid transparent",
111
+ cursor: "pointer"
112
+ };
113
+ const tabStyleActive = {
114
+ ...tabStyle,
115
+ color: "var(--dsw-alias-state-business-primary, #185FA5)",
116
+ borderBottomColor: "var(--dsw-alias-state-business-primary, #185FA5)",
117
+ fontWeight: 500
118
+ };
119
+ const list = {
120
+ display: "flex",
121
+ flexDirection: "column",
122
+ gap: 6
123
+ };
124
+ const row = {
125
+ display: "flex",
126
+ alignItems: "center",
127
+ gap: 12,
128
+ padding: "10px 12px",
129
+ borderRadius: 8,
130
+ background: "var(--dsw-alias-bg-layer-2, #fafafa)",
131
+ border: "1px solid var(--dsw-alias-border-l3, #f0f0f0)",
132
+ cursor: "pointer"
133
+ };
134
+ const rowMain = {
135
+ flex: 1,
136
+ minWidth: 0,
137
+ display: "flex",
138
+ flexDirection: "column",
139
+ gap: 2
140
+ };
141
+ const rowTitle = {
142
+ fontSize: 13,
143
+ fontWeight: 500,
144
+ color: "var(--dsw-alias-label-primary, inherit)",
145
+ overflow: "hidden",
146
+ textOverflow: "ellipsis",
147
+ whiteSpace: "nowrap"
148
+ };
149
+ const rowMeta = {
150
+ fontSize: 11,
151
+ color: "var(--dsw-alias-label-secondary, #888)"
152
+ };
153
+ const rowActions = {
154
+ display: "flex",
155
+ alignItems: "center",
156
+ gap: 6,
157
+ flexShrink: 0
158
+ };
159
+ const badge = {
160
+ display: "inline-flex",
161
+ alignItems: "center",
162
+ padding: "1px 8px",
163
+ borderRadius: 999,
164
+ fontSize: 10,
165
+ background: "var(--dsw-alias-bg-layer-3, #eee)",
166
+ color: "var(--dsw-alias-label-secondary, #777)"
167
+ };
168
+ const badgeArchived = {
169
+ ...badge,
170
+ color: "var(--dsw-alias-state-warning-primary, #b8860b)",
171
+ background: "rgba(184,134,11,0.12)"
172
+ };
173
+ const badgeStray = {
174
+ ...badge,
175
+ color: "var(--dsw-alias-state-error-primary, #e24b4a)",
176
+ background: "rgba(226,75,74,0.10)"
177
+ };
178
+ const empty = {
179
+ display: "flex",
180
+ flexDirection: "column",
181
+ alignItems: "center",
182
+ justifyContent: "center",
183
+ gap: 6,
184
+ padding: "40px 24px",
185
+ border: "1px dashed var(--dsw-alias-border-l3, #ccc)",
186
+ borderRadius: 10,
187
+ background: "var(--dsw-alias-bg-layer-1, transparent)",
188
+ textAlign: "center"
189
+ };
190
+ const emptyText = {
191
+ margin: 0,
192
+ fontSize: 13,
193
+ color: "var(--dsw-alias-label-secondary, #555)"
194
+ };
195
+ const errorStyle = {
196
+ fontSize: 12,
197
+ lineHeight: 1.5,
198
+ color: "var(--dsw-alias-state-error-primary, #e24b4a)",
199
+ padding: "10px 14px",
200
+ background: "rgba(226,75,74,0.08)",
201
+ border: "1px solid rgba(226,75,74,0.35)",
202
+ borderRadius: 8,
203
+ whiteSpace: "pre-wrap"
204
+ };
205
+ const hint = {
206
+ fontSize: 12,
207
+ color: "var(--dsw-alias-label-secondary, #888)",
208
+ lineHeight: 1.6
209
+ };
210
+ const modalOverlay = {
211
+ position: "fixed",
212
+ inset: 0,
213
+ background: "rgba(0,0,0,0.45)",
214
+ display: "flex",
215
+ alignItems: "center",
216
+ justifyContent: "center",
217
+ zIndex: 1e3
218
+ };
219
+ const modalCard = {
220
+ width: 420,
221
+ maxWidth: "86vw",
222
+ background: "var(--dsw-alias-bg-layer-3, #fff)",
223
+ color: "var(--dsw-alias-label-primary, #222)",
224
+ borderRadius: 12,
225
+ boxShadow: "0 8px 32px rgba(0,0,0,0.25)",
226
+ padding: "20px 22px",
227
+ display: "flex",
228
+ flexDirection: "column",
229
+ gap: 12
230
+ };
231
+ const modalTitle = {
232
+ margin: 0,
233
+ fontSize: 15,
234
+ fontWeight: 600
235
+ };
236
+ const modalBody = {
237
+ fontSize: 13,
238
+ lineHeight: 1.6,
239
+ color: "var(--dsw-alias-label-secondary, #555)"
240
+ };
241
+ const modalBodyStrong = {
242
+ fontSize: 13,
243
+ lineHeight: 1.6,
244
+ color: "var(--dsw-alias-state-error-primary, #e24b4a)",
245
+ fontWeight: 500
246
+ };
247
+ const modalActions = {
248
+ display: "flex",
249
+ justifyContent: "flex-end",
250
+ gap: 8,
251
+ marginTop: 4
252
+ };
253
+ const previewModalCard = {
254
+ ...modalCard,
255
+ width: 560,
256
+ maxHeight: "70vh",
257
+ overflow: "hidden",
258
+ display: "flex",
259
+ flexDirection: "column"
260
+ };
261
+ const previewBody = {
262
+ flex: 1,
263
+ minHeight: 0,
264
+ overflowY: "auto",
265
+ display: "flex",
266
+ flexDirection: "column",
267
+ gap: 8,
268
+ padding: "4px 2px"
269
+ };
270
+ const msgBubble = {
271
+ maxWidth: "92%",
272
+ padding: "8px 12px",
273
+ borderRadius: 8,
274
+ fontSize: 12.5,
275
+ lineHeight: 1.6,
276
+ whiteSpace: "pre-wrap",
277
+ wordBreak: "break-word"
278
+ };
279
+ const msgUser = {
280
+ ...msgBubble,
281
+ alignSelf: "flex-end",
282
+ background: "var(--dsw-alias-state-business-primary, #185FA5)",
283
+ color: "#fff"
284
+ };
285
+ const msgAssistant = {
286
+ ...msgBubble,
287
+ alignSelf: "flex-start",
288
+ background: "var(--dsw-alias-bg-layer-3, #f0f0f0)",
289
+ color: "var(--dsw-alias-label-primary, #222)"
290
+ };
291
+ const previewMeta = {
292
+ fontSize: 11,
293
+ color: "var(--dsw-alias-label-secondary, #888)"
294
+ };
295
+ function fmtBytes(n) {
296
+ if (n < 1024) return `${n} B`;
297
+ if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`;
298
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
299
+ }
300
+ function fmtTime(ms) {
301
+ const d = new Date(ms);
302
+ const p = (x) => String(x).padStart(2, "0");
303
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
304
+ }
305
+ /** 会话展示名:优先标题,回退 workspace 解码 + id 短前缀 */
306
+ function fmtSessionName(s) {
307
+ if (s.title) return s.title;
308
+ return `${s.workspaceDir.replace(/~003A/g, ":").replace(/^--/, "").replace(/--$/, "")} / ${s.id.length > 8 ? `${s.id.slice(0, 8)}…` : s.id}`;
309
+ }
310
+ /** 格式化时间戳 */
311
+ function fmtDateTime(ms) {
312
+ if (!ms) return "—";
313
+ const d = new Date(ms);
314
+ const p = (x) => String(x).padStart(2, "0");
315
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
316
+ }
317
+ function SessionManagerSection(props) {
318
+ const { api } = props;
319
+ const [sessions, setSessions] = (0, react.useState)([]);
320
+ const [loading, setLoading] = (0, react.useState)(true);
321
+ const [error, setError] = (0, react.useState)("");
322
+ const [tab, setTab] = (0, react.useState)("all");
323
+ const [confirmTarget, setConfirmTarget] = (0, react.useState)(null);
324
+ const [busy, setBusy] = (0, react.useState)(false);
325
+ const [previewTarget, setPreviewTarget] = (0, react.useState)(null);
326
+ const [previewData, setPreviewData] = (0, react.useState)(null);
327
+ const [previewLoading, setPreviewLoading] = (0, react.useState)(false);
328
+ const mountedRef = (0, react.useRef)(true);
329
+ const refresh = async () => {
330
+ setLoading(true);
331
+ setError("");
332
+ const res = await api.list();
333
+ if (!mountedRef.current) return;
334
+ setLoading(false);
335
+ if (!res.ok) {
336
+ setError(`加载会话失败:${res.message ?? res.code ?? "未知错误"}`);
337
+ return;
338
+ }
339
+ setSessions(res.value ?? []);
340
+ };
341
+ (0, react.useEffect)(() => {
342
+ mountedRef.current = true;
343
+ refresh();
344
+ return () => {
345
+ mountedRef.current = false;
346
+ };
347
+ }, []);
348
+ const filtered = (0, react.useMemo)(() => {
349
+ if (tab === "archived") return sessions.filter((s) => s.archived);
350
+ if (tab === "stray") return sessions.filter((s) => !s.archived && !s.active);
351
+ return sessions;
352
+ }, [sessions, tab]);
353
+ const counts = (0, react.useMemo)(() => ({
354
+ all: sessions.length,
355
+ archived: sessions.filter((s) => s.archived).length,
356
+ stray: sessions.filter((s) => !s.archived && !s.active).length
357
+ }), [sessions]);
358
+ const run = async (fn) => {
359
+ if (busy) return;
360
+ setBusy(true);
361
+ setError("");
362
+ const res = await fn();
363
+ setBusy(false);
364
+ if (!res.ok) {
365
+ setError(`操作失败:${res.message ?? "未知错误"}`);
366
+ return;
367
+ }
368
+ await refresh();
369
+ };
370
+ const openPreview = async (s) => {
371
+ setPreviewTarget(s);
372
+ setPreviewData(null);
373
+ setPreviewLoading(true);
374
+ const res = await api.preview(s.id);
375
+ setPreviewLoading(false);
376
+ if (res.ok) setPreviewData(res.value ?? null);
377
+ else setError(`加载预览失败:${res.message ?? "未知错误"}`);
378
+ };
379
+ const handleDelete = (s) => {
380
+ setConfirmTarget(s);
381
+ };
382
+ const confirmDelete = async () => {
383
+ if (!confirmTarget) return;
384
+ const target = confirmTarget;
385
+ setConfirmTarget(null);
386
+ await run(() => api.remove(target.id));
387
+ };
388
+ const handleArchive = (s) => {
389
+ const { archiveSession } = props;
390
+ if (archiveSession) {
391
+ (async () => {
392
+ if (busy) return;
393
+ setBusy(true);
394
+ setError("");
395
+ const ok = await archiveSession(s.id);
396
+ setBusy(false);
397
+ if (!ok) setError("归档失败(官方 API 不可用,请重试或重启 dsh)");
398
+ await refresh();
399
+ })();
400
+ return;
401
+ }
402
+ run(() => api.archive(s.id));
403
+ };
404
+ const handleUnarchive = (s) => {
405
+ run(() => api.unarchive(s.id));
406
+ };
407
+ const totalBytes = (0, react.useMemo)(() => sessions.reduce((a, s) => a + s.sizeBytes, 0), [sessions]);
408
+ return (0, react.createElement)("div", { style: page }, (0, react.createElement)("div", { style: header }, (0, react.createElement)("h2", { style: title }, "会话管理", (0, react.createElement)("span", { style: countBadge }, `${sessions.length} 个会话 · ${fmtBytes(totalBytes)}`)), (0, react.createElement)("div", { style: toolbar }, (0, react.createElement)("button", {
409
+ style: btnGhost,
410
+ onClick: () => void refresh(),
411
+ disabled: loading
412
+ }, loading ? "加载中…" : "刷新"))), (0, react.createElement)("div", { style: tabs }, [
413
+ "all",
414
+ "archived",
415
+ "stray"
416
+ ].map((k) => (0, react.createElement)("button", {
417
+ key: k,
418
+ style: tab === k ? tabStyleActive : tabStyle,
419
+ onClick: () => setTab(k)
420
+ }, k === "all" ? `全部 (${counts.all})` : k === "archived" ? `已归档 (${counts.archived})` : `游离 (${counts.stray})`))), error ? (0, react.createElement)("div", { style: errorStyle }, error) : null, (0, react.createElement)("div", { style: hint }, "会话以文件形式保存在 ~/.dsh/sessions。归档会从官方列表隐藏(与官方归档打通),删除为永久删除,不可恢复。"), (0, react.createElement)("div", { style: list }, filtered.map((s) => (0, react.createElement)("div", {
421
+ key: s.id,
422
+ style: row,
423
+ onClick: () => void openPreview(s)
424
+ }, (0, react.createElement)("div", { style: rowMain }, (0, react.createElement)("div", { style: rowTitle }, fmtSessionName(s)), (0, react.createElement)("div", { style: rowMeta }, `${fmtTime(s.mtimeMs)} · ${fmtBytes(s.sizeBytes)} · ${s.id}`)), s.archived ? (0, react.createElement)("span", { style: badgeArchived }, "已归档") : !s.active ? (0, react.createElement)("span", { style: badgeStray }, "游离") : null, (0, react.createElement)("div", {
425
+ style: rowActions,
426
+ onClick: (e) => e.stopPropagation()
427
+ }, s.archived ? (0, react.createElement)("button", {
428
+ style: btnGhost,
429
+ onClick: () => handleUnarchive(s),
430
+ disabled: busy
431
+ }, "恢复") : (0, react.createElement)("button", {
432
+ style: btnGhost,
433
+ onClick: () => handleArchive(s),
434
+ disabled: busy
435
+ }, "归档"), (0, react.createElement)("button", {
436
+ style: btnDanger,
437
+ onClick: () => handleDelete(s),
438
+ disabled: busy
439
+ }, "删除"))))), !loading && filtered.length === 0 ? (0, react.createElement)("div", { style: empty }, (0, react.createElement)("p", { style: emptyText }, tab === "archived" ? "没有已归档的会话" : tab === "stray" ? "没有游离会话" : "没有会话")) : null, confirmTarget ? (0, react.createElement)("div", {
440
+ style: modalOverlay,
441
+ onClick: () => setConfirmTarget(null)
442
+ }, (0, react.createElement)("div", {
443
+ style: modalCard,
444
+ onClick: (e) => e.stopPropagation()
445
+ }, (0, react.createElement)("h3", { style: modalTitle }, "删除会话?"), (0, react.createElement)("div", { style: modalBody }, `确定要删除会话「${fmtSessionName(confirmTarget)}」吗?`), (0, react.createElement)("div", { style: modalBodyStrong }, "此操作将永久删除该会话及其日志文件,无法恢复(不会进入回收站)。"), (0, react.createElement)("div", { style: modalActions }, (0, react.createElement)("button", {
446
+ style: btnGhost,
447
+ onClick: () => setConfirmTarget(null),
448
+ disabled: busy
449
+ }, "取消"), (0, react.createElement)("button", {
450
+ style: btnDangerSolid,
451
+ onClick: () => void confirmDelete(),
452
+ disabled: busy
453
+ }, busy ? "删除中…" : "永久删除")))) : null, previewTarget ? (0, react.createElement)("div", {
454
+ style: modalOverlay,
455
+ onClick: () => setPreviewTarget(null)
456
+ }, (0, react.createElement)("div", {
457
+ style: previewModalCard,
458
+ onClick: (e) => e.stopPropagation()
459
+ }, (0, react.createElement)("h3", { style: modalTitle }, fmtSessionName(previewTarget)), (0, react.createElement)("div", { style: previewMeta }, `ID: ${previewTarget.id} · 创建: ${fmtDateTime(previewData?.createdAt)} · ${fmtBytes(previewTarget.sizeBytes)}`), (0, react.createElement)("div", { style: previewBody }, previewLoading ? (0, react.createElement)("p", { style: emptyText }, "加载中…") : previewData && previewData.messages.length > 0 ? previewData.messages.map((m, i) => (0, react.createElement)("div", {
460
+ key: i,
461
+ style: m.role === "user" ? msgUser : msgAssistant
462
+ }, m.text)) : (0, react.createElement)("p", { style: emptyText }, "(该会话暂无文本消息)")), (0, react.createElement)("div", { style: modalActions }, (0, react.createElement)("button", {
463
+ style: btnDanger,
464
+ onClick: () => {
465
+ setPreviewTarget(null);
466
+ handleDelete(previewTarget);
467
+ }
468
+ }, "删除此会话"), (0, react.createElement)("button", {
469
+ style: btnGhost,
470
+ onClick: () => setPreviewTarget(null)
471
+ }, "关闭")))) : null);
472
+ }
473
+ //#endregion
474
+ //#region src/client/config-api.ts
475
+ const PACKAGE = "@lijian-ui/dsh-session-cleaner";
476
+ const SERVICE = "sessionCleanerRemote";
477
+ /** Identity codec — the host manifest owns strict validation. */
478
+ const identity = (value) => value;
479
+ const codec = (symbol) => ({
480
+ mode: "strict",
481
+ typeSymbol: symbol,
482
+ schema: { parse: identity }
483
+ });
484
+ /** Wire descriptors mirroring the host contribution (src/remote.ts). */
485
+ const CONTRIBUTION = {
486
+ package: PACKAGE,
487
+ descriptors: [
488
+ {
489
+ id: `${PACKAGE}#${SERVICE}/listSessions`,
490
+ service: SERVICE,
491
+ namespace: SERVICE,
492
+ method: "listSessions",
493
+ invocation: { kind: "direct" },
494
+ parameters: [],
495
+ result: codec(`${PACKAGE}#SessionList`)
496
+ },
497
+ {
498
+ id: `${PACKAGE}#${SERVICE}/previewSession`,
499
+ service: SERVICE,
500
+ namespace: SERVICE,
501
+ method: "previewSession",
502
+ invocation: { kind: "direct" },
503
+ parameters: [{
504
+ name: "sessionId",
505
+ wire: "sessionId",
506
+ source: "json",
507
+ codec: codec(`${PACKAGE}#SessionId`)
508
+ }, {
509
+ name: "limit",
510
+ wire: "limit",
511
+ source: "json",
512
+ acceptsUndefined: true,
513
+ codec: codec(`${PACKAGE}#Limit`)
514
+ }],
515
+ result: codec(`${PACKAGE}#SessionPreview`)
516
+ },
517
+ {
518
+ id: `${PACKAGE}#${SERVICE}/deleteSession`,
519
+ service: SERVICE,
520
+ namespace: SERVICE,
521
+ method: "deleteSession",
522
+ invocation: { kind: "direct" },
523
+ parameters: [{
524
+ name: "sessionId",
525
+ wire: "sessionId",
526
+ source: "json",
527
+ codec: codec(`${PACKAGE}#SessionId`)
528
+ }],
529
+ result: codec(`${PACKAGE}#DeleteResult`)
530
+ },
531
+ {
532
+ id: `${PACKAGE}#${SERVICE}/archiveSession`,
533
+ service: SERVICE,
534
+ namespace: SERVICE,
535
+ method: "archiveSession",
536
+ invocation: { kind: "direct" },
537
+ parameters: [{
538
+ name: "sessionId",
539
+ wire: "sessionId",
540
+ source: "json",
541
+ codec: codec(`${PACKAGE}#SessionId`)
542
+ }],
543
+ result: codec(`${PACKAGE}#ArchiveResult`)
544
+ },
545
+ {
546
+ id: `${PACKAGE}#${SERVICE}/unarchiveSession`,
547
+ service: SERVICE,
548
+ namespace: SERVICE,
549
+ method: "unarchiveSession",
550
+ invocation: { kind: "direct" },
551
+ parameters: [{
552
+ name: "sessionId",
553
+ wire: "sessionId",
554
+ source: "json",
555
+ codec: codec(`${PACKAGE}#SessionId`)
556
+ }],
557
+ result: codec(`${PACKAGE}#ArchiveResult`)
558
+ }
559
+ ]
560
+ };
561
+ /**
562
+ * Create the session api bound to this browser plugin's context.
563
+ * Mounts the remote contribution once; every call awaits the mount, then
564
+ * invokes the namespace service and unwraps the envelope.
565
+ */
566
+ function createSessionApi(ctx) {
567
+ const mount = ctx.remote.$mount(CONTRIBUTION);
568
+ const call = async (method, ...args) => {
569
+ try {
570
+ await mount;
571
+ const result = await ctx.get(`remote.${SERVICE}`)[method](...args);
572
+ if (!result.ok) return {
573
+ ok: false,
574
+ code: result.error?.code ?? "remote-error",
575
+ message: result.error?.message ?? `${SERVICE}.${method} failed`
576
+ };
577
+ return {
578
+ ok: true,
579
+ value: result.value
580
+ };
581
+ } catch (err) {
582
+ return {
583
+ ok: false,
584
+ code: "remote-error",
585
+ message: err instanceof Error ? err.message : String(err)
586
+ };
587
+ }
588
+ };
589
+ return {
590
+ list: () => call("listSessions"),
591
+ preview: (sessionId) => call("previewSession", sessionId, void 0),
592
+ remove: (sessionId) => call("deleteSession", sessionId),
593
+ archive: (sessionId) => call("archiveSession", sessionId),
594
+ unarchive: (sessionId) => call("unarchiveSession", sessionId)
595
+ };
596
+ }
597
+ //#endregion
598
+ //#region src/client/index.ts
599
+ /** Section identity in the Settings left-nav rail. */
600
+ const SECTION_ID = "session-cleaner";
601
+ /** Sits below Models (10) and IM 通道 (20). */
602
+ const SECTION_ORDER = 30;
603
+ /** Services this browser plugin requires (cordis fiber inject). */
604
+ const inject = [
605
+ "slots",
606
+ "locale",
607
+ "connection",
608
+ "remote"
609
+ ];
610
+ /**
611
+ * Mount the session management settings page.
612
+ * @param ctx - the browser plugin context (guarded facade).
613
+ */
614
+ function apply(ctx) {
615
+ const api = createSessionApi(ctx);
616
+ let officialArchive = null;
617
+ ctx.inject(["workspaces"], (sctx) => {
618
+ officialArchive = async (sessionId) => {
619
+ try {
620
+ return (await sctx.workspaces.archiveSession(sessionId)).ok === true;
621
+ } catch {
622
+ return false;
623
+ }
624
+ };
625
+ });
626
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
627
+ name: "settings.section",
628
+ id: SECTION_ID,
629
+ order: SECTION_ORDER,
630
+ label: () => "会话管理",
631
+ inject: () => ({
632
+ api,
633
+ archiveSession: (sessionId) => officialArchive ? officialArchive(sessionId) : api.archive(sessionId).then((r) => r.ok)
634
+ })
635
+ }, SessionManagerSection));
636
+ }
637
+ //#endregion
638
+ exports.apply = apply;
639
+ exports.inject = inject;
640
+ return module.exports;
641
+ }
642
+ });
643
+
644
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,21 @@
1
+ import Schema from "@deepseek-ai/schemastery";
2
+ import { Context } from "@deepseek-ai/cordis";
3
+
4
+ //#region src/index.d.ts
5
+
6
+ /**
7
+ * Plugin-level dependency declaration: waits for the host services before
8
+ * apply runs (mirrors im-gateway / dsh-skill-viewer).
9
+ */
10
+ declare const inject: string[];
11
+ /** Empty config schema (the settings section needs a registered namespace). */
12
+ declare const Config: Schema<Schemastery.ObjectS<{}>, Schemastery.ObjectT<{}>>;
13
+ /**
14
+ * Session-cleaner bundle. One plugin (`@lijian-ui/dsh-session-cleaner`)
15
+ * provides a "会话管理" settings page + host RPC that lists / deletes /
16
+ * archives / restores dsh sessions on the file layer (official has no
17
+ * delete; archive is hidden-without-entry — this plugin fixes both).
18
+ */
19
+ declare function apply(ctx: Context): void;
20
+ //#endregion
21
+ export { Config, apply, inject };
package/lib/index.js ADDED
@@ -0,0 +1,516 @@
1
+ import Schema from "@deepseek-ai/schemastery";
2
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
+ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import { homedir } from "os";
7
+ import { zstdDecompressSync } from "node:zlib";
8
+ //#region src/session-store.ts
9
+ /**
10
+ * 会话存储层(session-cleaner host half)
11
+ * ------------------------------------------------------------------
12
+ * 直接操作 dsh 官方文件层(无需官方 API):
13
+ *
14
+ * sessions 树: ~/.dsh/sessions/<encoded-workspace>/<sessionId>/session.jsonl.zstd
15
+ * workspace 状态:~/.dsh/storages/workspace.json
16
+ * - global.archivedSessionIds ← 归档状态(与官方 UI 共享)
17
+ * - tables.workspaces[*].sessionIds ← 会话归属
18
+ * projcache: ~/.dsh/storages/session_projcache.json(删除时必须清,否则旧投影残留)
19
+ *
20
+ * 删除 = 三处清理:session 目录 + projcache + workspace.json(sessionIds/archivedSessionIds)。
21
+ * 归档/恢复 = 读写 workspace.json 的 global.archivedSessionIds(单真相源与官方打通)。
22
+ *
23
+ * ⚠️ 已知边界:归档/恢复直接改文件,host 内存态不会热更新——插件页面以文件为准(准确),
24
+ * 官方 UI 的隐藏/恢复在 dsh 重启后同步。删除同理(已删除的会话从文件层消失,官方 UI
25
+ * 重启后不再列出)。
26
+ */
27
+ /** dsh 用户数据目录(与桌面壳 buildDshEnv 的 DSH_HOME 保持一致) */
28
+ function dshHome() {
29
+ return process.env.DSH_HOME ?? path.join(homedir(), ".dsh");
30
+ }
31
+ function sessionsRoot() {
32
+ return path.join(dshHome(), "sessions");
33
+ }
34
+ function workspaceFile() {
35
+ return path.join(dshHome(), "storages", "workspace.json");
36
+ }
37
+ function projcacheFile() {
38
+ return path.join(dshHome(), "storages", "session_projcache.json");
39
+ }
40
+ /** 原子写 JSON(先写 .tmp 再 rename,避免半写损坏) */
41
+ function writeJsonAtomic(file, data) {
42
+ const tmp = `${file}.tmp`;
43
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
44
+ fs.renameSync(tmp, file);
45
+ }
46
+ /** 剔除值为 undefined 的字段(typert 边界校验拒绝非 JSON-safe 值) */
47
+ function stripUndefined(obj) {
48
+ const out = {};
49
+ for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
50
+ return out;
51
+ }
52
+ /** 读 JSON(缺失/损坏返回默认值,不抛错) */
53
+ function readJson(file, fallback) {
54
+ try {
55
+ if (!fs.existsSync(file)) return fallback;
56
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
57
+ } catch {
58
+ return fallback;
59
+ }
60
+ }
61
+ /** 读 workspace 状态 */
62
+ function readWorkspace() {
63
+ return readJson(workspaceFile(), {});
64
+ }
65
+ /** 读 archivedSessionIds 集合 */
66
+ function readArchived() {
67
+ const ws = readWorkspace();
68
+ return new Set(ws.global?.archivedSessionIds ?? []);
69
+ }
70
+ /** 写 archivedSessionIds 集合(保留其他字段原样) */
71
+ function writeArchived(archived) {
72
+ const ws = readWorkspace();
73
+ ws.global = {
74
+ ...ws.global ?? {},
75
+ archivedSessionIds: [...archived]
76
+ };
77
+ writeJsonAtomic(workspaceFile(), ws);
78
+ }
79
+ /**
80
+ * 扫描全部 session(文件层权威)。
81
+ * 遍历 ~/.dsh/sessions/<workspace>/<sessionId>/,结合 workspace.json 标注状态。
82
+ */
83
+ function listSessions() {
84
+ const root = sessionsRoot();
85
+ const out = [];
86
+ if (!fs.existsSync(root)) return out;
87
+ const archived = readArchived();
88
+ const ws = readWorkspace();
89
+ const activeIds = /* @__PURE__ */ new Set();
90
+ for (const w of Object.values(ws.tables?.workspaces ?? {})) for (const id of w.sessionIds ?? []) activeIds.add(id);
91
+ for (const workspaceDir of fs.readdirSync(root, { withFileTypes: true })) {
92
+ if (!workspaceDir.isDirectory()) continue;
93
+ const wsPath = path.join(root, workspaceDir.name);
94
+ for (const entry of fs.readdirSync(wsPath, { withFileTypes: true })) {
95
+ if (!entry.isDirectory()) continue;
96
+ const dir = path.join(wsPath, entry.name);
97
+ const logFile = path.join(dir, "session.jsonl.zstd");
98
+ if (!fs.existsSync(logFile)) continue;
99
+ let sizeBytes = 0;
100
+ let mtimeMs = 0;
101
+ try {
102
+ const st = fs.statSync(logFile);
103
+ sizeBytes = st.size;
104
+ mtimeMs = st.mtimeMs;
105
+ } catch {
106
+ continue;
107
+ }
108
+ out.push(stripUndefined({
109
+ id: entry.name,
110
+ workspaceDir: workspaceDir.name,
111
+ dir,
112
+ sizeBytes,
113
+ mtimeMs,
114
+ archived: archived.has(entry.name),
115
+ active: activeIds.has(entry.name),
116
+ title: readSessionTitle(logFile)
117
+ }));
118
+ }
119
+ }
120
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
121
+ return out;
122
+ }
123
+ /** 从 workspace.json 的 sessionIds 里移除一个 id(若存在) */
124
+ function removeFromWorkspaceSessionIds(id) {
125
+ const ws = readWorkspace();
126
+ let changed = false;
127
+ for (const w of Object.values(ws.tables?.workspaces ?? {})) if (w.sessionIds && w.sessionIds.includes(id)) {
128
+ w.sessionIds = w.sessionIds.filter((x) => x !== id);
129
+ changed = true;
130
+ }
131
+ if (changed) writeJsonAtomic(workspaceFile(), ws);
132
+ }
133
+ /** 从 projcache 里移除一个 session 的投影(嵌套结构,深度清理) */
134
+ function removeFromProjcache(id) {
135
+ const file = projcacheFile();
136
+ if (!fs.existsSync(file)) return;
137
+ const data = readJson(file, null);
138
+ if (data === null) return;
139
+ const walk = (o) => {
140
+ if (!o || typeof o !== "object") return false;
141
+ let changed = false;
142
+ for (const k of Object.keys(o)) {
143
+ if (String(k).includes(id)) {
144
+ delete o[k];
145
+ changed = true;
146
+ continue;
147
+ }
148
+ if (walk(o[k])) changed = true;
149
+ }
150
+ return changed;
151
+ };
152
+ if (walk(data)) writeJsonAtomic(file, data);
153
+ }
154
+ /**
155
+ * 真删除会话(不可恢复,调用方必须已二次确认):
156
+ * ① 删会话目录;② 清 projcache;③ 清 workspace.json(sessionIds + archivedSessionIds)。
157
+ * 返回已删除的目录路径。
158
+ */
159
+ function deleteSession(id) {
160
+ const root = sessionsRoot();
161
+ let target = null;
162
+ if (fs.existsSync(root)) for (const workspaceDir of fs.readdirSync(root, { withFileTypes: true })) {
163
+ if (!workspaceDir.isDirectory()) continue;
164
+ const dir = path.join(root, workspaceDir.name, id);
165
+ if (fs.existsSync(path.join(dir, "session.jsonl.zstd"))) {
166
+ target = dir;
167
+ break;
168
+ }
169
+ }
170
+ if (!target) throw new Error(`session ${id} 不存在(文件层未找到)`);
171
+ fs.rmSync(target, {
172
+ recursive: true,
173
+ force: true
174
+ });
175
+ removeFromProjcache(id);
176
+ removeFromWorkspaceSessionIds(id);
177
+ const archived = readArchived();
178
+ if (archived.has(id)) {
179
+ archived.delete(id);
180
+ writeArchived(archived);
181
+ }
182
+ return {
183
+ ok: true,
184
+ removedDir: target
185
+ };
186
+ }
187
+ /** 归档会话(写入官方 archivedSessionIds 集合,官方 UI 重启后同步隐藏) */
188
+ function archiveSession(id) {
189
+ const archived = readArchived();
190
+ archived.add(id);
191
+ writeArchived(archived);
192
+ return { ok: true };
193
+ }
194
+ /** 恢复归档会话(从官方 archivedSessionIds 集合移除) */
195
+ function unarchiveSession(id) {
196
+ const archived = readArchived();
197
+ if (archived.delete(id)) writeArchived(archived);
198
+ return { ok: true };
199
+ }
200
+ /**
201
+ * 多帧 zstd 解码:dsh 的 session 日志每次追加写一个独立 zstd 帧
202
+ * (文件里多个 magic 头),Node 的 zstdDecompressSync 只解第一帧——
203
+ * 必须按 magic 位置逐帧解压拼接。
204
+ */
205
+ function zstdDecompressAll(input) {
206
+ const magic = Buffer.from([
207
+ 40,
208
+ 181,
209
+ 47,
210
+ 253
211
+ ]);
212
+ const chunks = [];
213
+ let start = 0;
214
+ while (start < input.length) {
215
+ const idx = input.indexOf(magic, start);
216
+ if (idx === -1) break;
217
+ const next = input.indexOf(magic, idx + 4);
218
+ const end = next === -1 ? input.length : next;
219
+ try {
220
+ chunks.push(zstdDecompressSync(input.subarray(idx, end)));
221
+ } catch {}
222
+ start = end;
223
+ }
224
+ if (chunks.length === 0) return zstdDecompressSync(input);
225
+ return Buffer.concat(chunks);
226
+ }
227
+ /** 解压 session.jsonl.zstd 返回文本行(失败返回 null) */
228
+ function readSessionLines(logFile) {
229
+ try {
230
+ if (!fs.existsSync(logFile)) return null;
231
+ return zstdDecompressAll(fs.readFileSync(logFile)).toString("utf8").split("\n").filter(Boolean);
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+ /** 从日志行提取最后一个 session/title 事件的标题 */
237
+ function extractTitle(lines) {
238
+ let title;
239
+ for (const line of lines) {
240
+ if (!line.includes("session/title")) continue;
241
+ try {
242
+ const event = JSON.parse(line);
243
+ if (event.type === "session/title" && event.data?.title) title = event.data.title;
244
+ } catch {}
245
+ }
246
+ return title;
247
+ }
248
+ /** 从日志行提取 header(第一行 session 元数据) */
249
+ function extractHeader(lines) {
250
+ try {
251
+ const header = JSON.parse(lines[0] ?? "{}");
252
+ if (header.type !== "session") return void 0;
253
+ return {
254
+ createdAt: header.createdAt,
255
+ cwd: header.cwd
256
+ };
257
+ } catch {
258
+ return;
259
+ }
260
+ }
261
+ /** 从消息事件提取纯文本(宽容解析各种 content 形态) */
262
+ function messageText(message) {
263
+ if (typeof message === "string") return message;
264
+ if (!message || typeof message !== "object") return "";
265
+ const content = message["content"];
266
+ if (typeof content === "string") return content;
267
+ if (Array.isArray(content)) return content.map((part) => {
268
+ if (typeof part === "string") return part;
269
+ if (part && typeof part === "object") {
270
+ const p = part;
271
+ if (p["type"] === "text" && typeof p["text"] === "string") return p["text"];
272
+ if (typeof p["text"] === "string") return p["text"];
273
+ }
274
+ return "";
275
+ }).filter(Boolean).join("\n");
276
+ return "";
277
+ }
278
+ /** 截断到 ~max 字符(保留首部) */
279
+ function truncate(text, max = 200) {
280
+ if (text.length <= max) return text;
281
+ return text.slice(0, max) + "…";
282
+ }
283
+ /** 读取会话标题(不存在返回 undefined) */
284
+ function readSessionTitle(logFile) {
285
+ const lines = readSessionLines(logFile);
286
+ if (!lines) return void 0;
287
+ return extractTitle(lines);
288
+ }
289
+ /**
290
+ * 读取会话预览:header 元数据 + 最近 N 条用户/助手消息文本。
291
+ * 找不到文件抛错(调用方转 RPC 错误)。
292
+ */
293
+ function previewSession(id, limit = 12) {
294
+ const root = sessionsRoot();
295
+ let logFile = null;
296
+ if (fs.existsSync(root)) for (const workspaceDir of fs.readdirSync(root, { withFileTypes: true })) {
297
+ if (!workspaceDir.isDirectory()) continue;
298
+ const candidate = path.join(root, workspaceDir.name, id, "session.jsonl.zstd");
299
+ if (fs.existsSync(candidate)) {
300
+ logFile = candidate;
301
+ break;
302
+ }
303
+ }
304
+ if (!logFile) throw new Error(`session ${id} 不存在(文件层未找到)`);
305
+ const lines = readSessionLines(logFile);
306
+ if (!lines) throw new Error(`session ${id} 日志读取失败(可能损坏)`);
307
+ const header = extractHeader(lines);
308
+ const messages = [];
309
+ for (const line of lines.slice(1)) try {
310
+ const event = JSON.parse(line);
311
+ if (event.type === "user/message") {
312
+ const text = messageText(event.data);
313
+ if (text.trim()) messages.push({
314
+ role: "user",
315
+ text: truncate(text)
316
+ });
317
+ } else if (event.type === "assistant/message") {
318
+ const data = event.data;
319
+ const text = messageText(data?.message);
320
+ if (text.trim()) messages.push({
321
+ role: "assistant",
322
+ text: truncate(text)
323
+ });
324
+ }
325
+ } catch {}
326
+ return stripUndefined({
327
+ id,
328
+ title: extractTitle(lines),
329
+ createdAt: header?.createdAt,
330
+ cwd: header?.cwd,
331
+ messages: messages.slice(-limit)
332
+ });
333
+ }
334
+ //#endregion
335
+ //#region src/remote.ts
336
+ const SERVICE = "sessionCleanerRemote";
337
+ const PACKAGE = "@lijian-ui/dsh-session-cleaner";
338
+ const codec = (typeSymbol) => ({
339
+ mode: "strict",
340
+ typeSymbol,
341
+ schema: { parse: (v) => v }
342
+ });
343
+ /**
344
+ * 注册到 `ctx.typert` 的 host 面契约。
345
+ * face: 'host' + invocations(client 侧的 $mount 贡献则带 descriptors)。
346
+ */
347
+ const CONTRIBUTION = {
348
+ package: PACKAGE,
349
+ face: "host",
350
+ schemas: [],
351
+ invocations: [
352
+ {
353
+ id: `${PACKAGE}#${SERVICE}/listSessions`,
354
+ service: SERVICE,
355
+ namespace: SERVICE,
356
+ method: "listSessions",
357
+ invocation: { kind: "direct" },
358
+ parameters: [],
359
+ result: codec(`${PACKAGE}#SessionList`)
360
+ },
361
+ {
362
+ id: `${PACKAGE}#${SERVICE}/previewSession`,
363
+ service: SERVICE,
364
+ namespace: SERVICE,
365
+ method: "previewSession",
366
+ invocation: { kind: "direct" },
367
+ parameters: [{
368
+ name: "sessionId",
369
+ wire: "sessionId",
370
+ source: "json",
371
+ codec: codec(`${PACKAGE}#SessionId`)
372
+ }, {
373
+ name: "limit",
374
+ wire: "limit",
375
+ source: "json",
376
+ acceptsUndefined: true,
377
+ codec: codec(`${PACKAGE}#Limit`)
378
+ }],
379
+ result: codec(`${PACKAGE}#SessionPreview`)
380
+ },
381
+ {
382
+ id: `${PACKAGE}#${SERVICE}/deleteSession`,
383
+ service: SERVICE,
384
+ namespace: SERVICE,
385
+ method: "deleteSession",
386
+ invocation: { kind: "direct" },
387
+ parameters: [{
388
+ name: "sessionId",
389
+ wire: "sessionId",
390
+ source: "json",
391
+ codec: codec(`${PACKAGE}#SessionId`)
392
+ }],
393
+ result: codec(`${PACKAGE}#DeleteResult`)
394
+ },
395
+ {
396
+ id: `${PACKAGE}#${SERVICE}/archiveSession`,
397
+ service: SERVICE,
398
+ namespace: SERVICE,
399
+ method: "archiveSession",
400
+ invocation: { kind: "direct" },
401
+ parameters: [{
402
+ name: "sessionId",
403
+ wire: "sessionId",
404
+ source: "json",
405
+ codec: codec(`${PACKAGE}#SessionId`)
406
+ }],
407
+ result: codec(`${PACKAGE}#ArchiveResult`)
408
+ },
409
+ {
410
+ id: `${PACKAGE}#${SERVICE}/unarchiveSession`,
411
+ service: SERVICE,
412
+ namespace: SERVICE,
413
+ method: "unarchiveSession",
414
+ invocation: { kind: "direct" },
415
+ parameters: [{
416
+ name: "sessionId",
417
+ wire: "sessionId",
418
+ source: "json",
419
+ codec: codec(`${PACKAGE}#SessionId`)
420
+ }],
421
+ result: codec(`${PACKAGE}#ArchiveResult`)
422
+ }
423
+ ],
424
+ model: {
425
+ services: [],
426
+ events: [],
427
+ objects: []
428
+ }
429
+ };
430
+ /**
431
+ * Remote 服务实现。构造时注册 `sessionCleanerRemote` cordis 服务,
432
+ * 上面的贡献让 API gateway 能分发四个方法。
433
+ */
434
+ var SessionCleanerApi = class extends TypertRemoteService {
435
+ constructor(ctx) {
436
+ super(ctx, SERVICE);
437
+ }
438
+ /** 全部会话摘要(文件层扫描 + 归档/归属状态) */
439
+ listSessions() {
440
+ return listSessions();
441
+ }
442
+ /** 会话预览:标题 + 最近消息文本 */
443
+ previewSession(sessionId, limit) {
444
+ return previewSession(sessionId, limit);
445
+ }
446
+ /** 真删除(调用方必须已二次确认) */
447
+ deleteSession(sessionId) {
448
+ return deleteSession(sessionId);
449
+ }
450
+ /** 归档(写入官方 archivedSessionIds 集合) */
451
+ archiveSession(sessionId) {
452
+ return archiveSession(sessionId);
453
+ }
454
+ /** 恢复归档 */
455
+ unarchiveSession(sessionId) {
456
+ return unarchiveSession(sessionId);
457
+ }
458
+ };
459
+ /** 注册 remote 服务 + typert 契约(对齐 im-gateway:effect 内取 typert 并 register) */
460
+ function registerSessionCleanerRemote(ctx) {
461
+ new SessionCleanerApi(ctx);
462
+ ctx.effect(() => ctx.get("typert").register(CONTRIBUTION), "session-cleaner: typert contribution");
463
+ }
464
+ //#endregion
465
+ //#region src/index.ts
466
+ /**
467
+ * Plugin-level dependency declaration: waits for the host services before
468
+ * apply runs (mirrors im-gateway / dsh-skill-viewer).
469
+ */
470
+ const inject = ["typert", "settings"];
471
+ /** Settings namespace: no editable config — the page reads via Remote RPC. */
472
+ const NS = settingsNamespace("session-cleaner");
473
+ /** Empty config schema (the settings section needs a registered namespace). */
474
+ const Config = Schema.object({});
475
+ /**
476
+ * Session-cleaner bundle. One plugin (`@lijian-ui/dsh-session-cleaner`)
477
+ * provides a "会话管理" settings page + host RPC that lists / deletes /
478
+ * archives / restores dsh sessions on the file layer (official has no
479
+ * delete; archive is hidden-without-entry — this plugin fixes both).
480
+ */
481
+ function apply(ctx) {
482
+ installConsoleLoggerExporter(ctx);
483
+ installSettingsSection(ctx, NS, Config, {}, {
484
+ setSource: () => {},
485
+ onChange: () => {}
486
+ });
487
+ registerSessionCleanerRemote(ctx);
488
+ }
489
+ /**
490
+ * Route every cordis logger message to the native console (dsh stderr →
491
+ * desktop shell "[dsh] " prefix). Without this, ctx.logger.* lands in
492
+ * cordis's in-memory buffer only and is invisible while debugging.
493
+ */
494
+ function installConsoleLoggerExporter(ctx) {
495
+ try {
496
+ ctx.root.logger?.exporter?.({
497
+ colors: 0,
498
+ export: (message) => {
499
+ const { name = "session-cleaner", type = "log", args = [] } = message;
500
+ const line = `[${name}] ${args.map((arg) => arg instanceof Error ? arg.stack ?? arg.message : typeof arg === "string" ? arg : (() => {
501
+ try {
502
+ return JSON.stringify(arg);
503
+ } catch {
504
+ return String(arg);
505
+ }
506
+ })()).join(" ")}`;
507
+ if (type === "error") console.error(line);
508
+ else if (type === "warn") console.warn(line);
509
+ else if (type === "debug") console.debug(line);
510
+ else console.info(line);
511
+ }
512
+ });
513
+ } catch {}
514
+ }
515
+ //#endregion
516
+ export { Config, apply, inject };
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@lijian-ui/dsh-session-cleaner",
3
+ "version": "0.1.0",
4
+ "description": "Session management plugin for DeepSeek Harness (dsh): list / delete / archive & restore sessions, with a built-in \"Archived\" group and a delete confirm dialog (real delete, not trash). 为 DeepSeek Harness 提供会话管理:列表 / 真删除(二次确认)/ 归档分组与恢复。",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "types": "lib/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/index.d.ts",
12
+ "default": "./lib/index.js"
13
+ },
14
+ "./client": {
15
+ "default": "./lib/client.js"
16
+ },
17
+ "./cordis.patch.yml": "./cordis.patch.yml",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "lib/**/*.js",
22
+ "lib/**/*.d.ts",
23
+ "cordis.patch.yml",
24
+ "README.md",
25
+ "README.en.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsdown",
30
+ "watch": "tsdown --watch",
31
+ "typecheck": "tsc --noEmit",
32
+ "prepare": "npm run build"
33
+ },
34
+ "keywords": [
35
+ "deepseek-harness",
36
+ "dsh",
37
+ "dsh-plugin",
38
+ "session",
39
+ "session-cleaner",
40
+ "archive",
41
+ "delete",
42
+ "cordis"
43
+ ],
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/lijian-ui/dsh-session-cleaner.git"
47
+ },
48
+ "dsh": {
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ },
52
+ "client": {
53
+ "inject": [
54
+ "@deepseek-ai/dsh-client-runtime",
55
+ "@deepseek-ai/dsh-client-locale",
56
+ "@deepseek-ai/dsh-api-gateway"
57
+ ],
58
+ "platform": "web",
59
+ "immediately": true
60
+ }
61
+ },
62
+ "peerDependencies": {
63
+ "@deepseek-ai/cordis": "*",
64
+ "@deepseek-ai/schemastery": "*",
65
+ "@deepseek-ai/dsh-settings": "*",
66
+ "@deepseek-ai/dsh-typert-protocol": "*"
67
+ },
68
+ "devDependencies": {
69
+ "@deepseek-ai/cordis": "4.0.1",
70
+ "@deepseek-ai/schemastery": "3.18.1",
71
+ "@deepseek-ai/dsh-settings": "0.1.0-rc.6",
72
+ "@deepseek-ai/dsh-typert-protocol": "0.1.0-rc.6",
73
+ "@deepseek-ai/dsh-client-connection": "0.1.0-rc.6",
74
+ "@deepseek-ai/dsh-client-locale": "0.1.0-rc.6",
75
+ "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6",
76
+ "@deepseek-ai/dsh-client-schema-form": "0.1.0-rc.6",
77
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6",
78
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.6",
79
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "0.1.0-rc.6",
80
+ "@deepseek-ai/dsh-api-gateway": "0.1.0-rc.6",
81
+ "@types/node": "^22.0.0",
82
+ "@types/react": "^18.3.0",
83
+ "react": "^18.2.0",
84
+ "tsdown": "^0.12.0",
85
+ "typescript": "^5.6.0"
86
+ }
87
+ }