apple-tools-mcp 1.1.3 → 1.2.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/config.js ADDED
@@ -0,0 +1,312 @@
1
+ /**
2
+ * User config for apple-tools-mcp.
3
+ *
4
+ * Path is fixed at ~/.apple-tools-mcp/config.json (or $HOME). Arbitrary paths
5
+ * from config contents are ignored — config is treated as data, not as
6
+ * instructions or path overrides.
7
+ *
8
+ * Interval precedence (highest wins):
9
+ * 1. INDEX_INTERVAL_MS environment variable
10
+ * 2. config.json `indexInterval` (or `indexIntervalMs`)
11
+ * 3. Product default (5 minutes)
12
+ *
13
+ * Values are clamped to [15s, 6h]. Human forms like `30s`, `1m`, `1h` are accepted.
14
+ */
15
+
16
+ import fs from "fs";
17
+ import os from "os";
18
+ import path from "path";
19
+
20
+ export const APPLE_TOOLS_DIR_NAME = ".apple-tools-mcp";
21
+ export const CONFIG_FILE_NAME = "config.json";
22
+
23
+ /** MacBook / MCP local-fallback default. */
24
+ export const DEFAULT_INDEX_INTERVAL_MS = 5 * 60 * 1000;
25
+
26
+ /** Documented floor: 15 seconds (30s remains allowed). */
27
+ export const MIN_INDEX_INTERVAL_MS = 15 * 1000;
28
+
29
+ /** Documented ceiling: 6 hours. */
30
+ export const MAX_INDEX_INTERVAL_MS = 6 * 60 * 60 * 1000;
31
+
32
+ /** Recommended Mini always-on value (set in config.json, not the product default). */
33
+ export const MINI_RECOMMENDED_INDEX_INTERVAL_MS = 60 * 1000;
34
+
35
+ const KNOWN_CONFIG_KEYS = new Set(["indexInterval", "indexIntervalMs"]);
36
+
37
+ const MAX_DURATION_STRING_LENGTH = 32;
38
+
39
+ function defaultWarn(message) {
40
+ console.error(message);
41
+ }
42
+
43
+ /**
44
+ * Directory that holds config.json, indexer.lock, and vector-index.
45
+ * Always under the user home directory — never a path from config contents.
46
+ *
47
+ * @param {{ env?: NodeJS.ProcessEnv, homedir?: () => string }} [options]
48
+ * @returns {string}
49
+ */
50
+ export function getAppleToolsDir(options = {}) {
51
+ const env = options.env || process.env;
52
+ const homedir = options.homedir || (() => os.homedir());
53
+ const home = env.HOME || homedir();
54
+ return path.join(home, APPLE_TOOLS_DIR_NAME);
55
+ }
56
+
57
+ /**
58
+ * @param {{ env?: NodeJS.ProcessEnv, homedir?: () => string }} [options]
59
+ * @returns {string}
60
+ */
61
+ export function getConfigPath(options = {}) {
62
+ return path.join(getAppleToolsDir(options), CONFIG_FILE_NAME);
63
+ }
64
+
65
+ /**
66
+ * Parse a millisecond count or human duration (`500ms`, `30s`, `1m`, `1h`).
67
+ * @param {unknown} value
68
+ * @returns {number|null} milliseconds, or null if unparseable
69
+ */
70
+ export function parseDuration(value) {
71
+ if (typeof value === "number") {
72
+ if (!Number.isFinite(value)) {
73
+ return null;
74
+ }
75
+ return value;
76
+ }
77
+ if (typeof value !== "string") {
78
+ return null;
79
+ }
80
+ const trimmed = value.trim();
81
+ if (trimmed.length === 0 || trimmed.length > MAX_DURATION_STRING_LENGTH) {
82
+ return null;
83
+ }
84
+ if (/^\d+$/.test(trimmed)) {
85
+ const n = Number(trimmed);
86
+ if (!Number.isSafeInteger(n)) {
87
+ return null;
88
+ }
89
+ return n;
90
+ }
91
+ const match = /^(\d+)(ms|s|m|h)$/i.exec(trimmed);
92
+ if (!match) {
93
+ return null;
94
+ }
95
+ const n = Number(match[1]);
96
+ if (!Number.isSafeInteger(n)) {
97
+ return null;
98
+ }
99
+ const unit = match[2].toLowerCase();
100
+ const multiplier = unit === "ms" ? 1
101
+ : unit === "s" ? 1000
102
+ : unit === "m" ? 60 * 1000
103
+ : 60 * 60 * 1000;
104
+ const result = n * multiplier;
105
+ if (!Number.isSafeInteger(result)) {
106
+ return null;
107
+ }
108
+ return result;
109
+ }
110
+
111
+ /**
112
+ * Compact human form for logs (`1m`, `30s`, `6h`, or `1500ms`).
113
+ * @param {number} ms
114
+ * @returns {string}
115
+ */
116
+ export function formatIntervalMs(ms) {
117
+ if (!Number.isFinite(ms)) {
118
+ return String(ms);
119
+ }
120
+ const rounded = Math.round(ms);
121
+ if (rounded % (60 * 60 * 1000) === 0) {
122
+ return `${rounded / (60 * 60 * 1000)}h`;
123
+ }
124
+ if (rounded % (60 * 1000) === 0) {
125
+ return `${rounded / (60 * 1000)}m`;
126
+ }
127
+ if (rounded % 1000 === 0) {
128
+ return `${rounded / 1000}s`;
129
+ }
130
+ return `${rounded}ms`;
131
+ }
132
+
133
+ /**
134
+ * Clamp a parsed millisecond value. Invalid input yields the default (not the min).
135
+ *
136
+ * @param {unknown} raw
137
+ * @param {{ defaultMs?: number, minMs?: number, maxMs?: number }} [bounds]
138
+ * @returns {{ ms: number, human: string, clamped: boolean, invalid: boolean, requestedMs: number|null }}
139
+ */
140
+ export function clampIndexInterval(raw, bounds = {}) {
141
+ const defaultMs = bounds.defaultMs ?? DEFAULT_INDEX_INTERVAL_MS;
142
+ const minMs = bounds.minMs ?? MIN_INDEX_INTERVAL_MS;
143
+ const maxMs = bounds.maxMs ?? MAX_INDEX_INTERVAL_MS;
144
+ const requestedMs = parseDuration(raw);
145
+
146
+ if (requestedMs === null) {
147
+ return {
148
+ ms: defaultMs,
149
+ human: formatIntervalMs(defaultMs),
150
+ clamped: false,
151
+ invalid: true,
152
+ requestedMs: null
153
+ };
154
+ }
155
+
156
+ const clampedMs = Math.min(maxMs, Math.max(minMs, requestedMs));
157
+ return {
158
+ ms: clampedMs,
159
+ human: formatIntervalMs(clampedMs),
160
+ clamped: clampedMs !== requestedMs,
161
+ invalid: false,
162
+ requestedMs
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Load ~/.apple-tools-mcp/config.json. Missing file is fine. Invalid JSON
168
+ * does not throw — callers get empty data plus a warn log.
169
+ *
170
+ * @param {{
171
+ * configPath?: string,
172
+ * env?: NodeJS.ProcessEnv,
173
+ * readFile?: (p: string) => string,
174
+ * exists?: (p: string) => boolean,
175
+ * warn?: (msg: string) => void
176
+ * }} [options]
177
+ * @returns {{ data: Record<string, unknown>, missing: boolean, invalid: boolean, path: string }}
178
+ */
179
+ export function loadConfigFile(options = {}) {
180
+ const warn = options.warn || defaultWarn;
181
+ const configPath = options.configPath || getConfigPath({ env: options.env });
182
+ const exists = options.exists || ((p) => fs.existsSync(p));
183
+ const readFile = options.readFile || ((p) => fs.readFileSync(p, "utf8"));
184
+
185
+ if (!exists(configPath)) {
186
+ return { data: {}, missing: true, invalid: false, path: configPath };
187
+ }
188
+
189
+ try {
190
+ const raw = readFile(configPath);
191
+ const parsed = JSON.parse(raw);
192
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
193
+ warn("Invalid config.json: expected a JSON object. Using defaults.");
194
+ return { data: {}, missing: false, invalid: true, path: configPath };
195
+ }
196
+ for (const key of Object.keys(parsed)) {
197
+ if (!KNOWN_CONFIG_KEYS.has(key)) {
198
+ warn(`Ignoring unknown config key: ${key}`);
199
+ }
200
+ }
201
+ return { data: parsed, missing: false, invalid: false, path: configPath };
202
+ } catch (err) {
203
+ const message = err && err.message ? err.message : "parse error";
204
+ warn(`Invalid config.json (${message}). Using defaults.`);
205
+ return { data: {}, missing: false, invalid: true, path: configPath };
206
+ }
207
+ }
208
+
209
+ function fileIntervalRaw(data) {
210
+ if (!data || typeof data !== "object") {
211
+ return undefined;
212
+ }
213
+ if (Object.prototype.hasOwnProperty.call(data, "indexInterval")) {
214
+ return data.indexInterval;
215
+ }
216
+ if (Object.prototype.hasOwnProperty.call(data, "indexIntervalMs")) {
217
+ return data.indexIntervalMs;
218
+ }
219
+ return undefined;
220
+ }
221
+
222
+ /**
223
+ * Resolve the index refresh interval once at process start.
224
+ *
225
+ * @param {{
226
+ * env?: NodeJS.ProcessEnv,
227
+ * configPath?: string,
228
+ * fileData?: Record<string, unknown>,
229
+ * warn?: (msg: string) => void
230
+ * }} [options]
231
+ * @returns {{
232
+ * ms: number,
233
+ * human: string,
234
+ * source: "env" | "config" | "default",
235
+ * clamped: boolean,
236
+ * invalid: boolean,
237
+ * requestedMs: number|null,
238
+ * raw: unknown
239
+ * }}
240
+ */
241
+ export function resolveIndexInterval(options = {}) {
242
+ const env = options.env || process.env;
243
+ const warn = options.warn || defaultWarn;
244
+ const envRaw = env.INDEX_INTERVAL_MS;
245
+ const envSet = envRaw !== undefined && envRaw !== "";
246
+
247
+ let source = "default";
248
+ let raw = DEFAULT_INDEX_INTERVAL_MS;
249
+
250
+ if (envSet) {
251
+ source = "env";
252
+ raw = envRaw;
253
+ } else {
254
+ const data = options.fileData !== undefined
255
+ ? options.fileData
256
+ : loadConfigFile({ configPath: options.configPath, env, warn }).data;
257
+ const fromFile = fileIntervalRaw(data);
258
+ if (fromFile !== undefined) {
259
+ source = "config";
260
+ raw = fromFile;
261
+ }
262
+ }
263
+
264
+ const clamped = clampIndexInterval(raw);
265
+ if (clamped.invalid && source !== "default") {
266
+ warn(`Invalid index interval ${JSON.stringify(raw)} from ${source}; using default ${clamped.human} (${clamped.ms} ms)`);
267
+ } else if (clamped.clamped) {
268
+ const fromHuman = formatIntervalMs(clamped.requestedMs);
269
+ warn(
270
+ `Index interval ${fromHuman} (${clamped.requestedMs} ms) from ${source} is outside ${formatIntervalMs(MIN_INDEX_INTERVAL_MS)}–${formatIntervalMs(MAX_INDEX_INTERVAL_MS)}; clamped to ${clamped.human} (${clamped.ms} ms)`
271
+ );
272
+ }
273
+
274
+ return {
275
+ ms: clamped.ms,
276
+ human: clamped.human,
277
+ source: clamped.invalid && source !== "default" ? "default" : source,
278
+ clamped: clamped.clamped,
279
+ invalid: clamped.invalid,
280
+ requestedMs: clamped.requestedMs,
281
+ raw
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Load config from disk (if present) and resolve the interval.
287
+ * @param {{ env?: NodeJS.ProcessEnv, configPath?: string, warn?: (msg: string) => void }} [options]
288
+ */
289
+ export function loadResolvedIndexInterval(options = {}) {
290
+ const warn = options.warn || defaultWarn;
291
+ const loaded = loadConfigFile({
292
+ configPath: options.configPath,
293
+ env: options.env,
294
+ warn
295
+ });
296
+ return resolveIndexInterval({
297
+ env: options.env,
298
+ fileData: loaded.data,
299
+ warn
300
+ });
301
+ }
302
+
303
+ /**
304
+ * Log the effective interval once. Human form and milliseconds.
305
+ * @param {{ ms: number, human: string, source: string, clamped: boolean }} resolved
306
+ * @param {{ log?: (msg: string) => void }} [options]
307
+ */
308
+ export function logResolvedInterval(resolved, options = {}) {
309
+ const log = options.log || defaultWarn;
310
+ const clampedNote = resolved.clamped ? ", clamped" : "";
311
+ log(`Effective index refresh interval: ${resolved.human} (${resolved.ms} ms) [source=${resolved.source}${clampedNote}]`);
312
+ }
@@ -0,0 +1,376 @@
1
+ /**
2
+ * indexer.lock acquire / release / heartbeat.
3
+ *
4
+ * A live holder is never displaced, even if the lock timestamp is old
5
+ * (blocked event loop, long index cycle). Dead-PID takeover never renames
6
+ * the live lock path. Waiters take a wx mutex, re-read, unlink only if the
7
+ * contents are still the expected dead lock, wx-create the new lock, then
8
+ * drop the mutex — so a peer's freshly created lock is never deleted.
9
+ * An orphaned takeover mutex is never stolen with compare-then-unlink of
10
+ * the live `.takeover` path: waiters wx a fence named by the dead PID.
11
+ * Empty or unparsable mutex/lock contents are unknown — do not fence or steal
12
+ * (covers wx open-then-write and heartbeat truncate-then-write windows).
13
+ */
14
+
15
+ import fs from "fs";
16
+ import path from "path";
17
+
18
+ export const DEFAULT_LOCK_TIMEOUT_MS = 30 * 60 * 1000;
19
+ export const DEFAULT_LOCK_HEARTBEAT_MS = 60 * 1000;
20
+ export const TAKEOVER_MUTEX_SUFFIX = ".takeover";
21
+ export const TAKEOVER_FENCE_DEPTH = 8;
22
+
23
+ /**
24
+ * @param {string} lockFile
25
+ * @returns {string}
26
+ */
27
+ export function getTakeoverMutexPath(lockFile) {
28
+ return `${lockFile}${TAKEOVER_MUTEX_SUFFIX}`;
29
+ }
30
+
31
+ /**
32
+ * wx fence used when indexer.lock.takeover is orphaned (dead PID).
33
+ * @param {string} lockFile
34
+ * @param {...number|string} deadIds
35
+ * @returns {string}
36
+ */
37
+ export function getTakeoverFencePath(lockFile, ...deadIds) {
38
+ let p = getTakeoverMutexPath(lockFile);
39
+ for (const id of deadIds) {
40
+ p = `${p}.${id}`;
41
+ }
42
+ return p;
43
+ }
44
+
45
+ /**
46
+ * @param {unknown} pid
47
+ * @param {(pid: number, signal: number) => void} [killFn]
48
+ * @returns {boolean}
49
+ */
50
+ export function isProcessAlive(pid, killFn = (p, signal) => process.kill(p, signal)) {
51
+ try {
52
+ killFn(pid, 0);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @param {string} text
61
+ * @returns {{ pid: number, timestamp: number, raw: string } | null}
62
+ */
63
+ export function parseLockData(text) {
64
+ if (typeof text !== "string" || text.length === 0) {
65
+ return null;
66
+ }
67
+ const [pidStr, timestampStr] = text.split(":");
68
+ const pid = parseInt(pidStr, 10);
69
+ const timestamp = parseInt(timestampStr, 10);
70
+ if (!Number.isInteger(pid) || pid <= 0) {
71
+ return null;
72
+ }
73
+ return {
74
+ pid,
75
+ timestamp: Number.isInteger(timestamp) ? timestamp : 0,
76
+ raw: text
77
+ };
78
+ }
79
+
80
+ /**
81
+ * @param {number} pid
82
+ * @param {number} timestamp
83
+ * @returns {string}
84
+ */
85
+ export function formatLockData(pid, timestamp) {
86
+ return `${pid}:${timestamp}`;
87
+ }
88
+
89
+ /**
90
+ * @param {{
91
+ * lockFile: string,
92
+ * pid?: number,
93
+ * now?: () => number,
94
+ * timeoutMs?: number,
95
+ * isAlive?: (pid: number) => boolean,
96
+ * fsApi?: Pick<typeof fs, "existsSync" | "readFileSync" | "writeFileSync" | "unlinkSync" | "mkdirSync">,
97
+ * log?: (msg: string) => void,
98
+ * setIntervalFn?: typeof setInterval,
99
+ * clearIntervalFn?: typeof clearInterval
100
+ * }} options
101
+ */
102
+ export function createIndexerLock(options) {
103
+ const lockFile = options.lockFile;
104
+ const mutexPath = getTakeoverMutexPath(lockFile);
105
+ const pid = options.pid ?? process.pid;
106
+ const now = options.now || (() => Date.now());
107
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
108
+ const isAlive = options.isAlive || ((holderPid) => isProcessAlive(holderPid));
109
+ const fsApi = options.fsApi || fs;
110
+ const log = options.log || ((msg) => console.error(msg));
111
+ const setIntervalFn = options.setIntervalFn || setInterval;
112
+ const clearIntervalFn = options.clearIntervalFn || clearInterval;
113
+
114
+ let ownsLock = false;
115
+ let heartbeatTimer = null;
116
+ let heldMutexPath = null;
117
+
118
+ function readLockFile() {
119
+ if (!fsApi.existsSync(lockFile)) {
120
+ return null;
121
+ }
122
+ return fsApi.readFileSync(lockFile, "utf8");
123
+ }
124
+
125
+ function skipLiveHolder(parsed) {
126
+ const lockAge = now() - parsed.timestamp;
127
+ if (lockAge > timeoutMs) {
128
+ log(
129
+ `Lock file is ${Math.round(lockAge / 60000)} minutes old, but PID ${parsed.pid} is still running. Not taking over.`
130
+ );
131
+ } else {
132
+ log(`Another indexing instance running (PID ${parsed.pid}). Skipping indexing.`);
133
+ }
134
+ ownsLock = false;
135
+ return false;
136
+ }
137
+
138
+ function refreshOwned() {
139
+ ownsLock = true;
140
+ try {
141
+ fsApi.writeFileSync(lockFile, formatLockData(pid, now()));
142
+ } catch {
143
+ // Heartbeat or the next cycle can retry the write.
144
+ }
145
+ return true;
146
+ }
147
+
148
+ function tryAcquireMutex() {
149
+ const token = formatLockData(pid, now());
150
+ let candidate = mutexPath;
151
+ try {
152
+ for (let depth = 0; depth < TAKEOVER_FENCE_DEPTH; depth++) {
153
+ let exists = false;
154
+ for (let spin = 0; spin < 4; spin++) {
155
+ try {
156
+ fsApi.writeFileSync(candidate, token, { flag: "wx" });
157
+ heldMutexPath = candidate;
158
+ return true;
159
+ } catch (err) {
160
+ if (err && err.code === "ENOENT") {
161
+ continue;
162
+ }
163
+ if (!err || err.code !== "EEXIST") {
164
+ throw err;
165
+ }
166
+ exists = true;
167
+ break;
168
+ }
169
+ }
170
+ if (!exists) {
171
+ return false;
172
+ }
173
+ let data;
174
+ try {
175
+ data = fsApi.readFileSync(candidate, "utf8");
176
+ } catch (err) {
177
+ if (err && err.code === "ENOENT") {
178
+ continue;
179
+ }
180
+ throw err;
181
+ }
182
+ const parsed = parseLockData(data);
183
+ if (!parsed) {
184
+ return false;
185
+ }
186
+ if (isAlive(parsed.pid)) {
187
+ return false;
188
+ }
189
+ candidate = `${candidate}.${parsed.pid}`;
190
+ }
191
+ return false;
192
+ } catch (err) {
193
+ if (err && err.code === "EEXIST") {
194
+ return false;
195
+ }
196
+ log(`Takeover mutex error: ${err.message}`);
197
+ return false;
198
+ }
199
+ }
200
+
201
+ function dropMutex() {
202
+ const held = heldMutexPath;
203
+ heldMutexPath = null;
204
+ if (!held) {
205
+ return;
206
+ }
207
+ try {
208
+ if (!fsApi.existsSync(held)) {
209
+ return;
210
+ }
211
+ const data = fsApi.readFileSync(held, "utf8");
212
+ const parsed = parseLockData(data);
213
+ if (parsed && parsed.pid === pid) {
214
+ fsApi.unlinkSync(held);
215
+ }
216
+ } catch (err) {
217
+ log(`Takeover mutex release error: ${err.message}`);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Under the wx mutex: re-read, unlink only the still-expected dead lock,
223
+ * then wx-create. Never rename the live path.
224
+ */
225
+ function finishAcquireUnderMutex(expectedStale) {
226
+ const current = readLockFile();
227
+
228
+ if (current !== null) {
229
+ const parsed = parseLockData(current);
230
+ if (!parsed) {
231
+ ownsLock = false;
232
+ return false;
233
+ }
234
+ if (parsed.pid === pid) {
235
+ return refreshOwned();
236
+ }
237
+ if (isAlive(parsed.pid)) {
238
+ return skipLiveHolder(parsed);
239
+ }
240
+ if (expectedStale === null || current !== expectedStale) {
241
+ ownsLock = false;
242
+ return false;
243
+ }
244
+ // Re-read immediately before unlink. Never delete a peer's wx lock.
245
+ const again = readLockFile();
246
+ if (again !== expectedStale) {
247
+ ownsLock = false;
248
+ return false;
249
+ }
250
+ try {
251
+ fsApi.unlinkSync(lockFile);
252
+ } catch (err) {
253
+ if (!err || err.code !== "ENOENT") {
254
+ throw err;
255
+ }
256
+ }
257
+ log(`Removing stale lock file (PID ${parsed ? parsed.pid : "unknown"} not running)`);
258
+ }
259
+
260
+ try {
261
+ fsApi.writeFileSync(lockFile, formatLockData(pid, now()), { flag: "wx" });
262
+ ownsLock = true;
263
+ return true;
264
+ } catch (err) {
265
+ if (err.code === "EEXIST") {
266
+ log("Another process acquired lock during race. Skipping indexing.");
267
+ ownsLock = false;
268
+ return false;
269
+ }
270
+ throw err;
271
+ }
272
+ }
273
+
274
+ function acquire() {
275
+ try {
276
+ const lockDir = path.dirname(lockFile);
277
+ if (!fsApi.existsSync(lockDir)) {
278
+ fsApi.mkdirSync(lockDir, { recursive: true });
279
+ }
280
+
281
+ const existing = readLockFile();
282
+ if (existing !== null) {
283
+ const parsed = parseLockData(existing);
284
+ if (!parsed) {
285
+ ownsLock = false;
286
+ return false;
287
+ }
288
+ if (parsed.pid === pid) {
289
+ return refreshOwned();
290
+ }
291
+ if (isAlive(parsed.pid)) {
292
+ return skipLiveHolder(parsed);
293
+ }
294
+ }
295
+
296
+ if (!tryAcquireMutex()) {
297
+ log("Another process acquired lock during race. Skipping indexing.");
298
+ ownsLock = false;
299
+ return false;
300
+ }
301
+ try {
302
+ return finishAcquireUnderMutex(existing);
303
+ } finally {
304
+ dropMutex();
305
+ }
306
+ } catch (e) {
307
+ log(`Lock file error: ${e.message}`);
308
+ ownsLock = false;
309
+ return false;
310
+ }
311
+ }
312
+
313
+ function release() {
314
+ try {
315
+ const lockData = readLockFile();
316
+ if (lockData) {
317
+ const parsed = parseLockData(lockData);
318
+ if (parsed && parsed.pid === pid) {
319
+ fsApi.unlinkSync(lockFile);
320
+ ownsLock = false;
321
+ log(`Released lock file (PID ${pid})`);
322
+ }
323
+ }
324
+ } catch (err) {
325
+ log(`Error releasing lock: ${err.message}`);
326
+ }
327
+ }
328
+
329
+ function refresh() {
330
+ try {
331
+ if (!ownsLock) {
332
+ return false;
333
+ }
334
+ const lockData = readLockFile();
335
+ if (!lockData) {
336
+ return false;
337
+ }
338
+ const parsed = parseLockData(lockData);
339
+ if (parsed && parsed.pid === pid) {
340
+ fsApi.writeFileSync(lockFile, formatLockData(pid, now()));
341
+ return true;
342
+ }
343
+ return false;
344
+ } catch (err) {
345
+ log(`Lock heartbeat error: ${err.message}`);
346
+ return false;
347
+ }
348
+ }
349
+
350
+ function startHeartbeat(intervalMs = DEFAULT_LOCK_HEARTBEAT_MS) {
351
+ if (heartbeatTimer) {
352
+ return;
353
+ }
354
+ heartbeatTimer = setIntervalFn(() => {
355
+ refresh();
356
+ }, intervalMs);
357
+ }
358
+
359
+ function stopHeartbeat() {
360
+ if (heartbeatTimer) {
361
+ clearIntervalFn(heartbeatTimer);
362
+ heartbeatTimer = null;
363
+ }
364
+ }
365
+
366
+ return {
367
+ acquire,
368
+ release,
369
+ refresh,
370
+ startHeartbeat,
371
+ stopHeartbeat,
372
+ get ownsLock() {
373
+ return ownsLock;
374
+ }
375
+ };
376
+ }