@idl3/claude-control 0.4.1 → 1.0.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/lib/auth.js +23 -2
- package/lib/config.js +2 -1
- package/lib/json-file.js +40 -0
- package/lib/match.js +78 -2
- package/lib/mlx.js +13 -0
- package/lib/pins.js +2 -3
- package/lib/push.js +3 -2
- package/lib/resources.js +112 -52
- package/lib/sessions.js +116 -3
- package/lib/shell.js +3 -1
- package/lib/subagents.js +7 -6
- package/lib/tmux.js +26 -7
- package/lib/transcribe.js +55 -24
- package/lib/ws-heartbeat.js +32 -0
- package/package.json +1 -1
- package/server.js +189 -78
- package/web/dist/assets/{core-BA72pMy-.js → core-CEtbx-dx.js} +1 -1
- package/web/dist/assets/index-CjJtW-Kv.css +1 -0
- package/web/dist/assets/index-DFru8Gzx.js +103 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CgHrw_VR.js +0 -103
- package/web/dist/assets/index-Dv9NwX8Q.css +0 -1
package/lib/auth.js
CHANGED
|
@@ -15,6 +15,27 @@
|
|
|
15
15
|
// header, so it keeps its own `?token=` mechanism (handled in server.js's
|
|
16
16
|
// /term/ branch, not here).
|
|
17
17
|
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Constant-time token equality. Digests both sides with SHA-256 before calling
|
|
22
|
+
* `crypto.timingSafeEqual` so the buffers are always the same length regardless
|
|
23
|
+
* of the candidate string (timingSafeEqual throws on length mismatch).
|
|
24
|
+
*
|
|
25
|
+
* Returns `false` — never throws — for null/undefined/empty candidates, which
|
|
26
|
+
* preserves the "open server when no token configured" contract used by every
|
|
27
|
+
* call site (they gate on `!configToken` before calling this).
|
|
28
|
+
*
|
|
29
|
+
* @param {string|null|undefined} candidate
|
|
30
|
+
* @param {string|null|undefined} expected
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function safeTokenEqual(candidate, expected) {
|
|
34
|
+
if (!candidate) return false;
|
|
35
|
+
const digest = (s) => crypto.createHash('sha256').update(String(s)).digest();
|
|
36
|
+
return crypto.timingSafeEqual(digest(candidate), digest(expected));
|
|
37
|
+
}
|
|
38
|
+
|
|
18
39
|
// A dedicated subprotocol label the client always offers alongside the token,
|
|
19
40
|
// so the server can select a non-secret protocol to echo back (some proxies /
|
|
20
41
|
// strict clients want a selection) without ever reflecting the raw token.
|
|
@@ -47,7 +68,7 @@ export function tokenFromRequest(req) {
|
|
|
47
68
|
*/
|
|
48
69
|
export function checkToken(req, configToken) {
|
|
49
70
|
if (!configToken) return true;
|
|
50
|
-
return tokenFromRequest(req)
|
|
71
|
+
return safeTokenEqual(tokenFromRequest(req), configToken);
|
|
51
72
|
}
|
|
52
73
|
|
|
53
74
|
/**
|
|
@@ -77,5 +98,5 @@ export function parseWsProtocols(headerValue) {
|
|
|
77
98
|
export function checkWsToken(req, configToken) {
|
|
78
99
|
if (!configToken) return true;
|
|
79
100
|
const offered = parseWsProtocols(req?.headers?.['sec-websocket-protocol']);
|
|
80
|
-
return offered.
|
|
101
|
+
return offered.some((o) => safeTokenEqual(o, configToken));
|
|
81
102
|
}
|
package/lib/config.js
CHANGED
|
@@ -23,6 +23,7 @@ import fs from 'node:fs';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import os from 'node:os';
|
|
25
25
|
import { detectMachine, recommendMlxModel, recommendClaudeModel } from './models.js';
|
|
26
|
+
import { writeJsonAtomic } from './json-file.js';
|
|
26
27
|
|
|
27
28
|
// Env lookup mirrors server.js: prefer CLAUDE_CONTROL_<X>, fall back to the
|
|
28
29
|
// legacy COCKPIT_<X> so existing launchers keep working.
|
|
@@ -241,6 +242,6 @@ export function writeConfig(partial = {}) {
|
|
|
241
242
|
|
|
242
243
|
const dir = dataDir();
|
|
243
244
|
fs.mkdirSync(dir, { recursive: true });
|
|
244
|
-
|
|
245
|
+
writeJsonAtomic(configPath(), next, { mode: 0o600 });
|
|
245
246
|
return next;
|
|
246
247
|
}
|
package/lib/json-file.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/json-file.js — atomic JSON file writes.
|
|
3
|
+
*
|
|
4
|
+
* writeJsonAtomic(filePath, obj, options?) serialises obj to JSON, writes it
|
|
5
|
+
* to a same-directory temp file, then renames the temp file over the
|
|
6
|
+
* destination. The rename is the commit point — a crash before the rename
|
|
7
|
+
* leaves the previous file intact; a crash after leaves the new file intact.
|
|
8
|
+
* A truncated in-progress write can never be observed by readers.
|
|
9
|
+
*
|
|
10
|
+
* The temp file is placed in the same directory as the destination so that
|
|
11
|
+
* the rename is guaranteed to be atomic (same filesystem, no cross-device
|
|
12
|
+
* move). On any error the temp file is unlinked before re-throwing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Write obj as pretty-printed JSON to filePath atomically.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} filePath - absolute or relative destination path
|
|
22
|
+
* @param {unknown} obj - value passed to JSON.stringify
|
|
23
|
+
* @param {{ mode?: number }} [options]
|
|
24
|
+
* @param {number} [options.mode=0o600] - file permission mode for the temp file
|
|
25
|
+
*/
|
|
26
|
+
export function writeJsonAtomic(filePath, obj, { mode = 0o600 } = {}) {
|
|
27
|
+
const dir = path.dirname(filePath);
|
|
28
|
+
const tmp = `${filePath}.tmp`;
|
|
29
|
+
try {
|
|
30
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode });
|
|
31
|
+
fs.renameSync(tmp, filePath);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
try {
|
|
34
|
+
fs.unlinkSync(tmp);
|
|
35
|
+
} catch {
|
|
36
|
+
// best-effort cleanup; ignore unlink errors
|
|
37
|
+
}
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/lib/match.js
CHANGED
|
@@ -39,10 +39,11 @@ export function parseEtime(etime) {
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* @typedef {Object} MatchPane
|
|
42
|
-
* @property {string} target
|
|
42
|
+
* @property {string} target "session:window.pane"
|
|
43
43
|
* @property {string} windowName
|
|
44
44
|
* @property {string} cwd
|
|
45
|
-
* @property {number|null} procStartMs
|
|
45
|
+
* @property {number|null} procStartMs claude process start (ms epoch), or null
|
|
46
|
+
* @property {string|null} [capturedText] recent visible text captured from the pane
|
|
46
47
|
*
|
|
47
48
|
* @typedef {Object} MatchCandidate
|
|
48
49
|
* @property {string} transcriptPath
|
|
@@ -52,8 +53,69 @@ export function parseEtime(etime) {
|
|
|
52
53
|
* @property {number|null} lastActivityMs
|
|
53
54
|
* @property {string|null} customTitle
|
|
54
55
|
* @property {string|null} aiTitle
|
|
56
|
+
* @property {string|null} [recentText] recent assistant message text from the transcript tail
|
|
55
57
|
*/
|
|
56
58
|
|
|
59
|
+
// Minimum number of word tokens that must overlap for the content-fingerprint
|
|
60
|
+
// tiebreak to fire a decision. Prevents noise from short/common words.
|
|
61
|
+
const FINGERPRINT_MIN_OVERLAP = 3;
|
|
62
|
+
|
|
63
|
+
// Self-heal thresholds — controls when a matcher-bound pane is re-bound to a
|
|
64
|
+
// better candidate during periodic re-verification (PLE-44). Both must be
|
|
65
|
+
// satisfied before a rebind fires.
|
|
66
|
+
//
|
|
67
|
+
// SELFHEAL_FLOOR — the CURRENT binding's score must be below this before
|
|
68
|
+
// a rebind is even considered. A clearly-good binding
|
|
69
|
+
// (score ≥ FLOOR) is left alone even if another candidate
|
|
70
|
+
// slightly outscores it.
|
|
71
|
+
//
|
|
72
|
+
// SELFHEAL_MARGIN — the BEST OTHER candidate's score minus the current score
|
|
73
|
+
// must be at least this margin. Near-ties never flip an
|
|
74
|
+
// existing binding. Must be larger than FINGERPRINT_MIN_OVERLAP
|
|
75
|
+
// so the tiebreak signal alone cannot trigger a self-heal.
|
|
76
|
+
export const SELFHEAL_FLOOR = 2; // current score must be < this
|
|
77
|
+
export const SELFHEAL_MARGIN = 6; // bestOther − current must be ≥ this
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide whether a matcher-bound pane should be re-bound to a different
|
|
81
|
+
* transcript during the self-heal pass.
|
|
82
|
+
*
|
|
83
|
+
* Conservative by design: both the floor AND the margin must be met before a
|
|
84
|
+
* rebind fires. A near-tie on the current binding must NEVER flip.
|
|
85
|
+
*
|
|
86
|
+
* @param {number} currentScore fingerprintScore of the current binding
|
|
87
|
+
* @param {number} bestOtherScore fingerprintScore of the best alternative
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function shouldRebind(currentScore, bestOtherScore) {
|
|
91
|
+
return currentScore < SELFHEAL_FLOOR && (bestOtherScore - currentScore) >= SELFHEAL_MARGIN;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Score how well a candidate's transcript text matches a pane's captured text.
|
|
96
|
+
* Returns the count of distinct word tokens present in both strings (case-folded,
|
|
97
|
+
* alpha-only, ≥4 chars). Returns 0 when either input is absent or empty.
|
|
98
|
+
*
|
|
99
|
+
* @param {string|null|undefined} paneText
|
|
100
|
+
* @param {string|null|undefined} candidateText
|
|
101
|
+
* @returns {number}
|
|
102
|
+
*/
|
|
103
|
+
export function fingerprintScore(paneText, candidateText) {
|
|
104
|
+
if (!paneText || !candidateText) return 0;
|
|
105
|
+
const tokenise = (s) => {
|
|
106
|
+
const tokens = new Set();
|
|
107
|
+
for (const m of s.matchAll(/[a-zA-Z]{4,}/g)) tokens.add(m[0].toLowerCase());
|
|
108
|
+
return tokens;
|
|
109
|
+
};
|
|
110
|
+
const paneTokens = tokenise(paneText);
|
|
111
|
+
if (paneTokens.size === 0) return 0;
|
|
112
|
+
let overlap = 0;
|
|
113
|
+
for (const m of candidateText.matchAll(/[a-zA-Z]{4,}/g)) {
|
|
114
|
+
if (paneTokens.has(m[0].toLowerCase())) overlap++;
|
|
115
|
+
}
|
|
116
|
+
return overlap;
|
|
117
|
+
}
|
|
118
|
+
|
|
57
119
|
/**
|
|
58
120
|
* Assign transcripts to panes 1:1.
|
|
59
121
|
*
|
|
@@ -128,6 +190,12 @@ export function assignTranscripts(panes, candidates, opts = {}) {
|
|
|
128
190
|
// transcript is born long ago but is the most-recently-active, so it must beat
|
|
129
191
|
// a freshly-BORN but stale sibling whose birth merely coincides with the
|
|
130
192
|
// resume time (the old "start-time grabs the wrong transcript" bug).
|
|
193
|
+
//
|
|
194
|
+
// Content-fingerprint tiebreak (PLE-41): when timing signals still cannot
|
|
195
|
+
// distinguish candidates (procStartMs unknown + activities within RECENCY_TIE_MS),
|
|
196
|
+
// compare word-token overlap between the pane's captured text and each
|
|
197
|
+
// candidate's recent transcript text. This is a NO-OP when either side lacks
|
|
198
|
+
// text data, preserving all existing behavior.
|
|
131
199
|
const prefer = (pane, c, best) => {
|
|
132
200
|
const ca = activity(c);
|
|
133
201
|
const ba = activity(best);
|
|
@@ -137,6 +205,14 @@ export function assignTranscripts(panes, candidates, opts = {}) {
|
|
|
137
205
|
const bd = Math.abs(best.birthtimeMs - pane.procStartMs);
|
|
138
206
|
if (cd !== bd) return cd < bd;
|
|
139
207
|
}
|
|
208
|
+
// Content-fingerprint tiebreak: only fires when both candidates carry
|
|
209
|
+
// recentText and the pane has capturedText, AND the scores differ by at
|
|
210
|
+
// least FINGERPRINT_MIN_OVERLAP (avoids flipping on trivial noise).
|
|
211
|
+
if (pane.capturedText && c.recentText && best.recentText) {
|
|
212
|
+
const cs = fingerprintScore(pane.capturedText, c.recentText);
|
|
213
|
+
const bs = fingerprintScore(pane.capturedText, best.recentText);
|
|
214
|
+
if (Math.abs(cs - bs) >= FINGERPRINT_MIN_OVERLAP) return cs > bs;
|
|
215
|
+
}
|
|
140
216
|
return ca > ba;
|
|
141
217
|
};
|
|
142
218
|
|
package/lib/mlx.js
CHANGED
|
@@ -70,6 +70,18 @@ let child = null;
|
|
|
70
70
|
let childModel = null; // model id the current child was spawned with
|
|
71
71
|
let idleTimer = null;
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Test-only helper: inject a fake child process and idle timer so shutdown()
|
|
75
|
+
* can be exercised without spawning a real Python server.
|
|
76
|
+
* @param {{ kill: Function, pid: number } | null} fakeChild
|
|
77
|
+
* @param {NodeJS.Timeout | null} [fakeTimer]
|
|
78
|
+
*/
|
|
79
|
+
export function _setChildForTest(fakeChild, fakeTimer = null) {
|
|
80
|
+
child = fakeChild;
|
|
81
|
+
childModel = fakeChild ? 'test-model' : null;
|
|
82
|
+
idleTimer = fakeTimer;
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
function bumpIdle() {
|
|
74
86
|
if (idleTimer) clearTimeout(idleTimer);
|
|
75
87
|
idleTimer = setTimeout(() => shutdown(), IDLE_MS);
|
|
@@ -169,6 +181,7 @@ function spawnServer(model, port) {
|
|
|
169
181
|
['-m', 'mlx_lm.server', '--model', model, '--host', '127.0.0.1', '--port', String(port)],
|
|
170
182
|
{ stdio: ['ignore', out, out], env: { ...process.env, HOME: os.homedir() } },
|
|
171
183
|
);
|
|
184
|
+
child.unref(); // don't let the sidecar pin the event loop; shutdown() is the real reap
|
|
172
185
|
childModel = model;
|
|
173
186
|
child.on('exit', () => { child = null; childModel = null; });
|
|
174
187
|
}
|
package/lib/pins.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
+
import { writeJsonAtomic } from './json-file.js';
|
|
15
16
|
|
|
16
17
|
/** Stable pin key for a pane/session: windowId.paneIndex. */
|
|
17
18
|
export function pinKey(windowId, paneIndex) {
|
|
@@ -33,9 +34,7 @@ export function loadPins(file) {
|
|
|
33
34
|
export function savePins(file, pins) {
|
|
34
35
|
const dir = path.dirname(file);
|
|
35
36
|
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
-
|
|
37
|
-
fs.writeFileSync(tmp, JSON.stringify(pins, null, 2), { mode: 0o600 });
|
|
38
|
-
fs.renameSync(tmp, file);
|
|
37
|
+
writeJsonAtomic(file, pins, { mode: 0o600 });
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
/**
|
package/lib/push.js
CHANGED
|
@@ -9,6 +9,7 @@ import fs from 'node:fs';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import webpush from 'web-push';
|
|
12
|
+
import { writeJsonAtomic } from './json-file.js';
|
|
12
13
|
|
|
13
14
|
const STORE_DIR = path.join(os.homedir(), '.claude-control');
|
|
14
15
|
const VAPID_PATH = path.join(STORE_DIR, 'vapid.json');
|
|
@@ -37,7 +38,7 @@ function loadOrCreateKeys() {
|
|
|
37
38
|
}
|
|
38
39
|
const generated = webpush.generateVAPIDKeys();
|
|
39
40
|
try {
|
|
40
|
-
|
|
41
|
+
writeJsonAtomic(VAPID_PATH, generated, { mode: 0o600 });
|
|
41
42
|
} catch (err) {
|
|
42
43
|
console.error('push: failed to persist VAPID keys:', err?.message || err);
|
|
43
44
|
}
|
|
@@ -58,7 +59,7 @@ function loadSubscriptions() {
|
|
|
58
59
|
function persistSubscriptions() {
|
|
59
60
|
try {
|
|
60
61
|
ensureStoreDir();
|
|
61
|
-
|
|
62
|
+
writeJsonAtomic(SUBS_PATH, subscriptions, { mode: 0o600 });
|
|
62
63
|
} catch (err) {
|
|
63
64
|
console.error('push: failed to persist subscriptions:', err?.message || err);
|
|
64
65
|
}
|
package/lib/resources.js
CHANGED
|
@@ -10,57 +10,90 @@
|
|
|
10
10
|
|
|
11
11
|
import { EventEmitter } from 'node:events';
|
|
12
12
|
import os from 'node:os';
|
|
13
|
-
import { execSync } from 'node:child_process';
|
|
13
|
+
import { execSync, execFile } from 'node:child_process';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
|
|
18
|
+
// ── Pure parsers (no exec — exported for unit testing) ───────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse `vm_stat` stdout into reclaimable bytes, or null on parse failure.
|
|
22
|
+
*
|
|
23
|
+
* Treats free + inactive + speculative + purgeable + file-backed pages as
|
|
24
|
+
* available (matches Activity Monitor / memory_pressure). Returns null if the
|
|
25
|
+
* output is structurally unparseable.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} out — raw stdout from `vm_stat`
|
|
28
|
+
* @returns {number|null}
|
|
29
|
+
*/
|
|
30
|
+
export function parseVmStat(out) {
|
|
31
|
+
const pageSize = Number((out.match(/page size of (\d+) bytes/) || [])[1]) || 4096;
|
|
32
|
+
const pages = (label) => {
|
|
33
|
+
const m = out.match(new RegExp(`${label}:\\s+(\\d+)\\.`));
|
|
34
|
+
return m ? Number(m[1]) : 0;
|
|
35
|
+
};
|
|
36
|
+
const available =
|
|
37
|
+
pages('Pages free') +
|
|
38
|
+
pages('Pages inactive') +
|
|
39
|
+
pages('Pages speculative') +
|
|
40
|
+
pages('Pages purgeable') +
|
|
41
|
+
pages('File-backed pages');
|
|
42
|
+
// If every field is zero and there's no page-size line, treat as bad output.
|
|
43
|
+
if (available === 0 && !out.includes('Pages free')) return null;
|
|
44
|
+
return available * pageSize;
|
|
45
|
+
}
|
|
14
46
|
|
|
15
47
|
/**
|
|
16
|
-
*
|
|
48
|
+
* Parse `pmset -g batt` stdout into a power-status object, or null on failure.
|
|
49
|
+
*
|
|
50
|
+
* `low` is a UI hint: ≤20% and not charging.
|
|
51
|
+
* ponytail: macOS-only via pmset; add upower/sysfs parsing if Linux is ever needed.
|
|
17
52
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* excluded, so memUsedPct computed from it pins near ~98% even when the
|
|
21
|
-
* machine is nowhere near pressure. On darwin we parse `vm_stat` and treat
|
|
22
|
-
* free + inactive + speculative + purgeable + file-backed as available
|
|
23
|
-
* (matching what `memory_pressure` / Activity Monitor report). Returns null
|
|
24
|
-
* on non-darwin or any parse/exec failure so the caller falls back to
|
|
25
|
-
* `os.freemem()`.
|
|
53
|
+
* @param {string} out — raw stdout from `pmset -g batt`
|
|
54
|
+
* @returns {{ hasBattery: boolean, percent?: number|null, charging?: boolean, low?: boolean }|null}
|
|
26
55
|
*/
|
|
27
|
-
function
|
|
56
|
+
export function parsePmset(out) {
|
|
57
|
+
const onAc = /AC Power/.test(out);
|
|
58
|
+
if (!/InternalBattery/.test(out)) return { hasBattery: false, charging: onAc };
|
|
59
|
+
const percent = Number((out.match(/(\d+)%/) || [])[1]);
|
|
60
|
+
const charging = onAc || /\bcharging\b/.test(out) || /\bcharged\b/.test(out);
|
|
61
|
+
const pct = Number.isFinite(percent) ? percent : null;
|
|
62
|
+
return { hasBattery: true, percent: pct, charging, low: pct != null && pct <= 20 && !charging };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Async exec wrappers (timer-path only) ────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reclaimable ("available") free memory in bytes, fetched asynchronously.
|
|
69
|
+
*
|
|
70
|
+
* Returns null on non-darwin or any exec/parse failure so the caller falls
|
|
71
|
+
* back to `os.freemem()`.
|
|
72
|
+
*
|
|
73
|
+
* @returns {Promise<number|null>}
|
|
74
|
+
*/
|
|
75
|
+
async function reclaimableFreeBytes() {
|
|
28
76
|
if (process.platform !== 'darwin') return null;
|
|
29
77
|
try {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const pages = (label) => {
|
|
33
|
-
const m = out.match(new RegExp(`${label}:\\s+(\\d+)\\.`));
|
|
34
|
-
return m ? Number(m[1]) : 0;
|
|
35
|
-
};
|
|
36
|
-
const available =
|
|
37
|
-
pages('Pages free') +
|
|
38
|
-
pages('Pages inactive') +
|
|
39
|
-
pages('Pages speculative') +
|
|
40
|
-
pages('Pages purgeable') +
|
|
41
|
-
pages('File-backed pages');
|
|
42
|
-
return available * pageSize;
|
|
78
|
+
const { stdout } = await execFileAsync('vm_stat', [], { encoding: 'utf8', timeout: 1000 });
|
|
79
|
+
return parseVmStat(stdout);
|
|
43
80
|
} catch {
|
|
44
81
|
return null;
|
|
45
82
|
}
|
|
46
83
|
}
|
|
47
84
|
|
|
48
85
|
/**
|
|
49
|
-
* Battery / power status via `pmset -g batt` (darwin only)
|
|
50
|
-
*
|
|
51
|
-
* UI
|
|
52
|
-
*
|
|
86
|
+
* Battery / power status via `pmset -g batt` (darwin only), fetched asynchronously.
|
|
87
|
+
*
|
|
88
|
+
* Returns null on other platforms or any failure (UI then hides the battery chip).
|
|
89
|
+
*
|
|
90
|
+
* @returns {Promise<{ hasBattery: boolean, percent?: number|null, charging?: boolean, low?: boolean }|null>}
|
|
53
91
|
*/
|
|
54
|
-
function powerStatus() {
|
|
92
|
+
async function powerStatus() {
|
|
55
93
|
if (process.platform !== 'darwin') return null;
|
|
56
94
|
try {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
if (!/InternalBattery/.test(out)) return { hasBattery: false, charging: onAc };
|
|
60
|
-
const percent = Number((out.match(/(\d+)%/) || [])[1]);
|
|
61
|
-
const charging = onAc || /\bcharging\b/.test(out) || /\bcharged\b/.test(out);
|
|
62
|
-
const pct = Number.isFinite(percent) ? percent : null;
|
|
63
|
-
return { hasBattery: true, percent: pct, charging, low: pct != null && pct <= 20 && !charging };
|
|
95
|
+
const { stdout } = await execFileAsync('pmset', ['-g', 'batt'], { encoding: 'utf8', timeout: 1000 });
|
|
96
|
+
return parsePmset(stdout);
|
|
64
97
|
} catch {
|
|
65
98
|
return null;
|
|
66
99
|
}
|
|
@@ -83,9 +116,17 @@ export class ResourceMonitor extends EventEmitter {
|
|
|
83
116
|
|
|
84
117
|
// Power is sampled less often than cpu/mem (pmset is a subprocess): refresh
|
|
85
118
|
// every 5th tick (~15s). Cache between refreshes.
|
|
86
|
-
|
|
119
|
+
// Starts as null; first real value arrives on the first tick.
|
|
120
|
+
this._power = null;
|
|
87
121
|
this._powerTick = 0;
|
|
88
122
|
|
|
123
|
+
// Reclaimable free-memory bytes: cached by the async tick. Starts as null
|
|
124
|
+
// so the first snapshot falls back to os.freemem() (same as non-darwin).
|
|
125
|
+
this._reclaimable = null;
|
|
126
|
+
|
|
127
|
+
// In-flight guard: prevents a slow tick from overlapping the next interval.
|
|
128
|
+
this._ticking = false;
|
|
129
|
+
|
|
89
130
|
// Compute an initial snapshot so snapshot() works before start().
|
|
90
131
|
this._latest = this._compute();
|
|
91
132
|
}
|
|
@@ -112,19 +153,35 @@ export class ResourceMonitor extends EventEmitter {
|
|
|
112
153
|
|
|
113
154
|
// ---- internals -------------------------------------------------------------
|
|
114
155
|
|
|
115
|
-
_tick() {
|
|
116
|
-
if
|
|
117
|
-
|
|
118
|
-
this.
|
|
119
|
-
this.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
this.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
156
|
+
async _tick() {
|
|
157
|
+
// Re-entrancy guard: if a previous tick is still awaiting subprocess results,
|
|
158
|
+
// skip this interval entirely rather than running two ticks in parallel.
|
|
159
|
+
if (this._ticking) return;
|
|
160
|
+
this._ticking = true;
|
|
161
|
+
try {
|
|
162
|
+
// Refresh power every 5th tick (~15s). Awaiting here is safe — the guard
|
|
163
|
+
// above prevents any overlap with the next scheduled interval.
|
|
164
|
+
if (++this._powerTick % 5 === 0) {
|
|
165
|
+
this._power = await powerStatus();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Refresh reclaimable memory bytes every tick (vm_stat is fast, ~5ms).
|
|
169
|
+
this._reclaimable = await reclaimableFreeBytes();
|
|
170
|
+
|
|
171
|
+
const snap = this._compute();
|
|
172
|
+
this._latest = snap;
|
|
173
|
+
this.emit('sample', snap);
|
|
174
|
+
|
|
175
|
+
if (snap.overLimit && !this._overLimit) {
|
|
176
|
+
// Rising edge only.
|
|
177
|
+
this._overLimit = true;
|
|
178
|
+
this.emit('overlimit', snap);
|
|
179
|
+
} else if (!snap.overLimit) {
|
|
180
|
+
// Reset so we can emit again if it crosses again later.
|
|
181
|
+
this._overLimit = false;
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
this._ticking = false;
|
|
128
185
|
}
|
|
129
186
|
}
|
|
130
187
|
|
|
@@ -150,8 +207,8 @@ export class ResourceMonitor extends EventEmitter {
|
|
|
150
207
|
const loadavg = /** @type {[number, number, number]} */ (os.loadavg());
|
|
151
208
|
const cpuCount = os.cpus().length;
|
|
152
209
|
const totalMB = Math.round(os.totalmem() / 1048576);
|
|
153
|
-
|
|
154
|
-
const freeMB = Math.round((
|
|
210
|
+
// Use the async-cached reclaimable value; falls back to os.freemem() when null.
|
|
211
|
+
const freeMB = Math.round((this._reclaimable != null ? this._reclaimable : os.freemem()) / 1048576);
|
|
155
212
|
const memUsedPct = Math.round(((totalMB - freeMB) / totalMB) * 100 * 10) / 10;
|
|
156
213
|
|
|
157
214
|
return {
|
|
@@ -167,6 +224,9 @@ export class ResourceMonitor extends EventEmitter {
|
|
|
167
224
|
/**
|
|
168
225
|
* Snapshot of the busiest processes via `ps`, newest BSD/macOS + Linux compatible
|
|
169
226
|
* flags. Returns up to `limit` rows sorted by CPU%. Best-effort: [] on failure.
|
|
227
|
+
*
|
|
228
|
+
* NOTE: This is request-driven (called per HTTP request), not timer-driven, so
|
|
229
|
+
* it intentionally stays synchronous. Do not convert to async.
|
|
170
230
|
*/
|
|
171
231
|
export function listProcesses(limit = 40) {
|
|
172
232
|
try {
|