disk-space-saviour 0.22.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 +16 -0
- package/README.md +22 -5
- package/package.json +1 -1
- package/src/audit.js +32 -1
- package/src/clean.js +13 -5
- package/src/conditions.js +7 -0
- package/src/emergency.js +4 -4
- package/src/index.d.ts +4 -0
- package/src/liveness.js +116 -8
- package/src/report.js +5 -2
- package/src/rules/downloads.js +27 -0
- package/src/rules/other.js +9 -0
- package/src/scan.js +1 -0
- package/src/scanners/node-modules.js +89 -0
- package/src/scanners/projects.js +9 -0
- package/src/swap.js +9 -2
- package/src/usage.js +86 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
3
19
|
## 0.22.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -94,12 +94,19 @@ Run `dss --help` for every option. The most useful ones:
|
|
|
94
94
|
files, and for each directory what the latest dss scan or clean found
|
|
95
95
|
inside it (`[dss: 1.2 GiB freeable, 3.4 GiB blocked]`). On macOS it adds
|
|
96
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.
|
|
98
|
-
|
|
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.
|
|
99
101
|
- APFS clones that share every block with an installed app, such as Chrome's
|
|
100
102
|
code-sign clones, free nothing when deleted, so they are never offered. The
|
|
101
103
|
scan lists them under `SHARED WITH INSTALLED APPS` and `dss usage` marks
|
|
102
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.
|
|
103
110
|
- Git worktree folders (`.worktrees`, `worktrees`) restart the project search
|
|
104
111
|
depth, so dependencies of nested worktrees are found below `--max-depth`.
|
|
105
112
|
- `clean` automatically reuses a matching scan from the last 30 minutes.
|
|
@@ -139,7 +146,9 @@ their blocks with the installed app), Chromium on-device AI models
|
|
|
139
146
|
Electron and electron-builder archives, the Bun transpiler cache, Docker
|
|
140
147
|
Desktop and Warp staged updates (kept while the app runs), Zed language
|
|
141
148
|
servers, tree-sitter toolchains, Codex plugin staging, the Clang module cache
|
|
142
|
-
|
|
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,
|
|
143
152
|
Chromium code/GPU/Dawn/service-worker caches and marker-validated Electron
|
|
144
153
|
caches are included. Old Playwright browser revisions are kept while a
|
|
145
154
|
Playwright package recorded in the registry's `.links` still launches them or
|
|
@@ -148,6 +157,10 @@ directories are removed once untouched. Telegram caches are removed only
|
|
|
148
157
|
while Telegram is not running. Telegram databases, browser profiles and
|
|
149
158
|
Playwright MCP profiles are preserved. macOS access failures explain when Full Disk Access
|
|
150
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.
|
|
151
164
|
|
|
152
165
|
## Safety
|
|
153
166
|
|
|
@@ -233,7 +246,10 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
|
|
|
233
246
|
include every candidate and its blockers; blocked candidates do not
|
|
234
247
|
contribute to reclaimable tier totals.
|
|
235
248
|
If a read-only scan cannot save its audit, it warns and still prints the
|
|
236
|
-
report.
|
|
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
|
|
237
253
|
directory. Set `--audit-dir` to choose a persistent writable location.
|
|
238
254
|
|
|
239
255
|
Long-lived `npm exec`/`npx` servers allow idle npm download and node-gyp
|
|
@@ -290,7 +306,8 @@ Each scan records swap usage and its largest holders in the state directory, so
|
|
|
290
306
|
the next scan shows how much both grew during the last week. A `WARNING:` line
|
|
291
307
|
appears when swap grew by 1 GiB or more, is at least 75% full, or exceeds the free
|
|
292
308
|
disk space it can grow into, naming the processes holding the memory and their
|
|
293
|
-
growth.
|
|
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
|
|
294
311
|
resuming them (`codex resume <session-id>`, `claude --resume`) releases it. When
|
|
295
312
|
Docker Desktop consumes memory, saving work and restarting it or lowering its VM
|
|
296
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.22.
|
|
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",
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
999
|
+
recordAudit(audit, { ...options, inProgress: true })
|
|
992
1000
|
);
|
|
993
1001
|
}
|
|
994
1002
|
}
|
|
@@ -1025,7 +1033,7 @@ export async function finishAudit(audit, report, options) {
|
|
|
1025
1033
|
)
|
|
1026
1034
|
.map((env) => ({ label: env.label, status: env.scanStatus }));
|
|
1027
1035
|
if (options.audit !== false) {
|
|
1028
|
-
await
|
|
1036
|
+
await recordAudit(audit, options);
|
|
1029
1037
|
}
|
|
1030
1038
|
return audit;
|
|
1031
1039
|
}
|
package/src/conditions.js
CHANGED
|
@@ -47,6 +47,13 @@ const CONDITIONS = [
|
|
|
47
47
|
action: `they stay untouched for the ${m[1]} activity window`,
|
|
48
48
|
}),
|
|
49
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
|
+
],
|
|
50
57
|
[
|
|
51
58
|
/^scan incomplete: (.+)$/,
|
|
52
59
|
(m) => ({
|
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 {
|
|
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
|
|
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
|
|
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
|
|
198
|
+
await recordAudit(audit, { ...options, inProgress: true });
|
|
199
199
|
}
|
|
200
200
|
if (entry.status !== 'removed' && entry.status !== 'planned') {
|
|
201
201
|
continue;
|
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;
|
|
@@ -288,6 +290,8 @@ export interface AuditLog {
|
|
|
288
290
|
plannedBytes: number;
|
|
289
291
|
environments: Record<string, EnvironmentTotal>;
|
|
290
292
|
file?: string;
|
|
293
|
+
/** Set while the disk was too full to write the log; deletion went on. */
|
|
294
|
+
auditError?: { message: string; code: string };
|
|
291
295
|
goal?: { freeBytes: number | null; untilPercent: number | null };
|
|
292
296
|
goalMet?: boolean;
|
|
293
297
|
reachedTier?: Tier;
|
package/src/liveness.js
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
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
|
*/
|
|
@@ -27,6 +29,32 @@ import { isWithin } from './paths.js';
|
|
|
27
29
|
import { formatDuration } from './units.js';
|
|
28
30
|
|
|
29
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
|
+
}
|
|
30
58
|
|
|
31
59
|
/** Bun subcommands that read or write the install cache. */
|
|
32
60
|
const BUN_PACKAGE_COMMANDS = new Set([
|
|
@@ -210,11 +238,21 @@ function appLabel(proc, matched) {
|
|
|
210
238
|
export class LivenessProbe {
|
|
211
239
|
/**
|
|
212
240
|
* @param {object} env environment adapter
|
|
213
|
-
* @param {{staleAgeMs: number, now?: () => number,
|
|
241
|
+
* @param {{staleAgeMs: number, inactiveMs?: number, now?: () => number,
|
|
242
|
+
* refreshMs?: number}} options
|
|
214
243
|
*/
|
|
215
|
-
constructor(
|
|
244
|
+
constructor(
|
|
245
|
+
env,
|
|
246
|
+
{
|
|
247
|
+
staleAgeMs,
|
|
248
|
+
inactiveMs = DEFAULT_INACTIVE_MS,
|
|
249
|
+
now = () => Date.now(),
|
|
250
|
+
refreshMs = 2000,
|
|
251
|
+
}
|
|
252
|
+
) {
|
|
216
253
|
this.env = env;
|
|
217
254
|
this.staleAgeMs = staleAgeMs;
|
|
255
|
+
this.inactiveMs = inactiveMs;
|
|
218
256
|
this.now = now;
|
|
219
257
|
this.refreshMs = refreshMs;
|
|
220
258
|
this.refreshedAt = -Infinity;
|
|
@@ -225,6 +263,7 @@ export class LivenessProbe {
|
|
|
225
263
|
this.pathOwners = new Map();
|
|
226
264
|
this.pathStats = new Map();
|
|
227
265
|
this.inodes = new Map();
|
|
266
|
+
this.agentSessions = new Map();
|
|
228
267
|
// Bundle identifiers never change, so they outlive refreshes.
|
|
229
268
|
this.bundleIds = new Map();
|
|
230
269
|
}
|
|
@@ -247,6 +286,7 @@ export class LivenessProbe {
|
|
|
247
286
|
this.pathStats.clear();
|
|
248
287
|
this.pathOwners.clear();
|
|
249
288
|
this.inodes.clear();
|
|
289
|
+
this.agentSessions.clear();
|
|
250
290
|
}
|
|
251
291
|
|
|
252
292
|
/** Reads the identifiers of the app bundles running processes come from. */
|
|
@@ -393,6 +433,7 @@ export class LivenessProbe {
|
|
|
393
433
|
* @param {string[]} paths
|
|
394
434
|
*/
|
|
395
435
|
async resolve(paths) {
|
|
436
|
+
await this.#resolveAgentSessions(paths);
|
|
396
437
|
if (this.env.unreadableProcesses?.length) {
|
|
397
438
|
const missing = [
|
|
398
439
|
...new Set(
|
|
@@ -436,6 +477,72 @@ export class LivenessProbe {
|
|
|
436
477
|
}
|
|
437
478
|
}
|
|
438
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
|
+
|
|
439
546
|
recentWrite(newestMtimeMs) {
|
|
440
547
|
if (!newestMtimeMs || this.staleAgeMs <= 0) {
|
|
441
548
|
return null;
|
|
@@ -611,16 +718,17 @@ export class LivenessProbe {
|
|
|
611
718
|
*/
|
|
612
719
|
busyReason(item) {
|
|
613
720
|
const checks = item.checks ?? {};
|
|
614
|
-
const
|
|
721
|
+
const all = item.paths ?? [];
|
|
615
722
|
return (
|
|
616
|
-
this.installationBlocker(
|
|
723
|
+
this.installationBlocker(all) ??
|
|
617
724
|
this.probeBlocker() ??
|
|
618
725
|
(checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
|
|
726
|
+
this.agentSessionUsage(all) ??
|
|
619
727
|
this.runningTool(checks.busy, checks.cwd, item) ??
|
|
620
|
-
this.appUsage(
|
|
621
|
-
this.processUsage(
|
|
728
|
+
this.appUsage(all) ??
|
|
729
|
+
this.processUsage(checks.perPath ? [] : all) ??
|
|
622
730
|
this.executableUsage(checks.executables) ??
|
|
623
|
-
this.uncertainUsage(
|
|
731
|
+
this.uncertainUsage(all)
|
|
624
732
|
);
|
|
625
733
|
}
|
|
626
734
|
|
package/src/report.js
CHANGED
|
@@ -438,7 +438,8 @@ function holderLine(holder) {
|
|
|
438
438
|
`${change > 0 ? '+' : '-'}${formatBytes(Math.abs(change))} since ${new Date(holder.growth.since).toISOString()}`
|
|
439
439
|
);
|
|
440
440
|
}
|
|
441
|
-
|
|
441
|
+
const self = holder.self ? ' (this dss run)' : '';
|
|
442
|
+
return `${holder.pid} ${holder.command}${self}: ${details.join(', ')}`;
|
|
442
443
|
}
|
|
443
444
|
|
|
444
445
|
function swapLines(swap) {
|
|
@@ -617,7 +618,9 @@ export function formatAudit(audit, options = {}) {
|
|
|
617
618
|
...conditionsSection(audit.blocked, options).slice(2)
|
|
618
619
|
);
|
|
619
620
|
}
|
|
620
|
-
if (audit.
|
|
621
|
+
if (audit.auditError) {
|
|
622
|
+
lines.push(`Audit log: not written (${audit.auditError.message})`);
|
|
623
|
+
} else if (audit.file) {
|
|
621
624
|
lines.push(`Audit log: ${audit.file}`);
|
|
622
625
|
}
|
|
623
626
|
return lines.join('\n');
|
package/src/rules/downloads.js
CHANGED
|
@@ -184,4 +184,31 @@ export const DOWNLOAD_RULES = [
|
|
|
184
184
|
],
|
|
185
185
|
minAge: 'stale',
|
|
186
186
|
},
|
|
187
|
+
{
|
|
188
|
+
id: 'crashpad-pending-reports',
|
|
189
|
+
ecosystem: 'system',
|
|
190
|
+
description:
|
|
191
|
+
'Chromium and Electron app crash reports never sent: after a month no app uploads them',
|
|
192
|
+
tier: 'moderate',
|
|
193
|
+
aggregate: true,
|
|
194
|
+
paths: [
|
|
195
|
+
'~/Library/Application Support/*/Crashpad/pending/*',
|
|
196
|
+
...['Google', 'Yandex', 'BraveSoftware'].map(
|
|
197
|
+
(vendor) =>
|
|
198
|
+
`~/Library/Application Support/${vendor}/*/Crashpad/pending/*`
|
|
199
|
+
),
|
|
200
|
+
'~/.config/*/Crashpad/pending/*',
|
|
201
|
+
],
|
|
202
|
+
minAge: 'inactive',
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: 'gh-run-log-cache',
|
|
206
|
+
ecosystem: 'system',
|
|
207
|
+
description:
|
|
208
|
+
'GitHub CLI cached workflow run logs: gh downloads them again while GitHub keeps them',
|
|
209
|
+
tier: 'moderate',
|
|
210
|
+
aggregate: true,
|
|
211
|
+
paths: ['~/.cache/gh/run-log-*.zip'],
|
|
212
|
+
minAge: 'stale',
|
|
213
|
+
},
|
|
187
214
|
];
|
package/src/rules/other.js
CHANGED
|
@@ -414,6 +414,15 @@ export const OTHER_RULES = [
|
|
|
414
414
|
aggregate: true,
|
|
415
415
|
paths: ['~/Library/Logs/DiagnosticReports/*'],
|
|
416
416
|
},
|
|
417
|
+
{
|
|
418
|
+
id: 'rotated-app-logs',
|
|
419
|
+
ecosystem: 'system',
|
|
420
|
+
description:
|
|
421
|
+
'rotated app logs (warp.log.old.2, app.log.1): apps write only the current log',
|
|
422
|
+
tier: 'moderate',
|
|
423
|
+
paths: ['~/Library/Logs/*.log.*', '~/Library/Logs/*/*.log.*'],
|
|
424
|
+
minAge: 'stale',
|
|
425
|
+
},
|
|
417
426
|
{
|
|
418
427
|
id: 'apt-archives',
|
|
419
428
|
ecosystem: 'system',
|
package/src/scan.js
CHANGED
|
@@ -173,6 +173,7 @@ export async function environmentContext(env, options, overrides = {}) {
|
|
|
173
173
|
});
|
|
174
174
|
const liveness = new LivenessProbe(env, {
|
|
175
175
|
staleAgeMs: options.staleAgeMs,
|
|
176
|
+
inactiveMs: options.inactiveMs,
|
|
176
177
|
now: options.now,
|
|
177
178
|
});
|
|
178
179
|
await liveness.refresh(true);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A node_modules directory is regenerable only as far as package managers
|
|
3
|
+
* fill it. Other data kept there (verification runs, evidence, hand-made
|
|
4
|
+
* fixtures) survives no reinstall, so it keeps the whole directory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { formatBytes } from '../units.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Top-level entries package managers and the tools they run create:
|
|
11
|
+
* links, lockfile copies, store and workspace state, and caches that
|
|
12
|
+
* re-create themselves.
|
|
13
|
+
*/
|
|
14
|
+
const MANAGED_ENTRIES = new Set([
|
|
15
|
+
'.bin',
|
|
16
|
+
'.package-lock.json',
|
|
17
|
+
'.modules.yaml',
|
|
18
|
+
'.pnpm',
|
|
19
|
+
'.pnpm-workspace-state.json',
|
|
20
|
+
'.pnpm-workspace-state-v1.json',
|
|
21
|
+
'.yarn-state.yml',
|
|
22
|
+
'.yarn-integrity',
|
|
23
|
+
'.yarn-metadata.json',
|
|
24
|
+
'.store',
|
|
25
|
+
'.staging',
|
|
26
|
+
'.ignored',
|
|
27
|
+
'.cache',
|
|
28
|
+
'.vite',
|
|
29
|
+
'.vite-temp',
|
|
30
|
+
'.vitest',
|
|
31
|
+
'.vitest-cache',
|
|
32
|
+
'.deno',
|
|
33
|
+
'.prisma',
|
|
34
|
+
'.astro',
|
|
35
|
+
'.mf',
|
|
36
|
+
'.tmp',
|
|
37
|
+
'.DS_Store',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Entries of `dir` no package manager installs, with their size; empty
|
|
42
|
+
* leftovers are left out.
|
|
43
|
+
* @returns {Promise<Array<{name: string, bytes: number}>>}
|
|
44
|
+
*/
|
|
45
|
+
export async function foreignEntries(env, dir) {
|
|
46
|
+
const candidates = [];
|
|
47
|
+
for (const entry of await env.list(dir)) {
|
|
48
|
+
if (
|
|
49
|
+
MANAGED_ENTRIES.has(entry.name) ||
|
|
50
|
+
entry.name.startsWith('@') ||
|
|
51
|
+
entry.type === 'symlink'
|
|
52
|
+
) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
entry.type === 'dir' &&
|
|
57
|
+
(await env.stat(env.path.join(entry.path, 'package.json')))
|
|
58
|
+
) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
candidates.push(entry);
|
|
62
|
+
}
|
|
63
|
+
if (candidates.length === 0) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const usages = await env.usageMany(
|
|
67
|
+
candidates.map((entry) => entry.path),
|
|
68
|
+
{ cache: true }
|
|
69
|
+
);
|
|
70
|
+
return candidates
|
|
71
|
+
.map((entry) => ({ name: entry.name, usage: usages.get(entry.path) }))
|
|
72
|
+
.filter(({ usage }) => usage && (usage.files ?? 1) > 0)
|
|
73
|
+
.map(({ name, usage }) => ({ name, bytes: usage.bytes }))
|
|
74
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Blocker naming the foreign data inside `dir`, or null. */
|
|
78
|
+
export async function foreignDataBlocker(env, dir) {
|
|
79
|
+
const foreign = await foreignEntries(env, dir);
|
|
80
|
+
if (foreign.length === 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const names = foreign
|
|
84
|
+
.slice(0, 3)
|
|
85
|
+
.map((entry) => `${entry.name} (${formatBytes(entry.bytes)})`)
|
|
86
|
+
.join(', ');
|
|
87
|
+
const more = foreign.length > 3 ? ` and ${foreign.length - 3} more` : '';
|
|
88
|
+
return `holds data no package manager installs: ${names}${more}; a reinstall does not re-create it, so move it out of node_modules first`;
|
|
89
|
+
}
|
package/src/scanners/projects.js
CHANGED
|
@@ -23,6 +23,7 @@ import { block, makeItem } from '../items.js';
|
|
|
23
23
|
import { isWithin, matchesGlob } from '../paths.js';
|
|
24
24
|
import { listMany, scanTimeBusy } from './common.js';
|
|
25
25
|
import { analyzeRustProfile, findRustProfiles } from './rust.js';
|
|
26
|
+
import { foreignDataBlocker } from './node-modules.js';
|
|
26
27
|
import { timed } from '../timings.js';
|
|
27
28
|
|
|
28
29
|
const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
|
|
@@ -248,6 +249,14 @@ async function projectItem(context, { candidate, rule }, usage) {
|
|
|
248
249
|
...projectTier(rule),
|
|
249
250
|
checks: { busy: rule.busy ?? [], cwd: project, mtime: true },
|
|
250
251
|
});
|
|
252
|
+
if (rule.id === 'node-modules') {
|
|
253
|
+
block(
|
|
254
|
+
item,
|
|
255
|
+
await timed(context.timings, 'projects:contents', () =>
|
|
256
|
+
foreignDataBlocker(env, candidate.path)
|
|
257
|
+
)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
251
260
|
await addBlockers(context, item, false);
|
|
252
261
|
return item;
|
|
253
262
|
}
|
package/src/swap.js
CHANGED
|
@@ -260,8 +260,11 @@ export function swapWarnings(swap, disk) {
|
|
|
260
260
|
if (reasons.length === 0) {
|
|
261
261
|
return [];
|
|
262
262
|
}
|
|
263
|
-
// The kernel (PID 0) cannot be quit
|
|
264
|
-
|
|
263
|
+
// The kernel (PID 0) cannot be quit and this dss run ends by itself, so
|
|
264
|
+
// naming either helps nobody.
|
|
265
|
+
const candidates = swap.holders.filter(
|
|
266
|
+
(holder) => holder.pid > 0 && !holder.self
|
|
267
|
+
);
|
|
265
268
|
const growing = candidates.filter(
|
|
266
269
|
(holder) => holder.growth?.memoryBytes >= GROWTH_WARNING_BYTES
|
|
267
270
|
);
|
|
@@ -308,6 +311,10 @@ export async function readSwap(env, options = {}) {
|
|
|
308
311
|
if (!swap) {
|
|
309
312
|
return null;
|
|
310
313
|
}
|
|
314
|
+
// This dss run shows up among the holders while it scans; it ends on its own.
|
|
315
|
+
swap.holders = swap.holders.map((holder) =>
|
|
316
|
+
holder.pid === process.pid ? { ...holder, self: true } : holder
|
|
317
|
+
);
|
|
311
318
|
const history = await loadSwapHistory(stateDir, now);
|
|
312
319
|
const grown = withGrowth(swap, history);
|
|
313
320
|
await saveSwapHistory(stateDir, [
|
package/src/usage.js
CHANGED
|
@@ -26,6 +26,22 @@ import { formatBytes } from './units.js';
|
|
|
26
26
|
|
|
27
27
|
const MiB = 1024 * 1024;
|
|
28
28
|
const GiB = 1024 * MiB;
|
|
29
|
+
const GROUP_PATTERNS = 64;
|
|
30
|
+
const GROUP_MIN_ENTRIES = 10;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The name pattern an entry is grouped under: its leading word and the
|
|
34
|
+
* separator after it (`ms-tg-creds-a1b2` is `ms-*`), else its extension
|
|
35
|
+
* (`trace.zip` is `*.zip`), else null.
|
|
36
|
+
*/
|
|
37
|
+
export function namePattern(name) {
|
|
38
|
+
const word = /^[A-Za-z][A-Za-z0-9]*[-_.]/.exec(name);
|
|
39
|
+
if (word && word[0].length < name.length) {
|
|
40
|
+
return `${word[0]}*`;
|
|
41
|
+
}
|
|
42
|
+
const extension = /\.[A-Za-z0-9]{1,8}$/.exec(name);
|
|
43
|
+
return extension ? `*${extension[0]}` : null;
|
|
44
|
+
}
|
|
29
45
|
const DATA_VOLUME = '/System/Volumes/Data';
|
|
30
46
|
const DENIED = /^du: (.+): (Permission denied|Operation not permitted)$/;
|
|
31
47
|
|
|
@@ -52,6 +68,10 @@ export class UsageCollector {
|
|
|
52
68
|
this.entries = 0;
|
|
53
69
|
this.previous = null;
|
|
54
70
|
this.last = null;
|
|
71
|
+
// Small entries summed by name pattern, per directory still being
|
|
72
|
+
// listed, so a directory of many small entries can say what fills it.
|
|
73
|
+
this.open = new Map();
|
|
74
|
+
this.groups = new Map();
|
|
55
75
|
}
|
|
56
76
|
|
|
57
77
|
add(line) {
|
|
@@ -64,7 +84,11 @@ export class UsageCollector {
|
|
|
64
84
|
const isDir = this.previous?.startsWith(prefix) ?? false;
|
|
65
85
|
this.previous = entry.path;
|
|
66
86
|
this.last = entry;
|
|
87
|
+
if (isDir) {
|
|
88
|
+
this.#closeDir(entry);
|
|
89
|
+
}
|
|
67
90
|
if (entry.bytes < this.minBytes) {
|
|
91
|
+
this.#group(entry);
|
|
68
92
|
return;
|
|
69
93
|
}
|
|
70
94
|
if (isDir) {
|
|
@@ -77,6 +101,45 @@ export class UsageCollector {
|
|
|
77
101
|
}
|
|
78
102
|
}
|
|
79
103
|
|
|
104
|
+
#group(entry) {
|
|
105
|
+
const slash = entry.path.lastIndexOf('/');
|
|
106
|
+
const pattern = namePattern(entry.path.slice(slash + 1));
|
|
107
|
+
if (slash <= 0 || !pattern) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const parent = entry.path.slice(0, slash);
|
|
111
|
+
const groups = this.open.get(parent) ?? new Map();
|
|
112
|
+
this.open.set(parent, groups);
|
|
113
|
+
const group = groups.get(pattern);
|
|
114
|
+
if (group) {
|
|
115
|
+
group.bytes += entry.bytes;
|
|
116
|
+
group.count += 1;
|
|
117
|
+
} else if (groups.size < GROUP_PATTERNS) {
|
|
118
|
+
groups.set(pattern, { pattern, bytes: entry.bytes, count: 1 });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `du` lists a directory after everything inside it. */
|
|
123
|
+
#closeDir(entry) {
|
|
124
|
+
const groups = this.open.get(entry.path);
|
|
125
|
+
if (!groups) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.open.delete(entry.path);
|
|
129
|
+
const kept = [...groups.values()].filter(
|
|
130
|
+
(group) =>
|
|
131
|
+
group.bytes >= this.minBytes && group.count >= GROUP_MIN_ENTRIES
|
|
132
|
+
);
|
|
133
|
+
if (entry.bytes >= this.minBytes && kept.length > 0) {
|
|
134
|
+
this.groups.set(
|
|
135
|
+
entry.path,
|
|
136
|
+
kept
|
|
137
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
138
|
+
.map((group) => ({ ...group, below: this.minBytes }))
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
80
143
|
#trimFiles() {
|
|
81
144
|
this.files.sort((a, b) => b.bytes - a.bytes);
|
|
82
145
|
this.files.length = Math.min(this.files.length, this.topFiles);
|
|
@@ -93,6 +156,7 @@ export class UsageCollector {
|
|
|
93
156
|
entries: this.entries,
|
|
94
157
|
dirs: this.dirs,
|
|
95
158
|
files: this.files,
|
|
159
|
+
groups: this.groups,
|
|
96
160
|
};
|
|
97
161
|
}
|
|
98
162
|
}
|
|
@@ -417,9 +481,13 @@ function rootReport(result, items, options) {
|
|
|
417
481
|
[...result.dirs].map(([dir, bytes]) => [show(dir), bytes])
|
|
418
482
|
);
|
|
419
483
|
const rootPath = show(result.root);
|
|
484
|
+
const groups = new Map(
|
|
485
|
+
[...(result.groups ?? [])].map(([dir, list]) => [show(dir), list])
|
|
486
|
+
);
|
|
420
487
|
const annotate = (node) => ({
|
|
421
488
|
...node,
|
|
422
489
|
dss: summary(scanCoverage(node.path, items)),
|
|
490
|
+
groups: smallEntries(node, groups.get(node.path), minBytes),
|
|
423
491
|
children: node.children.map(annotate),
|
|
424
492
|
});
|
|
425
493
|
const inside = items.filter((item) =>
|
|
@@ -458,6 +526,18 @@ function rootReport(result, items, options) {
|
|
|
458
526
|
};
|
|
459
527
|
}
|
|
460
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Groups of small entries worth naming in `node`: those as large as a
|
|
531
|
+
* shown directory, when the shown subdirectories leave much of it unnamed.
|
|
532
|
+
*/
|
|
533
|
+
function smallEntries(node, groups, minBytes) {
|
|
534
|
+
const named = node.children.reduce((sum, child) => sum + child.bytes, 0);
|
|
535
|
+
if (!groups || named >= node.bytes * 0.5) {
|
|
536
|
+
return [];
|
|
537
|
+
}
|
|
538
|
+
return groups.filter((group) => group.bytes >= minBytes).slice(0, 5);
|
|
539
|
+
}
|
|
540
|
+
|
|
461
541
|
function summary(coverage) {
|
|
462
542
|
return {
|
|
463
543
|
rule: coverage.item?.rule ?? null,
|
|
@@ -517,9 +597,15 @@ function treeLines(node, depth = 0) {
|
|
|
517
597
|
) {
|
|
518
598
|
return treeLines(only, depth);
|
|
519
599
|
}
|
|
600
|
+
const indent = ' '.repeat(depth + 1);
|
|
601
|
+
const separator = node.path.endsWith('/') ? '' : '/';
|
|
520
602
|
return [
|
|
521
603
|
`${formatBytes(node.bytes).padStart(10)} ${' '.repeat(depth)}${node.path}${dssNote(node.dss)}`,
|
|
522
604
|
...node.children.flatMap((child) => treeLines(child, depth + 1)),
|
|
605
|
+
...(node.groups ?? []).map(
|
|
606
|
+
(group) =>
|
|
607
|
+
`${formatBytes(group.bytes).padStart(10)} ${indent}${node.path}${separator}${group.pattern} (${group.count.toLocaleString('en-US')} entries, each under ${formatBytes(group.below)})`
|
|
608
|
+
),
|
|
523
609
|
];
|
|
524
610
|
}
|
|
525
611
|
|