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/CHANGELOG.md +22 -0
- package/README.md +55 -3
- package/package.json +1 -1
- package/src/app-owners.js +87 -0
- package/src/audit.js +32 -1
- package/src/clean.js +24 -5
- package/src/cli.js +32 -3
- package/src/conditions.js +152 -0
- package/src/emergency.js +4 -4
- package/src/env/local.js +70 -7
- package/src/index.d.ts +58 -0
- package/src/index.js +1 -0
- package/src/items.js +3 -0
- package/src/liveness.js +182 -11
- package/src/report.js +123 -30
- package/src/rules/downloads.js +27 -0
- package/src/rules/other.js +9 -0
- package/src/scan.js +31 -1
- package/src/scanners/global.js +4 -0
- package/src/scanners/node-modules.js +89 -0
- package/src/scanners/projects.js +27 -2
- package/src/swap.js +9 -2
- package/src/usage.js +687 -0
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
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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,
|
|
241
|
+
* @param {{staleAgeMs: number, inactiveMs?: number, now?: () => number,
|
|
242
|
+
* refreshMs?: number}} options
|
|
202
243
|
*/
|
|
203
|
-
constructor(
|
|
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
|
|
409
|
-
if (!
|
|
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
|
|
721
|
+
const all = item.paths ?? [];
|
|
553
722
|
return (
|
|
554
|
-
this.installationBlocker(
|
|
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.
|
|
728
|
+
this.appUsage(all) ??
|
|
729
|
+
this.processUsage(checks.perPath ? [] : all) ??
|
|
559
730
|
this.executableUsage(checks.executables) ??
|
|
560
|
-
this.uncertainUsage(
|
|
731
|
+
this.uncertainUsage(all)
|
|
561
732
|
);
|
|
562
733
|
}
|
|
563
734
|
|