disk-space-saviour 0.13.1 → 0.15.2

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/src/liveness.js CHANGED
@@ -1,16 +1,20 @@
1
1
  /**
2
2
  * Liveness checks: is anything using a path right now?
3
3
  *
4
- * Three independent signals, any of which blocks deletion:
4
+ * Independent signals, any of which blocks deletion:
5
5
  * - recent writes: newest file mtime inside the stale-age window;
6
6
  * - a relevant tool is running (by process name), optionally only when its
7
7
  * working directory is inside the project;
8
8
  * - a process holds a file, working directory, or executable inside the path
9
9
  * (`/proc/<pid>/{fd,cwd,exe}` on Linux, `lsof` on macOS).
10
+ * - a command argument names a path inside the item (including scripts
11
+ * whose interpreter has already closed its input descriptor).
10
12
  *
11
13
  * The cleaner refreshes the probe right before acting on each item.
12
14
  */
13
15
 
16
+ import path from 'node:path';
17
+
14
18
  import { isWithin } from './paths.js';
15
19
  import { formatDuration } from './units.js';
16
20
 
@@ -25,6 +29,76 @@ function nameMatches(name, candidate) {
25
29
  );
26
30
  }
27
31
 
32
+ /** Arguments preserved by /proc; command titles are a fallback. */
33
+ export function processArgs(proc) {
34
+ return (
35
+ proc.argv ??
36
+ (proc.command ?? '')
37
+ .match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)
38
+ ?.map((arg) => arg.replace(/^["']|["']$/g, '')) ??
39
+ []
40
+ );
41
+ }
42
+
43
+ function scriptIndex(args) {
44
+ const first = path.posix.basename(args[0] ?? '');
45
+ if (!/^(node|nodejs|bun|deno|python[\d.]*|bash|sh)$/.test(first)) {
46
+ return 0;
47
+ }
48
+ // Only the interpreter's script operand; option values and arbitrary
49
+ // arguments are not executable names.
50
+ let index = 1;
51
+ while (args[index]?.startsWith('-')) {
52
+ if (['-e', '--eval', '-p', '--print', '-c'].includes(args[index])) {
53
+ return -1;
54
+ }
55
+ index += ['-r', '--require', '--import', '--loader'].includes(args[index])
56
+ ? 2
57
+ : 1;
58
+ }
59
+ return index;
60
+ }
61
+
62
+ function commandNames(proc) {
63
+ const args = processArgs(proc);
64
+ const first = path.posix.basename(args[0] ?? '');
65
+ const names = [first];
66
+ if (/^npm(?: |$)/.test(proc.name ?? '') || /^npm(?: |$)/.test(first)) {
67
+ names.push('npm');
68
+ }
69
+ const index = scriptIndex(args);
70
+ if (index > 0) {
71
+ const script = path.posix.basename(args[index] ?? '');
72
+ names.push(
73
+ script,
74
+ ...(script === 'npm-cli.js' ? ['npm'] : []),
75
+ ...(script === 'npx-cli.js' ? ['npx'] : [])
76
+ );
77
+ }
78
+ // An explicit npm launcher package is also a tool (e.g. Playwright MCP).
79
+ if (isNpmExec(proc)) {
80
+ const packageArg = args.find((arg) => /^@[^/]+\/[^/]+$/.test(arg));
81
+ if (packageArg) {
82
+ names.push(packageArg.slice(1).split('/')[0]);
83
+ }
84
+ }
85
+ return names;
86
+ }
87
+
88
+ export function isNpmExec(proc) {
89
+ const args = processArgs(proc);
90
+ const index = scriptIndex(args);
91
+ const executable = path.posix.basename(args[Math.max(index, 0)] ?? '');
92
+ return (
93
+ /^npm (exec|x)(?: |$)|^npx(?: |$)/.test(proc.name ?? '') ||
94
+ /^npm (exec|x)(?: |$)|^npx(?: |$)/.test(args[0] ?? '') ||
95
+ (index >= 0 &&
96
+ (['npx', 'npx-cli.js'].includes(executable) ||
97
+ (['npm', 'npm-cli.js'].includes(executable) &&
98
+ ['exec', 'x'].includes(args[index + 1]))))
99
+ );
100
+ }
101
+
28
102
  /**
29
103
  * Name of `proc` that matches one of `names`, or null. Besides the kernel
30
104
  * name (`comm`, which programs may rename: Node.js calls its main thread
@@ -32,18 +106,12 @@ function nameMatches(name, candidate) {
32
106
  * base names.
33
107
  */
34
108
  export function matchingName(proc, names) {
35
- const own = [proc.name ?? '', ...(proc.aliases ?? [])];
109
+ const own = [proc.name ?? '', ...(proc.aliases ?? []), ...commandNames(proc)];
36
110
  for (const name of own) {
37
111
  if (name && names.some((candidate) => nameMatches(name, candidate))) {
38
112
  return name;
39
113
  }
40
114
  }
41
- const commandWords = (proc.command ?? '').toLowerCase().split(/[^a-z0-9_-]+/);
42
- for (const candidate of names) {
43
- if (commandWords.includes(candidate.toLowerCase())) {
44
- return candidate;
45
- }
46
- }
47
115
  return null;
48
116
  }
49
117
 
@@ -62,16 +130,17 @@ export class LivenessProbe {
62
130
  this.openPaths = null;
63
131
  this.probeError = false;
64
132
  this.realPaths = new Map();
133
+ this.pathOwners = new Map();
65
134
  }
66
135
 
67
136
  async refresh(force = false) {
68
137
  if (!force && this.now() - this.refreshedAt < this.refreshMs) {
69
138
  return;
70
139
  }
140
+ this.openPaths = await this.env.openPaths().catch(() => null);
71
141
  const processes = await this.env.processes().catch(() => null);
72
142
  this.listed = processes !== null;
73
143
  this.processes = processes ?? [];
74
- this.openPaths = await this.env.openPaths().catch(() => null);
75
144
  this.probeError =
76
145
  (processes === null || this.openPaths === null) &&
77
146
  (this.env.platform === 'linux' || this.env.platform === 'darwin');
@@ -90,6 +159,11 @@ export class LivenessProbe {
90
159
  * @param {string[]} paths
91
160
  */
92
161
  async resolve(paths) {
162
+ if (this.env.unreadableProcesses?.length && this.env.stat) {
163
+ for (const target of paths) {
164
+ this.pathOwners.set(target, (await this.env.stat(target))?.uid);
165
+ }
166
+ }
93
167
  if (typeof this.env.realPath !== 'function') {
94
168
  return;
95
169
  }
@@ -111,12 +185,18 @@ export class LivenessProbe {
111
185
  return null;
112
186
  }
113
187
 
114
- runningTool(names, cwd) {
188
+ runningTool(names, cwd, item = {}) {
115
189
  if (!names || names.length === 0) {
116
190
  return null;
117
191
  }
118
192
  const pathApi = this.env.path;
119
193
  for (const proc of this.processes) {
194
+ if (
195
+ ['npm-cache', 'node-gyp-cache', 'npx-cache'].includes(item.rule) &&
196
+ isNpmExec(proc)
197
+ ) {
198
+ continue;
199
+ }
120
200
  const name = matchingName(proc, names);
121
201
  if (!name) {
122
202
  continue;
@@ -169,6 +249,58 @@ export class LivenessProbe {
169
249
  return open;
170
250
  }
171
251
 
252
+ commandUsage(paths) {
253
+ for (const proc of this.processes) {
254
+ for (const arg of processArgs(proc)) {
255
+ const candidate = arg.startsWith('/')
256
+ ? arg
257
+ : proc.cwd && !arg.startsWith('-')
258
+ ? this.env.path.resolve(proc.cwd, arg)
259
+ : null;
260
+ if (
261
+ candidate &&
262
+ paths.some((target) => isWithin(candidate, target, this.env.path))
263
+ ) {
264
+ return `in use: ${candidate} (pid ${proc.pid})`;
265
+ }
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+
271
+ uncertainUsage(paths) {
272
+ for (const proc of this.env.unreadableProcesses ?? []) {
273
+ const args = processArgs(proc);
274
+ const roots = args.flatMap((arg) => {
275
+ const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : arg;
276
+ return value.startsWith('/') ? [value] : [];
277
+ });
278
+ if (proc.name === 'dockerd') {
279
+ roots.push('/var/lib/docker', '/run');
280
+ }
281
+ if (proc.name === 'containerd') {
282
+ roots.push('/var/lib/containerd', '/run/containerd');
283
+ }
284
+ for (const target of paths) {
285
+ const intersects = roots.some(
286
+ (root) =>
287
+ isWithin(root, target, this.env.path) ||
288
+ isWithin(target, root, this.env.path)
289
+ );
290
+ const owner = this.pathOwners.get(target);
291
+ if (
292
+ intersects ||
293
+ proc.uid === null ||
294
+ owner === undefined ||
295
+ owner === proc.uid
296
+ ) {
297
+ return `unreadable process ${proc.name || 'unknown'} (pid ${proc.pid}, uid ${proc.uid ?? 'unknown'}) may use ${target}`;
298
+ }
299
+ }
300
+ }
301
+ return null;
302
+ }
303
+
172
304
  /**
173
305
  * First reason the item is busy, or null when it is idle. Items with
174
306
  * `checks.perPath` leave open files to `openPathsOf`: the caller skips
@@ -179,12 +311,27 @@ export class LivenessProbe {
179
311
  const checks = item.checks ?? {};
180
312
  const paths = checks.perPath ? [] : (item.paths ?? []);
181
313
  return (
314
+ this.installationBlocker(item.paths ?? []) ??
182
315
  (this.probeError
183
- ? 'cannot verify process and open-file activity'
316
+ ? `cannot verify process and open-file activity${this.env.probeDiagnostic ? `: ${this.env.probeDiagnostic}` : ''}`
184
317
  : null) ??
185
318
  (checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
186
- this.runningTool(checks.busy, checks.cwd) ??
187
- this.openInside(paths)
319
+ this.runningTool(checks.busy, checks.cwd, item) ??
320
+ this.openInside(paths) ??
321
+ this.commandUsage(paths) ??
322
+ this.uncertainUsage(item.paths ?? [])
188
323
  );
189
324
  }
325
+
326
+ installationBlocker(paths) {
327
+ return paths.some((target) =>
328
+ (this.env.protectedPaths ?? []).some(
329
+ (root) =>
330
+ isWithin(root, target, this.env.path) ||
331
+ isWithin(target, root, this.env.path)
332
+ )
333
+ )
334
+ ? 'dss current installation is protected'
335
+ : null;
336
+ }
190
337
  }
package/src/options.js CHANGED
@@ -21,6 +21,7 @@ export const DEFAULTS = {
21
21
  exclude: [],
22
22
  includeVolumes: false,
23
23
  removeStoppedContainers: false,
24
+ investigationMaxAge: null,
24
25
  removeContainers: [],
25
26
  removeImages: [],
26
27
  allowDirtyRepos: false,
@@ -77,6 +78,10 @@ export function resolveOptions(input = {}) {
77
78
  dockerDepth: Number(merged.dockerDepth),
78
79
  staleAgeMs: toMs(staleAge),
79
80
  inactiveMs: toMs(merged.inactive),
81
+ investigationMaxAgeMs:
82
+ merged.investigationMaxAge === null
83
+ ? null
84
+ : toMs(merged.investigationMaxAge),
80
85
  minSizeBytes: toBytes(merged.minSize),
81
86
  journalKeepBytes: toBytes(merged.journalKeep),
82
87
  containerFilter: toList(merged.containers),
package/src/paths.js CHANGED
@@ -13,8 +13,17 @@ export function globToRegExp(glob) {
13
13
  let cached = globCache.get(glob);
14
14
  if (!cached) {
15
15
  let source = '';
16
- for (const char of glob) {
17
- if (char === '*') {
16
+ for (let index = 0; index < glob.length; index++) {
17
+ const char = glob[index];
18
+ if (char === '*' && glob[index + 1] === '*') {
19
+ index++;
20
+ if (glob[index + 1] === '/') {
21
+ source += '(?:.*/)?';
22
+ index++;
23
+ } else {
24
+ source += '.*';
25
+ }
26
+ } else if (char === '*') {
18
27
  source += '[^/\\\\]*';
19
28
  } else if (char === '?') {
20
29
  source += '[^/\\\\]';
@@ -38,6 +47,29 @@ export function matchesGlob(name, glob) {
38
47
  return globToRegExp(glob).test(name);
39
48
  }
40
49
 
50
+ /** Match exclusions against a path and each ancestor, including dotfiles. */
51
+ export function matchesExcluded(target, patterns, pathApi) {
52
+ const normalize = (value) => {
53
+ const normalized = value.replaceAll('\\', '/');
54
+ return pathApi.sep === '\\' ? normalized.toLowerCase() : normalized;
55
+ };
56
+ for (const pattern of patterns ?? []) {
57
+ const full = /[/\\]/.test(pattern);
58
+ for (let current = pathApi.resolve(target); ; ) {
59
+ const candidate = full ? current : pathApi.basename(current);
60
+ if (matchesGlob(normalize(candidate), normalize(pattern))) {
61
+ return true;
62
+ }
63
+ const parent = pathApi.dirname(current);
64
+ if (parent === current) {
65
+ break;
66
+ }
67
+ current = parent;
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+
41
73
  /**
42
74
  * Whether `child` equals `parent` or lies below it.
43
75
  * @param {string} child
package/src/report.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { TIERS, consentFlag } from './items.js';
7
- import { formatBytes } from './units.js';
7
+ import { formatBytes, formatDuration } from './units.js';
8
8
 
9
9
  const DEFAULT_LIMIT = 25;
10
10
 
@@ -51,7 +51,12 @@ function itemLine(item, showEnv) {
51
51
  const confirm = item.requiresConfirmation
52
52
  ? ` (needs ${consentFlag(item)})`
53
53
  : '';
54
- return ` ${pad(formatBytes(item.bytes), 10)} ${env}${item.rule} ${itemLocation(item)}${confirm}`;
54
+ const storage = item.sizeUnknown
55
+ ? ' (may be in an image layer; freeable bytes unknown, excluded from totals)'
56
+ : item.imageBytes
57
+ ? ` (${formatBytes(item.imageBytes)} in image${item.imageRef ? ` ${item.imageRef}` : ''}; 0 B of that data freeable now; remove the stopped container and unused image after the task completes)`
58
+ : '';
59
+ return ` ${pad(formatBytes(item.bytes), 10)} ${env}${item.rule} ${itemLocation(item)}${confirm}${storage}`;
55
60
  }
56
61
 
57
62
  function limited(lines, limit) {
@@ -108,7 +113,12 @@ function repoSummary(repo) {
108
113
  }
109
114
  const parts = [];
110
115
  if (repo.dirty) {
111
- parts.push(`${repo.dirty} uncommitted`);
116
+ parts.push(
117
+ `${repo.dirty} uncommitted${repo.untracked ? ` (${repo.untracked} untracked)` : ''}`
118
+ );
119
+ if (repo.preservedOn) {
120
+ parts.push(`uncommitted work preserved on ${repo.preservedOn}`);
121
+ }
112
122
  }
113
123
  if (repo.unpushed) {
114
124
  parts.push(`${repo.unpushed} unpushed`);
@@ -155,6 +165,14 @@ function containerLines(container, stoppedItems) {
155
165
  lines.push(` ${ended}`);
156
166
  }
157
167
  const item = stoppedItems.get(container.id);
168
+ if (container.sizeUnknown) {
169
+ lines.push(' writable layer size unknown');
170
+ }
171
+ if (container.pinnedImage) {
172
+ lines.push(
173
+ ` ${formatBytes(container.pinnedImage.bytes)} unique image bytes pinned by this container (${container.pinnedImage.ref}); after removing it, approve the unused image separately with --remove-image ${container.pinnedImage.ref}`
174
+ );
175
+ }
158
176
  if (item?.container?.investigation) {
159
177
  lines.push(` ${item.container.investigation}`);
160
178
  }
@@ -192,6 +210,25 @@ function imageLine(item) {
192
210
  )}) unused, ${formatBytes(image.bytes)}, ${created}${lastUseText(image.lastUsedBy)}${consent}`;
193
211
  }
194
212
 
213
+ function investigationSummary(docker, stoppedItems, createdAt) {
214
+ const lines = [];
215
+ const kept = docker.containers.filter(
216
+ (container) => stoppedItems.get(container.id)?.container?.investigation
217
+ );
218
+ if (kept.length) {
219
+ const dates = kept
220
+ .map((container) => Date.parse(container.owner?.finishedAt))
221
+ .filter(Number.isFinite);
222
+ const age = dates.length
223
+ ? `, oldest ${formatDuration(Math.max(0, Date.parse(createdAt) - Math.min(...dates)))}`
224
+ : '';
225
+ lines.push(
226
+ ` ${formatBytes(kept.reduce((sum, container) => sum + container.bytes, 0))} in ${kept.length} containers kept for investigation${age}`
227
+ );
228
+ }
229
+ return lines;
230
+ }
231
+
195
232
  function dockerSection(report) {
196
233
  const docker = report.docker;
197
234
  if (!docker || docker.daemons.length === 0) {
@@ -202,7 +239,11 @@ function dockerSection(report) {
202
239
  .filter((item) => item.container)
203
240
  .map((item) => [item.container.id, item])
204
241
  );
205
- const lines = ['', 'DOCKER'];
242
+ const lines = [
243
+ '',
244
+ 'DOCKER',
245
+ ...investigationSummary(docker, stoppedItems, report.createdAt),
246
+ ];
206
247
  for (const daemon of docker.daemons) {
207
248
  lines.push(
208
249
  ` daemon ${daemon.name} (depth ${daemon.depth}, ${daemon.serverVersion}, ${daemon.driver}) via ${daemon.env}`
@@ -214,9 +255,20 @@ function dockerSection(report) {
214
255
  for (const item of report.items.filter((entry) => entry.image)) {
215
256
  lines.push(imageLine(item));
216
257
  }
258
+ const grouped = new Map();
217
259
  for (const hint of docker.hints ?? []) {
260
+ const key = `${(hint.images ?? [hint.image]).join(', ')}\0${hint.hint}`;
261
+ const group = grouped.get(key);
262
+ if (group) {
263
+ group.bytes += hint.bytes;
264
+ group.layers += 1;
265
+ } else {
266
+ grouped.set(key, { ...hint, layers: 1 });
267
+ }
268
+ }
269
+ for (const hint of grouped.values()) {
218
270
  lines.push(
219
- ` hint ${(hint.images ?? [hint.image]).join(', ')}: ${formatBytes(hint.bytes)} layer, ${hint.hint}`
271
+ ` hint ${(hint.images ?? [hint.image]).join(', ')}: ${formatBytes(hint.bytes)} in ${hint.layers} layer${hint.layers === 1 ? '' : 's'}, ${hint.hint}`
220
272
  );
221
273
  }
222
274
  for (const volume of docker.volumes ?? []) {
@@ -246,7 +298,7 @@ function errorsSection(errors) {
246
298
  }
247
299
  return [
248
300
  '',
249
- 'ERRORS',
301
+ 'ERRORS (scan incomplete)',
250
302
  ...errors.map(
251
303
  (error) => ` ${error.env} ${error.scanner}: ${error.message}`
252
304
  ),
@@ -269,6 +321,11 @@ export function formatReport(report, options = {}) {
269
321
  for (const tier of TIERS) {
270
322
  lines.push(...tierSection(report, tier, { limit }));
271
323
  }
324
+ for (const env of report.environments) {
325
+ if (env.hint) {
326
+ lines.push(` ${env.label}: ${env.hint}`);
327
+ }
328
+ }
272
329
  lines.push(...blockedSection(report, { limit }));
273
330
  lines.push(...dockerSection(report));
274
331
  lines.push(...errorsSection(report.errors));
@@ -310,7 +367,7 @@ function healthLines(records) {
310
367
  if (!record.available) {
311
368
  return ` ${record.envLabel}: processes could not be listed`;
312
369
  }
313
- return ` ${record.envLabel}: ${record.watched.length} watched processes still running`;
370
+ return ` ${record.envLabel}: ${record.watched.length} watched processes still running: ${record.watched.map((proc) => `${proc.name} (pid ${proc.pid})`).join(', ')}`;
314
371
  });
315
372
  return lines.length > 0 ? ['', 'Task health:', ...lines] : [];
316
373
  }
@@ -358,6 +415,9 @@ export function formatAudit(audit, options = {}) {
358
415
  '',
359
416
  ...limited(shown.map(entryLine), limit),
360
417
  ];
418
+ if (audit.aborted) {
419
+ lines.push(` cleanup aborted: ${audit.error ?? 'interrupted'}`);
420
+ }
361
421
  if (hidden > 0) {
362
422
  lines.push(` ${hidden} skipped (use --verbose to list them)`);
363
423
  }
@@ -0,0 +1,178 @@
1
+ /** Only regenerable cache leaves; profiles, databases and local storage survive. */
2
+ export const CACHE_LEAVES = [
3
+ 'Cache',
4
+ 'Code Cache',
5
+ 'GPUCache',
6
+ 'DawnGraphiteCache',
7
+ 'DawnWebGPUCache',
8
+ 'Service Worker/CacheStorage',
9
+ 'Service Worker/ScriptCache',
10
+ ];
11
+ const telegramAccounts =
12
+ '~/Library/Group Containers/*.ru.keepcoder.Telegram/*/account-*/postbox/media';
13
+ const telegramDesktop = [
14
+ '~/.local/share/TelegramDesktop/tdata',
15
+ '~/Library/Application Support/Telegram Desktop/tdata',
16
+ '{APPDATA}/Telegram Desktop/tdata',
17
+ ];
18
+ export const APP_RULES = [
19
+ {
20
+ id: 'telegram-caches',
21
+ ecosystem: 'apps',
22
+ description: 'Telegram regenerable media cache partitions',
23
+ paths: [
24
+ ...['cache', 'cache-storage', 'short-cache'].map(
25
+ (leaf) => `${telegramAccounts}/${leaf}`
26
+ ),
27
+ '~/Library/Containers/ru.keepcoder.Telegram/Data/Library/Caches',
28
+ '~/Library/Caches/ru.keepcoder.Telegram',
29
+ ...telegramDesktop.flatMap((root) =>
30
+ ['cache', 'user_data/cache', 'user_data/media_cache'].map(
31
+ (leaf) => `${root}/${leaf}`
32
+ )
33
+ ),
34
+ ],
35
+ busy: ['Telegram', 'telegram-desktop'],
36
+ },
37
+ {
38
+ id: 'telegram-downloaded-media',
39
+ excludeNames: ['cache', 'cache-storage', 'short-cache'],
40
+ ecosystem: 'apps',
41
+ tier: 'moderate',
42
+ description:
43
+ 'Telegram downloaded media: secret-chat media may not be recoverable',
44
+ paths: [
45
+ `${telegramAccounts}/*`,
46
+ ...telegramDesktop.map((root) => `${root}/user_data/downloads`),
47
+ ],
48
+ busy: ['Telegram', 'telegram-desktop'],
49
+ },
50
+ {
51
+ id: 'safari-webkit-caches',
52
+ ecosystem: 'browsers',
53
+ description: 'Safari and WebKit HTTP/compiled resource caches',
54
+ paths: [
55
+ '~/Library/Caches/com.apple.Safari',
56
+ '~/Library/Caches/com.apple.WebKit.Networking',
57
+ '~/Library/Caches/*/WebKit',
58
+ '~/Library/Containers/com.apple.Safari/Data/Library/Caches',
59
+ '~/Library/Containers/com.apple.Safari/Data/Library/WebKit/WebsiteData/Default/CacheStorage',
60
+ '~/Library/WebKit/com.apple.Safari/WebsiteData/Default/CacheStorage',
61
+ ],
62
+ busy: ['Safari', 'com.apple.WebKit'],
63
+ },
64
+ {
65
+ id: 'electron-profile-caches',
66
+ ecosystem: 'apps',
67
+ description: 'Electron app regenerable caches',
68
+ paths: [
69
+ '~/Library/Application Support/*',
70
+ '~/.config/*',
71
+ '{APPDATA}/*',
72
+ ].flatMap((root) => CACHE_LEAVES.map((leaf) => `${root}/${leaf}`)),
73
+ markers: ['Local Storage', 'Preferences'],
74
+ busy: ['electron', 'Electron'],
75
+ },
76
+ {
77
+ id: 'agent-scratch-sessions',
78
+ directoryOnly: true,
79
+ ecosystem: 'agents',
80
+ scope: 'tmp',
81
+ tier: 'moderate',
82
+ description:
83
+ 'inactive Claude/Codex scratch sessions and partial work copies',
84
+ paths: [
85
+ '{TMP}/claude-*/*/*',
86
+ '{TMP}/codex-*/*/*',
87
+ '{TMP}/codex-scratch-*',
88
+ '{TMP}/claude-copy-*',
89
+ '{TMP}/codex-copy-*',
90
+ ],
91
+ minAge: 'inactive',
92
+ git: 'ifPresent',
93
+ busy: ['claude', 'codex', 'git', 'node', 'bun'],
94
+ },
95
+ {
96
+ id: 'agent-scratch-files',
97
+ ecosystem: 'agents',
98
+ scope: 'tmp',
99
+ tier: 'moderate',
100
+ description: 'inactive agent-named temporary logs and scripts',
101
+ paths: [
102
+ '{TMP}/claude-*.log',
103
+ '{TMP}/claude-*.sh',
104
+ '{TMP}/codex-*.log',
105
+ '{TMP}/codex-*.sh',
106
+ ],
107
+ minAge: 'inactive',
108
+ busy: ['claude', 'codex', 'bash', 'sh'],
109
+ fileOnly: true,
110
+ },
111
+ {
112
+ id: 'bunx-cache',
113
+ ecosystem: 'javascript',
114
+ description: 'per-entry bunx package cache',
115
+ scope: 'tmp',
116
+ paths: ['{TMP}/bunx-*'],
117
+ minAge: 'stale',
118
+ busy: [],
119
+ },
120
+ {
121
+ id: 'pnpm-dlx-cache',
122
+ ecosystem: 'javascript',
123
+ description: 'per-entry pnpm dlx package cache',
124
+ paths: [
125
+ '~/.cache/pnpm/dlx/*',
126
+ '~/Library/Caches/pnpm/dlx/*',
127
+ '{LOCALAPPDATA}/pnpm-cache/dlx/*',
128
+ ],
129
+ minAge: 'stale',
130
+ busy: [],
131
+ },
132
+ {
133
+ id: 'yarn-dlx-cache',
134
+ ecosystem: 'javascript',
135
+ description: 'per-entry Yarn dlx temporary cache',
136
+ scope: 'tmp',
137
+ paths: ['{TMP}/dlx-*'],
138
+ minAge: 'stale',
139
+ busy: [],
140
+ },
141
+ ];
142
+
143
+ export function expandChromiumCaches(rules) {
144
+ return rules.map((rule) => {
145
+ if (
146
+ !rule.id.includes('http-caches') ||
147
+ !rule.paths.some((p) => p.includes('/Code Cache'))
148
+ ) {
149
+ return rule;
150
+ }
151
+ const bases = rule.paths
152
+ .filter((p) => p.endsWith('/Code Cache'))
153
+ .map((p) => p.slice(0, -'/Code Cache'.length));
154
+ const support = bases
155
+ .filter((p) => p.includes('~/Library/Caches/'))
156
+ .map((p) =>
157
+ p.replace('~/Library/Caches/', '~/Library/Application Support/')
158
+ );
159
+ const profiles = [...bases, ...support];
160
+ const roots = profiles.map((p) => (p.endsWith('/*') ? p.slice(0, -2) : p));
161
+ return {
162
+ ...rule,
163
+ paths: [
164
+ ...new Set([
165
+ ...rule.paths,
166
+ ...profiles.flatMap((p) =>
167
+ CACHE_LEAVES.map((leaf) => `${p}/${leaf}`)
168
+ ),
169
+ ...roots.flatMap((p) =>
170
+ ['GrShaderCache', 'ShaderCache', 'component_crx_cache'].map(
171
+ (leaf) => `${p}/${leaf}`
172
+ )
173
+ ),
174
+ ]),
175
+ ],
176
+ };
177
+ });
178
+ }
@@ -31,15 +31,16 @@ export const ECOSYSTEMS = [
31
31
  caches: [
32
32
  {
33
33
  id: 'npm-cache',
34
+ mtime: true,
34
35
  description: 'npm download cache',
35
36
  paths: ['~/.npm/_cacache', '{LOCALAPPDATA}/npm-cache/_cacache'],
36
- native: { tool: 'npm', argv: ['npm', 'cache', 'clean', '--force'] },
37
37
  busy: ['npm'],
38
38
  },
39
39
  {
40
40
  id: 'npx-cache',
41
41
  description: 'npx package cache',
42
- paths: ['~/.npm/_npx', '{LOCALAPPDATA}/npm-cache/_npx'],
42
+ paths: ['~/.npm/_npx/*', '{LOCALAPPDATA}/npm-cache/_npx/*'],
43
+ minAge: 'stale',
43
44
  busy: ['npm', 'npx'],
44
45
  },
45
46
  {
@@ -84,6 +85,7 @@ export const ECOSYSTEMS = [
84
85
  },
85
86
  {
86
87
  id: 'node-gyp-cache',
88
+ mtime: true,
87
89
  description: 'node-gyp headers cache',
88
90
  paths: ['~/.cache/node-gyp', '~/Library/Caches/node-gyp'],
89
91
  busy: ['node-gyp', 'npm'],
@@ -724,7 +726,6 @@ export const ECOSYSTEMS = [
724
726
  id: 'opam-download-cache',
725
727
  description: 'opam download cache',
726
728
  paths: ['~/.opam/download-cache'],
727
- native: { tool: 'opam', argv: ['opam', 'clean', '--download-cache'] },
728
729
  busy: ['opam', 'dune', 'ocaml'],
729
730
  },
730
731
  ],