@cleocode/core 2026.10.6 → 2026.10.7
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/dist/build-provenance.json +2 -2
- package/dist/cloud/http.d.ts +8 -0
- package/dist/cloud/http.d.ts.map +1 -1
- package/dist/cloud/http.js +11 -0
- package/dist/cloud/http.js.map +1 -1
- package/dist/cloud/journal.d.ts +6 -0
- package/dist/cloud/journal.d.ts.map +1 -1
- package/dist/cloud/journal.js +1 -1
- package/dist/cloud/journal.js.map +1 -1
- package/dist/cloud/nexus-cloud-status.d.ts.map +1 -1
- package/dist/cloud/nexus-cloud-status.js +3 -1
- package/dist/cloud/nexus-cloud-status.js.map +1 -1
- package/dist/cloud/nexus-vault.d.ts +86 -1
- package/dist/cloud/nexus-vault.d.ts.map +1 -1
- package/dist/cloud/nexus-vault.js +875 -98
- package/dist/cloud/nexus-vault.js.map +1 -1
- package/dist/docs/doctor.d.ts +183 -0
- package/dist/docs/doctor.d.ts.map +1 -0
- package/dist/docs/doctor.js +537 -0
- package/dist/docs/doctor.js.map +1 -0
- package/dist/doctor/system-health.d.ts +189 -0
- package/dist/doctor/system-health.d.ts.map +1 -0
- package/dist/doctor/system-health.js +783 -0
- package/dist/doctor/system-health.js.map +1 -0
- package/dist/git/hooks-install.d.ts +17 -34
- package/dist/git/hooks-install.d.ts.map +1 -1
- package/dist/git/hooks-install.js +173 -70
- package/dist/git/hooks-install.js.map +1 -1
- package/dist/harness/ask-enforce.d.ts +43 -0
- package/dist/harness/ask-enforce.d.ts.map +1 -0
- package/dist/harness/ask-enforce.js +165 -0
- package/dist/harness/ask-enforce.js.map +1 -0
- package/dist/hooks.d.ts +8 -30
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +36 -116
- package/dist/hooks.js.map +1 -1
- package/dist/init.d.ts +2 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +5 -14
- package/dist/init.js.map +1 -1
- package/dist/injection.d.ts +2 -2
- package/dist/injection.d.ts.map +1 -1
- package/dist/injection.js +108 -10
- package/dist/injection.js.map +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/issue/create.d.ts +4 -0
- package/dist/issue/create.d.ts.map +1 -1
- package/dist/issue/create.js +12 -0
- package/dist/issue/create.js.map +1 -1
- package/dist/issue/index.d.ts +1 -1
- package/dist/issue/index.d.ts.map +1 -1
- package/dist/issue/index.js +1 -1
- package/dist/issue/index.js.map +1 -1
- package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
- package/dist/orchestrate/spawn-ops.js +8 -2
- package/dist/orchestrate/spawn-ops.js.map +1 -1
- package/dist/orchestration/lean-change.d.ts +15 -0
- package/dist/orchestration/lean-change.d.ts.map +1 -0
- package/dist/orchestration/lean-change.js +17 -0
- package/dist/orchestration/lean-change.js.map +1 -0
- package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
- package/dist/orchestration/spawn-prompt.js +6 -4
- package/dist/orchestration/spawn-prompt.js.map +1 -1
- package/dist/release/ci-evidence.d.ts +59 -0
- package/dist/release/ci-evidence.d.ts.map +1 -1
- package/dist/release/ci-evidence.js +150 -5
- package/dist/release/ci-evidence.js.map +1 -1
- package/dist/release/plan.d.ts.map +1 -1
- package/dist/release/plan.js +11 -2
- package/dist/release/plan.js.map +1 -1
- package/dist/release/pr-evidence.d.ts +15 -0
- package/dist/release/pr-evidence.d.ts.map +1 -1
- package/dist/release/pr-evidence.js +42 -1
- package/dist/release/pr-evidence.js.map +1 -1
- package/dist/release/reconcile.js +2 -2
- package/dist/release/reconcile.js.map +1 -1
- package/dist/release/verify-provenance.js +2 -2
- package/dist/release/verify-provenance.js.map +1 -1
- package/dist/resources/admission-ledger.d.ts +8 -0
- package/dist/resources/admission-ledger.d.ts.map +1 -1
- package/dist/resources/admission-ledger.js +5 -0
- package/dist/resources/admission-ledger.js.map +1 -1
- package/dist/resources/claim-keepalive.d.ts +34 -0
- package/dist/resources/claim-keepalive.d.ts.map +1 -0
- package/dist/resources/claim-keepalive.js +49 -0
- package/dist/resources/claim-keepalive.js.map +1 -0
- package/dist/resources/governor.d.ts.map +1 -1
- package/dist/resources/governor.js +3 -0
- package/dist/resources/governor.js.map +1 -1
- package/dist/scaffold/ensure-config.js +1 -1
- package/dist/scaffold/ensure-config.js.map +1 -1
- package/dist/store/attachment-store.d.ts.map +1 -1
- package/dist/store/attachment-store.js +2 -1
- package/dist/store/attachment-store.js.map +1 -1
- package/dist/store/restore-marker.d.ts +50 -7
- package/dist/store/restore-marker.d.ts.map +1 -1
- package/dist/store/restore-marker.js +77 -25
- package/dist/store/restore-marker.js.map +1 -1
- package/dist/store/row-identity-registry.d.ts +6 -4
- package/dist/store/row-identity-registry.d.ts.map +1 -1
- package/dist/store/row-identity-registry.js +156 -33
- package/dist/store/row-identity-registry.js.map +1 -1
- package/dist/store/schema/cleo-project/provenance-orphans.d.ts +16 -0
- package/dist/store/schema/cleo-project/provenance-orphans.d.ts.map +1 -1
- package/dist/store/schema/cleo-project/provenance-orphans.js +4 -1
- package/dist/store/schema/cleo-project/provenance-orphans.js.map +1 -1
- package/dist/store/schema/cleo-shared/brain.d.ts +240 -0
- package/dist/store/schema/cleo-shared/brain.d.ts.map +1 -1
- package/dist/store/schema/cleo-shared/brain.js +69 -0
- package/dist/store/schema/cleo-shared/brain.js.map +1 -1
- package/dist/store/schema/tasks.d.ts +1 -1
- package/dist/store/schema/tasks.d.ts.map +1 -1
- package/dist/store/schema/tasks.js +9 -1
- package/dist/store/schema/tasks.js.map +1 -1
- package/dist/store/sync/apply/applier.d.ts.map +1 -1
- package/dist/store/sync/apply/applier.js +85 -3
- package/dist/store/sync/apply/applier.js.map +1 -1
- package/dist/store/sync/apply/parent-delete.d.ts.map +1 -1
- package/dist/store/sync/apply/parent-delete.js +4 -0
- package/dist/store/sync/apply/parent-delete.js.map +1 -1
- package/dist/store/sync/capture.d.ts +8 -0
- package/dist/store/sync/capture.d.ts.map +1 -1
- package/dist/store/sync/capture.js +20 -0
- package/dist/store/sync/capture.js.map +1 -1
- package/dist/store/sync/clock-store.d.ts +21 -0
- package/dist/store/sync/clock-store.d.ts.map +1 -1
- package/dist/store/sync/clock-store.js +47 -0
- package/dist/store/sync/clock-store.js.map +1 -1
- package/dist/store/sync/conflicts.d.ts +13 -0
- package/dist/store/sync/conflicts.d.ts.map +1 -1
- package/dist/store/sync/conflicts.js +22 -0
- package/dist/store/sync/conflicts.js.map +1 -1
- package/dist/store/sync/genesis.d.ts +113 -12
- package/dist/store/sync/genesis.d.ts.map +1 -1
- package/dist/store/sync/genesis.js +325 -27
- package/dist/store/sync/genesis.js.map +1 -1
- package/dist/store/sync/merge/types.d.ts +8 -2
- package/dist/store/sync/merge/types.d.ts.map +1 -1
- package/dist/store/sync/pull.d.ts +192 -0
- package/dist/store/sync/pull.d.ts.map +1 -0
- package/dist/store/sync/pull.js +429 -0
- package/dist/store/sync/pull.js.map +1 -0
- package/dist/store/sync/push.d.ts +110 -0
- package/dist/store/sync/push.d.ts.map +1 -0
- package/dist/store/sync/push.js +172 -0
- package/dist/store/sync/push.js.map +1 -0
- package/dist/store/sync/replay-pin.d.ts +70 -0
- package/dist/store/sync/replay-pin.d.ts.map +1 -0
- package/dist/store/sync/replay-pin.js +172 -0
- package/dist/store/sync/replay-pin.js.map +1 -0
- package/dist/store/sync/sealer.d.ts.map +1 -1
- package/dist/store/sync/sealer.js +6 -2
- package/dist/store/sync/sealer.js.map +1 -1
- package/dist/store/sync/timestamp-canon.d.ts.map +1 -1
- package/dist/store/sync/timestamp-canon.js +15 -0
- package/dist/store/sync/timestamp-canon.js.map +1 -1
- package/dist/store/table-classification.d.ts.map +1 -1
- package/dist/store/table-classification.js +51 -15
- package/dist/store/table-classification.js.map +1 -1
- package/dist/store/tasks-schema.d.ts +1 -1
- package/dist/store/tasks-schema.d.ts.map +1 -1
- package/dist/store/tasks-schema.js +1 -1
- package/dist/store/tasks-schema.js.map +1 -1
- package/dist/store/twin-collapse.d.ts.map +1 -1
- package/dist/store/twin-collapse.js +5 -0
- package/dist/store/twin-collapse.js.map +1 -1
- package/dist/store/vault-manifest.d.ts +7 -0
- package/dist/store/vault-manifest.d.ts.map +1 -1
- package/dist/store/vault-manifest.js +4 -0
- package/dist/store/vault-manifest.js.map +1 -1
- package/dist/tasks/ac-table.d.ts +17 -0
- package/dist/tasks/ac-table.d.ts.map +1 -1
- package/dist/tasks/ac-table.js +49 -0
- package/dist/tasks/ac-table.js.map +1 -1
- package/dist/tasks/affected-scope.d.ts +52 -3
- package/dist/tasks/affected-scope.d.ts.map +1 -1
- package/dist/tasks/affected-scope.js +94 -3
- package/dist/tasks/affected-scope.js.map +1 -1
- package/dist/tasks/change-set.d.ts.map +1 -1
- package/dist/tasks/change-set.js +9 -5
- package/dist/tasks/change-set.js.map +1 -1
- package/dist/tasks/complete-ci.d.ts +2 -0
- package/dist/tasks/complete-ci.d.ts.map +1 -1
- package/dist/tasks/complete-ci.js +26 -7
- package/dist/tasks/complete-ci.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +10 -0
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/done-plan.d.ts.map +1 -1
- package/dist/tasks/done-plan.js +63 -6
- package/dist/tasks/done-plan.js.map +1 -1
- package/dist/tasks/evidence.d.ts +3 -0
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +106 -2
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/gate-preview.d.ts +19 -1
- package/dist/tasks/gate-preview.d.ts.map +1 -1
- package/dist/tasks/gate-preview.js +44 -2
- package/dist/tasks/gate-preview.js.map +1 -1
- package/dist/tasks/gate-runner.d.ts +16 -0
- package/dist/tasks/gate-runner.d.ts.map +1 -1
- package/dist/tasks/gate-runner.js +25 -2
- package/dist/tasks/gate-runner.js.map +1 -1
- package/dist/tasks/qa-run-binding.d.ts +88 -0
- package/dist/tasks/qa-run-binding.d.ts.map +1 -0
- package/dist/tasks/qa-run-binding.js +188 -0
- package/dist/tasks/qa-run-binding.js.map +1 -0
- package/dist/tasks/req.d.ts +50 -0
- package/dist/tasks/req.d.ts.map +1 -1
- package/dist/tasks/req.js +99 -1
- package/dist/tasks/req.js.map +1 -1
- package/dist/tasks/test-run-binding.d.ts +29 -0
- package/dist/tasks/test-run-binding.d.ts.map +1 -1
- package/dist/tasks/test-run-binding.js +47 -23
- package/dist/tasks/test-run-binding.js.map +1 -1
- package/dist/tasks/tool-resolver.d.ts +9 -0
- package/dist/tasks/tool-resolver.d.ts.map +1 -1
- package/dist/tasks/tool-resolver.js +14 -3
- package/dist/tasks/tool-resolver.js.map +1 -1
- package/dist/tasks/tool-semaphore.d.ts.map +1 -1
- package/dist/tasks/tool-semaphore.js +3 -0
- package/dist/tasks/tool-semaphore.js.map +1 -1
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +10 -1
- package/dist/upgrade.js.map +1 -1
- package/dist/validation/engine-ops.d.ts.map +1 -1
- package/dist/validation/engine-ops.js +14 -3
- package/dist/validation/engine-ops.js.map +1 -1
- package/migrations/drizzle-cleo-global/20261010130000_t12895-brain-natural-uids/migration.sql +28 -0
- package/migrations/drizzle-cleo-global/20261010140000_t12896-brain-autoinc-uids/migration.sql +54 -0
- package/migrations/drizzle-cleo-project/20261010130000_t12895-brain-natural-uids/migration.sql +36 -0
- package/migrations/drizzle-cleo-project/20261010140000_t12896-brain-autoinc-uids/migration.sql +58 -0
- package/migrations/sync-journal/20261008120000_t12343-pull-cursor/migration.sql +28 -0
- package/migrations/sync-journal/20261009120000_t13318-seen-floor/migration.sql +41 -0
- package/package.json +18 -18
- package/templates/CLEO-INJECTION.md +5 -3
- package/templates/CLEO-REFERENCE.md +1 -1
- package/templates/git-hooks/legacy-hashes.json +20 -0
- package/templates/git-hooks/pre-commit +1 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cleo doctor system`: what is hurting this machine, ranked, with the exact
|
|
3
|
+
* remedy for each finding (T13435).
|
|
4
|
+
*
|
|
5
|
+
* On 2026-10-10 one Mac ran at 24 of 25 GB swap with load 24: 49 playwright-mcp,
|
|
6
|
+
* 33 mcpvault and ~60 agentmbx stdio MCP servers (one set per harness session),
|
|
7
|
+
* 475 dangling docker volumes from throwaway Postgres runs, long-lived database
|
|
8
|
+
* containers, and full typechecks started outside `cleo run`. None of it was
|
|
9
|
+
* visible from one place. This module makes it visible.
|
|
10
|
+
*
|
|
11
|
+
* Two halves, so the analysis is testable on any platform:
|
|
12
|
+
* - {@link collectSystemSnapshot} reads the machine: one resource sample
|
|
13
|
+
* (reusing the governor's Darwin/Linux backends), one `ps`, the admission
|
|
14
|
+
* ledger, docker (each call time-boxed, skipped when absent) and the
|
|
15
|
+
* project's Spotlight/Time Machine state.
|
|
16
|
+
* - {@link assessSystemHealth} is pure: snapshot in, ranked report out.
|
|
17
|
+
*
|
|
18
|
+
* Read-only. It never signals a process. A remedy that deletes data or stops a
|
|
19
|
+
* process is marked `needsOwnerChoice` so the agent asks the owner first.
|
|
20
|
+
*
|
|
21
|
+
* @task T13435
|
|
22
|
+
* @epic T13434
|
|
23
|
+
*/
|
|
24
|
+
import { execFile } from 'node:child_process';
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { cpus, loadavg, totalmem } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { promisify } from 'node:util';
|
|
29
|
+
import { readLedger } from '../resources/admission-ledger.js';
|
|
30
|
+
import { auditMemoryGuard } from '../resources/memory-guard.js';
|
|
31
|
+
import { classifyPressure, defaultResourceBackend } from '../resources/monitor.js';
|
|
32
|
+
import { looksHeavy } from '../resources/run-class.js';
|
|
33
|
+
/** `[[dd-]hh:]mm:ss` (both BSD and procps `etime`) to seconds. */
|
|
34
|
+
export function parseEtime(etime) {
|
|
35
|
+
const [days, rest] = etime.includes('-') ? etime.split('-', 2) : ['0', etime];
|
|
36
|
+
const parts = (rest ?? '').split(':').map(Number);
|
|
37
|
+
while (parts.length < 3)
|
|
38
|
+
parts.unshift(0);
|
|
39
|
+
const [h, m, s] = parts;
|
|
40
|
+
const total = Number(days) * 86400 + h * 3600 + m * 60 + s;
|
|
41
|
+
return Number.isFinite(total) ? total : 0;
|
|
42
|
+
}
|
|
43
|
+
/** Parse `ps -A -o pid=,ppid=,pgid=,rss=,pcpu=,etime=,args=` output. */
|
|
44
|
+
export function parsePs(output) {
|
|
45
|
+
const rows = [];
|
|
46
|
+
for (const line of output.split('\n')) {
|
|
47
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(\S+)\s+(.+)$/.exec(line);
|
|
48
|
+
if (!m)
|
|
49
|
+
continue;
|
|
50
|
+
const args = m[7].trim();
|
|
51
|
+
rows.push({
|
|
52
|
+
pid: Number(m[1]),
|
|
53
|
+
ppid: Number(m[2]),
|
|
54
|
+
pgid: Number(m[3]),
|
|
55
|
+
rssBytes: Number(m[4]) * 1024,
|
|
56
|
+
pcpu: Number(m[5]),
|
|
57
|
+
elapsedSec: parseEtime(m[6]),
|
|
58
|
+
args,
|
|
59
|
+
argv: args.split(/\s+/),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return rows;
|
|
63
|
+
}
|
|
64
|
+
function basename(token) {
|
|
65
|
+
return token.split('/').pop() ?? token;
|
|
66
|
+
}
|
|
67
|
+
const INTERPRETERS = /^(node|nodejs|bun|deno|python3?)$/;
|
|
68
|
+
/**
|
|
69
|
+
* The command words with the interpreter dropped and the script's extension
|
|
70
|
+
* stripped: `node …/typescript/bin/tsc -b` → `['tsc', '-b']`.
|
|
71
|
+
*/
|
|
72
|
+
export function commandWords(argv) {
|
|
73
|
+
const words = withoutInterpreter(argv);
|
|
74
|
+
if (words[0] !== undefined)
|
|
75
|
+
words[0] = basename(words[0]).replace(/\.(m?js|cjs|ts)$/, '');
|
|
76
|
+
return words;
|
|
77
|
+
}
|
|
78
|
+
function withoutInterpreter(argv) {
|
|
79
|
+
const first = argv[0];
|
|
80
|
+
return argv.length > 1 && first !== undefined && INTERPRETERS.test(basename(first))
|
|
81
|
+
? argv.slice(1)
|
|
82
|
+
: [...argv];
|
|
83
|
+
}
|
|
84
|
+
function buildTree(rows) {
|
|
85
|
+
const byPid = new Map();
|
|
86
|
+
const children = new Map();
|
|
87
|
+
for (const r of rows) {
|
|
88
|
+
byPid.set(r.pid, r);
|
|
89
|
+
const list = children.get(r.ppid) ?? [];
|
|
90
|
+
list.push(r);
|
|
91
|
+
children.set(r.ppid, list);
|
|
92
|
+
}
|
|
93
|
+
return { byPid, children };
|
|
94
|
+
}
|
|
95
|
+
/** Ancestors of `row`, nearest first (cycle-safe, stops at pid 1). */
|
|
96
|
+
function ancestors(tree, row) {
|
|
97
|
+
const out = [];
|
|
98
|
+
const seen = new Set([row.pid]);
|
|
99
|
+
let cur = tree.byPid.get(row.ppid);
|
|
100
|
+
while (cur && !seen.has(cur.pid) && cur.pid > 1) {
|
|
101
|
+
out.push(cur);
|
|
102
|
+
seen.add(cur.pid);
|
|
103
|
+
cur = tree.byPid.get(cur.ppid);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/** `row` and every descendant (cycle-safe). */
|
|
108
|
+
function subtree(tree, row) {
|
|
109
|
+
const out = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
const stack = [row];
|
|
112
|
+
while (stack.length > 0) {
|
|
113
|
+
const cur = stack.pop();
|
|
114
|
+
if (seen.has(cur.pid))
|
|
115
|
+
continue;
|
|
116
|
+
seen.add(cur.pid);
|
|
117
|
+
out.push(cur);
|
|
118
|
+
stack.push(...(tree.children.get(cur.pid) ?? []));
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
const sumRss = (rows) => rows.reduce((n, r) => n + r.rssBytes, 0);
|
|
123
|
+
const MIB = 1024 * 1024;
|
|
124
|
+
const GIB = 1024 * MIB;
|
|
125
|
+
const mib = (bytes) => Math.round(bytes / MIB);
|
|
126
|
+
const gib = (bytes) => Math.round((bytes / GIB) * 10) / 10;
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Process classification
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
const HARNESSES = new Set(['claude', 'codex', 'opencode', 'kimi', 'kimi-code', 'gemini', 'aider']);
|
|
131
|
+
/** Harness binaries started as helpers or servers, not as agent sessions. */
|
|
132
|
+
const HARNESS_HELPER_ARGS = /--chrome-native-host|\bapp-server\b|\bserve\b|\bmcp\b/;
|
|
133
|
+
/** An interactive agent session (claude, codex, opencode, kimi, …). */
|
|
134
|
+
export function isHarnessSession(row) {
|
|
135
|
+
const words = commandWords(row.argv);
|
|
136
|
+
return HARNESSES.has(words[0] ?? '') && !HARNESS_HELPER_ARGS.test(row.args);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The MCP server a process runs, or `null` when it is not one. Recognised by
|
|
140
|
+
* `mcp` in the command or script name (`playwright-mcp`, `mcpvault`,
|
|
141
|
+
* `agentmbx mcp`, `railway mcp`, `…/axiom-qa-mcp/dist/server.js`).
|
|
142
|
+
*/
|
|
143
|
+
export function mcpServerName(row) {
|
|
144
|
+
const words = commandWords(row.argv);
|
|
145
|
+
// `npx -y @playwright/mcp@latest`, `uvx mcp-server-fetch`: the package names the server.
|
|
146
|
+
const pkg = launchedPackage(words);
|
|
147
|
+
if (pkg !== undefined) {
|
|
148
|
+
if (pkg === null)
|
|
149
|
+
return null;
|
|
150
|
+
// `npx -y agentmbx mcp`: an `mcp` subcommand marks a server too.
|
|
151
|
+
const next = words[words.findIndex((w) => w.startsWith(pkg)) + 1];
|
|
152
|
+
return /mcp/i.test(pkg) || next === 'mcp' ? pkg : null;
|
|
153
|
+
}
|
|
154
|
+
if (!withoutInterpreter(row.argv)
|
|
155
|
+
.slice(0, 3)
|
|
156
|
+
.some((w) => /mcp/i.test(w)))
|
|
157
|
+
return null;
|
|
158
|
+
// Harness self-invocations (`claude mcp serve`) are not per-session servers.
|
|
159
|
+
if (HARNESSES.has(words[0] ?? ''))
|
|
160
|
+
return null;
|
|
161
|
+
const first = words[0] ?? '';
|
|
162
|
+
// A generic entry file names nothing: use the package directory above it.
|
|
163
|
+
if (/^(index|server|main|cli|serve)$/.test(first)) {
|
|
164
|
+
const script = row.argv.find((w) => /\/(index|server|main|cli|serve)\.(m?js|cjs|ts)$/.test(w)) ?? '';
|
|
165
|
+
const dirs = script.split('/').slice(0, -1);
|
|
166
|
+
const pkg = [...dirs]
|
|
167
|
+
.reverse()
|
|
168
|
+
.find((d) => d !== '' && !/^(dist|build|bin|lib|src|\.)$/.test(d));
|
|
169
|
+
return pkg ?? first;
|
|
170
|
+
}
|
|
171
|
+
return first;
|
|
172
|
+
}
|
|
173
|
+
const LAUNCHERS = new Set(['npx', 'pnpx', 'bunx', 'uvx']);
|
|
174
|
+
const PM_LAUNCH = /^(npm|pnpm|yarn|bun)$/;
|
|
175
|
+
const PM_LAUNCH_SUB = new Set(['exec', 'dlx', 'x']);
|
|
176
|
+
/** Launcher flags that take a separate value. */
|
|
177
|
+
const LAUNCHER_VALUE_FLAGS = new Set([
|
|
178
|
+
'--package',
|
|
179
|
+
'-p',
|
|
180
|
+
'--from',
|
|
181
|
+
'--with',
|
|
182
|
+
'--python',
|
|
183
|
+
'--call',
|
|
184
|
+
'-c',
|
|
185
|
+
'--spec',
|
|
186
|
+
]);
|
|
187
|
+
/**
|
|
188
|
+
* The package a launcher runs (`npx -y @playwright/mcp@latest` → `@playwright/mcp`),
|
|
189
|
+
* `null` when a launcher names none, `undefined` when `words` is not a launcher.
|
|
190
|
+
* The scope is kept (`@a/mcp` and `@b/mcp` are different servers); the version is not.
|
|
191
|
+
*/
|
|
192
|
+
export function launchedPackage(words) {
|
|
193
|
+
const [head = '', sub = ''] = words;
|
|
194
|
+
let rest;
|
|
195
|
+
if (LAUNCHERS.has(head))
|
|
196
|
+
rest = words.slice(1);
|
|
197
|
+
else if (head === 'pipx' && sub === 'run')
|
|
198
|
+
rest = words.slice(2);
|
|
199
|
+
else if (PM_LAUNCH.test(head) && PM_LAUNCH_SUB.has(sub))
|
|
200
|
+
rest = words.slice(2);
|
|
201
|
+
else
|
|
202
|
+
return undefined;
|
|
203
|
+
let pkg;
|
|
204
|
+
for (let i = 0; i < rest.length; i++) {
|
|
205
|
+
const w = rest[i];
|
|
206
|
+
if (w === '--')
|
|
207
|
+
continue;
|
|
208
|
+
if (w.startsWith('-')) {
|
|
209
|
+
// `npx --package foo bin`, `uvx --from git+… srv`: the flag's value is not the server.
|
|
210
|
+
if (LAUNCHER_VALUE_FLAGS.has(w))
|
|
211
|
+
i++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
pkg = w;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (pkg === undefined)
|
|
218
|
+
return null;
|
|
219
|
+
return pkg.replace(/(.)@[^/]*$/, '$1').replace(/==.*$/, '');
|
|
220
|
+
}
|
|
221
|
+
/** Single-quote `value` for a POSIX shell. */
|
|
222
|
+
function shellQuote(value) {
|
|
223
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
224
|
+
}
|
|
225
|
+
/** Long-lived tool servers `looksHeavy` would otherwise count (`biome lsp-proxy`, `esbuild --service`). */
|
|
226
|
+
const SERVER_ARGS = /\blsp-proxy\b|__run_server|--stdio\b|\blsp\b|--watch\b|--service\b/;
|
|
227
|
+
/** Whether `row` is a governed `cleo run` (or the heavy-run script). */
|
|
228
|
+
function isGovernor(row) {
|
|
229
|
+
const words = commandWords(row.argv);
|
|
230
|
+
if ((words[0] === 'cleo' || words[0] === 'ct') && words[1] === 'run')
|
|
231
|
+
return true;
|
|
232
|
+
return row.args.includes('.cleo-heavy/run.sh');
|
|
233
|
+
}
|
|
234
|
+
function memoryFindings(s, rows) {
|
|
235
|
+
const out = [];
|
|
236
|
+
const top = [...rows]
|
|
237
|
+
.sort((a, b) => b.rssBytes - a.rssBytes)
|
|
238
|
+
.slice(0, 5)
|
|
239
|
+
.map((r) => `${commandWords(r.argv)[0] ?? '?'} pid ${r.pid} ${mib(r.rssBytes)} MiB`);
|
|
240
|
+
if (s.sample) {
|
|
241
|
+
const { state, reason } = classifyPressure(s.sample);
|
|
242
|
+
if (state !== 'ok') {
|
|
243
|
+
out.push({
|
|
244
|
+
id: 'memory-pressure',
|
|
245
|
+
category: 'memory',
|
|
246
|
+
severity: state === 'backoff' ? 'critical' : 'warning',
|
|
247
|
+
title: `Memory pressure is ${state === 'backoff' ? 'severe' : 'elevated'}: cleo run is holding heavy jobs`,
|
|
248
|
+
evidence: { state, reason, topProcesses: top },
|
|
249
|
+
impactBytes: null,
|
|
250
|
+
remedy: {
|
|
251
|
+
command: null,
|
|
252
|
+
description: 'Free memory by acting on the findings below (idle sessions, MCP fan-out, ungoverned heavy jobs, containers), largest first.',
|
|
253
|
+
},
|
|
254
|
+
needsOwnerChoice: false,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const swap = s.sample?.darwinMemory?.swapTotalBytes != null && s.sample.darwinMemory.swapUsedBytes != null
|
|
259
|
+
? {
|
|
260
|
+
usedBytes: s.sample.darwinMemory.swapUsedBytes,
|
|
261
|
+
totalBytes: s.sample.darwinMemory.swapTotalBytes,
|
|
262
|
+
}
|
|
263
|
+
: s.linuxSwap;
|
|
264
|
+
if (swap && swap.totalBytes > 0) {
|
|
265
|
+
const ratio = swap.usedBytes / swap.totalBytes;
|
|
266
|
+
// macOS grows swap on demand, so also weigh swap against RAM.
|
|
267
|
+
const ofRam = swap.usedBytes / s.totalMemBytes;
|
|
268
|
+
if (ratio >= 0.6 || ofRam >= 0.25) {
|
|
269
|
+
out.push({
|
|
270
|
+
id: 'swap',
|
|
271
|
+
category: 'memory',
|
|
272
|
+
severity: ratio >= 0.9 || ofRam >= 0.5 ? 'critical' : 'warning',
|
|
273
|
+
title: `Swap is ${Math.round(ratio * 100)}% used (${gib(swap.usedBytes)} of ${gib(swap.totalBytes)} GiB)`,
|
|
274
|
+
evidence: {
|
|
275
|
+
swapUsedGib: gib(swap.usedBytes),
|
|
276
|
+
swapTotalGib: gib(swap.totalBytes),
|
|
277
|
+
swapOfRamPercent: Math.round(ofRam * 100),
|
|
278
|
+
topProcesses: top,
|
|
279
|
+
},
|
|
280
|
+
impactBytes: swap.usedBytes,
|
|
281
|
+
remedy: {
|
|
282
|
+
command: null,
|
|
283
|
+
description: 'Swap drains only when resident memory falls: close what the findings below name. A reboot clears it but ends every session.',
|
|
284
|
+
},
|
|
285
|
+
needsOwnerChoice: false,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const perCore = s.cpuCount > 0 ? s.loadAvg1 / s.cpuCount : 0;
|
|
290
|
+
if (perCore >= 1.5) {
|
|
291
|
+
out.push({
|
|
292
|
+
id: 'cpu-load',
|
|
293
|
+
category: 'memory',
|
|
294
|
+
severity: perCore >= 3 ? 'critical' : 'warning',
|
|
295
|
+
title: `Load ${s.loadAvg1.toFixed(1)} on ${s.cpuCount} cores (${perCore.toFixed(1)} per core)`,
|
|
296
|
+
evidence: { loadAvg1: Math.round(s.loadAvg1 * 10) / 10, cpuCount: s.cpuCount },
|
|
297
|
+
impactBytes: null,
|
|
298
|
+
remedy: {
|
|
299
|
+
command: null,
|
|
300
|
+
description: 'Run heavy work one at a time through `cleo run --wait`; see ungoverned heavy jobs below.',
|
|
301
|
+
},
|
|
302
|
+
needsOwnerChoice: false,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
/** Servers with at least this many copies are flagged. */
|
|
308
|
+
const MCP_FANOUT_WARN = 6;
|
|
309
|
+
function mcpFindings(tree, rows) {
|
|
310
|
+
const groups = new Map();
|
|
311
|
+
for (const r of rows) {
|
|
312
|
+
const name = mcpServerName(r);
|
|
313
|
+
if (name === null)
|
|
314
|
+
continue;
|
|
315
|
+
// Count a wrapper (`npm exec` → `node … mcp`) once: skip a server under another server.
|
|
316
|
+
const parent = tree.byPid.get(r.ppid);
|
|
317
|
+
if (parent && mcpServerName(parent) !== null)
|
|
318
|
+
continue;
|
|
319
|
+
const g = groups.get(name) ?? { count: 0, rss: 0, pids: [] };
|
|
320
|
+
g.count++;
|
|
321
|
+
g.rss += sumRss(subtree(tree, r));
|
|
322
|
+
if (g.pids.length < 10)
|
|
323
|
+
g.pids.push(r.pid);
|
|
324
|
+
groups.set(name, g);
|
|
325
|
+
}
|
|
326
|
+
const out = [];
|
|
327
|
+
for (const [name, g] of groups) {
|
|
328
|
+
if (g.count < MCP_FANOUT_WARN)
|
|
329
|
+
continue;
|
|
330
|
+
out.push({
|
|
331
|
+
id: `mcp-fanout:${name}`,
|
|
332
|
+
category: 'mcp-fanout',
|
|
333
|
+
severity: g.count >= 20 || g.rss >= 2 * GIB ? 'warning' : 'info',
|
|
334
|
+
title: `${g.count} copies of MCP server ${name} (${mib(g.rss)} MiB): one per harness session`,
|
|
335
|
+
evidence: {
|
|
336
|
+
server: name,
|
|
337
|
+
processes: g.count,
|
|
338
|
+
rssMib: mib(g.rss),
|
|
339
|
+
samplePids: g.pids.map(String),
|
|
340
|
+
},
|
|
341
|
+
impactBytes: g.rss,
|
|
342
|
+
remedy: {
|
|
343
|
+
command: `claude mcp remove ${name} --scope user`,
|
|
344
|
+
description: `Each harness session starts its own stdio copy. If ${name} is not needed in every session, ` +
|
|
345
|
+
'remove it from user scope and add it to only the projects that use it (`claude mcp add … --scope project`); ' +
|
|
346
|
+
'the name may differ in the harness config (`claude mcp list`). Restarting sessions applies it. ' +
|
|
347
|
+
'Servers with a shared-daemon mode should use one daemon per machine instead.',
|
|
348
|
+
},
|
|
349
|
+
needsOwnerChoice: true,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
function heavyFindings(s, tree, rows) {
|
|
355
|
+
const governedPgids = new Set(s.governedPgids);
|
|
356
|
+
const governedPids = new Set(s.governedPids);
|
|
357
|
+
const isHeavy = (r) => !SERVER_ARGS.test(r.args) && looksHeavy(commandWords(r.argv));
|
|
358
|
+
const ungoverned = rows.filter((r) => {
|
|
359
|
+
if (!isHeavy(r) || governedPgids.has(r.pgid) || governedPids.has(r.pid))
|
|
360
|
+
return false;
|
|
361
|
+
const up = ancestors(tree, r);
|
|
362
|
+
if (up.some((a) => isGovernor(a) || governedPids.has(a.pid)))
|
|
363
|
+
return false;
|
|
364
|
+
// Report the root of a heavy tree once (pnpm → vitest → workers).
|
|
365
|
+
return !up.some(isHeavy);
|
|
366
|
+
});
|
|
367
|
+
return ungoverned.map((r) => {
|
|
368
|
+
const rss = sumRss(subtree(tree, r));
|
|
369
|
+
return {
|
|
370
|
+
id: `heavy-ungoverned:${r.pid}`,
|
|
371
|
+
category: 'heavy-ungoverned',
|
|
372
|
+
severity: rss >= 4 * GIB ? 'critical' : 'warning',
|
|
373
|
+
title: `Heavy job outside cleo run: ${commandWords(r.argv).slice(0, 4).join(' ')} (${mib(rss)} MiB)`,
|
|
374
|
+
evidence: {
|
|
375
|
+
pid: r.pid,
|
|
376
|
+
command: r.args.slice(0, 300),
|
|
377
|
+
rssMib: mib(rss),
|
|
378
|
+
elapsedSec: r.elapsedSec,
|
|
379
|
+
},
|
|
380
|
+
impactBytes: rss,
|
|
381
|
+
remedy: {
|
|
382
|
+
command: 'cleo doctor heavy-command-hook --fix',
|
|
383
|
+
description: 'Runs in the project that started it: installs the hook that routes agent-run tests, builds and typechecks through ' +
|
|
384
|
+
'`cleo run --wait`. Re-run the job as `cleo run --wait --class <test|build|full-build> -- <cmd>`. ' +
|
|
385
|
+
'Stopping the running job is the owner’s call.',
|
|
386
|
+
},
|
|
387
|
+
needsOwnerChoice: false,
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const DB_IMAGES = /^(postgres|postgis|timescaledb|mysql|mariadb|redis|valkey|mongo|clickhouse)/;
|
|
392
|
+
/** A database container not managed by compose and up this long is probably a forgotten throwaway. */
|
|
393
|
+
const THROWAWAY_AGE_SEC = 12 * 3600;
|
|
394
|
+
/** `2026-10-10 10:23:54 -0700 PDT` (docker CreatedAt) to epoch ms, `NaN` when unparseable. */
|
|
395
|
+
export function parseDockerCreatedAt(value) {
|
|
396
|
+
const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-]\d{2})(\d{2})/.exec(value.trim());
|
|
397
|
+
return m ? Date.parse(`${m[1]}T${m[2]}${m[3]}:${m[4]}`) : Number.NaN;
|
|
398
|
+
}
|
|
399
|
+
/** Docker's human sizes (`52.3GB`, `16.38kB`, `0B`) to bytes. */
|
|
400
|
+
export function parseDockerSize(value) {
|
|
401
|
+
const m = /^([\d.]+)\s*([kMGT]?B)/.exec(value.trim());
|
|
402
|
+
if (!m)
|
|
403
|
+
return 0;
|
|
404
|
+
const unit = { B: 1, kB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12 }[m[2]] ?? 1;
|
|
405
|
+
return Math.round(Number(m[1]) * unit);
|
|
406
|
+
}
|
|
407
|
+
function containerFindings(s, docker) {
|
|
408
|
+
const out = [];
|
|
409
|
+
const dangling = (docker.danglingVolumes ?? '').split('\n').filter((l) => l.trim() !== '');
|
|
410
|
+
const df = new Map();
|
|
411
|
+
for (const line of (docker.systemDf ?? '').split('\n')) {
|
|
412
|
+
try {
|
|
413
|
+
const row = JSON.parse(line);
|
|
414
|
+
if (row.Type)
|
|
415
|
+
df.set(row.Type, {
|
|
416
|
+
reclaimable: parseDockerSize(row.Reclaimable ?? '0B'),
|
|
417
|
+
size: parseDockerSize(row.Size ?? '0B'),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
// not a JSON line
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const volumes = df.get('Local Volumes');
|
|
425
|
+
if (dangling.length >= 10 || (volumes?.reclaimable ?? 0) >= 5e9) {
|
|
426
|
+
out.push({
|
|
427
|
+
id: 'docker-dangling-volumes',
|
|
428
|
+
category: 'containers',
|
|
429
|
+
severity: dangling.length >= 100 || (volumes?.reclaimable ?? 0) >= 20e9 ? 'warning' : 'info',
|
|
430
|
+
title: `${dangling.length} anonymous dangling docker volumes (${gib(volumes?.reclaimable ?? 0)} GB reclaimable)`,
|
|
431
|
+
evidence: {
|
|
432
|
+
danglingVolumes: dangling.length,
|
|
433
|
+
reclaimableBytes: volumes?.reclaimable ?? null,
|
|
434
|
+
},
|
|
435
|
+
impactBytes: null,
|
|
436
|
+
remedy: {
|
|
437
|
+
command: 'docker volume prune --force',
|
|
438
|
+
description: 'Removes anonymous volumes no container uses (Docker 23+ keeps named volumes unless `--all`). ' +
|
|
439
|
+
'Their data is gone. Throwaway runs should use `docker run --rm` so none are left.',
|
|
440
|
+
},
|
|
441
|
+
needsOwnerChoice: true,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const cache = df.get('Build Cache');
|
|
445
|
+
if ((cache?.reclaimable ?? 0) >= 10e9) {
|
|
446
|
+
out.push({
|
|
447
|
+
id: 'docker-build-cache',
|
|
448
|
+
category: 'containers',
|
|
449
|
+
severity: 'info',
|
|
450
|
+
title: `${gib(cache?.reclaimable ?? 0)} GB of docker build cache is reclaimable`,
|
|
451
|
+
evidence: { reclaimableBytes: cache?.reclaimable ?? null },
|
|
452
|
+
impactBytes: null,
|
|
453
|
+
remedy: { command: 'docker builder prune --force', description: 'Next builds start cold.' },
|
|
454
|
+
needsOwnerChoice: true,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
const stale = [];
|
|
458
|
+
for (const line of (docker.containers ?? '').split('\n')) {
|
|
459
|
+
const [id, name, image, createdAt, labels = ''] = line.split('\t');
|
|
460
|
+
if (!id || !name || !image || !createdAt)
|
|
461
|
+
continue;
|
|
462
|
+
const repo = basename(image.split(':')[0] ?? '');
|
|
463
|
+
if (!DB_IMAGES.test(repo) || labels.includes('com.docker.compose.project='))
|
|
464
|
+
continue;
|
|
465
|
+
const ageSec = (s.sampledAtMs - parseDockerCreatedAt(createdAt)) / 1000;
|
|
466
|
+
if (ageSec >= THROWAWAY_AGE_SEC)
|
|
467
|
+
stale.push(`${name} (${image}, created ${Math.round(ageSec / 3600)}h ago)`);
|
|
468
|
+
}
|
|
469
|
+
if (stale.length > 0) {
|
|
470
|
+
out.push({
|
|
471
|
+
id: 'docker-long-running-db',
|
|
472
|
+
category: 'containers',
|
|
473
|
+
severity: stale.length >= 5 ? 'warning' : 'info',
|
|
474
|
+
title: `${stale.length} running database containers outside compose, created over ${THROWAWAY_AGE_SEC / 3600}h ago`,
|
|
475
|
+
evidence: { containers: stale.slice(0, 20) },
|
|
476
|
+
impactBytes: null,
|
|
477
|
+
remedy: {
|
|
478
|
+
command: `docker rm -f ${stale.map((c) => c.split(' ')[0]).join(' ')}`,
|
|
479
|
+
description: 'Stops and removes them, and their anonymous volumes become dangling. Keep any that hold data someone still needs.',
|
|
480
|
+
},
|
|
481
|
+
needsOwnerChoice: true,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
/** A session using less CPU than this (percent) is reported idle. */
|
|
487
|
+
const IDLE_PCPU = 1;
|
|
488
|
+
function sessionFindings(tree, rows) {
|
|
489
|
+
const sessions = rows.filter((r) => isHarnessSession(r) && !ancestors(tree, r).some(isHarnessSession));
|
|
490
|
+
if (sessions.length === 0)
|
|
491
|
+
return [];
|
|
492
|
+
const measured = sessions.map((r) => {
|
|
493
|
+
const all = subtree(tree, r);
|
|
494
|
+
return {
|
|
495
|
+
row: r,
|
|
496
|
+
rss: sumRss(all),
|
|
497
|
+
pcpu: all.reduce((n, x) => n + x.pcpu, 0),
|
|
498
|
+
};
|
|
499
|
+
});
|
|
500
|
+
const idle = measured.filter((m) => m.pcpu < IDLE_PCPU);
|
|
501
|
+
const totalRss = measured.reduce((n, m) => n + m.rss, 0);
|
|
502
|
+
const idleRss = idle.reduce((n, m) => n + m.rss, 0);
|
|
503
|
+
const severity = idleRss >= 8 * GIB || idle.length >= 15 ? 'warning' : 'info';
|
|
504
|
+
return [
|
|
505
|
+
{
|
|
506
|
+
id: 'sessions',
|
|
507
|
+
category: 'sessions',
|
|
508
|
+
severity,
|
|
509
|
+
title: `${sessions.length} agent sessions (${gib(totalRss)} GiB with their MCP servers); ${idle.length} idle hold ${gib(idleRss)} GiB`,
|
|
510
|
+
evidence: {
|
|
511
|
+
sessions: sessions.length,
|
|
512
|
+
idle: idle.length,
|
|
513
|
+
totalRssMib: mib(totalRss),
|
|
514
|
+
idleRssMib: mib(idleRss),
|
|
515
|
+
idleSessions: idle
|
|
516
|
+
.sort((a, b) => b.rss - a.rss)
|
|
517
|
+
.slice(0, 15)
|
|
518
|
+
.map((m) => `${commandWords(m.row.argv)[0]} pid ${m.row.pid} up ${Math.round(m.row.elapsedSec / 3600)}h ${mib(m.rss)} MiB`),
|
|
519
|
+
},
|
|
520
|
+
impactBytes: idleRss,
|
|
521
|
+
remedy: {
|
|
522
|
+
command: null,
|
|
523
|
+
description: 'Close idle sessions the owner no longer needs from their own terminal or Orca pane: each one also frees its MCP servers. ' +
|
|
524
|
+
'Never kill them from another session.',
|
|
525
|
+
},
|
|
526
|
+
needsOwnerChoice: idle.length > 0,
|
|
527
|
+
},
|
|
528
|
+
];
|
|
529
|
+
}
|
|
530
|
+
function indexingFindings(rows, indexing) {
|
|
531
|
+
const out = [];
|
|
532
|
+
for (const r of rows) {
|
|
533
|
+
const name = commandWords(r.argv)[0] ?? '';
|
|
534
|
+
if (!/^(mds_stores|mdworker_shared|backupd|baloo_file|tracker-miner-fs-3?)$/.test(name))
|
|
535
|
+
continue;
|
|
536
|
+
if (r.pcpu < 50)
|
|
537
|
+
continue;
|
|
538
|
+
out.push({
|
|
539
|
+
id: `indexing-cpu:${name}:${r.pid}`,
|
|
540
|
+
category: 'indexing',
|
|
541
|
+
severity: 'warning',
|
|
542
|
+
title: `${name} is using ${Math.round(r.pcpu)}% CPU`,
|
|
543
|
+
evidence: { pid: r.pid, pcpu: r.pcpu, rssMib: mib(r.rssBytes) },
|
|
544
|
+
impactBytes: r.rssBytes,
|
|
545
|
+
remedy: {
|
|
546
|
+
command: null,
|
|
547
|
+
description: 'Indexing or backup is churning, usually over node_modules, build output or worktrees. Exclude code directories (see below).',
|
|
548
|
+
},
|
|
549
|
+
needsOwnerChoice: false,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
if (!indexing)
|
|
553
|
+
return out;
|
|
554
|
+
const nodeModules = join(indexing.projectRoot, 'node_modules');
|
|
555
|
+
if (indexing.timeMachine?.trimStart().startsWith('[Included]')) {
|
|
556
|
+
out.push({
|
|
557
|
+
id: 'time-machine-node-modules',
|
|
558
|
+
category: 'indexing',
|
|
559
|
+
severity: 'info',
|
|
560
|
+
title: 'Time Machine backs up this project’s node_modules',
|
|
561
|
+
evidence: { path: nodeModules },
|
|
562
|
+
impactBytes: null,
|
|
563
|
+
remedy: {
|
|
564
|
+
command: `tmutil addexclusion ${shellQuote(nodeModules)}`,
|
|
565
|
+
description: 'A sticky exclusion on the directory; it is reinstallable, so nothing of value is lost. ' +
|
|
566
|
+
'It changes the owner’s backup settings, so ask first.',
|
|
567
|
+
},
|
|
568
|
+
needsOwnerChoice: true,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
const count = Number((indexing.spotlightCount ?? '').trim());
|
|
572
|
+
if (Number.isFinite(count) && count > 0) {
|
|
573
|
+
out.push({
|
|
574
|
+
id: 'spotlight-node-modules',
|
|
575
|
+
category: 'indexing',
|
|
576
|
+
severity: 'info',
|
|
577
|
+
title: `Spotlight indexes this project’s node_modules (${count} package.json files indexed)`,
|
|
578
|
+
evidence: { path: nodeModules, indexedPackageJson: count },
|
|
579
|
+
impactBytes: null,
|
|
580
|
+
remedy: {
|
|
581
|
+
command: null,
|
|
582
|
+
description: `Add ${indexing.projectRoot} (or its parent code directory) under System Settings > Siri & Spotlight > Spotlight Privacy.`,
|
|
583
|
+
},
|
|
584
|
+
needsOwnerChoice: true,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
function memoryGuardFindings(audit) {
|
|
590
|
+
if (!audit.supported || audit.guarded)
|
|
591
|
+
return [];
|
|
592
|
+
return [
|
|
593
|
+
{
|
|
594
|
+
id: 'memory-guard',
|
|
595
|
+
category: 'memory-guard',
|
|
596
|
+
severity: 'warning',
|
|
597
|
+
title: 'No cgroup memory limit bounds agent-run test commands',
|
|
598
|
+
evidence: {
|
|
599
|
+
memoryHighGib: audit.memoryHighGib,
|
|
600
|
+
recommendedHighGib: audit.recommendedHighGib,
|
|
601
|
+
findings: audit.findings.filter((f) => f.severity !== 'ok').map((f) => f.summary),
|
|
602
|
+
},
|
|
603
|
+
impactBytes: null,
|
|
604
|
+
remedy: {
|
|
605
|
+
command: 'cleo doctor memory-guard --fix',
|
|
606
|
+
description: 'Applies MemoryHigh/MemoryMax to app.slice: a systemd drop-in that affects the whole desktop session.',
|
|
607
|
+
},
|
|
608
|
+
needsOwnerChoice: true,
|
|
609
|
+
},
|
|
610
|
+
];
|
|
611
|
+
}
|
|
612
|
+
const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
|
|
613
|
+
/**
|
|
614
|
+
* Turn a snapshot into a ranked report. Pure: no I/O.
|
|
615
|
+
*
|
|
616
|
+
* @param s - the raw machine state from {@link collectSystemSnapshot}.
|
|
617
|
+
* @returns findings worst first, plus which checks ran.
|
|
618
|
+
*
|
|
619
|
+
* @example
|
|
620
|
+
* ```ts
|
|
621
|
+
* const report = assessSystemHealth(await collectSystemSnapshot({ projectRoot }));
|
|
622
|
+
* for (const f of report.findings) console.error(f.severity, f.title, f.remedy?.command);
|
|
623
|
+
* ```
|
|
624
|
+
*
|
|
625
|
+
* @task T13435
|
|
626
|
+
*/
|
|
627
|
+
export function assessSystemHealth(s) {
|
|
628
|
+
const coverage = [];
|
|
629
|
+
const findings = [];
|
|
630
|
+
const rows = s.ps === null ? [] : parsePs(s.ps);
|
|
631
|
+
const tree = buildTree(rows);
|
|
632
|
+
const psCoverage = (check) => s.ps === null ? { check, status: 'error', reason: 'ps failed' } : { check, status: 'ok' };
|
|
633
|
+
findings.push(...memoryFindings(s, rows));
|
|
634
|
+
coverage.push(s.sample === null
|
|
635
|
+
? {
|
|
636
|
+
check: 'memory',
|
|
637
|
+
status: 'error',
|
|
638
|
+
reason: 'resource sample unavailable; swap and load only',
|
|
639
|
+
}
|
|
640
|
+
: { check: 'memory', status: 'ok' });
|
|
641
|
+
if (s.ps !== null) {
|
|
642
|
+
findings.push(...mcpFindings(tree, rows), ...heavyFindings(s, tree, rows), ...sessionFindings(tree, rows));
|
|
643
|
+
}
|
|
644
|
+
coverage.push(psCoverage('mcp-fanout'), psCoverage('heavy-ungoverned'), psCoverage('sessions'));
|
|
645
|
+
if (s.docker === null) {
|
|
646
|
+
coverage.push({
|
|
647
|
+
check: 'containers',
|
|
648
|
+
status: 'skipped',
|
|
649
|
+
reason: 'docker not installed or not running',
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
findings.push(...containerFindings(s, s.docker));
|
|
654
|
+
coverage.push({ check: 'containers', status: 'ok' });
|
|
655
|
+
}
|
|
656
|
+
findings.push(...indexingFindings(rows, s.indexing));
|
|
657
|
+
coverage.push(s.indexing === null
|
|
658
|
+
? {
|
|
659
|
+
check: 'indexing',
|
|
660
|
+
status: 'skipped',
|
|
661
|
+
reason: s.platform === 'darwin'
|
|
662
|
+
? 'no project root'
|
|
663
|
+
: 'Spotlight/Time Machine are macOS-only; indexer CPU still checked',
|
|
664
|
+
}
|
|
665
|
+
: { check: 'indexing', status: 'ok' });
|
|
666
|
+
if (s.memoryGuard?.supported) {
|
|
667
|
+
findings.push(...memoryGuardFindings(s.memoryGuard));
|
|
668
|
+
coverage.push({ check: 'memory-guard', status: 'ok' });
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
coverage.push({ check: 'memory-guard', status: 'skipped', reason: 'Linux cgroup v2 only' });
|
|
672
|
+
}
|
|
673
|
+
findings.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
|
|
674
|
+
(b.impactBytes ?? 0) - (a.impactBytes ?? 0));
|
|
675
|
+
const count = (sev) => findings.filter((f) => f.severity === sev).length;
|
|
676
|
+
return {
|
|
677
|
+
platform: s.platform,
|
|
678
|
+
sampledAt: new Date(s.sampledAtMs).toISOString(),
|
|
679
|
+
summary: { critical: count('critical'), warning: count('warning'), info: count('info') },
|
|
680
|
+
findings,
|
|
681
|
+
coverage,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
// ---------------------------------------------------------------------------
|
|
685
|
+
// Collection (I/O)
|
|
686
|
+
// ---------------------------------------------------------------------------
|
|
687
|
+
const execFileAsync = promisify(execFile);
|
|
688
|
+
/** Run a read-only command, time-boxed. `null` on any failure (absent, timeout, non-zero). */
|
|
689
|
+
async function run(cmd, args, timeoutMs = 5000) {
|
|
690
|
+
try {
|
|
691
|
+
const { stdout } = await execFileAsync(cmd, [...args], {
|
|
692
|
+
timeout: timeoutMs,
|
|
693
|
+
maxBuffer: 32 * MIB,
|
|
694
|
+
encoding: 'utf8',
|
|
695
|
+
});
|
|
696
|
+
return stdout;
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/** Linux swap from `/proc/meminfo`, `null` when unreadable. */
|
|
703
|
+
export function parseLinuxSwap(meminfo) {
|
|
704
|
+
const kb = (key) => {
|
|
705
|
+
const m = new RegExp(`^${key}:\\s+(\\d+)\\s+kB`, 'm').exec(meminfo);
|
|
706
|
+
return m ? Number(m[1]) * 1024 : null;
|
|
707
|
+
};
|
|
708
|
+
const total = kb('SwapTotal');
|
|
709
|
+
const free = kb('SwapFree');
|
|
710
|
+
return total === null || free === null ? null : { usedBytes: total - free, totalBytes: total };
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Read the machine for {@link assessSystemHealth}. Read-only, never throws:
|
|
714
|
+
* a failed source leaves its field `null` and the report marks the check.
|
|
715
|
+
*
|
|
716
|
+
* @task T13435
|
|
717
|
+
*/
|
|
718
|
+
export async function collectSystemSnapshot(opts) {
|
|
719
|
+
const platform = process.platform;
|
|
720
|
+
const sample = await defaultResourceBackend(platform)
|
|
721
|
+
.sample()
|
|
722
|
+
.catch(() => null);
|
|
723
|
+
let linuxSwap = null;
|
|
724
|
+
if (platform === 'linux') {
|
|
725
|
+
try {
|
|
726
|
+
linuxSwap = parseLinuxSwap(readFileSync('/proc/meminfo', 'utf8'));
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
linuxSwap = null;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
let governedPgids = [];
|
|
733
|
+
let governedPids = [];
|
|
734
|
+
try {
|
|
735
|
+
const ledger = readLedger();
|
|
736
|
+
governedPids = ledger.map((e) => e.pid);
|
|
737
|
+
governedPgids = ledger.flatMap((e) => [...e.toolGroups]);
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
// no ledger: every heavy job is judged by its ancestry alone
|
|
741
|
+
}
|
|
742
|
+
const [ps, danglingVolumes, systemDf, containers] = await Promise.all([
|
|
743
|
+
run('ps', ['-A', '-o', 'pid=,ppid=,pgid=,rss=,pcpu=,etime=,args=']),
|
|
744
|
+
run('docker', ['volume', 'ls', '-q', '-f', 'dangling=true']),
|
|
745
|
+
run('docker', ['system', 'df', '--format', '{{json .}}']),
|
|
746
|
+
run('docker', [
|
|
747
|
+
'ps',
|
|
748
|
+
'--format',
|
|
749
|
+
'{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.CreatedAt}}\t{{.Labels}}',
|
|
750
|
+
]),
|
|
751
|
+
]);
|
|
752
|
+
const docker = danglingVolumes === null && systemDf === null && containers === null
|
|
753
|
+
? null
|
|
754
|
+
: { danglingVolumes, systemDf, containers };
|
|
755
|
+
let indexing = null;
|
|
756
|
+
if (platform === 'darwin' && opts.projectRoot !== null) {
|
|
757
|
+
const nodeModules = join(opts.projectRoot, 'node_modules');
|
|
758
|
+
const present = existsSync(nodeModules);
|
|
759
|
+
const [timeMachine, spotlightCount] = present
|
|
760
|
+
? await Promise.all([
|
|
761
|
+
run('tmutil', ['isexcluded', nodeModules]),
|
|
762
|
+
run('mdfind', ['-onlyin', nodeModules, '-count', 'kMDItemFSName == "package.json"']),
|
|
763
|
+
])
|
|
764
|
+
: [null, null];
|
|
765
|
+
indexing = { projectRoot: opts.projectRoot, timeMachine, spotlightCount };
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
platform,
|
|
769
|
+
sampledAtMs: Date.now(),
|
|
770
|
+
totalMemBytes: totalmem(),
|
|
771
|
+
cpuCount: cpus().length,
|
|
772
|
+
loadAvg1: loadavg()[0] ?? 0,
|
|
773
|
+
sample,
|
|
774
|
+
linuxSwap,
|
|
775
|
+
ps,
|
|
776
|
+
governedPgids,
|
|
777
|
+
governedPids,
|
|
778
|
+
docker,
|
|
779
|
+
indexing,
|
|
780
|
+
memoryGuard: platform === 'linux' ? auditMemoryGuard() : null,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
//# sourceMappingURL=system-health.js.map
|