disk-space-saviour 0.20.0 → 0.22.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.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Chromium browsers on macOS clone their app bundle at launch with APFS
3
+ * `clonefile` and replace the clone's main executable with a hard link to
4
+ * the installed one (chrome/browser/mac/code_sign_clone_manager.mm). The
5
+ * browser holds no file open inside its clone: it runs the shared inode,
6
+ * which `lsof` reports under the installed path until an update replaces
7
+ * the app. After an update the clone keeps the running browser's code
8
+ * signature valid, so a clone stays while any process runs its executable.
9
+ * Until then the clone shares every block with the installed app, and
10
+ * removing it frees nothing but directory entries.
11
+ */
12
+
13
+ const BUNDLE_SUFFIX = '.bundle';
14
+
15
+ /**
16
+ * Main executables of the bundle cloned in `clone`, and whether the clone
17
+ * still shares its blocks with an app installed in one of `appDirs`.
18
+ * @returns {Promise<{executables: string[], sharedWith: string|null}>}
19
+ */
20
+ export async function appCloneDetails(env, clone, appDirs) {
21
+ const executables = [];
22
+ let sharedWith = null;
23
+ for (const bundle of await env.list(clone)) {
24
+ if (
25
+ bundle.type !== 'dir' ||
26
+ !bundle.name.endsWith(`.app${BUNDLE_SUFFIX}`)
27
+ ) {
28
+ continue;
29
+ }
30
+ const appName = bundle.name.slice(0, -BUNDLE_SUFFIX.length);
31
+ const macos = env.path.join(bundle.path, 'Contents', 'MacOS');
32
+ for (const entry of await env.list(macos)) {
33
+ if (entry.type !== 'file') {
34
+ continue;
35
+ }
36
+ executables.push(entry.path);
37
+ sharedWith ??= await installedCopy(env, entry, appName, appDirs);
38
+ }
39
+ }
40
+ return { executables, sharedWith };
41
+ }
42
+
43
+ async function installedCopy(env, executable, appName, appDirs) {
44
+ const inode = (await env.stat(executable.path))?.inode;
45
+ for (const dir of appDirs) {
46
+ const app = env.path.join(dir, appName);
47
+ const installed = env.path.join(app, 'Contents', 'MacOS', executable.name);
48
+ if (inode && (await env.stat(installed))?.inode === inode) {
49
+ return app;
50
+ }
51
+ }
52
+ return null;
53
+ }
@@ -27,6 +27,7 @@ export async function listMany(env, dirs) {
27
27
  */
28
28
  export async function scanTimeBusy(context, item) {
29
29
  await resolvePaths(context, item.paths);
30
+ await context.liveness?.resolveExecutables(item.checks?.executables);
30
31
  return context.liveness ? context.liveness.busyReason(item) : null;
31
32
  }
32
33
 
@@ -1,4 +1,4 @@
1
- import { scanItems } from '../env/scope.js';
1
+ import { checkSignal, scanItems } from '../env/scope.js';
2
2
  /**
3
3
  * Global per-user caches (ecosystem download and compilation caches, IDE and
4
4
  * browser caches), system package caches, and leftovers in temporary
@@ -7,7 +7,7 @@ import { scanItems } from '../env/scope.js';
7
7
  */
8
8
 
9
9
  import { CACHE_RULES } from '../rules/ecosystems.js';
10
- import { OTHER_RULES } from '../rules/other.js';
10
+ import { OTHER_RULES, TEMP_CLONE_RULES } from '../rules/other.js';
11
11
  import { APP_RULES, expandChromiumCaches } from '../rules/apps.js';
12
12
  import { VERSION_RULES, compareVersions } from '../rules/versions.js';
13
13
  import { block, makeItem } from '../items.js';
@@ -17,6 +17,10 @@ import { configuredPattern } from '../env/cache-roots.js';
17
17
  import { TOOLCHAIN_CACHE_RULES } from '../rules/toolchains.js';
18
18
  import { DOWNLOAD_RULES } from '../rules/downloads.js';
19
19
  import { timed } from '../timings.js';
20
+ import { appCloneDetails } from './app-clones.js';
21
+ import { gitDirectories } from '../git.js';
22
+ import { networkRemotes } from '../git-remote.js';
23
+ import { playwrightBlocker } from '../playwright-registry.js';
20
24
 
21
25
  export const GLOBAL_RULES = [
22
26
  ...CACHE_RULES,
@@ -24,6 +28,7 @@ export const GLOBAL_RULES = [
24
28
  ...APP_RULES,
25
29
  ...TOOLCHAIN_CACHE_RULES,
26
30
  ...DOWNLOAD_RULES,
31
+ ...TEMP_CLONE_RULES,
27
32
  ];
28
33
 
29
34
  function scopeBases(rule, homes, tmpDirs) {
@@ -173,7 +178,13 @@ function olderVersions(env, rule, paths, stats) {
173
178
  return paths.filter((target) => old.has(target));
174
179
  }
175
180
 
176
- async function buildItem(context, rule, base, group) {
181
+ /** What cleanup re-verifies right before deleting, by the rule's `git` mode. */
182
+ const GIT_RECHECKS = {
183
+ ifPresent: { type: 'scratch' },
184
+ clone: { type: 'clone' },
185
+ };
186
+
187
+ async function buildItem(context, rule, base, group, homes) {
177
188
  const { env } = context;
178
189
  const paths = group.map((entry) => entry.path);
179
190
  const appRoot = rule.markers
@@ -195,7 +206,7 @@ async function buildItem(context, rule, base, group) {
195
206
  reason: rule.minAge
196
207
  ? `untouched for longer than the ${rule.minAge === 'stale' ? 'activity' : 'inactivity'} window`
197
208
  : 're-downloaded or re-created on demand',
198
- recheck: rule.git === 'ifPresent' ? { type: 'scratch' } : undefined,
209
+ recheck: GIT_RECHECKS[rule.git],
199
210
  action: action ?? { type: 'none' },
200
211
  checks: {
201
212
  busy: [
@@ -207,10 +218,35 @@ async function buildItem(context, rule, base, group) {
207
218
  },
208
219
  });
209
220
  block(item, blocker);
221
+ if (rule.appClone) {
222
+ await describeAppClone(context, rule, item, homes);
223
+ }
210
224
  await addBlockers(context, rule, item);
211
225
  return item;
212
226
  }
213
227
 
228
+ async function describeAppClone(context, rule, item, homes) {
229
+ const { env } = context;
230
+ const appDirs = rule.appClone.installed
231
+ .flatMap((dir) =>
232
+ homes.map((home) => expandRulePath(dir, { home, pathApi: env.path }))
233
+ )
234
+ .filter(Boolean);
235
+ const { executables, sharedWith } = await appCloneDetails(env, item.path, [
236
+ ...new Set(appDirs),
237
+ ]);
238
+ item.checks.executables = executables;
239
+ if (sharedWith) {
240
+ // Every block is shared with the installed app: nothing to reclaim.
241
+ // The report lists it under `shared` so the space `du` counts for it
242
+ // is explained.
243
+ item.sharedWith = sharedWith;
244
+ item.apparentBytes = item.bytes;
245
+ item.bytes = 0;
246
+ item.reason = `shares its disk blocks with ${sharedWith}`;
247
+ }
248
+ }
249
+
214
250
  async function addBlockers(context, rule, item) {
215
251
  const { env, git } = context;
216
252
  if (rule.scope === 'system' && !(await env.isRoot())) {
@@ -218,13 +254,24 @@ async function addBlockers(context, rule, item) {
218
254
  }
219
255
  if (rule.git) {
220
256
  for (const target of item.paths) {
221
- for (const reason of await git.treeBlockers(target, {
222
- allowNoRepo: rule.git === 'ifPresent',
223
- })) {
257
+ for (const reason of rule.git === 'clone'
258
+ ? await git.cloneBlockers(target)
259
+ : await git.treeBlockers(target, {
260
+ allowNoRepo: rule.git === 'ifPresent',
261
+ })) {
224
262
  block(item, reason);
225
263
  }
226
264
  }
227
265
  }
266
+ if (rule.registry === 'playwright') {
267
+ context.playwrightRegistries ??= new Map();
268
+ for (const target of item.paths) {
269
+ block(
270
+ item,
271
+ await playwrightBlocker(env, target, context.playwrightRegistries)
272
+ );
273
+ }
274
+ }
228
275
  const busy = await scanTimeBusy(context, item);
229
276
  if (busy) {
230
277
  block(item, `busy: ${busy}`);
@@ -292,9 +339,12 @@ export async function scanGlobal(
292
339
 
293
340
  const accepted = [];
294
341
  for (const target of candidates) {
342
+ // Acceptance can read Git metadata of thousands of temporary entries
343
+ // without passing through the environment, so honour the budget here.
344
+ checkSignal(env.signal);
295
345
  if (
296
346
  !(await timed(context.timings, 'global:accept', () =>
297
- acceptedPath(env, rule, target, stats)
347
+ acceptedPath(env, rule, target, stats, seen)
298
348
  ))
299
349
  ) {
300
350
  continue;
@@ -338,7 +388,7 @@ export async function scanGlobal(
338
388
  : measured.filter(large).map((entry) => [entry]);
339
389
  for (const group of groups.filter((g) => g.length > 0)) {
340
390
  const item = await timed(context.timings, 'global:item', () =>
341
- buildItem(context, rule, base, group)
391
+ buildItem(context, rule, base, group, homes)
342
392
  );
343
393
  items.push(item);
344
394
  if ((await context.ready?.([item])) === false) {
@@ -349,7 +399,40 @@ export async function scanGlobal(
349
399
  return items;
350
400
  }
351
401
 
352
- async function acceptedPath(env, rule, target, stats) {
402
+ /**
403
+ * Whether `target` or one of its ancestors is in `claimed`. Both come from
404
+ * the same glob expansion, so ancestors compare as plain strings and the
405
+ * cost is the path depth, not the number of claimed paths.
406
+ */
407
+ function claimedAbove(env, target, claimed) {
408
+ for (let dir = target; ; dir = env.path.dirname(dir)) {
409
+ if (claimed.has(dir)) {
410
+ return true;
411
+ }
412
+ if (env.path.dirname(dir) === dir) {
413
+ return false;
414
+ }
415
+ }
416
+ }
417
+
418
+ /**
419
+ * A temporary directory holding a Git work tree with a network remote,
420
+ * outside every path another rule (or a parent clone) already claimed.
421
+ */
422
+ async function tempClone(env, target, seen) {
423
+ if (env.kind !== 'host' || claimedAbove(env, target, seen)) {
424
+ return false;
425
+ }
426
+ try {
427
+ // Other users' temporary directories are unreadable; that is no error.
428
+ const { commonDir } = await gitDirectories(target);
429
+ return (await networkRemotes(commonDir)).length > 0;
430
+ } catch {
431
+ return false;
432
+ }
433
+ }
434
+
435
+ async function acceptedPath(env, rule, target, stats, seen = new Set()) {
353
436
  const name = env.path.basename(target);
354
437
  if (rule.excludeNames?.includes(name)) {
355
438
  return false;
@@ -357,6 +440,9 @@ async function acceptedPath(env, rule, target, stats) {
357
440
  if (rule.directoryOnly && stats.get(target)?.type !== 'dir') {
358
441
  return false;
359
442
  }
443
+ if (rule.git === 'clone' && !(await tempClone(env, target, seen))) {
444
+ return false;
445
+ }
360
446
  if (rule.fileOnly && stats.get(target)?.type !== 'file') {
361
447
  return false;
362
448
  }
@@ -9,6 +9,9 @@ import { scanItems } from '../env/scope.js';
9
9
  * are present. Generic names (`build`, `lib`, `target`, `bin`) that fail
10
10
  * every rule are searched again below, with the remaining depth, so a
11
11
  * non-matching `build` directory never hides real projects inside it.
12
+ * Git worktree folders (`.worktrees`, `worktrees`, `.claude/worktrees`)
13
+ * hold whole checkouts, so the search below them starts with the full
14
+ * depth again: an agent's worktree deep in a project is a project too.
12
15
  *
13
16
  * Cargo `target` directories additionally yield `cargo-superseded` items:
14
17
  * older build hashes that a newer build of the same unit replaced (see
@@ -23,6 +26,7 @@ import { analyzeRustProfile, findRustProfiles } from './rust.js';
23
26
  import { timed } from '../timings.js';
24
27
 
25
28
  const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
29
+ const WORKTREE_DIRS = ['.worktrees', 'worktrees'];
26
30
 
27
31
  function anyMatch(entries, globs) {
28
32
  return (entries ?? []).some((entry) =>
@@ -37,6 +41,13 @@ function depthBelow(env, roots, target) {
37
41
  return env.path.relative(root, target).split(/[\\/]/).filter(Boolean).length;
38
42
  }
39
43
 
44
+ /** Depth left below a directory no rule claims; worktrees start afresh. */
45
+ function remainingDepth(env, dirs, candidate, { depth, maxDepth }) {
46
+ return WORKTREE_DIRS.includes(candidate.name)
47
+ ? maxDepth
48
+ : depth - depthBelow(env, dirs, candidate.path);
49
+ }
50
+
40
51
  function ruleAccepts(env, rule, candidate, selfListing, grandListing) {
41
52
  if (rule.markers && !anyMatch(candidate.siblings, rule.markers)) {
42
53
  return false;
@@ -139,7 +150,9 @@ async function indexedMatches(env, roots, globs, maxDepth, rules) {
139
150
  export async function findProjectDirs(env, roots, options = {}) {
140
151
  const rules = options.rules ?? PROJECT_RULES;
141
152
  const maxDepth = options.maxDepth ?? 6;
142
- const globs = [...new Set(rules.flatMap((rule) => rule.names))];
153
+ const globs = [
154
+ ...new Set([...rules.flatMap((rule) => rule.names), ...WORKTREE_DIRS]),
155
+ ];
143
156
  const matches = [];
144
157
  const seen = new Set();
145
158
  const accept = (match) => {
@@ -171,7 +184,10 @@ export async function findProjectDirs(env, roots, options = {}) {
171
184
  }
172
185
  continue;
173
186
  }
174
- const remaining = depth - depthBelow(env, dirs, match.candidate.path);
187
+ const remaining = remainingDepth(env, dirs, match.candidate, {
188
+ depth,
189
+ maxDepth,
190
+ });
175
191
  if (remaining > 0) {
176
192
  next.set(remaining, [
177
193
  ...(next.get(remaining) ?? []),
@@ -235,11 +235,13 @@ function referencedInstalls(rule, installs, refs, versionOf) {
235
235
  }
236
236
 
237
237
  /**
238
- * Global packages a user installed into a version (`npm install -g`) are
239
- * lost with it. Packages bundled with the runtime and caches that are
240
- * installed again on demand (`rule.globalPackages.regenerable`) are not.
238
+ * Global packages a user installed into a version (`npm install -g`) go
239
+ * with it, so the item names them. Packages bundled with the runtime and
240
+ * caches installed again on demand (`rule.globalPackages.regenerable`) are
241
+ * not named. A package that is running keeps the version through the
242
+ * process checks: its binary, arguments or PATH entry lies inside.
241
243
  */
242
- async function blockGlobalPackages(env, rule, dir, item) {
244
+ async function noteGlobalPackages(env, rule, dir, item) {
243
245
  if (!rule.globalPackages) {
244
246
  return;
245
247
  }
@@ -262,7 +264,8 @@ async function blockGlobalPackages(env, rule, dir, item) {
262
264
  if (own.length > 0) {
263
265
  const shown = own.slice(0, 5).join(', ');
264
266
  const more = own.length > 5 ? ` and ${own.length - 5} more` : '';
265
- block(item, `has globally installed packages: ${shown}${more}`);
267
+ item.globalPackages = own;
268
+ item.reason += `; also removes its global packages: ${shown}${more}`;
266
269
  }
267
270
  }
268
271
 
@@ -357,7 +360,7 @@ async function ruleItems(context, rule, home, pins) {
357
360
  : null,
358
361
  });
359
362
  await blockPyenvEnvironments(env, rule, dir, item);
360
- await blockGlobalPackages(env, rule, dir, item);
363
+ await noteGlobalPackages(env, rule, dir, item);
361
364
  await blockLiveLock(context, rule, home, version, item);
362
365
  const busy = await scanTimeBusy(context, item);
363
366
  if (busy) {
package/src/swap.js CHANGED
@@ -1,5 +1,23 @@
1
- /** Swap is a capacity diagnostic, never a deletion candidate. */
2
- import { parseSize } from './units.js';
1
+ /**
2
+ * Swap is a capacity diagnostic, never a deletion candidate. Swap files
3
+ * live on the system disk, so memory a process keeps but never touches
4
+ * turns into used disk space. Each scan records swap usage and the largest
5
+ * memory holders in the state directory; the next scan reports how much
6
+ * both grew since the oldest sample of the last week.
7
+ */
8
+ import { promises as fsp } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { AGENT_NAMES } from './health.js';
11
+ import { formatBytes, parseSize } from './units.js';
12
+
13
+ const GIB = 1024 ** 3;
14
+ export const HISTORY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
15
+ const HISTORY_LIMIT = 200;
16
+ const HOLDER_LIMIT = 10;
17
+ /** Growth below this is noise from normal paging. */
18
+ const GROWTH_WARNING_BYTES = GIB;
19
+ /** A single process holding this much is worth naming in a warning. */
20
+ const LARGE_HOLDER_BYTES = 2 * GIB;
3
21
 
4
22
  export function parseSwapUsage(text) {
5
23
  const match =
@@ -31,39 +49,279 @@ export function parseMemoryHolders(text) {
31
49
  compressedBytes: parseSize(match[4].replace(/[+-]$/, '')),
32
50
  }))
33
51
  .sort((a, b) => b.memoryBytes - a.memoryBytes)
34
- .slice(0, 10);
52
+ .slice(0, HOLDER_LIMIT);
53
+ }
54
+
55
+ /** Swap totals from `/proc/meminfo`, or null. */
56
+ export function parseMeminfo(text) {
57
+ const field = (name) => {
58
+ const match = new RegExp(`^${name}:\\s+(\\d+)\\s*kB`, 'm').exec(text);
59
+ return match ? Number(match[1]) * 1024 : null;
60
+ };
61
+ const total = field('SwapTotal');
62
+ const free = field('SwapFree');
63
+ return total === null || free === null || total === 0
64
+ ? null
65
+ : { total, used: total - free, free };
66
+ }
67
+
68
+ /** Name, PID, resident and swapped bytes from a `/proc/<pid>/status`. */
69
+ export function parseProcStatus(text) {
70
+ const value = (name) => new RegExp(`^${name}:\\s+(.*)$`, 'm').exec(text)?.[1];
71
+ const kib = (name) => Number(/^(\d+)/.exec(value(name) ?? '')?.[1] ?? 0);
72
+ const pid = Number(value('Pid'));
73
+ if (!Number.isInteger(pid)) {
74
+ return null;
75
+ }
76
+ const residentBytes = kib('VmRSS') * 1024;
77
+ const swappedBytes = kib('VmSwap') * 1024;
78
+ return {
79
+ pid,
80
+ command: value('Name')?.trim() ?? '',
81
+ memoryBytes: residentBytes + swappedBytes,
82
+ swappedBytes,
83
+ };
84
+ }
85
+
86
+ async function linuxHolders(procRoot) {
87
+ const pids = (await fsp.readdir(procRoot)).filter((name) =>
88
+ /^\d+$/.test(name)
89
+ );
90
+ const holders = await Promise.all(
91
+ pids.map((pid) =>
92
+ fsp
93
+ .readFile(path.join(procRoot, pid, 'status'), 'utf8')
94
+ .then(parseProcStatus, () => null)
95
+ )
96
+ );
97
+ return holders
98
+ .filter((holder) => holder && holder.memoryBytes > 0)
99
+ .sort(
100
+ (a, b) => b.swappedBytes - a.swappedBytes || b.memoryBytes - a.memoryBytes
101
+ )
102
+ .slice(0, HOLDER_LIMIT);
103
+ }
104
+
105
+ async function readDarwin(env) {
106
+ const usage = await env.run(['sysctl', 'vm.swapusage']);
107
+ const swap = usage.code === 0 ? parseSwapUsage(usage.stdout) : null;
108
+ if (!swap) {
109
+ return null;
110
+ }
111
+ const top = await env.run([
112
+ 'top',
113
+ '-l',
114
+ '1',
115
+ '-o',
116
+ 'mem',
117
+ '-n',
118
+ String(HOLDER_LIMIT),
119
+ '-stats',
120
+ 'pid,command,mem,cmprs',
121
+ ]);
122
+ return {
123
+ ...swap,
124
+ holders: top.code === 0 ? parseMemoryHolders(top.stdout) : [],
125
+ attribution:
126
+ 'Largest memory consumers (memory includes compressed and swapped pages); macOS does not expose per-process swap ownership here.',
127
+ advice:
128
+ 'If Docker Desktop is consuming memory, restart it after saving running work or lower its VM memory limit. Swap files are managed by macOS; dss never deletes them.',
129
+ };
35
130
  }
36
131
 
37
- export async function readSwap(env) {
38
- if (env.kind !== 'host' || env.platform !== 'darwin') {
132
+ async function readLinux(procRoot) {
133
+ const swap = parseMeminfo(
134
+ await fsp.readFile(path.join(procRoot, 'meminfo'), 'utf8')
135
+ );
136
+ if (!swap) {
39
137
  return null;
40
138
  }
139
+ return {
140
+ ...swap,
141
+ holders: await linuxHolders(procRoot),
142
+ attribution:
143
+ 'Processes with the most swapped memory (VmSwap in /proc/<pid>/status), then the most resident memory.',
144
+ advice:
145
+ 'Swap is managed by the kernel; dss never deletes swap files or partitions.',
146
+ };
147
+ }
148
+
149
+ const holderKey = (holder) => `${holder.pid}\0${holder.command}`;
150
+
151
+ /** Samples of the last week, oldest first; missing state is empty. */
152
+ export async function loadSwapHistory(stateDir, now) {
153
+ if (!stateDir) {
154
+ return [];
155
+ }
156
+ try {
157
+ const data = JSON.parse(
158
+ await fsp.readFile(path.join(stateDir, 'swap-history.json'), 'utf8')
159
+ );
160
+ return (Array.isArray(data.samples) ? data.samples : []).filter(
161
+ (sample) =>
162
+ Number.isFinite(sample?.time) &&
163
+ sample.time <= now &&
164
+ now - sample.time <= HISTORY_WINDOW_MS
165
+ );
166
+ } catch {
167
+ return [];
168
+ }
169
+ }
170
+
171
+ async function saveSwapHistory(stateDir, samples) {
172
+ if (!stateDir) {
173
+ return;
174
+ }
41
175
  try {
42
- const usage = await env.run(['sysctl', 'vm.swapusage']);
43
- const swap = usage.code === 0 ? parseSwapUsage(usage.stdout) : null;
44
- if (!swap) {
45
- return null;
176
+ await fsp.mkdir(stateDir, { recursive: true, mode: 0o700 });
177
+ const file = path.join(stateDir, 'swap-history.json');
178
+ const temporary = `${file}.${process.pid}`;
179
+ await fsp.writeFile(
180
+ temporary,
181
+ JSON.stringify({ schema: 1, samples: samples.slice(-HISTORY_LIMIT) }),
182
+ { mode: 0o600 }
183
+ );
184
+ await fsp.rename(temporary, file);
185
+ } catch {
186
+ // History only adds growth figures; a read-only state dir loses them.
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Adds swap and per-holder growth since the oldest sample of `history`.
192
+ * A holder's baseline is the oldest sample that lists the same PID and
193
+ * command, so a restarted process starts over.
194
+ */
195
+ export function withGrowth(swap, history) {
196
+ const baseline = history[0];
197
+ const firstSeen = new Map();
198
+ for (const sample of history) {
199
+ for (const holder of sample.holders ?? []) {
200
+ if (!firstSeen.has(holderKey(holder))) {
201
+ firstSeen.set(holderKey(holder), { time: sample.time, ...holder });
202
+ }
203
+ }
204
+ }
205
+ return {
206
+ ...swap,
207
+ growth: baseline
208
+ ? { since: baseline.time, usedBytes: swap.used - baseline.used }
209
+ : null,
210
+ holders: swap.holders.map((holder) => {
211
+ const first = firstSeen.get(holderKey(holder));
212
+ return first
213
+ ? {
214
+ ...holder,
215
+ growth: {
216
+ since: first.time,
217
+ memoryBytes: holder.memoryBytes - first.memoryBytes,
218
+ },
219
+ }
220
+ : holder;
221
+ }),
222
+ };
223
+ }
224
+
225
+ const isAgent = (holder) =>
226
+ AGENT_NAMES.includes(holder.command.trim().toLowerCase());
227
+
228
+ function holderSummary(holder) {
229
+ const grew =
230
+ holder.growth?.memoryBytes > 0
231
+ ? `, +${formatBytes(holder.growth.memoryBytes)} since ${new Date(holder.growth.since).toISOString()}`
232
+ : '';
233
+ return `${holder.command} (pid ${holder.pid}): ${formatBytes(holder.memoryBytes)}${grew}`;
234
+ }
235
+
236
+ /**
237
+ * Warnings for swap that is large, growing, or larger than the free disk
238
+ * space it can grow into, naming the processes that hold the memory.
239
+ * @param {object} swap result of `withGrowth`
240
+ * @param {{free: number}|null} disk
241
+ * @returns {string[]}
242
+ */
243
+ export function swapWarnings(swap, disk) {
244
+ const reasons = [];
245
+ if (swap.growth && swap.growth.usedBytes >= GROWTH_WARNING_BYTES) {
246
+ reasons.push(
247
+ `grew by ${formatBytes(swap.growth.usedBytes)} since ${new Date(swap.growth.since).toISOString()}`
248
+ );
249
+ }
250
+ if (swap.total > 0 && swap.used >= GIB && swap.used >= 0.75 * swap.total) {
251
+ reasons.push(
252
+ `is ${Math.round((100 * swap.used) / swap.total)}% full, so the system adds swap files on disk when memory demand grows`
253
+ );
254
+ }
255
+ if (disk && swap.used >= GIB && disk.free < swap.used) {
256
+ reasons.push(
257
+ `has only ${formatBytes(disk.free)} of free disk to grow into, less than the ${formatBytes(swap.used)} already used`
258
+ );
259
+ }
260
+ if (reasons.length === 0) {
261
+ return [];
262
+ }
263
+ // The kernel (PID 0) cannot be quit, so naming it helps nobody.
264
+ const candidates = swap.holders.filter((holder) => holder.pid > 0);
265
+ const growing = candidates.filter(
266
+ (holder) => holder.growth?.memoryBytes >= GROWTH_WARNING_BYTES
267
+ );
268
+ const large = candidates.filter(
269
+ (holder) => holder.memoryBytes >= LARGE_HOLDER_BYTES
270
+ );
271
+ const named = [...new Set([...growing, ...large])].slice(0, 5);
272
+ const warnings = [`Swap ${reasons.join('; ')}.`];
273
+ if (named.length > 0) {
274
+ warnings.push(
275
+ `Memory held by: ${named.map(holderSummary).join('; ')}. Quitting or restarting them frees that swap and its disk space.`
276
+ );
277
+ }
278
+ if (named.some(isAgent)) {
279
+ warnings.push(
280
+ 'Long-running AI agent sessions keep their history in memory: quit the session and resume it (for example `codex resume <session-id>` or `claude --resume`) to release it without losing the conversation.'
281
+ );
282
+ }
283
+ return warnings;
284
+ }
285
+
286
+ /**
287
+ * Swap usage, its largest memory holders, growth since earlier scans and
288
+ * warnings; null where swap cannot be read.
289
+ * @param {object} env
290
+ * @param {{stateDir?: string|false, disk?: object|null, now?: number,
291
+ * procRoot?: string}} [options]
292
+ */
293
+ export async function readSwap(env, options = {}) {
294
+ if (env.kind !== 'host') {
295
+ return null;
296
+ }
297
+ const { stateDir, disk = null, now = Date.now() } = options;
298
+ let swap = null;
299
+ try {
300
+ if (env.platform === 'darwin') {
301
+ swap = await readDarwin(env);
302
+ } else if (env.platform === 'linux') {
303
+ swap = await readLinux(options.procRoot ?? '/proc');
46
304
  }
47
- const top = await env.run([
48
- 'top',
49
- '-l',
50
- '1',
51
- '-o',
52
- 'mem',
53
- '-n',
54
- '10',
55
- '-stats',
56
- 'pid,command,mem,cmprs',
57
- ]);
58
- return {
59
- ...swap,
60
- holders: top.code === 0 ? parseMemoryHolders(top.stdout) : [],
61
- attribution:
62
- 'Largest resident/compressed memory consumers; macOS does not expose per-process swap ownership here.',
63
- advice:
64
- 'If Docker Desktop is consuming memory, restart it after saving running work or lower its VM memory limit. Swap files are managed by macOS; dss never deletes them.',
65
- };
66
305
  } catch {
306
+ swap = null;
307
+ }
308
+ if (!swap) {
67
309
  return null;
68
310
  }
311
+ const history = await loadSwapHistory(stateDir, now);
312
+ const grown = withGrowth(swap, history);
313
+ await saveSwapHistory(stateDir, [
314
+ ...history,
315
+ {
316
+ time: now,
317
+ used: swap.used,
318
+ total: swap.total,
319
+ holders: swap.holders.map(({ pid, command, memoryBytes }) => ({
320
+ pid,
321
+ command,
322
+ memoryBytes,
323
+ })),
324
+ },
325
+ ]);
326
+ return { ...grown, warnings: swapWarnings(grown, disk) };
69
327
  }