disk-space-saviour 0.20.0 → 0.22.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a63f8ca: Add `dss usage`, which shows where the space of a whole volume goes (largest directories and files, what dss can free or has blocked inside each, APFS volumes, snapshots and unread space), list APFS clones that share their blocks with an installed app under `SHARED WITH INSTALLED APPS`, search Git worktree folders at full depth, keep per-app macOS Library folders while a process of the owning app runs, name the process and app holding a busy path, group blocked space by the condition that frees it in a `WARNING:` line and a `FREEABLE ON CONDITIONS` section, scan again when a process that blocked items has exited, warn when a clean plan rests on an incomplete scan, and collapse access errors inside directories this user cannot enter into one line.
8
+
9
+ ## 0.21.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Keep a Chromium code-sign clone while any process runs its hard-linked main executable, under the installed path or the clone's, at scan time and right before deletion; a clone that still shares its blocks with the installed app reports no reclaimable space.
14
+
15
+ Warn when swap grows, nearly fills, or outgrows the free disk space, naming the processes holding the memory and how much each grew since earlier scans (with Linux `VmSwap` support), and offer Git clones in temporary directories as safe once every worktree is clean and `git ls-remote` shows the remote still holds every local commit.
16
+
17
+ Keep a Playwright browser revision while an installed Playwright package recorded in the registry's `.links` still launches it (rechecked right before deletion), offer Playwright installs and downloads left in temporary directories, offer unused nvm Node versions while naming the global packages they take with them, and limit Telegram downloaded media to the cloud files Telegram downloads again, never its media database or files the user sent.
18
+
3
19
  ## 0.20.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -57,6 +57,7 @@ dss scan [paths...] [--docker] [--depth N] [--json] # report only
57
57
  dss clean [paths...] --tier safe|moderate [--yes] [--older-than 1h]
58
58
  dss emergency --free 20G | --until 80% [--path /] [--yes]
59
59
  dss docker scan|clean [--container ID] [--recursive]
60
+ dss usage [paths...] [--min-size 1G] [--json] # where space goes
60
61
  ```
61
62
 
62
63
  | Exit code | Meaning |
@@ -85,6 +86,22 @@ Run `dss --help` for every option. The most useful ones:
85
86
  Recent reports authenticated by this installation are reused; older, unsigned
86
87
  or edited reports require discovery again. Changing discovery or action options
87
88
  also rebuilds the plan. Every deletion still gets fresh checks.
89
+ - `dss usage` shows where the space of a whole volume goes, including data
90
+ no rule covers. One read-only `du -axk` pass walks the macOS Data volume
91
+ (`/Users`, `/Applications`, `/private`, `/opt`, ...) or `/` on Linux, or
92
+ the given paths, staying on their filesystem. It prints a tree of the
93
+ largest directories (1% of the walk and up, or `--min-size`), the largest
94
+ files, and for each directory what the latest dss scan or clean found
95
+ inside it (`[dss: 1.2 GiB freeable, 3.4 GiB blocked]`). On macOS it adds
96
+ the APFS volumes a walk cannot see (system, VM swap, Preboot, Recovery),
97
+ local snapshots and the part of the Data volume left unread. It needs
98
+ `du`, so it runs on macOS and Linux; a full walk takes minutes.
99
+ - APFS clones that share every block with an installed app, such as Chrome's
100
+ code-sign clones, free nothing when deleted, so they are never offered. The
101
+ scan lists them under `SHARED WITH INSTALLED APPS` and `dss usage` marks
102
+ them, because `du` and Finder count them at full size.
103
+ - Git worktree folders (`.worktrees`, `worktrees`) restart the project search
104
+ depth, so dependencies of nested worktrees are found below `--max-depth`.
88
105
  - `clean` automatically reuses a matching scan from the last 30 minutes.
89
106
  `--fresh` bypasses this plan and the discovery/size index; `--cache-dir DIR`
90
107
  chooses the state location (default `$XDG_STATE_HOME/disk-space-saviour`, or
@@ -97,11 +114,11 @@ ranked by how recently it was used: a cache, build output or dependency
97
114
  directory is `safe` and is held back only while something uses it (see
98
115
  [Safety](#safety)).
99
116
 
100
- | Tier | What it removes |
101
- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
- | `safe` | Everything regenerable: download, runtime and application caches, idle package-runner entries, old browser revisions, `node_modules`, `vendor`, `target/`, `.venv`, `build/` and similar folders of any project, dangling Docker images and build cache. |
103
- | `moderate` | Data that is disposable but not regenerated by a tool: old agent scratch and Telegram downloaded media (secret-chat media may be irreplaceable), unused toolchain versions (never one holding globally installed packages or virtual environments), tagged images and stopped containers (both require explicit consent). |
104
- | `aggressive` | Accepted by `dss emergency` for compatibility; currently the same as `moderate`. |
117
+ | Tier | What it removes |
118
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
119
+ | `safe` | Everything regenerable: download, runtime and application caches, idle package-runner entries, old browser revisions, `node_modules`, `vendor`, `target/`, `.venv`, `build/` and similar folders of any project, dangling Docker images and build cache. |
120
+ | `moderate` | Data that is disposable but not regenerated by a tool: old agent scratch and Telegram downloaded media (secret-chat media may be irreplaceable), unused toolchain versions no process runs from (the item names the global packages it removes; never one holding virtual environments), tagged images and stopped containers (both require explicit consent). |
121
+ | `aggressive` | Accepted by `dss emergency` for compatibility; currently the same as `moderate`. |
105
122
 
106
123
  Covered ecosystems: JavaScript/TypeScript (npm, yarn, pnpm, bun, deno,
107
124
  framework build caches), Python, Rust, JVM (Gradle, Maven, Kotlin, Android),
@@ -115,15 +132,21 @@ LibreWolf, Waterfox, Zen and Floorp, IDE caches (JetBrains, VS Code, Cursor,
115
132
  Windsurf, Qoder), AI agent caches and logs, the Discord cache, macOS app
116
133
  updates staged by Sparkle and Squirrel, package manager archives, trash, crash
117
134
  reports, core dumps and journald logs. Application downloads are covered too:
118
- Chrome bundle clones left by exited browsers, Chromium on-device AI models
135
+ Chrome bundle clones left by exited browsers (kept while any browser runs the
136
+ clone's hard-linked executable, and counted as free when they still share
137
+ their blocks with the installed app), Chromium on-device AI models
119
138
  (downloaded again when a page uses built-in AI), Google Updater installers, ChatGPT Atlas and Codex app caches,
120
139
  Electron and electron-builder archives, the Bun transpiler cache, Docker
121
140
  Desktop and Warp staged updates (kept while the app runs), Zed language
122
141
  servers, tree-sitter toolchains, Codex plugin staging, the Clang module cache
123
142
  and processed Crashpad reports. Telegram pure caches, Safari/WebKit,
124
143
  Chromium code/GPU/Dawn/service-worker caches and marker-validated Electron
125
- caches are included. Telegram databases, browser profiles and Playwright MCP
126
- profiles are preserved. macOS access failures explain when Full Disk Access
144
+ caches are included. Old Playwright browser revisions are kept while a
145
+ Playwright package recorded in the registry's `.links` still launches them or
146
+ a browser runs from them; Playwright installs and downloads left in temporary
147
+ directories are removed once untouched. Telegram caches are removed only
148
+ while Telegram is not running. Telegram databases, browser profiles and
149
+ Playwright MCP profiles are preserved. macOS access failures explain when Full Disk Access
127
150
  is needed. Agent scratch cleanup requires inactivity and Git safety checks.
128
151
 
129
152
  ## Safety
@@ -143,6 +166,24 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
143
166
  cache, while `bun install`, `bunx` or an auto-installing script does.
144
167
  A directory on the `PATH` of a running process is kept too: the process
145
168
  may still start programs from it.
169
+ - **Per-app macOS folders stay while their app runs.** Caches, containers
170
+ and other folders under `~/Library/<kind>/<bundle-id>` belong to the app
171
+ with that bundle identifier, read from the `Info.plist` of every bundle a
172
+ running process starts from. Such a folder is blocked while that app, a
173
+ helper or an updater of it runs (for example the WebKit cache of Music,
174
+ Telegram's group container), even when no rule names the app's process.
175
+ A path held open names its holder: `in use: <path> (pid N, App)`.
176
+ - **Blocked space is grouped by what frees it.** A `WARNING:` line and the
177
+ `FREEABLE ON CONDITIONS` section total blocked space by the condition that
178
+ clears every blocker of an item: quit an app, a process or Claude Code
179
+ session ending, files closing, the activity window passing, a container
180
+ stopping or a later scan finishing what this one ran out of time for.
181
+ Items kept for Git state, protected installs or unverifiable records are
182
+ counted apart. `clean` repeats the summary after the plan. Rerun dss once
183
+ a condition is met; it rechecks everything before deleting. `clean` scans
184
+ again instead of reusing a saved scan when a process named by its blockers
185
+ has exited, and warns when the scan behind its plan stopped early (for
186
+ example at `--scan-budget`), since items it never reached are not planned.
146
187
  - **Toolchain versions are kept while anything may need them.** The
147
188
  manager's default and aliases, versions pinned by a project (`.nvmrc`,
148
189
  `rust-toolchain.toml`, ...) and the newest install stay. A partial nvm pin
@@ -241,10 +282,27 @@ and sizes. Safe cleanup also rechecks source activity. Explicit moderate cleanup
241
282
  preserves the ability to remove ignored dependencies when only source changed.
242
283
  Library callers opt into state with `cacheDir`.
243
284
 
244
- On macOS, reports include `sysctl vm.swapusage` and the largest resident/compressed
245
- memory consumers from `top`. These are memory diagnostics, not verified per-process
246
- swap ownership. When Docker Desktop consumes memory, saving work and restarting it
247
- or lowering its VM memory limit can help. dss never deletes swap files.
285
+ On macOS, reports include `sysctl vm.swapusage` and the largest memory consumers
286
+ from `top` (memory includes compressed and swapped pages). These are memory
287
+ diagnostics, not verified per-process swap ownership. On Linux, reports include
288
+ `/proc/meminfo` swap and the processes with the most swapped memory (`VmSwap`).
289
+ Each scan records swap usage and its largest holders in the state directory, so
290
+ the next scan shows how much both grew during the last week. A `WARNING:` line
291
+ appears when swap grew by 1 GiB or more, is at least 75% full, or exceeds the free
292
+ disk space it can grow into, naming the processes holding the memory and their
293
+ growth. Long-running AI agent sessions keep their history in memory: quitting and
294
+ resuming them (`codex resume <session-id>`, `claude --resume`) releases it. When
295
+ Docker Desktop consumes memory, saving work and restarting it or lowering its VM
296
+ memory limit can help. dss never deletes swap files.
297
+
298
+ Git clones in temporary directories (`{TMP}/*` and `{TMP}/*/*`) are safe to remove
299
+ once untouched for the activity window when every worktree sharing the repository
300
+ is clean, none lives outside the clone, nothing is stashed, and every commit
301
+ reachable from `HEAD`, a branch or a worktree is advertised by an https or ssh
302
+ remote. That last check runs `git ls-remote` against the remote rather than
303
+ trusting remote-tracking refs, outside any repository, with only the https and
304
+ ssh transports and no prompts. Clones of local paths and clones whose `.git` is a
305
+ symlink are left alone, and cleanup repeats every check right before deleting.
248
306
 
249
307
  Cache discovery honors tool configuration before default paths, including
250
308
  `GOMODCACHE`, `GOPATH`, `GOCACHE`, `CARGO_HOME`, `RUSTUP_HOME`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Safely reclaims disk space: caches and build outputs of 20+ ecosystems, forgotten projects, and Docker (running, stopped and nested containers), with an emergency mode",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,87 @@
1
+ /**
2
+ * macOS apps that own per-app Library folders.
3
+ *
4
+ * Caches, containers and other per-app data under `~/Library/<kind>/` are
5
+ * named after the owning app's bundle identifier
6
+ * (`~/Library/Caches/com.apple.Music/WebKit`, group containers carry a
7
+ * team prefix: `6N38VWS5BX.ru.keepcoder.Telegram`). A running app may read
8
+ * or write such a folder at any moment without holding a file open, so a
9
+ * folder stays while a process runs from a bundle of its owner. This
10
+ * covers apps a rule's process names do not list, such as every app that
11
+ * embeds WebKit.
12
+ */
13
+
14
+ const OWNED =
15
+ /\/Library\/(Caches|Containers|Group Containers|HTTPStorages|WebKit|Saved Application State|Application Scripts|Application Support|Logs|Cookies)\/([^/]+)/;
16
+ const BUNDLE_ID = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9_-]+)+$/;
17
+
18
+ /** Bundle identifier owning `target`, or null outside per-app folders. */
19
+ export function ownerBundleId(target) {
20
+ const match = OWNED.exec(target);
21
+ if (!match) {
22
+ return null;
23
+ }
24
+ let id = match[2].replace(/\.(savedState|binarycookies)$/, '');
25
+ if (match[1] === 'Group Containers') {
26
+ id = id.replace(/^(?:[A-Z0-9]{10}\.|group\.)/, '');
27
+ }
28
+ return BUNDLE_ID.test(id) ? id : null;
29
+ }
30
+
31
+ /** Bundles (`.app`, `.appex`, `.xpc`) an executable runs from, outermost first. */
32
+ export function bundleRoots(executable) {
33
+ const roots = [];
34
+ const parts = (executable ?? '').split('/');
35
+ for (let index = 1; index < parts.length - 1; index++) {
36
+ if (/\.(app|appex|xpc)$/.test(parts[index])) {
37
+ roots.push(parts.slice(0, index + 1).join('/'));
38
+ }
39
+ }
40
+ return roots;
41
+ }
42
+
43
+ /** `CFBundleIdentifier` of an XML property list, or null. */
44
+ export function parseBundleIdentifier(plist) {
45
+ return (
46
+ /<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/
47
+ .exec(plist ?? '')?.[1]
48
+ ?.trim() || null
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Whether a running bundle belongs to the owner of a folder: the same
54
+ * identifier, or one nested under the other (helpers such as
55
+ * `com.google.Chrome.helper`, updaters such as `….ShipIt`).
56
+ */
57
+ export function ownsFolder(running, owner) {
58
+ const a = running.toLowerCase();
59
+ const b = owner.toLowerCase();
60
+ return a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`);
61
+ }
62
+
63
+ /**
64
+ * Identifiers a process runs under. A bundle without a readable identifier
65
+ * falls back to its name, which still matches an owner ending in it
66
+ * (`Telegram.app` and `ru.keepcoder.Telegram`).
67
+ * @param {string[]} roots bundle paths of the process
68
+ * @param {Map<string, string|null>} ids bundle path → identifier
69
+ */
70
+ export function processBundles(roots, ids) {
71
+ return roots.map((root) => ({
72
+ id: ids.get(root) ?? null,
73
+ name: root
74
+ .split('/')
75
+ .at(-1)
76
+ .replace(/\.(app|appex|xpc)$/, ''),
77
+ }));
78
+ }
79
+
80
+ /** Whether one of `bundles` owns the folder of bundle identifier `owner`. */
81
+ export function bundlesOwn(bundles, owner) {
82
+ return bundles.some((bundle) =>
83
+ bundle.id
84
+ ? ownsFolder(bundle.id, owner)
85
+ : owner.toLowerCase().split('.').at(-1) === bundle.name.toLowerCase()
86
+ );
87
+ }
package/src/clean.js CHANGED
@@ -39,6 +39,7 @@ import { GitInspector } from './git.js';
39
39
  import { HealthWatch } from './health.js';
40
40
  import { consentFlag, dropNested, selectByTier, tierRank } from './items.js';
41
41
  import { LivenessProbe } from './liveness.js';
42
+ import { playwrightBlocker } from './playwright-registry.js';
42
43
  import { resolveOptions } from './options.js';
43
44
  import { isWithin } from './paths.js';
44
45
  import { OTHER_RULES } from './rules/other.js';
@@ -132,6 +133,11 @@ async function browserRevisionBlocker(env, item) {
132
133
  if (!newer) {
133
134
  return `${target} is now the newest installed browser revision`;
134
135
  }
136
+ const needed =
137
+ rule.registry === 'playwright' && (await playwrightBlocker(env, target));
138
+ if (needed) {
139
+ return needed;
140
+ }
135
141
  }
136
142
  return null;
137
143
  }
@@ -432,6 +438,7 @@ export class Cleaner {
432
438
  async #safetyBlocker(ctx, item, type) {
433
439
  await ctx.liveness.refresh(true);
434
440
  await ctx.liveness.resolve(item.paths);
441
+ await ctx.liveness.resolveExecutables(item.checks?.executables);
435
442
  const busy = ctx.liveness.busyReason(item);
436
443
  if (busy) {
437
444
  return `busy: ${busy}`;
@@ -475,6 +482,11 @@ export class Cleaner {
475
482
  }
476
483
 
477
484
  async #gitBlocker(ctx, item) {
485
+ if (item.recheck?.type === 'clone') {
486
+ ctx.git.invalidate();
487
+ const blockers = await ctx.git.cloneBlockers(item.path);
488
+ return blockers.length > 0 ? blockers.join('; ') : null;
489
+ }
478
490
  if (
479
491
  item.project ||
480
492
  item.rule === 'gh-issue-solver-workdirs' ||
@@ -667,6 +679,7 @@ export class Cleaner {
667
679
  return `stopped before ${target}: ${stopped}`;
668
680
  }
669
681
  await ctx.liveness.resolve([target]);
682
+ await ctx.liveness.resolveExecutables(item.checks?.executables);
670
683
  const busy = ctx.liveness.busyReason({ ...item, paths: [target] });
671
684
  return busy ? `stopped before ${target}: busy: ${busy}` : null;
672
685
  }
@@ -1000,6 +1013,17 @@ export async function finishAudit(audit, report, options) {
1000
1013
  .filter((e) => e.status === 'planned')
1001
1014
  .reduce((sum, e) => sum + e.plannedBytes, 0);
1002
1015
  audit.environments = environmentTotals(report, audit.entries);
1016
+ // What the run had to leave, grouped by the condition that frees it.
1017
+ audit.blocked = report.totals?.blocked ?? null;
1018
+ // Scanners an incomplete scan never reached contribute no items at all.
1019
+ audit.incomplete = (report.environments ?? [])
1020
+ .filter(
1021
+ (env) =>
1022
+ env.scanStatus &&
1023
+ env.scanStatus !== 'complete' &&
1024
+ env.scanStatus !== 'goal-reached'
1025
+ )
1026
+ .map((env) => ({ label: env.label, status: env.scanStatus }));
1003
1027
  if (options.audit !== false) {
1004
1028
  await writeAudit(audit, options);
1005
1029
  }
package/src/cli.js CHANGED
@@ -13,14 +13,16 @@ import { parseArgs } from 'node:util';
13
13
 
14
14
  import { startAudit, writeAudit } from './audit.js';
15
15
  import { clean } from './clean.js';
16
+ import { exitedBlockerPids, pidAlive } from './conditions.js';
16
17
  import { emergency } from './emergency.js';
17
18
  import { setTrace } from './exec.js';
18
19
  import { resolveGoal, resolveOptions } from './options.js';
19
20
  import { formatAudit, formatReport } from './report.js';
20
21
  import { scan } from './scan.js';
22
+ import { formatUsage, usage } from './usage.js';
21
23
  import { cleanDuringScan } from './fast-clean.js';
22
24
  import { defaultStateDirectory, loadScan } from './scan-state.js';
23
- import { formatBytes } from './units.js';
25
+ import { formatBytes, parseSize } from './units.js';
24
26
 
25
27
  export const USAGE = `Usage: dss <command> [paths...] [options]
26
28
 
@@ -28,6 +30,9 @@ Commands:
28
30
  scan [paths...] Report reclaimable space (never deletes)
29
31
  clean [paths...] Delete what the chosen tier allows
30
32
  emergency Escalate tiers until a free-space goal is met
33
+ usage [paths...] Show where space goes on whole volumes: the
34
+ largest directories and files, what dss can
35
+ free inside them, APFS volumes (never deletes)
31
36
  docker scan|clean Only Docker: daemons, containers, nested daemons
32
37
  help, version
33
38
 
@@ -288,7 +293,7 @@ export function processIo() {
288
293
  };
289
294
  }
290
295
 
291
- const API = { scan, clean, emergency, cleanDuringScan };
296
+ const API = { scan, clean, emergency, cleanDuringScan, usage };
292
297
 
293
298
  class Cli {
294
299
  constructor(io, api) {
@@ -305,6 +310,23 @@ class Cli {
305
310
  return this.printScan(report, options, values);
306
311
  }
307
312
 
313
+ async usage(options, values) {
314
+ const report = await this.api.usage({
315
+ roots: options.roots,
316
+ // Without --min-size the tree shows directories of 1% of a root.
317
+ minSize: values['min-size'] ? parseSize(values['min-size']) : undefined,
318
+ verbose: values.verbose,
319
+ signal: options.signal,
320
+ auditDir: options.auditDir,
321
+ onUsageProgress: (progress) =>
322
+ this.io.stderr(
323
+ `[usage] ${progress.root}: ${Math.round(progress.elapsedMs / 1000)}s, ${progress.entries.toLocaleString('en-US')} entries, at ${progress.at ?? progress.root}`
324
+ ),
325
+ });
326
+ this.print(values, report, () => formatUsage(report));
327
+ return 0;
328
+ }
329
+
308
330
  async printScan(report, options, values) {
309
331
  const audit = scanAudit(report);
310
332
  if (options.audit !== false) {
@@ -329,7 +351,12 @@ class Cli {
329
351
  const file = values.from ?? values.report;
330
352
  if (!file) {
331
353
  const recent = await loadScan(resolveOptions(options));
332
- if (recent) {
354
+ const exited = recent ? exitedBlockerPids(recent, pidAlive) : [];
355
+ if (exited.length > 0) {
356
+ this.io.stderr(
357
+ `Scanning again: processes that blocked items in the scan from ${recent.createdAt} exited (pid ${exited.slice(0, 5).join(', ')}${exited.length > 5 ? ', …' : ''}).`
358
+ );
359
+ } else if (recent) {
333
360
  this.io.stderr(
334
361
  `Reusing scan from ${recent.createdAt}; planned items will be checked before deletion.`
335
362
  );
@@ -491,6 +518,8 @@ function dispatch(cli, command, options, values) {
491
518
  return cli.clean(options, values);
492
519
  case 'emergency':
493
520
  return cli.emergency(options, values);
521
+ case 'usage':
522
+ return cli.usage(options, values);
494
523
  case 'docker-scan':
495
524
  return cli.scan(dockerOnly(options, values), values);
496
525
  case 'docker-clean':
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Space that frees when a condition is met: blocked items grouped by what
3
+ * keeps them, so a report says "quit Telegram: 1.9 GiB" in one line for
4
+ * hundreds of busy items. An item frees only when every one of
5
+ * its blockers clears; items kept for reasons no running program explains
6
+ * (Git state, protected installs, unreadable records) are counted apart.
7
+ */
8
+
9
+ const CONDITIONS = [
10
+ [
11
+ /^(?:busy: )?(.+?) is running in (.+?) \(pid (\d+)\)$/,
12
+ (m) => ({
13
+ key: `exit:${m[3]}`,
14
+ action: `${m[1]} (pid ${m[3]}) in ${m[2]} exits`,
15
+ }),
16
+ ],
17
+ [
18
+ /^(?:busy: )?(.+?) is running \(pid \d+(?:, working directory unknown)?\)(?: and owns \S+)?$/,
19
+ (m) => ({ key: `quit:${m[1]}`, action: `quit ${m[1]}` }),
20
+ ],
21
+ [
22
+ /^locked by running Claude Code \(pid (\d+)/,
23
+ (m) => ({
24
+ key: `exit:${m[1]}`,
25
+ action: `the Claude Code session (pid ${m[1]}) ends`,
26
+ }),
27
+ ],
28
+ [
29
+ /^(?:busy: )?on the PATH of (.+?) \(pid (\d+)\)/,
30
+ (m) => ({ key: `exit:${m[2]}`, action: `${m[1]} (pid ${m[2]}) exits` }),
31
+ ],
32
+ [
33
+ /^(?:busy: )?in use: .+ \(pid (\d+)(?:, (.+))?\)$/,
34
+ (m) => ({
35
+ key: `exit:${m[1]}`,
36
+ action: `${m[2] ?? 'process'} (pid ${m[1]}) exits`,
37
+ }),
38
+ ],
39
+ [
40
+ /^(?:busy: )?(?:in use: |every path is in use)/,
41
+ () => ({ key: 'open', action: 'the programs holding the files open exit' }),
42
+ ],
43
+ [
44
+ /^(?:busy: )?modified .+ ago, inside the (.+) activity window$/,
45
+ (m) => ({
46
+ key: 'idle',
47
+ action: `they stay untouched for the ${m[1]} activity window`,
48
+ }),
49
+ ],
50
+ [
51
+ /^scan incomplete: (.+)$/,
52
+ (m) => ({
53
+ key: 'rescan',
54
+ action: `a later scan finishes measuring them (${m[1]})`,
55
+ }),
56
+ ],
57
+ [
58
+ /^bind-mounted by running container (\S+)/,
59
+ (m) => ({ key: `container:${m[1]}`, action: `container ${m[1]} stops` }),
60
+ ],
61
+ ];
62
+
63
+ /** What must happen for `blocker` to clear, or null when nothing running explains it. */
64
+ export function blockerCondition(blocker) {
65
+ for (const [pattern, condition] of CONDITIONS) {
66
+ const match = pattern.exec(blocker);
67
+ if (match) {
68
+ return condition(match);
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Blocked space grouped by the conditions that free it, largest first.
76
+ * @param {object[]} items blocked items, nested ones already dropped
77
+ * @returns {{conditions: object[], other: {items: number, bytes: number}}}
78
+ */
79
+ export function blockedConditions(items) {
80
+ const groups = new Map();
81
+ const other = { items: 0, bytes: 0 };
82
+ for (const item of items) {
83
+ const found = item.blockers.map(blockerCondition);
84
+ if (found.length === 0 || found.includes(null)) {
85
+ other.items += 1;
86
+ other.bytes += item.bytes;
87
+ continue;
88
+ }
89
+ // The first blocker names a process most specifically (a Claude Code
90
+ // lock before the files it holds open).
91
+ const unique = found.filter(
92
+ (entry, index) => found.findIndex((e) => e.key === entry.key) === index
93
+ );
94
+ const key = unique
95
+ .map((entry) => entry.key)
96
+ .sort()
97
+ .join('+');
98
+ const group = groups.get(key) ?? {
99
+ action: unique.map((entry) => entry.action).join(' and '),
100
+ items: 0,
101
+ bytes: 0,
102
+ tiers: {},
103
+ };
104
+ group.items += 1;
105
+ group.bytes += item.bytes;
106
+ group.tiers[item.tier] = (group.tiers[item.tier] ?? 0) + item.bytes;
107
+ groups.set(key, group);
108
+ }
109
+ return {
110
+ conditions: [...groups.values()].sort((a, b) => b.bytes - a.bytes),
111
+ other,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Host processes named by blockers of a saved scan that no longer run, so
117
+ * the items they blocked may be free now and the scan is stale.
118
+ * @param {object} report saved scan
119
+ * @param {(pid: number) => boolean} alive
120
+ * @returns {number[]}
121
+ */
122
+ export function exitedBlockerPids(report, alive) {
123
+ const pids = new Set();
124
+ for (const item of report.items ?? []) {
125
+ if (item.env !== 'host') {
126
+ continue;
127
+ }
128
+ for (const blocker of item.blockers ?? []) {
129
+ for (const match of blocker.matchAll(/\(pid (\d+)/g)) {
130
+ pids.add(Number(match[1]));
131
+ }
132
+ }
133
+ }
134
+ return [...pids].filter((pid) => !alive(pid));
135
+ }
136
+
137
+ /** Whether a process with `pid` exists, including other users' processes. */
138
+ export function pidAlive(pid) {
139
+ try {
140
+ process.kill(pid, 0);
141
+ return true;
142
+ } catch (error) {
143
+ return error.code === 'EPERM';
144
+ }
145
+ }