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.
- package/README.md +208 -13
- package/dist/content/index.js +116 -0
- package/dist/content/index.js.map +1 -0
- package/dist/manifest.json +2 -11
- package/dist/options/options.js +3 -3
- package/dist/options/options.js.map +1 -1
- package/dist/service-worker/index.js +261 -61
- package/dist/service-worker/index.js.map +1 -1
- package/native/abort.cjs +65 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +169 -0
- package/native/chatgpt-client.cjs +63 -30
- package/native/cli.cjs +947 -460
- package/native/client-transport.cjs +168 -0
- package/native/config.cjs +2 -2
- package/native/do-executor.cjs +25 -51
- package/native/do-parser.cjs +12 -0
- package/native/doctor.cjs +633 -0
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +244 -88
- package/native/grok-client.cjs +321 -212
- package/native/host-helpers.cjs +88 -16
- package/native/host-sessions.cjs +283 -0
- package/native/host.cjs +811 -616
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -62
- package/native/network-export.cjs +113 -0
- package/native/perplexity-client.cjs +46 -17
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +46 -0
- package/package.json +11 -9
- package/scripts/install-native-host.cjs +184 -51
- package/scripts/uninstall-native-host.cjs +93 -15
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- package/skills/surf/SKILL.md +77 -22
- package/dist/content/accessibility-tree.js +0 -11
- package/dist/content/accessibility-tree.js.map +0 -1
- package/dist/content/visual-indicator.js +0 -111
- package/dist/content/visual-indicator.js.map +0 -1
package/native/abort.cjs
ADDED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
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
|
+
};
|