@steipete/oracle 0.8.1 → 0.8.3

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 CHANGED
@@ -18,7 +18,7 @@ Oracle bundles your prompt and files so another AI can answer with real context.
18
18
  Install globally: `npm install -g @steipete/oracle`
19
19
  Homebrew: `brew install steipete/tap/oracle`
20
20
 
21
- Requires Node 22+. Prefer `npx -y @steipete/oracle …` (pnpx can be quirky with ESM caching).
21
+ Requires Node 22+. Or use `npx -y @steipete/oracle …` (or pnpx).
22
22
 
23
23
  ```bash
24
24
  # Copy the bundle and paste into ChatGPT
@@ -149,7 +149,7 @@ Advanced flags
149
149
 
150
150
  | Area | Flags |
151
151
  | --- | --- |
152
- | Browser | `--browser-manual-login`, `--browser-thinking-time`, `--browser-timeout`, `--browser-input-timeout`, `--browser-inline-cookies[(-file)]`, `--browser-attachments`, `--browser-inline-files`, `--browser-bundle-files`, `--browser-keep-browser`, `--browser-headless`, `--browser-hide-window`, `--browser-no-cookie-sync`, `--browser-allow-cookie-errors`, `--browser-chrome-path`, `--browser-cookie-path`, `--chatgpt-url` |
152
+ | Browser | `--browser-manual-login`, `--browser-thinking-time`, `--browser-timeout`, `--browser-input-timeout`, `--browser-cookie-wait`, `--browser-inline-cookies[(-file)]`, `--browser-attachments`, `--browser-inline-files`, `--browser-bundle-files`, `--browser-keep-browser`, `--browser-headless`, `--browser-hide-window`, `--browser-no-cookie-sync`, `--browser-allow-cookie-errors`, `--browser-chrome-path`, `--browser-cookie-path`, `--chatgpt-url` |
153
153
  | Azure/OpenAI | `--azure-endpoint`, `--azure-deployment`, `--azure-api-version`, `--base-url` |
154
154
 
155
155
  Remote browser example
@@ -163,6 +163,7 @@ program
163
163
  .addOption(new Option('--browser-url <url>', `Alias for --chatgpt-url (default ${CHATGPT_URL}).`).hideHelp())
164
164
  .addOption(new Option('--browser-timeout <ms|s|m>', 'Maximum time to wait for an answer (default 1200s / 20m).').hideHelp())
165
165
  .addOption(new Option('--browser-input-timeout <ms|s|m>', 'Maximum time to wait for the prompt textarea (default 30s).').hideHelp())
166
+ .addOption(new Option('--browser-cookie-wait <ms|s|m>', 'Wait before retrying cookie sync when Chrome cookies are empty or locked.').hideHelp())
166
167
  .addOption(new Option('--browser-port <port>', 'Use a fixed Chrome DevTools port (helpful on WSL firewalls).')
167
168
  .argParser(parseIntOption))
168
169
  .addOption(new Option('--browser-debug-port <port>', '(alias) Use a fixed Chrome DevTools port.').argParser(parseIntOption).hideHelp())
@@ -926,6 +927,7 @@ function printDebugHelp(cliName) {
926
927
  ['--browser-url <url>', 'Alias for --chatgpt-url.'],
927
928
  ['--browser-timeout <ms|s|m>', 'Cap total wait time for the assistant response.'],
928
929
  ['--browser-input-timeout <ms|s|m>', 'Cap how long we wait for the composer textarea.'],
930
+ ['--browser-cookie-wait <ms|s|m>', 'Wait before retrying cookie sync when Chrome cookies are empty or locked.'],
929
931
  ['--browser-no-cookie-sync', 'Skip copying cookies from your main profile.'],
930
932
  ['--browser-manual-login', 'Skip cookie copy; reuse a persistent automation profile and log in manually.'],
931
933
  ['--browser-headless', 'Launch Chrome in headless mode.'],
@@ -4,7 +4,7 @@ import { delay } from '../utils.js';
4
4
  import { logDomFailure } from '../domDebug.js';
5
5
  import { transferAttachmentViaDataTransfer } from './attachmentDataTransfer.js';
6
6
  export async function uploadAttachmentFile(deps, attachment, logger, options) {
7
- const { runtime, dom } = deps;
7
+ const { runtime, dom, input } = deps;
8
8
  if (!dom) {
9
9
  throw new Error('DOM domain unavailable while uploading attachments.');
10
10
  }
@@ -170,7 +170,7 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
170
170
  });
171
171
  });
172
172
 
173
- const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
173
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
174
174
  const collectFileCount = (candidates) => {
175
175
  let count = 0;
176
176
  for (const node of candidates) {
@@ -204,7 +204,8 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
204
204
  let hasFileHint = false;
205
205
  for (const raw of values) {
206
206
  if (!raw) continue;
207
- if (normalize(raw).includes('file')) {
207
+ const normalized = normalize(raw);
208
+ if (normalized.includes('file') || normalized.includes('attachment')) {
208
209
  hasFileHint = true;
209
210
  break;
210
211
  }
@@ -273,43 +274,70 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
273
274
  };
274
275
  };
275
276
  // New ChatGPT UI hides the real file input behind a composer "+" menu; click it pre-emptively.
276
- await Promise.resolve(runtime.evaluate({
277
- expression: `(() => {
278
- const selectors = [
279
- '#composer-plus-btn',
280
- 'button[data-testid="composer-plus-btn"]',
281
- '[data-testid*="plus"]',
282
- 'button[aria-label*="add"]',
283
- 'button[aria-label*="attachment"]',
284
- 'button[aria-label*="file"]',
285
- ];
286
- for (const selector of selectors) {
287
- const el = document.querySelector(selector);
288
- if (el instanceof HTMLElement) {
289
- el.click();
290
- return true;
277
+ // Learned: synthetic `.click()` is sometimes ignored (isTrusted checks). Prefer a CDP mouse click when possible.
278
+ const clickPlusTrusted = async () => {
279
+ if (!input || typeof input.dispatchMouseEvent !== 'function')
280
+ return false;
281
+ const locate = await runtime
282
+ .evaluate({
283
+ expression: `(() => {
284
+ const selectors = [
285
+ '#composer-plus-btn',
286
+ 'button[data-testid="composer-plus-btn"]',
287
+ '[data-testid*="plus"]',
288
+ 'button[aria-label*="add"]',
289
+ 'button[aria-label*="attachment"]',
290
+ 'button[aria-label*="file"]',
291
+ ];
292
+ for (const selector of selectors) {
293
+ const el = document.querySelector(selector);
294
+ if (!(el instanceof HTMLElement)) continue;
295
+ const rect = el.getBoundingClientRect();
296
+ if (rect.width <= 0 || rect.height <= 0) continue;
297
+ el.scrollIntoView({ block: 'center', inline: 'center' });
298
+ const nextRect = el.getBoundingClientRect();
299
+ return { ok: true, x: nextRect.left + nextRect.width / 2, y: nextRect.top + nextRect.height / 2 };
291
300
  }
292
- }
293
- return false;
294
- })()`,
295
- returnByValue: true,
296
- })).catch(() => undefined);
297
- await delay(250);
298
- // Helper to click the upload menu item (if present) to reveal the real attachment input.
299
- await Promise.resolve(runtime.evaluate({
300
- expression: `(() => {
301
- const menuItems = Array.from(document.querySelectorAll('[data-testid*="upload"],[data-testid*="attachment"], [role="menuitem"], [data-radix-collection-item]'));
302
- for (const el of menuItems) {
303
- const text = (el.textContent || '').toLowerCase();
304
- const tid = el.getAttribute?.('data-testid')?.toLowerCase?.() || '';
305
- if (tid.includes('upload') || tid.includes('attachment') || text.includes('upload') || text.includes('file')) {
306
- if (el instanceof HTMLElement) { el.click(); return true; }
301
+ return { ok: false };
302
+ })()`,
303
+ returnByValue: true,
304
+ })
305
+ .then((res) => res?.result?.value)
306
+ .catch(() => undefined);
307
+ if (!locate?.ok || typeof locate.x !== 'number' || typeof locate.y !== 'number')
308
+ return false;
309
+ const x = locate.x;
310
+ const y = locate.y;
311
+ await input.dispatchMouseEvent({ type: 'mouseMoved', x, y });
312
+ await input.dispatchMouseEvent({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
313
+ await input.dispatchMouseEvent({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
314
+ return true;
315
+ };
316
+ const clickedTrusted = await clickPlusTrusted().catch(() => false);
317
+ if (!clickedTrusted) {
318
+ await Promise.resolve(runtime.evaluate({
319
+ expression: `(() => {
320
+ const selectors = [
321
+ '#composer-plus-btn',
322
+ 'button[data-testid="composer-plus-btn"]',
323
+ '[data-testid*="plus"]',
324
+ 'button[aria-label*="add"]',
325
+ 'button[aria-label*="attachment"]',
326
+ 'button[aria-label*="file"]',
327
+ ];
328
+ for (const selector of selectors) {
329
+ const el = document.querySelector(selector);
330
+ if (el instanceof HTMLElement) {
331
+ el.click();
332
+ return true;
333
+ }
307
334
  }
308
- }
309
- return false;
310
- })()`,
311
- returnByValue: true,
312
- })).catch(() => undefined);
335
+ return false;
336
+ })()`,
337
+ returnByValue: true,
338
+ })).catch(() => undefined);
339
+ }
340
+ await delay(350);
313
341
  const normalizeForMatch = (value) => String(value || '')
314
342
  .toLowerCase()
315
343
  .replace(/\s+/g, ' ')
@@ -461,7 +489,7 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
461
489
  return /\\buploading\\b/.test(text) || /\\bprocessing\\b/.test(text);
462
490
  });
463
491
  });
464
- const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
492
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
465
493
  const collectFileCount = (candidates) => {
466
494
  let count = 0;
467
495
  for (const node of candidates) {
@@ -495,7 +523,8 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
495
523
  let hasFileHint = false;
496
524
  for (const raw of values) {
497
525
  if (!raw) continue;
498
- if (String(raw).toLowerCase().includes('file')) {
526
+ const lowered = String(raw).toLowerCase();
527
+ if (lowered.includes('file') || lowered.includes('attachment')) {
499
528
  hasFileHint = true;
500
529
  break;
501
530
  }
@@ -537,23 +566,25 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
537
566
  }
538
567
 
539
568
  // Mark candidates with stable indices so we can select them via DOM.querySelector.
569
+ // Learned: ChatGPT sometimes renders a zero-sized file input that does *not* trigger uploads;
570
+ // keep it as a fallback, but strongly prefer visible (even sr-only 1x1) inputs.
571
+ const localSet = new Set(localInputs);
540
572
  let idx = 0;
541
- let candidates = inputs.map((el) => {
573
+ const candidates = inputs.map((el) => {
542
574
  const accept = el.getAttribute('accept') || '';
543
575
  const imageOnly = acceptIsImageOnly(accept);
576
+ const rect = el instanceof HTMLElement ? el.getBoundingClientRect() : { width: 0, height: 0 };
577
+ const visible = rect.width > 0 && rect.height > 0;
578
+ const local = localSet.has(el);
544
579
  const score =
545
580
  (el.hasAttribute('multiple') ? 100 : 0) +
546
- (!imageOnly ? 20 : isImageAttachment ? 15 : -500);
581
+ (local ? 40 : 0) +
582
+ (visible ? 30 : -200) +
583
+ (!imageOnly ? 30 : isImageAttachment ? 20 : 5);
547
584
  el.setAttribute('data-oracle-upload-candidate', 'true');
548
585
  el.setAttribute('data-oracle-upload-idx', String(idx));
549
- return { idx: idx++, score, imageOnly };
586
+ return { idx: idx++, score, imageOnly, visible, local };
550
587
  });
551
- if (!isImageAttachment) {
552
- const nonImage = candidates.filter((candidate) => !candidate.imageOnly);
553
- if (nonImage.length > 0) {
554
- candidates = nonImage;
555
- }
556
- }
557
588
 
558
589
  // Prefer higher scores first.
559
590
  candidates.sort((a, b) => b.score - a.score);
@@ -822,25 +853,25 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
822
853
  const evaluateSignals = async (signalResult, postInputSignals, immediateInputMatch) => {
823
854
  const expectedSatisfied = Boolean(signalResult?.expectedSatisfied) ||
824
855
  (signalResult?.signals ? isExpectedSatisfied(signalResult.signals) : false);
825
- const uiAcknowledged = Boolean(signalResult?.signals?.ui) ||
826
- Boolean(signalResult?.chipDelta) ||
827
- Boolean(signalResult?.uploadDelta) ||
828
- Boolean(signalResult?.fileCountDelta) ||
829
- expectedSatisfied;
830
- if (uiAcknowledged) {
856
+ const inputNameCandidates = resolveInputNameCandidates();
857
+ const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
858
+ (lastInputValue && matchesExpectedName(lastInputValue));
859
+ const inputEvidence = immediateInputMatch ||
860
+ postInputSignals.touched ||
861
+ Boolean(signalResult?.signals?.input) ||
862
+ Boolean(signalResult?.inputDelta) ||
863
+ inputHasFile;
864
+ const uiDirect = Boolean(signalResult?.signals?.ui) || expectedSatisfied;
865
+ const uiDelta = Boolean(signalResult?.chipDelta) || Boolean(signalResult?.uploadDelta) || Boolean(signalResult?.fileCountDelta);
866
+ if (uiDirect || (uiDelta && inputEvidence)) {
831
867
  return { status: 'ui' };
832
868
  }
833
869
  const postSignals = await readAttachmentSignals(expectedName);
834
870
  if (postSignals.ui ||
835
871
  isExpectedSatisfied(postSignals) ||
836
- hasChipDelta(postSignals) ||
837
- hasUploadDelta(postSignals) ||
838
- hasFileCountDelta(postSignals)) {
872
+ ((hasChipDelta(postSignals) || hasUploadDelta(postSignals) || hasFileCountDelta(postSignals)) && inputEvidence)) {
839
873
  return { status: 'ui' };
840
874
  }
841
- const inputNameCandidates = resolveInputNameCandidates();
842
- const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
843
- (lastInputValue && matchesExpectedName(lastInputValue));
844
875
  const inputSignal = immediateInputMatch ||
845
876
  postInputSignals.touched ||
846
877
  Boolean(signalResult?.signals?.input) ||
@@ -859,6 +890,22 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
859
890
  if (!hasExpectedFile) {
860
891
  if (mode === 'set') {
861
892
  await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
893
+ await runtime
894
+ .evaluate({
895
+ expression: `(() => {
896
+ const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
897
+ if (!(input instanceof HTMLInputElement)) return false;
898
+ try {
899
+ input.dispatchEvent(new Event('input', { bubbles: true }));
900
+ input.dispatchEvent(new Event('change', { bubbles: true }));
901
+ return true;
902
+ } catch {
903
+ return false;
904
+ }
905
+ })()`,
906
+ returnByValue: true,
907
+ })
908
+ .catch(() => undefined);
862
909
  }
863
910
  else {
864
911
  const selector = `input[type="file"][data-oracle-upload-idx="${idx}"]`;
@@ -889,9 +936,19 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
889
936
  break;
890
937
  }
891
938
  if (result.evaluation.status === 'input') {
892
- logger('Attachment input set; proceeding without UI confirmation.');
893
- inputConfirmed = true;
894
- break;
939
+ logger('Attachment input set; retrying with data transfer to trigger ChatGPT upload.');
940
+ await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [] }).catch(() => undefined);
941
+ await delay(150);
942
+ result = await runInputAttempt('transfer');
943
+ if (result.evaluation.status === 'ui') {
944
+ confirmedAttachment = true;
945
+ break;
946
+ }
947
+ if (result.evaluation.status === 'input') {
948
+ logger('Attachment input set; proceeding without UI confirmation.');
949
+ inputConfirmed = true;
950
+ break;
951
+ }
895
952
  }
896
953
  const lateSignals = await readAttachmentSignals(expectedName);
897
954
  if (lateSignals.ui ||
@@ -1007,12 +1064,12 @@ export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
1007
1064
  return parentHasSend ? parent : root;
1008
1065
  })();
1009
1066
  const removeSelectors = [
1010
- '[aria-label*="Remove"]',
1011
- '[aria-label*="remove"]',
1012
- 'button[aria-label*="Remove"]',
1013
- 'button[aria-label*="remove"]',
1014
- '[data-testid*="remove"]',
1015
- '[data-testid*="delete"]',
1067
+ '[aria-label="Remove file"]',
1068
+ 'button[aria-label="Remove file"]',
1069
+ '[aria-label*="Remove file"]',
1070
+ '[aria-label*="remove file"]',
1071
+ '[data-testid*="remove-attachment"]',
1072
+ '[data-testid*="attachment-remove"]',
1016
1073
  ];
1017
1074
  const visible = (el) => {
1018
1075
  if (!(el instanceof HTMLElement)) return false;
@@ -1023,10 +1080,15 @@ export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
1023
1080
  ? Array.from(scope.querySelectorAll(removeSelectors.join(','))).filter(visible)
1024
1081
  : [];
1025
1082
  for (const button of removeButtons.slice(0, 20)) {
1026
- try { button.click(); } catch {}
1083
+ try {
1084
+ if (button instanceof HTMLButtonElement) {
1085
+ // Ensure remove buttons never submit the composer form.
1086
+ button.type = 'button';
1087
+ }
1088
+ button.click();
1089
+ } catch {}
1027
1090
  }
1028
- const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
1029
- const chipCount = scope ? scope.querySelectorAll(chipSelector).length : 0;
1091
+ const chipCount = removeButtons.length;
1030
1092
  const inputs = scope ? Array.from(scope.querySelectorAll('input[type="file"]')) : [];
1031
1093
  let inputCount = 0;
1032
1094
  for (const input of inputs) {
@@ -1189,7 +1251,7 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1189
1251
  if (file?.name) inputNames.push(file.name.toLowerCase());
1190
1252
  }
1191
1253
  }
1192
- const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
1254
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1193
1255
  const fileCountSelectors = [
1194
1256
  'button',
1195
1257
  '[role="button"]',
@@ -1235,7 +1297,8 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1235
1297
  let hasFileHint = false;
1236
1298
  for (const raw of candidates) {
1237
1299
  if (!raw) continue;
1238
- if (String(raw).toLowerCase().includes('file')) {
1300
+ const lowered = String(raw).toLowerCase();
1301
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1239
1302
  hasFileHint = true;
1240
1303
  break;
1241
1304
  }
@@ -1340,9 +1403,8 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1340
1403
  if (stable && value.state === 'ready') {
1341
1404
  return;
1342
1405
  }
1343
- if (stable && value.state === 'disabled' && !value.uploading) {
1344
- return;
1345
- }
1406
+ // Don't treat disabled button as complete - wait for it to become 'ready'.
1407
+ // The spinner detection is unreliable, so a disabled button likely means upload is in progress.
1346
1408
  if (value.state === 'missing' && (value.filesAttached || fileCountSatisfied)) {
1347
1409
  return;
1348
1410
  }
@@ -1363,16 +1425,18 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1363
1425
  const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
1364
1426
  return !inputNames.some((raw) => raw.includes(normalizedExpected) || (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
1365
1427
  });
1366
- const inputStateOk = value.state === 'ready' || value.state === 'missing' || value.state === 'disabled';
1428
+ // Don't include 'disabled' - a disabled button likely means upload is still in progress.
1429
+ const inputStateOk = value.state === 'ready' || value.state === 'missing';
1367
1430
  const inputSeenNow = inputMissing.length === 0 || fileCountSatisfied;
1431
+ const inputEvidenceOk = Boolean(value.filesAttached) || Boolean(value.uploading) || fileCountSatisfied;
1368
1432
  const stableThresholdMs = value.uploading ? 3000 : 1500;
1369
- if (inputSeenNow && inputStateOk) {
1433
+ if (inputSeenNow && inputStateOk && inputEvidenceOk) {
1370
1434
  if (inputMatchSince === null) {
1371
1435
  inputMatchSince = Date.now();
1372
1436
  }
1373
1437
  sawInputMatch = true;
1374
1438
  }
1375
- if (inputMatchSince !== null && inputStateOk && Date.now() - inputMatchSince > stableThresholdMs) {
1439
+ if (inputMatchSince !== null && inputStateOk && inputEvidenceOk && Date.now() - inputMatchSince > stableThresholdMs) {
1376
1440
  return;
1377
1441
  }
1378
1442
  if (!inputSeenNow && !sawInputMatch) {
@@ -1416,10 +1480,10 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1416
1480
  '[title*="file"]',
1417
1481
  '[title*="attachment"]',
1418
1482
  ];
1483
+ const attachmentUiCount = lastUser.querySelectorAll(attachmentSelectors.join(',')).length;
1419
1484
  const hasAttachmentUi =
1420
- lastUser.querySelectorAll(attachmentSelectors.join(',')).length > 0 ||
1421
- attrs.some((attr) => attr.includes('file') || attr.includes('attachment'));
1422
- const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
1485
+ attachmentUiCount > 0 || attrs.some((attr) => attr.includes('file') || attr.includes('attachment'));
1486
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1423
1487
  const fileCountNodes = Array.from(lastUser.querySelectorAll('button,span,div,[aria-label],[title]'));
1424
1488
  let fileCount = 0;
1425
1489
  for (const node of fileCountNodes) {
@@ -1435,7 +1499,8 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1435
1499
  let hasFileHint = false;
1436
1500
  for (const raw of candidates) {
1437
1501
  if (!raw) continue;
1438
- if (String(raw).toLowerCase().includes('file')) {
1502
+ const lowered = String(raw).toLowerCase();
1503
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1439
1504
  hasFileHint = true;
1440
1505
  break;
1441
1506
  }
@@ -1452,7 +1517,7 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1452
1517
  }
1453
1518
  }
1454
1519
  }
1455
- return { ok: true, text, attrs, fileCount, hasAttachmentUi };
1520
+ return { ok: true, text, attrs, fileCount, hasAttachmentUi, attachmentUiCount };
1456
1521
  })()`;
1457
1522
  const deadline = Date.now() + timeoutMs;
1458
1523
  let sawAttachmentUi = false;
@@ -1468,7 +1533,9 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1468
1533
  }
1469
1534
  const haystack = [value.text ?? '', ...(value.attrs ?? [])].join('\n');
1470
1535
  const fileCount = typeof value.fileCount === 'number' ? value.fileCount : 0;
1536
+ const attachmentUiCount = typeof value.attachmentUiCount === 'number' ? value.attachmentUiCount : 0;
1471
1537
  const fileCountSatisfied = fileCount >= expectedNormalized.length && expectedNormalized.length > 0;
1538
+ const attachmentUiSatisfied = attachmentUiCount >= expectedNormalized.length && expectedNormalized.length > 0;
1472
1539
  const missing = expectedNormalized.filter((expected) => {
1473
1540
  const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
1474
1541
  const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -1479,7 +1546,7 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1479
1546
  return false;
1480
1547
  return true;
1481
1548
  });
1482
- if (missing.length === 0 || fileCountSatisfied) {
1549
+ if (missing.length === 0 || fileCountSatisfied || attachmentUiSatisfied) {
1483
1550
  return true;
1484
1551
  }
1485
1552
  await delay(250);
@@ -1500,6 +1567,12 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
1500
1567
  const expected = ${JSON.stringify(expectedName)};
1501
1568
  const normalized = expected.toLowerCase();
1502
1569
  const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
1570
+ const matchesExpectedFileName = (value) => {
1571
+ const text = String(value || '').toLowerCase();
1572
+ if (!text) return false;
1573
+ if (text.includes(normalized)) return true;
1574
+ return normalizedNoExt.length >= 6 && text.includes(normalizedNoExt);
1575
+ };
1503
1576
  const matchNode = (node) => {
1504
1577
  if (!node) return false;
1505
1578
  if (node.tagName === 'INPUT' && node.type === 'file') return false;
@@ -1512,39 +1585,96 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
1512
1585
  return candidates.some((value) => value.includes(normalized) || (normalizedNoExt.length >= 6 && value.includes(normalizedNoExt)));
1513
1586
  };
1514
1587
 
1515
- const attachmentSelectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]','[data-testid*="file"]'];
1516
- const attachmentMatch = attachmentSelectors.some((selector) =>
1517
- Array.from(document.querySelectorAll(selector)).some(matchNode),
1588
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
1589
+ for (const input of inputs) {
1590
+ if (!(input instanceof HTMLInputElement)) continue;
1591
+ const files = Array.from(input.files || []);
1592
+ if (files.some((file) => matchesExpectedFileName(file?.name))) {
1593
+ return { found: true, source: 'file-input' };
1594
+ }
1595
+ }
1596
+
1597
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
1598
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
1599
+ const findPromptNode = () => {
1600
+ for (const selector of promptSelectors) {
1601
+ const nodes = Array.from(document.querySelectorAll(selector));
1602
+ for (const node of nodes) {
1603
+ if (!(node instanceof HTMLElement)) continue;
1604
+ const rect = node.getBoundingClientRect();
1605
+ if (rect.width > 0 && rect.height > 0) return node;
1606
+ }
1607
+ }
1608
+ for (const selector of promptSelectors) {
1609
+ const node = document.querySelector(selector);
1610
+ if (node) return node;
1611
+ }
1612
+ return null;
1613
+ };
1614
+ const attachmentSelectors = [
1615
+ 'input[type="file"]',
1616
+ '[data-testid*="attachment"]',
1617
+ '[data-testid*="chip"]',
1618
+ '[data-testid*="upload"]',
1619
+ '[data-testid*="file"]',
1620
+ '[aria-label*="Remove"]',
1621
+ '[aria-label*="remove"]',
1622
+ ];
1623
+ const locateComposerRoot = () => {
1624
+ const promptNode = findPromptNode();
1625
+ if (promptNode) {
1626
+ const initial =
1627
+ promptNode.closest('[data-testid*="composer"]') ??
1628
+ promptNode.closest('form') ??
1629
+ promptNode.parentElement ??
1630
+ document.body;
1631
+ let current = initial;
1632
+ let fallback = initial;
1633
+ while (current && current !== document.body) {
1634
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
1635
+ if (hasSend) {
1636
+ fallback = current;
1637
+ const hasAttachment = attachmentSelectors.some((selector) => current.querySelector(selector));
1638
+ if (hasAttachment) return current;
1639
+ }
1640
+ current = current.parentElement;
1641
+ }
1642
+ return fallback ?? initial;
1643
+ }
1644
+ return document.querySelector('form') ?? document.body;
1645
+ };
1646
+ const composerRoot = locateComposerRoot() ?? document.body;
1647
+
1648
+ const attachmentMatch = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]','[data-testid*="file"]'].some((selector) =>
1649
+ Array.from(composerRoot.querySelectorAll(selector)).some(matchNode),
1518
1650
  );
1519
1651
  if (attachmentMatch) {
1520
1652
  return { found: true, source: 'attachments' };
1521
1653
  }
1522
1654
 
1523
- const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
1655
+ const removeButtons = Array.from(
1656
+ (composerRoot ?? document).querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]'),
1657
+ );
1658
+ const visibleRemove = removeButtons.some((btn) => {
1659
+ if (!(btn instanceof HTMLElement)) return false;
1660
+ const rect = btn.getBoundingClientRect();
1661
+ if (rect.width <= 0 || rect.height <= 0) return false;
1662
+ const style = window.getComputedStyle(btn);
1663
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
1664
+ });
1665
+ if (visibleRemove) {
1666
+ return { found: true, source: 'remove-button' };
1667
+ }
1668
+
1669
+ const cardTexts = Array.from(composerRoot.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
1524
1670
  btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
1525
1671
  );
1526
1672
  if (cardTexts.some((text) => text.includes(normalized) || (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)))) {
1527
1673
  return { found: true, source: 'attachment-cards' };
1528
1674
  }
1529
1675
 
1530
- const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
1531
- const fileCountNodes = (() => {
1532
- const nodes = [];
1533
- const seen = new Set();
1534
- const add = (node) => {
1535
- if (!node || seen.has(node)) return;
1536
- seen.add(node);
1537
- nodes.push(node);
1538
- };
1539
- const root =
1540
- document.querySelector('[data-testid*="composer"]') || document.querySelector('form') || document.body;
1541
- const localNodes = root ? Array.from(root.querySelectorAll('button,span,div,[aria-label],[title]')) : [];
1542
- for (const node of localNodes) add(node);
1543
- for (const node of Array.from(document.querySelectorAll('button,span,div,[aria-label],[title]'))) {
1544
- add(node);
1545
- }
1546
- return nodes;
1547
- })();
1676
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1677
+ const fileCountNodes = Array.from(composerRoot.querySelectorAll('button,span,div,[aria-label],[title]'));
1548
1678
  let fileCount = 0;
1549
1679
  for (const node of fileCountNodes) {
1550
1680
  if (!(node instanceof HTMLElement)) continue;
@@ -1577,7 +1707,8 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
1577
1707
  let hasFileHint = false;
1578
1708
  for (const raw of candidates) {
1579
1709
  if (!raw) continue;
1580
- if (String(raw).toLowerCase().includes('file')) {
1710
+ const lowered = String(raw).toLowerCase();
1711
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1581
1712
  hasFileHint = true;
1582
1713
  break;
1583
1714
  }
@@ -1635,12 +1766,24 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
1635
1766
  return false;
1636
1767
  };
1637
1768
 
1769
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
1770
+ for (const input of inputs) {
1771
+ if (!(input instanceof HTMLInputElement)) continue;
1772
+ for (const file of Array.from(input.files || [])) {
1773
+ if (file?.name && matchesExpected(file.name)) {
1774
+ return { found: true, text: 'file-input' };
1775
+ }
1776
+ }
1777
+ }
1778
+
1638
1779
  const selectors = [
1639
1780
  '[data-testid*="attachment"]',
1640
1781
  '[data-testid*="chip"]',
1641
1782
  '[data-testid*="upload"]',
1642
1783
  '[aria-label*="Remove"]',
1643
1784
  'button[aria-label*="Remove"]',
1785
+ '[aria-label*="remove"]',
1786
+ 'button[aria-label*="remove"]',
1644
1787
  ];
1645
1788
  for (const selector of selectors) {
1646
1789
  for (const node of Array.from(document.querySelectorAll(selector))) {
@@ -1659,7 +1802,7 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
1659
1802
  if (cards.some(matchesExpected)) {
1660
1803
  return { found: true, text: cards.find(matchesExpected) };
1661
1804
  }
1662
- const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
1805
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1663
1806
  const fileCountNodes = (() => {
1664
1807
  const nodes = [];
1665
1808
  const seen = new Set();
@@ -1709,7 +1852,8 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
1709
1852
  let hasFileHint = false;
1710
1853
  for (const raw of candidates) {
1711
1854
  if (!raw) continue;
1712
- if (String(raw).toLowerCase().includes('file')) {
1855
+ const lowered = String(raw).toLowerCase();
1856
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1713
1857
  hasFileHint = true;
1714
1858
  break;
1715
1859
  }
@@ -1,11 +1,126 @@
1
1
  import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, INPUT_SELECTORS, } from '../constants.js';
2
2
  import { delay } from '../utils.js';
3
3
  import { logDomFailure } from '../domDebug.js';
4
+ export function installJavaScriptDialogAutoDismissal(Page, logger) {
5
+ const pageAny = Page;
6
+ if (typeof pageAny.on !== 'function' || typeof pageAny.handleJavaScriptDialog !== 'function') {
7
+ return () => { };
8
+ }
9
+ const handler = async (params) => {
10
+ const type = typeof params?.type === 'string' ? params.type : 'unknown';
11
+ const message = typeof params?.message === 'string' ? params.message : '';
12
+ logger(`[nav] dismissing JS dialog (${type})${message ? `: ${message.slice(0, 140)}` : ''}`);
13
+ try {
14
+ await pageAny.handleJavaScriptDialog?.({ accept: true, promptText: '' });
15
+ }
16
+ catch (error) {
17
+ const msg = error instanceof Error ? error.message : String(error);
18
+ logger(`[nav] failed to dismiss JS dialog: ${msg}`);
19
+ }
20
+ };
21
+ pageAny.on('javascriptDialogOpening', handler);
22
+ return () => {
23
+ try {
24
+ pageAny.off?.('javascriptDialogOpening', handler);
25
+ }
26
+ catch {
27
+ try {
28
+ pageAny.removeListener?.('javascriptDialogOpening', handler);
29
+ }
30
+ catch {
31
+ // ignore
32
+ }
33
+ }
34
+ };
35
+ }
4
36
  export async function navigateToChatGPT(Page, Runtime, url, logger) {
5
37
  logger(`Navigating to ${url}`);
6
38
  await Page.navigate({ url });
7
39
  await waitForDocumentReady(Runtime, 45_000);
8
40
  }
41
+ async function dismissBlockingUi(Runtime, logger) {
42
+ const outcome = await Runtime.evaluate({
43
+ expression: `(() => {
44
+ const isVisible = (el) => {
45
+ if (!(el instanceof HTMLElement)) return false;
46
+ const rect = el.getBoundingClientRect();
47
+ if (rect.width <= 0 || rect.height <= 0) return false;
48
+ const style = window.getComputedStyle(el);
49
+ if (!style) return false;
50
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
51
+ return true;
52
+ };
53
+ const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
54
+ const labelFor = (el) => normalize(el?.textContent || el?.getAttribute?.('aria-label') || el?.getAttribute?.('title'));
55
+ const buttonCandidates = (root) =>
56
+ Array.from(root.querySelectorAll('button,[role="button"],a')).filter((el) => isVisible(el));
57
+
58
+ const roots = [
59
+ ...Array.from(document.querySelectorAll('[role="dialog"],dialog')),
60
+ document.body,
61
+ ].filter(Boolean);
62
+ for (const root of roots) {
63
+ const buttons = buttonCandidates(root);
64
+ const close = buttons.find((el) => labelFor(el).includes('close'));
65
+ if (close) {
66
+ (close).click();
67
+ return { dismissed: true, action: 'close' };
68
+ }
69
+ const okLike = buttons.find((el) => {
70
+ const label = labelFor(el);
71
+ return (
72
+ label === 'ok' ||
73
+ label === 'got it' ||
74
+ label === 'dismiss' ||
75
+ label === 'continue' ||
76
+ label === 'back' ||
77
+ label.includes('back to chatgpt') ||
78
+ label.includes('go to chatgpt') ||
79
+ label.includes('return') ||
80
+ label.includes('take me')
81
+ );
82
+ });
83
+ if (okLike) {
84
+ (okLike).click();
85
+ return { dismissed: true, action: 'confirm' };
86
+ }
87
+ }
88
+ return { dismissed: false };
89
+ })()`,
90
+ returnByValue: true,
91
+ }).catch(() => null);
92
+ const value = outcome?.result?.value;
93
+ if (value?.dismissed) {
94
+ logger(`[nav] dismissed blocking UI (${value.action ?? 'unknown'})`);
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+ export async function navigateToPromptReadyWithFallback(Page, Runtime, options, deps = {}) {
100
+ const { url, fallbackUrl, timeoutMs, fallbackTimeoutMs, headless, logger, } = options;
101
+ const navigate = deps.navigateToChatGPT ?? navigateToChatGPT;
102
+ const ensureBlocked = deps.ensureNotBlocked ?? ensureNotBlocked;
103
+ const ensureReady = deps.ensurePromptReady ?? ensurePromptReady;
104
+ await navigate(Page, Runtime, url, logger);
105
+ await ensureBlocked(Runtime, headless, logger);
106
+ await dismissBlockingUi(Runtime, logger).catch(() => false);
107
+ try {
108
+ await ensureReady(Runtime, timeoutMs, logger);
109
+ return { usedFallback: false };
110
+ }
111
+ catch (error) {
112
+ if (!fallbackUrl || fallbackUrl === url) {
113
+ throw error;
114
+ }
115
+ const fallbackTimeout = fallbackTimeoutMs ?? Math.max(timeoutMs * 2, 120_000);
116
+ logger(`Prompt not ready after ${Math.round(timeoutMs / 1000)}s on ${url}; retrying ${fallbackUrl} with ${Math.round(fallbackTimeout / 1000)}s timeout.`);
117
+ await navigate(Page, Runtime, fallbackUrl, logger);
118
+ await ensureBlocked(Runtime, headless, logger);
119
+ await dismissBlockingUi(Runtime, logger).catch(() => false);
120
+ await ensureReady(Runtime, fallbackTimeout, logger);
121
+ return { usedFallback: true };
122
+ }
123
+ }
9
124
  export async function ensureNotBlocked(Runtime, headless, logger) {
10
125
  if (await isCloudflareInterstitial(Runtime)) {
11
126
  const message = headless
@@ -110,6 +110,8 @@ function buildThinkingTimeExpression(level) {
110
110
  for (const selector of CHIP_SELECTORS) {
111
111
  const buttons = document.querySelectorAll(selector);
112
112
  for (const btn of buttons) {
113
+ // Skip toggle buttons (no haspopup) - only click dropdown triggers to avoid disabling Pro mode
114
+ if (btn.getAttribute?.('aria-haspopup') !== 'menu') continue;
113
115
  const aria = normalize(btn.getAttribute?.('aria-label') ?? '');
114
116
  const text = normalize(btn.textContent ?? '');
115
117
  if (aria.includes('thinking') || text.includes('thinking')) {
@@ -172,6 +172,8 @@ function buildChromeFlags(headless, debugBindAddress) {
172
172
  '--disable-features=TranslateUI,AutomationControlled',
173
173
  '--mute-audio',
174
174
  '--window-size=1280,720',
175
+ '--lang=en-US',
176
+ '--accept-lang=en-US,en',
175
177
  ];
176
178
  if (process.platform !== 'win32' && !isWsl()) {
177
179
  flags.push('--password-store=basic', '--use-mock-keychain');
@@ -14,6 +14,7 @@ export const DEFAULT_BROWSER_CONFIG = {
14
14
  inputTimeoutMs: 60_000,
15
15
  cookieSync: true,
16
16
  cookieNames: null,
17
+ cookieSyncWaitMs: 0,
17
18
  inlineCookies: null,
18
19
  inlineCookiesSource: null,
19
20
  headless: false,
@@ -58,6 +59,7 @@ export function resolveBrowserConfig(config) {
58
59
  inputTimeoutMs: config?.inputTimeoutMs ?? DEFAULT_BROWSER_CONFIG.inputTimeoutMs,
59
60
  cookieSync: config?.cookieSync ?? cookieSyncDefault,
60
61
  cookieNames: config?.cookieNames ?? DEFAULT_BROWSER_CONFIG.cookieNames,
62
+ cookieSyncWaitMs: config?.cookieSyncWaitMs ?? DEFAULT_BROWSER_CONFIG.cookieSyncWaitMs,
61
63
  inlineCookies: config?.inlineCookies ?? DEFAULT_BROWSER_CONFIG.inlineCookies,
62
64
  inlineCookiesSource: config?.inlineCookiesSource ?? DEFAULT_BROWSER_CONFIG.inlineCookiesSource,
63
65
  headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
@@ -1,14 +1,15 @@
1
1
  import { COOKIE_URLS } from './constants.js';
2
+ import { delay } from './utils.js';
2
3
  import { getCookies } from '@steipete/sweet-cookie';
3
4
  export class ChromeCookieSyncError extends Error {
4
5
  }
5
6
  export async function syncCookies(Network, url, profile, logger, options = {}) {
6
- const { allowErrors = false, filterNames, inlineCookies, cookiePath } = options;
7
+ const { allowErrors = false, filterNames, inlineCookies, cookiePath, waitMs = 0 } = options;
7
8
  try {
8
9
  // Learned: inline cookies are the most deterministic (avoid Keychain + profile ambiguity).
9
10
  const cookies = inlineCookies?.length
10
11
  ? normalizeInlineCookies(inlineCookies, new URL(url).hostname)
11
- : await readChromeCookies(url, profile, filterNames ?? undefined, cookiePath ?? undefined);
12
+ : await readChromeCookiesWithWait(url, profile, filterNames ?? undefined, cookiePath ?? undefined, waitMs, logger);
12
13
  if (!cookies.length) {
13
14
  return 0;
14
15
  }
@@ -38,6 +39,32 @@ export async function syncCookies(Network, url, profile, logger, options = {}) {
38
39
  throw error instanceof ChromeCookieSyncError ? error : new ChromeCookieSyncError(message);
39
40
  }
40
41
  }
42
+ async function readChromeCookiesWithWait(url, profile, filterNames, cookiePath, waitMs, logger) {
43
+ if (waitMs <= 0) {
44
+ return readChromeCookies(url, profile, filterNames, cookiePath);
45
+ }
46
+ let cookies = [];
47
+ let firstError;
48
+ try {
49
+ cookies = await readChromeCookies(url, profile, filterNames, cookiePath);
50
+ }
51
+ catch (error) {
52
+ firstError = error;
53
+ }
54
+ if (cookies.length > 0 && !firstError) {
55
+ return cookies;
56
+ }
57
+ const waitLabel = waitMs >= 1000 ? `${Math.round(waitMs / 1000)}s` : `${waitMs}ms`;
58
+ const message = firstError instanceof Error ? firstError.message : String(firstError ?? '');
59
+ if (firstError) {
60
+ logger(`[cookies] Cookie read failed (${message}); waiting ${waitLabel} then retrying once.`);
61
+ }
62
+ else {
63
+ logger(`[cookies] No cookies found; waiting ${waitLabel} then retrying once.`);
64
+ }
65
+ await delay(waitMs);
66
+ return readChromeCookies(url, profile, filterNames, cookiePath);
67
+ }
41
68
  async function readChromeCookies(url, profile, filterNames, cookiePath) {
42
69
  const origins = Array.from(new Set([stripQuery(url), ...COOKIE_URLS]));
43
70
  const chromeProfile = cookiePath ?? profile ?? undefined;
@@ -5,7 +5,7 @@ import net from 'node:net';
5
5
  import { resolveBrowserConfig } from './config.js';
6
6
  import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
7
7
  import { syncCookies } from './cookies.js';
8
- import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
8
+ import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
9
9
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
10
10
  import { ensureThinkingTime } from './actions/thinkingTime.js';
11
11
  import { estimateTokenCount, withRetries, delay } from './utils.js';
@@ -129,6 +129,7 @@ export async function runBrowserMode(options) {
129
129
  let runStatus = 'attempted';
130
130
  let connectionClosedUnexpectedly = false;
131
131
  let stopThinkingMonitor = null;
132
+ let removeDialogHandler = null;
132
133
  let appliedCookies = 0;
133
134
  try {
134
135
  try {
@@ -158,6 +159,7 @@ export async function runBrowserMode(options) {
158
159
  domainEnablers.push(DOM.enable());
159
160
  }
160
161
  await Promise.all(domainEnablers);
162
+ removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
161
163
  if (!manualLogin) {
162
164
  await Network.clearBrowserCookies();
163
165
  }
@@ -179,6 +181,7 @@ export async function runBrowserMode(options) {
179
181
  filterNames: config.cookieNames ?? undefined,
180
182
  inlineCookies: config.inlineCookies ?? undefined,
181
183
  cookiePath: config.chromeCookiePath ?? undefined,
184
+ waitMs: config.cookieSyncWaitMs ?? 0,
182
185
  });
183
186
  appliedCookies = cookieCount;
184
187
  if (config.inlineCookies && cookieCount === 0) {
@@ -201,7 +204,8 @@ export async function runBrowserMode(options) {
201
204
  // Learned: if the profile has no ChatGPT cookies, browser mode will just bounce to login.
202
205
  // Fail early so the user knows to sign in.
203
206
  throw new BrowserAutomationError('No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. ' +
204
- 'Make sure ChatGPT is signed in in the selected profile, or use --browser-manual-login / inline cookies.', {
207
+ 'Make sure ChatGPT is signed in in the selected profile, use --browser-manual-login / inline cookies, ' +
208
+ 'or retry with --browser-cookie-wait 5s if Keychain prompts are slow.', {
205
209
  stage: 'execute-browser',
206
210
  details: {
207
211
  profile: config.chromeProfile ?? 'Default',
@@ -218,10 +222,17 @@ export async function runBrowserMode(options) {
218
222
  // Learned: login checks must happen on the base domain before jumping into project URLs.
219
223
  await raceWithDisconnect(waitForLogin({ runtime: Runtime, logger, appliedCookies, manualLogin, timeoutMs: config.timeoutMs }));
220
224
  if (config.url !== baseUrl) {
221
- await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.url, logger));
222
- await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
225
+ await raceWithDisconnect(navigateToPromptReadyWithFallback(Page, Runtime, {
226
+ url: config.url,
227
+ fallbackUrl: baseUrl,
228
+ timeoutMs: config.inputTimeoutMs,
229
+ headless: config.headless,
230
+ logger,
231
+ }));
232
+ }
233
+ else {
234
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
223
235
  }
224
- await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
225
236
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
226
237
  const captureRuntimeSnapshot = async () => {
227
238
  try {
@@ -336,6 +347,7 @@ export async function runBrowserMode(options) {
336
347
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
337
348
  const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
338
349
  const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
350
+ let attachmentWaitTimedOut = false;
339
351
  let inputOnlyAttachments = false;
340
352
  if (submissionAttachments.length > 0) {
341
353
  if (!DOM) {
@@ -345,25 +357,38 @@ export async function runBrowserMode(options) {
345
357
  for (let attachmentIndex = 0; attachmentIndex < submissionAttachments.length; attachmentIndex += 1) {
346
358
  const attachment = submissionAttachments[attachmentIndex];
347
359
  logger(`Uploading attachment: ${attachment.displayPath}`);
348
- const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger, { expectedCount: attachmentIndex + 1 });
360
+ const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM, input: Input }, attachment, logger, { expectedCount: attachmentIndex + 1 });
349
361
  if (!uiConfirmed) {
350
362
  inputOnlyAttachments = true;
351
363
  }
352
364
  await delay(500);
353
365
  }
354
- // Scale timeout based on number of files: base 30s + 15s per additional file
366
+ // Scale timeout based on number of files: base 45s + 20s per additional file.
355
367
  const baseTimeout = config.inputTimeoutMs ?? 30_000;
356
- const perFileTimeout = 15_000;
357
- const waitBudget = Math.max(baseTimeout, 30_000) + (submissionAttachments.length - 1) * perFileTimeout;
358
- await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
359
- logger('All attachments uploaded');
368
+ const perFileTimeout = 20_000;
369
+ const waitBudget = Math.max(baseTimeout, 45_000) + (submissionAttachments.length - 1) * perFileTimeout;
370
+ try {
371
+ await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
372
+ logger('All attachments uploaded');
373
+ }
374
+ catch (error) {
375
+ const message = error instanceof Error ? error.message : String(error);
376
+ if (/Attachments did not finish uploading before timeout/i.test(message)) {
377
+ attachmentWaitTimedOut = true;
378
+ logger(`[browser] Attachment upload timed out after ${Math.round(waitBudget / 1000)}s; continuing without confirmation.`);
379
+ }
380
+ else {
381
+ throw error;
382
+ }
383
+ }
360
384
  }
361
385
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
362
386
  // Learned: return baselineTurns so assistant polling can ignore earlier content.
387
+ const sendAttachmentNames = attachmentWaitTimedOut ? [] : attachmentNames;
363
388
  const committedTurns = await submitPrompt({
364
389
  runtime: Runtime,
365
390
  input: Input,
366
- attachmentNames,
391
+ attachmentNames: sendAttachmentNames,
367
392
  baselineTurns: baselineTurns ?? undefined,
368
393
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
369
394
  }, prompt, logger);
@@ -373,14 +398,18 @@ export async function runBrowserMode(options) {
373
398
  }
374
399
  }
375
400
  if (attachmentNames.length > 0) {
376
- if (inputOnlyAttachments) {
401
+ if (attachmentWaitTimedOut) {
402
+ logger('Attachment confirmation timed out; skipping user-turn attachment verification.');
403
+ }
404
+ else if (inputOnlyAttachments) {
377
405
  logger('Attachment UI did not render before send; skipping user-turn attachment verification.');
378
406
  }
379
407
  else {
380
408
  const verified = await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
381
- if (verified) {
382
- logger('Verified attachments present on sent user message');
409
+ if (!verified) {
410
+ throw new Error('Sent user message did not expose attachment UI after upload.');
383
411
  }
412
+ logger('Verified attachments present on sent user message');
384
413
  }
385
414
  }
386
415
  // Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
@@ -618,6 +647,7 @@ export async function runBrowserMode(options) {
618
647
  catch {
619
648
  // ignore
620
649
  }
650
+ removeDialogHandler?.();
621
651
  removeTerminationHooks?.();
622
652
  if (!effectiveKeepBrowser) {
623
653
  if (!connectionClosedUnexpectedly) {
@@ -796,6 +826,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
796
826
  let answerHtml = '';
797
827
  let connectionClosedUnexpectedly = false;
798
828
  let stopThinkingMonitor = null;
829
+ let removeDialogHandler = null;
799
830
  try {
800
831
  const connection = await connectToRemoteChrome(host, port, logger, config.url);
801
832
  client = connection.client;
@@ -811,6 +842,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
811
842
  domainEnablers.push(DOM.enable());
812
843
  }
813
844
  await Promise.all(domainEnablers);
845
+ removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
814
846
  // Skip cookie sync for remote Chrome - it already has cookies
815
847
  logger('Skipping cookie sync for remote Chrome (using existing session)');
816
848
  await navigateToChatGPT(Page, Runtime, config.url, logger);
@@ -1082,6 +1114,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1082
1114
  catch {
1083
1115
  // ignore
1084
1116
  }
1117
+ removeDialogHandler?.();
1085
1118
  await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
1086
1119
  // Don't kill remote Chrome - it's not ours to manage
1087
1120
  const totalSeconds = (Date.now() - startedAt) / 1000;
@@ -1,4 +1,4 @@
1
- export { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady } from './actions/navigation.js';
1
+ export { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, } from './actions/navigation.js';
2
2
  export { ensureModelSelection } from './actions/modelSelection.js';
3
3
  export { submitPrompt, clearPromptComposer } from './actions/promptComposer.js';
4
4
  export { clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, } from './actions/attachments.js';
@@ -114,6 +114,7 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
114
114
  filterNames: resolved.cookieNames ?? undefined,
115
115
  inlineCookies: resolved.inlineCookies ?? undefined,
116
116
  cookiePath: resolved.chromeCookiePath ?? undefined,
117
+ waitMs: resolved.cookieSyncWaitMs ?? 0,
117
118
  });
118
119
  }
119
120
  await navigateToChatGPT(Page, Runtime, CHATGPT_URL, logger);
@@ -82,6 +82,7 @@ export async function buildBrowserConfig(options) {
82
82
  inputTimeoutMs: options.browserInputTimeout
83
83
  ? parseDuration(options.browserInputTimeout, DEFAULT_BROWSER_INPUT_TIMEOUT_MS)
84
84
  : undefined,
85
+ cookieSyncWaitMs: options.browserCookieWait ? parseDuration(options.browserCookieWait, 0) : undefined,
85
86
  cookieSync: options.browserNoCookieSync ? false : undefined,
86
87
  cookieNames,
87
88
  inlineCookies: inline?.cookies,
@@ -33,6 +33,9 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
33
33
  if (isUnset('browserInputTimeout') && typeof browser.inputTimeoutMs === 'number') {
34
34
  options.browserInputTimeout = String(browser.inputTimeoutMs);
35
35
  }
36
+ if (isUnset('browserCookieWait') && typeof browser.cookieSyncWaitMs === 'number') {
37
+ options.browserCookieWait = String(browser.cookieSyncWaitMs);
38
+ }
36
39
  if (isUnset('browserHeadless') && browser.headless !== undefined) {
37
40
  options.browserHeadless = browser.headless;
38
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.2 Pro (via gpt-5.1-pro alias), GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "type": "module",
6
6
  "main": "dist/bin/oracle-cli.js",
@@ -23,6 +23,7 @@
23
23
  "test:mcp:mcporter": "npx -y mcporter list oracle-local --schema --config config/mcporter.json && npx -y mcporter call oracle-local.sessions limit:1 --config config/mcporter.json",
24
24
  "test:browser": "pnpm run build && tsx scripts/test-browser.ts && ./scripts/browser-smoke.sh",
25
25
  "test:live": "ORACLE_LIVE_TEST=1 vitest run tests/live --exclude tests/live/openai-live.test.ts",
26
+ "test:live:fast": "ORACLE_LIVE_TEST=1 ORACLE_LIVE_TEST_FAST=1 vitest run tests/live/browser-fast-live.test.ts",
26
27
  "test:pro": "ORACLE_LIVE_TEST=1 vitest run tests/live/openai-live.test.ts",
27
28
  "test:coverage": "vitest run --coverage",
28
29
  "prepare": "pnpm run build",