@steipete/oracle 0.8.0 → 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
@@ -112,6 +112,8 @@ npx -y @steipete/oracle oracle-mcp
112
112
  | `--base-url <url>` | Point API runs at LiteLLM/Azure/OpenRouter/etc. |
113
113
  | `--chatgpt-url <url>` | Target a ChatGPT workspace/folder (browser). |
114
114
  | `--browser-model-strategy <select\|current\|ignore>` | Control ChatGPT model selection in browser mode (current keeps the active model; ignore skips the picker). |
115
+ | `--browser-manual-login` | Skip cookie copy; reuse a persistent automation profile and wait for manual ChatGPT login. |
116
+ | `--browser-thinking-time <light\|standard\|extended\|heavy>` | Set ChatGPT thinking-time intensity (browser; Thinking/Pro models only). |
115
117
  | `--browser-port <port>` | Pin the Chrome DevTools port (WSL/Windows firewall helper). |
116
118
  | `--browser-inline-cookies[(-file)] <payload|path>` | Supply cookies without Chrome/Keychain (browser). |
117
119
  | `--browser-timeout`, `--browser-input-timeout` | Control overall/browser input timeouts (supports h/m/s/ms). |
@@ -147,7 +149,7 @@ Advanced flags
147
149
 
148
150
  | Area | Flags |
149
151
  | --- | --- |
150
- | Browser | `--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` |
151
153
  | Azure/OpenAI | `--azure-endpoint`, `--azure-deployment`, `--azure-api-version`, `--base-url` |
152
154
 
153
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,11 +110,18 @@ 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')) {
116
118
  return btn;
117
119
  }
120
+
121
+ // In some cases the pill is labeled "Pro".
122
+ if (aria.includes('pro') || text.includes('pro')) {
123
+ return btn;
124
+ }
118
125
  }
119
126
  }
120
127
  return null;
@@ -6,6 +6,7 @@ import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
7
  import CDP from 'chrome-remote-interface';
8
8
  import { launch, Launcher } from 'chrome-launcher';
9
+ import { cleanupStaleProfileState } from './profileState.js';
9
10
  const execFileAsync = promisify(execFile);
10
11
  export async function launchChrome(config, userDataDir, logger) {
11
12
  const connectHost = resolveRemoteDebugHost();
@@ -64,7 +65,14 @@ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logge
64
65
  catch {
65
66
  // ignore kill failures
66
67
  }
67
- await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
68
+ if (opts?.preserveUserDataDir) {
69
+ // Preserve the profile directory (manual login), but clear reattach hints so we don't
70
+ // try to reuse a dead DevTools port on the next run.
71
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: 'never' }).catch(() => undefined);
72
+ }
73
+ else {
74
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
75
+ }
68
76
  }
69
77
  })().finally(() => {
70
78
  const exitCode = signal === 'SIGINT' ? 130 : 1;
@@ -164,6 +172,8 @@ function buildChromeFlags(headless, debugBindAddress) {
164
172
  '--disable-features=TranslateUI,AutomationControlled',
165
173
  '--mute-audio',
166
174
  '--window-size=1280,720',
175
+ '--lang=en-US',
176
+ '--accept-lang=en-US,en',
167
177
  ];
168
178
  if (process.platform !== 'win32' && !isWsl()) {
169
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,
@@ -26,6 +27,7 @@ export const DEFAULT_BROWSER_CONFIG = {
26
27
  remoteChrome: null,
27
28
  manualLogin: false,
28
29
  manualLoginProfileDir: null,
30
+ manualLoginCookieSync: false,
29
31
  };
30
32
  export function resolveBrowserConfig(config) {
31
33
  const debugPortEnv = parseDebugPort(process.env.ORACLE_BROWSER_PORT ?? process.env.ORACLE_BROWSER_DEBUG_PORT);
@@ -57,6 +59,7 @@ export function resolveBrowserConfig(config) {
57
59
  inputTimeoutMs: config?.inputTimeoutMs ?? DEFAULT_BROWSER_CONFIG.inputTimeoutMs,
58
60
  cookieSync: config?.cookieSync ?? cookieSyncDefault,
59
61
  cookieNames: config?.cookieNames ?? DEFAULT_BROWSER_CONFIG.cookieNames,
62
+ cookieSyncWaitMs: config?.cookieSyncWaitMs ?? DEFAULT_BROWSER_CONFIG.cookieSyncWaitMs,
60
63
  inlineCookies: config?.inlineCookies ?? DEFAULT_BROWSER_CONFIG.inlineCookies,
61
64
  inlineCookiesSource: config?.inlineCookiesSource ?? DEFAULT_BROWSER_CONFIG.inlineCookiesSource,
62
65
  headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
@@ -72,6 +75,7 @@ export function resolveBrowserConfig(config) {
72
75
  thinkingTime: config?.thinkingTime,
73
76
  manualLogin,
74
77
  manualLoginProfileDir: manualLogin ? resolvedProfileDir : null,
78
+ manualLoginCookieSync: config?.manualLoginCookieSync ?? DEFAULT_BROWSER_CONFIG.manualLoginCookieSync,
75
79
  };
76
80
  }
77
81
  function parseDebugPort(raw) {
@@ -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';
@@ -13,7 +13,7 @@ import { formatElapsed } from '../oracle/format.js';
13
13
  import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from './constants.js';
14
14
  import { BrowserAutomationError } from '../oracle/errors.js';
15
15
  import { alignPromptEchoPair, buildPromptEchoMatcher } from './reattachHelpers.js';
16
- import { cleanupStaleProfileState, readChromePid, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
16
+ import { cleanupStaleProfileState, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
17
17
  export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from './constants.js';
18
18
  export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
19
19
  export async function runBrowserMode(options) {
@@ -95,7 +95,7 @@ export async function runBrowserMode(options) {
95
95
  else {
96
96
  logger(`Created temporary Chrome profile at ${userDataDir}`);
97
97
  }
98
- const effectiveKeepBrowser = config.keepBrowser || manualLogin;
98
+ const effectiveKeepBrowser = Boolean(config.keepBrowser);
99
99
  const reusedChrome = manualLogin ? await maybeReuseRunningChrome(userDataDir, logger) : null;
100
100
  const chrome = reusedChrome ??
101
101
  (await launchChrome({
@@ -115,6 +115,7 @@ export async function runBrowserMode(options) {
115
115
  removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
116
116
  isInFlight: () => runStatus !== 'complete',
117
117
  emitRuntimeHint,
118
+ preserveUserDataDir: manualLogin,
118
119
  });
119
120
  }
120
121
  catch {
@@ -128,6 +129,7 @@ export async function runBrowserMode(options) {
128
129
  let runStatus = 'attempted';
129
130
  let connectionClosedUnexpectedly = false;
130
131
  let stopThinkingMonitor = null;
132
+ let removeDialogHandler = null;
131
133
  let appliedCookies = 0;
132
134
  try {
133
135
  try {
@@ -157,11 +159,16 @@ export async function runBrowserMode(options) {
157
159
  domainEnablers.push(DOM.enable());
158
160
  }
159
161
  await Promise.all(domainEnablers);
162
+ removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
160
163
  if (!manualLogin) {
161
164
  await Network.clearBrowserCookies();
162
165
  }
163
- const cookieSyncEnabled = config.cookieSync && !manualLogin;
166
+ const manualLoginCookieSync = manualLogin && Boolean(config.manualLoginCookieSync);
167
+ const cookieSyncEnabled = config.cookieSync && (!manualLogin || manualLoginCookieSync);
164
168
  if (cookieSyncEnabled) {
169
+ if (manualLoginCookieSync) {
170
+ logger('Manual login mode: seeding persistent profile with cookies from your Chrome profile.');
171
+ }
165
172
  if (!config.inlineCookies) {
166
173
  logger('Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow.');
167
174
  }
@@ -174,6 +181,7 @@ export async function runBrowserMode(options) {
174
181
  filterNames: config.cookieNames ?? undefined,
175
182
  inlineCookies: config.inlineCookies ?? undefined,
176
183
  cookiePath: config.chromeCookiePath ?? undefined,
184
+ waitMs: config.cookieSyncWaitMs ?? 0,
177
185
  });
178
186
  appliedCookies = cookieCount;
179
187
  if (config.inlineCookies && cookieCount === 0) {
@@ -196,7 +204,8 @@ export async function runBrowserMode(options) {
196
204
  // Learned: if the profile has no ChatGPT cookies, browser mode will just bounce to login.
197
205
  // Fail early so the user knows to sign in.
198
206
  throw new BrowserAutomationError('No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. ' +
199
- '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.', {
200
209
  stage: 'execute-browser',
201
210
  details: {
202
211
  profile: config.chromeProfile ?? 'Default',
@@ -213,10 +222,17 @@ export async function runBrowserMode(options) {
213
222
  // Learned: login checks must happen on the base domain before jumping into project URLs.
214
223
  await raceWithDisconnect(waitForLogin({ runtime: Runtime, logger, appliedCookies, manualLogin, timeoutMs: config.timeoutMs }));
215
224
  if (config.url !== baseUrl) {
216
- await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.url, logger));
217
- 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));
218
235
  }
219
- await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
220
236
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
221
237
  const captureRuntimeSnapshot = async () => {
222
238
  try {
@@ -331,6 +347,7 @@ export async function runBrowserMode(options) {
331
347
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
332
348
  const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
333
349
  const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
350
+ let attachmentWaitTimedOut = false;
334
351
  let inputOnlyAttachments = false;
335
352
  if (submissionAttachments.length > 0) {
336
353
  if (!DOM) {
@@ -340,25 +357,38 @@ export async function runBrowserMode(options) {
340
357
  for (let attachmentIndex = 0; attachmentIndex < submissionAttachments.length; attachmentIndex += 1) {
341
358
  const attachment = submissionAttachments[attachmentIndex];
342
359
  logger(`Uploading attachment: ${attachment.displayPath}`);
343
- 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 });
344
361
  if (!uiConfirmed) {
345
362
  inputOnlyAttachments = true;
346
363
  }
347
364
  await delay(500);
348
365
  }
349
- // 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.
350
367
  const baseTimeout = config.inputTimeoutMs ?? 30_000;
351
- const perFileTimeout = 15_000;
352
- const waitBudget = Math.max(baseTimeout, 30_000) + (submissionAttachments.length - 1) * perFileTimeout;
353
- await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
354
- 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
+ }
355
384
  }
356
385
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
357
386
  // Learned: return baselineTurns so assistant polling can ignore earlier content.
387
+ const sendAttachmentNames = attachmentWaitTimedOut ? [] : attachmentNames;
358
388
  const committedTurns = await submitPrompt({
359
389
  runtime: Runtime,
360
390
  input: Input,
361
- attachmentNames,
391
+ attachmentNames: sendAttachmentNames,
362
392
  baselineTurns: baselineTurns ?? undefined,
363
393
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
364
394
  }, prompt, logger);
@@ -368,14 +398,18 @@ export async function runBrowserMode(options) {
368
398
  }
369
399
  }
370
400
  if (attachmentNames.length > 0) {
371
- if (inputOnlyAttachments) {
401
+ if (attachmentWaitTimedOut) {
402
+ logger('Attachment confirmation timed out; skipping user-turn attachment verification.');
403
+ }
404
+ else if (inputOnlyAttachments) {
372
405
  logger('Attachment UI did not render before send; skipping user-turn attachment verification.');
373
406
  }
374
407
  else {
375
408
  const verified = await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
376
- if (verified) {
377
- 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.');
378
411
  }
412
+ logger('Verified attachments present on sent user message');
379
413
  }
380
414
  }
381
415
  // Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
@@ -613,6 +647,7 @@ export async function runBrowserMode(options) {
613
647
  catch {
614
648
  // ignore
615
649
  }
650
+ removeDialogHandler?.();
616
651
  removeTerminationHooks?.();
617
652
  if (!effectiveKeepBrowser) {
618
653
  if (!connectionClosedUnexpectedly) {
@@ -623,7 +658,19 @@ export async function runBrowserMode(options) {
623
658
  // ignore kill failures
624
659
  }
625
660
  }
626
- await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
661
+ if (manualLogin) {
662
+ const shouldCleanup = await shouldCleanupManualLoginProfileState(userDataDir, logger.verbose ? logger : undefined, {
663
+ connectionClosedUnexpectedly,
664
+ host: chromeHost,
665
+ });
666
+ if (shouldCleanup) {
667
+ // Preserve the persistent manual-login profile, but clear stale reattach hints.
668
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: 'never' }).catch(() => undefined);
669
+ }
670
+ }
671
+ else {
672
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
673
+ }
627
674
  if (!connectionClosedUnexpectedly) {
628
675
  const totalSeconds = (Date.now() - startedAt) / 1000;
629
676
  logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`);
@@ -779,6 +826,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
779
826
  let answerHtml = '';
780
827
  let connectionClosedUnexpectedly = false;
781
828
  let stopThinkingMonitor = null;
829
+ let removeDialogHandler = null;
782
830
  try {
783
831
  const connection = await connectToRemoteChrome(host, port, logger, config.url);
784
832
  client = connection.client;
@@ -794,6 +842,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
794
842
  domainEnablers.push(DOM.enable());
795
843
  }
796
844
  await Promise.all(domainEnablers);
845
+ removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
797
846
  // Skip cookie sync for remote Chrome - it already has cookies
798
847
  logger('Skipping cookie sync for remote Chrome (using existing session)');
799
848
  await navigateToChatGPT(Page, Runtime, config.url, logger);
@@ -1065,6 +1114,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1065
1114
  catch {
1066
1115
  // ignore
1067
1116
  }
1117
+ removeDialogHandler?.();
1068
1118
  await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
1069
1119
  // Don't kill remote Chrome - it's not ours to manage
1070
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';
@@ -105,6 +105,22 @@ export async function verifyDevToolsReachable({ port, host = '127.0.0.1', attemp
105
105
  }
106
106
  return { ok: false, error: 'unreachable' };
107
107
  }
108
+ export async function shouldCleanupManualLoginProfileState(userDataDir, logger, options = {}) {
109
+ if (!options.connectionClosedUnexpectedly) {
110
+ return true;
111
+ }
112
+ const port = await readDevToolsPort(userDataDir);
113
+ if (!port) {
114
+ return true;
115
+ }
116
+ const probe = await (options.probe ?? verifyDevToolsReachable)({ port, host: options.host });
117
+ if (probe.ok) {
118
+ logger?.(`DevTools port ${port} still reachable; preserving manual-login profile state`);
119
+ return false;
120
+ }
121
+ logger?.(`DevTools port ${port} unreachable (${probe.error}); clearing stale profile state`);
122
+ return true;
123
+ }
108
124
  export async function cleanupStaleProfileState(userDataDir, logger, options = {}) {
109
125
  for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
110
126
  try {
@@ -7,6 +7,7 @@ import { launchChrome, connectToChrome, hideChromeWindow } from './chromeLifecyc
7
7
  import { resolveBrowserConfig } from './config.js';
8
8
  import { syncCookies } from './cookies.js';
9
9
  import { CHATGPT_URL } from './constants.js';
10
+ import { cleanupStaleProfileState } from './profileState.js';
10
11
  import { pickTarget, extractConversationIdFromUrl, buildConversationUrl, withTimeout, openConversationFromSidebar, openConversationFromSidebarWithRetry, waitForLocationChange, readConversationTurnIndex, buildPromptEchoMatcher, recoverPromptEcho, alignPromptEchoMarkdown, } from './reattachHelpers.js';
11
12
  export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
12
13
  const recoverSession = deps.recoverSession ??
@@ -113,6 +114,7 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
113
114
  filterNames: resolved.cookieNames ?? undefined,
114
115
  inlineCookies: resolved.inlineCookies ?? undefined,
115
116
  cookiePath: resolved.chromeCookiePath ?? undefined,
117
+ waitMs: resolved.cookieSyncWaitMs ?? 0,
116
118
  });
117
119
  }
118
120
  await navigateToChatGPT(Page, Runtime, CHATGPT_URL, logger);
@@ -159,14 +161,19 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
159
161
  // ignore
160
162
  }
161
163
  }
162
- if (!resolved.keepBrowser && !manualLogin) {
164
+ if (!resolved.keepBrowser) {
163
165
  try {
164
166
  await chrome.kill();
165
167
  }
166
168
  catch {
167
169
  // ignore
168
170
  }
169
- await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
171
+ if (manualLogin) {
172
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: 'never' }).catch(() => undefined);
173
+ }
174
+ else {
175
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
176
+ }
170
177
  }
171
178
  return { answerText: aligned.answerText, answerMarkdown: aligned.answerMarkdown };
172
179
  }
@@ -82,13 +82,15 @@ 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,
88
89
  inlineCookiesSource: inline?.source ?? null,
89
90
  headless: undefined, // disable headless; Cloudflare blocks it
90
91
  keepBrowser: options.browserKeepBrowser ? true : undefined,
91
- manualLogin: options.browserManualLogin ? true : undefined,
92
+ manualLogin: options.browserManualLogin === undefined ? undefined : options.browserManualLogin,
93
+ manualLoginProfileDir: options.browserManualLoginProfileDir ?? undefined,
92
94
  hideWindow: options.browserHideWindow ? true : undefined,
93
95
  desiredModel,
94
96
  modelStrategy,
@@ -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
  }
@@ -45,4 +48,13 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
45
48
  if (isUnset('browserModelStrategy') && browser.modelStrategy !== undefined) {
46
49
  options.browserModelStrategy = browser.modelStrategy;
47
50
  }
51
+ if (isUnset('browserThinkingTime') && browser.thinkingTime !== undefined) {
52
+ options.browserThinkingTime = browser.thinkingTime;
53
+ }
54
+ if (isUnset('browserManualLogin') && browser.manualLogin !== undefined) {
55
+ options.browserManualLogin = browser.manualLogin;
56
+ }
57
+ if (isUnset('browserManualLoginProfileDir') && browser.manualLoginProfileDir !== undefined) {
58
+ options.browserManualLoginProfileDir = browser.manualLoginProfileDir;
59
+ }
48
60
  }
@@ -1,12 +1,19 @@
1
1
  import process from 'node:process';
2
2
  import { startOscProgress as startOscProgressShared, supportsOscProgress as supportsOscProgressShared, } from 'osc-progress';
3
3
  export function supportsOscProgress(env = process.env, isTty = process.stdout.isTTY) {
4
+ if (env.CODEX_MANAGED_BY_NPM === '1' && env.ORACLE_FORCE_OSC_PROGRESS !== '1') {
5
+ return false;
6
+ }
4
7
  return supportsOscProgressShared(env, isTty, {
5
8
  disableEnvVar: 'ORACLE_NO_OSC_PROGRESS',
6
9
  forceEnvVar: 'ORACLE_FORCE_OSC_PROGRESS',
7
10
  });
8
11
  }
9
12
  export function startOscProgress(options = {}) {
13
+ const env = options.env ?? process.env;
14
+ if (env.CODEX_MANAGED_BY_NPM === '1' && env.ORACLE_FORCE_OSC_PROGRESS !== '1') {
15
+ return () => { };
16
+ }
10
17
  return startOscProgressShared({
11
18
  ...options,
12
19
  // Preserve Oracle's previous default: progress emits to stdout.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.8.0",
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",