disk-space-saviour 0.22.0 → 0.23.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/CHANGELOG.md +28 -0
- package/README.md +87 -5
- package/package.json +1 -1
- package/src/audit.js +32 -1
- package/src/clean.js +38 -12
- package/src/cli.js +11 -0
- package/src/conditions.js +7 -0
- package/src/docker/scan.js +1 -0
- package/src/docker/writable.js +7 -1
- package/src/emergency.js +4 -4
- package/src/env/hardlinks.js +139 -0
- package/src/env/local.js +53 -11
- package/src/env/roots.js +138 -0
- package/src/env/shell.js +14 -2
- package/src/index.d.ts +46 -0
- package/src/liveness.js +116 -8
- package/src/mac-apps.js +286 -0
- package/src/options.js +3 -0
- package/src/report.js +18 -2
- package/src/rules/downloads.js +27 -0
- package/src/rules/other.js +9 -0
- package/src/scan-state.js +1 -0
- package/src/scan.js +33 -23
- package/src/scanners/agents.js +2 -1
- package/src/scanners/global.js +2 -1
- package/src/scanners/node-modules.js +89 -0
- package/src/scanners/projects.js +11 -1
- package/src/swap.js +9 -2
- package/src/usage.js +86 -0
package/src/env/local.js
CHANGED
|
@@ -100,6 +100,7 @@ import { hostExecutor } from '../exec.js';
|
|
|
100
100
|
import { parseBundleIdentifier } from '../app-owners.js';
|
|
101
101
|
import { matchesGlob } from '../paths.js';
|
|
102
102
|
import { mapLimit } from './parallel.js';
|
|
103
|
+
import { withLinkAccounting } from './hardlinks.js';
|
|
103
104
|
import { directoryFingerprint, SIZE_MAX_AGE } from '../scan-state.js';
|
|
104
105
|
import { indexedDirectories } from './platform-index.js';
|
|
105
106
|
|
|
@@ -213,6 +214,15 @@ async function makeWritable(target) {
|
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
function linkOf(stats, file) {
|
|
218
|
+
return {
|
|
219
|
+
path: file,
|
|
220
|
+
id: `${stats.dev}:${stats.ino}`,
|
|
221
|
+
nlink: stats.nlink,
|
|
222
|
+
bytes: diskBytes(stats),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
216
226
|
export class LocalEnv {
|
|
217
227
|
/**
|
|
218
228
|
* @param {{homes?: string[], tmpDirs?: string[], executor?: object,
|
|
@@ -322,6 +332,7 @@ export class LocalEnv {
|
|
|
322
332
|
mtimeMs: stats.mtimeMs,
|
|
323
333
|
ctimeMs: stats.ctimeMs,
|
|
324
334
|
inode: `${stats.dev}:${stats.ino}`,
|
|
335
|
+
nlink: stats.nlink,
|
|
325
336
|
uid: stats.uid,
|
|
326
337
|
gid: stats.gid,
|
|
327
338
|
mode: stats.mode,
|
|
@@ -440,14 +451,16 @@ export class LocalEnv {
|
|
|
440
451
|
return null;
|
|
441
452
|
}
|
|
442
453
|
const seen = new Set();
|
|
454
|
+
const links = [];
|
|
443
455
|
let bytes = 0;
|
|
444
456
|
let apparentBytes = 0;
|
|
445
457
|
let files = 0;
|
|
446
458
|
let errors = 0;
|
|
447
459
|
let newestMtimeMs = 0;
|
|
448
|
-
const account = (stats) => {
|
|
460
|
+
const account = (stats, full) => {
|
|
449
461
|
if (stats.nlink > 1 && !stats.isDirectory()) {
|
|
450
462
|
const key = `${stats.dev}:${stats.ino}`;
|
|
463
|
+
links.push(linkOf(stats, full));
|
|
451
464
|
if (seen.has(key)) {
|
|
452
465
|
return;
|
|
453
466
|
}
|
|
@@ -463,7 +476,7 @@ export class LocalEnv {
|
|
|
463
476
|
newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
|
|
464
477
|
}
|
|
465
478
|
};
|
|
466
|
-
account(root);
|
|
479
|
+
account(root, target);
|
|
467
480
|
const visit = async (dir) => {
|
|
468
481
|
let names;
|
|
469
482
|
try {
|
|
@@ -489,7 +502,7 @@ export class LocalEnv {
|
|
|
489
502
|
) {
|
|
490
503
|
continue;
|
|
491
504
|
}
|
|
492
|
-
account(stats);
|
|
505
|
+
account(stats, full);
|
|
493
506
|
if (stats.isDirectory()) {
|
|
494
507
|
subdirs.push(full);
|
|
495
508
|
}
|
|
@@ -511,7 +524,10 @@ export class LocalEnv {
|
|
|
511
524
|
if (files === 0) {
|
|
512
525
|
newestMtimeMs = root.mtimeMs;
|
|
513
526
|
}
|
|
514
|
-
return
|
|
527
|
+
return withLinkAccounting(
|
|
528
|
+
{ bytes, apparentBytes, files, errors, newestMtimeMs },
|
|
529
|
+
links
|
|
530
|
+
);
|
|
515
531
|
}
|
|
516
532
|
|
|
517
533
|
/**
|
|
@@ -734,6 +750,29 @@ export class LocalEnv {
|
|
|
734
750
|
return usage;
|
|
735
751
|
}
|
|
736
752
|
|
|
753
|
+
/** Multi-link files below `target`, which `du` cannot attribute. */
|
|
754
|
+
async #multiLinks(target) {
|
|
755
|
+
const result = await this.run([
|
|
756
|
+
'find',
|
|
757
|
+
target,
|
|
758
|
+
'-xdev',
|
|
759
|
+
'!',
|
|
760
|
+
'-type',
|
|
761
|
+
'd',
|
|
762
|
+
'-links',
|
|
763
|
+
'+1',
|
|
764
|
+
'-print0',
|
|
765
|
+
]);
|
|
766
|
+
const files = result.stdout.split('\0').filter(Boolean);
|
|
767
|
+
const stats = await mapLimit(files, 32, async (file) => [
|
|
768
|
+
file,
|
|
769
|
+
await this.#lstat(file),
|
|
770
|
+
]);
|
|
771
|
+
return stats
|
|
772
|
+
.filter(([, entry]) => entry)
|
|
773
|
+
.map(([file, entry]) => linkOf(entry, file));
|
|
774
|
+
}
|
|
775
|
+
|
|
737
776
|
async quickUsage(target) {
|
|
738
777
|
const stats = await this.stat(target);
|
|
739
778
|
if (!stats) {
|
|
@@ -755,13 +794,16 @@ export class LocalEnv {
|
|
|
755
794
|
if (result.code !== 0 || !match) {
|
|
756
795
|
return this.rawUsage(target);
|
|
757
796
|
}
|
|
758
|
-
const usage =
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
797
|
+
const usage = withLinkAccounting(
|
|
798
|
+
{
|
|
799
|
+
bytes: Number(match[1]) * 1024,
|
|
800
|
+
apparentBytes: Number(match[1]) * 1024,
|
|
801
|
+
newestMtimeMs: 0,
|
|
802
|
+
files: 0,
|
|
803
|
+
errors: 0,
|
|
804
|
+
},
|
|
805
|
+
await this.#multiLinks(target)
|
|
806
|
+
);
|
|
765
807
|
if (this.scanMetrics) {
|
|
766
808
|
this.scanMetrics.directoriesSized++;
|
|
767
809
|
}
|
package/src/env/roots.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective scan roots of one environment and the scope report that makes
|
|
3
|
+
* them visible.
|
|
4
|
+
*
|
|
5
|
+
* Root semantics (also documented in README "Scan roots"):
|
|
6
|
+
* - host: `roots` (positional paths) or, when none, the homes and temporary
|
|
7
|
+
* directories;
|
|
8
|
+
* - container (every Docker level): `containerRoots` replaces the defaults
|
|
9
|
+
* when given; otherwise the defaults (homes, temporary directories and
|
|
10
|
+
* the common workspace paths in CONTAINER_ROOTS that exist) plus every
|
|
11
|
+
* host `roots` path that also exists inside that container. Host and
|
|
12
|
+
* container namespaces differ, so a host root absent from a container is
|
|
13
|
+
* reported as `absent`; paths are never guessed or remapped.
|
|
14
|
+
* The same requested roots reach the traversal policy (mount selection)
|
|
15
|
+
* and project discovery, so both always agree.
|
|
16
|
+
*/
|
|
17
|
+
import { existingPaths, matchesExcluded } from '../paths.js';
|
|
18
|
+
|
|
19
|
+
/** Common project locations inside containers, scanned when present. */
|
|
20
|
+
export const CONTAINER_ROOTS = [
|
|
21
|
+
'/workspace',
|
|
22
|
+
'/workspaces',
|
|
23
|
+
'/app',
|
|
24
|
+
'/src',
|
|
25
|
+
'/code',
|
|
26
|
+
'/project',
|
|
27
|
+
'/usr/src/app',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Roots explicitly requested for `env` (null when only defaults apply). */
|
|
31
|
+
export function requestedRoots(env, options, overrides = {}) {
|
|
32
|
+
if (overrides.roots) {
|
|
33
|
+
return overrides.roots;
|
|
34
|
+
}
|
|
35
|
+
if (env.kind === 'container') {
|
|
36
|
+
return options.containerRoots ?? options.roots ?? null;
|
|
37
|
+
}
|
|
38
|
+
return options.roots ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const unique = (list) => [...new Set(list)];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves the roots project discovery walks plus one scope entry per
|
|
45
|
+
* considered root.
|
|
46
|
+
* @returns {Promise<{roots: string[], entries: object[]}>}
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveRoots(env, options, overrides, homes, tmpDirs) {
|
|
49
|
+
const entries = [];
|
|
50
|
+
const add = (path, source) => {
|
|
51
|
+
if (!entries.some((entry) => entry.path === path)) {
|
|
52
|
+
entries.push({ path, source });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
if (overrides.roots) {
|
|
56
|
+
overrides.roots.forEach((root) => add(root, 'roots'));
|
|
57
|
+
} else if (env.kind === 'container' && options.containerRoots) {
|
|
58
|
+
options.containerRoots.forEach((root) => add(root, 'container-roots'));
|
|
59
|
+
} else if (env.kind !== 'container' && options.roots) {
|
|
60
|
+
options.roots.forEach((root) => add(root, 'roots'));
|
|
61
|
+
} else {
|
|
62
|
+
homes.forEach((root) => add(root, 'home'));
|
|
63
|
+
tmpDirs.forEach((root) => add(root, 'tmp'));
|
|
64
|
+
if (env.kind === 'container') {
|
|
65
|
+
const present = await existingPaths(env, CONTAINER_ROOTS);
|
|
66
|
+
CONTAINER_ROOTS.filter((root) => present.has(root)).forEach((root) =>
|
|
67
|
+
add(root, 'container-default')
|
|
68
|
+
);
|
|
69
|
+
(options.roots ?? []).forEach((root) => add(root, 'roots'));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await classify(env, options, entries);
|
|
73
|
+
return {
|
|
74
|
+
roots: unique(
|
|
75
|
+
entries.filter((entry) => entry.status === 'pending').map((e) => e.path)
|
|
76
|
+
),
|
|
77
|
+
entries,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function classify(env, options, entries) {
|
|
82
|
+
const policy = env.scanPolicy;
|
|
83
|
+
const allowed = entries.filter((entry) => {
|
|
84
|
+
if (matchesExcluded(entry.path, options.exclude ?? [], env.path)) {
|
|
85
|
+
entry.status = 'excluded';
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (policy && !policy.allows(entry.path)) {
|
|
89
|
+
entry.status = 'pruned';
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
});
|
|
94
|
+
const present = allowed.length
|
|
95
|
+
? await existingPaths(
|
|
96
|
+
env,
|
|
97
|
+
allowed.map((entry) => entry.path)
|
|
98
|
+
)
|
|
99
|
+
: new Set();
|
|
100
|
+
for (const entry of allowed) {
|
|
101
|
+
entry.status = present.has(entry.path) ? 'pending' : 'absent';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Scope descriptor stored on the environment and copied into reports. */
|
|
106
|
+
export function scanScope(env, options, entries) {
|
|
107
|
+
return {
|
|
108
|
+
roots: entries.map((entry) => ({ ...entry })),
|
|
109
|
+
maxDepth: options.maxDepth,
|
|
110
|
+
exclude: [...(options.exclude ?? [])],
|
|
111
|
+
prunedMounts: [...(env.scanPolicy?.pruned ?? [])],
|
|
112
|
+
note:
|
|
113
|
+
'scanStatus "complete" means complete within these roots, maxDepth, ' +
|
|
114
|
+
'exclusions and mount boundaries; fixed cache, toolchain and agent ' +
|
|
115
|
+
'locations are checked independently of roots',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Final per-root status when the scanners of the environment finish. */
|
|
120
|
+
export function finishScope(env, ranProjects) {
|
|
121
|
+
const scope = env.scanScope;
|
|
122
|
+
if (!scope) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const denied = new Set(env.accessErrors?.keys() ?? []);
|
|
126
|
+
for (const root of scope.roots) {
|
|
127
|
+
if (root.status !== 'pending') {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (denied.has(root.path)) {
|
|
131
|
+
root.status = 'inaccessible';
|
|
132
|
+
} else if (!ranProjects) {
|
|
133
|
+
root.status = 'not-searched';
|
|
134
|
+
} else {
|
|
135
|
+
root.status = env.scanStatus === 'complete' ? 'searched' : 'partial';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/env/shell.js
CHANGED
|
@@ -20,6 +20,7 @@ import { sharedWritableLayer } from '../docker/writable.js';
|
|
|
20
20
|
import { containerExecutor, shellQuote, trace } from '../exec.js';
|
|
21
21
|
import { DEFAULT_SKIP_NAMES, parseDf, processAliases } from './local.js';
|
|
22
22
|
import { batches } from './batches.js';
|
|
23
|
+
import { LINKS_SCRIPT, parseLinks, withLinkAccounting } from './hardlinks.js';
|
|
23
24
|
import { signalExecutor } from './scope.js';
|
|
24
25
|
|
|
25
26
|
const STAT_FORMAT = '%F|%s|%b|%B|%Y|%n';
|
|
@@ -44,6 +45,8 @@ const USAGE_MANY_SCRIPT = `for t in "$@"; do
|
|
|
44
45
|
du -skx -- "$t" 2>/dev/null | cut -f1
|
|
45
46
|
find "$t" -xdev ! -type d -exec stat -c %Y {} + 2>/dev/null | awk 'BEGIN{m=0;n=0}{n++; if ($1>m) m=$1} END{print n, m}'
|
|
46
47
|
stat -c %Y -- "$t"
|
|
48
|
+
echo links
|
|
49
|
+
${LINKS_SCRIPT}
|
|
47
50
|
done
|
|
48
51
|
true`;
|
|
49
52
|
|
|
@@ -120,10 +123,18 @@ function splitRecords(output) {
|
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
function parseUsage(body) {
|
|
123
|
-
const [
|
|
126
|
+
const [head, links = ''] = body.split('\nlinks\n');
|
|
127
|
+
const [kib, counts = '', rootMtime] = head.trim().split('\n');
|
|
124
128
|
const [files, newest] = counts.trim().split(/\s+/).map(Number);
|
|
125
129
|
const bytes = Number(kib) * 1024;
|
|
126
130
|
const newestSeconds = files > 0 ? newest : Number(rootMtime);
|
|
131
|
+
return withLinkAccounting(
|
|
132
|
+
usageFields(bytes, files, newestSeconds),
|
|
133
|
+
parseLinks(links)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function usageFields(bytes, files, newestSeconds) {
|
|
127
138
|
return {
|
|
128
139
|
bytes: Number.isFinite(bytes) ? bytes : 0,
|
|
129
140
|
apparentBytes: Number.isFinite(bytes) ? bytes : 0,
|
|
@@ -292,7 +303,7 @@ export class ShellEnv {
|
|
|
292
303
|
const result = await this.executor.run([
|
|
293
304
|
'stat',
|
|
294
305
|
'-c',
|
|
295
|
-
`${STAT_FORMAT}|%d:%i:%u:%g:%f`,
|
|
306
|
+
`${STAT_FORMAT}|%d:%i:%u:%g:%f:%h`,
|
|
296
307
|
'--',
|
|
297
308
|
...batch,
|
|
298
309
|
]);
|
|
@@ -310,6 +321,7 @@ export class ShellEnv {
|
|
|
310
321
|
identity.length > 4
|
|
311
322
|
? Number.parseInt(identity[4], 16)
|
|
312
323
|
: undefined,
|
|
324
|
+
nlink: identity.length > 5 ? Number(identity[5]) : undefined,
|
|
313
325
|
});
|
|
314
326
|
}
|
|
315
327
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -9,6 +9,13 @@ export type Duration = number | string;
|
|
|
9
9
|
export interface ScanOptions {
|
|
10
10
|
/** Directories to search for projects (default: homes and tmp dirs). */
|
|
11
11
|
roots?: string[] | string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Project roots inside every scanned container (all Docker depths).
|
|
14
|
+
* Replaces the container defaults; without it containers search their
|
|
15
|
+
* homes, temporary directories, common workspace paths and every `roots`
|
|
16
|
+
* path that exists inside them.
|
|
17
|
+
*/
|
|
18
|
+
containerRoots?: string[] | string | null;
|
|
12
19
|
/** Directory depth for project search. */
|
|
13
20
|
maxDepth?: number;
|
|
14
21
|
/** Time budget per filesystem environment (`2m`); incomplete findings are kept. */
|
|
@@ -117,7 +124,15 @@ export interface Item {
|
|
|
117
124
|
description: string;
|
|
118
125
|
path: string | null;
|
|
119
126
|
paths: string[];
|
|
127
|
+
/**
|
|
128
|
+
* Physically reclaimable bytes: blocks of hard-linked files with a link
|
|
129
|
+
* outside the item are not counted.
|
|
130
|
+
*/
|
|
120
131
|
bytes: number;
|
|
132
|
+
/** Blocks reachable from the item (each inode once); set with sharedBytes. */
|
|
133
|
+
allocatedBytes?: number;
|
|
134
|
+
/** Blocks retained by hard links outside the item; present when non-zero. */
|
|
135
|
+
sharedBytes?: number;
|
|
121
136
|
/** Cached size is advisory; cleanup always measures again. */
|
|
122
137
|
sizeEstimated?: boolean;
|
|
123
138
|
/** Merged filesystem bytes, including image data, when measured in Docker. */
|
|
@@ -180,6 +195,8 @@ export interface EnvironmentDescriptor {
|
|
|
180
195
|
swappedBytes?: number;
|
|
181
196
|
/** Change since the earliest scan of the last week listing this process. */
|
|
182
197
|
growth?: { since: number; memoryBytes: number };
|
|
198
|
+
/** The dss process itself, never named as one to quit. */
|
|
199
|
+
self?: boolean;
|
|
183
200
|
}>;
|
|
184
201
|
/** Change since the earliest scan of the last week. */
|
|
185
202
|
growth?: { since: number; usedBytes: number } | null;
|
|
@@ -196,9 +213,32 @@ export interface EnvironmentDescriptor {
|
|
|
196
213
|
| 'budget-exceeded'
|
|
197
214
|
| 'error'
|
|
198
215
|
| null;
|
|
216
|
+
/** What `scanStatus: complete` covers in this environment. */
|
|
217
|
+
scope?: ScanScope | null;
|
|
199
218
|
layerSource?: string | null;
|
|
200
219
|
}
|
|
201
220
|
|
|
221
|
+
export interface ScanScope {
|
|
222
|
+
roots: Array<{
|
|
223
|
+
path: string;
|
|
224
|
+
source: 'roots' | 'container-roots' | 'home' | 'tmp' | 'container-default';
|
|
225
|
+
status:
|
|
226
|
+
| 'searched'
|
|
227
|
+
| 'partial'
|
|
228
|
+
| 'absent'
|
|
229
|
+
| 'inaccessible'
|
|
230
|
+
| 'pruned'
|
|
231
|
+
| 'excluded'
|
|
232
|
+
| 'not-searched'
|
|
233
|
+
| 'pending';
|
|
234
|
+
}>;
|
|
235
|
+
maxDepth: number;
|
|
236
|
+
exclude: string[];
|
|
237
|
+
/** Mount points never traversed (unselected, read-only or overlay). */
|
|
238
|
+
prunedMounts: string[];
|
|
239
|
+
note: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
202
242
|
export interface TierTotal {
|
|
203
243
|
items: number;
|
|
204
244
|
bytes: number;
|
|
@@ -258,6 +298,10 @@ export interface AuditEntry {
|
|
|
258
298
|
freedBytes: number;
|
|
259
299
|
/** True when authoritative before/after allocation could not be measured. */
|
|
260
300
|
freedEstimated?: boolean;
|
|
301
|
+
/** `'hardlink-aware-estimate'`: freedBytes is the link-aware selection estimate. */
|
|
302
|
+
freedAccounting?: 'hardlink-aware-estimate';
|
|
303
|
+
/** Blocks of deleted files that stay allocated through outside hard links. */
|
|
304
|
+
sharedBytes?: number;
|
|
261
305
|
durationMs: number;
|
|
262
306
|
backup?: string;
|
|
263
307
|
[key: string]: unknown;
|
|
@@ -288,6 +332,8 @@ export interface AuditLog {
|
|
|
288
332
|
plannedBytes: number;
|
|
289
333
|
environments: Record<string, EnvironmentTotal>;
|
|
290
334
|
file?: string;
|
|
335
|
+
/** Set while the disk was too full to write the log; deletion went on. */
|
|
336
|
+
auditError?: { message: string; code: string };
|
|
291
337
|
goal?: { freeBytes: number | null; untilPercent: number | null };
|
|
292
338
|
goalMet?: boolean;
|
|
293
339
|
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
|
|