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/src/env/local.js CHANGED
@@ -89,7 +89,7 @@ export function parsePsSearchPaths(output) {
89
89
  * host and inside containers (see shell.js).
90
90
  */
91
91
 
92
- import { promises as fsp } from 'node:fs';
92
+ import { constants as fsConstants, promises as fsp } from 'node:fs';
93
93
  import os from 'node:os';
94
94
  import path from 'node:path';
95
95
  import { URL, fileURLToPath } from 'node:url';
@@ -97,6 +97,7 @@ import { procIdentity, procOpenPaths } from './proc.js';
97
97
  import { CACHE_VARIABLES } from './cache-roots.js';
98
98
 
99
99
  import { hostExecutor } from '../exec.js';
100
+ import { parseBundleIdentifier } from '../app-owners.js';
100
101
  import { matchesGlob } from '../paths.js';
101
102
  import { mapLimit } from './parallel.js';
102
103
  import { directoryFingerprint, SIZE_MAX_AGE } from '../scan-state.js';
@@ -251,6 +252,36 @@ export class LocalEnv {
251
252
  );
252
253
  }
253
254
 
255
+ /**
256
+ * Access errors as report messages. Paths inside a directory this user
257
+ * cannot enter collapse into one line: nothing inside such a directory
258
+ * can be deleted by this user, so no candidate hides there.
259
+ * @returns {Promise<string[]>}
260
+ */
261
+ async accessErrorMessages() {
262
+ const messages = [];
263
+ const foreign = new Set();
264
+ for (const [target, message] of this.accessErrors ?? []) {
265
+ const parent = path.dirname(target);
266
+ const enterable = await fsp
267
+ .access(parent, fsConstants.X_OK)
268
+ .then(() => true)
269
+ .catch(() => false);
270
+ if (enterable) {
271
+ messages.push(message);
272
+ } else {
273
+ foreign.add(parent);
274
+ }
275
+ }
276
+ if (foreign.size > 0) {
277
+ const [example] = [...foreign].sort();
278
+ messages.push(
279
+ `skipped ${foreign.size} ${foreign.size === 1 ? 'directory' : 'directories'} this user cannot enter, such as ${example}; nothing inside can be deleted without their owner's rights`
280
+ );
281
+ }
282
+ return messages;
283
+ }
284
+
254
285
  async #lstat(target) {
255
286
  try {
256
287
  return await (this.options.fileFs ?? fsp).lstat(target);
@@ -830,6 +861,8 @@ export class LocalEnv {
830
861
  .map((m) => ({
831
862
  pid: Number(m[1]),
832
863
  name: path.basename(m[2].trim()),
864
+ // macOS `comm` is the executable's full path.
865
+ executable: m[2].trim(),
833
866
  cwd: cwds.get(Number(m[1])) ?? null,
834
867
  command: commands.get(Number(m[1])) ?? '',
835
868
  searchPath: searchPaths.get(Number(m[1])) ?? [],
@@ -978,12 +1011,22 @@ export class LocalEnv {
978
1011
  if (result.code !== 0 && !result.stdout) {
979
1012
  return null;
980
1013
  }
981
- return new Set(
982
- result.stdout
983
- .split('\n')
984
- .filter((line) => line.startsWith('n/'))
985
- .map((line) => line.slice(1))
986
- );
1014
+ // `-F` always prints the pid (`p`) before the files of a process.
1015
+ const paths = new Set();
1016
+ const owners = new Map();
1017
+ let pid = null;
1018
+ for (const line of result.stdout.split('\n')) {
1019
+ if (line.startsWith('p')) {
1020
+ pid = Number(line.slice(1));
1021
+ } else if (line.startsWith('n/')) {
1022
+ paths.add(line.slice(1));
1023
+ if (!owners.has(line.slice(1))) {
1024
+ owners.set(line.slice(1), pid);
1025
+ }
1026
+ }
1027
+ }
1028
+ this.openPathPids = owners;
1029
+ return paths;
987
1030
  }
988
1031
  return null;
989
1032
  }
@@ -992,6 +1035,26 @@ export class LocalEnv {
992
1035
  return this.executor.run(argv, options);
993
1036
  }
994
1037
 
1038
+ /** `CFBundleIdentifier` of a macOS bundle, or null. */
1039
+ async bundleIdentifier(bundle) {
1040
+ if (this.platform !== 'darwin') {
1041
+ return null;
1042
+ }
1043
+ const plist = path.join(bundle, 'Contents', 'Info.plist');
1044
+ const text = await fsp.readFile(plist, 'utf8').catch(() => null);
1045
+ if (text === null) {
1046
+ return null;
1047
+ }
1048
+ if (!text.startsWith('bplist')) {
1049
+ return parseBundleIdentifier(text);
1050
+ }
1051
+ const result = await this.run(
1052
+ ['plutil', '-extract', 'CFBundleIdentifier', 'raw', '-o', '-', plist],
1053
+ { timeoutMs: 10000 }
1054
+ ).catch(() => null);
1055
+ return result?.code === 0 ? result.stdout.trim() || null : null;
1056
+ }
1057
+
995
1058
  async which(command) {
996
1059
  const dirs = (process.env.PATH ?? '').split(path.delimiter);
997
1060
  const extensions =
package/src/index.d.ts CHANGED
@@ -180,6 +180,8 @@ export interface EnvironmentDescriptor {
180
180
  swappedBytes?: number;
181
181
  /** Change since the earliest scan of the last week listing this process. */
182
182
  growth?: { since: number; memoryBytes: number };
183
+ /** The dss process itself, never named as one to quit. */
184
+ self?: boolean;
183
185
  }>;
184
186
  /** Change since the earliest scan of the last week. */
185
187
  growth?: { since: number; usedBytes: number } | null;
@@ -219,6 +221,16 @@ export interface Report {
219
221
  options: Record<string, unknown>;
220
222
  environments: EnvironmentDescriptor[];
221
223
  items: Item[];
224
+ /**
225
+ * Host items that share every block with an installed app (APFS clones):
226
+ * never offered, listed so the space `du` counts for them is explained.
227
+ */
228
+ shared?: Array<{
229
+ rule: string;
230
+ path: string;
231
+ apparentBytes: number;
232
+ sharedWith: string;
233
+ }>;
222
234
  /** Tier totals exclude items that need explicit consent (`consent`). */
223
235
  totals: Record<Tier | 'blocked' | 'consent', TierTotal>;
224
236
  docker: {
@@ -278,6 +290,8 @@ export interface AuditLog {
278
290
  plannedBytes: number;
279
291
  environments: Record<string, EnvironmentTotal>;
280
292
  file?: string;
293
+ /** Set while the disk was too full to write the log; deletion went on. */
294
+ auditError?: { message: string; code: string };
281
295
  goal?: { freeBytes: number | null; untilPercent: number | null };
282
296
  goalMet?: boolean;
283
297
  reachedTier?: Tier;
@@ -300,6 +314,50 @@ export declare function clean(
300
314
  /** Escalates tiers until the free-space goal is met, then stops. */
301
315
  export declare function emergency(options: EmergencyOptions): Promise<AuditLog>;
302
316
 
317
+ export interface UsageOptions {
318
+ /** Directories to walk; default the macOS Data volume or `/`. */
319
+ roots?: string[] | null;
320
+ /** Smallest directory in the tree, in bytes; default 1% of a root. */
321
+ minSize?: number;
322
+ verbose?: boolean;
323
+ signal?: AbortSignal;
324
+ auditDir?: string;
325
+ onUsageProgress?: (progress: {
326
+ root: string;
327
+ elapsedMs: number;
328
+ entries: number;
329
+ at: string | null;
330
+ }) => void;
331
+ }
332
+
333
+ export interface UsageReport {
334
+ schema: 1;
335
+ createdAt: string;
336
+ durationMs: number;
337
+ platform: string;
338
+ scan: { file: string; createdAt: string | null } | null;
339
+ roots: Array<{
340
+ root: string;
341
+ path: string;
342
+ bytes: number;
343
+ entries: number;
344
+ tree: object;
345
+ files: Array<{ path: string; bytes: number }>;
346
+ [key: string]: unknown;
347
+ }>;
348
+ volumes: Array<{
349
+ name: string;
350
+ mount: string | null;
351
+ bytes: number;
352
+ role: string;
353
+ }>;
354
+ snapshots: number | null;
355
+ }
356
+
357
+ /** Shows where the space of whole volumes goes. Never deletes. */
358
+ export declare function usage(options?: UsageOptions): Promise<UsageReport>;
359
+ export declare function formatUsage(report: UsageReport): string;
360
+
303
361
  export declare function formatReport(
304
362
  report: Report,
305
363
  options?: { verbose?: boolean }
package/src/index.js CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  writeAudit,
24
24
  } from './audit.js';
25
25
  export { formatReport, formatAudit, formatDisk } from './report.js';
26
+ export { usage, formatUsage } from './usage.js';
26
27
  export { runCli, parseCli, USAGE } from './cli.js';
27
28
  export { DEFAULTS, resolveOptions, resolveGoal } from './options.js';
28
29
  export {
package/src/items.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * named flag (or an interactive yes) on top of their tier being selected.
8
8
  */
9
9
 
10
+ import { blockedConditions } from './conditions.js';
11
+
10
12
  export const TIERS = ['safe', 'moderate', 'aggressive'];
11
13
 
12
14
  /**
@@ -111,6 +113,7 @@ export function tierTotals(items) {
111
113
  unknownBytes: blocked
112
114
  .filter((item) => item.sizeUnknown)
113
115
  .reduce((sum, item) => sum + (item.totalBytes ?? item.bytes), 0),
116
+ ...blockedConditions(blocked),
114
117
  };
115
118
  return totals;
116
119
  }
package/src/liveness.js CHANGED
@@ -10,17 +10,51 @@
10
10
  * - a command argument names a path inside the item (including scripts
11
11
  * whose interpreter has already closed its input descriptor);
12
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.
13
+ * may start a program from it at any time;
14
+ * - the path lies in the scratchpad of a Claude Code session that was active
15
+ * inside the inactive window, so the session may still rely on it.
14
16
  *
15
17
  * The cleaner refreshes the probe right before acting on each item.
16
18
  */
17
19
 
18
20
  import path from 'node:path';
19
21
 
22
+ import {
23
+ bundleRoots,
24
+ bundlesOwn,
25
+ ownerBundleId,
26
+ processBundles,
27
+ } from './app-owners.js';
20
28
  import { isWithin } from './paths.js';
21
29
  import { formatDuration } from './units.js';
22
30
 
23
31
  const PROC_COMM_LIMIT = 15;
32
+ const DEFAULT_INACTIVE_MS = 30 * 24 * 60 * 60 * 1000;
33
+ const SESSION_ID =
34
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
35
+
36
+ /**
37
+ * The Claude Code session directory a path lies in: Claude Code keeps each
38
+ * session's scratchpad under `<tmp>/claude-<uid>/<project>/<session id>`.
39
+ * @returns {{dir: string, project: string, id: string}|null}
40
+ */
41
+ export function agentSessionOf(target, pathApi = path) {
42
+ const parts = target.split(/[\\/]/);
43
+ const index = parts.findIndex(
44
+ (part, at) =>
45
+ /^claude-\d+$/.test(part) &&
46
+ Boolean(parts[at + 1]) &&
47
+ SESSION_ID.test(parts[at + 2] ?? '')
48
+ );
49
+ if (index < 0) {
50
+ return null;
51
+ }
52
+ return {
53
+ dir: parts.slice(0, index + 3).join(pathApi.sep),
54
+ project: parts[index + 1],
55
+ id: parts[index + 2],
56
+ };
57
+ }
24
58
 
25
59
  /** Bun subcommands that read or write the install cache. */
26
60
  const BUN_PACKAGE_COMMANDS = new Set([
@@ -195,14 +229,30 @@ export function matchingName(proc, names) {
195
229
  return null;
196
230
  }
197
231
 
232
+ /** `matched`, prefixed with the app it runs from: `Warp (stable)`. */
233
+ function appLabel(proc, matched) {
234
+ const app = proc.bundles?.[0]?.name;
235
+ return app && app !== matched ? `${app} (${matched})` : matched;
236
+ }
237
+
198
238
  export class LivenessProbe {
199
239
  /**
200
240
  * @param {object} env environment adapter
201
- * @param {{staleAgeMs: number, now?: () => number, refreshMs?: number}} options
241
+ * @param {{staleAgeMs: number, inactiveMs?: number, now?: () => number,
242
+ * refreshMs?: number}} options
202
243
  */
203
- constructor(env, { staleAgeMs, now = () => Date.now(), refreshMs = 2000 }) {
244
+ constructor(
245
+ env,
246
+ {
247
+ staleAgeMs,
248
+ inactiveMs = DEFAULT_INACTIVE_MS,
249
+ now = () => Date.now(),
250
+ refreshMs = 2000,
251
+ }
252
+ ) {
204
253
  this.env = env;
205
254
  this.staleAgeMs = staleAgeMs;
255
+ this.inactiveMs = inactiveMs;
206
256
  this.now = now;
207
257
  this.refreshMs = refreshMs;
208
258
  this.refreshedAt = -Infinity;
@@ -213,6 +263,9 @@ export class LivenessProbe {
213
263
  this.pathOwners = new Map();
214
264
  this.pathStats = new Map();
215
265
  this.inodes = new Map();
266
+ this.agentSessions = new Map();
267
+ // Bundle identifiers never change, so they outlive refreshes.
268
+ this.bundleIds = new Map();
216
269
  }
217
270
 
218
271
  async refresh(force = false) {
@@ -224,6 +277,7 @@ export class LivenessProbe {
224
277
  this.listed = processes !== null;
225
278
  this.processes = processes ?? [];
226
279
  this.bunCacheUsers = await this.#bunCacheUsers();
280
+ await this.#resolveBundles();
227
281
  this.probeError =
228
282
  (processes === null || this.openPaths === null) &&
229
283
  (this.env.platform === 'linux' || this.env.platform === 'darwin');
@@ -232,6 +286,53 @@ export class LivenessProbe {
232
286
  this.pathStats.clear();
233
287
  this.pathOwners.clear();
234
288
  this.inodes.clear();
289
+ this.agentSessions.clear();
290
+ }
291
+
292
+ /** Reads the identifiers of the app bundles running processes come from. */
293
+ async #resolveBundles() {
294
+ if (!this.env.bundleIdentifier) {
295
+ return;
296
+ }
297
+ for (const proc of this.processes) {
298
+ const roots = bundleRoots(proc.executable);
299
+ for (const root of roots) {
300
+ if (!this.bundleIds.has(root)) {
301
+ this.bundleIds.set(
302
+ root,
303
+ await this.env.bundleIdentifier(root).catch(() => null)
304
+ );
305
+ }
306
+ }
307
+ proc.bundles = processBundles(roots, this.bundleIds);
308
+ }
309
+ }
310
+
311
+ /** ` (pid N, name)` of the process holding `open`, when the probe knows it. */
312
+ #holder(open) {
313
+ const pid = this.env.openPathPids?.get(open);
314
+ if (!pid) {
315
+ return '';
316
+ }
317
+ const proc = this.processes.find((entry) => entry.pid === pid);
318
+ return proc
319
+ ? ` (pid ${pid}, ${appLabel(proc, proc.name)})`
320
+ : ` (pid ${pid})`;
321
+ }
322
+
323
+ /** A running app that owns a per-app Library folder of `paths`. */
324
+ appUsage(paths) {
325
+ const owners = [...new Set(paths.map(ownerBundleId).filter(Boolean))];
326
+ if (owners.length === 0) {
327
+ return null;
328
+ }
329
+ for (const proc of this.processes) {
330
+ const owner = owners.find((id) => bundlesOwn(proc.bundles ?? [], id));
331
+ if (owner) {
332
+ return `${appLabel(proc, proc.name)} is running (pid ${proc.pid}) and owns ${owner}`;
333
+ }
334
+ }
335
+ return null;
235
336
  }
236
337
 
237
338
  /**
@@ -332,6 +433,7 @@ export class LivenessProbe {
332
433
  * @param {string[]} paths
333
434
  */
334
435
  async resolve(paths) {
436
+ await this.#resolveAgentSessions(paths);
335
437
  if (this.env.unreadableProcesses?.length) {
336
438
  const missing = [
337
439
  ...new Set(
@@ -375,6 +477,72 @@ export class LivenessProbe {
375
477
  }
376
478
  }
377
479
 
480
+ /**
481
+ * When each Claude Code session holding one of the paths was last active:
482
+ * its transcript, its session directory and the entries two levels below.
483
+ */
484
+ async #resolveAgentSessions(paths) {
485
+ const pending = new Map();
486
+ for (const target of paths) {
487
+ const session = agentSessionOf(target, this.env.path);
488
+ if (session && !this.agentSessions.has(session.dir)) {
489
+ pending.set(session.dir, session);
490
+ }
491
+ }
492
+ if (pending.size === 0) {
493
+ return;
494
+ }
495
+ this.homes ??= (await this.env.homeDirs?.().catch(() => null)) ?? [];
496
+ for (const session of pending.values()) {
497
+ const children = await this.env.list(session.dir);
498
+ const grandchildren = await Promise.all(
499
+ children
500
+ .filter((entry) => entry.type === 'dir')
501
+ .map((entry) => this.env.list(entry.path))
502
+ );
503
+ const candidates = [
504
+ session.dir,
505
+ ...children.map((entry) => entry.path),
506
+ ...grandchildren.flat().map((entry) => entry.path),
507
+ ...this.homes.map((home) =>
508
+ this.env.path.join(
509
+ home,
510
+ '.claude',
511
+ 'projects',
512
+ session.project,
513
+ `${session.id}.jsonl`
514
+ )
515
+ ),
516
+ ];
517
+ const stats = await Promise.all(
518
+ candidates.map((target) => this.env.stat?.(target))
519
+ );
520
+ const activeMs = Math.max(0, ...stats.map((stat) => stat?.mtimeMs ?? 0));
521
+ this.agentSessions.set(session.dir, { ...session, activeMs });
522
+ }
523
+ }
524
+
525
+ /** A path inside the scratchpad of a Claude Code session active recently. */
526
+ agentSessionUsage(paths) {
527
+ for (const target of paths) {
528
+ const session = this.agentSessions.get(
529
+ agentSessionOf(target, this.env.path)?.dir
530
+ );
531
+ if (!session) {
532
+ continue;
533
+ }
534
+ const age = this.now() - session.activeMs;
535
+ if (session.activeMs && age >= this.inactiveMs) {
536
+ continue;
537
+ }
538
+ const active = session.activeMs
539
+ ? `active ${formatDuration(Math.max(age, 0))} ago`
540
+ : 'activity unknown';
541
+ return `inside the scratchpad of Claude Code session ${session.id.slice(0, 8)} (${active}); kept until it is idle for ${formatDuration(this.inactiveMs)}`;
542
+ }
543
+ return null;
544
+ }
545
+
378
546
  recentWrite(newestMtimeMs) {
379
547
  if (!newestMtimeMs || this.staleAgeMs <= 0) {
380
548
  return null;
@@ -405,10 +573,11 @@ export class LivenessProbe {
405
573
  ) {
406
574
  continue;
407
575
  }
408
- const name = matchingName(proc, names);
409
- if (!name) {
576
+ const matched = matchingName(proc, names);
577
+ if (!matched) {
410
578
  continue;
411
579
  }
580
+ const name = appLabel(proc, matched);
412
581
  if (!cwd) {
413
582
  return `${name} is running (pid ${proc.pid})`;
414
583
  }
@@ -431,7 +600,7 @@ export class LivenessProbe {
431
600
  for (const open of this.openPaths) {
432
601
  for (const target of targets) {
433
602
  if (isWithin(open, target, pathApi)) {
434
- return `in use: ${open}`;
603
+ return `in use: ${open}${this.#holder(open)}`;
435
604
  }
436
605
  }
437
606
  }
@@ -466,7 +635,7 @@ export class LivenessProbe {
466
635
  candidate &&
467
636
  paths.some((target) => isWithin(candidate, target, this.env.path))
468
637
  ) {
469
- return `in use: ${candidate} (pid ${proc.pid})`;
638
+ return `in use: ${candidate} (pid ${proc.pid}, ${appLabel(proc, proc.name)})`;
470
639
  }
471
640
  }
472
641
  }
@@ -549,15 +718,17 @@ export class LivenessProbe {
549
718
  */
550
719
  busyReason(item) {
551
720
  const checks = item.checks ?? {};
552
- const paths = checks.perPath ? [] : (item.paths ?? []);
721
+ const all = item.paths ?? [];
553
722
  return (
554
- this.installationBlocker(item.paths ?? []) ??
723
+ this.installationBlocker(all) ??
555
724
  this.probeBlocker() ??
556
725
  (checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
726
+ this.agentSessionUsage(all) ??
557
727
  this.runningTool(checks.busy, checks.cwd, item) ??
558
- this.processUsage(paths) ??
728
+ this.appUsage(all) ??
729
+ this.processUsage(checks.perPath ? [] : all) ??
559
730
  this.executableUsage(checks.executables) ??
560
- this.uncertainUsage(item.paths ?? [])
731
+ this.uncertainUsage(all)
561
732
  );
562
733
  }
563
734