dsh-disk-cleaner 1.4.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.4.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-eight 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-eight 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 |
@@ -74,6 +74,10 @@ Every path is built from this table — a caller selects an **id**, never a path
74
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
75
  | `rewind-snapshots` | `session-*` folders under `%USERPROFILE%\.dsh\rewind-snapshots` | aged, 30 days | caution | a recent session stays restorable |
76
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 |
77
81
 
78
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.
79
83
 
@@ -153,6 +157,8 @@ These are enforced in `lib/catalog.js` and must not be relaxed:
153
157
  that holds a single bundle all resolve to *nothing to delete*. The newest member is never returned.
154
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.
155
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.
156
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.
157
163
 
158
164
  ### Install
@@ -189,7 +195,7 @@ The bundle's own `cordis.patch.yml` inserts the plugin row; no profile patch edi
189
195
  ### Development
190
196
 
191
197
  ```bash
192
- node smoke.mjs # 68 assertions, zero dependencies
198
+ node smoke.mjs # 69 assertions, zero dependencies
193
199
  node --check lib/client.js # and lib/index.js, lib/catalog.js, lib/audit.js, lib/plan.js
194
200
  ```
195
201
 
@@ -241,15 +247,15 @@ What the smoke test deliberately does **not** cover: anything that walks a real
241
247
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
242
248
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
243
249
  ├─ lib/index.js (411) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
244
- ├─ lib/catalog.js (969) The 28 targets, measurePath / purgePath / resolveTargets / drives
250
+ ├─ lib/catalog.js (1152) The 32 targets, measurePath / purgePath / resolveTargets / drives
245
251
  ├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
246
252
  ├─ lib/plan.js (150) Pure: measurements → ordered steps
247
- ├─ lib/client.js (1379) Client half: slots, panel, preferences, zh/en dictionaries, CSS
253
+ ├─ lib/client.js (1397) Client half: slots, panel, preferences, zh/en dictionaries, CSS
248
254
  ├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
249
255
  ├─ icon.svg Plugin icon
250
256
  ├─ README.md This file
251
257
  ├─ LICENSE MIT
252
- └─ smoke.mjs 68 assertions, zero dependencies
258
+ └─ smoke.mjs 69 assertions, zero dependencies
253
259
  ```
254
260
 
255
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).
@@ -261,7 +267,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
261
267
 
262
268
  **dsh-disk-cleaner** 是 [DeepSeek Harness](https://github.com/deepseek-ai)(DSH)的磁盘空间插件。它做两件不同的事,并且刻意分开:
263
269
 
264
- 1. **清理** —— 二十八个已知安全的位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站、更新安装包残留、各应用的缓存与日志、被淘汰的插件版本与已应用的更新包)。只有这些会被删除。
270
+ 1. **清理** —— 三十二个已知安全的位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站、更新安装包残留、各应用的缓存与日志、被淘汰的插件版本与已应用的更新包)。只有这些会被删除。
265
271
  2. **评估** —— 所有本插件**不会**删的东西(已装程序、WSL 虚拟磁盘、Windows 组件库、聊天软件数据、你自己的文件夹)。只测量、只列出,绝不删除。
266
272
 
267
273
  评估的最后是一份**处理方案**:同一批数字按「你要付出多少代价」排序,本插件做不到的步骤附上确切的 Windows 命令。
@@ -272,9 +278,9 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
272
278
 
273
279
  | 功能 | 说明 |
274
280
  | --- | --- |
275
- | 扫描 | 并发测量全部二十八个目标,显示各自占用 |
281
+ | 扫描 | 并发测量全部三十二个目标,显示各自占用 |
276
282
  | 按盘过滤 | 已挂载卷的胶囊按钮;扫描与清理都可限定单盘,该盘上为空的项会隐藏 |
277
- | 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`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`),图例常驻,所以「注意」永远不是「说不清」 |
278
284
  | 保留期删除 | `temp` 与 `windows-temp` 只删一天前的文件;被运行中程序占住的自动跳过 |
279
285
  | 空间评估 | 只读遍历磁盘,按 Windows 自身 / 已装软件 / 你自己的文件 分组,另列占用最大的十个应用数据目录。每一行都能打开所在目录——单击、右键或回车 |
280
286
  | 重新评估真的重测 | 「重新评估」会重新测量;普通调用仍然走十分钟缓存 |
@@ -319,6 +325,10 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
319
325
  | `agent-app-caches` | `%USERPROFILE%\.workbuddy\app` 与 `\.workbuddy-ai\app` 下的 `Cache` / `Code Cache` / `GPUCache` | 全清 | 注意 | 同级的 `cache` 目录存的是对话溢写内容,从不作为目标 |
320
326
  | `rewind-snapshots` | `%USERPROFILE%\.dsh\rewind-snapshots` 下的 `session-*` 目录 | 保留期 30 天 | 注意 | 最近的会话仍然可以回滚 |
321
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 先暂存再应用,应用后没有东西会读回 |
322
332
 
323
333
  保留期方式会跳过保留期内修改过的文件;全清方式清掉全部条目。两者都保留目标目录本身,并把锁定或无权限的条目跳过计数。
324
334
 
@@ -397,6 +407,8 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
397
407
  `vscode-remote-wsl\stable` 下只有一个服务包,结果都是*没有可删项*。最新那一份永远不会被返回。
398
408
  - `editor-builds` 只有在 `bin\code.cmd` 存在且指向另一份确实在磁盘上的构建时才删;`old_x` 也只有在 `x` 仍在旁边时才删。
399
409
  - `database-backups` 只按名字匹配 `.sqlite*.bak`,正在用的 `zotero.sqlite` 永不成为候选。
410
+ - `qoder-old-versions` 从启动器的 `state.ini` 读出它实际运行哪个版本;文件缺失、解析不出、或它指的版本不在磁盘上,一律一个都不删。
411
+ - `orphan-app-data` 要求四条独立证据同时成立:开始菜单没有快捷方式、注册表没有卸载项、两个 `Program Files` 下都没有目录、两年内没有任何文件被写过。缺一条就不会列为可删。
400
412
  - 那个拒绝普通权限写入的 Windows 目录标为 `elevation`,并排除在「现在能回收」之外。
401
413
 
402
414
  ### 安装
@@ -437,7 +449,7 @@ git clone https://github.com/stallenlu/dsh-disk-cleaner
437
449
  ### 开发
438
450
 
439
451
  ```bash
440
- node smoke.mjs # 68 条断言,零依赖
452
+ node smoke.mjs # 69 条断言,零依赖
441
453
  node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
442
454
  ```
443
455
 
@@ -489,15 +501,15 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
489
501
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
490
502
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
491
503
  ├─ lib/index.js (411) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
492
- ├─ lib/catalog.js (969) 28 个目标,measurePath / purgePath / resolveTargets / drives
504
+ ├─ lib/catalog.js (1152) 32 个目标,measurePath / purgePath / resolveTargets / drives
493
505
  ├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
494
506
  ├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
495
- ├─ lib/client.js (1379) Client 半面:slot、面板、偏好、zh/en 字典、CSS
507
+ ├─ lib/client.js (1397) Client 半面:slot、面板、偏好、zh/en 字典、CSS
496
508
  ├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
497
509
  ├─ icon.svg 插件图标
498
510
  ├─ README.md 本文件
499
511
  ├─ LICENSE MIT
500
- └─ smoke.mjs 68 条断言,零依赖
512
+ └─ smoke.mjs 69 条断言,零依赖
501
513
  ```
502
514
 
503
515
  Client 半面注册的 slot:`sidebar.panellist`(order 50,仅设置页模式下拆除)、`main`(以包名为 key)、`settings.section`(order 30,仅侧边栏模式下拆除)、`plugins.bundle.config`(以包名为 key)。
package/lib/catalog.js CHANGED
@@ -465,6 +465,110 @@ const zoteroBackups = () => {
465
465
  .map((entry) => path.join(data, entry.name));
466
466
  };
467
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
+
468
572
  /**
469
573
  * `purge` targets drop every entry under the path. `aged` targets keep anything modified
470
574
  * within `minAgeDays`, because those files may belong to a process that is still running.
@@ -475,8 +579,9 @@ const zoteroBackups = () => {
475
579
  * refuses an unelevated delete), 'pending-update' (a download that may not be applied
476
580
  * yet), 'rollback' (an older version kept beside the live one), 'superseded' (an update
477
581
  * package the installed version already contains), 'history' (a snapshot nothing reads
478
- * back), 'duplicate' (a byte-identical copy of a live directory) or 'database-backup' (a
479
- * 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.
480
585
  */
481
586
  const CATALOG = [
482
587
  {
@@ -512,8 +617,15 @@ const CATALOG = [
512
617
  id: 'pnpm-store',
513
618
  kind: 'purge',
514
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.
515
623
  roots: ['LOCALAPPDATA'],
516
- 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
+ ],
517
629
  },
518
630
  {
519
631
  id: 'pip-cache',
@@ -705,6 +817,46 @@ const CATALOG = [
705
817
  roots: ['USERPROFILE'],
706
818
  paths: () => rotatedLogs(),
707
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
+ },
708
860
  ];
709
861
 
710
862
  const byId = new Map(CATALOG.map((entry) => [entry.id, entry]));
@@ -740,12 +892,30 @@ const onDrive = (abs, drive) => drive === undefined || path.parse(abs).root.toLo
740
892
  */
741
893
  const isFile = (p) => {
742
894
  try {
743
- return fs.statSync(p).isFile();
895
+ return statReal(p).isFile();
744
896
  } catch {
745
897
  return false;
746
898
  }
747
899
  };
748
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
+
749
919
  /** Resolve catalog ids to the concrete directories that exist right now, optionally on one volume. */
750
920
  export function resolveTargets(only, drive) {
751
921
  const selected = only === undefined ? CATALOG : CATALOG.filter((entry) => only.includes(entry.id));
@@ -775,21 +945,27 @@ export function resolveTargets(only, drive) {
775
945
  }
776
946
 
777
947
  /**
778
- * Total the files under `abs`. With a `cutoff`, only files older than it count, so a dry run
779
- * reports what a real run would actually delete instead of the whole folder.
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.
780
952
  */
781
953
  export async function measurePath(abs, cutoff) {
782
954
  if (isFile(abs)) {
783
955
  try {
784
- const stat = fs.statSync(abs);
785
- if (cutoff !== undefined && stat.mtimeMs > cutoff) return { bytes: 0, files: 0 };
786
- return { bytes: stat.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 };
787
961
  } catch {
788
- return { bytes: 0, files: 0 };
962
+ return { bytes: 0, files: 0, totalBytes: 0, totalFiles: 0 };
789
963
  }
790
964
  }
791
965
  let bytes = 0;
792
966
  let files = 0;
967
+ let totalBytes = 0;
968
+ let totalFiles = 0;
793
969
  let seen = 0;
794
970
  const stack = [abs];
795
971
  while (stack.length > 0 && seen < MAX_ENTRIES) {
@@ -810,7 +986,9 @@ export async function measurePath(abs, cutoff) {
810
986
  }
811
987
  if (!entry.isFile()) continue;
812
988
  try {
813
- const stat = await fsp.stat(path.join(dir, entry.name));
989
+ const stat = statReal(path.join(dir, entry.name));
990
+ totalBytes += stat.size;
991
+ totalFiles += 1;
814
992
  if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
815
993
  bytes += stat.size;
816
994
  files += 1;
@@ -822,13 +1000,14 @@ export async function measurePath(abs, cutoff) {
822
1000
  /* an unreadable directory ends this branch */
823
1001
  }
824
1002
  }
825
- return { bytes, files };
1003
+ return { bytes, files, totalBytes, totalFiles };
826
1004
  }
827
1005
 
828
1006
  /**
829
- * Measure every resolved target concurrently, keeping the host event loop free. Each target's
830
- * own age limit is applied, so the numbers answer "what would a clean free right now" rather
831
- * than "how much is in the folder" — the two differ for every `aged` target.
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".
832
1011
  */
833
1012
  export async function scan(only, drive) {
834
1013
  const targets = resolveTargets(only, drive);
@@ -837,12 +1016,16 @@ export async function scan(only, drive) {
837
1016
  const cutoff = target.minAgeDays > 0 ? Date.now() - target.minAgeDays * DAY : undefined;
838
1017
  let bytes = 0;
839
1018
  let files = 0;
1019
+ let totalBytes = 0;
1020
+ let totalFiles = 0;
840
1021
  for (const abs of target.paths) {
841
1022
  const size = await measurePath(abs, cutoff);
842
1023
  bytes += size.bytes;
843
1024
  files += size.files;
1025
+ totalBytes += size.totalBytes;
1026
+ totalFiles += size.totalFiles;
844
1027
  }
845
- return { ...target, bytes, files };
1028
+ return { ...target, bytes, files, totalBytes, totalFiles };
846
1029
  }),
847
1030
  );
848
1031
  return measured;
@@ -874,7 +1057,7 @@ async function purgePath(abs, cutoff) {
874
1057
  }
875
1058
  if (!entry.isFile()) continue;
876
1059
  try {
877
- const stat = await fsp.stat(full);
1060
+ const stat = statReal(full);
878
1061
  if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
879
1062
  await fsp.unlink(full);
880
1063
  bytes += stat.size;
@@ -926,7 +1109,7 @@ export async function clean(ids, options = {}) {
926
1109
  }
927
1110
  if (isFile(abs)) {
928
1111
  try {
929
- const stat = await fsp.stat(abs);
1112
+ const stat = statReal(abs);
930
1113
  if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
931
1114
  await fsp.unlink(abs);
932
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: '扫描失败',
@@ -78,6 +80,10 @@ window.__ModuleLoader__.load({
78
80
  'target_agent-app-caches': 'AI 应用的浏览器缓存',
79
81
  'target_rewind-snapshots': '过期的回滚快照',
80
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 更新暂存包',
81
87
  auditTitle: '空间评估(本插件不会删除)',
82
88
  auditHint: '统计缓存之外真正占空间的东西:已装软件、WSL 虚拟磁盘、Windows 组件库,以及你自己的文件。这些一律不由本插件删除,只列出供你判断。',
83
89
  auditRun: '开始评估',
@@ -197,10 +203,12 @@ window.__ModuleLoader__.load({
197
203
  'why_pending-update': 'only downloads older than {n} day(s), so a pending update survives',
198
204
  why_rollback: 'only the highest version of each family stays; the rest are rollback copies',
199
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',
200
207
  why_history: 'a whole-file backup per session; removing it does not touch the session',
201
208
  why_duplicate: 'an identical copy; the newest one is kept',
202
209
  'why_database-backup': 'a copy made before an upgrade; the live database is beside it',
203
210
  pathCount: '{n} location(s)',
211
+ holds: 'holds {n}, of which the age limit allows',
204
212
  driveLabel: 'Limit to',
205
213
  allDrives: 'All drives',
206
214
  scanError: 'Scan failed',
@@ -234,6 +242,10 @@ window.__ModuleLoader__.load({
234
242
  'target_agent-app-caches': 'Browser caches of the AI apps',
235
243
  'target_rewind-snapshots': 'Expired rewind snapshots',
236
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',
237
249
  auditTitle: 'Space assessment (this plugin deletes none of it)',
238
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.',
239
251
  auditRun: 'Run assessment',
@@ -835,8 +847,12 @@ window.__ModuleLoader__.load({
835
847
  : h(
836
848
  'div',
837
849
  { className: 'dsc-list' },
838
- shown.map((row) =>
839
- 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(
840
856
  'label',
841
857
  { key: row.id, className: 'dsc-row' },
842
858
  h('input', {
@@ -852,7 +868,9 @@ window.__ModuleLoader__.load({
852
868
  h(
853
869
  'span',
854
870
  { className: 'dsc-row-hint' },
855
- `${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
+ }`,
856
874
  ),
857
875
  ),
858
876
  h(
@@ -864,8 +882,8 @@ window.__ModuleLoader__.load({
864
882
  t(row.risk === 'caution' ? 'risky' : 'safe'),
865
883
  ),
866
884
  h('span', { className: `dsc-size${row.bytes > 0 ? '' : ' dsc-idle'}` }, formatBytes(row.bytes)),
867
- ),
868
- ),
885
+ );
886
+ }),
869
887
  ),
870
888
  h(
871
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) => {
@@ -281,7 +290,9 @@ export function apply(ctx) {
281
290
  'caches, the Windows internet cache, per-app caches, logs and scratch folders under %APPDATA%, ' +
282
291
  'superseded plugin versions and applied update packages, the backup snapshots, dated logs, ' +
283
292
  'rotated-out log files, duplicate toolchain copies and database backups under the user profile, ' +
284
- 'editor builds their launcher no longer names, and the Chromium caches of the agent apps. ' +
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. ' +
285
296
  'Call with action "overview" to list drives and target ids, ' +
286
297
  '"scan" to measure them, and "clean" to delete. clean without confirm: true is a dry run that only ' +
287
298
  'reports what it would free, so the user can approve the amount before anything is removed. ' +
@@ -366,7 +377,12 @@ export function apply(ctx) {
366
377
  }
367
378
  if (action === 'scan') {
368
379
  const measured = await scan(readIds(args.ids), readDrive(args.drive));
369
- const lines = measured.map((target) => `${target.id}: ${formatBytes(target.bytes)} in ${target.files} file(s)`);
380
+ const lines = measured.map((target) => {
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}`;
385
+ });
370
386
  const total = measured.reduce((sum, target) => sum + target.bytes, 0);
371
387
  const notes = [...new Set(measured.map((target) => target.note).filter(Boolean))];
372
388
  const meaning = notes.map((note) => `${note}: ${NOTE_MEANING[note] ?? note}`).join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-disk-cleaner",
3
- "version": "1.4.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,9 +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
 
264
264
  const ids = [...catalog.matchAll(/^\s{4}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
265
- check('catalog has the documented 28 targets', ids.length === 28, `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));
@@ -275,6 +275,11 @@ check(
275
275
  (catalog.match(/await measurePath\(abs, cutoff\)/g) ?? []).length === 2,
276
276
  `${(catalog.match(/await measurePath\(abs, cutoff\)/g) ?? []).length} cutoff-aware call sites`,
277
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
+ );
278
283
 
279
284
  const codes = [...new Set([...plan.matchAll(/code: '([a-z-]+)'/g)].map((m) => m[1]))];
280
285
  check('plan emits the documented 9 step codes', codes.length === 9, `got ${codes.length}`);