disk-space-saviour 0.16.0 → 0.18.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/src/git.js CHANGED
@@ -36,36 +36,77 @@ const SAFE_GIT_ENV = {
36
36
  GIT_TERMINAL_PROMPT: '0',
37
37
  };
38
38
 
39
- /** A metadata-only Git directory, with no repository-supplied config or hooks. */
40
- async function safeGitDirectory(root, callback) {
41
- const source = path.join(root, '.git');
42
- const stat = await fsp.lstat(source);
43
- if (!stat.isDirectory()) {
44
- throw new Error('cannot safely inspect a linked Git worktree');
39
+ /**
40
+ * The checkout's own Git directory (HEAD, index) and the common directory
41
+ * holding refs and objects. Both are `<root>/.git` in a plain repository;
42
+ * a linked worktree or a submodule has a `.git` file naming its directory,
43
+ * and a linked worktree names the common directory in `commondir`.
44
+ */
45
+ async function gitDirectories(root) {
46
+ const dotGit = path.join(root, '.git');
47
+ const stat = await fsp.lstat(dotGit);
48
+ let gitDir = dotGit;
49
+ if (stat.isFile()) {
50
+ const named = /^gitdir: (.+)$/m.exec(await fsp.readFile(dotGit, 'utf8'));
51
+ if (!named) {
52
+ throw new Error(`${dotGit} does not name a Git directory`);
53
+ }
54
+ gitDir = path.resolve(root, named[1].trim());
55
+ } else if (!stat.isDirectory()) {
56
+ throw new Error(`${dotGit} is neither a directory nor a gitdir file`);
57
+ }
58
+ const commonDir = await fsp
59
+ .readFile(path.join(gitDir, 'commondir'), 'utf8')
60
+ .then(
61
+ (text) => path.resolve(gitDir, text.trim()),
62
+ (error) => {
63
+ if (error.code === 'ENOENT') {
64
+ return gitDir;
65
+ }
66
+ throw error;
67
+ }
68
+ );
69
+ if (!(await fsp.stat(path.join(commonDir, 'objects'))).isDirectory()) {
70
+ throw new Error(`${commonDir} has no object database`);
71
+ }
72
+ return { gitDir, commonDir };
73
+ }
74
+
75
+ async function copyGitMetadata(source, dir, names, force) {
76
+ for (const name of names) {
77
+ await fsp
78
+ .cp(path.join(source, name), path.join(dir, name), {
79
+ recursive: true,
80
+ force,
81
+ errorOnExist: !force,
82
+ })
83
+ .catch((error) => {
84
+ if (error.code !== 'ENOENT') {
85
+ throw error;
86
+ }
87
+ });
45
88
  }
89
+ }
90
+
91
+ /**
92
+ * A metadata-only Git directory, with no repository-supplied config or hooks.
93
+ * With `indexOnly`, refs and reflogs are left out: enough for `ls-files`,
94
+ * which reads only HEAD, the index and the work tree.
95
+ */
96
+ async function safeGitDirectory(root, callback, { indexOnly = false } = {}) {
97
+ const { gitDir, commonDir } = await gitDirectories(root);
46
98
  const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dss-safe-git-'));
47
99
  try {
48
- for (const name of [
49
- 'HEAD',
50
- 'index',
51
- 'packed-refs',
52
- 'shallow',
53
- 'refs',
54
- 'logs',
55
- ]) {
56
- await fsp
57
- .cp(path.join(source, name), path.join(dir, name), {
58
- recursive: true,
59
- force: false,
60
- errorOnExist: true,
61
- })
62
- .catch((error) => {
63
- if (error.code !== 'ENOENT') {
64
- throw error;
65
- }
66
- });
100
+ if (indexOnly) {
101
+ await copyGitMetadata(gitDir, dir, ['HEAD', 'index'], false);
102
+ await fsp.mkdir(path.join(dir, 'refs'));
103
+ } else {
104
+ await copyIndexAndRefs(gitDir, commonDir, dir);
67
105
  }
68
- await fsp.symlink(path.join(source, 'objects'), path.join(dir, 'objects'));
106
+ await fsp.symlink(
107
+ path.join(commonDir, 'objects'),
108
+ path.join(dir, 'objects')
109
+ );
69
110
  await fsp.writeFile(
70
111
  path.join(dir, 'config'),
71
112
  '[core]\n\trepositoryformatversion = 0\n\tbare = false\n'
@@ -76,6 +117,24 @@ async function safeGitDirectory(root, callback) {
76
117
  }
77
118
  }
78
119
 
120
+ async function copyIndexAndRefs(gitDir, commonDir, dir) {
121
+ await copyGitMetadata(
122
+ commonDir,
123
+ dir,
124
+ ['packed-refs', 'shallow', 'refs', 'logs'],
125
+ false
126
+ );
127
+ // Per-worktree state overrides the shared refs and reflogs.
128
+ await copyGitMetadata(
129
+ gitDir,
130
+ dir,
131
+ gitDir === commonDir
132
+ ? ['HEAD', 'index']
133
+ : ['HEAD', 'index', 'refs', 'logs'],
134
+ true
135
+ );
136
+ }
137
+
79
138
  function countLines(text) {
80
139
  return text.split('\n').filter((line) => line.trim() !== '').length;
81
140
  }
@@ -310,19 +369,22 @@ export class GitInspector {
310
369
  try {
311
370
  const result =
312
371
  this.env.kind === 'host'
313
- ? await safeGitDirectory(root, (safeDir) =>
314
- this.#git(
315
- root,
316
- [
317
- 'ls-files',
318
- '--cached',
319
- '--others',
320
- '--exclude-standard',
321
- '--',
322
- relative,
323
- ],
324
- safeDir
325
- )
372
+ ? await safeGitDirectory(
373
+ root,
374
+ (safeDir) =>
375
+ this.#git(
376
+ root,
377
+ [
378
+ 'ls-files',
379
+ '--cached',
380
+ '--others',
381
+ '--exclude-standard',
382
+ '--',
383
+ relative,
384
+ ],
385
+ safeDir
386
+ ),
387
+ { indexOnly: true }
326
388
  )
327
389
  : await this.#git(root, [
328
390
  'ls-files',
package/src/health.js CHANGED
@@ -107,9 +107,9 @@ function describe(lost) {
107
107
  * Per-environment snapshots and re-checks. `records` is the audit view.
108
108
  */
109
109
  export class HealthWatch {
110
- constructor() {
111
- this.records = [];
112
- this.byEnv = new Map();
110
+ constructor(records = []) {
111
+ this.records = [...records];
112
+ this.byEnv = new Map(records.map((record) => [record.env, record]));
113
113
  }
114
114
 
115
115
  /**
package/src/index.d.ts CHANGED
@@ -13,19 +13,28 @@ export interface ScanOptions {
13
13
  maxDepth?: number;
14
14
  /** Time budget per filesystem environment (`2m`); incomplete findings are kept. */
15
15
  scanBudget?: Duration;
16
+ /** Persistent scan/index directory. Library default is false; CLI uses XDG state. */
17
+ cacheDir?: string | false;
18
+ /** Use verified Spotlight/locate hints before the discovery walk. */
19
+ platformIndex?: boolean;
16
20
  signal?: AbortSignal;
17
21
  onScanProgress?: (progress: {
18
22
  env: string;
19
23
  scanner: string;
20
24
  elapsedMs: number;
21
25
  items: number;
26
+ candidates: number;
27
+ filesSized: number;
28
+ directoriesSized: number;
29
+ indexHits: number;
30
+ sizeCacheHits: number;
22
31
  status: string;
23
32
  }) => void;
24
33
  /** Activity window: newer files are kept (`1h`). */
25
34
  staleAge?: Duration;
26
35
  /** Alias of `staleAge`. */
27
36
  olderThan?: Duration;
28
- /** Inactivity before lockfile-backed node_modules become `safe`, other dependency dirs `moderate` (`30d`). */
37
+ /** Idle time before agent scratch and similar leftovers count as abandoned (`30d`). Project build and dependency directories do not depend on it. */
29
38
  inactive?: Duration;
30
39
  /** Items smaller than this are dropped (`1M`). */
31
40
  minSize?: Size;
@@ -109,6 +118,8 @@ export interface Item {
109
118
  path: string | null;
110
119
  paths: string[];
111
120
  bytes: number;
121
+ /** Cached size is advisory; cleanup always measures again. */
122
+ sizeEstimated?: boolean;
112
123
  /** Merged filesystem bytes, including image data, when measured in Docker. */
113
124
  totalBytes?: number;
114
125
  /** Immutable image bytes excluded from reclaimable totals. */
@@ -154,9 +165,23 @@ export interface EnvironmentDescriptor {
154
165
  chain: Array<{ containerId: string; name: string }>;
155
166
  hint?: string | null;
156
167
  disk?: Disk | null;
168
+ swap?: {
169
+ total: number;
170
+ used: number;
171
+ free: number;
172
+ holders: Array<{
173
+ pid: number;
174
+ command: string;
175
+ memoryBytes: number;
176
+ compressedBytes: number;
177
+ }>;
178
+ attribution: string;
179
+ advice: string;
180
+ } | null;
157
181
  scanStatus?:
158
182
  | 'scanning'
159
183
  | 'complete'
184
+ | 'goal-reached'
160
185
  | 'aborted'
161
186
  | 'budget-exceeded'
162
187
  | 'error'
@@ -178,6 +203,7 @@ export interface Report {
178
203
  createdAt: string;
179
204
  durationMs: number;
180
205
  aborted?: boolean;
206
+ authentication?: { hostname: string; uid: number | string; digest: string };
181
207
  audit?: { file: string | null; error: string; code?: string };
182
208
  host: { env: string; label: string; platform: string };
183
209
  options: Record<string, unknown>;
@@ -235,6 +261,7 @@ export interface AuditLog {
235
261
  startedAt: string;
236
262
  finishedAt: string | null;
237
263
  aborted?: boolean;
264
+ report?: Partial<Report>;
238
265
  error?: string;
239
266
  entries: AuditEntry[];
240
267
  freedBytes: number;
package/src/liveness.js CHANGED
@@ -8,7 +8,9 @@
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
10
  * - a command argument names a path inside the item (including scripts
11
- * whose interpreter has already closed its input descriptor).
11
+ * whose interpreter has already closed its input descriptor);
12
+ * - a directory inside the item is on a process's `PATH`, so the process
13
+ * may start a program from it at any time.
12
14
  *
13
15
  * The cleaner refreshes the probe right before acting on each item.
14
16
  */
@@ -20,6 +22,44 @@ import { formatDuration } from './units.js';
20
22
 
21
23
  const PROC_COMM_LIMIT = 15;
22
24
 
25
+ /** Bun subcommands that read or write the install cache. */
26
+ const BUN_PACKAGE_COMMANDS = new Set([
27
+ 'install',
28
+ 'i',
29
+ 'add',
30
+ 'a',
31
+ 'remove',
32
+ 'rm',
33
+ 'update',
34
+ 'upgrade',
35
+ 'outdated',
36
+ 'link',
37
+ 'unlink',
38
+ 'pm',
39
+ 'x',
40
+ 'create',
41
+ 'c',
42
+ 'init',
43
+ 'patch',
44
+ 'publish',
45
+ 'audit',
46
+ 'why',
47
+ 'info',
48
+ ]);
49
+
50
+ function isBun(proc) {
51
+ return Boolean(matchingName(proc, ['bun', 'bunx']));
52
+ }
53
+
54
+ function bunPackageCommand(proc) {
55
+ const args = processArgs(proc);
56
+ if (path.posix.basename(args[0] ?? '') === 'bunx' || proc.name === 'bunx') {
57
+ return true;
58
+ }
59
+ const command = args.slice(1).find((arg) => !arg.startsWith('-'));
60
+ return BUN_PACKAGE_COMMANDS.has(command);
61
+ }
62
+
23
63
  function uncertaintyRoots(proc) {
24
64
  const roots = processArgs(proc).flatMap((arg) => {
25
65
  const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : arg;
@@ -182,6 +222,7 @@ export class LivenessProbe {
182
222
  const processes = await this.env.processes().catch(() => null);
183
223
  this.listed = processes !== null;
184
224
  this.processes = processes ?? [];
225
+ this.bunCacheUsers = await this.#bunCacheUsers();
185
226
  this.probeError =
186
227
  (processes === null || this.openPaths === null) &&
187
228
  (this.env.platform === 'linux' || this.env.platform === 'darwin');
@@ -191,6 +232,49 @@ export class LivenessProbe {
191
232
  this.pathOwners.clear();
192
233
  }
193
234
 
235
+ /**
236
+ * Pids of Bun processes that may use the install cache: package commands,
237
+ * and anything else (scripts, tests, the REPL) without a `node_modules`
238
+ * directory above its working directory, because Bun then auto-installs
239
+ * from the cache. A long-running script of an installed project does not
240
+ * read the cache, so it does not keep the cache busy.
241
+ */
242
+ async #bunCacheUsers() {
243
+ const users = new Set();
244
+ const installed = new Map();
245
+ const hasModules = async (dir) => {
246
+ if (!installed.has(dir)) {
247
+ installed.set(
248
+ dir,
249
+ Boolean(
250
+ await this.env.exists?.(this.env.path.join(dir, 'node_modules'))
251
+ )
252
+ );
253
+ }
254
+ return installed.get(dir);
255
+ };
256
+ for (const proc of this.processes) {
257
+ if (!isBun(proc)) {
258
+ continue;
259
+ }
260
+ if (bunPackageCommand(proc) || !proc.cwd || !this.env.exists) {
261
+ users.add(proc.pid);
262
+ continue;
263
+ }
264
+ let found = false;
265
+ for (const dir of [proc.cwd, ...ancestors(proc.cwd, this.env.path)]) {
266
+ if (await hasModules(dir)) {
267
+ found = true;
268
+ break;
269
+ }
270
+ }
271
+ if (!found) {
272
+ users.add(proc.pid);
273
+ }
274
+ }
275
+ return users;
276
+ }
277
+
194
278
  /** Processes of the last refresh, or null when they could not be listed. */
195
279
  processList() {
196
280
  return this.listed ? this.processes : null;
@@ -269,6 +353,13 @@ export class LivenessProbe {
269
353
  ) {
270
354
  continue;
271
355
  }
356
+ if (
357
+ item.rule === 'bun-cache' &&
358
+ isBun(proc) &&
359
+ !this.bunCacheUsers?.has(proc.pid)
360
+ ) {
361
+ continue;
362
+ }
272
363
  const name = matchingName(proc, names);
273
364
  if (!name) {
274
365
  continue;
@@ -291,10 +382,7 @@ export class LivenessProbe {
291
382
  return null;
292
383
  }
293
384
  const pathApi = this.env.path;
294
- const targets = paths.flatMap((target) => {
295
- const real = this.realPaths.get(target);
296
- return real && real !== target ? [target, real] : [target];
297
- });
385
+ const targets = this.withRealPaths(paths);
298
386
  for (const open of this.openPaths) {
299
387
  for (const target of targets) {
300
388
  if (isWithin(open, target, pathApi)) {
@@ -340,6 +428,26 @@ export class LivenessProbe {
340
428
  return null;
341
429
  }
342
430
 
431
+ /** `paths` plus their resolved forms, as processes report paths. */
432
+ withRealPaths(paths) {
433
+ return paths.flatMap((target) => {
434
+ const real = this.realPaths.get(target);
435
+ return real && real !== target ? [target, real] : [target];
436
+ });
437
+ }
438
+
439
+ searchPathUsage(paths) {
440
+ const targets = this.withRealPaths(paths);
441
+ for (const proc of this.processes) {
442
+ for (const dir of proc.searchPath ?? []) {
443
+ if (targets.some((target) => isWithin(dir, target, this.env.path))) {
444
+ return `on the PATH of ${proc.name || 'a process'} (pid ${proc.pid}): ${dir}`;
445
+ }
446
+ }
447
+ }
448
+ return null;
449
+ }
450
+
343
451
  uncertainUsage(paths) {
344
452
  for (const proc of this.env.unreadableProcesses ?? []) {
345
453
  const args = processArgs(proc);
@@ -399,14 +507,28 @@ export class LivenessProbe {
399
507
  const paths = checks.perPath ? [] : (item.paths ?? []);
400
508
  return (
401
509
  this.installationBlocker(item.paths ?? []) ??
402
- (this.probeError
403
- ? `cannot verify process and open-file activity${this.env.probeDiagnostic ? `: ${this.env.probeDiagnostic}` : ''}`
404
- : null) ??
510
+ this.probeBlocker() ??
405
511
  (checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
406
512
  this.runningTool(checks.busy, checks.cwd, item) ??
513
+ this.processUsage(paths) ??
514
+ this.uncertainUsage(item.paths ?? [])
515
+ );
516
+ }
517
+
518
+ probeBlocker() {
519
+ if (!this.probeError) {
520
+ return null;
521
+ }
522
+ const diagnostic = this.env.probeDiagnostic;
523
+ return `cannot verify process and open-file activity${diagnostic ? `: ${diagnostic}` : ''}`;
524
+ }
525
+
526
+ /** Open files, command arguments and PATH entries inside the paths. */
527
+ processUsage(paths) {
528
+ return (
407
529
  this.openInside(paths) ??
408
530
  this.commandUsage(paths) ??
409
- this.uncertainUsage(item.paths ?? [])
531
+ this.searchPathUsage(paths)
410
532
  );
411
533
  }
412
534
 
package/src/options.js CHANGED
@@ -16,7 +16,7 @@ export const DEFAULTS = {
16
16
  docker: null,
17
17
  dockerDepth: 3,
18
18
  containers: [],
19
- scanners: ['projects', 'global', 'versions', 'agents', 'system'],
19
+ scanners: ['global', 'projects', 'versions', 'agents', 'system'],
20
20
  only: [],
21
21
  exclude: [],
22
22
  includeVolumes: false,
@@ -33,6 +33,8 @@ export const DEFAULTS = {
33
33
  backupDir: null,
34
34
  auditDir: null,
35
35
  scanBudget: '2m',
36
+ cacheDir: false,
37
+ platformIndex: true,
36
38
  };
37
39
 
38
40
  function toBytes(value) {
package/src/report.js CHANGED
@@ -56,7 +56,7 @@ function itemLine(item, showEnv) {
56
56
  : item.imageBytes
57
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
58
  : '';
59
- return ` ${pad(formatBytes(item.bytes), 10)} ${env}${item.rule} ${itemLocation(item)}${confirm}${storage}`;
59
+ return ` ${pad(formatBytes(item.bytes), 10)}${item.sizeEstimated ? ' (estimated)' : ''} ${env}${item.rule} ${itemLocation(item)}${confirm}${storage}`;
60
60
  }
61
61
 
62
62
  function limited(lines, limit) {
@@ -305,6 +305,52 @@ function errorsSection(errors) {
305
305
  ];
306
306
  }
307
307
 
308
+ const TIMING_LIMIT = 5;
309
+
310
+ function phaseList(phases) {
311
+ return phases
312
+ .map(
313
+ (phase) =>
314
+ `${phase.phase} ${(phase.ms / 1000).toFixed(1)}s${phase.count > 1 ? ` ×${phase.count}` : ''}`
315
+ )
316
+ .join(', ');
317
+ }
318
+
319
+ /**
320
+ * Slowest cleanup phases and items. `audit.timings` is a live PhaseTimer
321
+ * while cleaning and a plain array once read back from JSON.
322
+ */
323
+ function auditTimingLines(audit, { verbose }) {
324
+ const phases = audit.timings?.toJSON?.() ?? audit.timings ?? [];
325
+ const items = audit.entries
326
+ .filter((entry) => entry.durationMs > 0)
327
+ .sort((a, b) => b.durationMs - a.durationMs);
328
+ const limit = verbose ? Infinity : TIMING_LIMIT;
329
+ const lines = [];
330
+ if (phases.length > 0) {
331
+ lines.push(` slowest phases: ${phaseList(phases.slice(0, limit))}`);
332
+ }
333
+ for (const entry of items.slice(0, limit)) {
334
+ lines.push(
335
+ ` ${(entry.durationMs / 1000).toFixed(1)}s ${entry.rule} ${entry.path ?? entry.description}`
336
+ );
337
+ }
338
+ return lines.length > 0 ? ['', 'TIMINGS', ...lines] : [];
339
+ }
340
+
341
+ /**
342
+ * The slowest phases of each environment, so bottlenecks are visible
343
+ * without debug flags.
344
+ */
345
+ function timingLines(report, { verbose }) {
346
+ return report.environments
347
+ .filter((env) => env.timings?.length > 0)
348
+ .map((env) => {
349
+ const phases = verbose ? env.timings : env.timings.slice(0, TIMING_LIMIT);
350
+ return ` ${env.label} slowest phases: ${phaseList(phases)}`;
351
+ });
352
+ }
353
+
308
354
  /**
309
355
  * Renders a scan report.
310
356
  * @param {object} report
@@ -318,6 +364,18 @@ export function formatReport(report, options = {}) {
318
364
  `disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
319
365
  formatDisk(host.disk),
320
366
  ];
367
+ if (host.swap) {
368
+ lines.push(
369
+ `Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
370
+ );
371
+ lines.push(host.swap.attribution);
372
+ for (const holder of host.swap.holders) {
373
+ lines.push(
374
+ ` ${holder.pid} ${holder.command}: ${formatBytes(holder.memoryBytes)} memory, ${formatBytes(holder.compressedBytes)} compressed`
375
+ );
376
+ }
377
+ lines.push(host.swap.advice);
378
+ }
321
379
  for (const tier of TIERS) {
322
380
  lines.push(...tierSection(report, tier, { limit }));
323
381
  }
@@ -325,7 +383,11 @@ export function formatReport(report, options = {}) {
325
383
  if (env.layerSource) {
326
384
  lines.push(` ${env.label}: layer source ${env.layerSource}`);
327
385
  }
328
- if (env.scanStatus && env.scanStatus !== 'complete') {
386
+ if (env.scanStatus === 'goal-reached') {
387
+ lines.push(
388
+ ` ${env.label}: scan stopped after the free-space goal was met`
389
+ );
390
+ } else if (env.scanStatus && env.scanStatus !== 'complete') {
329
391
  lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
330
392
  }
331
393
  if (env.hint) {
@@ -336,6 +398,10 @@ export function formatReport(report, options = {}) {
336
398
  lines.push(...dockerSection(report));
337
399
  lines.push(...errorsSection(report.errors));
338
400
  lines.push(...totalsSection(report));
401
+ const timings = timingLines(report, options);
402
+ if (timings.length > 0) {
403
+ lines.push('', 'TIMINGS', ...timings);
404
+ }
339
405
  return lines.join('\n');
340
406
  }
341
407
 
@@ -429,6 +495,7 @@ export function formatAudit(audit, options = {}) {
429
495
  }
430
496
  lines.push('', 'Per environment:', ...environmentLines(audit.environments));
431
497
  lines.push(...healthLines(audit.health));
498
+ lines.push(...auditTimingLines(audit, options));
432
499
  lines.push(
433
500
  '',
434
501
  audit.dryRun
@@ -8,9 +8,8 @@
8
8
  *
9
9
  * `projects` are directories inside project trees, recognised by name plus a
10
10
  * marker file next to them (`markers`), inside them (`selfMarkers`), or next
11
- * to their parent (`parentMarkers`, with `parentName`). Kind `cache` is always
12
- * `safe`; `build` and `dependencies` are `moderate` after the project has been
13
- * inactive for the configured number of days and `aggressive` otherwise.
11
+ * to their parent (`parentMarkers`, with `parentName`). All kinds (`cache`,
12
+ * `build`, `dependencies`) are `safe` whenever nothing is using them.
14
13
  *
15
14
  * `busy` lists process names that, when running (inside the project for
16
15
  * project directories), mean the tool may be using the directory right now.
@@ -71,7 +70,9 @@ export const ECOSYSTEMS = [
71
70
  description: 'Bun install cache',
72
71
  paths: ['~/.bun/install/cache'],
73
72
  native: { tool: 'bun', argv: ['bun', 'pm', 'cache', 'rm'] },
74
- busy: ['bun'],
73
+ // Only package commands and auto-installing scripts read the cache
74
+ // (see LivenessProbe.refresh).
75
+ busy: ['bun', 'bunx'],
75
76
  },
76
77
  {
77
78
  id: 'deno-cache',
@@ -349,8 +349,11 @@ export const OTHER_RULES = [
349
349
  ecosystem: 'agents',
350
350
  description: 'abandoned Codex runtime install staging directories',
351
351
  paths: ['~/.cache/codex-runtimes/codex-runtime-install-*'],
352
+ // The Codex daemon runs for days, so a running `codex` says nothing
353
+ // about a staging directory: an install in progress writes to it and
354
+ // holds its files open, which the activity window and open-file checks
355
+ // catch.
352
356
  minAge: 'stale',
353
- busy: ['codex'],
354
357
  },
355
358
  {
356
359
  id: 'claude-cli-mcp-logs',
@@ -6,6 +6,11 @@
6
6
  * - the newest installed version,
7
7
  * - in use by a running process (checked by the liveness layer).
8
8
  *
9
+ * `refsPickNewest` makes a partial ref keep only the newest install it
10
+ * matches (nvm runs the newest 20.x for `20`); otherwise it keeps them all.
11
+ * `globalPackages` blocks versions holding packages the user installed
12
+ * globally, which reinstalling the version would not bring back.
13
+ *
9
14
  * `versionOf(dirPath)` maps an install directory to the version string that
10
15
  * defaults and pin files are compared with (see `versionMatches`).
11
16
  */
@@ -112,6 +117,16 @@ export const VERSION_RULES = [
112
117
  busy: ['claude'],
113
118
  recheck: true,
114
119
  },
120
+ {
121
+ id: 'codex-daemon-releases',
122
+ ecosystem: 'agents',
123
+ manager: 'Codex',
124
+ description: 'Codex app-server daemon release',
125
+ dirs: ['~/.codex/packages/app-server-daemon/releases/*'],
126
+ defaultLinks: ['~/.codex/packages/app-server-daemon/current'],
127
+ busy: ['codex'],
128
+ recheck: true,
129
+ },
115
130
  {
116
131
  id: 'copilot-cli-versions',
117
132
  ecosystem: 'agents',
@@ -133,6 +148,14 @@ export const VERSION_RULES = [
133
148
  aliasDir: '~/.nvm/alias',
134
149
  defaults: [{ path: '~/.nvm/alias/default', parse: firstLines }],
135
150
  projectFiles: { '.nvmrc': firstLines, '.node-version': firstLines },
151
+ refsPickNewest: true,
152
+ globalPackages: {
153
+ dir: 'lib/node_modules',
154
+ // Bundled with Node.js, or a use-m cache (`<name>-v-<version>`) that
155
+ // use-m installs again on first use.
156
+ regenerable: (name) =>
157
+ name === 'npm' || name === 'corepack' || /^[^@][^/]*-v-./.test(name),
158
+ },
136
159
  busy: ['node'],
137
160
  },
138
161
  {