surf-cli 2.7.2 → 2.9.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.
Files changed (45) hide show
  1. package/README.md +208 -13
  2. package/dist/content/index.js +116 -0
  3. package/dist/content/index.js.map +1 -0
  4. package/dist/manifest.json +2 -11
  5. package/dist/options/options.js +3 -3
  6. package/dist/options/options.js.map +1 -1
  7. package/dist/service-worker/index.js +261 -61
  8. package/dist/service-worker/index.js.map +1 -1
  9. package/native/abort.cjs +65 -0
  10. package/native/ai-queue.cjs +64 -0
  11. package/native/aistudio-build.cjs +21 -13
  12. package/native/aistudio-client.cjs +40 -20
  13. package/native/browser-lock.cjs +169 -0
  14. package/native/chatgpt-client.cjs +63 -30
  15. package/native/cli.cjs +947 -460
  16. package/native/client-transport.cjs +168 -0
  17. package/native/config.cjs +2 -2
  18. package/native/do-executor.cjs +25 -51
  19. package/native/do-parser.cjs +12 -0
  20. package/native/doctor.cjs +633 -0
  21. package/native/endpoint.cjs +174 -0
  22. package/native/file-transfer.cjs +734 -0
  23. package/native/gemini-client.cjs +244 -88
  24. package/native/grok-client.cjs +321 -212
  25. package/native/host-helpers.cjs +88 -16
  26. package/native/host-sessions.cjs +283 -0
  27. package/native/host.cjs +811 -616
  28. package/native/listener.cjs +20 -0
  29. package/native/mcp-server.cjs +60 -62
  30. package/native/network-export.cjs +113 -0
  31. package/native/perplexity-client.cjs +46 -17
  32. package/native/remote-auth.cjs +279 -0
  33. package/native/remote-transport.cjs +337 -0
  34. package/native/request-pending.cjs +148 -0
  35. package/native/socket-path.cjs +46 -0
  36. package/package.json +11 -9
  37. package/scripts/install-native-host.cjs +184 -51
  38. package/scripts/uninstall-native-host.cjs +93 -15
  39. package/skills/README.md +11 -5
  40. package/skills/deep-x-research/SKILL.md +106 -0
  41. package/skills/surf/SKILL.md +77 -22
  42. package/dist/content/accessibility-tree.js +0 -11
  43. package/dist/content/accessibility-tree.js.map +0 -1
  44. package/dist/content/visual-indicator.js +0 -111
  45. package/dist/content/visual-indicator.js.map +0 -1
@@ -0,0 +1,65 @@
1
+ class RequestAbortError extends Error {
2
+ constructor(message = "Request cancelled") {
3
+ super(message);
4
+ this.name = "RequestAbortError";
5
+ this.code = "SURF_REQUEST_ABORTED";
6
+ }
7
+ }
8
+
9
+ function abortError(signal, fallback = "Request cancelled") {
10
+ if (signal?.reason instanceof Error && signal.reason.name !== "AbortError") return signal.reason;
11
+ return new RequestAbortError(fallback);
12
+ }
13
+
14
+ function throwIfAborted(signal, fallback) {
15
+ if (signal?.aborted) throw abortError(signal, fallback);
16
+ }
17
+
18
+ function raceAbort(promiseOrFactory, signal, fallback) {
19
+ if (!signal) return typeof promiseOrFactory === "function" ? promiseOrFactory() : promiseOrFactory;
20
+ throwIfAborted(signal, fallback);
21
+ const promise = typeof promiseOrFactory === "function" ? promiseOrFactory() : promiseOrFactory;
22
+ if (signal.aborted) return Promise.reject(abortError(signal, fallback));
23
+ return new Promise((resolve, reject) => {
24
+ let settled = false;
25
+ const onAbort = () => {
26
+ if (settled) return;
27
+ settled = true;
28
+ signal.removeEventListener("abort", onAbort);
29
+ reject(abortError(signal, fallback));
30
+ };
31
+ signal.addEventListener("abort", onAbort, { once: true });
32
+ Promise.resolve(promise).then(
33
+ (value) => {
34
+ if (settled) return;
35
+ settled = true;
36
+ signal.removeEventListener("abort", onAbort);
37
+ resolve(value);
38
+ },
39
+ (error) => {
40
+ if (settled) return;
41
+ settled = true;
42
+ signal.removeEventListener("abort", onAbort);
43
+ reject(error);
44
+ },
45
+ );
46
+ });
47
+ }
48
+
49
+ function abortableDelay(ms, signal, fallback) {
50
+ throwIfAborted(signal, fallback);
51
+ return new Promise((resolve, reject) => {
52
+ const onAbort = () => {
53
+ clearTimeout(timer);
54
+ signal.removeEventListener("abort", onAbort);
55
+ reject(abortError(signal, fallback));
56
+ };
57
+ const timer = setTimeout(() => {
58
+ signal?.removeEventListener("abort", onAbort);
59
+ resolve();
60
+ }, ms);
61
+ signal?.addEventListener("abort", onAbort, { once: true });
62
+ });
63
+ }
64
+
65
+ module.exports = { RequestAbortError, abortError, abortableDelay, raceAbort, throwIfAborted };
@@ -0,0 +1,64 @@
1
+ const { abortError } = require("./abort.cjs");
2
+
3
+ class BoundedAiQueue {
4
+ constructor({ maxQueued = 8, spacingMs = 2000, audit = () => {}, run = (handler) => handler() } = {}) {
5
+ this.maxQueued = maxQueued;
6
+ this.spacingMs = spacingMs;
7
+ this.audit = audit;
8
+ this.run = run;
9
+ this.items = [];
10
+ this.active = false;
11
+ }
12
+
13
+ enqueue(handler, request) {
14
+ return new Promise((resolve, reject) => {
15
+ if (request?.signal.aborted) {
16
+ reject(abortError(request.signal));
17
+ return;
18
+ }
19
+ if (this.items.length >= this.maxQueued) {
20
+ reject(new Error("AI request queue is full"));
21
+ return;
22
+ }
23
+ const item = { handler, request, resolve, reject, abortCleanup: null };
24
+ const onAbort = () => {
25
+ const index = this.items.indexOf(item);
26
+ if (index === -1) return;
27
+ this.items.splice(index, 1);
28
+ item.abortCleanup = null;
29
+ reject(abortError(request.signal));
30
+ this.audit({ event: "request", context: request.context, request, outcome: "queued-cancel" });
31
+ };
32
+ if (request?.signal) {
33
+ request.signal.addEventListener("abort", onAbort, { once: true });
34
+ item.abortCleanup = () => request.signal.removeEventListener("abort", onAbort);
35
+ }
36
+ this.items.push(item);
37
+ this.#process();
38
+ });
39
+ }
40
+
41
+ get queued() {
42
+ return this.items.length;
43
+ }
44
+
45
+ async #process() {
46
+ if (this.active || this.items.length === 0) return;
47
+ this.active = true;
48
+ const item = this.items.shift();
49
+ item.abortCleanup?.();
50
+ try {
51
+ if (item.request?.signal.aborted) throw abortError(item.request.signal);
52
+ const result = await this.run(item.handler, item.request);
53
+ if (item.request?.signal.aborted) throw abortError(item.request.signal);
54
+ item.resolve(result);
55
+ } catch (error) {
56
+ item.reject(error);
57
+ } finally {
58
+ this.active = false;
59
+ setTimeout(() => this.#process(), this.spacingMs);
60
+ }
61
+ }
62
+ }
63
+
64
+ module.exports = { BoundedAiQueue };
@@ -1,4 +1,5 @@
1
1
  const fs = require("fs");
2
+ const { raceAbort, throwIfAborted } = require("./abort.cjs");
2
3
  const { execFileSync } = require("child_process");
3
4
  const {
4
5
  delay,
@@ -483,26 +484,29 @@ async function build({
483
484
  cdpCommand,
484
485
  searchDownloads,
485
486
  log = () => {},
487
+ signal,
486
488
  }) {
489
+ throwIfAborted(signal);
490
+ const guardedSearchDownloads = (...args) => raceAbort(() => searchDownloads(...args), signal);
487
491
  const startedAt = Date.now();
488
492
  const timeoutMs = Number.isFinite(timeout) ? timeout : 600000;
489
493
  const requestedModel = normalizeModelString(model || "");
490
494
  const resolvedModel = requestedModel || DEFAULT_MODEL;
491
495
 
492
- const cookieResult = await getCookies();
496
+ const cookieResult = await raceAbort(getCookies, signal);
493
497
  const cookies = cookieResult?.cookies || [];
494
498
  if (!hasRequiredCookies(cookies)) {
495
499
  throw new Error("Sign into Google in Chrome first.");
496
500
  }
497
501
 
498
- const tabInfo = await createTab(AISTUDIO_APPS_URL);
502
+ const tabInfo = await raceAbort(() => createTab(AISTUDIO_APPS_URL), signal);
499
503
  const tabId = tabInfo?.tabId;
500
504
  if (!tabId) {
501
505
  throw new Error(`Failed to create AI Studio tab: ${JSON.stringify(tabInfo)}`);
502
506
  }
503
507
 
504
- const cdp = (expression) => cdpEvaluate(tabId, expression);
505
- const inputCdp = (method, params) => cdpCommand(tabId, method, params);
508
+ const cdp = (expression) => raceAbort(() => cdpEvaluate(tabId, expression), signal);
509
+ const inputCdp = (method, params) => raceAbort(() => cdpCommand(tabId, method, params), signal);
506
510
 
507
511
  let buildDuration = null;
508
512
  let zipPath = null;
@@ -510,7 +514,7 @@ async function build({
510
514
  let modelUsed = resolvedModel;
511
515
 
512
516
  try {
513
- await waitForBuildPageReady(cdp, 30000);
517
+ await raceAbort(waitForBuildPageReady(cdp, 30000), signal);
514
518
 
515
519
  if (requestedModel) {
516
520
  const applied = await selectModelInAdvancedSettings(cdp, inputCdp, requestedModel, log);
@@ -518,20 +522,20 @@ async function build({
518
522
  }
519
523
 
520
524
  await typePromptAndBuild(cdp, inputCdp, prompt);
521
- const completion = await waitForBuildCompletion(cdp, timeoutMs, log);
525
+ const completion = await raceAbort(waitForBuildCompletion(cdp, timeoutMs, log), signal);
522
526
  buildDuration = completion.buildDuration;
523
527
 
524
- await activateCodeTab(cdp, inputCdp);
525
- await waitForDownloadButton(cdp, 5000);
528
+ await raceAbort(activateCodeTab(cdp, inputCdp), signal);
529
+ await raceAbort(waitForDownloadButton(cdp, 5000), signal);
526
530
 
527
- const beforeDownloads = await searchDownloads({
531
+ const beforeDownloads = await guardedSearchDownloads({
528
532
  limit: 1,
529
533
  orderBy: ["-startTime"],
530
534
  });
531
535
  const latestIdBefore = beforeDownloads?.[0]?.id || 0;
532
536
 
533
- await clickDownloadButton(cdp);
534
- zipPath = await waitForDownloadComplete(searchDownloads, latestIdBefore, 30000);
537
+ await raceAbort(clickDownloadButton(cdp), signal);
538
+ zipPath = await waitForDownloadComplete(guardedSearchDownloads, latestIdBefore, 30000);
535
539
 
536
540
  if (output) {
537
541
  const outputDir = String(output);
@@ -553,8 +557,12 @@ async function build({
553
557
  tookMs: Date.now() - startedAt,
554
558
  };
555
559
  } finally {
556
- if (!keepOpen) {
557
- await closeTab(tabId).catch(() => {});
560
+ if (!keepOpen || signal?.aborted) {
561
+ try {
562
+ await closeTab(tabId);
563
+ } catch (error) {
564
+ log(`Failed to close AI Studio Build tab ${tabId}: ${error?.message || error}`);
565
+ }
558
566
  }
559
567
  }
560
568
  }
@@ -33,6 +33,7 @@ const {
33
33
  waitForGenerateResponseFromNetwork,
34
34
  waitForResponse,
35
35
  } = require("./aistudio-response.cjs");
36
+ const { raceAbort, throwIfAborted } = require("./abort.cjs");
36
37
 
37
38
  const DEFAULT_MODEL = "gemini-3.1-pro-preview";
38
39
 
@@ -310,22 +311,27 @@ async function query(options) {
310
311
  cdpCommand,
311
312
  readNetworkEntries,
312
313
  log = () => {},
314
+ signal,
313
315
  } = options;
316
+ throwIfAborted(signal);
314
317
 
318
+ const guardedReadNetworkEntries = readNetworkEntries
319
+ ? (...args) => raceAbort(() => readNetworkEntries(...args), signal)
320
+ : readNetworkEntries;
315
321
  const startTime = Date.now();
316
322
  log("Starting AI Studio query");
317
323
 
318
324
  const resolvedModel = normalizeModelString(model) || DEFAULT_MODEL;
319
325
  log(`Requested model: ${resolvedModel}`);
320
326
 
321
- const { cookies } = await getCookies();
327
+ const { cookies } = await raceAbort(getCookies, signal);
322
328
  if (!hasRequiredCookies(cookies)) {
323
329
  throw new Error("Google login required - sign into Google in Chrome first");
324
330
  }
325
331
  log(`Got ${cookies.length} cookies`);
326
332
 
327
333
  const createdUrl = buildAiStudioUrl(resolvedModel);
328
- const tabInfo = await createTab(createdUrl);
334
+ const tabInfo = await raceAbort(() => createTab(createdUrl), signal);
329
335
  const { tabId } = tabInfo || {};
330
336
 
331
337
  if (!tabId) {
@@ -333,21 +339,21 @@ async function query(options) {
333
339
  }
334
340
  log(`Created tab ${tabId}`);
335
341
 
336
- const cdp = (expr) => cdpEvaluate(tabId, expr);
337
- const inputCdp = (method, params) => cdpCommand(tabId, method, params);
342
+ const cdp = (expr) => raceAbort(() => cdpEvaluate(tabId, expr), signal);
343
+ const inputCdp = (method, params) => raceAbort(() => cdpCommand(tabId, method, params), signal);
338
344
 
339
345
  let baselineGenerateEntryIds = new Set();
340
346
 
341
347
  try {
342
- await waitForPageLoad(cdp);
348
+ await raceAbort(waitForPageLoad(cdp), signal);
343
349
  log("Page loaded");
344
350
 
345
- await waitForStudioReady(cdp);
351
+ await raceAbort(waitForStudioReady(cdp), signal);
346
352
  log("AI Studio ready");
347
353
 
348
354
  if (typeof readNetworkEntries === 'function') {
349
355
  try {
350
- const baselineNetwork = await readNetworkEntries(tabId);
356
+ const baselineNetwork = await guardedReadNetworkEntries(tabId);
351
357
  const baselineEntries = Array.isArray(baselineNetwork?.entries)
352
358
  ? baselineNetwork.entries
353
359
  : Array.isArray(baselineNetwork?.requests)
@@ -362,6 +368,7 @@ async function query(options) {
362
368
 
363
369
  log(`Network baseline ready (${baselineGenerateEntryIds.size} GenerateContent entries)`);
364
370
  } catch (e) {
371
+ if (signal?.aborted) throw e;
365
372
  log(`Network baseline failed: ${e.message || e}`);
366
373
  }
367
374
  }
@@ -369,6 +376,7 @@ async function query(options) {
369
376
  try {
370
377
  await enableUnformattedMarkdownView(cdp, log);
371
378
  } catch (e) {
379
+ if (signal?.aborted) throw e;
372
380
  log(`Markdown toggle failed: ${e.message}`);
373
381
  }
374
382
 
@@ -387,8 +395,8 @@ async function query(options) {
387
395
  try {
388
396
  log(`Runtime model param missing; retrying direct navigation: ${createdUrl}`);
389
397
  await inputCdp('Page.navigate', { url: createdUrl });
390
- await waitForPageLoad(cdp);
391
- await waitForStudioReady(cdp);
398
+ await raceAbort(waitForPageLoad(cdp), signal);
399
+ await raceAbort(waitForStudioReady(cdp), signal);
392
400
  runtimeUrl = await evaluate(cdp, 'location.href');
393
401
  runtimeModelParam = await evaluate(cdp, `(() => {
394
402
  try {
@@ -398,6 +406,7 @@ async function query(options) {
398
406
  }
399
407
  })()`);
400
408
  } catch (e) {
409
+ if (signal?.aborted) throw e;
401
410
  log(`Direct model URL navigation retry failed: ${e.message || e}`);
402
411
  }
403
412
  }
@@ -408,7 +417,10 @@ async function query(options) {
408
417
  log(`Model via UI (no URL param): requested="${resolvedModel}"`);
409
418
  }
410
419
 
411
- const currentModelInfo = await readCurrentModelInfo(cdp).catch(() => ({ found: false, label: '', modelId: '' }));
420
+ const currentModelInfo = await readCurrentModelInfo(cdp).catch((error) => {
421
+ if (signal?.aborted) throw error;
422
+ return { found: false, label: '', modelId: '' };
423
+ });
412
424
 
413
425
  log(`AI Studio URL: ${runtimeUrl}`);
414
426
  if (currentModelInfo?.label) {
@@ -427,8 +439,9 @@ async function query(options) {
427
439
 
428
440
  if (!modelApplied && usedUrlParam) {
429
441
  try {
430
- modelApplied = await waitForModelToApply(cdp, resolvedModel, log);
442
+ modelApplied = await raceAbort(waitForModelToApply(cdp, resolvedModel, log), signal);
431
443
  } catch (e) {
444
+ if (signal?.aborted) throw e;
432
445
  log(`Model apply wait failed: ${e.message}`);
433
446
  }
434
447
  }
@@ -436,19 +449,20 @@ async function query(options) {
436
449
  if (!modelApplied) {
437
450
  try {
438
451
  log(`Attempting UI model selection fallback: ${resolvedModel}`);
439
- await selectModel(cdp, resolvedModel, log);
440
- modelApplied = await waitForModelToApply(cdp, resolvedModel, log, 10000);
452
+ await raceAbort(selectModel(cdp, resolvedModel, log), signal);
453
+ modelApplied = await raceAbort(waitForModelToApply(cdp, resolvedModel, log, 10000), signal);
441
454
  } catch (e) {
455
+ if (signal?.aborted) throw e;
442
456
  log(`UI model selection fallback failed: ${e.message}`);
443
457
  }
444
458
  }
445
459
 
446
- await closeModelSelectorIfOpen(cdp, log);
460
+ await raceAbort(closeModelSelectorIfOpen(cdp, log), signal);
447
461
 
448
- await typePrompt(cdp, inputCdp, prompt);
462
+ await raceAbort(typePrompt(cdp, inputCdp, prompt), signal);
449
463
  log("Prompt typed");
450
464
 
451
- await submitPrompt(cdp, inputCdp);
465
+ await raceAbort(submitPrompt(cdp, inputCdp), signal);
452
466
  log("Submitted, waiting for response...");
453
467
 
454
468
  let response;
@@ -457,10 +471,11 @@ async function query(options) {
457
471
  try {
458
472
  const networkResult = await waitForGenerateResponseFromNetwork({
459
473
  tabId,
460
- readNetworkEntries,
474
+ readNetworkEntries: guardedReadNetworkEntries,
461
475
  timeoutMs: timeout,
462
476
  baselineEntryIds: baselineGenerateEntryIds,
463
477
  prompt,
478
+ signal,
464
479
  log,
465
480
  });
466
481
 
@@ -474,13 +489,14 @@ async function query(options) {
474
489
  `${networkResult.requestId ? ` (request ${networkResult.requestId})` : ''}`
475
490
  );
476
491
  } catch (networkErr) {
492
+ if (signal?.aborted) throw networkErr;
477
493
  log(`Network extraction failed, falling back to DOM: ${networkErr.message || networkErr}`);
478
494
 
479
495
  const remainingTimeoutMs = Math.max(timeout - (Date.now() - startTime), 10000);
480
- response = await waitForResponse(cdp, remainingTimeoutMs, prompt, log);
496
+ response = await raceAbort(waitForResponse(cdp, remainingTimeoutMs, prompt, log), signal);
481
497
  }
482
498
  } else {
483
- response = await waitForResponse(cdp, timeout, prompt, log);
499
+ response = await raceAbort(waitForResponse(cdp, timeout, prompt, log), signal);
484
500
  }
485
501
 
486
502
  const thinkingInfo = response.thinkingTime ? ` (thought for ${response.thinkingTime}s)` : '';
@@ -493,7 +509,11 @@ async function query(options) {
493
509
  tookMs: Date.now() - startTime,
494
510
  };
495
511
  } finally {
496
- await closeTab(tabId).catch(() => {});
512
+ try {
513
+ await closeTab(tabId);
514
+ } catch (error) {
515
+ log(`Failed to close AI Studio tab ${tabId}: ${error?.message || error}`);
516
+ }
497
517
  }
498
518
  }
499
519
 
@@ -0,0 +1,169 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
5
+ const DEFAULT_STALE_MS = 30000;
6
+ const DEFAULT_TIMEOUT_MS = 60000;
7
+ const DEFAULT_MIN_WAIT_MS = 50;
8
+ const DEFAULT_MAX_WAIT_MS = 1000;
9
+
10
+ function sleepSync(ms) {
11
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
12
+ }
13
+
14
+ function getBrowserLockDir(endpointKey, tempDir) {
15
+ const hash = crypto.createHash("sha256").update(endpointKey).digest("hex").slice(0, 16);
16
+ return path.join(tempDir, `surf-lock-${hash}`);
17
+ }
18
+
19
+ function createToken() {
20
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
21
+ return crypto.randomBytes(16).toString("hex");
22
+ }
23
+
24
+ function readLockOwner(lockDir) {
25
+ try {
26
+ return JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8"));
27
+ } catch (error) {
28
+ if (error && (error.code === "ENOENT" || error instanceof SyntaxError)) return null;
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ function isProcessAlive(pid) {
34
+ if (!Number.isInteger(pid) || pid <= 0) return false;
35
+ try {
36
+ process.kill(pid, 0);
37
+ return true;
38
+ } catch (error) {
39
+ return Boolean(error && error.code === "EPERM");
40
+ }
41
+ }
42
+
43
+ function writeLockOwner(lockDir, socketPath, token) {
44
+ fs.writeFileSync(
45
+ path.join(lockDir, "owner.json"),
46
+ JSON.stringify({ pid: process.pid, token, socketPath, createdAt: new Date().toISOString() }),
47
+ );
48
+ }
49
+
50
+ function removeLockDir(lockDir) {
51
+ fs.rmSync(lockDir, { recursive: true, force: true });
52
+ }
53
+
54
+ function getLockTimestamp(lockDir, owner) {
55
+ const target = owner ? path.join(lockDir, "owner.json") : lockDir;
56
+ try {
57
+ return fs.statSync(target).mtimeMs;
58
+ } catch (error) {
59
+ if (error && error.code === "ENOENT") return 0;
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ function ownerMatches(left, right) {
65
+ if (!left && !right) return true;
66
+ return Boolean(left && right && left.token && left.token === right.token);
67
+ }
68
+
69
+ function tryCreateStaleClaim(lockDir, staleMs, now = Date.now()) {
70
+ const claimDir = path.join(lockDir, "stale-claim");
71
+ try {
72
+ fs.mkdirSync(claimDir);
73
+ return claimDir;
74
+ } catch (error) {
75
+ if (!error || error.code !== "EEXIST") throw error;
76
+ try {
77
+ if (now - fs.statSync(claimDir).mtimeMs > staleMs) {
78
+ fs.rmSync(claimDir, { recursive: true, force: true });
79
+ }
80
+ } catch (claimError) {
81
+ if (!claimError || claimError.code !== "ENOENT") throw claimError;
82
+ }
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function claimAndRemoveStaleLock(lockDir, staleMs, now = Date.now()) {
88
+ let lockStats;
89
+ try {
90
+ lockStats = fs.statSync(lockDir);
91
+ } catch (error) {
92
+ if (error && error.code === "ENOENT") return false;
93
+ throw error;
94
+ }
95
+
96
+ const inspectedOwner = readLockOwner(lockDir);
97
+ if (inspectedOwner && isProcessAlive(inspectedOwner.pid)) return false;
98
+ if (now - getLockTimestamp(lockDir, inspectedOwner) <= staleMs) return false;
99
+
100
+ const claimDir = tryCreateStaleClaim(lockDir, staleMs, now);
101
+ if (!claimDir) return false;
102
+
103
+ try {
104
+ const currentOwner = readLockOwner(lockDir);
105
+ if (!ownerMatches(inspectedOwner, currentOwner)) return false;
106
+ if (currentOwner && isProcessAlive(currentOwner.pid)) return false;
107
+ if (!currentOwner && Date.now() - lockStats.mtimeMs <= staleMs) return false;
108
+
109
+ removeLockDir(lockDir);
110
+ return true;
111
+ } finally {
112
+ try {
113
+ fs.rmSync(claimDir, { recursive: true, force: true });
114
+ } catch {}
115
+ }
116
+ }
117
+
118
+ function acquireBrowserLock(socketPath, tempDir, options = {}) {
119
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
120
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
121
+ const sleep = options.sleep ?? sleepSync;
122
+ const lockDir = getBrowserLockDir(socketPath, tempDir);
123
+ const startedAt = Date.now();
124
+ let waitMs = options.minWaitMs ?? DEFAULT_MIN_WAIT_MS;
125
+ const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
126
+
127
+ while (true) {
128
+ const token = createToken();
129
+ try {
130
+ fs.mkdirSync(lockDir, { recursive: false, mode: 0o700 });
131
+ try {
132
+ writeLockOwner(lockDir, socketPath, token);
133
+ } catch (error) {
134
+ removeLockDir(lockDir);
135
+ throw error;
136
+ }
137
+ let released = false;
138
+ return {
139
+ lockDir,
140
+ release() {
141
+ if (released) return;
142
+ released = true;
143
+ const owner = readLockOwner(lockDir);
144
+ if (owner && owner.token === token) removeLockDir(lockDir);
145
+ },
146
+ };
147
+ } catch (error) {
148
+ if (!error || error.code !== "EEXIST") throw error;
149
+ }
150
+
151
+ if (claimAndRemoveStaleLock(lockDir, staleMs)) continue;
152
+
153
+ if (Date.now() - startedAt >= timeoutMs) {
154
+ throw new Error(
155
+ `Timed out waiting for browser lock after ${Math.round(timeoutMs / 1000)}s. Use --no-lock to bypass.`,
156
+ );
157
+ }
158
+
159
+ sleep(waitMs);
160
+ waitMs = Math.min(Math.round(waitMs * 1.5), maxWaitMs);
161
+ }
162
+ }
163
+
164
+ module.exports = {
165
+ DEFAULT_STALE_MS,
166
+ DEFAULT_TIMEOUT_MS,
167
+ acquireBrowserLock,
168
+ getBrowserLockDir,
169
+ };