surf-cli 2.8.0 → 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 (42) hide show
  1. package/README.md +98 -4
  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 +2 -2
  14. package/native/chatgpt-client.cjs +47 -31
  15. package/native/cli.cjs +300 -204
  16. package/native/client-transport.cjs +168 -0
  17. package/native/do-executor.cjs +25 -44
  18. package/native/doctor.cjs +55 -5
  19. package/native/endpoint.cjs +174 -0
  20. package/native/file-transfer.cjs +734 -0
  21. package/native/gemini-client.cjs +156 -71
  22. package/native/grok-client.cjs +98 -89
  23. package/native/host-helpers.cjs +37 -12
  24. package/native/host-sessions.cjs +283 -0
  25. package/native/host.cjs +800 -620
  26. package/native/listener.cjs +20 -0
  27. package/native/mcp-server.cjs +60 -65
  28. package/native/network-export.cjs +113 -0
  29. package/native/perplexity-client.cjs +46 -17
  30. package/native/remote-auth.cjs +279 -0
  31. package/native/remote-transport.cjs +337 -0
  32. package/native/request-pending.cjs +148 -0
  33. package/native/socket-path.cjs +1 -1
  34. package/package.json +8 -6
  35. package/scripts/install-native-host.cjs +36 -5
  36. package/skills/README.md +11 -5
  37. package/skills/deep-x-research/SKILL.md +106 -0
  38. package/skills/surf/SKILL.md +31 -4
  39. package/dist/content/accessibility-tree.js +0 -11
  40. package/dist/content/accessibility-tree.js.map +0 -1
  41. package/dist/content/visual-indicator.js +0 -111
  42. 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
 
@@ -11,8 +11,8 @@ function sleepSync(ms) {
11
11
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
12
12
  }
13
13
 
14
- function getBrowserLockDir(socketPath, tempDir) {
15
- const hash = crypto.createHash("sha256").update(socketPath).digest("hex").slice(0, 16);
14
+ function getBrowserLockDir(endpointKey, tempDir) {
15
+ const hash = crypto.createHash("sha256").update(endpointKey).digest("hex").slice(0, 16);
16
16
  return path.join(tempDir, `surf-lock-${hash}`);
17
17
  }
18
18
 
@@ -1,4 +1,5 @@
1
1
  const path = require("path");
2
+ const { abortableDelay, raceAbort, throwIfAborted } = require("./abort.cjs");
2
3
 
3
4
  const CHATGPT_URL = "https://chatgpt.com/";
4
5
 
@@ -14,8 +15,8 @@ const SELECTORS = {
14
15
  cloudflareScript: 'script[src*="/challenge-platform/"]',
15
16
  };
16
17
 
17
- function delay(ms) {
18
- return new Promise((resolve) => setTimeout(resolve, ms));
18
+ function delay(ms, signal) {
19
+ return abortableDelay(ms, signal);
19
20
  }
20
21
 
21
22
  function buildClickDispatcher() {
@@ -189,8 +190,10 @@ function isChatGPTResponseComplete(snapshot, stableCycles, stableMs) {
189
190
  return stableCycles >= 6 && stableMs >= 1200;
190
191
  }
191
192
 
192
- async function evaluate(cdp, expression) {
193
+ async function evaluate(cdp, expression, signal) {
194
+ throwIfAborted(signal);
193
195
  const result = await cdp(expression);
196
+ throwIfAborted(signal);
194
197
  if (result.exceptionDetails) {
195
198
  const desc = result.exceptionDetails.exception?.description ||
196
199
  result.exceptionDetails.text ||
@@ -203,14 +206,15 @@ async function evaluate(cdp, expression) {
203
206
  return result.result?.value;
204
207
  }
205
208
 
206
- async function waitForPageLoad(cdp, timeoutMs = 45000) {
209
+ async function waitForPageLoad(cdp, timeoutMs = 45000, signal) {
210
+ throwIfAborted(signal);
207
211
  const deadline = Date.now() + timeoutMs;
208
212
  while (Date.now() < deadline) {
209
213
  const ready = await evaluate(cdp, "document.readyState");
210
214
  if (ready === "complete" || ready === "interactive") {
211
215
  return;
212
216
  }
213
- await delay(100);
217
+ await delay(100, signal);
214
218
  }
215
219
  throw new Error("Page did not load in time");
216
220
  }
@@ -252,7 +256,8 @@ async function checkLoginStatus(cdp) {
252
256
  return result || { status: 0 };
253
257
  }
254
258
 
255
- async function waitForPromptReady(cdp, timeoutMs = 30000) {
259
+ async function waitForPromptReady(cdp, timeoutMs = 30000, signal) {
260
+ throwIfAborted(signal);
256
261
  const deadline = Date.now() + timeoutMs;
257
262
  const selectors = JSON.stringify(SELECTORS.promptTextarea.split(", "));
258
263
  while (Date.now() < deadline) {
@@ -270,7 +275,7 @@ async function waitForPromptReady(cdp, timeoutMs = 30000) {
270
275
  })()`
271
276
  );
272
277
  if (found) return true;
273
- await delay(200);
278
+ await delay(200, signal);
274
279
  }
275
280
  return false;
276
281
  }
@@ -302,7 +307,8 @@ function resolveChatGPTModelMenuOption(items, desiredModel) {
302
307
  }) || null;
303
308
  }
304
309
 
305
- async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
310
+ async function selectModel(cdp, desiredModel, timeoutMs = 8000, signal) {
311
+ throwIfAborted(signal);
306
312
  const modelButton = await evaluate(
307
313
  cdp,
308
314
  `(() => {
@@ -321,7 +327,7 @@ async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
321
327
  if (btn) dispatchClickSequence(btn);
322
328
  })()`
323
329
  );
324
- await delay(300);
330
+ await delay(300, signal);
325
331
 
326
332
  const normalizedModel = normalizeChatGPTModelChoice(desiredModel);
327
333
  const deadline = Date.now() + timeoutMs;
@@ -361,7 +367,7 @@ async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
361
367
  if (item) dispatchClickSequence(item);
362
368
  })()`
363
369
  );
364
- await delay(200);
370
+ await delay(200, signal);
365
371
  return match.label;
366
372
  }
367
373
 
@@ -379,13 +385,14 @@ async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
379
385
  );
380
386
  }
381
387
 
382
- await delay(100);
388
+ await delay(100, signal);
383
389
  }
384
390
 
385
391
  throw new Error(`Model not found: ${desiredModel} (timeout)`);
386
392
  }
387
393
 
388
- async function typePrompt(cdp, inputCdp, prompt) {
394
+ async function typePrompt(cdp, inputCdp, prompt, signal) {
395
+ throwIfAborted(signal);
389
396
  const selectors = JSON.stringify(SELECTORS.promptTextarea.split(", "));
390
397
  const encodedPrompt = JSON.stringify(prompt);
391
398
  const focused = await evaluate(
@@ -416,7 +423,7 @@ async function typePrompt(cdp, inputCdp, prompt) {
416
423
  throw new Error("Failed to focus prompt textarea");
417
424
  }
418
425
  await inputCdp("Input.insertText", { text: prompt });
419
- await delay(300);
426
+ await delay(300, signal);
420
427
  const verified = await evaluate(
421
428
  cdp,
422
429
  `(() => {
@@ -449,7 +456,8 @@ async function typePrompt(cdp, inputCdp, prompt) {
449
456
  }
450
457
  }
451
458
 
452
- async function clickSend(cdp, inputCdp) {
459
+ async function clickSend(cdp, inputCdp, signal) {
460
+ throwIfAborted(signal);
453
461
  const selectors = SELECTORS.sendButton.split(", ");
454
462
  const selectorsJson = JSON.stringify(selectors);
455
463
  const deadline = Date.now() + 8000;
@@ -475,7 +483,7 @@ async function clickSend(cdp, inputCdp) {
475
483
  );
476
484
  if (result === "clicked") return true;
477
485
  if (result === "missing") break;
478
- await delay(100);
486
+ await delay(100, signal);
479
487
  }
480
488
  await inputCdp("Input.dispatchKeyEvent", {
481
489
  type: "keyDown",
@@ -574,8 +582,10 @@ async function waitForResponse(
574
582
  cdp,
575
583
  timeoutMs = 2700000,
576
584
  baselineAssistant,
577
- baselineAssistantCount
585
+ baselineAssistantCount,
586
+ signal
578
587
  ) {
588
+ throwIfAborted(signal);
579
589
  const deadline = Date.now() + timeoutMs;
580
590
  let previousText = "";
581
591
  let stableCycles = 0;
@@ -588,7 +598,7 @@ async function waitForResponse(
588
598
  const snapshot = await readChatGPTResponseSnapshot(cdp);
589
599
 
590
600
  if (!snapshot) {
591
- await delay(400);
601
+ await delay(400, signal);
592
602
  continue;
593
603
  }
594
604
 
@@ -602,7 +612,7 @@ async function waitForResponse(
602
612
  );
603
613
 
604
614
  if (!hasNewAssistantContent) {
605
- await delay(400);
615
+ await delay(400, signal);
606
616
  continue;
607
617
  }
608
618
 
@@ -630,7 +640,7 @@ async function waitForResponse(
630
640
  };
631
641
  }
632
642
 
633
- await delay(400);
643
+ await delay(400, signal);
634
644
  }
635
645
 
636
646
  throw new Error("Response timeout");
@@ -649,26 +659,31 @@ async function query(options) {
649
659
  cdpCommand,
650
660
  uploadFile,
651
661
  log = () => {},
662
+ signal,
652
663
  } = options;
664
+ throwIfAborted(signal);
665
+ const guardedUploadFile = uploadFile
666
+ ? (...args) => raceAbort(() => uploadFile(...args), signal)
667
+ : uploadFile;
653
668
  const startTime = Date.now();
654
669
  log("Starting ChatGPT query");
655
- const { cookies } = await getCookies();
670
+ const { cookies } = await raceAbort(getCookies, signal);
656
671
  if (!hasRequiredCookies(cookies)) {
657
672
  throw new Error("ChatGPT login required");
658
673
  }
659
674
  log(`Got ${cookies.length} cookies`);
660
- const tabInfo = await createTab();
675
+ const tabInfo = await raceAbort(createTab, signal);
661
676
  const { tabId } = tabInfo;
662
677
  if (!tabId) {
663
678
  throw new Error("Failed to create ChatGPT tab");
664
679
  }
665
680
  log(`Created tab ${tabId}`);
666
681
 
667
- const cdp = (expr) => cdpEvaluate(tabId, expr);
668
- const inputCdp = (method, params) => cdpCommand(tabId, method, params);
682
+ const cdp = (expr) => raceAbort(() => cdpEvaluate(tabId, expr), signal);
683
+ const inputCdp = (method, params) => raceAbort(() => cdpCommand(tabId, method, params), signal);
669
684
 
670
685
  try {
671
- await waitForPageLoad(cdp);
686
+ await waitForPageLoad(cdp, 45000, signal);
672
687
  log("Page loaded");
673
688
  if (await isCloudflareBlocked(cdp)) {
674
689
  throw new Error("Cloudflare challenge detected - complete in browser");
@@ -685,13 +700,13 @@ async function query(options) {
685
700
  throw new Error("ChatGPT login required");
686
701
  }
687
702
  log("Login verified");
688
- const promptReady = await waitForPromptReady(cdp);
703
+ const promptReady = await waitForPromptReady(cdp, 30000, signal);
689
704
  if (!promptReady) {
690
705
  throw new Error("Prompt textarea not ready");
691
706
  }
692
707
  log("Prompt ready");
693
708
  if (model) {
694
- const selectedLabel = await selectModel(cdp, model);
709
+ const selectedLabel = await selectModel(cdp, model, 8000, signal);
695
710
  log(`Selected model: ${selectedLabel}`);
696
711
  }
697
712
  if (file) {
@@ -701,7 +716,7 @@ async function query(options) {
701
716
  const files = Array.isArray(file) ? file : [file];
702
717
  const absFiles = files.map((filePath) => path.resolve(process.cwd(), filePath));
703
718
  log(`Uploading ${absFiles.length} file(s) to ChatGPT...`);
704
- const uploadResult = await uploadFile(tabId, absFiles);
719
+ const uploadResult = await guardedUploadFile(tabId, absFiles);
705
720
  if (uploadResult?.error) {
706
721
  throw new Error(`ChatGPT file upload failed: ${uploadResult.error}`);
707
722
  }
@@ -709,18 +724,19 @@ async function query(options) {
709
724
  throw new Error("ChatGPT file upload failed: upload did not report success");
710
725
  }
711
726
  log("File uploaded, waiting for ChatGPT attachment processing...");
712
- await delay(1500);
727
+ await delay(1500, signal);
713
728
  }
714
- await typePrompt(cdp, inputCdp, prompt);
729
+ await typePrompt(cdp, inputCdp, prompt, signal);
715
730
  log("Prompt typed");
716
731
  const baseline = normalizeResponseSnapshot(await readChatGPTResponseSnapshot(cdp));
717
- await clickSend(cdp, inputCdp);
732
+ await clickSend(cdp, inputCdp, signal);
718
733
  log("Prompt sent, waiting for response...");
719
734
  const response = await waitForResponse(
720
735
  cdp,
721
736
  timeout,
722
737
  baseline.latestAssistant,
723
- baseline.assistantCount
738
+ baseline.assistantCount,
739
+ signal
724
740
  );
725
741
  log(`Response received (${response.text.length} chars)`);
726
742
  return {