@idl3/claude-control 0.4.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 "session:window.pane"
42
+ * @property {string} target "session:window.pane"
43
43
  * @property {string} windowName
44
44
  * @property {string} cwd
45
- * @property {number|null} procStartMs claude process start (ms epoch), or null
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
- const tmp = `${file}.tmp`;
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
- fs.writeFileSync(VAPID_PATH, JSON.stringify(generated, null, 2), { mode: 0o600 });
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
- fs.writeFileSync(SUBS_PATH, JSON.stringify(subscriptions, null, 2), { mode: 0o600 });
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
- * Reclaimable ("available") free memory in bytes.
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
- * `os.freemem()` on macOS counts only truly-free pages inactive, cached
19
- * (file-backed), speculative and purgeable memory are all reclaimable but
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 reclaimableFreeBytes() {
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 out = execSync('vm_stat', { encoding: 'utf8', timeout: 1000 });
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
- 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). Returns null on
50
- * other platforms or any failure (UI then hides the battery chip). `low` is a
51
- * UI hint: ≤20% and not charging.
52
- * ponytail: macOS-only via pmset; add upower/sysfs parsing if Linux is ever needed.
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 out = execSync('pmset -g batt', { encoding: 'utf8', timeout: 1000 });
58
- const onAc = /AC Power/.test(out);
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
- this._power = powerStatus();
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 (++this._powerTick % 5 === 0) this._power = powerStatus();
117
- const snap = this._compute();
118
- this._latest = snap;
119
- this.emit('sample', snap);
120
-
121
- if (snap.overLimit && !this._overLimit) {
122
- // Rising edge only.
123
- this._overLimit = true;
124
- this.emit('overlimit', snap);
125
- } else if (!snap.overLimit) {
126
- // Reset so we can emit again if it crosses again later.
127
- this._overLimit = false;
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
- const reclaimable = reclaimableFreeBytes();
154
- const freeMB = Math.round((reclaimable != null ? reclaimable : os.freemem()) / 1048576);
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 {