dsh-disk-cleaner 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <!-- badges -->
6
6
  | | |
7
7
  | --- | --- |
8
- | **version** | `1.3.0` |
8
+ | **version** | `1.5.0` |
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** — twenty-four 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-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.
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.
@@ -27,9 +27,9 @@ Both the panel and the agent reach the *same* operations: the cleaning logic liv
27
27
 
28
28
  | Feature | Description |
29
29
  | --- | --- |
30
- | Scan | Measures all twenty-four targets concurrently and shows what each occupies |
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`) 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`) 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 |
@@ -70,6 +70,14 @@ Every path is built from this table — a caller selects an **id**, never a path
70
70
  | `toolchain-snapshots` | duplicate snapshots under `%USERPROFILE%\.dsh\dsh-ops\bash-ipc`: folders whose `.ops-ipc.json` carries the same `patchedSha256` collapse to the newest | purge | caution | a folder without that metadata is never touched |
71
71
  | `editor-server-bundles` | every bundle but the newest under `%USERPROFILE%\vscode-remote-wsl\stable` | purge | caution | the editor re-downloads the one it needs |
72
72
  | `database-backups` | `zotero.sqlite*.bak` under `%USERPROFILE%\Zotero` | purge | caution | the live `zotero.sqlite` sits beside the copies |
73
+ | `editor-builds` | complete build trees under `%LOCALAPPDATA%\Programs\Microsoft VS Code` that `bin\code.cmd` does not name, plus `old_*` files whose original still sits beside them | purge | caution | no launcher, a launcher naming a missing build, or a lone build means nothing is dropped |
74
+ | `agent-app-caches` | `Cache` / `Code Cache` / `GPUCache` under `%USERPROFILE%\.workbuddy\app` and `\.workbuddy-ai\app` | purge | caution | the sibling `cache` folder holds spilled conversation text and is never a target |
75
+ | `rewind-snapshots` | `session-*` folders under `%USERPROFILE%\.dsh\rewind-snapshots` | aged, 30 days | caution | a recent session stays restorable |
76
+ | `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 |
77
+ | `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 |
78
+ | `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
+ | `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 |
80
+ | `office-update-cache` | `%LOCALAPPDATA%\Microsoft\Office\SolutionPackages` - update packages Office already applied | purge | caution | Office stages them before applying; nothing reads them back afterwards |
73
81
 
74
82
  `aged` targets skip anything modified within the retention window; `purge` targets remove every entry. Both keep the target directory itself and leave locked entries alone, counting them as skipped.
75
83
 
@@ -147,7 +155,10 @@ These are enforced in `lib/catalog.js` and must not be relaxed:
147
155
  - Rules that pick "the newest of a group" fail closed: a family with only one member, a folder
148
156
  whose metadata file is missing or whose hash is not a string, and a `vscode-remote-wsl\stable`
149
157
  that holds a single bundle all resolve to *nothing to delete*. The newest member is never returned.
158
+ - `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.
150
159
  - `database-backups` matches `.sqlite*.bak` by name only; the live `zotero.sqlite` is never a candidate.
160
+ - `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.
161
+ - `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.
151
162
  - 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.
152
163
 
153
164
  ### Install
@@ -184,7 +195,7 @@ The bundle's own `cordis.patch.yml` inserts the plugin row; no profile patch edi
184
195
  ### Development
185
196
 
186
197
  ```bash
187
- node smoke.mjs # 67 assertions, zero dependencies
198
+ node smoke.mjs # 69 assertions, zero dependencies
188
199
  node --check lib/client.js # and lib/index.js, lib/catalog.js, lib/audit.js, lib/plan.js
189
200
  ```
190
201
 
@@ -209,6 +220,8 @@ What the smoke test deliberately does **not** cover: anything that walks a real
209
220
  | Drive filter rejects unmounted and malformed volumes | `C:extra`, `Z:`, `abc` → 400 |
210
221
  | Plan excludes elevation bytes from `reclaimNow` | Live audit against this machine |
211
222
  | "Keep only the newest of a group" fails closed | Throwaway fixture tree: a same-hash duplicate collapses to the newest, a folder with no metadata is left alone, a different hash is kept, the live database survives (9/9) |
223
+ | Old editor builds need a launcher that proves which one runs | Throwaway fixture tree: a build the launcher does not name goes, no launcher deletes nothing, a launcher naming a missing build deletes nothing, a lone build stays, `old_x` goes only while `x` exists (7/7) |
224
+ | 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 |
212
225
  | Visual layout | Checked by hand in the running Web UI. No browser automation here, so this one is a human check, not a test. |
213
226
 
214
227
  ### Implementation notes
@@ -234,15 +247,15 @@ What the smoke test deliberately does **not** cover: anything that walks a real
234
247
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
235
248
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
236
249
  ├─ lib/index.js (411) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
237
- ├─ lib/catalog.js (836) The 24 targets, measurePath / purgePath / resolveTargets / drives
250
+ ├─ lib/catalog.js (1152) The 32 targets, measurePath / purgePath / resolveTargets / drives
238
251
  ├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
239
252
  ├─ lib/plan.js (150) Pure: measurements → ordered steps
240
- ├─ lib/client.js (1371) Client half: slots, panel, preferences, zh/en dictionaries, CSS
253
+ ├─ lib/client.js (1397) Client half: slots, panel, preferences, zh/en dictionaries, CSS
241
254
  ├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
242
255
  ├─ icon.svg Plugin icon
243
256
  ├─ README.md This file
244
257
  ├─ LICENSE MIT
245
- └─ smoke.mjs 67 assertions, zero dependencies
258
+ └─ smoke.mjs 69 assertions, zero dependencies
246
259
  ```
247
260
 
248
261
  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).
@@ -254,7 +267,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
254
267
 
255
268
  **dsh-disk-cleaner** 是 [DeepSeek Harness](https://github.com/deepseek-ai)(DSH)的磁盘空间插件。它做两件不同的事,并且刻意分开:
256
269
 
257
- 1. **清理** —— 二十四个已知安全的位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站、更新安装包残留、各应用的缓存与日志、被淘汰的插件版本与已应用的更新包)。只有这些会被删除。
270
+ 1. **清理** —— 三十二个已知安全的位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站、更新安装包残留、各应用的缓存与日志、被淘汰的插件版本与已应用的更新包)。只有这些会被删除。
258
271
  2. **评估** —— 所有本插件**不会**删的东西(已装程序、WSL 虚拟磁盘、Windows 组件库、聊天软件数据、你自己的文件夹)。只测量、只列出,绝不删除。
259
272
 
260
273
  评估的最后是一份**处理方案**:同一批数字按「你要付出多少代价」排序,本插件做不到的步骤附上确切的 Windows 命令。
@@ -265,9 +278,9 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
265
278
 
266
279
  | 功能 | 说明 |
267
280
  | --- | --- |
268
- | 扫描 | 并发测量全部二十四个目标,显示各自占用 |
281
+ | 扫描 | 并发测量全部三十二个目标,显示各自占用 |
269
282
  | 按盘过滤 | 已挂载卷的胶囊按钮;扫描与清理都可限定单盘,该盘上为空的项会隐藏 |
270
- | 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup`),图例常驻,所以「注意」永远不是「说不清」 |
283
+ | 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup` / `orphan`),图例常驻,所以「注意」永远不是「说不清」 |
271
284
  | 保留期删除 | `temp` 与 `windows-temp` 只删一天前的文件;被运行中程序占住的自动跳过 |
272
285
  | 空间评估 | 只读遍历磁盘,按 Windows 自身 / 已装软件 / 你自己的文件 分组,另列占用最大的十个应用数据目录。每一行都能打开所在目录——单击、右键或回车 |
273
286
  | 重新评估真的重测 | 「重新评估」会重新测量;普通调用仍然走十分钟缓存 |
@@ -308,6 +321,14 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
308
321
  | `toolchain-snapshots` | `%USERPROFILE%\.dsh\dsh-ops\bash-ipc` 下重复的快照:`.ops-ipc.json` 里 `patchedSha256` 相同的目录只留最新一份 | 全清 | 注意 | 没有该元数据的目录永不触碰 |
309
322
  | `editor-server-bundles` | `%USERPROFILE%\vscode-remote-wsl\stable` 下除最新以外的服务包 | 全清 | 注意 | 编辑器需要时会重新下载 |
310
323
  | `database-backups` | `%USERPROFILE%\Zotero` 下的 `zotero.sqlite*.bak` | 全清 | 注意 | 正在用的 `zotero.sqlite` 就在副本旁边 |
324
+ | `editor-builds` | `%LOCALAPPDATA%\Programs\Microsoft VS Code` 下 `bin\code.cmd` 未指向的完整构建目录,以及原件仍在旁边的 `old_*` 文件 | 全清 | 注意 | 没有启动器、启动器指向的构建不在磁盘上、或只有一份构建时,一律不删 |
325
+ | `agent-app-caches` | `%USERPROFILE%\.workbuddy\app` 与 `\.workbuddy-ai\app` 下的 `Cache` / `Code Cache` / `GPUCache` | 全清 | 注意 | 同级的 `cache` 目录存的是对话溢写内容,从不作为目标 |
326
+ | `rewind-snapshots` | `%USERPROFILE%\.dsh\rewind-snapshots` 下的 `session-*` 目录 | 保留期 30 天 | 注意 | 最近的会话仍然可以回滚 |
327
+ | `rotated-logs` | 各 agent 应用 `logs` 目录下的 `*.old.log` 与 `*.log.N` 文件 | 保留期 3 天 | 安全 | 轮转掉的日志不会被读回 |
328
+ | `qoder-old-versions` | `%LOCALAPPDATA%\Programs\Qoder\.qoder-versions` 下不是启动器所指的那个版本目录 | 全清 | 注意 | 启动器 `state.ini` 写明它跑哪个版本;读不到就一个都不删 |
329
+ | `orphan-app-data` | `%LOCALAPPDATA%\Tencent\QQGuild` —— 没有快捷方式、没有卸载项、两年多没写过文件的程序留下的数据 | 全清 | 注意 | 四条证据必须同时成立才会被列为可删 |
330
+ | `app-data-caches` | `%LOCALAPPDATA%` 下 Electron、钉钉、剪映、微信开发者工具的 Chromium 缓存 | 保留期 7 天 | 注意 | 还在跑的程序会不停改写缓存,所以只有一周没动过的才算 |
331
+ | `office-update-cache` | `%LOCALAPPDATA%\Microsoft\Office\SolutionPackages` —— Office 已应用的更新暂存包 | 全清 | 注意 | Office 先暂存再应用,应用后没有东西会读回 |
311
332
 
312
333
  保留期方式会跳过保留期内修改过的文件;全清方式清掉全部条目。两者都保留目标目录本身,并把锁定或无权限的条目跳过计数。
313
334
 
@@ -323,8 +344,8 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
323
344
  | `reclaim-caution` | 注意目标共 ≥200 MB(不含需提权的) | 是,一个按钮 |
324
345
  | `wsl-compact` | WSL 映像 ≥1 GB | 否 —— `wsl --shutdown` + `diskpart` 的 `compact vdisk` |
325
346
  | `winsxs-cleanup` | WinSxS ≥1 GB | 否 —— `Dism.exe /Online /Cleanup-Image /StartComponentCleanup` |
326
- | `elevation-clean` | 需提权目标 ≥200 MB | 否 —— 以管理员身份运行本插件 |
327
347
  | `hibernate-off` | `system-files` ≥200 MB | 否 —— `powercfg.exe /hibernate off` |
348
+ | `elevation-clean` | 需提权目标 ≥200 MB | 否 —— 以管理员身份运行本插件 |
328
349
  | `app-review` | 单个应用数据目录 ≥1 GB,取前 3 个且未被其它步骤覆盖 | 否 —— 走卸载,绝不删目录 |
329
350
  | `move-folder` | 个人文件夹 ≥2 GB,且另有盘有空间 | 否 —— 属性 → 位置 |
330
351
  | `pressure` | 系统盘可用 < 12% | 否 —— 作为上述步骤的背景 |
@@ -384,7 +405,10 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
384
405
  - 保留目标目录本身,只清内容。
385
406
  - 「只留最新一份」这类规则一律选保守的一侧:族内只有一个成员、元数据文件缺失或哈希不是字符串、
386
407
  `vscode-remote-wsl\stable` 下只有一个服务包,结果都是*没有可删项*。最新那一份永远不会被返回。
408
+ - `editor-builds` 只有在 `bin\code.cmd` 存在且指向另一份确实在磁盘上的构建时才删;`old_x` 也只有在 `x` 仍在旁边时才删。
387
409
  - `database-backups` 只按名字匹配 `.sqlite*.bak`,正在用的 `zotero.sqlite` 永不成为候选。
410
+ - `qoder-old-versions` 从启动器的 `state.ini` 读出它实际运行哪个版本;文件缺失、解析不出、或它指的版本不在磁盘上,一律一个都不删。
411
+ - `orphan-app-data` 要求四条独立证据同时成立:开始菜单没有快捷方式、注册表没有卸载项、两个 `Program Files` 下都没有目录、两年内没有任何文件被写过。缺一条就不会列为可删。
388
412
  - 那个拒绝普通权限写入的 Windows 目录标为 `elevation`,并排除在「现在能回收」之外。
389
413
 
390
414
  ### 安装
@@ -425,7 +449,7 @@ git clone https://github.com/stallenlu/dsh-disk-cleaner
425
449
  ### 开发
426
450
 
427
451
  ```bash
428
- node smoke.mjs # 67 条断言,零依赖
452
+ node smoke.mjs # 69 条断言,零依赖
429
453
  node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
430
454
  ```
431
455
 
@@ -450,6 +474,8 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
450
474
  | 盘过滤拒绝未挂载与畸形卷名 | `C:extra`、`Z:`、`abc` → 400 |
451
475
  | 方案不把提权目标的字节算进「现在能回收」 | 在本机跑真实 audit |
452
476
  | 「只留最新一份」一律选保守一侧 | 一次性 fixture 目录:同哈希的重复项收敛到最新、无元数据的目录不动、不同哈希保留、正在用的数据库完好(9/9) |
477
+ | 旧版编辑器构建要有启动器作证才删 | 一次性 fixture 目录:启动器未指向的构建会删、没有启动器一律不删、启动器指向的构建不在磁盘上也不删、只有一份构建时不删、`old_x` 只在 `x` 仍在时删(7/7) |
478
+ | 预演报告的字节数等于真删能释放的字节数 | 30 天的回滚窗口报 263 KB 而不是 283 MB,且扫描结果与预演一致 |
453
479
  | 视觉排版 | 在运行中的 Web UI 里手工确认过。本环境没有浏览器自动化,所以这条是人眼检查,不是测试。 |
454
480
 
455
481
  ### 实现要点
@@ -475,15 +501,15 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
475
501
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
476
502
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
477
503
  ├─ lib/index.js (411) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
478
- ├─ lib/catalog.js (836) 24 个目标,measurePath / purgePath / resolveTargets / drives
504
+ ├─ lib/catalog.js (1152) 32 个目标,measurePath / purgePath / resolveTargets / drives
479
505
  ├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
480
506
  ├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
481
- ├─ lib/client.js (1371) Client 半面:slot、面板、偏好、zh/en 字典、CSS
507
+ ├─ lib/client.js (1397) Client 半面:slot、面板、偏好、zh/en 字典、CSS
482
508
  ├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
483
509
  ├─ icon.svg 插件图标
484
510
  ├─ README.md 本文件
485
511
  ├─ LICENSE MIT
486
- └─ smoke.mjs 67 条断言,零依赖
512
+ └─ smoke.mjs 69 条断言,零依赖
487
513
  ```
488
514
 
489
515
  Client 半面注册的 slot:`sidebar.panellist`(order 50,仅设置页模式下拆除)、`main`(以包名为 key)、`settings.section`(order 30,仅侧边栏模式下拆除)、`plugins.bundle.config`(以包名为 key)。
package/lib/catalog.js CHANGED
@@ -369,6 +369,93 @@ const keepNewestChild = (parent) => {
369
369
  return stamped.slice(1).map((item) => item.abs);
370
370
  };
371
371
 
372
+ /**
373
+ * An editor that ships a whole runtime per commit leaves the previous one behind after every
374
+ * update: VS Code kept two complete 764 MB trees plus `old_Code.exe`. Unlike a version holder,
375
+ * the tree names are commit hashes, so the newest mtime proves nothing — the launcher does.
376
+ * `bin\code.cmd` names the live hash, and a build is only dropped when that launcher exists,
377
+ * parses, and points at a different build that is also present. No launcher, no deletion.
378
+ *
379
+ * The renamed `old_*` copies are dead weight only while the file they duplicate still sits
380
+ * beside them; a lone `old_x` may be the only copy left, so it stays.
381
+ */
382
+ const vscodeSuperseded = () => {
383
+ const appDir = path.join(env('LOCALAPPDATA') ?? '', 'Programs', 'Microsoft VS Code');
384
+ if (!isDir(appDir)) return [];
385
+ const out = [];
386
+ let live = '';
387
+ try {
388
+ const launcher = fs.readFileSync(path.join(appDir, 'bin', 'code.cmd'), 'utf8');
389
+ const match = /[\\/]([0-9a-f]{10,})[\\/]/.exec(launcher);
390
+ if (match !== null) live = match[1];
391
+ } catch {
392
+ /* no launcher means no evidence about which build runs, so nothing is dropped */
393
+ }
394
+ if (live !== '') {
395
+ const builds = listDir(appDir)
396
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
397
+ .map((entry) => entry.name)
398
+ .filter((name) => /^[0-9a-f]{10,}$/.test(name));
399
+ if (builds.length >= 2 && builds.includes(live)) {
400
+ for (const name of builds) if (name !== live) out.push(path.join(appDir, name));
401
+ }
402
+ }
403
+ for (const dir of [appDir, path.join(appDir, 'bin')]) {
404
+ for (const entry of listDir(dir)) {
405
+ if (!entry.isFile() || entry.isSymbolicLink()) continue;
406
+ const match = /^old_(.+)$/.exec(entry.name);
407
+ if (match === null) continue;
408
+ if (!isFile(path.join(dir, match[1]))) continue;
409
+ out.push(path.join(dir, entry.name));
410
+ }
411
+ }
412
+ return out;
413
+ };
414
+
415
+ /**
416
+ * The Chromium caches an Electron-based agent app writes under its own app folder. The root
417
+ * is spelled out per app because the sibling `cache` next to it (`~/.workbuddy/cache`) holds
418
+ * spilled conversation text rather than anything regenerable — it stays.
419
+ */
420
+ const agentAppCaches = () => {
421
+ const out = [];
422
+ for (const name of ['.workbuddy', '.workbuddy-ai']) {
423
+ const app = path.join(home(), name, 'app');
424
+ out.push(...subdirs(path.join(app, 'session'), ['Cache', 'Code Cache', 'GPUCache', 'DawnCache', 'ShaderCache']));
425
+ out.push(...subdirs(app, ['cache', 'CodeCache']));
426
+ }
427
+ return out;
428
+ };
429
+
430
+ /**
431
+ * Rewind snapshots this harness writes one folder per session, never pruning. The folders are
432
+ * named by session id, so age is the only signal; the target's 30-day floor keeps a recent
433
+ * session restorable.
434
+ */
435
+ const rewindSnapshots = () => {
436
+ const root = path.join(home(), '.dsh', 'rewind-snapshots');
437
+ return listDir(root)
438
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
439
+ .map((entry) => path.join(root, entry.name));
440
+ };
441
+
442
+ /**
443
+ * A log file rotated out of the way — `daemon.old.log`, `main.log.1` — as opposed to the
444
+ * day-named folders the `agent-logs` target covers. Nothing reads a rotated file at runtime.
445
+ */
446
+ const rotatedLogs = () => {
447
+ const out = [];
448
+ for (const name of ['.workbuddy', '.workbuddy-ai', '.qoder', '.zcode', '.codebuddy']) {
449
+ const logs = path.join(home(), name, 'logs');
450
+ for (const entry of listDir(logs)) {
451
+ if (!entry.isFile() || entry.isSymbolicLink()) continue;
452
+ if (!/\.(old\.log|log\.[0-9]+)$/i.test(entry.name)) continue;
453
+ out.push(path.join(logs, entry.name));
454
+ }
455
+ }
456
+ return out;
457
+ };
458
+
372
459
  /** The one-generation database copies Zotero writes beside the library it is using. */
373
460
  const zoteroBackups = () => {
374
461
  const data = path.join(home(), 'Zotero');
@@ -378,6 +465,110 @@ const zoteroBackups = () => {
378
465
  .map((entry) => path.join(data, entry.name));
379
466
  };
380
467
 
468
+ /**
469
+ * Qoder keeps one complete build per version under `.qoder-versions` and never removes the
470
+ * one it replaced. The launcher writes the live build into `state.ini`
471
+ * (`appExecutable=.qoder-versions\0.5.3\Qoder.exe`); `install.ini` next to it still names an
472
+ * older version, so only `state.ini` is read. No readable state file means no evidence about
473
+ * which build runs, and then nothing is returned at all.
474
+ */
475
+ const qoderOldVersions = () => {
476
+ const versions = path.join(env('LOCALAPPDATA') ?? '', 'Programs', 'Qoder', '.qoder-versions');
477
+ if (!isDir(versions)) return [];
478
+ let live = '';
479
+ try {
480
+ const state = fs.readFileSync(
481
+ path.join(env('LOCALAPPDATA') ?? '', 'Qoder', 'Qoder Launcher', 'state.ini'),
482
+ 'utf8',
483
+ );
484
+ const match = /appExecutable\s*=\s*(.+)/.exec(state);
485
+ if (match !== null) {
486
+ const parts = match[1].trim().split(/[\\/]/).filter(Boolean);
487
+ const index = parts.indexOf('.qoder-versions');
488
+ if (index !== -1 && parts[index + 1] !== undefined) live = parts[index + 1];
489
+ }
490
+ } catch {
491
+ /* no state.ini, no proof of which build is live: drop nothing */
492
+ }
493
+ if (live === '') return [];
494
+ const builds = listDir(versions)
495
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
496
+ .map((entry) => entry.name);
497
+ if (builds.length < 2 || !builds.includes(live)) return [];
498
+ return builds.filter((name) => name !== live).map((name) => path.join(versions, name));
499
+ };
500
+
501
+ /**
502
+ * The plugin packages the WeChat devtools downloads per account. They are fetched again the
503
+ * next time a mini program needs one, which is why this stays behind an age limit.
504
+ */
505
+ const wechatDevtoolsCaches = () => {
506
+ const data = path.join(env('LOCALAPPDATA') ?? '', '微信开发者工具', 'User Data');
507
+ const out = [];
508
+ for (const entry of listDir(data)) {
509
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
510
+ out.push(...subdirs(path.join(data, entry.name), ['WeappPlugin', 'WeappForeignPkgs', 'WeappVendor']));
511
+ }
512
+ return out;
513
+ };
514
+
515
+ /**
516
+ * The newest modification time anywhere under a folder, or 0 when it cannot be read. Used to
517
+ * tell "this app was uninstalled" from "this app is installed but its cache happens to be old":
518
+ * only a completely quiet tree is treated as an orphan.
519
+ */
520
+ const newestMtime = (dir) => {
521
+ let newest = 0;
522
+ const stack = [dir];
523
+ while (stack.length > 0) {
524
+ const current = stack.pop();
525
+ let handle;
526
+ try {
527
+ handle = fs.opendirSync(current);
528
+ } catch {
529
+ continue;
530
+ }
531
+ let entry;
532
+ while ((entry = handle.readSync()) !== null) {
533
+ const abs = path.join(current, entry.name);
534
+ if (entry.isSymbolicLink()) continue;
535
+ if (entry.isDirectory()) {
536
+ stack.push(abs);
537
+ continue;
538
+ }
539
+ try {
540
+ const stat = statReal(abs);
541
+ if (stat.mtimeMs > newest) newest = stat.mtimeMs;
542
+ } catch {
543
+ /* unreadable file contributes nothing */
544
+ }
545
+ }
546
+ try {
547
+ handle.closeSync();
548
+ } catch {
549
+ /* already closed */
550
+ }
551
+ }
552
+ return newest;
553
+ };
554
+
555
+ /**
556
+ * The Chromium caches left in `%LOCALAPPDATA%` by apps that are gone or idle. Each root is
557
+ * listed because the app name alone proves nothing: `JianyingPro` and `DingTalk_108` have no
558
+ * install and no shortcut, while `electron` and the devtools are still in use, so the age
559
+ * limit is what keeps a live app's cache out of the result.
560
+ */
561
+ const appDataCaches = () => {
562
+ const local = env('LOCALAPPDATA') ?? '';
563
+ const out = [
564
+ path.join(local, 'electron', 'Cache'),
565
+ ...subdirs(path.join(local, 'DingTalk_108'), ['Cache', 'Code Cache', 'Service Worker']),
566
+ ...subdirs(path.join(local, 'JianyingPro', 'User Data'), ['Cache', 'Resources']),
567
+ ...wechatDevtoolsCaches(),
568
+ ];
569
+ return out.filter((candidate) => isDir(candidate));
570
+ };
571
+
381
572
  /**
382
573
  * `purge` targets drop every entry under the path. `aged` targets keep anything modified
383
574
  * within `minAgeDays`, because those files may belong to a process that is still running.
@@ -388,8 +579,9 @@ const zoteroBackups = () => {
388
579
  * refuses an unelevated delete), 'pending-update' (a download that may not be applied
389
580
  * yet), 'rollback' (an older version kept beside the live one), 'superseded' (an update
390
581
  * package the installed version already contains), 'history' (a snapshot nothing reads
391
- * back), 'duplicate' (a byte-identical copy of a live directory) or 'database-backup' (a
392
- * copy of a database file made before an upgrade). Safe targets have none.
582
+ * back), 'duplicate' (a byte-identical copy of a live directory), 'database-backup' (a copy of
583
+ * a database file made before an upgrade) or 'orphan' (the app that wrote this folder is no
584
+ * longer installed anywhere). Safe targets have none.
393
585
  */
394
586
  const CATALOG = [
395
587
  {
@@ -425,8 +617,15 @@ const CATALOG = [
425
617
  id: 'pnpm-store',
426
618
  kind: 'purge',
427
619
  risk: 'safe',
620
+ // pnpm 11 kept its content under `store`; 12 moved it to `package-manager-store` and left
621
+ // `store` behind as an empty shell, so both are listed. `pnpm-cache` holds only registry
622
+ // metadata, which pnpm downloads again on demand.
428
623
  roots: ['LOCALAPPDATA'],
429
- paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'pnpm', 'store')],
624
+ paths: () => [
625
+ path.join(env('LOCALAPPDATA') ?? '', 'pnpm', 'store'),
626
+ path.join(env('LOCALAPPDATA') ?? '', 'pnpm', 'package-manager-store'),
627
+ path.join(env('LOCALAPPDATA') ?? '', 'pnpm-cache'),
628
+ ],
430
629
  },
431
630
  {
432
631
  id: 'pip-cache',
@@ -585,6 +784,79 @@ const CATALOG = [
585
784
  roots: ['USERPROFILE'],
586
785
  paths: () => zoteroBackups(),
587
786
  },
787
+ {
788
+ id: 'editor-builds',
789
+ kind: 'purge',
790
+ risk: 'caution',
791
+ note: 'superseded',
792
+ roots: ['LOCALAPPDATA'],
793
+ paths: () => vscodeSuperseded(),
794
+ },
795
+ {
796
+ id: 'agent-app-caches',
797
+ kind: 'purge',
798
+ risk: 'caution',
799
+ note: 'rebuild',
800
+ roots: ['USERPROFILE'],
801
+ paths: () => agentAppCaches(),
802
+ },
803
+ {
804
+ id: 'rewind-snapshots',
805
+ kind: 'aged',
806
+ minAgeDays: 30,
807
+ risk: 'caution',
808
+ note: 'history',
809
+ roots: ['USERPROFILE'],
810
+ paths: () => rewindSnapshots(),
811
+ },
812
+ {
813
+ id: 'rotated-logs',
814
+ kind: 'aged',
815
+ minAgeDays: 3,
816
+ risk: 'safe',
817
+ roots: ['USERPROFILE'],
818
+ paths: () => rotatedLogs(),
819
+ },
820
+ {
821
+ id: 'qoder-old-versions',
822
+ kind: 'purge',
823
+ risk: 'caution',
824
+ note: 'rollback',
825
+ roots: ['LOCALAPPDATA'],
826
+ paths: () => qoderOldVersions(),
827
+ },
828
+ {
829
+ id: 'orphan-app-data',
830
+ kind: 'purge',
831
+ risk: 'caution',
832
+ note: 'orphan',
833
+ roots: ['LOCALAPPDATA'],
834
+ paths: () => {
835
+ const guild = path.join(env('LOCALAPPDATA') ?? '', 'Tencent', 'QQGuild');
836
+ // Measured: no Start-menu shortcut, no uninstall entry, no Program Files folder and no
837
+ // file touched for over two years. The folder also carries its own `Uninstall QQGuild.exe`,
838
+ // which only a real install does. Every one of those signals has to agree before it goes.
839
+ const recent = newestMtime(guild) > Date.now() - 30 * DAY;
840
+ return isDir(guild) && !recent ? [guild] : [];
841
+ },
842
+ },
843
+ {
844
+ id: 'app-data-caches',
845
+ kind: 'aged',
846
+ minAgeDays: 7,
847
+ risk: 'caution',
848
+ note: 'rebuild',
849
+ roots: ['LOCALAPPDATA'],
850
+ paths: () => appDataCaches(),
851
+ },
852
+ {
853
+ id: 'office-update-cache',
854
+ kind: 'purge',
855
+ risk: 'caution',
856
+ note: 'superseded',
857
+ roots: ['LOCALAPPDATA'],
858
+ paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'Microsoft', 'Office', 'SolutionPackages')],
859
+ },
588
860
  ];
589
861
 
590
862
  const byId = new Map(CATALOG.map((entry) => [entry.id, entry]));
@@ -620,12 +892,30 @@ const onDrive = (abs, drive) => drive === undefined || path.parse(abs).root.toLo
620
892
  */
621
893
  const isFile = (p) => {
622
894
  try {
623
- return fs.statSync(p).isFile();
895
+ return statReal(p).isFile();
624
896
  } catch {
625
897
  return false;
626
898
  }
627
899
  };
628
900
 
901
+ /**
902
+ * `fs.statSync` with Electron's asar layer switched off for the call.
903
+ *
904
+ * The host is an Electron app, and its patched fs answers size 0 for any `*.asar` it cannot
905
+ * parse as an archive. A superseded editor build can hold exactly one file — a 58 MB
906
+ * `node_modules.asar` — so without this the target reports "0 B" and reads as nothing found.
907
+ * The switch is synchronous and restored immediately, so no other filesystem call sees it.
908
+ */
909
+ function statReal(p) {
910
+ const prior = process.noAsar;
911
+ process.noAsar = true;
912
+ try {
913
+ return fs.statSync(p);
914
+ } finally {
915
+ process.noAsar = prior;
916
+ }
917
+ }
918
+
629
919
  /** Resolve catalog ids to the concrete directories that exist right now, optionally on one volume. */
630
920
  export function resolveTargets(only, drive) {
631
921
  const selected = only === undefined ? CATALOG : CATALOG.filter((entry) => only.includes(entry.id));
@@ -654,16 +944,28 @@ export function resolveTargets(only, drive) {
654
944
  });
655
945
  }
656
946
 
657
- export async function measurePath(abs) {
947
+ /**
948
+ * Total the files under `abs`, answering two questions in one pass: how much the whole thing
949
+ * holds (`bytes` / `files` when no cutoff is given) and how much a clean would free right now
950
+ * (`bytes` / `files` restricted to the cutoff). Both are returned so a caller can say "245 MB
951
+ * here, 0.2 MB past the age limit" instead of quietly reporting the smaller number.
952
+ */
953
+ export async function measurePath(abs, cutoff) {
658
954
  if (isFile(abs)) {
659
955
  try {
660
- return { bytes: fs.statSync(abs).size, files: 1 };
956
+ const stat = statReal(abs);
957
+ const eligible = cutoff === undefined || stat.mtimeMs <= cutoff;
958
+ return eligible
959
+ ? { bytes: stat.size, files: 1, totalBytes: stat.size, totalFiles: 1 }
960
+ : { bytes: 0, files: 0, totalBytes: stat.size, totalFiles: 1 };
661
961
  } catch {
662
- return { bytes: 0, files: 0 };
962
+ return { bytes: 0, files: 0, totalBytes: 0, totalFiles: 0 };
663
963
  }
664
964
  }
665
965
  let bytes = 0;
666
966
  let files = 0;
967
+ let totalBytes = 0;
968
+ let totalFiles = 0;
667
969
  let seen = 0;
668
970
  const stack = [abs];
669
971
  while (stack.length > 0 && seen < MAX_ENTRIES) {
@@ -684,7 +986,11 @@ export async function measurePath(abs) {
684
986
  }
685
987
  if (!entry.isFile()) continue;
686
988
  try {
687
- bytes += (await fsp.stat(path.join(dir, entry.name))).size;
989
+ const stat = statReal(path.join(dir, entry.name));
990
+ totalBytes += stat.size;
991
+ totalFiles += 1;
992
+ if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
993
+ bytes += stat.size;
688
994
  files += 1;
689
995
  } catch {
690
996
  /* a file that vanished or is locked contributes nothing */
@@ -694,22 +1000,32 @@ export async function measurePath(abs) {
694
1000
  /* an unreadable directory ends this branch */
695
1001
  }
696
1002
  }
697
- return { bytes, files };
1003
+ return { bytes, files, totalBytes, totalFiles };
698
1004
  }
699
1005
 
700
- /** Measure every resolved target concurrently, keeping the host event loop free. */
1006
+ /**
1007
+ * Measure every resolved target concurrently, keeping the host event loop free. `bytes` is what
1008
+ * a clean would free right now (the target's own age limit applied); `totalBytes` is what the
1009
+ * folders hold. Reporting both is what tells the user "245 MB here, 0.2 MB past the limit"
1010
+ * instead of a small number that reads as "nothing found".
1011
+ */
701
1012
  export async function scan(only, drive) {
702
1013
  const targets = resolveTargets(only, drive);
703
1014
  const measured = await Promise.all(
704
1015
  targets.map(async (target) => {
1016
+ const cutoff = target.minAgeDays > 0 ? Date.now() - target.minAgeDays * DAY : undefined;
705
1017
  let bytes = 0;
706
1018
  let files = 0;
1019
+ let totalBytes = 0;
1020
+ let totalFiles = 0;
707
1021
  for (const abs of target.paths) {
708
- const size = await measurePath(abs);
1022
+ const size = await measurePath(abs, cutoff);
709
1023
  bytes += size.bytes;
710
1024
  files += size.files;
1025
+ totalBytes += size.totalBytes;
1026
+ totalFiles += size.totalFiles;
711
1027
  }
712
- return { ...target, bytes, files };
1028
+ return { ...target, bytes, files, totalBytes, totalFiles };
713
1029
  }),
714
1030
  );
715
1031
  return measured;
@@ -741,7 +1057,7 @@ async function purgePath(abs, cutoff) {
741
1057
  }
742
1058
  if (!entry.isFile()) continue;
743
1059
  try {
744
- const stat = await fsp.stat(full);
1060
+ const stat = statReal(full);
745
1061
  if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
746
1062
  await fsp.unlink(full);
747
1063
  bytes += stat.size;
@@ -776,7 +1092,7 @@ export async function clean(ids, options = {}) {
776
1092
  let bytes = 0;
777
1093
  let files = 0;
778
1094
  for (const abs of target.paths) {
779
- const size = await measurePath(abs);
1095
+ const size = await measurePath(abs, cutoff);
780
1096
  bytes += size.bytes;
781
1097
  files += size.files;
782
1098
  }
@@ -793,7 +1109,7 @@ export async function clean(ids, options = {}) {
793
1109
  }
794
1110
  if (isFile(abs)) {
795
1111
  try {
796
- const stat = await fsp.stat(abs);
1112
+ const stat = statReal(abs);
797
1113
  if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
798
1114
  await fsp.unlink(abs);
799
1115
  bytes += stat.size;
package/lib/client.js CHANGED
@@ -41,10 +41,12 @@ window.__ModuleLoader__.load({
41
41
  'why_pending-update': '只清 {n} 天前的下载,避免删掉还没装的更新',
42
42
  why_rollback: '每个插件/程序只留版本号最高的一份,其余是回滚副本',
43
43
  why_superseded: '版本号不高于已装版本的更新包,装着的那份已经生效',
44
+ why_orphan: '写这个目录的程序已经不在这台机器上了:没有快捷方式、没有卸载项,两年多没写过文件',
44
45
  why_history: '每个会话的整文件备份,删了不影响会话本身',
45
46
  why_duplicate: '内容完全相同的重复副本,保留最新那份',
46
47
  'why_database-backup': '升级前的数据库副本,正在用的数据库还在旁边',
47
48
  pathCount: '{n} 处',
49
+ holds: '共 {n},够老的可清',
48
50
  driveLabel: '清理范围',
49
51
  allDrives: '全部磁盘',
50
52
  scanError: '扫描失败',
@@ -74,6 +76,14 @@ window.__ModuleLoader__.load({
74
76
  'target_toolchain-snapshots': '重复的工具链快照',
75
77
  'target_editor-server-bundles': '多余的编辑器服务包',
76
78
  'target_database-backups': '数据库备份副本',
79
+ 'target_editor-builds': '编辑器的旧版构建',
80
+ 'target_agent-app-caches': 'AI 应用的浏览器缓存',
81
+ 'target_rewind-snapshots': '过期的回滚快照',
82
+ 'target_rotated-logs': '轮转掉的日志',
83
+ 'target_qoder-old-versions': '编辑器少用的旧版本目录',
84
+ 'target_orphan-app-data': '已卸载程序的残留数据',
85
+ 'target_app-data-caches': '已停用应用的缓存',
86
+ 'target_office-update-cache': 'Office 更新暂存包',
77
87
  auditTitle: '空间评估(本插件不会删除)',
78
88
  auditHint: '统计缓存之外真正占空间的东西:已装软件、WSL 虚拟磁盘、Windows 组件库,以及你自己的文件。这些一律不由本插件删除,只列出供你判断。',
79
89
  auditRun: '开始评估',
@@ -193,10 +203,12 @@ window.__ModuleLoader__.load({
193
203
  'why_pending-update': 'only downloads older than {n} day(s), so a pending update survives',
194
204
  why_rollback: 'only the highest version of each family stays; the rest are rollback copies',
195
205
  why_superseded: 'the update package version is not above what is installed, so it is already applied',
206
+ why_orphan: 'the app that wrote this folder is gone: no shortcut, no uninstall entry, nothing written for over two years',
196
207
  why_history: 'a whole-file backup per session; removing it does not touch the session',
197
208
  why_duplicate: 'an identical copy; the newest one is kept',
198
209
  'why_database-backup': 'a copy made before an upgrade; the live database is beside it',
199
210
  pathCount: '{n} location(s)',
211
+ holds: 'holds {n}, of which the age limit allows',
200
212
  driveLabel: 'Limit to',
201
213
  allDrives: 'All drives',
202
214
  scanError: 'Scan failed',
@@ -226,6 +238,14 @@ window.__ModuleLoader__.load({
226
238
  'target_toolchain-snapshots': 'Duplicate toolchain snapshots',
227
239
  'target_editor-server-bundles': 'Extra editor server bundles',
228
240
  'target_database-backups': 'Database backup copies',
241
+ 'target_editor-builds': 'Old editor builds',
242
+ 'target_agent-app-caches': 'Browser caches of the AI apps',
243
+ 'target_rewind-snapshots': 'Expired rewind snapshots',
244
+ 'target_rotated-logs': 'Rotated-out log files',
245
+ 'target_qoder-old-versions': 'Old versions the editor no longer runs',
246
+ 'target_orphan-app-data': 'Leftover data from an uninstalled app',
247
+ 'target_app-data-caches': 'Caches of apps that stopped writing',
248
+ 'target_office-update-cache': 'Office update staging packages',
229
249
  auditTitle: 'Space assessment (this plugin deletes none of it)',
230
250
  auditHint: 'What actually takes up room outside caches: installed programs, WSL disk images, the Windows component store, and your own files. None of it is removed here; it is listed so you can decide.',
231
251
  auditRun: 'Run assessment',
@@ -827,8 +847,12 @@ window.__ModuleLoader__.load({
827
847
  : h(
828
848
  'div',
829
849
  { className: 'dsc-list' },
830
- shown.map((row) =>
831
- h(
850
+ shown.map((row) => {
851
+ // An `aged` target reports only the bytes past its age limit, so a folder
852
+ // holding 261 MB can read as "0.2 MB" and look like nothing was found.
853
+ // Name the folder size too when the two disagree.
854
+ const holds = row.totalBytes > row.bytes ? row.totalBytes : 0;
855
+ return h(
832
856
  'label',
833
857
  { key: row.id, className: 'dsc-row' },
834
858
  h('input', {
@@ -844,7 +868,9 @@ window.__ModuleLoader__.load({
844
868
  h(
845
869
  'span',
846
870
  { className: 'dsc-row-hint' },
847
- `${t('pathCount').replace('{n}', String(row.pathCount))} · ${riskWhy(t, row)}`,
871
+ `${t('pathCount').replace('{n}', String(row.pathCount))} · ${riskWhy(t, row)}${
872
+ holds > 0 ? ` · ${t('holds').replace('{n}', formatBytes(holds))}` : ''
873
+ }`,
848
874
  ),
849
875
  ),
850
876
  h(
@@ -856,8 +882,8 @@ window.__ModuleLoader__.load({
856
882
  t(row.risk === 'caution' ? 'risky' : 'safe'),
857
883
  ),
858
884
  h('span', { className: `dsc-size${row.bytes > 0 ? '' : ' dsc-idle'}` }, formatBytes(row.bytes)),
859
- ),
860
- ),
885
+ );
886
+ }),
861
887
  ),
862
888
  h(
863
889
  'div',
package/lib/index.js CHANGED
@@ -155,7 +155,15 @@ async function run(action, payload) {
155
155
  if (action === 'scan') {
156
156
  const measured = await scan(readIds(payload.ids), readDrive(payload.drive));
157
157
  return {
158
- targets: measured.map((target) => ({ ...toPublic(target), bytes: target.bytes, files: target.files })),
158
+ targets: measured.map((target) => ({
159
+ ...toPublic(target),
160
+ bytes: target.bytes,
161
+ files: target.files,
162
+ // The folder's real size, which differs from `bytes` for every `aged` target. Without
163
+ // it a folder holding 261 MB reads as "0.2 MB" and looks like nothing was found.
164
+ totalBytes: target.totalBytes,
165
+ totalFiles: target.totalFiles,
166
+ })),
159
167
  };
160
168
  }
161
169
  if (action === 'audit') return audit({ force: payload.force === true });
@@ -201,6 +209,7 @@ const NOTE_MEANING = {
201
209
  history: 'a backup snapshot of file edits that nothing reads back; the live state sits beside it',
202
210
  duplicate: 'a byte-identical copy of a directory that is still present',
203
211
  'database-backup': 'a copy of a database file taken before an upgrade; the live database is beside it',
212
+ orphan: 'the app that wrote this folder is no longer installed: no shortcut, no uninstall entry, and nothing written for over two years',
204
213
  };
205
214
 
206
215
  const formatBytes = (bytes) => {
@@ -279,8 +288,12 @@ export function apply(ctx) {
279
288
  'Inspect and reclaim disk space on this Windows machine from known-safe cache locations only: ' +
280
289
  'temp directories, package manager caches (npm, pnpm, pip, uv), crash dumps, browser and editor ' +
281
290
  'caches, the Windows internet cache, per-app caches, logs and scratch folders under %APPDATA%, ' +
282
- 'superseded plugin versions and applied update packages, and the backup snapshots, dated logs, ' +
283
- 'duplicate toolchain copies and database backups under the user profile. Call with action "overview" to list drives and target ids, ' +
291
+ 'superseded plugin versions and applied update packages, the backup snapshots, dated logs, ' +
292
+ 'rotated-out log files, duplicate toolchain copies and database backups under the user profile, ' +
293
+ 'editor builds their launcher no longer names, superseded editor versions, data left by an app ' +
294
+ 'that is no longer installed, applied Office update packages, and the Chromium caches of the ' +
295
+ 'agent apps. ' +
296
+ 'Call with action "overview" to list drives and target ids, ' +
284
297
  '"scan" to measure them, and "clean" to delete. clean without confirm: true is a dry run that only ' +
285
298
  'reports what it would free, so the user can approve the amount before anything is removed. ' +
286
299
  'Action "audit" instead assesses the space that CANNOT be cleaned automatically — installed programs, ' +
@@ -365,9 +378,10 @@ export function apply(ctx) {
365
378
  if (action === 'scan') {
366
379
  const measured = await scan(readIds(args.ids), readDrive(args.drive));
367
380
  const lines = measured.map((target) => {
368
- // scan measures everything present; a retention limit means clean frees less.
369
- const limit = target.minAgeDays > 0 ? ` (only files older than ${target.minAgeDays} day(s) go)` : '';
370
- return `${target.id}: ${formatBytes(target.bytes)} in ${target.files} file(s)${limit}`;
381
+ const limit = target.minAgeDays > 0 ? ` (${target.minAgeDays}d age limit)` : '';
382
+ const holds =
383
+ target.totalBytes > target.bytes ? ` of ${formatBytes(target.totalBytes)} present` : '';
384
+ return `${target.id}: ${formatBytes(target.bytes)}${holds} in ${target.files} file(s)${limit}`;
371
385
  });
372
386
  const total = measured.reduce((sum, target) => sum + target.bytes, 0);
373
387
  const notes = [...new Set(measured.map((target) => target.note).filter(Boolean))];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-disk-cleaner",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
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,13 +262,24 @@ 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
 
264
264
  const ids = [...catalog.matchAll(/^\s{4}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
265
- check('catalog has the documented 24 targets', ids.length === 24, `got ${ids.length}`);
265
+ check('catalog has the documented 32 targets', ids.length === 32, `got ${ids.length}`);
266
266
  check('every catalog id has a client label', ids.every((id) => zhKeys.includes(`target_${id}`) && enKeys.includes(`target_${id}`)));
267
- check('every note code has an explanation', ['why_safe', 'why_in-use', 'why_rebuild', 'why_elevation', 'why_pending-update', 'why_rollback', 'why_superseded', 'why_history', 'why_duplicate', 'why_database-backup'].every((k) => zhKeys.includes(k) && enKeys.includes(k)));
267
+ check('every note code has an explanation', ['why_safe', 'why_in-use', 'why_rebuild', 'why_elevation', 'why_pending-update', 'why_rollback', 'why_superseded', 'why_history', 'why_duplicate', 'why_database-backup', 'why_orphan'].every((k) => zhKeys.includes(k) && enKeys.includes(k)));
268
268
  const areaIds = [...auditSrc.matchAll(/^\s{6}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
269
269
  check('every assessment area has a translated label and reason', areaIds.length > 0 && areaIds.every((id) => zhKeys.includes(`area_${id}`) && zhKeys.includes(`area_${id}_why`) && enKeys.includes(`area_${id}`) && enKeys.includes(`area_${id}_why`)), `areas: ${areaIds.join(',')}`);
270
270
  check('a reveal request names an area, never a path', index.includes("action === 'reveal'") && auditSrc.includes('export function areaPaths') && !/revealPath[\s\S]{0,200}payload\.path/.test(index));
271
271
  check('no caller may pass a raw path', !/parameters[\s\S]{0,400}paths/.test(index) && catalog.includes('Callers select catalog ids'));
272
+ check(
273
+ 'a dry run reports what a real run would delete, not the folder size',
274
+ catalog.includes('export async function measurePath(abs, cutoff)') &&
275
+ (catalog.match(/await measurePath\(abs, cutoff\)/g) ?? []).length === 2,
276
+ `${(catalog.match(/await measurePath\(abs, cutoff\)/g) ?? []).length} cutoff-aware call sites`,
277
+ );
278
+ check(
279
+ 'an age-limited row also names the folder size',
280
+ catalog.includes('totalBytes') && index.includes('totalBytes: target.totalBytes') && source.includes('row.totalBytes'),
281
+ 'scan must carry totalBytes through to the row, or an `aged` target reads as empty',
282
+ );
272
283
 
273
284
  const codes = [...new Set([...plan.matchAll(/code: '([a-z-]+)'/g)].map((m) => m[1]))];
274
285
  check('plan emits the documented 9 step codes', codes.length === 9, `got ${codes.length}`);