disk-space-saviour 0.21.0 → 0.22.1

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 9479acd: Keep a `node_modules` folder that holds data no package manager installs, naming each such entry and its size, at scan time and right before deletion.
8
+
9
+ Let `clean` and `emergency` delete on a full disk: an audit log that does not fit is reported as not written and written as soon as deletions free space.
10
+
11
+ Keep everything inside a live Claude Code session scratchpad until the session and its transcript stay idle for the inactive window, at scan time and right before deletion.
12
+
13
+ Show the dss run among swap holders as `(this dss run)` and never name it as a process to quit.
14
+
15
+ Name what fills a directory of many small entries in `dss usage` by name pattern, with their count and total size.
16
+
17
+ Offer rotated app logs in `~/Library/Logs`, Crashpad reports left unsent for 30 days and the GitHub CLI run-log cache in the moderate tier.
18
+
19
+ ## 0.22.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 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.
24
+
3
25
  ## 0.21.0
4
26
 
5
27
  ### 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,29 @@ 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. A directory
98
+ filled by many small entries names what fills it by pattern
99
+ (`T/ms-* (68523 entries, each under 64 MiB)`). It needs `du`, so it runs
100
+ on macOS and Linux; a full walk takes minutes.
101
+ - APFS clones that share every block with an installed app, such as Chrome's
102
+ code-sign clones, free nothing when deleted, so they are never offered. The
103
+ scan lists them under `SHARED WITH INSTALLED APPS` and `dss usage` marks
104
+ them, because `du` and Finder count them at full size.
105
+ - `node_modules` counts as regenerable only while it holds what package
106
+ managers install: packages, `@scope` folders, `.bin`, lockfile copies and
107
+ tool caches such as `.cache` or `.vite`. Any other data kept there
108
+ (verification runs, captured evidence) blocks the whole directory, and the
109
+ check runs again right before deleting.
110
+ - Git worktree folders (`.worktrees`, `worktrees`) restart the project search
111
+ depth, so dependencies of nested worktrees are found below `--max-depth`.
88
112
  - `clean` automatically reuses a matching scan from the last 30 minutes.
89
113
  `--fresh` bypasses this plan and the discovery/size index; `--cache-dir DIR`
90
114
  chooses the state location (default `$XDG_STATE_HOME/disk-space-saviour`, or
@@ -122,7 +146,9 @@ their blocks with the installed app), Chromium on-device AI models
122
146
  Electron and electron-builder archives, the Bun transpiler cache, Docker
123
147
  Desktop and Warp staged updates (kept while the app runs), Zed language
124
148
  servers, tree-sitter toolchains, Codex plugin staging, the Clang module cache
125
- and processed Crashpad reports. Telegram pure caches, Safari/WebKit,
149
+ Crashpad reports (processed ones, and unsent ones after 30 days), rotated app
150
+ logs in `~/Library/Logs` (`warp.log.old.2`) and the GitHub CLI run-log cache.
151
+ Telegram pure caches, Safari/WebKit,
126
152
  Chromium code/GPU/Dawn/service-worker caches and marker-validated Electron
127
153
  caches are included. Old Playwright browser revisions are kept while a
128
154
  Playwright package recorded in the registry's `.links` still launches them or
@@ -131,6 +157,10 @@ directories are removed once untouched. Telegram caches are removed only
131
157
  while Telegram is not running. Telegram databases, browser profiles and
132
158
  Playwright MCP profiles are preserved. macOS access failures explain when Full Disk Access
133
159
  is needed. Agent scratch cleanup requires inactivity and Git safety checks.
160
+ Anything inside a Claude Code session scratchpad
161
+ (`{TMP}/claude-<uid>/<project>/<session>`) is kept until the session and its
162
+ transcript in `~/.claude/projects` stay untouched for the inactive window
163
+ (30 days), checked again right before deletion.
134
164
 
135
165
  ## Safety
136
166
 
@@ -149,6 +179,24 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
149
179
  cache, while `bun install`, `bunx` or an auto-installing script does.
150
180
  A directory on the `PATH` of a running process is kept too: the process
151
181
  may still start programs from it.
182
+ - **Per-app macOS folders stay while their app runs.** Caches, containers
183
+ and other folders under `~/Library/<kind>/<bundle-id>` belong to the app
184
+ with that bundle identifier, read from the `Info.plist` of every bundle a
185
+ running process starts from. Such a folder is blocked while that app, a
186
+ helper or an updater of it runs (for example the WebKit cache of Music,
187
+ Telegram's group container), even when no rule names the app's process.
188
+ A path held open names its holder: `in use: <path> (pid N, App)`.
189
+ - **Blocked space is grouped by what frees it.** A `WARNING:` line and the
190
+ `FREEABLE ON CONDITIONS` section total blocked space by the condition that
191
+ clears every blocker of an item: quit an app, a process or Claude Code
192
+ session ending, files closing, the activity window passing, a container
193
+ stopping or a later scan finishing what this one ran out of time for.
194
+ Items kept for Git state, protected installs or unverifiable records are
195
+ counted apart. `clean` repeats the summary after the plan. Rerun dss once
196
+ a condition is met; it rechecks everything before deleting. `clean` scans
197
+ again instead of reusing a saved scan when a process named by its blockers
198
+ has exited, and warns when the scan behind its plan stopped early (for
199
+ example at `--scan-budget`), since items it never reached are not planned.
152
200
  - **Toolchain versions are kept while anything may need them.** The
153
201
  manager's default and aliases, versions pinned by a project (`.nvmrc`,
154
202
  `rust-toolchain.toml`, ...) and the newest install stay. A partial nvm pin
@@ -198,7 +246,10 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
198
246
  include every candidate and its blockers; blocked candidates do not
199
247
  contribute to reclaimable tier totals.
200
248
  If a read-only scan cannot save its audit, it warns and still prints the
201
- report. Root scans with another user's `HOME` use a private temporary audit
249
+ report. When the disk is full, `clean` and `emergency` delete anyway and
250
+ write their audit as soon as deletions free room; if it never fits, the
251
+ summary says `Audit log: not written (ENOSPC ...)`. Other audit failures,
252
+ such as an unusable `--audit-dir`, still stop them before deleting. Root scans with another user's `HOME` use a private temporary audit
202
253
  directory. Set `--audit-dir` to choose a persistent writable location.
203
254
 
204
255
  Long-lived `npm exec`/`npx` servers allow idle npm download and node-gyp
@@ -255,7 +306,8 @@ Each scan records swap usage and its largest holders in the state directory, so
255
306
  the next scan shows how much both grew during the last week. A `WARNING:` line
256
307
  appears when swap grew by 1 GiB or more, is at least 75% full, or exceeds the free
257
308
  disk space it can grow into, naming the processes holding the memory and their
258
- growth. Long-running AI agent sessions keep their history in memory: quitting and
309
+ growth. The dss run itself is listed as `(this dss run)` and never named as a
310
+ process to quit. Long-running AI agent sessions keep their history in memory: quitting and
259
311
  resuming them (`codex resume <session-id>`, `claude --resume`) releases it. When
260
312
  Docker Desktop consumes memory, saving work and restarting it or lowering its VM
261
313
  memory limit can help. dss never deletes swap files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
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/audit.js CHANGED
@@ -101,7 +101,38 @@ export async function writeAudit(audit, options = {}) {
101
101
  dir,
102
102
  `dss-${audit.command}-${stamp}-${audit.pid}.json`
103
103
  );
104
- await fsp.writeFile(file, `${JSON.stringify(audit, null, 2)}\n`);
104
+ // A write that fails half way (a full disk) leaves the previous record.
105
+ const partial = `${file}.${process.pid}.tmp`;
106
+ try {
107
+ await fsp.writeFile(partial, `${JSON.stringify(audit, null, 2)}\n`);
108
+ await fsp.rename(partial, file);
109
+ } catch (error) {
110
+ await fsp.rm(partial, { force: true }).catch(() => {});
111
+ throw error;
112
+ }
105
113
  audit.file = file;
106
114
  return file;
107
115
  }
116
+
117
+ /** Errors meaning the disk is full, which is what cleaning fixes. */
118
+ const NO_SPACE = new Set(['ENOSPC', 'EDQUOT']);
119
+
120
+ /**
121
+ * Writes the record of a run that deletes. A full disk does not stop the
122
+ * run: the failure is kept in `audit.auditError`, and the next write, after
123
+ * deletions freed space, tries again.
124
+ * @returns {Promise<string|null>} the file, or null when the disk is full
125
+ */
126
+ export async function recordAudit(audit, options = {}) {
127
+ try {
128
+ const file = await writeAudit(audit, options);
129
+ delete audit.auditError;
130
+ return file;
131
+ } catch (error) {
132
+ if (!NO_SPACE.has(error.code)) {
133
+ throw error;
134
+ }
135
+ audit.auditError = { message: error.message, code: error.code };
136
+ return null;
137
+ }
138
+ }
package/src/clean.js CHANGED
@@ -17,7 +17,7 @@ import { createWriteStream, promises as fsp } from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { finished } from 'node:stream/promises';
19
19
 
20
- import { backupDirectory, startAudit, writeAudit } from './audit.js';
20
+ import { backupDirectory, recordAudit, startAudit } from './audit.js';
21
21
  import { PhaseTimer } from './timings.js';
22
22
  import { configureContainerHost } from './docker/writable.js';
23
23
  import { DockerCli, assertAllowed, parseDockerSize } from './docker/cli.js';
@@ -45,6 +45,7 @@ import { isWithin } from './paths.js';
45
45
  import { OTHER_RULES } from './rules/other.js';
46
46
  import { compareVersions, VERSION_RULES } from './rules/versions.js';
47
47
  import { scanVersions } from './scanners/versions.js';
48
+ import { foreignDataBlocker } from './scanners/node-modules.js';
48
49
  import { analyzeRustProfile } from './scanners/rust.js';
49
50
  import {
50
51
  filterItems,
@@ -192,6 +193,7 @@ export class Cleaner {
192
193
  git: new GitInspector(env),
193
194
  liveness: new LivenessProbe(env, {
194
195
  staleAgeMs: this.options.staleAgeMs,
196
+ inactiveMs: this.options.inactiveMs,
195
197
  now: this.options.now,
196
198
  }),
197
199
  });
@@ -447,6 +449,12 @@ export class Cleaner {
447
449
  if (mount) {
448
450
  return mount;
449
451
  }
452
+ if (item.rule === 'node-modules' && type === 'remove') {
453
+ const foreign = await foreignDataBlocker(ctx.env, item.path);
454
+ if (foreign) {
455
+ return foreign;
456
+ }
457
+ }
450
458
  return this.#gitBlocker(ctx, item);
451
459
  }
452
460
 
@@ -959,7 +967,7 @@ export async function clean(report, input = {}) {
959
967
  }
960
968
  if (options.audit !== false) {
961
969
  await timings.measure('audit-write', () =>
962
- writeAudit(audit, { ...options, inProgress: true })
970
+ recordAudit(audit, { ...options, inProgress: true })
963
971
  );
964
972
  }
965
973
  };
@@ -971,7 +979,7 @@ export async function clean(report, input = {}) {
971
979
  audit.health = cleaner.health.records;
972
980
  audit.timings = timings;
973
981
  if (options.audit !== false) {
974
- await writeAudit(audit, { ...options, inProgress: true });
982
+ await recordAudit(audit, { ...options, inProgress: true });
975
983
  }
976
984
  try {
977
985
  const done = new Set(audit.entries.map((entry) => entry.id));
@@ -988,7 +996,7 @@ export async function clean(report, input = {}) {
988
996
  options.onEntry?.(entry, item);
989
997
  if (options.audit !== false) {
990
998
  await timings.measure('audit-write', () =>
991
- writeAudit(audit, { ...options, inProgress: true })
999
+ recordAudit(audit, { ...options, inProgress: true })
992
1000
  );
993
1001
  }
994
1002
  }
@@ -1013,8 +1021,19 @@ export async function finishAudit(audit, report, options) {
1013
1021
  .filter((e) => e.status === 'planned')
1014
1022
  .reduce((sum, e) => sum + e.plannedBytes, 0);
1015
1023
  audit.environments = environmentTotals(report, audit.entries);
1024
+ // What the run had to leave, grouped by the condition that frees it.
1025
+ audit.blocked = report.totals?.blocked ?? null;
1026
+ // Scanners an incomplete scan never reached contribute no items at all.
1027
+ audit.incomplete = (report.environments ?? [])
1028
+ .filter(
1029
+ (env) =>
1030
+ env.scanStatus &&
1031
+ env.scanStatus !== 'complete' &&
1032
+ env.scanStatus !== 'goal-reached'
1033
+ )
1034
+ .map((env) => ({ label: env.label, status: env.scanStatus }));
1016
1035
  if (options.audit !== false) {
1017
- await writeAudit(audit, options);
1036
+ await recordAudit(audit, options);
1018
1037
  }
1019
1038
  return audit;
1020
1039
  }
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,152 @@
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
+ /^(?:busy: )?inside the scratchpad of Claude Code session (\S+) .*; kept until it is idle for (.+)$/,
52
+ (m) => ({
53
+ key: `session:${m[1]}`,
54
+ action: `the Claude Code session ${m[1]} stays idle for ${m[2]}`,
55
+ }),
56
+ ],
57
+ [
58
+ /^scan incomplete: (.+)$/,
59
+ (m) => ({
60
+ key: 'rescan',
61
+ action: `a later scan finishes measuring them (${m[1]})`,
62
+ }),
63
+ ],
64
+ [
65
+ /^bind-mounted by running container (\S+)/,
66
+ (m) => ({ key: `container:${m[1]}`, action: `container ${m[1]} stops` }),
67
+ ],
68
+ ];
69
+
70
+ /** What must happen for `blocker` to clear, or null when nothing running explains it. */
71
+ export function blockerCondition(blocker) {
72
+ for (const [pattern, condition] of CONDITIONS) {
73
+ const match = pattern.exec(blocker);
74
+ if (match) {
75
+ return condition(match);
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Blocked space grouped by the conditions that free it, largest first.
83
+ * @param {object[]} items blocked items, nested ones already dropped
84
+ * @returns {{conditions: object[], other: {items: number, bytes: number}}}
85
+ */
86
+ export function blockedConditions(items) {
87
+ const groups = new Map();
88
+ const other = { items: 0, bytes: 0 };
89
+ for (const item of items) {
90
+ const found = item.blockers.map(blockerCondition);
91
+ if (found.length === 0 || found.includes(null)) {
92
+ other.items += 1;
93
+ other.bytes += item.bytes;
94
+ continue;
95
+ }
96
+ // The first blocker names a process most specifically (a Claude Code
97
+ // lock before the files it holds open).
98
+ const unique = found.filter(
99
+ (entry, index) => found.findIndex((e) => e.key === entry.key) === index
100
+ );
101
+ const key = unique
102
+ .map((entry) => entry.key)
103
+ .sort()
104
+ .join('+');
105
+ const group = groups.get(key) ?? {
106
+ action: unique.map((entry) => entry.action).join(' and '),
107
+ items: 0,
108
+ bytes: 0,
109
+ tiers: {},
110
+ };
111
+ group.items += 1;
112
+ group.bytes += item.bytes;
113
+ group.tiers[item.tier] = (group.tiers[item.tier] ?? 0) + item.bytes;
114
+ groups.set(key, group);
115
+ }
116
+ return {
117
+ conditions: [...groups.values()].sort((a, b) => b.bytes - a.bytes),
118
+ other,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Host processes named by blockers of a saved scan that no longer run, so
124
+ * the items they blocked may be free now and the scan is stale.
125
+ * @param {object} report saved scan
126
+ * @param {(pid: number) => boolean} alive
127
+ * @returns {number[]}
128
+ */
129
+ export function exitedBlockerPids(report, alive) {
130
+ const pids = new Set();
131
+ for (const item of report.items ?? []) {
132
+ if (item.env !== 'host') {
133
+ continue;
134
+ }
135
+ for (const blocker of item.blockers ?? []) {
136
+ for (const match of blocker.matchAll(/\(pid (\d+)/g)) {
137
+ pids.add(Number(match[1]));
138
+ }
139
+ }
140
+ }
141
+ return [...pids].filter((pid) => !alive(pid));
142
+ }
143
+
144
+ /** Whether a process with `pid` exists, including other users' processes. */
145
+ export function pidAlive(pid) {
146
+ try {
147
+ process.kill(pid, 0);
148
+ return true;
149
+ } catch (error) {
150
+ return error.code === 'EPERM';
151
+ }
152
+ }
package/src/emergency.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * kept for investigation needs its own `removeContainers` entry.
13
13
  */
14
14
 
15
- import { startAudit, writeAudit, throwIfAborted } from './audit.js';
15
+ import { recordAudit, startAudit, throwIfAborted } from './audit.js';
16
16
  import { Cleaner, cleanOrder, finishAudit, wantedItems } from './clean.js';
17
17
  import { LocalEnv } from './env/local.js';
18
18
  import { TIERS, selectByTier, tierRank } from './items.js';
@@ -120,7 +120,7 @@ export async function emergency(input = {}) {
120
120
  });
121
121
  audit.report = report;
122
122
  if (options.audit !== false) {
123
- await writeAudit(audit, { ...options, inProgress: true });
123
+ await recordAudit(audit, { ...options, inProgress: true });
124
124
  }
125
125
  let disk = audit.diskAfter ?? diskBefore;
126
126
  if (report.aborted) {
@@ -145,7 +145,7 @@ async function escalate({ report, options, env, target, goal, audit }) {
145
145
  audit.entries.push(entry);
146
146
  }
147
147
  if (options.audit !== false) {
148
- await writeAudit(audit, { ...options, inProgress: true });
148
+ await recordAudit(audit, { ...options, inProgress: true });
149
149
  }
150
150
  };
151
151
  const cleaner = new Cleaner(report, {
@@ -195,7 +195,7 @@ async function escalate({ report, options, env, target, goal, audit }) {
195
195
  }
196
196
  options.onEntry?.(entry, item);
197
197
  if (options.audit !== false) {
198
- await writeAudit(audit, { ...options, inProgress: true });
198
+ await recordAudit(audit, { ...options, inProgress: true });
199
199
  }
200
200
  if (entry.status !== 'removed' && entry.status !== 'planned') {
201
201
  continue;