@wangxiaocuo/dsh-session-trash 0.6.2 → 0.6.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,85 @@
1
+ # Changelog
2
+
3
+ 本文件记录这个插件的所有重要变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
4
+
5
+ ## [0.6.3] - 2026-11-11
6
+
7
+ ### Added
8
+
9
+ - `/state` 的条目新增稳定的 `lastErrorCode`,页面据此把失败原因本地化;`lastError` 保留英文原始诊断,只用作 tooltip。
10
+ - 新增本 CHANGELOG,并把它包含在 npm 包里。
11
+
12
+ ### Changed
13
+
14
+ - 中文界面不再直接显示 Host 的英文诊断;「在 Finder 中显示」改为平台中性的「在文件管理器中显示」。
15
+ - 英文界面使用正确的单复数(会话数、子会话数、清理/删除数量)。
16
+ - 清理结果区分部分失败与全部失败,失败时使用失败 Toast 并给出数量。
17
+ - 到期倒计时每分钟刷新;条目的数据变少时页码自动收敛。
18
+ - 生成 `/state` 时,同一原始工作区只探测一次,而不是每个条目各探测一次。
19
+ - 未知大小显示为 `—`,不再显示 `0 B`。
20
+
21
+ ### Fixed
22
+
23
+ - 恢复、永久删除、从索引移除或显示工作区成功后,清除该行残留的旧错误提示。
24
+ - 跨设备复制后源目录删除失败时,HTTP 错误不再返回源路径/回收站绝对路径;错误 `details` 只暴露白名单字段(`code`、`phase`、`sessionId`、`metrics` 等)。
25
+ - 跨设备复制校验现在比较符号链接数量,能发现复制过程中被静默丢弃的符号链接。
26
+
27
+ ### Tests
28
+
29
+ - 新增 wire 错误脱敏、`lastErrorCode` 映射、工作区探测去重、清理部分失败、行错误清除、未知大小和符号链接校验的回归测试。
30
+
31
+ ## [0.6.2] - 2026-11-11
32
+
33
+ ### Fixed
34
+
35
+ - 客户端写操作串行化:同一时间只允许一个写请求,行级 pending 状态独立清理,程序化重复点击会被忽略。
36
+ - 写请求 30 秒、`/state` 15 秒硬超时;设置页卸载时取消在途请求,超时显示本地化文案。
37
+ - 后台刷新不再禁用行操作;`ActionMenu` 在变为禁用时自动关闭。
38
+
39
+ ### Tests / CI
40
+
41
+ - 测试入口改为跨平台的 `test/run-unit.mjs`(不依赖 shell glob)。
42
+ - CI 增加 Windows(Node 22)矩阵,并把单元测试失败输出转成 check annotations。
43
+ - e2e 支持跨平台发现桌面版,Windows 上跳过依赖 POSIX 权限位的清理失败场景。
44
+
45
+ ### Docs
46
+
47
+ - README 用可核对的验证矩阵替代原先无法验证的跨平台表述。
48
+
49
+ ## [0.6.1] - 2026-11-11
50
+
51
+ ### Fixed
52
+
53
+ - Host 冒烟测试改为普通 Node 可运行:索引版本断言改为读取 `INDEX_VERSION`,不再硬编码;Cordis 来自 `@deepseek-ai/cordis` devDependency;自己创建并清理临时 `DSH_HOME`。
54
+
55
+ ### Performance
56
+
57
+ - 批量删除/清理改为批量提交索引:200 条目的永久删除从 400 次索引提交、约 70 MB 写入降到 2 次提交、约 0.4 MB。
58
+ - 启动恢复复用 reconcile 的探测结果,并对成员目录并行探测;单个条目变化时只重写它自己的 `meta.json`。
59
+
60
+ ### Added
61
+
62
+ - GitHub Actions CI(单元测试、冒烟测试、覆盖率、打包内容检查)。
63
+
64
+ ## [0.6.0] - 2026-11-09
65
+
66
+ ### Added
67
+
68
+ - 隐藏的 subagent 子会话随父会话一起移入回收站、一起恢复;含子会话的条目显示数量徽章。
69
+ - 生成组合包并以 `@wangxiaocuo/dsh-session-trash` 发布。
70
+
71
+ ### Changed
72
+
73
+ - **BREAKING**:索引 schema 升级为 v2(条目携带 `members`);旧版本插件无法读取 v2 索引,会进入保护模式。降级前请先清空回收站。
74
+
75
+ ## [0.5.1] - 2026-11-09
76
+
77
+ ### Added
78
+
79
+ - 回收站工具栏的「永久删除全部」与二次确认;`missing` 记录只移出索引,不删除会话文件。
80
+
81
+ [0.6.3]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.2...v0.6.3
82
+ [0.6.2]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.1...v0.6.2
83
+ [0.6.1]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.0...v0.6.1
84
+ [0.6.0]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.5.1...v0.6.0
85
+ [0.5.1]: https://github.com/wangxiaocuo/dsh-session-trash/releases/tag/v0.5.1
package/README.en.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [简体中文](./README.md) | English
4
4
 
5
- [GitHub](https://github.com/wangxiaocuo/dsh-session-trash) · [npm](https://www.npmjs.com/package/@wangxiaocuo/dsh-session-trash)
5
+ [GitHub](https://github.com/wangxiaocuo/dsh-session-trash) · [npm](https://www.npmjs.com/package/@wangxiaocuo/dsh-session-trash) · [Changelog](./CHANGELOG.md)
6
6
 
7
7
  A session trash for DeepSeek Harness. Move cold sessions out of the sidebar without deleting their logs, restore them within the retention window, or delete the logs permanently. Built for DSH 0.2.2-rc.1 on Desktop and Web. Host and Client ship in one package, with no build step.
8
8
 
@@ -132,7 +132,7 @@ Restore and maintenance refusals include `TRASH_ENTRY_NOT_FOUND`, `TRASH_STATE_I
132
132
 
133
133
  `/state.stats.cleanup` reports the last cleanup attempt, the next sweep, a pending retry, consecutive failures, and the last run summary.
134
134
 
135
- `/state` entries gain `subagentCount` (hidden children moved along) and `members` (parent first, each with `sessionId`, `origin`, and `sizeBytes`); `sizeBytes`/`fileCount` cover the whole tree. The `entry` returned by `/trash` uses the same shape.
135
+ `/state` entries gain `subagentCount` (hidden children moved along) and `members` (parent first, each with `sessionId`, `origin`, and `sizeBytes`); `sizeBytes`/`fileCount` cover the whole tree. The `entry` returned by `/trash` uses the same shape. Entries also carry `lastErrorCode` (a stable category the page localizes) and `lastError` (the raw English diagnostic, used only as a tooltip).
136
136
 
137
137
  ## Storage and recovery
138
138
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  简体中文 | [English](./README.en.md)
4
4
 
5
- [GitHub](https://github.com/wangxiaocuo/dsh-session-trash) · [npm](https://www.npmjs.com/package/@wangxiaocuo/dsh-session-trash)
5
+ [GitHub](https://github.com/wangxiaocuo/dsh-session-trash) · [npm](https://www.npmjs.com/package/@wangxiaocuo/dsh-session-trash) · [更新记录](./CHANGELOG.md)
6
6
 
7
7
  DeepSeek Harness 的会话回收站插件。冷会话可以从侧边栏移出,日志保留在本地;保留期内可以恢复,到期后自动删除日志。面向 DSH 0.2.2-rc.1 的 Desktop 和 Web,Host 和 Client 在同一个包里,不需要构建步骤。
8
8
 
@@ -131,7 +131,7 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
131
131
 
132
132
  `/state` 的 `stats.cleanup` 记录上次清理时间、下次清理时间、重试时间、连续失败次数和上次运行摘要。
133
133
 
134
- `/state` 的条目新增 `subagentCount`(一并移入的隐藏子会话数)和 `members`(父会话在前,含 `sessionId`、`origin`、`sizeBytes`);`sizeBytes`/`fileCount` 是整棵树的总量。`/trash` 返回的 `entry` 使用同一形状。
134
+ `/state` 的条目新增 `subagentCount`(一并移入的隐藏子会话数)和 `members`(父会话在前,含 `sessionId`、`origin`、`sizeBytes`);`sizeBytes`/`fileCount` 是整棵树的总量。`/trash` 返回的 `entry` 使用同一形状。条目还带 `lastErrorCode`(稳定的失败分类,供页面本地化)和 `lastError`(英文原始诊断,只作 tooltip)。
135
135
 
136
136
  ## 数据与恢复
137
137
 
package/client.js CHANGED
@@ -32,6 +32,19 @@ window.__ModuleLoader__.load({
32
32
  * Preflight refusals the user can act on themselves. Those keep a visible
33
33
  * menu entry that explains the refusal instead of hiding it.
34
34
  */
35
+ /** Host `lastErrorCode` values mapped to owner-facing messages. */
36
+ const LAST_ERROR_KEYS = {
37
+ TRASH_COPY_MISSING: 'lastErrorTrashCopyMissing',
38
+ TRASH_COPY_MISSING_MEMBERS: 'lastErrorTrashCopyMissingMembers',
39
+ TRASH_OBJECT_MISSING: 'lastErrorTrashObjectMissing',
40
+ PURGE_FAILED: 'lastErrorPurgeFailed',
41
+ RESTORE_INVISIBLE: 'lastErrorRestoreInvisible',
42
+ MOVE_UNSETTLED: 'lastErrorMoveUnsettled',
43
+ SOURCE_CONFLICT: 'lastErrorSourceConflict',
44
+ INDEX_WRITE_FAILED: 'lastErrorIndexWriteFailed',
45
+ IO_ERROR: 'lastErrorIo',
46
+ }
47
+
35
48
  const MOVE_BLOCKED_KEYS = {
36
49
  SESSION_LIVE: { label: 'moveBlockedLive', toast: 'blockedLiveToast' },
37
50
  SESSION_ACTIVE: { label: 'moveBlockedActive', toast: 'blockedActiveToast' },
@@ -77,7 +90,8 @@ window.__ModuleLoader__.load({
77
90
  retry: '重试',
78
91
  cleanupExpired: '清理已过期',
79
92
  cleaning: '正在清理…',
80
- statsCount: '{count} 个会话',
93
+ statsCountOne: '{count} 个会话',
94
+ statsCountOther: '{count} 个会话',
81
95
  statsSize: '占用 {size}',
82
96
  searchLabel: '搜索回收站',
83
97
  searchPlaceholder: '搜索标题、Session ID 或原始工作区',
@@ -127,7 +141,8 @@ window.__ModuleLoader__.load({
127
141
  badgeMigration: '需要迁移',
128
142
  badgeExpiring: '即将到期',
129
143
  badgeExpired: '已过期',
130
- badgeSubagents: '含 {count} 个子会话',
144
+ badgeSubagentsOne: '含 {count} 个子会话',
145
+ badgeSubagentsOther: '含 {count} 个子会话',
131
146
  stateTrashing: '正在移入…',
132
147
  stateRestoring: '正在恢复…',
133
148
  statePurging: '正在删除…',
@@ -135,7 +150,7 @@ window.__ModuleLoader__.load({
135
150
  actionRestoreFor: '恢复「{title}」',
136
151
  actionMoreFor: '「{title}」的更多操作',
137
152
  actionRestoreOrphan: '恢复为孤儿会话',
138
- actionReveal: '在 Finder 中显示',
153
+ actionReveal: '在文件管理器中显示',
139
154
  actionRevealDisabled: '原工作区已不存在',
140
155
  actionCopyId: '复制 Session ID',
141
156
  actionForget: '从索引移除',
@@ -152,23 +167,37 @@ window.__ModuleLoader__.load({
152
167
  purgeAllTitle: '永久删除回收站中的全部 {count} 个会话?',
153
168
  purgeAllBody: '此操作不可撤销。附件、图片和搜索索引等衍生物不会被删除。',
154
169
  purgeAllConfirm: '全部永久删除',
155
- purgeAllDone: '已永久删除 {count} 个会话',
170
+ purgeAllDoneOne: '已永久删除 {count} 个会话',
171
+ purgeAllDoneOther: '已永久删除 {count} 个会话',
156
172
  purgeAllNone: '回收站中没有可删除的会话',
157
- purgeAllPartial: '已永久删除 {removed} 个会话,{failed} 个未能删除',
173
+ purgeAllPartial: '批量删除完成:成功 {removed} 个,失败 {failed} 个。',
158
174
  forgetTitle: '从回收站列表移除这条缺失记录?',
159
175
  forgetBody: '不会删除任何文件;仅移除索引中的这条记录。',
160
176
  forgetConfirm: '从索引移除',
161
- cleanupDone: '已清理 {count} 个过期会话',
177
+ cleanupDoneOne: '已清理 {count} 个过期会话',
178
+ cleanupDoneOther: '已清理 {count} 个过期会话',
162
179
  cleanupNone: '没有需要清理的过期会话',
163
- cleanupFailedBanner: '上次自动清理有 {count} 个会话未能删除。',
164
- cleanupErrorBanner: '上次自动清理失败:{message}',
180
+ cleanupFailedBannerOne: '上次自动清理有 {count} 个会话未能删除。',
181
+ cleanupFailedBannerOther: '上次自动清理有 {count} 个会话未能删除。',
182
+ cleanupErrorBanner: '上次自动清理失败,请稍后在本页重试。',
183
+ cleanupFailed: '清理失败:还有 {failed} 个过期会话没有删除。',
184
+ cleanupPartial: '已清理 {purged} 个过期会话,{failed} 个没有删除。',
165
185
  cleanupRetryAt: '将在 {time} 自动重试。',
166
186
  cleanupRetryNow: '立即重试',
167
187
  copySuccess: '已复制 Session ID',
168
188
  copyFailed: '无法复制 Session ID,请手动复制',
169
- revealSuccess: '已在 Finder 中显示原工作区',
189
+ revealSuccess: '已在文件管理器中显示原工作区',
170
190
  revealFailed: '无法打开原工作区',
171
191
  lastErrorLabel: '上次错误:{message}',
192
+ lastErrorTrashCopyMissing: '回收站里的会话副本缺失。',
193
+ lastErrorTrashCopyMissingMembers: '回收站里的部分子会话副本缺失。',
194
+ lastErrorTrashObjectMissing: '回收站对象目录缺失。',
195
+ lastErrorPurgeFailed: '会话日志删除失败。',
196
+ lastErrorRestoreInvisible: '恢复后会话存储没有识别到该会话。',
197
+ lastErrorMoveUnsettled: '会话已移动,但会话存储仍报告它存在。',
198
+ lastErrorSourceConflict: '原会话目录与回收站副本同时存在,两者都已保留。',
199
+ lastErrorIndexWriteFailed: '回收站索引写入失败。',
200
+ lastErrorIo: '文件操作失败。',
172
201
  footnote: '回收站目录:{path}',
173
202
  move: '移入回收站',
174
203
  moveBlockedLive: '移入回收站(会话已打开)',
@@ -177,7 +206,8 @@ window.__ModuleLoader__.load({
177
206
  undo: '撤销',
178
207
  undoFailed: '撤销失败',
179
208
  trashSuccess: '已将「{title}」移入回收站',
180
- trashSuccessTree: '「{title}」及其 {count} 个子会话已移入回收站',
209
+ trashSuccessTreeOne: '「{title}」及其 {count} 个子会话已移入回收站',
210
+ trashSuccessTreeOther: '「{title}」及其 {count} 个子会话已移入回收站',
181
211
  blockedLiveToast: '该会话已在本机打开。DSH 目前不会释放已打开的会话,请重启 DSH 后再移入回收站。',
182
212
  blockedActiveToast: '该会话还有正在运行的任务或子代理,等它停下来后再移入回收站。',
183
213
  blockedDescendantsToast: '该会话包含可见的子会话,只能使用 DSH 自带的「归档」整棵会话树。',
@@ -202,7 +232,7 @@ window.__ModuleLoader__.load({
202
232
  errorIndexCorrupt: '回收站索引损坏,已进入保护模式。',
203
233
  errorIndexOk: '回收站索引可正常读取,无需重建。',
204
234
  errorForgetNotAllowed: '只有文件缺失的记录可以从索引移除。',
205
- errorRevealUnavailable: '当前环境无法打开 Finder。',
235
+ errorRevealUnavailable: '当前环境无法打开文件管理器。',
206
236
  errorRevealFailed: '无法打开原工作区。',
207
237
  errorIo: '文件操作失败,请稍后重试。',
208
238
  }
@@ -215,7 +245,8 @@ window.__ModuleLoader__.load({
215
245
  retry: 'Retry',
216
246
  cleanupExpired: 'Delete expired',
217
247
  cleaning: 'Cleaning…',
218
- statsCount: '{count} sessions',
248
+ statsCountOne: '{count} session',
249
+ statsCountOther: '{count} sessions',
219
250
  statsSize: '{size} used',
220
251
  searchLabel: 'Search the trash',
221
252
  searchPlaceholder: 'Search title, Session ID, or original workspace',
@@ -265,7 +296,8 @@ window.__ModuleLoader__.load({
265
296
  badgeMigration: 'Migration required',
266
297
  badgeExpiring: 'Expiring soon',
267
298
  badgeExpired: 'Expired',
268
- badgeSubagents: '{count} sub-sessions',
299
+ badgeSubagentsOne: '{count} sub-session',
300
+ badgeSubagentsOther: '{count} sub-sessions',
269
301
  stateTrashing: 'Moving…',
270
302
  stateRestoring: 'Restoring…',
271
303
  statePurging: 'Deleting…',
@@ -273,7 +305,7 @@ window.__ModuleLoader__.load({
273
305
  actionRestoreFor: 'Restore “{title}”',
274
306
  actionMoreFor: 'More actions for “{title}”',
275
307
  actionRestoreOrphan: 'Restore as orphan',
276
- actionReveal: 'Show in Finder',
308
+ actionReveal: 'Show in file manager',
277
309
  actionRevealDisabled: 'The original workspace no longer exists',
278
310
  actionCopyId: 'Copy Session ID',
279
311
  actionForget: 'Remove from index',
@@ -290,23 +322,37 @@ window.__ModuleLoader__.load({
290
322
  purgeAllTitle: 'Permanently delete all {count} sessions in the trash?',
291
323
  purgeAllBody: 'This cannot be undone. Attachments, images, and search index rows are not deleted.',
292
324
  purgeAllConfirm: 'Delete all permanently',
293
- purgeAllDone: 'Permanently deleted {count} sessions',
325
+ purgeAllDoneOne: 'Permanently deleted {count} session',
326
+ purgeAllDoneOther: 'Permanently deleted {count} sessions',
294
327
  purgeAllNone: 'No sessions to delete',
295
- purgeAllPartial: 'Permanently deleted {removed} sessions; {failed} could not be deleted',
328
+ purgeAllPartial: 'Bulk delete finished: {removed} removed, {failed} failed.',
296
329
  forgetTitle: 'Remove this missing record from the trash list?',
297
330
  forgetBody: 'No file is deleted; only this index record is removed.',
298
331
  forgetConfirm: 'Remove from index',
299
- cleanupDone: 'Deleted {count} expired sessions',
332
+ cleanupDoneOne: 'Deleted {count} expired session',
333
+ cleanupDoneOther: 'Deleted {count} expired sessions',
300
334
  cleanupNone: 'No expired sessions to delete',
301
- cleanupFailedBanner: 'The last automatic cleanup could not delete {count} session(s).',
302
- cleanupErrorBanner: 'The last automatic cleanup failed: {message}',
335
+ cleanupFailedBannerOne: 'The last automatic cleanup could not delete {count} expired session.',
336
+ cleanupFailedBannerOther: 'The last automatic cleanup could not delete {count} expired sessions.',
337
+ cleanupErrorBanner: 'The last automatic cleanup failed. Retry from this page later.',
338
+ cleanupFailed: 'Cleanup could not delete {failed} of the expired sessions.',
339
+ cleanupPartial: 'Cleanup deleted {purged}; {failed} could not be deleted.',
303
340
  cleanupRetryAt: 'Next retry: {time}.',
304
341
  cleanupRetryNow: 'Retry now',
305
342
  copySuccess: 'Session ID copied',
306
343
  copyFailed: 'Could not copy the Session ID. Copy it manually.',
307
- revealSuccess: 'Revealed the original workspace in Finder',
344
+ revealSuccess: 'Revealed the original workspace in the file manager',
308
345
  revealFailed: 'Could not open the original workspace',
309
346
  lastErrorLabel: 'Last error: {message}',
347
+ lastErrorTrashCopyMissing: 'The stored copy of this session is missing from the trash.',
348
+ lastErrorTrashCopyMissingMembers: 'Some sub-session copies are missing from the trash.',
349
+ lastErrorTrashObjectMissing: 'The trash object directory is missing.',
350
+ lastErrorPurgeFailed: 'The session log could not be deleted.',
351
+ lastErrorRestoreInvisible: 'Session storage did not report the restored session.',
352
+ lastErrorMoveUnsettled: 'The session was moved, but session storage still reports it.',
353
+ lastErrorSourceConflict: 'The original session directory and the trash copy both exist; both were kept.',
354
+ lastErrorIndexWriteFailed: 'The trash index could not be written.',
355
+ lastErrorIo: 'A file operation failed.',
310
356
  footnote: 'Trash directory: {path}',
311
357
  move: 'Move to trash',
312
358
  moveBlockedLive: 'Move to trash (session is open)',
@@ -315,7 +361,8 @@ window.__ModuleLoader__.load({
315
361
  undo: 'Undo',
316
362
  undoFailed: 'Could not undo',
317
363
  trashSuccess: 'Moved “{title}” to the trash',
318
- trashSuccessTree: '“{title}” and its {count} sub-sessions were moved to the trash',
364
+ trashSuccessTreeOne: '“{title}” and its single sub-session were moved to the trash',
365
+ trashSuccessTreeOther: '“{title}” and its {count} sub-sessions were moved to the trash',
319
366
  blockedLiveToast: 'This session is open in DSH. DSH does not release opened sessions yet; restart DSH, then move it to the trash.',
320
367
  blockedActiveToast: 'This session still has running work; wait for it to stop, then move it to the trash.',
321
368
  blockedDescendantsToast: 'This session has visible child sessions; use the built-in Archive action for the whole tree instead.',
@@ -340,7 +387,7 @@ window.__ModuleLoader__.load({
340
387
  errorIndexCorrupt: 'The trash index is damaged, so protection mode is on.',
341
388
  errorIndexOk: 'The trash index is readable and does not need a rebuild.',
342
389
  errorForgetNotAllowed: 'Only records whose log is missing can be removed from the index.',
343
- errorRevealUnavailable: 'This environment cannot open Finder.',
390
+ errorRevealUnavailable: 'This environment cannot open a file manager.',
344
391
  errorRevealFailed: 'Could not open the original workspace.',
345
392
  errorIo: 'A file operation failed. Try again.',
346
393
  }
@@ -359,11 +406,26 @@ window.__ModuleLoader__.load({
359
406
  }
360
407
 
361
408
  /**
362
- * @param {number} bytes raw byte count.
363
- * @returns {string} a compact human-readable size.
409
+ * Pick the grammatical singular/plural locale form for a count.
410
+ *
411
+ * @param {(key: string) => string} t bound translator.
412
+ * @param {string} key base locale key (without the One/Other suffix).
413
+ * @param {number} count item count.
414
+ * @param {Record<string, unknown>} [values] extra placeholders.
415
+ * @returns {string} the formatted text.
416
+ */
417
+ function countText(t, key, count, values = {}) {
418
+ const template = t(`${key}${count === 1 ? 'One' : 'Other'}`)
419
+ return format(template, { ...values, count })
420
+ }
421
+
422
+ /**
423
+ * @param {number | null | undefined} bytes raw byte count.
424
+ * @returns {string} a compact human-readable size, or an em dash when unknown.
364
425
  */
365
426
  function formatBytes(bytes) {
366
- if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 B'
427
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return '—'
428
+ if (bytes <= 0) return '0 B'
367
429
  const units = ['B', 'KB', 'MB', 'GB', 'TB']
368
430
  let value = bytes
369
431
  let unit = 0
@@ -1090,6 +1152,7 @@ window.__ModuleLoader__.load({
1090
1152
  const [rebuilding, setRebuilding] = React.useState(false)
1091
1153
  const [purgingAll, setPurgingAll] = React.useState(false)
1092
1154
  const [announcement, setAnnouncement] = React.useState('')
1155
+ const [clock, setClock] = React.useState(() => Date.now())
1093
1156
  const [query, setQuery] = React.useState('')
1094
1157
  const [filter, setFilter] = React.useState('all')
1095
1158
  const [page, setPage] = React.useState(1)
@@ -1149,6 +1212,19 @@ window.__ModuleLoader__.load({
1149
1212
  })
1150
1213
  }
1151
1214
 
1215
+ /**
1216
+ * @param {string} sessionId row whose owner-facing error is resolved.
1217
+ * @returns {void}
1218
+ */
1219
+ function clearRowError(sessionId) {
1220
+ setRowErrors((previous) => {
1221
+ if (!(sessionId in previous)) return previous
1222
+ const next = { ...previous }
1223
+ delete next[sessionId]
1224
+ return next
1225
+ })
1226
+ }
1227
+
1152
1228
  /**
1153
1229
  * Open an abort scope for one write. It is cancelled by a hard timeout
1154
1230
  * and by the unmount cleanup, so a stuck request can never wedge the
@@ -1283,6 +1359,15 @@ window.__ModuleLoader__.load({
1283
1359
  }
1284
1360
  }, [load])
1285
1361
 
1362
+ // Keep relative expiry text honest on a page left open for a long time.
1363
+ React.useEffect(() => {
1364
+ if (typeof window === 'undefined' || typeof window.setInterval !== 'function') return undefined
1365
+ const handle = window.setInterval(() => setClock(Date.now()), 60_000)
1366
+ return () => {
1367
+ if (typeof window.clearInterval === 'function') window.clearInterval(handle)
1368
+ }
1369
+ }, [])
1370
+
1286
1371
  const data = view.data
1287
1372
  const entries = data !== null && Array.isArray(data.entries) ? [...data.entries] : []
1288
1373
  entries.sort((left, right) => (right.trashedAt ?? 0) - (left.trashedAt ?? 0))
@@ -1323,7 +1408,7 @@ window.__ModuleLoader__.load({
1323
1408
  const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
1324
1409
  const recovered = data !== null && data.index !== undefined && data.index.status === 'recovered'
1325
1410
  const unindexed = data !== null && data.index !== undefined && typeof data.index.unindexed === 'number' ? data.index.unindexed : 0
1326
- const now = Date.now()
1411
+ const now = clock
1327
1412
  const normalizedQuery = query.trim().toLowerCase()
1328
1413
 
1329
1414
  /**
@@ -1356,6 +1441,9 @@ window.__ModuleLoader__.load({
1356
1441
  const filteredEntries = entries.filter((entry) => matchesQuery(entry) && matchesFilter(entry))
1357
1442
  const pageCount = Math.max(1, Math.ceil(filteredEntries.length / PAGE_SIZE))
1358
1443
  const currentPage = Math.min(Math.max(1, page), pageCount)
1444
+ React.useEffect(() => {
1445
+ if (page > pageCount) setPage(pageCount)
1446
+ }, [page, pageCount])
1359
1447
  const pageStart = (currentPage - 1) * PAGE_SIZE
1360
1448
  const visibleEntries = filteredEntries.slice(pageStart, pageStart + PAGE_SIZE)
1361
1449
  const filtersActive = normalizedQuery !== '' || filter !== 'all'
@@ -1444,6 +1532,7 @@ window.__ModuleLoader__.load({
1444
1532
  session !== null && session !== undefined && typeof session.title === 'string' && session.title !== ''
1445
1533
  ? session.title
1446
1534
  : entry.title
1535
+ clearRowError(entry.sessionId)
1447
1536
  const message = format(t(orphan ? 'restoreOrphanSuccess' : 'restoreSuccess'), { title })
1448
1537
  bumpRevision()
1449
1538
  ctx.contextMenus.notify(message, true)
@@ -1472,6 +1561,7 @@ window.__ModuleLoader__.load({
1472
1561
  fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1473
1562
  return
1474
1563
  }
1564
+ clearRowError(entry.sessionId)
1475
1565
  const message = format(t('purgeSuccess'), { title: entry.title })
1476
1566
  bumpRevision()
1477
1567
  ctx.contextMenus.notify(message, true)
@@ -1500,6 +1590,7 @@ window.__ModuleLoader__.load({
1500
1590
  fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1501
1591
  return
1502
1592
  }
1593
+ clearRowError(entry.sessionId)
1503
1594
  bumpRevision()
1504
1595
  ctx.contextMenus.notify(t('forgetSuccess'), true)
1505
1596
  announce(t('forgetSuccess'))
@@ -1527,6 +1618,7 @@ window.__ModuleLoader__.load({
1527
1618
  fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1528
1619
  return
1529
1620
  }
1621
+ clearRowError(entry.sessionId)
1530
1622
  ctx.contextMenus.notify(t('revealSuccess'), true)
1531
1623
  announce(t('revealSuccess'))
1532
1624
  } finally {
@@ -1563,13 +1655,15 @@ window.__ModuleLoader__.load({
1563
1655
  ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
1564
1656
  return
1565
1657
  }
1566
- const purged =
1567
- outcome.payload !== null && outcome.payload !== undefined && typeof outcome.payload.purged === 'number'
1568
- ? outcome.payload.purged
1569
- : 0
1570
- const message = purged > 0 ? format(t('cleanupDone'), { count: purged }) : t('cleanupNone')
1658
+ const payload = outcome.payload
1659
+ const purged = payload !== null && payload !== undefined && typeof payload.purged === 'number' ? payload.purged : 0
1660
+ const failed = payload !== null && payload !== undefined && typeof payload.failed === 'number' ? payload.failed : 0
1661
+ let message
1662
+ if (failed > 0 && purged > 0) message = format(t('cleanupPartial'), { purged, failed })
1663
+ else if (failed > 0) message = format(t('cleanupFailed'), { failed })
1664
+ else message = purged > 0 ? countText(t, 'cleanupDone', purged) : t('cleanupNone')
1571
1665
  bumpRevision()
1572
- ctx.contextMenus.notify(message, true)
1666
+ ctx.contextMenus.notify(message, failed === 0)
1573
1667
  announce(message)
1574
1668
  } finally {
1575
1669
  endWrite()
@@ -1606,7 +1700,7 @@ window.__ModuleLoader__.load({
1606
1700
  failed > 0
1607
1701
  ? format(t('purgeAllPartial'), { removed, failed })
1608
1702
  : removed > 0
1609
- ? format(t('purgeAllDone'), { count: removed })
1703
+ ? countText(t, 'purgeAllDone', removed)
1610
1704
  : t('purgeAllNone')
1611
1705
  bumpRevision()
1612
1706
  ctx.contextMenus.notify(message, failed === 0)
@@ -1741,7 +1835,7 @@ window.__ModuleLoader__.load({
1741
1835
  if (subagentCount > 0) {
1742
1836
  badges.push({
1743
1837
  key: 'subagents',
1744
- label: format(t('badgeSubagents'), { count: subagentCount }),
1838
+ label: countText(t, 'badgeSubagents', subagentCount),
1745
1839
  tone: 'info',
1746
1840
  })
1747
1841
  }
@@ -1827,9 +1921,7 @@ window.__ModuleLoader__.load({
1827
1921
  const retryText =
1828
1922
  cleanupRetryAt !== null ? format(t('cleanupRetryAt'), { time: formatDate(cleanupRetryAt, locale, true) }) : ''
1829
1923
  const cleanupMessage =
1830
- cleanupFailed > 0
1831
- ? format(t('cleanupFailedBanner'), { count: cleanupFailed })
1832
- : format(t('cleanupErrorBanner'), { message: cleanupError })
1924
+ cleanupFailed > 0 ? countText(t, 'cleanupFailedBanner', cleanupFailed) : t('cleanupErrorBanner')
1833
1925
  banners.push(
1834
1926
  h(
1835
1927
  'div',
@@ -1837,7 +1929,7 @@ window.__ModuleLoader__.load({
1837
1929
  h(TrashGlyph, { size: 16 }),
1838
1930
  h(
1839
1931
  'span',
1840
- { className: 'dsh-trash__bannerText' },
1932
+ { className: 'dsh-trash__bannerText', title: cleanupError ?? undefined },
1841
1933
  cleanupMessage,
1842
1934
  retryText === '' ? null : ` ${retryText}`,
1843
1935
  ),
@@ -1882,7 +1974,7 @@ window.__ModuleLoader__.load({
1882
1974
  h(
1883
1975
  'span',
1884
1976
  { className: 'dsh-trash__stats' },
1885
- format(t('statsCount'), { count: entries.length }),
1977
+ countText(t, 'statsCount', entries.length),
1886
1978
  h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
1887
1979
  format(t('statsSize'), { size: formatBytes(totalBytes) }),
1888
1980
  h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
@@ -2047,7 +2139,14 @@ window.__ModuleLoader__.load({
2047
2139
  { className: 'dsh-trash__list', 'aria-label': t('listLabel') },
2048
2140
  visibleEntries.map((entry) => {
2049
2141
  const rowBusy = pendingRows.has(entry.sessionId)
2050
- const error = rowErrors[entry.sessionId] ?? entry.lastError
2142
+ const rowError = rowErrors[entry.sessionId]
2143
+ const lastErrorCode = typeof entry.lastErrorCode === 'string' ? entry.lastErrorCode : null
2144
+ const lastErrorText =
2145
+ typeof entry.lastError === 'string' && entry.lastError !== ''
2146
+ ? t(LAST_ERROR_KEYS[lastErrorCode] ?? 'lastErrorIo')
2147
+ : null
2148
+ const error = rowError ?? lastErrorText
2149
+ const errorTitle = rowError === undefined && typeof entry.lastError === 'string' ? entry.lastError : undefined
2051
2150
  const title = entry.title !== undefined && entry.title !== '' ? entry.title : entry.sessionId
2052
2151
  return h(
2053
2152
  'li',
@@ -2087,7 +2186,11 @@ window.__ModuleLoader__.load({
2087
2186
  h('span', null, formatBytes(entry.sizeBytes)),
2088
2187
  ),
2089
2188
  error !== null && error !== undefined && error !== ''
2090
- ? h('div', { className: 'dsh-trash__rowError', role: 'status' }, format(t('lastErrorLabel'), { message: error }))
2189
+ ? h(
2190
+ 'div',
2191
+ { className: 'dsh-trash__rowError', role: 'status', title: errorTitle },
2192
+ format(t('lastErrorLabel'), { message: error }),
2193
+ )
2091
2194
  : null,
2092
2195
  ),
2093
2196
  h(
@@ -2252,7 +2355,7 @@ window.__ModuleLoader__.load({
2252
2355
  : 0
2253
2356
  const message =
2254
2357
  subagentCount > 0
2255
- ? format(t('trashSuccessTree'), { title, count: subagentCount })
2358
+ ? countText(t, 'trashSuccessTree', subagentCount, { title })
2256
2359
  : format(t('trashSuccess'), { title })
2257
2360
  ctx.contextMenus.notify(message, true, {
2258
2361
  label: t('undo'),
package/lib/errors.js CHANGED
@@ -62,16 +62,76 @@ export function statusOf(error) {
62
62
  return ERROR_STATUS[code] ?? 500
63
63
  }
64
64
 
65
+ /**
66
+ * Detail keys the Host may return to the Client. Anything else - notably raw
67
+ * filesystem paths or nested error text - stays in the Host log, because the
68
+ * wire contract promises that the trash root's absolute path is never exposed.
69
+ */
70
+ const SAFE_DETAIL_KEYS = new Set([
71
+ 'sessionId',
72
+ 'trashId',
73
+ 'code',
74
+ 'phase',
75
+ 'copied',
76
+ 'kind',
77
+ 'targetKind',
78
+ 'expected',
79
+ 'measured',
80
+ 'activity',
81
+ 'descendants',
82
+ 'sessions',
83
+ ])
84
+
85
+ /**
86
+ * Metrics snapshots are the only nested detail value the Host returns; keep
87
+ * only their numeric counters so no path can ride along.
88
+ *
89
+ * @param {unknown} value candidate metrics object.
90
+ * @returns {object | undefined} the numeric metrics.
91
+ */
92
+ function sanitizeMetrics(value) {
93
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
94
+ const safe = {}
95
+ for (const key of ['sizeBytes', 'fileCount', 'symlinkCount']) {
96
+ if (typeof value[key] === 'number') safe[key] = value[key]
97
+ }
98
+ return Object.keys(safe).length === 0 ? undefined : safe
99
+ }
100
+
101
+ /**
102
+ * @param {unknown} details arbitrary structured context.
103
+ * @returns {object | undefined} the same context restricted to whitelisted keys.
104
+ */
105
+ export function sanitizeDetails(details) {
106
+ if (details === null || typeof details !== 'object' || Array.isArray(details)) return undefined
107
+ const safe = {}
108
+ for (const [key, value] of Object.entries(details)) {
109
+ if (!SAFE_DETAIL_KEYS.has(key)) continue
110
+ if (key === 'expected' || key === 'measured') {
111
+ const metrics = sanitizeMetrics(value)
112
+ if (metrics !== undefined) safe[key] = metrics
113
+ continue
114
+ }
115
+ if (Array.isArray(value)) {
116
+ safe[key] = value.filter((item) => typeof item === 'string' || typeof item === 'number')
117
+ continue
118
+ }
119
+ if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) safe[key] = value
120
+ }
121
+ return Object.keys(safe).length === 0 ? undefined : safe
122
+ }
123
+
65
124
  /**
66
125
  * @param {unknown} error arbitrary thrown value.
67
126
  * @returns {{ code: string, message: string, details?: object }} the wire error shape.
68
127
  */
69
128
  export function toWireError(error) {
70
129
  if (error instanceof TrashError) {
130
+ const details = sanitizeDetails(error.details)
71
131
  return {
72
132
  code: error.code,
73
133
  message: error.message,
74
- ...(error.details === undefined ? {} : { details: error.details }),
134
+ ...(details === undefined ? {} : { details }),
75
135
  }
76
136
  }
77
137
  const message = error instanceof Error ? error.message : String(error)
package/lib/fsops.js CHANGED
@@ -294,6 +294,11 @@ export function metricsMatch(entry, measured) {
294
294
  if (entry === null || entry === undefined) return true
295
295
  if (typeof entry.sizeBytes === 'number' && entry.sizeBytes !== measured.sizeBytes) return false
296
296
  if (typeof entry.fileCount === 'number' && entry.fileCount !== measured.fileCount) return false
297
+ // A cross-device copy records the symlink count of the source; comparing it
298
+ // catches a symlink that the copy silently dropped.
299
+ if (typeof entry.symlinkCount === 'number' && typeof measured.symlinkCount === 'number' && entry.symlinkCount !== measured.symlinkCount) {
300
+ return false
301
+ }
297
302
  return true
298
303
  }
299
304
 
@@ -356,10 +361,12 @@ async function copyAcrossDevices(source, destination, expected, rename, copy) {
356
361
  try {
357
362
  await fs.rm(source, { recursive: true, force: false, maxRetries: 3, retryDelay: 100 })
358
363
  } catch (error) {
364
+ // The paths stay in the Host log; the wire error only carries the error
365
+ // code and the fact that a verified copy already exists.
359
366
  throw new TrashError(
360
367
  'IO_ERROR',
361
368
  'The session was copied to the destination, but the source could not be removed.',
362
- { source, destination, code: errorCode(error) ?? null, copied: true },
369
+ { code: errorCode(error) ?? null, copied: true },
363
370
  )
364
371
  }
365
372
  }
package/lib/state.js CHANGED
@@ -5,6 +5,34 @@ import { memberList } from './members.js'
5
5
  /** Maximum concurrent filesystem probes while describing entries. */
6
6
  const PROBE_CONCURRENCY = 3
7
7
 
8
+ /**
9
+ * Known failure families recorded in `lastError`, in match order. The raw text
10
+ * stays an English diagnostic for logs and tooltips; the code is what the
11
+ * Client localizes.
12
+ */
13
+ const LAST_ERROR_KINDS = [
14
+ [/^The trash copy of this session is missing\./, 'TRASH_COPY_MISSING'],
15
+ [/^The trash copy is missing these member sessions:/, 'TRASH_COPY_MISSING_MEMBERS'],
16
+ [/^The trash object directory is missing\./, 'TRASH_OBJECT_MISSING'],
17
+ [/^The session log could not be deleted:/, 'PURGE_FAILED'],
18
+ [/^The restored session (tree )?(is|was) not visible to session storage\./, 'RESTORE_INVISIBLE'],
19
+ [/^Session storage still reports the moved session tree/, 'MOVE_UNSETTLED'],
20
+ [/^An original session directory unexpectedly exists/, 'SOURCE_CONFLICT'],
21
+ [/^The trash index could not be committed/, 'INDEX_WRITE_FAILED'],
22
+ ]
23
+
24
+ /**
25
+ * @param {unknown} message raw `lastError` text.
26
+ * @returns {string | null} a stable failure family, or null when none was recorded.
27
+ */
28
+ export function lastErrorCode(message) {
29
+ if (typeof message !== 'string' || message === '') return null
30
+ for (const [pattern, code] of LAST_ERROR_KINDS) {
31
+ if (pattern.test(message)) return code
32
+ }
33
+ return 'IO_ERROR'
34
+ }
35
+
8
36
  /**
9
37
  * @param {string | null | undefined} directory candidate workspace directory.
10
38
  * @returns {Promise<boolean>} whether the directory exists right now.
@@ -31,7 +59,14 @@ async function directoryExists(directory) {
31
59
  * }} deps state inputs.
32
60
  * @returns {{ snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> }} the state service.
33
61
  */
34
- export function createStateService({ store, config, trashRootDisplay, nextSweepAt = () => null, cleanup }) {
62
+ export function createStateService({
63
+ store,
64
+ config,
65
+ trashRootDisplay,
66
+ nextSweepAt = () => null,
67
+ cleanup,
68
+ directoryExists: probeDirectory = directoryExists,
69
+ }) {
35
70
  /**
36
71
  * @returns {object} cleanup diagnostics, or a neutral fallback.
37
72
  */
@@ -58,11 +93,17 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
58
93
 
59
94
  /**
60
95
  * @param {object} entry committed index entry.
96
+ * @param {Map<string, Promise<boolean>>} [probeCache] workspace probes already
97
+ * started for this snapshot, so entries sharing a cwd probe it once.
61
98
  * @returns {Promise<object>} the wire entry.
62
99
  */
63
- async function describeEntry(entry) {
100
+ async function describeEntry(entry, probeCache = new Map()) {
64
101
  const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
65
- const workspaceNowExists = cwd === null ? null : await directoryExists(cwd)
102
+ let workspaceNowExists = null
103
+ if (cwd !== null) {
104
+ if (!probeCache.has(cwd)) probeCache.set(cwd, probeDirectory(cwd))
105
+ workspaceNowExists = await probeCache.get(cwd)
106
+ }
66
107
  const expiresAt =
67
108
  typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt + config.retentionDays * 86_400_000 : null
68
109
  const trashed = entry.state === 'trashed'
@@ -85,6 +126,7 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
85
126
  canForget: entry.state === 'missing',
86
127
  requiresOrphanRestore: cwd !== null && workspaceNowExists === false,
87
128
  lastError: entry.lastError ?? null,
129
+ lastErrorCode: lastErrorCode(entry.lastError),
88
130
  subagentCount: Math.max(0, members.length - 1),
89
131
  members: members.map((member) => ({
90
132
  sessionId: member.sessionId,
@@ -104,7 +146,8 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
104
146
  * @returns {Promise<object>} the complete `/state` response body.
105
147
  */
106
148
  async snapshot() {
107
- const entries = await mapLimit(store.entries(), PROBE_CONCURRENCY, (entry) => describeEntry(entry))
149
+ const probeCache = new Map()
150
+ const entries = await mapLimit(store.entries(), PROBE_CONCURRENCY, (entry) => describeEntry(entry, probeCache))
108
151
  let totalBytes = 0
109
152
  let failedEntries = 0
110
153
  for (const entry of entries) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangxiaocuo/dsh-session-trash",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "DeepSeek Harness plugin: move cold sessions into a recoverable trash, restore them within the retention window, or delete their logs permanently.",
5
5
  "license": "MIT",
6
6
  "author": "wangxiaocuo",
@@ -40,7 +40,8 @@
40
40
  "locale/*.json",
41
41
  "icon.svg",
42
42
  "README.md",
43
- "README.en.md"
43
+ "README.en.md",
44
+ "CHANGELOG.md"
44
45
  ],
45
46
  "scripts": {
46
47
  "test": "node test/run-unit.mjs",