machine-bridge-mcp 0.11.1 → 0.12.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.
@@ -1,10 +1,12 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, writeFileSync, chmodSync, realpathSync, rmSync, unlinkSync, readdirSync, statSync } from "node:fs";
2
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, chmodSync, realpathSync, rmSync, unlinkSync, readdirSync, statSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
7
7
  import { replaceFileSync } from "./atomic-fs.mjs";
8
+ import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
9
+ import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
8
10
 
9
11
  export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
10
12
  export const appName = String(serverMetadata.name);
@@ -12,6 +14,9 @@ const STATE_MARKER = ".machine-bridge-mcp-state";
12
14
  const MAX_STATE_JSON_BYTES = 2 * 1024 * 1024;
13
15
  const MAX_LOCK_BYTES = 64 * 1024;
14
16
  const MAX_MARKER_BYTES = 4096;
17
+ const STARTUP_LOCK_MAX_AGE_MS = 2 * 60 * 60 * 1000;
18
+ const MALFORMED_LOCK_GRACE_MS = 60_000;
19
+ const MAINTENANCE_LOCK_MAX_AGE_MS = 2 * 60 * 60 * 1000;
15
20
 
16
21
  export function expandHome(input = "") {
17
22
  if (!input || input === "~") return os.homedir();
@@ -39,14 +44,19 @@ function configPath(stateRoot = defaultStateRoot()) {
39
44
  }
40
45
 
41
46
  export function loadGlobalConfig(stateRoot = defaultStateRoot()) {
42
- const file = configPath(stateRoot);
47
+ const root = path.resolve(expandHome(stateRoot));
48
+ assertNoForeignMaintenance(root);
49
+ const file = configPath(root);
43
50
  if (!existsSync(file)) return {};
44
51
  ownerOnlyFile(file);
45
52
  return readJsonObjectOrBackup(file);
46
53
  }
47
54
 
48
55
  export function saveGlobalConfig(config, stateRoot = defaultStateRoot()) {
49
- const root = ensureStateRoot(expandHome(stateRoot));
56
+ const requestedRoot = path.resolve(expandHome(stateRoot));
57
+ assertNoForeignMaintenance(requestedRoot);
58
+ const root = ensureStateRoot(requestedRoot);
59
+ assertNoForeignMaintenance(root);
50
60
  const file = configPath(root);
51
61
  atomicWriteJson(file, { ...config, updatedAt: new Date().toISOString() });
52
62
  }
@@ -91,6 +101,32 @@ function profileDirForWorkspace(workspace, stateRoot = defaultStateRoot()) {
91
101
  }
92
102
 
93
103
 
104
+
105
+ function canonicalizePotentialPath(input) {
106
+ let existing = path.resolve(input);
107
+ const suffix = [];
108
+ while (!existsSync(existing)) {
109
+ const parent = path.dirname(existing);
110
+ if (parent === existing) break;
111
+ suffix.unshift(path.basename(existing));
112
+ existing = parent;
113
+ }
114
+ const canonicalExisting = realpathSync.native ? realpathSync.native(existing) : realpathSync(existing);
115
+ return path.join(canonicalExisting, ...suffix);
116
+ }
117
+
118
+ function assertStateRootSeparatedFromWorkspace(stateRoot, workspace) {
119
+ const canonicalStateRoot = canonicalizePotentialPath(stateRoot);
120
+ const stateIdentity = process.platform === "win32" ? canonicalStateRoot.toLowerCase() : canonicalStateRoot;
121
+ const workspaceIdentity = process.platform === "win32" ? workspace.toLowerCase() : workspace;
122
+ const relativeWorkspace = path.relative(canonicalStateRoot, workspace);
123
+ const relativeState = path.relative(workspace, canonicalStateRoot);
124
+ const contains = (value) => value === "" || (!value.startsWith(`..${path.sep}`) && value !== ".." && !path.isAbsolute(value));
125
+ if (stateIdentity === workspaceIdentity || contains(relativeWorkspace) || contains(relativeState)) {
126
+ throw new Error("state root and selected workspace must be separate, non-overlapping directories");
127
+ }
128
+ }
129
+
94
130
  function matchingLegacyProfileDir(canonicalWorkspace, stateRoot) {
95
131
  const profilesRoot = path.join(stateRoot, "profiles");
96
132
  if (!existsSync(profilesRoot)) return "";
@@ -123,7 +159,11 @@ function sameWorkspaceIdentity(left, right) {
123
159
 
124
160
  export function loadState(workspace, options = {}) {
125
161
  const canonicalWorkspace = resolveWorkspace(workspace);
126
- const stateRoot = ensureStateRoot(options.stateDir ? expandHome(options.stateDir) : defaultStateRoot());
162
+ const requestedStateRoot = path.resolve(options.stateDir ? expandHome(options.stateDir) : defaultStateRoot());
163
+ assertStateRootSeparatedFromWorkspace(requestedStateRoot, canonicalWorkspace);
164
+ assertNoForeignMaintenance(requestedStateRoot);
165
+ const stateRoot = ensureStateRoot(requestedStateRoot);
166
+ assertNoForeignMaintenance(stateRoot);
127
167
  const canonicalProfileDir = profileDirForWorkspace(canonicalWorkspace, stateRoot);
128
168
  const profileDir = existsSync(canonicalProfileDir)
129
169
  ? canonicalProfileDir
@@ -150,6 +190,7 @@ export function loadState(workspace, options = {}) {
150
190
  }
151
191
 
152
192
  export function saveState(state) {
193
+ assertNoForeignMaintenance(state?.paths?.stateRoot);
153
194
  const statePath = state?.paths?.statePath;
154
195
  if (!statePath) throw new Error("state path is missing");
155
196
  ensureOwnerOnlyDir(path.dirname(statePath));
@@ -170,18 +211,87 @@ function lockPathForState(state, name) {
170
211
  return path.join(profileDir, name);
171
212
  }
172
213
 
214
+ export function acquireMaintenanceLock(stateRoot, metadata = {}) {
215
+ const root = path.resolve(expandHome(stateRoot));
216
+ if (!existsSync(root)) throw new Error("cannot acquire maintenance lock for a missing state root");
217
+ const operation = typeof metadata.operation === "string" && /^[a-z][a-z0-9._-]{0,63}$/.test(metadata.operation)
218
+ ? metadata.operation
219
+ : "maintenance";
220
+ return acquireProcessLock(path.join(root, "maintenance.lock"), {
221
+ workspace: { path: "" },
222
+ paths: { profileDir: root, stateRoot: root },
223
+ }, "maintenance", { operation }, { maxAgeMs: MAINTENANCE_LOCK_MAX_AGE_MS });
224
+ }
225
+
226
+ export function assertStateMaintenanceAvailable(stateRoot) {
227
+ assertNoForeignMaintenance(stateRoot);
228
+ }
229
+
230
+ function assertNoForeignMaintenance(stateRoot) {
231
+ if (!stateRoot) return;
232
+ const file = path.join(path.resolve(expandHome(stateRoot)), "maintenance.lock");
233
+ if (!existsSync(file)) return;
234
+ const snapshot = readProcessLockSnapshot(file);
235
+ if (!snapshot) return;
236
+ if (!snapshot.owner) {
237
+ const ageMs = Date.now() - snapshot.info.mtimeMs;
238
+ if (ageMs < MALFORMED_LOCK_GRACE_MS) throw new Error("state maintenance lock is recent but unreadable");
239
+ if (!removeLockSnapshot(file, snapshot)) {
240
+ throw new Error("state maintenance lock changed during inspection; retry after checking the owning process");
241
+ }
242
+ return;
243
+ }
244
+ if (snapshot.owner.purpose !== "maintenance") throw new Error("state maintenance lock contains mismatched purpose metadata");
245
+ const identity = inspectProcessInstance(snapshot.owner, { maxAgeMs: MAINTENANCE_LOCK_MAX_AGE_MS });
246
+ if (identity.current) {
247
+ if (Number(snapshot.owner.pid) === process.pid) return;
248
+ throw new Error(`state maintenance is active in another process (pid ${snapshot.owner.pid})`);
249
+ }
250
+ if (!identity.reclaimable) throw new Error(`state maintenance lock cannot be verified safely (${identity.reason})`);
251
+ if (!removeLockSnapshot(file, snapshot)) {
252
+ throw new Error("state maintenance lock changed during inspection; retry after checking the owning process");
253
+ }
254
+ }
255
+
173
256
  export function acquireDaemonLock(state, metadata = {}) {
257
+ assertNoForeignMaintenance(state?.paths?.stateRoot);
174
258
  const details = {};
175
259
  if (metadata?.mode === "foreground" || metadata?.mode === "service") details.mode = metadata.mode;
176
260
  if (typeof metadata?.version === "string" && /^[0-9A-Za-z.+_-]{1,64}$/.test(metadata.version)) details.version = metadata.version;
177
- return acquireProcessLock(daemonLockPathForState(state), state, "daemon", details);
261
+ return acquireProcessLock(daemonLockPathForState(state), state, "daemon", details, { maxAgeMs: Number.POSITIVE_INFINITY });
178
262
  }
179
263
 
180
- export function acquireStartupLock(state) {
181
- return acquireProcessLock(startupLockPathForState(state), state, "startup");
264
+ export function acquireStartupLock(state, metadata = {}) {
265
+ assertNoForeignMaintenance(state?.paths?.stateRoot);
266
+ const details = {};
267
+ if (typeof metadata?.operation === "string" && /^[a-z][a-z0-9._-]{0,63}$/.test(metadata.operation)) details.operation = metadata.operation;
268
+ return acquireProcessLock(startupLockPathForState(state), state, "startup", details, { maxAgeMs: STARTUP_LOCK_MAX_AGE_MS });
269
+ }
270
+
271
+ export async function acquireStartupLockWithWait(state, options = {}) {
272
+ const timeoutMs = boundedPositiveInteger(options.timeoutMs, 30_000);
273
+ const pollMs = boundedPositiveInteger(options.pollMs, 100);
274
+ const logger = options.logger || { info() {} };
275
+ const metadata = { operation: options.operation };
276
+ let lock = acquireStartupLock(state, metadata);
277
+ if (lock.acquired) return lock;
278
+ const ownerPid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "another process";
279
+ logger.info?.(`waiting for ${ownerPid} to finish the current startup/state operation`);
280
+ const deadline = Date.now() + timeoutMs;
281
+ while (Date.now() < deadline) {
282
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, Math.min(pollMs, Math.max(1, deadline - Date.now()))));
283
+ lock = acquireStartupLock(state, metadata);
284
+ if (lock.acquired) {
285
+ logger.info?.("the previous startup/state operation finished; continuing");
286
+ return lock;
287
+ }
288
+ }
289
+ const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
290
+ const operation = typeof lock.owner?.operation === "string" ? ` (${lock.owner.operation})` : "";
291
+ throw new Error(`another startup/state operation did not finish within ${Math.ceil(timeoutMs / 1000)} seconds (${pid}${operation}); inspect the process before retrying`);
182
292
  }
183
293
 
184
- function acquireProcessLock(lockPath, state, purpose, details = {}) {
294
+ function acquireProcessLock(lockPath, state, purpose, details = {}, options = {}) {
185
295
  ensureOwnerOnlyDir(path.dirname(lockPath));
186
296
  const token = randomBytes(16).toString("hex");
187
297
  const payload = {
@@ -190,18 +300,15 @@ function acquireProcessLock(lockPath, state, purpose, details = {}) {
190
300
  purpose,
191
301
  workspace: state?.workspace?.path || "",
192
302
  startedAt: new Date().toISOString(),
303
+ processStartedAt: new Date(currentProcessStartTimeMs()).toISOString(),
193
304
  entryScript: process.argv[1] || "",
194
305
  ...details,
195
306
  };
196
307
 
197
- for (let attempt = 0; attempt < 2; attempt += 1) {
198
- let fd;
308
+ for (let attempt = 0; attempt < 3; attempt += 1) {
199
309
  try {
200
- fd = openSync(lockPath, "wx", 0o600);
201
- writeFileSync(fd, JSON.stringify(payload, null, 2) + "\n");
202
- fsyncSync(fd);
203
- closeSync(fd);
204
- fd = undefined;
310
+ createExclusiveFileSync(lockPath, `${JSON.stringify(payload, null, 2)}
311
+ `, { mode: 0o600 });
205
312
  ownerOnlyFile(lockPath);
206
313
  return {
207
314
  acquired: true,
@@ -210,25 +317,85 @@ function acquireProcessLock(lockPath, state, purpose, details = {}) {
210
317
  release() { releaseProcessLock(lockPath, token); },
211
318
  };
212
319
  } catch (error) {
213
- if (fd !== undefined) {
214
- try { closeSync(fd); } catch {}
215
- }
216
320
  if (error?.code !== "EEXIST") throw error;
217
- const owner = readDaemonLockOwner(lockPath);
218
- if (owner?.pid && isPidAlive(owner.pid)) {
219
- return { acquired: false, path: lockPath, owner, release() {} };
321
+ const snapshot = readProcessLockSnapshot(lockPath);
322
+ if (!snapshot) continue;
323
+ if (!snapshot.owner) {
324
+ const ageMs = Date.now() - snapshot.info.mtimeMs;
325
+ if (ageMs < MALFORMED_LOCK_GRACE_MS) {
326
+ return { acquired: false, path: lockPath, owner: null, reason: "recent_invalid_lock", release() {} };
327
+ }
328
+ if (!removeLockSnapshot(lockPath, snapshot)) continue;
329
+ continue;
330
+ }
331
+ if (snapshot.owner.purpose !== purpose) throw new Error(`${purpose} lock contains mismatched purpose metadata`);
332
+ if (snapshot.owner.workspace && state?.workspace?.path && !sameWorkspaceIdentity(snapshot.owner.workspace, state.workspace.path)) {
333
+ throw new Error(`${purpose} lock belongs to a different workspace`);
334
+ }
335
+ const identity = inspectProcessInstance(snapshot.owner, { maxAgeMs: options.maxAgeMs });
336
+ if (identity.current || !identity.reclaimable) {
337
+ return { acquired: false, path: lockPath, owner: snapshot.owner, reason: identity.reason, release() {} };
220
338
  }
221
- try { unlinkSync(lockPath); } catch {}
339
+ if (!removeLockSnapshot(lockPath, snapshot)) continue;
222
340
  }
223
341
  }
224
342
  const owner = readDaemonLockOwner(lockPath);
225
343
  return { acquired: false, path: lockPath, owner, release() {} };
226
344
  }
227
345
 
346
+ function readProcessLockSnapshot(lockPath) {
347
+ let info;
348
+ try { info = lstatSync(lockPath); } catch (error) {
349
+ if (error?.code === "ENOENT") return null;
350
+ throw error;
351
+ }
352
+ if (info.isSymbolicLink()) throw new Error(`process lock must not be a symbolic link: ${lockPath}`);
353
+ if (!info.isFile()) throw new Error(`process lock is not a regular file: ${lockPath}`);
354
+ let owner = null;
355
+ try {
356
+ const parsed = JSON.parse(readBoundedUtf8(lockPath, MAX_LOCK_BYTES, "lock file"));
357
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) owner = parsed;
358
+ } catch (error) {
359
+ if (error?.code === "ENOENT") return null;
360
+ }
361
+ return { owner, info: lockIdentity(info) };
362
+ }
363
+
364
+ function removeLockSnapshot(lockPath, snapshot) {
365
+ let current;
366
+ try { current = lstatSync(lockPath); } catch (error) { return error?.code === "ENOENT"; }
367
+ if (current.isSymbolicLink() || !current.isFile()) return false;
368
+ if (!sameLockIdentity(snapshot.info, lockIdentity(current))) return false;
369
+ if (snapshot.owner?.token) {
370
+ const currentOwner = readDaemonLockOwner(lockPath);
371
+ if (currentOwner?.token !== snapshot.owner.token) return false;
372
+ }
373
+ try {
374
+ unlinkSync(lockPath);
375
+ return true;
376
+ } catch (error) {
377
+ return error?.code === "ENOENT";
378
+ }
379
+ }
380
+
381
+ function lockIdentity(info) {
382
+ return {
383
+ dev: Number(info.dev),
384
+ ino: Number(info.ino),
385
+ size: Number(info.size),
386
+ mtimeMs: Number(info.mtimeMs),
387
+ };
388
+ }
389
+
390
+ function sameLockIdentity(left, right) {
391
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
392
+ }
393
+
228
394
  function releaseProcessLock(lockPath, token) {
229
- const owner = readDaemonLockOwner(lockPath);
230
- if (owner?.token !== token) return;
231
- try { unlinkSync(lockPath); } catch {}
395
+ let snapshot;
396
+ try { snapshot = readProcessLockSnapshot(lockPath); } catch { return; }
397
+ if (!snapshot || snapshot.owner?.token !== token) return;
398
+ removeLockSnapshot(lockPath, snapshot);
232
399
  }
233
400
 
234
401
  export function readDaemonLockOwner(lockPath) {
@@ -240,15 +407,9 @@ export function readDaemonLockOwner(lockPath) {
240
407
  }
241
408
  }
242
409
 
243
- function isPidAlive(pid) {
244
- const parsed = Number(pid);
245
- if (!Number.isInteger(parsed) || parsed <= 0) return false;
246
- try {
247
- process.kill(parsed, 0);
248
- return true;
249
- } catch (error) {
250
- return error?.code === "EPERM";
251
- }
410
+ function boundedPositiveInteger(value, fallback) {
411
+ const parsed = Number(value);
412
+ return Number.isFinite(parsed) ? Math.max(1, Math.floor(parsed)) : fallback;
252
413
  }
253
414
 
254
415
  function readBoundedUtf8(filePath, maxBytes, label) {
@@ -267,24 +428,28 @@ function readBoundedUtf8(filePath, maxBytes, label) {
267
428
  if (count === 0) break;
268
429
  offset += count;
269
430
  }
270
- return buffer.subarray(0, offset).toString("utf8");
431
+ try {
432
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
433
+ } catch {
434
+ throw new Error(`${label} is not valid UTF-8`);
435
+ }
271
436
  } finally {
272
437
  closeSync(fd);
273
438
  }
274
439
  }
275
440
 
276
441
  function readJsonObjectOrBackup(filePath) {
442
+ const text = readBoundedUtf8(filePath, MAX_STATE_JSON_BYTES, "state JSON");
443
+ let parsed;
277
444
  try {
278
- const parsed = JSON.parse(readBoundedUtf8(filePath, MAX_STATE_JSON_BYTES, "state JSON"));
445
+ parsed = JSON.parse(text);
279
446
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("JSON root must be an object");
280
447
  return parsed;
281
448
  } catch {
282
449
  const backupPath = `${filePath}.corrupt-${Date.now()}-${randomBytes(4).toString("hex")}`;
283
- try {
284
- replaceFileSync(filePath, backupPath);
285
- ownerOnlyFile(backupPath);
286
- pruneBackups(filePath, 3);
287
- } catch {}
450
+ replaceFileSync(filePath, backupPath);
451
+ ownerOnlyFile(backupPath);
452
+ pruneBackups(filePath, 3);
288
453
  return {};
289
454
  }
290
455
  }
@@ -310,7 +475,7 @@ function ensureStateRoot(inputRoot) {
310
475
  }
311
476
  }
312
477
  try {
313
- writeFileSync(marker, `${JSON.stringify({ app: appName, schema: 1 })}\n`, { mode: 0o600, flag: "wx" });
478
+ createExclusiveFileSync(marker, `${JSON.stringify({ app: appName, schema: 1 })}\n`, { mode: 0o600 });
314
479
  } catch (error) {
315
480
  if (error?.code !== "EEXIST") throw error;
316
481
  assertValidStateMarker(marker, { migrateLegacy: true });
@@ -365,7 +530,7 @@ function assertValidStateMarker(marker, options = {}) {
365
530
  }
366
531
 
367
532
  function hasOnlyStateEntries(entries) {
368
- const allowed = new Set([STATE_MARKER, "config.json", "browser-bridge.json", "profiles", "logs"]);
533
+ const allowed = new Set([STATE_MARKER, "config.json", "browser-bridge.json", "maintenance.lock", "profiles", "logs"]);
369
534
  return entries.every((entry) => allowed.has(entry) || /^config\.json\.corrupt-\d+(?:-[a-f0-9]{8})?$/.test(entry));
370
535
  }
371
536
 
@@ -374,16 +539,28 @@ function looksLikeSourceTree(root) {
374
539
  }
375
540
 
376
541
  function stateRootMatchesRecordedWorkspace(root) {
542
+ const config = path.join(root, "config.json");
543
+ if (existsSync(config)) {
544
+ try {
545
+ const value = JSON.parse(readBoundedUtf8(config, MAX_STATE_JSON_BYTES, "config JSON"));
546
+ if (typeof value?.selectedWorkspace === "string" && sameWorkspaceIdentity(value.selectedWorkspace, root)) return true;
547
+ } catch {}
548
+ }
377
549
  const profiles = path.join(root, "profiles");
378
550
  if (!existsSync(profiles)) return false;
379
551
  try {
380
552
  for (const entry of readdirSync(profiles, { withFileTypes: true })) {
381
553
  if (!entry.isDirectory()) continue;
382
- const stateFile = path.join(profiles, entry.name, "state.json");
383
- if (!existsSync(stateFile)) continue;
384
- const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
385
- if (typeof value?.workspace?.path !== "string") continue;
386
- try { if (realpathSync(value.workspace.path) === root) return true; } catch {}
554
+ const profileDir = path.join(profiles, entry.name);
555
+ const stateFile = path.join(profileDir, "state.json");
556
+ if (existsSync(stateFile)) {
557
+ try {
558
+ const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
559
+ if (typeof value?.workspace?.path === "string" && sameWorkspaceIdentity(value.workspace.path, root)) return true;
560
+ } catch {}
561
+ }
562
+ const owner = readDaemonLockOwner(path.join(profileDir, "daemon.lock"));
563
+ if (typeof owner?.workspace === "string" && sameWorkspaceIdentity(owner.workspace, root)) return true;
387
564
  }
388
565
  } catch {}
389
566
  return false;
@@ -420,25 +597,10 @@ function atomicWriteJson(filePath, value) {
420
597
  const dir = path.dirname(filePath);
421
598
  ensureOwnerOnlyDir(dir);
422
599
  cleanupStaleAtomicTemps(dir, path.basename(filePath));
423
- const serialized = `${JSON.stringify(value, null, 2)}\n`;
600
+ const serialized = `${JSON.stringify(value, null, 2)}
601
+ `;
424
602
  if (Buffer.byteLength(serialized) > MAX_STATE_JSON_BYTES) throw new Error(`state JSON exceeds ${MAX_STATE_JSON_BYTES} bytes`);
425
- const tempPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
426
- let fd;
427
- try {
428
- fd = openSync(tempPath, "wx", 0o600);
429
- writeFileSync(fd, serialized, "utf8");
430
- fsyncSync(fd);
431
- closeSync(fd);
432
- fd = undefined;
433
- replaceFileSync(tempPath, filePath);
434
- ownerOnlyFile(filePath);
435
- } catch (error) {
436
- if (fd !== undefined) {
437
- try { closeSync(fd); } catch {}
438
- }
439
- try { unlinkSync(tempPath); } catch {}
440
- throw error;
441
- }
603
+ replaceFileAtomicallySync(filePath, serialized, { mode: 0o600 });
442
604
  }
443
605
 
444
606
  function cleanupStaleAtomicTemps(dir, baseName = "") {
@@ -200,6 +200,11 @@
200
200
  "type": "boolean",
201
201
  "default": false
202
202
  },
203
+ "include_menus": {
204
+ "type": "boolean",
205
+ "default": false,
206
+ "description": "Include and recursively inspect the application menu hierarchy. Disabled by default so main-window controls are reached quickly."
207
+ },
203
208
  "timeout_seconds": {
204
209
  "type": "integer",
205
210
  "minimum": 1,
@@ -286,6 +291,11 @@
286
291
  "type": "string",
287
292
  "pattern": "^[a-z][a-z0-9._-]{0,63}$"
288
293
  },
294
+ "include_menus": {
295
+ "type": "boolean",
296
+ "default": false,
297
+ "description": "Include menu-bar and menu subtrees when matching the action selector."
298
+ },
289
299
  "max_depth": {
290
300
  "type": "integer",
291
301
  "minimum": 1,
@@ -3,7 +3,7 @@ import toolCatalog from "../shared/tool-catalog.json";
3
3
  import serverMetadata from "../shared/server-metadata.json";
4
4
 
5
5
  const SERVER_NAME = String(serverMetadata.name);
6
- const SERVER_VERSION = "0.11.1";
6
+ const SERVER_VERSION = "0.12.1";
7
7
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
8
8
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
9
9
  const JSONRPC_VERSION = "2.0";