@steipete/oracle 0.8.1 → 0.8.4

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Peter Steinberger
3
+ \g<1>2026 Peter Steinberger
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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,17 +566,28 @@ 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
573
  let 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
586
  return { idx: idx++, score, imageOnly };
550
587
  });
588
+
589
+ // When the attachment isn't an image, avoid inputs that only accept images.
590
+ // Some ChatGPT surfaces expose multiple file inputs (e.g. image-only vs generic upload).
551
591
  if (!isImageAttachment) {
552
592
  const nonImage = candidates.filter((candidate) => !candidate.imageOnly);
553
593
  if (nonImage.length > 0) {
@@ -798,8 +838,8 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
798
838
  continue;
799
839
  }
800
840
  const baselineInputSnapshot = await readInputSnapshot(idx);
801
- const gatherSignals = async () => {
802
- const signalResult = await waitForAttachmentUiSignal(attachmentUiSignalWaitMs);
841
+ const gatherSignals = async (waitMs = attachmentUiSignalWaitMs) => {
842
+ const signalResult = await waitForAttachmentUiSignal(waitMs);
803
843
  const postInputSnapshot = await readInputSnapshot(idx);
804
844
  const postInputSignals = inputSignalsFor(baselineInputSnapshot, postInputSnapshot);
805
845
  const snapshot = await runtime
@@ -822,25 +862,25 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
822
862
  const evaluateSignals = async (signalResult, postInputSignals, immediateInputMatch) => {
823
863
  const expectedSatisfied = Boolean(signalResult?.expectedSatisfied) ||
824
864
  (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) {
865
+ const inputNameCandidates = resolveInputNameCandidates();
866
+ const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
867
+ (lastInputValue && matchesExpectedName(lastInputValue));
868
+ const inputEvidence = immediateInputMatch ||
869
+ postInputSignals.touched ||
870
+ Boolean(signalResult?.signals?.input) ||
871
+ Boolean(signalResult?.inputDelta) ||
872
+ inputHasFile;
873
+ const uiDirect = Boolean(signalResult?.signals?.ui) || expectedSatisfied;
874
+ const uiDelta = Boolean(signalResult?.chipDelta) || Boolean(signalResult?.uploadDelta) || Boolean(signalResult?.fileCountDelta);
875
+ if (uiDirect || (uiDelta && inputEvidence)) {
831
876
  return { status: 'ui' };
832
877
  }
833
878
  const postSignals = await readAttachmentSignals(expectedName);
834
879
  if (postSignals.ui ||
835
880
  isExpectedSatisfied(postSignals) ||
836
- hasChipDelta(postSignals) ||
837
- hasUploadDelta(postSignals) ||
838
- hasFileCountDelta(postSignals)) {
881
+ ((hasChipDelta(postSignals) || hasUploadDelta(postSignals) || hasFileCountDelta(postSignals)) && inputEvidence)) {
839
882
  return { status: 'ui' };
840
883
  }
841
- const inputNameCandidates = resolveInputNameCandidates();
842
- const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
843
- (lastInputValue && matchesExpectedName(lastInputValue));
844
884
  const inputSignal = immediateInputMatch ||
845
885
  postInputSignals.touched ||
846
886
  Boolean(signalResult?.signals?.input) ||
@@ -883,15 +923,56 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
883
923
  const evaluation = await evaluateSignals(signalState.signalResult, signalState.postInputSignals, immediateInputMatch);
884
924
  return { evaluation, signalState, immediateInputMatch };
885
925
  };
926
+ const dispatchInputEvents = async () => {
927
+ await runtime
928
+ .evaluate({
929
+ expression: `(() => {
930
+ const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
931
+ if (!(input instanceof HTMLInputElement)) return false;
932
+ try {
933
+ input.dispatchEvent(new Event('input', { bubbles: true }));
934
+ input.dispatchEvent(new Event('change', { bubbles: true }));
935
+ return true;
936
+ } catch {
937
+ return false;
938
+ }
939
+ })()`,
940
+ returnByValue: true,
941
+ })
942
+ .catch(() => undefined);
943
+ };
886
944
  let result = await runInputAttempt('set');
887
945
  if (result.evaluation.status === 'ui') {
888
946
  confirmedAttachment = true;
889
947
  break;
890
948
  }
891
949
  if (result.evaluation.status === 'input') {
892
- logger('Attachment input set; proceeding without UI confirmation.');
893
- inputConfirmed = true;
894
- break;
950
+ await dispatchInputEvents();
951
+ await delay(150);
952
+ const forcedState = await gatherSignals(1_500);
953
+ const forcedEvaluation = await evaluateSignals(forcedState.signalResult, forcedState.postInputSignals, result.immediateInputMatch);
954
+ if (forcedEvaluation.status === 'ui') {
955
+ confirmedAttachment = true;
956
+ break;
957
+ }
958
+ if (forcedEvaluation.status === 'input') {
959
+ logger('Attachment input set; proceeding without UI confirmation.');
960
+ inputConfirmed = true;
961
+ break;
962
+ }
963
+ logger('Attachment input set; retrying with data transfer to trigger ChatGPT upload.');
964
+ await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [] }).catch(() => undefined);
965
+ await delay(150);
966
+ result = await runInputAttempt('transfer');
967
+ if (result.evaluation.status === 'ui') {
968
+ confirmedAttachment = true;
969
+ break;
970
+ }
971
+ if (result.evaluation.status === 'input') {
972
+ logger('Attachment input set; proceeding without UI confirmation.');
973
+ inputConfirmed = true;
974
+ break;
975
+ }
895
976
  }
896
977
  const lateSignals = await readAttachmentSignals(expectedName);
897
978
  if (lateSignals.ui ||
@@ -1007,12 +1088,12 @@ export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
1007
1088
  return parentHasSend ? parent : root;
1008
1089
  })();
1009
1090
  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"]',
1091
+ '[aria-label="Remove file"]',
1092
+ 'button[aria-label="Remove file"]',
1093
+ '[aria-label*="Remove file"]',
1094
+ '[aria-label*="remove file"]',
1095
+ '[data-testid*="remove-attachment"]',
1096
+ '[data-testid*="attachment-remove"]',
1016
1097
  ];
1017
1098
  const visible = (el) => {
1018
1099
  if (!(el instanceof HTMLElement)) return false;
@@ -1023,10 +1104,15 @@ export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
1023
1104
  ? Array.from(scope.querySelectorAll(removeSelectors.join(','))).filter(visible)
1024
1105
  : [];
1025
1106
  for (const button of removeButtons.slice(0, 20)) {
1026
- try { button.click(); } catch {}
1107
+ try {
1108
+ if (button instanceof HTMLButtonElement) {
1109
+ // Ensure remove buttons never submit the composer form.
1110
+ button.type = 'button';
1111
+ }
1112
+ button.click();
1113
+ } catch {}
1027
1114
  }
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;
1115
+ const chipCount = removeButtons.length;
1030
1116
  const inputs = scope ? Array.from(scope.querySelectorAll('input[type="file"]')) : [];
1031
1117
  let inputCount = 0;
1032
1118
  for (const input of inputs) {
@@ -1189,7 +1275,7 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1189
1275
  if (file?.name) inputNames.push(file.name.toLowerCase());
1190
1276
  }
1191
1277
  }
1192
- const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
1278
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1193
1279
  const fileCountSelectors = [
1194
1280
  'button',
1195
1281
  '[role="button"]',
@@ -1235,7 +1321,8 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1235
1321
  let hasFileHint = false;
1236
1322
  for (const raw of candidates) {
1237
1323
  if (!raw) continue;
1238
- if (String(raw).toLowerCase().includes('file')) {
1324
+ const lowered = String(raw).toLowerCase();
1325
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1239
1326
  hasFileHint = true;
1240
1327
  break;
1241
1328
  }
@@ -1340,9 +1427,8 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1340
1427
  if (stable && value.state === 'ready') {
1341
1428
  return;
1342
1429
  }
1343
- if (stable && value.state === 'disabled' && !value.uploading) {
1344
- return;
1345
- }
1430
+ // Don't treat disabled button as complete - wait for it to become 'ready'.
1431
+ // The spinner detection is unreliable, so a disabled button likely means upload is in progress.
1346
1432
  if (value.state === 'missing' && (value.filesAttached || fileCountSatisfied)) {
1347
1433
  return;
1348
1434
  }
@@ -1363,16 +1449,18 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1363
1449
  const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
1364
1450
  return !inputNames.some((raw) => raw.includes(normalizedExpected) || (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
1365
1451
  });
1366
- const inputStateOk = value.state === 'ready' || value.state === 'missing' || value.state === 'disabled';
1452
+ // Don't include 'disabled' - a disabled button likely means upload is still in progress.
1453
+ const inputStateOk = value.state === 'ready' || value.state === 'missing';
1367
1454
  const inputSeenNow = inputMissing.length === 0 || fileCountSatisfied;
1455
+ const inputEvidenceOk = Boolean(value.filesAttached) || Boolean(value.uploading) || fileCountSatisfied;
1368
1456
  const stableThresholdMs = value.uploading ? 3000 : 1500;
1369
- if (inputSeenNow && inputStateOk) {
1457
+ if (inputSeenNow && inputStateOk && inputEvidenceOk) {
1370
1458
  if (inputMatchSince === null) {
1371
1459
  inputMatchSince = Date.now();
1372
1460
  }
1373
1461
  sawInputMatch = true;
1374
1462
  }
1375
- if (inputMatchSince !== null && inputStateOk && Date.now() - inputMatchSince > stableThresholdMs) {
1463
+ if (inputMatchSince !== null && inputStateOk && inputEvidenceOk && Date.now() - inputMatchSince > stableThresholdMs) {
1376
1464
  return;
1377
1465
  }
1378
1466
  if (!inputSeenNow && !sawInputMatch) {
@@ -1416,10 +1504,10 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1416
1504
  '[title*="file"]',
1417
1505
  '[title*="attachment"]',
1418
1506
  ];
1507
+ const attachmentUiCount = lastUser.querySelectorAll(attachmentSelectors.join(',')).length;
1419
1508
  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/;
1509
+ attachmentUiCount > 0 || attrs.some((attr) => attr.includes('file') || attr.includes('attachment'));
1510
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1423
1511
  const fileCountNodes = Array.from(lastUser.querySelectorAll('button,span,div,[aria-label],[title]'));
1424
1512
  let fileCount = 0;
1425
1513
  for (const node of fileCountNodes) {
@@ -1435,7 +1523,8 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1435
1523
  let hasFileHint = false;
1436
1524
  for (const raw of candidates) {
1437
1525
  if (!raw) continue;
1438
- if (String(raw).toLowerCase().includes('file')) {
1526
+ const lowered = String(raw).toLowerCase();
1527
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1439
1528
  hasFileHint = true;
1440
1529
  break;
1441
1530
  }
@@ -1452,7 +1541,7 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1452
1541
  }
1453
1542
  }
1454
1543
  }
1455
- return { ok: true, text, attrs, fileCount, hasAttachmentUi };
1544
+ return { ok: true, text, attrs, fileCount, hasAttachmentUi, attachmentUiCount };
1456
1545
  })()`;
1457
1546
  const deadline = Date.now() + timeoutMs;
1458
1547
  let sawAttachmentUi = false;
@@ -1468,7 +1557,9 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1468
1557
  }
1469
1558
  const haystack = [value.text ?? '', ...(value.attrs ?? [])].join('\n');
1470
1559
  const fileCount = typeof value.fileCount === 'number' ? value.fileCount : 0;
1560
+ const attachmentUiCount = typeof value.attachmentUiCount === 'number' ? value.attachmentUiCount : 0;
1471
1561
  const fileCountSatisfied = fileCount >= expectedNormalized.length && expectedNormalized.length > 0;
1562
+ const attachmentUiSatisfied = attachmentUiCount >= expectedNormalized.length && expectedNormalized.length > 0;
1472
1563
  const missing = expectedNormalized.filter((expected) => {
1473
1564
  const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
1474
1565
  const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -1479,7 +1570,7 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1479
1570
  return false;
1480
1571
  return true;
1481
1572
  });
1482
- if (missing.length === 0 || fileCountSatisfied) {
1573
+ if (missing.length === 0 || fileCountSatisfied || attachmentUiSatisfied) {
1483
1574
  return true;
1484
1575
  }
1485
1576
  await delay(250);
@@ -1500,6 +1591,12 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
1500
1591
  const expected = ${JSON.stringify(expectedName)};
1501
1592
  const normalized = expected.toLowerCase();
1502
1593
  const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
1594
+ const matchesExpectedFileName = (value) => {
1595
+ const text = String(value || '').toLowerCase();
1596
+ if (!text) return false;
1597
+ if (text.includes(normalized)) return true;
1598
+ return normalizedNoExt.length >= 6 && text.includes(normalizedNoExt);
1599
+ };
1503
1600
  const matchNode = (node) => {
1504
1601
  if (!node) return false;
1505
1602
  if (node.tagName === 'INPUT' && node.type === 'file') return false;
@@ -1512,39 +1609,96 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
1512
1609
  return candidates.some((value) => value.includes(normalized) || (normalizedNoExt.length >= 6 && value.includes(normalizedNoExt)));
1513
1610
  };
1514
1611
 
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),
1612
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
1613
+ for (const input of inputs) {
1614
+ if (!(input instanceof HTMLInputElement)) continue;
1615
+ const files = Array.from(input.files || []);
1616
+ if (files.some((file) => matchesExpectedFileName(file?.name))) {
1617
+ return { found: true, source: 'file-input' };
1618
+ }
1619
+ }
1620
+
1621
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
1622
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
1623
+ const findPromptNode = () => {
1624
+ for (const selector of promptSelectors) {
1625
+ const nodes = Array.from(document.querySelectorAll(selector));
1626
+ for (const node of nodes) {
1627
+ if (!(node instanceof HTMLElement)) continue;
1628
+ const rect = node.getBoundingClientRect();
1629
+ if (rect.width > 0 && rect.height > 0) return node;
1630
+ }
1631
+ }
1632
+ for (const selector of promptSelectors) {
1633
+ const node = document.querySelector(selector);
1634
+ if (node) return node;
1635
+ }
1636
+ return null;
1637
+ };
1638
+ const attachmentSelectors = [
1639
+ 'input[type="file"]',
1640
+ '[data-testid*="attachment"]',
1641
+ '[data-testid*="chip"]',
1642
+ '[data-testid*="upload"]',
1643
+ '[data-testid*="file"]',
1644
+ '[aria-label*="Remove"]',
1645
+ '[aria-label*="remove"]',
1646
+ ];
1647
+ const locateComposerRoot = () => {
1648
+ const promptNode = findPromptNode();
1649
+ if (promptNode) {
1650
+ const initial =
1651
+ promptNode.closest('[data-testid*="composer"]') ??
1652
+ promptNode.closest('form') ??
1653
+ promptNode.parentElement ??
1654
+ document.body;
1655
+ let current = initial;
1656
+ let fallback = initial;
1657
+ while (current && current !== document.body) {
1658
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
1659
+ if (hasSend) {
1660
+ fallback = current;
1661
+ const hasAttachment = attachmentSelectors.some((selector) => current.querySelector(selector));
1662
+ if (hasAttachment) return current;
1663
+ }
1664
+ current = current.parentElement;
1665
+ }
1666
+ return fallback ?? initial;
1667
+ }
1668
+ return document.querySelector('form') ?? document.body;
1669
+ };
1670
+ const composerRoot = locateComposerRoot() ?? document.body;
1671
+
1672
+ const attachmentMatch = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]','[data-testid*="file"]'].some((selector) =>
1673
+ Array.from(composerRoot.querySelectorAll(selector)).some(matchNode),
1518
1674
  );
1519
1675
  if (attachmentMatch) {
1520
1676
  return { found: true, source: 'attachments' };
1521
1677
  }
1522
1678
 
1523
- const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
1679
+ const removeButtons = Array.from(
1680
+ (composerRoot ?? document).querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]'),
1681
+ );
1682
+ const visibleRemove = removeButtons.some((btn) => {
1683
+ if (!(btn instanceof HTMLElement)) return false;
1684
+ const rect = btn.getBoundingClientRect();
1685
+ if (rect.width <= 0 || rect.height <= 0) return false;
1686
+ const style = window.getComputedStyle(btn);
1687
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
1688
+ });
1689
+ if (visibleRemove) {
1690
+ return { found: true, source: 'remove-button' };
1691
+ }
1692
+
1693
+ const cardTexts = Array.from(composerRoot.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
1524
1694
  btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
1525
1695
  );
1526
1696
  if (cardTexts.some((text) => text.includes(normalized) || (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)))) {
1527
1697
  return { found: true, source: 'attachment-cards' };
1528
1698
  }
1529
1699
 
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
- })();
1700
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1701
+ const fileCountNodes = Array.from(composerRoot.querySelectorAll('button,span,div,[aria-label],[title]'));
1548
1702
  let fileCount = 0;
1549
1703
  for (const node of fileCountNodes) {
1550
1704
  if (!(node instanceof HTMLElement)) continue;
@@ -1577,7 +1731,8 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
1577
1731
  let hasFileHint = false;
1578
1732
  for (const raw of candidates) {
1579
1733
  if (!raw) continue;
1580
- if (String(raw).toLowerCase().includes('file')) {
1734
+ const lowered = String(raw).toLowerCase();
1735
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1581
1736
  hasFileHint = true;
1582
1737
  break;
1583
1738
  }
@@ -1635,12 +1790,24 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
1635
1790
  return false;
1636
1791
  };
1637
1792
 
1793
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
1794
+ for (const input of inputs) {
1795
+ if (!(input instanceof HTMLInputElement)) continue;
1796
+ for (const file of Array.from(input.files || [])) {
1797
+ if (file?.name && matchesExpected(file.name)) {
1798
+ return { found: true, text: 'file-input' };
1799
+ }
1800
+ }
1801
+ }
1802
+
1638
1803
  const selectors = [
1639
1804
  '[data-testid*="attachment"]',
1640
1805
  '[data-testid*="chip"]',
1641
1806
  '[data-testid*="upload"]',
1642
1807
  '[aria-label*="Remove"]',
1643
1808
  'button[aria-label*="Remove"]',
1809
+ '[aria-label*="remove"]',
1810
+ 'button[aria-label*="remove"]',
1644
1811
  ];
1645
1812
  for (const selector of selectors) {
1646
1813
  for (const node of Array.from(document.querySelectorAll(selector))) {
@@ -1659,7 +1826,7 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
1659
1826
  if (cards.some(matchesExpected)) {
1660
1827
  return { found: true, text: cards.find(matchesExpected) };
1661
1828
  }
1662
- const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
1829
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1663
1830
  const fileCountNodes = (() => {
1664
1831
  const nodes = [];
1665
1832
  const seen = new Set();
@@ -1709,7 +1876,8 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
1709
1876
  let hasFileHint = false;
1710
1877
  for (const raw of candidates) {
1711
1878
  if (!raw) continue;
1712
- if (String(raw).toLowerCase().includes('file')) {
1879
+ const lowered = String(raw).toLowerCase();
1880
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1713
1881
  hasFileHint = true;
1714
1882
  break;
1715
1883
  }
@@ -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.4",
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",
@@ -79,14 +80,15 @@
79
80
  "markdansi": "0.2.0",
80
81
  "openai": "^6.15.0",
81
82
  "osc-progress": "^0.2.0",
83
+ "qs": "^6.14.1",
82
84
  "shiki": "^3.20.0",
83
85
  "toasted-notifier": "^10.1.0",
84
86
  "tokentally": "^0.1.1",
85
- "zod": "^4.2.1"
87
+ "zod": "^4.3.5"
86
88
  },
87
89
  "devDependencies": {
88
90
  "@anthropic-ai/tokenizer": "^0.0.4",
89
- "@biomejs/biome": "^2.3.10",
91
+ "@biomejs/biome": "^2.3.11",
90
92
  "@cdktf/node-pty-prebuilt-multiarch": "0.10.2",
91
93
  "@types/chrome-remote-interface": "^0.33.0",
92
94
  "@types/inquirer": "^9.0.9",