dsh-disk-cleaner 1.6.1 → 1.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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <!-- badges -->
6
6
  | | |
7
7
  | --- | --- |
8
- | **version** | `1.6.0` |
8
+ | **version** | `1.6.3` |
9
9
  | **DSH** | `0.2.1-rc.5`(开发与实测版本;用到 `plugins.bundle.config`、`settings.section`) |
10
10
  | **license** | [MIT](LICENSE) |
11
11
  | **Node** | `>= 22`(仅运行冒烟测试需要) |
@@ -16,7 +16,7 @@
16
16
 
17
17
  **dsh-disk-cleaner** is a disk-space plugin for [DeepSeek Harness](https://github.com/deepseek-ai) (DSH). It does two different jobs, and keeps them apart on purpose:
18
18
 
19
- 1. **Clean** — thirty-two known-safe locations (temp folders, package manager stores, browser caches, crash dumps, the Recycle Bin, leftover update installers, per-app caches and logs, superseded plugin versions and applied update packages). Only these are ever deleted.
19
+ 1. **Clean** — thirty-two known locations (temp folders, package manager stores, browser caches, crash dumps, the Recycle Bin, leftover update installers, per-app caches and logs, superseded plugin versions and applied update packages). Only these are ever deleted, and the ones that cannot be rebuilt are never preselected.
20
20
  2. **Assess** — everything the plugin will *not* delete (installed programs, the WSL virtual disk, the Windows component store, chat-app data, your own folders). Measured and listed, never removed.
21
21
 
22
22
  The assessment ends with a **plan**: the same numbers ordered by what it costs you to act, with the exact Windows command for the steps the plugin cannot perform itself.
@@ -29,7 +29,7 @@ Both the panel and the agent reach the *same* operations: the cleaning logic liv
29
29
  | --- | --- |
30
30
  | Scan | Measures all thirty-two targets concurrently and shows what each occupies |
31
31
  | Per-drive filter | Chip row of mounted volumes; scanning or cleaning can be limited to one drive, and rows with nothing on that drive disappear |
32
- | Risk labels | Every target is `safe` or `caution`, with a one-line reason (`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup` / `orphan`) and a permanent legend, so "caution" never means "unspecified" |
32
+ | Risk labels | Every target is `safe` or `caution`, with a one-line reason (`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup` / `orphan` / `irreversible`) and a permanent legend, so "caution" never means "unspecified" |
33
33
  | Aged deletion | `temp` and `windows-temp` only remove files older than one day; files a running program still holds are skipped |
34
34
  | Space assessment | Walks the disk read-only and groups the result into Windows / installed software / your own files, plus the ten largest app-data folders. Any row opens its folder in Explorer — click, right click, or Enter |
35
35
  | Honest re-run | Re-running the assessment re-measures; a plain call still answers from the ten-minute cache |
@@ -58,7 +58,7 @@ Every path is built from this table — a caller selects an **id**, never a path
58
58
  | `edge-cache` | same set, Edge profiles | purge | caution | rebuilt; next open is slower |
59
59
  | `code-cache` | `%APPDATA%\Code\{Cache,CachedData,CachedExtensionVSIXs}` | purge | caution | rebuilt; next start is slower |
60
60
  | `windows-update` | `C:\Windows\SoftwareDistribution\Download` | purge | caution | needs elevation |
61
- | `recycle-bin` | `$Recycle.Bin\S-1-5-*` on every mounted volume | purge | safe | it is the Recycle Bin |
61
+ | `recycle-bin` | `$Recycle.Bin\S-1-5-*` on every mounted volume | purge | caution | emptying it cannot be undone; never preselected |
62
62
  | `inet-cache` | `%LOCALAPPDATA%\Microsoft\Windows\INetCache` | purge | caution | rebuilt |
63
63
  | `app-updaters` | `*-updater` folders under `%LOCALAPPDATA%` and `%APPDATA%` — the installers app updaters leave behind | aged, 3 days | caution | a download from the last three days may still be a pending update |
64
64
  | `app-caches` | named cache folders inside `%APPDATA%`: the WPS CEF cache, FTNN `cef_cache`, and the `Cache` / `Code Cache` / `CachedData` / `GPUCache` of Qoder, Trae, the DSH desktop app, PersonalCenter, the WeChat devtools and QQ PC Manager | purge | caution | CEF and Chromium caches rebuild on next start |
@@ -76,7 +76,7 @@ Every path is built from this table — a caller selects an **id**, never a path
76
76
  | `rewind-snapshots` | `session-*` folders under `%USERPROFILE%\.dsh\rewind-snapshots` | aged, 30 days | caution | a recent session stays restorable |
77
77
  | `rotated-logs` | `*.old.log` and `*.log.N` files in the `logs` folders of the agent apps | aged, 3 days | safe | nothing reads a rotated file back |
78
78
  | `qoder-old-versions` | version folders under `%LOCALAPPDATA%\Programs\Qoder\.qoder-versions` other than the one the launcher names | purge | caution | the launcher's `state.ini` names the build it runs; an unreadable one means nothing is removed |
79
- | `orphan-app-data` | `%LOCALAPPDATA%\Tencent\QQGuild` - left by an app with no shortcut, no uninstall entry and nothing written for over two years | purge | caution | all four signals must agree before the folder is offered |
79
+ | `orphan-app-data` | `%LOCALAPPDATA%\Tencent\QQGuild` - the QQGuild data folder | purge | caution | the whole folder is offered for deletion once no file in it has been written for 30 days |
80
80
  | `app-data-caches` | Chromium caches under `%LOCALAPPDATA%` for Electron, DingTalk, JianyingPro and the WeChat devtools | aged, 7 days | caution | an app that still runs rewrites its cache constantly, so only a week-old tree qualifies |
81
81
  | `office-update-cache` | `%LOCALAPPDATA%\Microsoft\Office\SolutionPackages` - update packages Office already applied | purge | caution | Office stages them before applying; nothing reads them back afterwards |
82
82
 
@@ -159,23 +159,31 @@ These are enforced in `lib/catalog.js` and must not be relaxed:
159
159
  - `editor-builds` drops a build only when `bin\code.cmd` exists and names a different build that is also on disk; `old_x` goes only while `x` is still beside it.
160
160
  - `database-backups` matches `.sqlite*.bak` by name only; the live `zotero.sqlite` is never a candidate.
161
161
  - `qoder-old-versions` reads the launcher's `state.ini` for the build it runs; a missing or unparsable file, or a named build that is not on disk, removes nothing.
162
- - `orphan-app-data` needs four independent signals to agree: no Start-menu shortcut, no uninstall entry, no folder under either `Program Files`, and no file written for two years. Any one missing and the folder is not offered.
162
+ - `orphan-app-data` is offered when the QQGuild folder exists and no file in it has been written in the last 30 days; the whole folder is then a full-delete target. It does not check shortcuts, uninstall entries, or Program Files.
163
163
  - The directory Windows refuses to let an unelevated process write (`SoftwareDistribution\Download`) is labelled `elevation` and excluded from "reclaimable now". `C:\Windows\Temp` is **not** one of them on a default ACL: it grants `BUILTIN\Users` create and delete, so only EBUSY on a live file stops a delete there, which the age limit covers.
164
164
 
165
165
  ### Install
166
166
 
167
- Published on npm as [`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner):
167
+ A catalog entry has been submitted to the curated [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) list, which powers **dshmarket → Discover** ([PR #12970](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/12970), pending review) — once it merges, search **dsh-disk-cleaner** there and install it in one click. Until then, use a direct install below.
168
+
169
+ Published on npm as [`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner) — latest `1.6.3`:
168
170
 
169
171
  ```bash
170
172
  npm i dsh-disk-cleaner
171
173
  ```
172
174
 
175
+ A prebuilt tarball is attached to the [v1.6.3 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.3); installing from it skips DSH's build-approval step:
176
+
177
+ ```bash
178
+ dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.3/dsh-disk-cleaner-1.6.3.tgz
179
+ ```
180
+
173
181
  Then enable it in the profile — in `$DSH_PROFILE_DIR/package.json`:
174
182
 
175
183
  ```json
176
184
  {
177
185
  "dependencies": {
178
- "dsh-disk-cleaner": "^1.0.0"
186
+ "dsh-disk-cleaner": "^1.6.3"
179
187
  },
180
188
  "dsh": {
181
189
  "profile": {
@@ -196,7 +204,7 @@ The bundle's own `cordis.patch.yml` inserts the plugin row; no profile patch edi
196
204
  ### Development
197
205
 
198
206
  ```bash
199
- node smoke.mjs # 82 assertions, zero dependencies
207
+ node smoke.mjs # 85 assertions, zero dependencies
200
208
  node --check lib/client.js # and lib/index.js, lib/catalog.js, lib/audit.js, lib/plan.js
201
209
  ```
202
210
 
@@ -225,6 +233,8 @@ What the smoke test deliberately does **not** cover: anything that walks a real
225
233
  | A dry run reports what a real run frees | The 30-day rewind window reports 263 KB not 283 MB, and scan matches the dry run |
226
234
  | Grouping and folding | The real `groupRows` runs out of the bundle against a mixed list: fixed order, per-section totals, an unknown group lands in "other", an empty section is the folded one. A click on a section head folds that section and only that one, and a folded section's rows drop out of bulk select |
227
235
  | Visual layout | Checked by hand in the running Web UI. No browser automation here, so this one is a human check, not a test. |
236
+ | Agent tool returns a string matching its output schema | `node smoke.mjs` regression assertion (the 1.6.1 fix) |
237
+ | A real agent-tool delete invalidates the assessment cache | `node smoke.mjs` regression assertion (the 1.6.1 fix) |
228
238
 
229
239
  ### Implementation notes
230
240
 
@@ -248,20 +258,27 @@ What the smoke test deliberately does **not** cover: anything that walks a real
248
258
  ```
249
259
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
250
260
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
251
- ├─ lib/index.js (411) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
261
+ ├─ lib/index.js (431) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
252
262
  ├─ lib/catalog.js (1192) The 32 targets, measurePath / purgePath / resolveTargets / drives
253
263
  ├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
254
264
  ├─ lib/plan.js (150) Pure: measurements → ordered steps
255
- ├─ lib/client.js (1492) Client half: slots, panel, preferences, zh/en dictionaries, CSS
265
+ ├─ lib/client.js (1493) Client half: slots, panel, preferences, zh/en dictionaries, CSS
256
266
  ├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
257
267
  ├─ icon.svg Plugin icon
258
268
  ├─ README.md This file
259
269
  ├─ LICENSE MIT
260
- └─ smoke.mjs 82 assertions, zero dependencies
270
+ └─ smoke.mjs 85 assertions, zero dependencies
261
271
  ```
262
272
 
263
273
  Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode), `main` (keyed by the package name), `settings.section` (order 30, disposed in sidebar-only mode), `plugins.bundle.config` (keyed by the package name).
264
274
 
275
+ ### What's new
276
+
277
+ - **1.6.3** — Safety and honesty rather than new features. The Recycle Bin is no longer filed under `safe`: emptying it cannot be undone, so it is never preselected, never picked by "select all", and carries its own `irreversible` note instead of the generic "rebuilt; next start is slower". The confirm dialog now names each target and the total instead of asking a generic "delete these?". A target that names no environment root is bounded by the volume it sits on rather than skipping the boundary check. `measure` and `purge` now count entries in the same unit, and a walk that hits the 2,000,000-entry cap says so (`truncated`) instead of claiming exact totals. A directory removal refused for a reason other than "not empty" is counted as `blocked` instead of being swallowed, and a clean that left files behind reports a warning rather than a green success. The orphan target is named for what it actually measures (the QQGuild folder).
278
+ - **1.6.2** — Documentation only: the README, the in-source comment, the panel's zh/en explanations and the tool's note legend now describe `orphan-app-data` as it actually behaves (the QQGuild folder is offered for full deletion once no file in it has been written for 30 days), instead of claiming four independent signals and a two-year idle period. No behaviour change; the refreshed README ships in the npm package and the [v1.6.2 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.2) tarball.
279
+ - **1.6.1** — Fixed the `disk_cleaner` agent tool: `output.schema` now declares `type: 'string'`, matching the text `execute()` returns (previously every call was rejected by the host's output validator with `tool "disk_cleaner" returned invalid output: "value" must be an object`). A real delete through the agent tool now invalidates the assessment cache, the same way the panel's HTTP route does. Two regression assertions added; a prebuilt tarball ships on the [v1.6.1 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.1).
280
+ - **1.6.0** — The 32 targets are grouped under seven foldable headings.
281
+
265
282
  ---
266
283
 
267
284
  <a id="中文"></a>
@@ -269,7 +286,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
269
286
 
270
287
  **dsh-disk-cleaner** 是 [DeepSeek Harness](https://github.com/deepseek-ai)(DSH)的磁盘空间插件。它做两件不同的事,并且刻意分开:
271
288
 
272
- 1. **清理** —— 三十二个已知安全的位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站、更新安装包残留、各应用的缓存与日志、被淘汰的插件版本与已应用的更新包)。只有这些会被删除。
289
+ 1. **清理** —— 三十二个已知位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站、更新安装包残留、各应用的缓存与日志、被淘汰的插件版本与已应用的更新包)。只有这些会被删除,其中无法重建的项目永远不会被预选。
273
290
  2. **评估** —— 所有本插件**不会**删的东西(已装程序、WSL 虚拟磁盘、Windows 组件库、聊天软件数据、你自己的文件夹)。只测量、只列出,绝不删除。
274
291
 
275
292
  评估的最后是一份**处理方案**:同一批数字按「你要付出多少代价」排序,本插件做不到的步骤附上确切的 Windows 命令。
@@ -282,7 +299,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
282
299
  | --- | --- |
283
300
  | 扫描 | 并发测量全部三十二个目标,显示各自占用 |
284
301
  | 按盘过滤 | 已挂载卷的胶囊按钮;扫描与清理都可限定单盘,该盘上为空的项会隐藏 |
285
- | 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup` / `orphan`),图例常驻,所以「注意」永远不是「说不清」 |
302
+ | 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup` / `orphan` / `irreversible`),图例常驻,所以「注意」永远不是「说不清」 |
286
303
  | 保留期删除 | `temp` 与 `windows-temp` 只删一天前的文件;被运行中程序占住的自动跳过 |
287
304
  | 空间评估 | 只读遍历磁盘,按 Windows 自身 / 已装软件 / 你自己的文件 分组,另列占用最大的十个应用数据目录。每一行都能打开所在目录——单击、右键或回车 |
288
305
  | 重新评估真的重测 | 「重新评估」会重新测量;普通调用仍然走十分钟缓存 |
@@ -311,7 +328,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
311
328
  | `edge-cache` | 同上,Edge 的配置目录 | 全清 | 注意 | 会重建,下次打开更慢 |
312
329
  | `code-cache` | `%APPDATA%\Code\{Cache,CachedData,CachedExtensionVSIXs}` | 全清 | 注意 | 会重建,下次启动更慢 |
313
330
  | `windows-update` | `C:\Windows\SoftwareDistribution\Download` | 全清 | 注意 | 需要管理员权限 |
314
- | `recycle-bin` | 各已挂载卷的 `$Recycle.Bin\S-1-5-*` | 全清 | 安全 | 就是回收站 |
331
+ | `recycle-bin` | 各已挂载卷的 `$Recycle.Bin\S-1-5-*` | 全清 | 注意 | 清空不可恢复,永不被预选 |
315
332
  | `inet-cache` | `%LOCALAPPDATA%\Microsoft\Windows\INetCache` | 全清 | 注意 | 会重建 |
316
333
  | `app-updaters` | `%LOCALAPPDATA%` 与 `%APPDATA%` 下的 `*-updater` 目录——升级程序留下的安装包 | 保留期 3 天 | 注意 | 三天内的下载可能还是待安装的更新 |
317
334
  | `app-caches` | `%APPDATA%` 里点名列出的缓存目录:WPS 的 CEF 缓存、FTNN 的 `cef_cache`,以及 Qoder、Trae、DSH 桌面端、PersonalCenter、微信开发者工具、腾讯电脑管家的 `Cache` / `Code Cache` / `CachedData` / `GPUCache` | 全清 | 注意 | CEF 与 Chromium 缓存会在下次启动时重建 |
@@ -329,7 +346,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
329
346
  | `rewind-snapshots` | `%USERPROFILE%\.dsh\rewind-snapshots` 下的 `session-*` 目录 | 保留期 30 天 | 注意 | 最近的会话仍然可以回滚 |
330
347
  | `rotated-logs` | 各 agent 应用 `logs` 目录下的 `*.old.log` 与 `*.log.N` 文件 | 保留期 3 天 | 安全 | 轮转掉的日志不会被读回 |
331
348
  | `qoder-old-versions` | `%LOCALAPPDATA%\Programs\Qoder\.qoder-versions` 下不是启动器所指的那个版本目录 | 全清 | 注意 | 启动器 `state.ini` 写明它跑哪个版本;读不到就一个都不删 |
332
- | `orphan-app-data` | `%LOCALAPPDATA%\Tencent\QQGuild` —— 没有快捷方式、没有卸载项、两年多没写过文件的程序留下的数据 | 全清 | 注意 | 四条证据必须同时成立才会被列为可删 |
349
+ | `orphan-app-data` | `%LOCALAPPDATA%\Tencent\QQGuild` —— QQ 频道数据目录 | 全清 | 注意 | 目录内 30 天没有任何文件写入时,整个目录会被列为全清目标 |
333
350
  | `app-data-caches` | `%LOCALAPPDATA%` 下 Electron、钉钉、剪映、微信开发者工具的 Chromium 缓存 | 保留期 7 天 | 注意 | 还在跑的程序会不停改写缓存,所以只有一周没动过的才算 |
334
351
  | `office-update-cache` | `%LOCALAPPDATA%\Microsoft\Office\SolutionPackages` —— Office 已应用的更新暂存包 | 全清 | 注意 | Office 先暂存再应用,应用后没有东西会读回 |
335
352
 
@@ -411,23 +428,31 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
411
428
  - `editor-builds` 只有在 `bin\code.cmd` 存在且指向另一份确实在磁盘上的构建时才删;`old_x` 也只有在 `x` 仍在旁边时才删。
412
429
  - `database-backups` 只按名字匹配 `.sqlite*.bak`,正在用的 `zotero.sqlite` 永不成为候选。
413
430
  - `qoder-old-versions` 从启动器的 `state.ini` 读出它实际运行哪个版本;文件缺失、解析不出、或它指的版本不在磁盘上,一律一个都不删。
414
- - `orphan-app-data` 要求四条独立证据同时成立:开始菜单没有快捷方式、注册表没有卸载项、两个 `Program Files` 下都没有目录、两年内没有任何文件被写过。缺一条就不会列为可删。
431
+ - `orphan-app-data` 的判据只有一条:QQGuild 目录存在,且目录内 30 天内没有任何文件被写入过,此时整个目录被列为全清目标。它不检查快捷方式、卸载项或 Program Files。
415
432
  - 那个拒绝普通权限写入的 Windows 目录标为 `elevation`,并排除在「现在能回收」之外。
416
433
 
417
434
  ### 安装
418
435
 
419
- 已发布到 npm:[`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner)。
436
+ 已向精选目录 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 提交收录,它是 **dshmarket → 发现** 的数据源([PR #12970](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/12970),审核中)——合并后即可在 dshmarket 里搜索 **dsh-disk-cleaner** 一键安装。在那之前,用下面任一种直接安装方式。
437
+
438
+ 已发布到 npm:[`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner),最新版 `1.6.3`。
420
439
 
421
440
  ```bash
422
441
  npm i dsh-disk-cleaner
423
442
  ```
424
443
 
444
+ [v1.6.3 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.3) 还附带一个预构建 tarball,从它安装可跳过 DSH 的构建授权步骤:
445
+
446
+ ```bash
447
+ dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.3/dsh-disk-cleaner-1.6.3.tgz
448
+ ```
449
+
425
450
  然后在 profile 里启用 —— 在 `$DSH_PROFILE_DIR/package.json`:
426
451
 
427
452
  ```json
428
453
  {
429
454
  "dependencies": {
430
- "dsh-disk-cleaner": "^1.0.0"
455
+ "dsh-disk-cleaner": "^1.6.3"
431
456
  },
432
457
  "dsh": {
433
458
  "profile": {
@@ -445,14 +470,10 @@ git clone https://github.com/stallenlu/dsh-disk-cleaner
445
470
 
446
471
  bundle 自带的 `cordis.patch.yml` 会插入插件行,无需改 profile 的 patch。`link:` 依赖是符号链接,所以改 **client** 刷新页面即生效,改 **host**(`lib/index.js`)需要重新启用该 bundle 或重启 DSH。从 npm 装则是相反的一面:要升级就改版本号。
447
472
 
448
- ```bash
449
- git clone https://github.com/stallenlu/dsh-disk-cleaner
450
- ```
451
-
452
473
  ### 开发
453
474
 
454
475
  ```bash
455
- node smoke.mjs # 82 条断言,零依赖
476
+ node smoke.mjs # 85 条断言,零依赖
456
477
  node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
457
478
  ```
458
479
 
@@ -481,6 +502,8 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
481
502
  | 预演报告的字节数等于真删能释放的字节数 | 30 天的回滚窗口报 263 KB 而不是 283 MB,且扫描结果与预演一致 |
482
503
  | 分类与折叠 | 从 bundle 里取出真正的 `groupRows` 跑一组混合数据:顺序固定、按类合计、未知类别落到「其他」、空的类别是收起来的那个。点某一类的标题只折叠这一类,折叠里的行不参与批量选择 |
483
504
  | 视觉排版 | 在运行中的 Web UI 里手工确认过。本环境没有浏览器自动化,所以这条是人眼检查,不是测试。 |
505
+ | agent 工具返回字符串且与 output.schema 一致 | `node smoke.mjs` 回归断言(1.6.1 修复) |
506
+ | agent 工具真删后评估缓存失效 | `node smoke.mjs` 回归断言(1.6.1 修复) |
484
507
 
485
508
  ### 实现要点
486
509
 
@@ -504,20 +527,27 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
504
527
  ```
505
528
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
506
529
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
507
- ├─ lib/index.js (411) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
530
+ ├─ lib/index.js (431) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
508
531
  ├─ lib/catalog.js (1192) 32 个目标,measurePath / purgePath / resolveTargets / drives
509
532
  ├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
510
533
  ├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
511
- ├─ lib/client.js (1492) Client 半面:slot、面板、偏好、zh/en 字典、CSS
534
+ ├─ lib/client.js (1493) Client 半面:slot、面板、偏好、zh/en 字典、CSS
512
535
  ├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
513
536
  ├─ icon.svg 插件图标
514
537
  ├─ README.md 本文件
515
538
  ├─ LICENSE MIT
516
- └─ smoke.mjs 82 条断言,零依赖
539
+ └─ smoke.mjs 85 条断言,零依赖
517
540
  ```
518
541
 
519
542
  Client 半面注册的 slot:`sidebar.panellist`(order 50,仅设置页模式下拆除)、`main`(以包名为 key)、`settings.section`(order 30,仅侧边栏模式下拆除)、`plugins.bundle.config`(以包名为 key)。
520
543
 
544
+ ### 更新记录
545
+
546
+ - **1.6.3** — 改的是安全与诚实,不是新功能。回收站不再归入「安全」:清空不可恢复,所以它永不被预选、不被「全选」勾中,并单独带 `irreversible` 注意码,而不是笼统的「重建,下次启动慢一些」。确认框现在逐条列出目标名与合计字节,不再只问一句「删除选中缓存目录里的文件?」。没有声明根目录的目标,改为按所在卷做边界校验,而不是直接跳过检查。`measure` 与 `purge` 现在用同一单位计数,走查触到 200 万条上限时会如实标 `truncated`,不再声称精确总量。目录删除失败若原因不是「非空」会计入 `blocked`(此前被静默吞掉),清理后仍有残留的目标会报警告而不是绿色成功。orphan 目标按它实际测量的东西重新命名(QQ 频道目录)。
547
+ - **1.6.2** — 仅文档修正:README、源码注释、面板中英文说明和工具里的注意码释义,现在都按真实行为描述 `orphan-app-data`(QQGuild 目录内 30 天没有文件写入时会被整目录列为可全清),不再声称「四条独立证据 / 两年未写」。行为不变;更新后的 README 也随 npm 包与 [v1.6.2 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.2) 的 tarball 一起发布。
548
+ - **1.6.1** — 修复了 `disk_cleaner` agent 工具:`output.schema` 现在声明 `type: 'string'`,与 `execute()` 返回的文本一致(此前每次调用都被宿主的输出校验器拒绝,报 `tool "disk_cleaner" returned invalid output: "value" must be an object`)。经由 agent 工具真删后也会让评估缓存失效,与面板的 HTTP 路径一致。新增两条回归断言;[v1.6.1 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.1) 附带预构建 tarball。
549
+ - **1.6.0** — 32 个目标按七个可折叠分组归类。
550
+
521
551
  ## License
522
552
 
523
553
  [MIT](LICENSE)
package/lib/catalog.js CHANGED
@@ -642,7 +642,7 @@ const CATALOG = [
642
642
  // service still holds, which the age limit already handles. Calling it `elevation` sent
643
643
  // the user to an elevated prompt that changed nothing and hid the bytes from the plan.
644
644
  note: 'in-use',
645
- roots: [],
645
+ roots: ['SystemRoot'],
646
646
  paths: () => ['C:\\Windows\\Temp'],
647
647
  },
648
648
  {
@@ -716,13 +716,16 @@ const CATALOG = [
716
716
  kind: 'purge',
717
717
  risk: 'caution',
718
718
  note: 'elevation',
719
- roots: [],
719
+ roots: ['SystemRoot'],
720
720
  paths: () => ['C:\\Windows\\SoftwareDistribution\\Download'],
721
721
  },
722
722
  {
723
723
  id: 'recycle-bin',
724
724
  kind: 'purge',
725
- risk: 'safe',
725
+ // Everything in here was already deleted once; emptying it is not a cache rebuild, so it
726
+ // is never preselected and never counted as a safe win.
727
+ risk: 'caution',
728
+ note: 'irreversible',
726
729
  roots: [],
727
730
  paths: () => recycleBins(),
728
731
  },
@@ -872,9 +875,9 @@ const CATALOG = [
872
875
  roots: ['LOCALAPPDATA'],
873
876
  paths: () => {
874
877
  const guild = path.join(env('LOCALAPPDATA') ?? '', 'Tencent', 'QQGuild');
875
- // Measured: no Start-menu shortcut, no uninstall entry, no Program Files folder and no
876
- // file touched for over two years. The folder also carries its own `Uninstall QQGuild.exe`,
877
- // which only a real install does. Every one of those signals has to agree before it goes.
878
+ // The QQGuild data folder is offered for full deletion when it exists and no file
879
+ // inside it has been written in the last 30 days. Nothing else is checked - no
880
+ // shortcut, uninstall entry or Program Files probe. Keep the docs in step with this.
878
881
  const recent = newestMtime(guild) > Date.now() - 30 * DAY;
879
882
  return isDir(guild) && !recent ? [guild] : [];
880
883
  },
@@ -965,8 +968,11 @@ export function resolveTargets(only, drive) {
965
968
  if (tooBroad(candidate)) continue;
966
969
  if (!isDir(candidate) && !isFile(candidate)) continue;
967
970
  if (!onDrive(candidate, drive)) continue;
968
- const roots = entry.roots.map((name) => env(name)).filter(Boolean);
969
- if (roots.length > 0 && !roots.some((root) => within(candidate, root))) continue;
971
+ // A target that names no root is still bounded by the volume it sits on. An empty list
972
+ // used to skip this check outright, so any path that passed the earlier tests was taken.
973
+ const declared = entry.roots.map((name) => env(name)).filter(Boolean);
974
+ const roots = declared.length > 0 ? declared : [path.parse(path.resolve(candidate)).root];
975
+ if (!roots.some((root) => within(candidate, root))) continue;
970
976
  if (!found.includes(candidate)) found.push(candidate);
971
977
  }
972
978
  // The volume each target sits on, so a caller can group or filter by drive.
@@ -996,10 +1002,10 @@ export async function measurePath(abs, cutoff) {
996
1002
  const stat = statReal(abs);
997
1003
  const eligible = cutoff === undefined || stat.mtimeMs <= cutoff;
998
1004
  return eligible
999
- ? { bytes: stat.size, files: 1, totalBytes: stat.size, totalFiles: 1 }
1000
- : { bytes: 0, files: 0, totalBytes: stat.size, totalFiles: 1 };
1005
+ ? { bytes: stat.size, files: 1, totalBytes: stat.size, totalFiles: 1, truncated: false }
1006
+ : { bytes: 0, files: 0, totalBytes: stat.size, totalFiles: 1, truncated: false };
1001
1007
  } catch {
1002
- return { bytes: 0, files: 0, totalBytes: 0, totalFiles: 0 };
1008
+ return { bytes: 0, files: 0, totalBytes: 0, totalFiles: 0, truncated: false };
1003
1009
  }
1004
1010
  }
1005
1011
  let bytes = 0;
@@ -1040,7 +1046,9 @@ export async function measurePath(abs, cutoff) {
1040
1046
  /* an unreadable directory ends this branch */
1041
1047
  }
1042
1048
  }
1043
- return { bytes, files, totalBytes, totalFiles };
1049
+ // A tree larger than the cap was only partly measured, which the caller must say out loud:
1050
+ // the dry run no longer equals what a real run frees.
1051
+ return { bytes, files, totalBytes, totalFiles, truncated: stack.length > 0 };
1044
1052
  }
1045
1053
 
1046
1054
  /**
@@ -1058,14 +1066,16 @@ export async function scan(only, drive) {
1058
1066
  let files = 0;
1059
1067
  let totalBytes = 0;
1060
1068
  let totalFiles = 0;
1069
+ let truncated = false;
1061
1070
  for (const abs of target.paths) {
1062
1071
  const size = await measurePath(abs, cutoff);
1063
1072
  bytes += size.bytes;
1064
1073
  files += size.files;
1065
1074
  totalBytes += size.totalBytes;
1066
1075
  totalFiles += size.totalFiles;
1076
+ truncated = truncated || size.truncated === true;
1067
1077
  }
1068
- return { ...target, bytes, files, totalBytes, totalFiles };
1078
+ return { ...target, bytes, files, totalBytes, totalFiles, truncated };
1069
1079
  }),
1070
1080
  );
1071
1081
  return measured;
@@ -1077,7 +1087,10 @@ async function purgePath(abs, cutoff) {
1077
1087
  let failed = 0;
1078
1088
  const dirs = [];
1079
1089
  const stack = [abs];
1080
- while (stack.length > 0 && dirs.length < MAX_ENTRIES) {
1090
+ // The cap is counted in entries visited, the same unit measurePath uses; counting directories
1091
+ // here meant the two walks stopped at wildly different depths.
1092
+ let seen = 0;
1093
+ while (stack.length > 0 && seen < MAX_ENTRIES) {
1081
1094
  const dir = stack.pop();
1082
1095
  dirs.push(dir);
1083
1096
  let handle;
@@ -1089,6 +1102,7 @@ async function purgePath(abs, cutoff) {
1089
1102
  }
1090
1103
  try {
1091
1104
  for await (const entry of handle) {
1105
+ seen += 1;
1092
1106
  const full = path.join(dir, entry.name);
1093
1107
  if (entry.isSymbolicLink()) continue;
1094
1108
  if (entry.isDirectory()) {
@@ -1111,14 +1125,19 @@ async function purgePath(abs, cutoff) {
1111
1125
  }
1112
1126
  }
1113
1127
  // Deepest first; index 0 is the target itself, which stays as an empty directory.
1128
+ let blocked = 0;
1114
1129
  for (let i = dirs.length - 1; i > 0; i -= 1) {
1115
1130
  try {
1116
1131
  await fsp.rmdir(dirs[i]);
1117
- } catch {
1118
- /* still holds a kept or undeletable file, which is expected */
1132
+ } catch (error) {
1133
+ // A folder that still holds a file past the age limit is expected and costs nothing.
1134
+ // A folder Windows refuses to remove is not: its bytes stay on disk, so they are counted.
1135
+ const code = error && error.code;
1136
+ if (code === 'ENOTEMPTY' || code === 'EEXIST') continue;
1137
+ blocked += 1;
1119
1138
  }
1120
1139
  }
1121
- return { bytes, removed, failed };
1140
+ return { bytes, removed, failed, blocked, truncated: stack.length > 0 };
1122
1141
  }
1123
1142
 
1124
1143
  /** Delete the contents of the selected targets, or with `dryRun` only report what it would delete. */
@@ -1131,17 +1150,27 @@ export async function clean(ids, options = {}) {
1131
1150
  if (dryRun) {
1132
1151
  let bytes = 0;
1133
1152
  let files = 0;
1153
+ let failed = 0;
1154
+ let truncated = false;
1134
1155
  for (const abs of target.paths) {
1156
+ // The real run refuses a path this broad, so the estimate has to refuse it too, or it
1157
+ // promises bytes the deletion will never free.
1158
+ if (tooBroad(abs)) {
1159
+ failed += 1;
1160
+ continue;
1161
+ }
1135
1162
  const size = await measurePath(abs, cutoff);
1136
1163
  bytes += size.bytes;
1137
1164
  files += size.files;
1165
+ truncated = truncated || size.truncated === true;
1138
1166
  }
1139
- results.push({ id: target.id, bytes, removed: files, failed: 0 });
1167
+ results.push({ id: target.id, bytes, removed: files, failed, truncated });
1140
1168
  continue;
1141
1169
  }
1142
1170
  let bytes = 0;
1143
1171
  let removed = 0;
1144
1172
  let failed = 0;
1173
+ let truncated = false;
1145
1174
  for (const abs of target.paths) {
1146
1175
  if (tooBroad(abs)) {
1147
1176
  failed += 1;
@@ -1162,9 +1191,10 @@ export async function clean(ids, options = {}) {
1162
1191
  const outcome = await purgePath(abs, cutoff);
1163
1192
  bytes += outcome.bytes;
1164
1193
  removed += outcome.removed;
1165
- failed += outcome.failed;
1194
+ failed += outcome.failed + (outcome.blocked ?? 0);
1195
+ truncated = truncated || outcome.truncated === true;
1166
1196
  }
1167
- results.push({ id: target.id, bytes, removed, failed });
1197
+ results.push({ id: target.id, bytes, removed, failed, truncated });
1168
1198
  }
1169
1199
  return results;
1170
1200
  }
package/lib/client.js CHANGED
@@ -41,7 +41,8 @@ window.__ModuleLoader__.load({
41
41
  'why_pending-update': '只清 {n} 天前的下载,避免删掉还没装的更新',
42
42
  why_rollback: '每个插件/程序只留版本号最高的一份,其余是回滚副本',
43
43
  why_superseded: '版本号不高于已装版本的更新包,装着的那份已经生效',
44
- why_orphan: '写这个目录的程序已经不在这台机器上了:没有快捷方式、没有卸载项,两年多没写过文件',
44
+ why_orphan: 'QQ 频道数据目录:目录内 30 天没有任何文件写入时会被整目录清除',
45
+ why_irreversible: '回收站里的东西已经被删过一次,清空后无法恢复',
45
46
  why_history: '每个会话的整文件备份,删了不影响会话本身',
46
47
  why_duplicate: '内容完全相同的重复副本,保留最新那份',
47
48
  'why_database-backup': '升级前的数据库副本,正在用的数据库还在旁边',
@@ -52,6 +53,9 @@ window.__ModuleLoader__.load({
52
53
  scanError: '扫描失败',
53
54
  cleanError: '清理失败',
54
55
  cleanConfirm: '删除选中缓存目录里的文件?正在使用的文件会被跳过。',
56
+ cleanConfirmList: '即将清理 {n} 项,合计 {bytes}:\n\n{list}\n\n回收站等不可恢复的项目会单独标出。',
57
+ cleanConfirmIrreversible: '\n\n注意:其中「回收站」清空后无法恢复。',
58
+ truncatedHint: '这个目录太大,只统计/清理了前 {n} 条,实际释放可能少于这个数。',
55
59
  'target_temp': '用户临时文件',
56
60
  'target_windows-temp': 'Windows 临时文件',
57
61
  'target_npm-cache': 'npm 缓存',
@@ -64,7 +68,7 @@ window.__ModuleLoader__.load({
64
68
  'target_code-cache': 'VS Code 缓存',
65
69
  'target_inet-cache': '系统网络缓存',
66
70
  'target_windows-update': 'Windows 更新缓存',
67
- 'target_recycle-bin': '回收站',
71
+ 'target_recycle-bin': '回收站(清空不可恢复)',
68
72
  'target_app-updaters': '更新安装包残留',
69
73
  'target_app-caches': '各应用缓存',
70
74
  'target_app-logs': '各应用日志',
@@ -81,7 +85,7 @@ window.__ModuleLoader__.load({
81
85
  'target_rewind-snapshots': '过期的回滚快照',
82
86
  'target_rotated-logs': '轮转掉的日志',
83
87
  'target_qoder-old-versions': '编辑器少用的旧版本目录',
84
- 'target_orphan-app-data': '已卸载程序的残留数据',
88
+ 'target_orphan-app-data': 'QQ 频道数据(长期未写入)',
85
89
  'target_app-data-caches': '已停用应用的缓存',
86
90
  'target_office-update-cache': 'Office 更新暂存包',
87
91
  group_system: '系统临时与回收站',
@@ -90,7 +94,7 @@ window.__ModuleLoader__.load({
90
94
  group_app: '应用缓存与日志',
91
95
  group_updates: '升级残留',
92
96
  group_history: '历史备份与快照',
93
- group_orphan: '已卸载程序残留',
97
+ group_orphan: '长期未动的应用数据',
94
98
  group_other: '其他',
95
99
  groupExpanded: '收起这一类',
96
100
  groupFolded: '展开这一类',
@@ -213,7 +217,8 @@ window.__ModuleLoader__.load({
213
217
  'why_pending-update': 'only downloads older than {n} day(s), so a pending update survives',
214
218
  why_rollback: 'only the highest version of each family stays; the rest are rollback copies',
215
219
  why_superseded: 'the update package version is not above what is installed, so it is already applied',
216
- why_orphan: 'the app that wrote this folder is gone: no shortcut, no uninstall entry, nothing written for over two years',
220
+ why_orphan: 'the QQGuild data folder, deleted in full when no file in it has been written for 30 days',
221
+ why_irreversible: 'everything here was already deleted once; emptying it cannot be undone',
217
222
  why_history: 'a whole-file backup per session; removing it does not touch the session',
218
223
  why_duplicate: 'an identical copy; the newest one is kept',
219
224
  'why_database-backup': 'a copy made before an upgrade; the live database is beside it',
@@ -224,6 +229,9 @@ window.__ModuleLoader__.load({
224
229
  scanError: 'Scan failed',
225
230
  cleanError: 'Clean failed',
226
231
  cleanConfirm: 'Delete the files in the selected cache directories? Files in use are skipped.',
232
+ cleanConfirmList: 'About to clean {n} target(s), {bytes} in total:\n\n{list}\n\nAnything irreversible is called out separately.',
233
+ cleanConfirmIrreversible: '\n\nNote: emptying the Recycle Bin cannot be undone.',
234
+ truncatedHint: 'This folder is too large: only the first {n} entries were measured or removed, so less may be freed.',
227
235
  'target_temp': 'User temp files',
228
236
  'target_windows-temp': 'Windows temp files',
229
237
  'target_npm-cache': 'npm cache',
@@ -236,7 +244,7 @@ window.__ModuleLoader__.load({
236
244
  'target_code-cache': 'VS Code cache',
237
245
  'target_inet-cache': 'System internet cache',
238
246
  'target_windows-update': 'Windows update cache',
239
- 'target_recycle-bin': 'Recycle Bin',
247
+ 'target_recycle-bin': 'Recycle Bin (emptying is permanent)',
240
248
  'target_app-updaters': 'Leftover update installers',
241
249
  'target_app-caches': 'App caches',
242
250
  'target_app-logs': 'App logs',
@@ -253,7 +261,7 @@ window.__ModuleLoader__.load({
253
261
  'target_rewind-snapshots': 'Expired rewind snapshots',
254
262
  'target_rotated-logs': 'Rotated-out log files',
255
263
  'target_qoder-old-versions': 'Old versions the editor no longer runs',
256
- 'target_orphan-app-data': 'Leftover data from an uninstalled app',
264
+ 'target_orphan-app-data': 'QQ Guild data (long untouched)',
257
265
  'target_app-data-caches': 'Caches of apps that stopped writing',
258
266
  'target_office-update-cache': 'Office update staging packages',
259
267
  group_system: 'System temp and recycle bin',
@@ -262,7 +270,7 @@ window.__ModuleLoader__.load({
262
270
  group_app: 'App caches and logs',
263
271
  group_updates: 'Leftover updaters',
264
272
  group_history: 'Backups and snapshots',
265
- group_orphan: 'Data from uninstalled apps',
273
+ group_orphan: 'Long-untouched app data',
266
274
  group_other: 'Other',
267
275
  groupExpanded: 'Collapse this group',
268
276
  groupFolded: 'Expand this group',
@@ -413,6 +421,7 @@ window.__ModuleLoader__.load({
413
421
  .dsc-notice { margin-top: 10px; padding: 8px 10px; border-radius: 7px; font-size: 12px; line-height: 1.6; background: var(--dsw-alias-bg-layer-2); }
414
422
  .dsc-notice.dsc-ok { border-left: 3px solid var(--dsw-alias-state-success-primary); }
415
423
  .dsc-notice.dsc-err { border-left: 3px solid var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); }
424
+ .dsc-notice.dsc-warn { border-left: 3px solid var(--dsw-alias-state-warn-primary); }
416
425
  .dsc-audit-hint { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.7; margin: -4px 0 10px; }
417
426
  .dsc-plan { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--dsw-alias-border-l2); }
418
427
  .dsc-plan-step { display: flex; flex-direction: column; gap: 6px; padding: 10px 0; border-bottom: 1px solid var(--dsw-alias-border-l2); }
@@ -461,6 +470,12 @@ window.__ModuleLoader__.load({
461
470
  };
462
471
  }
463
472
 
473
+ /**
474
+ * Targets a clean cannot undo. These are never preselected and never swept in by "select
475
+ * all": emptying the recycle bin is not a cache rebuild, it is the second half of a delete.
476
+ */
477
+ const IRREVERSIBLE = new Set(['recycle-bin']);
478
+
464
479
  function formatBytes(bytes) {
465
480
  if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
466
481
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
@@ -723,10 +738,14 @@ window.__ModuleLoader__.load({
723
738
  const targets = Array.isArray(data.targets) ? data.targets : [];
724
739
  setRows(targets);
725
740
  const want = defaultPick.get();
741
+ // "all" means every rebuildable target; it never means the irreversible ones.
726
742
  const preselect =
727
743
  want === 'none'
728
744
  ? []
729
- : targets.filter((row) => row.bytes > 0 && (want === 'all' || row.risk === 'safe')).map((row) => row.id);
745
+ : targets
746
+ .filter((row) => row.bytes > 0 && !IRREVERSIBLE.has(row.id))
747
+ .filter((row) => want === 'all' || row.risk === 'safe')
748
+ .map((row) => row.id);
730
749
  setPicked(new Set(preselect));
731
750
  } catch (err) {
732
751
  setError(`${t('scanError')}: ${err instanceof Error ? err.message : String(err)}`);
@@ -755,13 +774,29 @@ window.__ModuleLoader__.load({
755
774
 
756
775
  const pickAll = (on) => {
757
776
  if (!shown) return;
758
- setPicked(on ? new Set(visibleRows().filter((row) => row.bytes > 0).map((row) => row.id)) : new Set());
777
+ // "Select all" means every rebuildable, non-empty row on screen; it never reaches an
778
+ // irreversible target. One line, because the shape is asserted in smoke.mjs.
779
+ const eligible = on ? visibleRows().filter((row) => row.bytes > 0 && !IRREVERSIBLE.has(row.id)).map((row) => row.id) : [];
780
+ setPicked(new Set(eligible));
759
781
  };
760
782
 
761
783
  const runClean = async () => {
762
784
  if (!rows || picked.size === 0) return;
763
- if (confirmFirst.get() === 'always' && !window.confirm(t('cleanConfirm'))) return;
764
- const ids = shown.filter((row) => picked.has(row.id)).map((row) => row.id);
785
+ const chosen = shown.filter((row) => picked.has(row.id));
786
+ if (confirmFirst.get() === 'always') {
787
+ // A confirm box that does not say what it deletes is not a confirmation. Name the
788
+ // targets and the total, and call out anything that cannot be undone.
789
+ const bytes = chosen.reduce((sum, row) => sum + (row.bytes || 0), 0);
790
+ const list = chosen.map((row) => `· ${targetName(t, row.id)} — ${formatBytes(row.bytes)}`).join('\n');
791
+ const irreversible = chosen.some((row) => IRREVERSIBLE.has(row.id));
792
+ const text =
793
+ t('cleanConfirmList')
794
+ .replace('{n}', String(chosen.length))
795
+ .replace('{bytes}', formatBytes(bytes))
796
+ .replace('{list}', list) + (irreversible ? t('cleanConfirmIrreversible') : '');
797
+ if (!window.confirm(text)) return;
798
+ }
799
+ const ids = chosen.map((row) => row.id);
765
800
  setBusy('clean');
766
801
  setError('');
767
802
  setNotice(null);
@@ -771,10 +806,14 @@ window.__ModuleLoader__.load({
771
806
  const bytes = results.reduce((sum, item) => sum + (item.bytes || 0), 0);
772
807
  const files = results.reduce((sum, item) => sum + (item.removed || 0), 0);
773
808
  const failed = results.reduce((sum, item) => sum + (item.failed || 0), 0);
774
- setNotice({
775
- kind: 'ok',
776
- text: `${t('done')} ${formatBytes(bytes)} · ${files} ${t('files')}${failed > 0 ? ` · ${t('failed')} ${failed} (${t('failedHint')})` : ''}`,
777
- });
809
+ const truncated = results.filter((item) => item.truncated === true).length > 0;
810
+ // A run that left files behind is not a success banner: an all-ok green line over
811
+ // "12 skipped" reads as done when it is not.
812
+ const kind = failed > 0 ? 'warn' : 'ok';
813
+ const parts = [`${t('done')} ${formatBytes(bytes)}`, `${files} ${t('files')}`];
814
+ if (failed > 0) parts.push(`${t('failed')} ${failed} (${t('failedHint')})`);
815
+ if (truncated) parts.push(t('truncatedHint').replace('{n}', String(results.length)));
816
+ setNotice({ kind, text: parts.join(' · ') });
778
817
  await scan();
779
818
  } catch (err) {
780
819
  setError(`${t('cleanError')}: ${err instanceof Error ? err.message : String(err)}`);
@@ -1152,7 +1191,19 @@ window.__ModuleLoader__.load({
1152
1191
 
1153
1192
  /** Run a plan step the plugin is allowed to run, then re-measure so the plan is honest. */
1154
1193
  const runStep = async (step) => {
1155
- if (confirmFirst.get() === 'always' && !window.confirm(t('cleanConfirm'))) return;
1194
+ if (confirmFirst.get() === 'always') {
1195
+ // The same confirmation as the main button: a plan step is still a delete, and
1196
+ // `reclaim-caution` can hold an irreversible target.
1197
+ const ids = Array.isArray(step.ids) ? step.ids : [];
1198
+ const list = ids.map((id) => `· ${targetName(t, id)}`).join('\n');
1199
+ const irreversible = ids.some((id) => IRREVERSIBLE.has(id));
1200
+ const text =
1201
+ t('cleanConfirmList')
1202
+ .replace('{n}', String(ids.length))
1203
+ .replace('{bytes}', formatBytes(step.bytes || 0))
1204
+ .replace('{list}', list) + (irreversible ? t('cleanConfirmIrreversible') : '');
1205
+ if (!window.confirm(text)) return;
1206
+ }
1156
1207
  setBusy(true);
1157
1208
  setError('');
1158
1209
  try {
package/lib/index.js CHANGED
@@ -164,6 +164,8 @@ async function run(action, payload) {
164
164
  // it a folder holding 261 MB reads as "0.2 MB" and looks like nothing was found.
165
165
  totalBytes: target.totalBytes,
166
166
  totalFiles: target.totalFiles,
167
+ // Set when the walk hit its cap, so a caller can say the totals are a lower bound.
168
+ truncated: target.truncated === true,
167
169
  })),
168
170
  };
169
171
  }
@@ -210,7 +212,8 @@ const NOTE_MEANING = {
210
212
  history: 'a backup snapshot of file edits that nothing reads back; the live state sits beside it',
211
213
  duplicate: 'a byte-identical copy of a directory that is still present',
212
214
  'database-backup': 'a copy of a database file taken before an upgrade; the live database is beside it',
213
- orphan: 'the app that wrote this folder is no longer installed: no shortcut, no uninstall entry, and nothing written for over two years',
215
+ orphan: 'the QQGuild data folder is deleted in full when no file in it has been written for 30 days',
216
+ irreversible: 'the recycle bin: everything in it was already deleted once, and emptying it cannot be undone',
214
217
  };
215
218
 
216
219
  const formatBytes = (bytes) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-disk-cleaner",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "type": "module",
5
5
  "description": "Disk Cleaner for DSH: scan known-safe cache locations, see what they occupy, and reclaim the space from a panel or from the agent.",
6
6
  "keywords": [
package/smoke.mjs CHANGED
@@ -262,6 +262,9 @@ check('tool description warns audit is read-only', /read-only: it never removes
262
262
  check('every effect is labelled', applied.every((label) => typeof label === 'string' && label.startsWith('dsh-disk-cleaner:')));
263
263
  check('tool output schema matches its string reply', definition.output.schema.type === 'string' && typeof (await definition.execute({ action: 'overview' })) === 'string');
264
264
  check('the read-only agent clean keeps the audit cache in sync', /if \(!dryRun\) invalidateAudit\(\);[\s\S]*const freed = results\.reduce/.test(index));
265
+ check('orphan target descriptions state the real 30-day rule, not four signals / two years',
266
+ /30 days/.test(catalog) && /30 days/.test(index) && /QQGuild data folder/.test(source) &&
267
+ !/four independent signals|over two years|no Start-menu shortcut|two years/.test(catalog + index + source));
265
268
 
266
269
  const ids = [...catalog.matchAll(/^\s{4}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
267
270
  check('catalog has the documented 32 targets', ids.length === 32, `got ${ids.length}`);
@@ -347,5 +350,66 @@ check('audit imports measuring only, never the deleter', Boolean(auditImport) &&
347
350
  const pkg = JSON.parse(read('package.json'));
348
351
  check('package lists every runtime file', ['lib/index.js', 'lib/client.js', 'lib/catalog.js', 'lib/audit.js', 'lib/plan.js'].every((f) => pkg.files.includes(f)));
349
352
 
353
+ // --- 1.6.3: the delete-safety regressions ------------------------------------------------
354
+ check(
355
+ 'the recycle bin is never filed as a safe, preselected target',
356
+ /id: 'recycle-bin',[\s\S]{0,200}?risk: 'caution'/.test(catalog) &&
357
+ !/id: 'recycle-bin',[\s\S]{0,200}?risk: 'safe'/.test(catalog) &&
358
+ /const IRREVERSIBLE = new Set\(\['recycle-bin'\]\)/.test(source),
359
+ 'emptying the recycle bin cannot be undone, so it must not sit under "safe" or in "select all"',
360
+ );
361
+ check(
362
+ 'no target skips the boundary check by naming no root',
363
+ /const roots = declared\.length > 0 \? declared : \[path\.parse/.test(catalog) &&
364
+ catalog
365
+ .split(/\n \{\n/)
366
+ .filter((block) => /roots: \[\]/.test(block))
367
+ .every((block) => /id: 'recycle-bin'/.test(block)),
368
+ 'an empty roots list used to disable `within` entirely; only the recycle bin, which spans volumes, may declare none',
369
+ );
370
+ check(
371
+ 'preselection and select-all both exclude irreversible targets',
372
+ /!IRREVERSIBLE\.has\(row\.id\)/.test(source) &&
373
+ (source.match(/!IRREVERSIBLE\.has\(row\.id\)/g) ?? []).length >= 2,
374
+ );
375
+ check(
376
+ 'every confirm box names the targets and the total',
377
+ (source.match(/t\('cleanConfirmList'\)/g) ?? []).length === 2 &&
378
+ (source.match(/t\('cleanConfirmIrreversible'\)/g) ?? []).length === 2 &&
379
+ !/window\.confirm\(t\('cleanConfirm'\)\)/.test(source),
380
+ 'a bare "delete these?" is not a confirmation, and the plan step is still a delete',
381
+ );
382
+ check(
383
+ 'a clean that left files behind is not reported as a green success',
384
+ /const kind = failed > 0 \? 'warn' : 'ok'/.test(source) && /\.dsc-notice\.dsc-warn/.test(source),
385
+ );
386
+ check(
387
+ 'a truncated walk says so instead of claiming exact totals',
388
+ catalog.includes('truncated: stack.length > 0') &&
389
+ catalog.includes('truncated = truncated || size.truncated === true') &&
390
+ index.includes('truncated: target.truncated === true') &&
391
+ source.includes("t('truncatedHint')"),
392
+ );
393
+ check(
394
+ 'measure and purge count entries in the same unit',
395
+ /let seen = 0;\n while \(stack\.length > 0 && seen < MAX_ENTRIES\)/.test(catalog) && /seen \+= 1;/.test(catalog),
396
+ );
397
+ check(
398
+ 'a refused directory removal is counted, a legitimately full one is not',
399
+ /code === 'ENOTEMPTY' \|\| code === 'EEXIST'/.test(catalog) && /blocked \+= 1/.test(catalog),
400
+ );
401
+ check(
402
+ 'the dry run applies the same too-broad guard as the real run',
403
+ (catalog.match(/if \(tooBroad\(abs\)\) \{/g) ?? []).length === 2,
404
+ 'an estimate that skips the guard promises bytes the delete will refuse',
405
+ );
406
+ check(
407
+ 'the orphan target is named for what it actually measures',
408
+ !/已卸载程序的残留数据|Leftover data from an uninstalled app|已卸载程序残留|Data from uninstalled apps/.test(source) &&
409
+ source.includes('QQ') ,
410
+ 'it only ever measures the QQGuild folder',
411
+ );
412
+ check('the version badge and package agree', pkg.version === '1.6.3');
413
+
350
414
  console.log(`\n${pass} passed, ${fail} failed`);
351
415
  if (fail > 0) process.exit(1);