@steipete/oracle 0.7.1 → 0.7.2

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.
@@ -4,6 +4,7 @@ import { logDomFailure, logConversationSnapshot, buildConversationDebugExpressio
4
4
  import { buildClickDispatcher } from './domEvents.js';
5
5
  const ASSISTANT_POLL_TIMEOUT_ERROR = 'assistant-response-watchdog-timeout';
6
6
  export async function waitForAssistantResponse(Runtime, timeoutMs, logger) {
7
+ const start = Date.now();
7
8
  logger('Waiting for ChatGPT response');
8
9
  const expression = buildResponseObserverExpression(timeoutMs);
9
10
  const evaluationPromise = Runtime.evaluate({ expression, awaitPromise: true, returnByValue: true });
@@ -61,7 +62,24 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger) {
61
62
  throw new Error('Unable to capture assistant response');
62
63
  }
63
64
  const refreshed = await refreshAssistantSnapshot(Runtime, parsed, logger);
64
- return refreshed ?? parsed;
65
+ const candidate = refreshed ?? parsed;
66
+ // The evaluation path can race ahead of completion. If ChatGPT is still streaming, wait for the watchdog poller.
67
+ const elapsedMs = Date.now() - start;
68
+ const remainingMs = Math.max(0, timeoutMs - elapsedMs);
69
+ if (remainingMs > 0) {
70
+ const [stopVisible, completionVisible] = await Promise.all([
71
+ isStopButtonVisible(Runtime),
72
+ isCompletionVisible(Runtime),
73
+ ]);
74
+ if (stopVisible && !completionVisible) {
75
+ logger('Assistant still generating; waiting for completion');
76
+ const completed = await pollAssistantCompletion(Runtime, remainingMs);
77
+ if (completed) {
78
+ return completed;
79
+ }
80
+ }
81
+ }
82
+ return candidate;
65
83
  }
66
84
  export async function readAssistantSnapshot(Runtime) {
67
85
  const { result } = await Runtime.evaluate({ expression: buildAssistantSnapshotExpression(), returnByValue: true });
@@ -118,11 +136,14 @@ async function parseAssistantEvaluationResult(Runtime, evaluation, timeoutMs, lo
118
136
  const messageId = typeof result.value.messageId === 'string'
119
137
  ? (result.value.messageId ?? undefined)
120
138
  : undefined;
121
- return {
122
- text: cleanAssistantText(String(result.value.text ?? '')),
123
- html,
124
- meta: { turnId, messageId },
125
- };
139
+ const text = cleanAssistantText(String(result.value.text ?? ''));
140
+ const normalized = text.toLowerCase();
141
+ if (normalized.includes('answer now') &&
142
+ (normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
143
+ const recovered = await recoverAssistantResponse(Runtime, Math.min(timeoutMs, 10_000), logger);
144
+ return recovered ?? null;
145
+ }
146
+ return { text, html, meta: { turnId, messageId } };
126
147
  }
127
148
  const fallbackText = typeof result.value === 'string' ? cleanAssistantText(result.value) : '';
128
149
  if (!fallbackText) {
@@ -261,7 +282,7 @@ function normalizeAssistantSnapshot(snapshot) {
261
282
  }
262
283
  const normalized = text.toLowerCase();
263
284
  // "Pro thinking" often renders a placeholder turn containing an "Answer now" gate.
264
- // Treat it as incomplete so browser mode keeps waiting (and can click the gate).
285
+ // Treat it as incomplete so browser mode keeps waiting for the real assistant text.
265
286
  if (normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
266
287
  return null;
267
288
  }
@@ -303,7 +324,10 @@ function buildResponseObserverExpression(timeoutMs) {
303
324
  const CONVERSATION_SELECTOR = ${conversationLiteral};
304
325
  const ASSISTANT_SELECTOR = ${assistantLiteral};
305
326
  const settleDelayMs = 800;
306
- const ANSWER_NOW_LABEL = 'answer now';
327
+ const isAnswerNowPlaceholder = (snapshot) => {
328
+ const normalized = String(snapshot?.text ?? '').toLowerCase();
329
+ return normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'));
330
+ };
307
331
 
308
332
  // Helper to detect assistant turns - matches buildAssistantExtractor logic
309
333
  const isAssistantTurn = (node) => {
@@ -324,7 +348,8 @@ function buildResponseObserverExpression(timeoutMs) {
324
348
  const deadline = Date.now() + ${timeoutMs};
325
349
  let stopInterval = null;
326
350
  const observer = new MutationObserver(() => {
327
- const extracted = extractFromTurns();
351
+ const extractedRaw = extractFromTurns();
352
+ const extracted = extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
328
353
  if (extracted) {
329
354
  observer.disconnect();
330
355
  if (stopInterval) {
@@ -341,11 +366,6 @@ function buildResponseObserverExpression(timeoutMs) {
341
366
  });
342
367
  observer.observe(document.body, { childList: true, subtree: true, characterData: true });
343
368
  stopInterval = setInterval(() => {
344
- // Pro thinking can gate the response behind an "Answer now" button. Keep clicking it while present.
345
- const answerNow = Array.from(document.querySelectorAll('button,span')).find((el) => (el?.textContent || '').trim().toLowerCase() === ANSWER_NOW_LABEL);
346
- if (answerNow) {
347
- dispatchClickSequence(answerNow.closest('button') ?? answerNow);
348
- }
349
369
  const stop = document.querySelector(STOP_SELECTOR);
350
370
  if (!stop) {
351
371
  return;
@@ -387,28 +407,28 @@ function buildResponseObserverExpression(timeoutMs) {
387
407
  const waitForSettle = async (snapshot) => {
388
408
  const settleWindowMs = 5000;
389
409
  const settleIntervalMs = 400;
390
- const deadline = Date.now() + settleWindowMs;
391
- let latest = snapshot;
392
- let lastLength = snapshot?.text?.length ?? 0;
393
- while (Date.now() < deadline) {
394
- await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
395
- const refreshed = extractFromTurns();
396
- if (refreshed && (refreshed.text?.length ?? 0) >= lastLength) {
397
- latest = refreshed;
398
- lastLength = refreshed.text?.length ?? lastLength;
399
- }
400
- const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
401
- const answerNowVisible = Boolean(Array.from(document.querySelectorAll('button,span')).find((el) => (el?.textContent || '').trim().toLowerCase() === ANSWER_NOW_LABEL));
402
- const finishedVisible = isLastAssistantTurnFinished();
410
+ const deadline = Date.now() + settleWindowMs;
411
+ let latest = snapshot;
412
+ let lastLength = snapshot?.text?.length ?? 0;
413
+ while (Date.now() < deadline) {
414
+ await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
415
+ const refreshed = extractFromTurns();
416
+ if (refreshed && !isAnswerNowPlaceholder(refreshed) && (refreshed.text?.length ?? 0) >= lastLength) {
417
+ latest = refreshed;
418
+ lastLength = refreshed.text?.length ?? lastLength;
419
+ }
420
+ const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
421
+ const finishedVisible = isLastAssistantTurnFinished();
403
422
 
404
- if ((!stopVisible && !answerNowVisible) || finishedVisible) {
405
- break;
423
+ if (!stopVisible || finishedVisible) {
424
+ break;
425
+ }
406
426
  }
407
- }
408
- return latest ?? snapshot;
409
- };
427
+ return latest ?? snapshot;
428
+ };
410
429
 
411
- const extracted = extractFromTurns();
430
+ const extractedRaw = extractFromTurns();
431
+ const extracted = extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
412
432
  if (extracted) {
413
433
  return waitForSettle(extracted);
414
434
  }
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { FILE_INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS } from '../constants.js';
2
+ import { CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS } from '../constants.js';
3
3
  import { delay } from '../utils.js';
4
4
  import { logDomFailure } from '../domDebug.js';
5
5
  export async function uploadAttachmentFile(deps, attachment, logger) {
@@ -22,17 +22,11 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
22
22
  ),
23
23
  );
24
24
  if (chips) return true;
25
- const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
25
+ const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]')).map((btn) =>
26
26
  btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
27
27
  );
28
28
  if (cardTexts.some((text) => text.includes(expected))) return true;
29
29
 
30
- const filesPill = Array.from(document.querySelectorAll('button,div')).some((node) => {
31
- const text = (node?.textContent || '').toLowerCase();
32
- return /\bfiles\b/.test(text) && text.includes('file');
33
- });
34
- if (filesPill) return true;
35
-
36
30
  const inputs = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
37
31
  Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(expected)),
38
32
  );
@@ -85,10 +79,21 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
85
79
  logger(`Attachment already present: ${path.basename(attachment.path)}`);
86
80
  return;
87
81
  }
88
- // Find a real input; prefer non-image accept fields and tag it for DOM.setFileInputFiles.
89
- const markResult = await runtime.evaluate({
82
+ const documentNode = await dom.getDocument();
83
+ const candidateSetup = await runtime.evaluate({
90
84
  expression: `(() => {
91
- const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
85
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
86
+ const locateComposerRoot = () => {
87
+ for (const selector of promptSelectors) {
88
+ const node = document.querySelector(selector);
89
+ if (!node) continue;
90
+ return node.closest('form') ?? node.closest('[data-testid*="composer"]') ?? node.parentElement;
91
+ }
92
+ return document.querySelector('form') ?? document.body;
93
+ };
94
+ const root = locateComposerRoot();
95
+ const localInputs = root ? Array.from(root.querySelectorAll('input[type="file"]')) : [];
96
+ const inputs = localInputs.length > 0 ? localInputs : Array.from(document.querySelectorAll('input[type="file"]'));
92
97
  const acceptIsImageOnly = (accept) => {
93
98
  if (!accept) return false;
94
99
  const parts = String(accept)
@@ -97,73 +102,142 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
97
102
  .filter(Boolean);
98
103
  return parts.length > 0 && parts.every((p) => p.startsWith('image/'));
99
104
  };
100
- const nonImage = inputs.filter((el) => !acceptIsImageOnly(el.getAttribute('accept')));
101
- const target = (nonImage.length ? nonImage[nonImage.length - 1] : inputs[inputs.length - 1]) ?? null;
102
- if (target) {
103
- target.setAttribute('data-oracle-upload-target', 'true');
104
- return true;
105
- }
106
- return false;
105
+ const chipContainer = root ?? document;
106
+ const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
107
+ const baselineChipCount = chipContainer.querySelectorAll(chipSelector).length;
108
+
109
+ // Mark candidates with stable indices so we can select them via DOM.querySelector.
110
+ let idx = 0;
111
+ const candidates = inputs.map((el) => {
112
+ const accept = el.getAttribute('accept') || '';
113
+ const score = (el.hasAttribute('multiple') ? 100 : 0) + (!acceptIsImageOnly(accept) ? 10 : 0);
114
+ el.setAttribute('data-oracle-upload-candidate', 'true');
115
+ el.setAttribute('data-oracle-upload-idx', String(idx));
116
+ return { idx: idx++, score };
117
+ });
118
+
119
+ // Prefer higher scores first.
120
+ candidates.sort((a, b) => b.score - a.score);
121
+ return { ok: candidates.length > 0, baselineChipCount, order: candidates.map((c) => c.idx) };
107
122
  })()`,
108
123
  returnByValue: true,
109
124
  });
110
- const marked = Boolean(markResult?.result?.value);
111
- if (!marked) {
125
+ const candidateValue = candidateSetup?.result?.value;
126
+ const candidateOrder = Array.isArray(candidateValue?.order) ? candidateValue.order : [];
127
+ const baselineChipCount = typeof candidateValue?.baselineChipCount === 'number' ? candidateValue.baselineChipCount : 0;
128
+ if (!candidateValue?.ok || candidateOrder.length === 0) {
112
129
  await logDomFailure(runtime, logger, 'file-input-missing');
113
130
  throw new Error('Unable to locate ChatGPT file attachment input.');
114
131
  }
115
- const documentNode = await dom.getDocument();
116
- const resultNode = await dom.querySelector({ nodeId: documentNode.root.nodeId, selector: 'input[type="file"][data-oracle-upload-target="true"]' });
117
- if (!resultNode?.nodeId) {
118
- await logDomFailure(runtime, logger, 'file-input-missing');
119
- throw new Error('Unable to locate ChatGPT file attachment input.');
132
+ const dispatchEventsFor = (idx) => `(() => {
133
+ const el = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
134
+ if (el instanceof HTMLInputElement) {
135
+ el.dispatchEvent(new Event('input', { bubbles: true }));
136
+ el.dispatchEvent(new Event('change', { bubbles: true }));
120
137
  }
121
- const resolvedNodeId = resultNode.nodeId;
122
- const dispatchEvents = FILE_INPUT_SELECTORS
123
- .map((selector) => `
124
- (() => {
125
- const el = document.querySelector(${JSON.stringify(selector)});
126
- if (el instanceof HTMLInputElement) {
127
- el.dispatchEvent(new Event('input', { bubbles: true }));
128
- el.dispatchEvent(new Event('change', { bubbles: true }));
129
- }
130
- })();
131
- `)
132
- .join('\\n');
133
- const tryFileInput = async () => {
134
- await dom.setFileInputFiles({ nodeId: resolvedNodeId, files: [attachment.path] });
135
- await runtime.evaluate({ expression: `(function(){${dispatchEvents} return true;})()`, returnByValue: true });
138
+ return true;
139
+ })()`;
140
+ const composerSnapshotFor = (idx) => `(() => {
141
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
142
+ const locateComposerRoot = () => {
143
+ for (const selector of promptSelectors) {
144
+ const node = document.querySelector(selector);
145
+ if (!node) continue;
146
+ return node.closest('form') ?? node.closest('[data-testid*="composer"]') ?? node.parentElement;
147
+ }
148
+ return document.querySelector('form') ?? document.body;
136
149
  };
137
- await tryFileInput();
138
- // Snapshot the attachment state immediately after setting files so we can detect silent failures.
139
- const snapshotExpr = `(() => {
140
- const chips = Array.from(document.querySelectorAll('[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label="Remove file"]'))
141
- .map((node) => (node?.textContent || '').trim())
142
- .filter(Boolean);
143
- const inputs = Array.from(document.querySelectorAll('input[type="file"]')).map((el) => ({
144
- files: Array.from(el.files || []).map((f) => f?.name ?? ''),
145
- }));
146
- return { chips, inputs };
150
+ const root = locateComposerRoot();
151
+ const chipContainer = root ?? document;
152
+ const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
153
+ const chips = Array.from(chipContainer.querySelectorAll(chipSelector))
154
+ .slice(0, 20)
155
+ .map((node) => ({
156
+ text: (node.textContent || '').trim(),
157
+ aria: node.getAttribute?.('aria-label') ?? '',
158
+ title: node.getAttribute?.('title') ?? '',
159
+ testid: node.getAttribute?.('data-testid') ?? '',
160
+ }));
161
+ const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
162
+ const inputNames =
163
+ input instanceof HTMLInputElement
164
+ ? Array.from(input.files || []).map((f) => f?.name ?? '').filter(Boolean)
165
+ : [];
166
+ const composerText = (chipContainer.innerText || '').toLowerCase();
167
+ return { chipCount: chipContainer.querySelectorAll(chipSelector).length, chips, inputNames, composerText };
147
168
  })()`;
148
- const snapshot = await runtime
149
- .evaluate({ expression: snapshotExpr, returnByValue: true })
150
- .then((res) => res?.result?.value)
151
- .catch(() => undefined);
152
- if (snapshot) {
153
- logger?.(`Attachment snapshot after setFileInputFiles: chips=${JSON.stringify(snapshot.chips || [])} inputs=${JSON.stringify(snapshot.inputs || [])}`);
169
+ let finalSnapshot = null;
170
+ for (const idx of candidateOrder) {
171
+ const resultNode = await dom.querySelector({
172
+ nodeId: documentNode.root.nodeId,
173
+ selector: `input[type="file"][data-oracle-upload-idx="${idx}"]`,
174
+ });
175
+ if (!resultNode?.nodeId) {
176
+ continue;
177
+ }
178
+ await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
179
+ await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
180
+ const probeDeadline = Date.now() + 4000;
181
+ let lastPoke = 0;
182
+ while (Date.now() < probeDeadline) {
183
+ // ChatGPT's composer can take a moment to hydrate the file-input onChange handler after navigation/model switches.
184
+ // If the UI hasn't reacted yet, poke the input a few times to ensure the handler fires once it's mounted.
185
+ if (Date.now() - lastPoke > 650) {
186
+ lastPoke = Date.now();
187
+ await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
188
+ }
189
+ const snapshot = await runtime
190
+ .evaluate({ expression: composerSnapshotFor(idx), returnByValue: true })
191
+ .then((res) => res?.result?.value)
192
+ .catch(() => undefined);
193
+ if (snapshot) {
194
+ finalSnapshot = {
195
+ chipCount: Number(snapshot.chipCount ?? 0),
196
+ chips: Array.isArray(snapshot.chips) ? snapshot.chips : [],
197
+ inputNames: Array.isArray(snapshot.inputNames) ? snapshot.inputNames : [],
198
+ composerText: typeof snapshot.composerText === 'string' ? snapshot.composerText : '',
199
+ };
200
+ const inputHasFile = finalSnapshot.inputNames.some((name) => name.toLowerCase().includes(expectedName.toLowerCase()));
201
+ const expectedLower = expectedName.toLowerCase();
202
+ const expectedNoExt = expectedLower.replace(/\.[a-z0-9]{1,10}$/i, '');
203
+ const uiAcknowledged = finalSnapshot.chipCount > baselineChipCount ||
204
+ (expectedNoExt.length >= 6
205
+ ? finalSnapshot.composerText.includes(expectedNoExt)
206
+ : finalSnapshot.composerText.includes(expectedLower));
207
+ if (inputHasFile && uiAcknowledged) {
208
+ logger?.(`Attachment snapshot after setFileInputFiles: chips=${JSON.stringify(finalSnapshot.chips)} input=${JSON.stringify(finalSnapshot.inputNames)}`);
209
+ break;
210
+ }
211
+ }
212
+ await delay(200);
213
+ }
214
+ const inputHasFile = finalSnapshot?.inputNames?.some((name) => name.toLowerCase().includes(expectedName.toLowerCase())) ?? false;
215
+ const uiAcknowledged = (finalSnapshot?.chipCount ?? 0) > baselineChipCount;
216
+ if (inputHasFile && uiAcknowledged) {
217
+ break;
218
+ }
154
219
  }
155
- const inputHasFile = snapshot?.inputs?.some((entry) => (entry.files || []).some((name) => name?.toLowerCase?.().includes(expectedName.toLowerCase()))) ?? false;
156
- if (await waitForAttachmentAnchored(runtime, expectedName, 20_000)) {
157
- await waitForAttachmentVisible(runtime, expectedName, 20_000, logger);
158
- logger(inputHasFile ? 'Attachment queued (file input, confirmed present)' : 'Attachment queued (file input)');
220
+ const inputHasFile = finalSnapshot?.inputNames?.some((name) => name.toLowerCase().includes(expectedName.toLowerCase())) ?? false;
221
+ const attachmentUiTimeoutMs = 25_000;
222
+ if (await waitForAttachmentAnchored(runtime, expectedName, attachmentUiTimeoutMs)) {
223
+ await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
224
+ logger(inputHasFile ? 'Attachment queued (UI anchored, file input confirmed)' : 'Attachment queued (UI anchored)');
159
225
  return;
160
226
  }
227
+ // If ChatGPT never reflects an attachment UI chip/remove control, the file input may be the wrong target:
228
+ // sending in this state often drops the attachment silently.
229
+ if (inputHasFile) {
230
+ await logDomFailure(runtime, logger, 'file-upload-missing');
231
+ throw new Error('Attachment input accepted the file but ChatGPT did not acknowledge it in the composer UI.');
232
+ }
161
233
  await logDomFailure(runtime, logger, 'file-upload-missing');
162
234
  throw new Error('Attachment did not register with the ChatGPT composer in time.');
163
235
  }
164
236
  export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNames = [], logger) {
165
237
  const deadline = Date.now() + timeoutMs;
166
238
  const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
239
+ let inputMatchSince = null;
240
+ let attachmentMatchSince = null;
167
241
  const expression = `(() => {
168
242
  const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
169
243
  let button = null;
@@ -185,8 +259,9 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
185
259
  if (ariaBusy === 'true' || dataState === 'loading' || dataState === 'uploading' || dataState === 'pending') {
186
260
  return true;
187
261
  }
262
+ // Avoid false positives from user prompts ("upload:") or generic UI copy; only treat explicit progress strings as uploading.
188
263
  const text = node.textContent?.toLowerCase?.() ?? '';
189
- return text.includes('upload') || text.includes('processing') || text.includes('uploading');
264
+ return /\buploading\b/.test(text) || /\bprocessing\b/.test(text);
190
265
  });
191
266
  });
192
267
  const attachmentSelectors = ['[data-testid*="chip"]', '[data-testid*="attachment"]', '[data-testid*="upload"]'];
@@ -197,26 +272,31 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
197
272
  if (text) attachedNames.push(text);
198
273
  }
199
274
  }
275
+ const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
276
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
277
+ );
278
+ attachedNames.push(...cardTexts.filter(Boolean));
279
+
280
+ const inputNames = [];
200
281
  for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
201
282
  if (!(input instanceof HTMLInputElement) || !input.files?.length) continue;
202
283
  for (const file of Array.from(input.files)) {
203
- if (file?.name) attachedNames.push(file.name.toLowerCase());
284
+ if (file?.name) inputNames.push(file.name.toLowerCase());
204
285
  }
205
286
  }
206
- const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
207
- btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
208
- );
209
- attachedNames.push(...cardTexts.filter(Boolean));
210
287
  const filesAttached = attachedNames.length > 0;
211
- return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached, attachedNames };
288
+ return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached, attachedNames, inputNames };
212
289
  })()`;
213
290
  while (Date.now() < deadline) {
214
291
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
215
292
  const value = result?.value;
216
- if (value && !value.uploading) {
293
+ if (value) {
217
294
  const attachedNames = (value.attachedNames ?? [])
218
295
  .map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
219
296
  .filter(Boolean);
297
+ const inputNames = (value.inputNames ?? [])
298
+ .map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
299
+ .filter(Boolean);
220
300
  const matchesExpected = (expected) => {
221
301
  const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
222
302
  const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -242,18 +322,50 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
242
322
  };
243
323
  const missing = expectedNormalized.filter((expected) => !matchesExpected(expected));
244
324
  if (missing.length === 0) {
325
+ const stableThresholdMs = value.uploading ? 3000 : 1500;
245
326
  if (value.state === 'ready') {
246
- return;
327
+ if (attachmentMatchSince === null) {
328
+ attachmentMatchSince = Date.now();
329
+ }
330
+ if (Date.now() - attachmentMatchSince > stableThresholdMs) {
331
+ return;
332
+ }
333
+ }
334
+ else {
335
+ attachmentMatchSince = null;
247
336
  }
248
337
  if (value.state === 'missing' && value.filesAttached) {
249
338
  return;
250
339
  }
251
- // If files are attached but button isn't ready yet, give it more time but don't fail immediately
340
+ // If files are attached but button isn't ready yet, give it more time but don't fail immediately.
252
341
  if (value.filesAttached) {
253
342
  await delay(500);
254
343
  continue;
255
344
  }
256
345
  }
346
+ else {
347
+ attachmentMatchSince = null;
348
+ }
349
+ // Fallback: if the file input has the expected names, allow progress once that condition is stable.
350
+ // Some ChatGPT surfaces only render the filename after sending the message.
351
+ const inputMissing = expectedNormalized.filter((expected) => {
352
+ const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
353
+ const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
354
+ const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
355
+ return !inputNames.some((raw) => raw.includes(normalizedExpected) || (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
356
+ });
357
+ if (inputMissing.length === 0 && (value.state === 'ready' || value.state === 'missing')) {
358
+ const stableThresholdMs = value.uploading ? 3000 : 1500;
359
+ if (inputMatchSince === null) {
360
+ inputMatchSince = Date.now();
361
+ }
362
+ if (Date.now() - inputMatchSince > stableThresholdMs) {
363
+ return;
364
+ }
365
+ }
366
+ else {
367
+ inputMatchSince = null;
368
+ }
257
369
  }
258
370
  await delay(250);
259
371
  }
@@ -261,6 +373,58 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
261
373
  await logDomFailure(Runtime, logger ?? (() => { }), 'file-upload-timeout');
262
374
  throw new Error('Attachments did not finish uploading before timeout.');
263
375
  }
376
+ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeoutMs, logger) {
377
+ if (!expectedNames || expectedNames.length === 0) {
378
+ return;
379
+ }
380
+ const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
381
+ const conversationSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
382
+ const expression = `(() => {
383
+ const CONVERSATION_SELECTOR = ${conversationSelectorLiteral};
384
+ const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
385
+ const userTurns = turns.filter((node) => {
386
+ const attr = (node.getAttribute('data-message-author-role') || node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
387
+ if (attr === 'user') return true;
388
+ return Boolean(node.querySelector('[data-message-author-role="user"]'));
389
+ });
390
+ const lastUser = userTurns[userTurns.length - 1];
391
+ if (!lastUser) return { ok: false };
392
+ const text = (lastUser.innerText || '').toLowerCase();
393
+ const attrs = Array.from(lastUser.querySelectorAll('[aria-label],[title]')).map((el) => {
394
+ const aria = el.getAttribute('aria-label') || '';
395
+ const title = el.getAttribute('title') || '';
396
+ return (aria + ' ' + title).trim().toLowerCase();
397
+ }).filter(Boolean);
398
+ return { ok: true, text, attrs };
399
+ })()`;
400
+ const deadline = Date.now() + timeoutMs;
401
+ while (Date.now() < deadline) {
402
+ const { result } = await Runtime.evaluate({ expression, returnByValue: true });
403
+ const value = result?.value;
404
+ if (!value?.ok) {
405
+ await delay(200);
406
+ continue;
407
+ }
408
+ const haystack = [value.text ?? '', ...(value.attrs ?? [])].join('\n');
409
+ const missing = expectedNormalized.filter((expected) => {
410
+ const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
411
+ const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
412
+ const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
413
+ if (haystack.includes(normalizedExpected))
414
+ return false;
415
+ if (expectedNoExt.length >= 6 && haystack.includes(expectedNoExt))
416
+ return false;
417
+ return true;
418
+ });
419
+ if (missing.length === 0) {
420
+ return;
421
+ }
422
+ await delay(250);
423
+ }
424
+ logger?.('Sent user message did not show expected attachment names in time.');
425
+ await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-missing-user-turn');
426
+ throw new Error('Attachment was not present on the sent user message.');
427
+ }
264
428
  export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs, logger) {
265
429
  // Attachments can take a few seconds to render in the composer (headless/remote Chrome is slower),
266
430
  // so respect the caller-provided timeout instead of capping at 2s.
@@ -268,6 +432,7 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
268
432
  const expression = `(() => {
269
433
  const expected = ${JSON.stringify(expectedName)};
270
434
  const normalized = expected.toLowerCase();
435
+ const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
271
436
  const matchNode = (node) => {
272
437
  if (!node) return false;
273
438
  const text = (node.textContent || '').toLowerCase();
@@ -275,67 +440,26 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
275
440
  const title = node.getAttribute?.('title')?.toLowerCase?.() ?? '';
276
441
  const testId = node.getAttribute?.('data-testid')?.toLowerCase?.() ?? '';
277
442
  const alt = node.getAttribute?.('alt')?.toLowerCase?.() ?? '';
278
- return [text, aria, title, testId, alt].some((value) => value.includes(normalized));
443
+ const candidates = [text, aria, title, testId, alt].filter(Boolean);
444
+ return candidates.some((value) => value.includes(normalized) || (normalizedNoExt.length >= 6 && value.includes(normalizedNoExt)));
279
445
  };
280
446
 
281
- const turns = Array.from(document.querySelectorAll('article[data-testid^="conversation-turn"]'));
282
- const userTurns = turns.filter((node) => {
283
- const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
284
- if (turnAttr === 'user') return true;
285
- return Boolean(node.querySelector('[data-message-author-role="user"]'));
286
- });
287
- const lastUser = userTurns[userTurns.length - 1];
288
- if (lastUser) {
289
- const turnMatch = Array.from(lastUser.querySelectorAll('*')).some(matchNode);
290
- if (turnMatch) return { found: true, userTurns: userTurns.length, source: 'turn' };
291
- }
292
-
293
- const composerSelectors = [
294
- '[data-testid*="composer"]',
295
- 'form textarea',
296
- 'form [data-testid*="attachment"]',
297
- '[data-testid*="upload"]',
298
- '[data-testid*="chip"]',
299
- 'form',
300
- 'button',
301
- 'label'
302
- ];
303
- const composerMatch = composerSelectors.some((selector) =>
304
- Array.from(document.querySelectorAll(selector)).some(matchNode),
305
- );
306
- if (composerMatch) {
307
- return { found: true, userTurns: userTurns.length, source: 'composer' };
308
- }
309
-
310
447
  const attachmentSelectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]'];
311
448
  const attachmentMatch = attachmentSelectors.some((selector) =>
312
449
  Array.from(document.querySelectorAll(selector)).some(matchNode),
313
450
  );
314
451
  if (attachmentMatch) {
315
- return { found: true, userTurns: userTurns.length, source: 'attachments' };
452
+ return { found: true, source: 'attachments' };
316
453
  }
317
454
 
318
- const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
455
+ const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
319
456
  btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
320
457
  );
321
- if (cardTexts.some((text) => text.includes(normalized))) {
322
- return { found: true, userTurns: userTurns.length, source: 'attachment-cards' };
323
- }
324
-
325
- const attrMatch = Array.from(document.querySelectorAll('[aria-label], [title], [data-testid]')).some(matchNode);
326
- if (attrMatch) {
327
- return { found: true, userTurns: userTurns.length, source: 'attrs' };
458
+ if (cardTexts.some((text) => text.includes(normalized) || (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)))) {
459
+ return { found: true, source: 'attachment-cards' };
328
460
  }
329
461
 
330
- const bodyMatch = (document.body?.innerText || '').toLowerCase().includes(normalized);
331
- if (bodyMatch) {
332
- return { found: true, userTurns: userTurns.length, source: 'body' };
333
- }
334
-
335
- const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
336
- Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
337
- );
338
- return { found: inputHit, userTurns: userTurns.length, source: inputHit ? 'input' : undefined };
462
+ return { found: false };
339
463
  })()`;
340
464
  while (Date.now() < deadline) {
341
465
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
@@ -353,28 +477,47 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
353
477
  const deadline = Date.now() + timeoutMs;
354
478
  const expression = `(() => {
355
479
  const normalized = ${JSON.stringify(expectedName.toLowerCase())};
356
- const selectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]'];
480
+ const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
481
+ const matchesExpected = (value) => {
482
+ const text = (value ?? '').toLowerCase();
483
+ if (!text) return false;
484
+ if (text.includes(normalized)) return true;
485
+ if (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)) return true;
486
+ if (text.includes('…') || text.includes('...')) {
487
+ const escaped = text.replace(/[.*+?^$\\{\\}()|[\\]\\\\]/g, '\\\\$&');
488
+ const pattern = escaped.replaceAll('…', '.*').replaceAll('...', '.*');
489
+ try {
490
+ const re = new RegExp(pattern);
491
+ return re.test(normalized) || (normalizedNoExt.length >= 6 && re.test(normalizedNoExt));
492
+ } catch {
493
+ return false;
494
+ }
495
+ }
496
+ return false;
497
+ };
498
+
499
+ const selectors = [
500
+ '[data-testid*="attachment"]',
501
+ '[data-testid*="chip"]',
502
+ '[data-testid*="upload"]',
503
+ '[aria-label*="Remove"]',
504
+ 'button[aria-label*="Remove"]',
505
+ ];
357
506
  for (const selector of selectors) {
358
507
  for (const node of Array.from(document.querySelectorAll(selector))) {
359
- const text = (node?.textContent || '').toLowerCase();
360
- if (text.includes(normalized)) {
361
- return { found: true, text };
508
+ const text = node?.textContent || '';
509
+ const aria = node?.getAttribute?.('aria-label') || '';
510
+ const title = node?.getAttribute?.('title') || '';
511
+ if ([text, aria, title].some(matchesExpected)) {
512
+ return { found: true, text: (text || aria || title).toLowerCase() };
362
513
  }
363
514
  }
364
515
  }
365
- const cards = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
516
+ const cards = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
366
517
  btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
367
518
  );
368
- if (cards.some((text) => text.includes(normalized))) {
369
- return { found: true, text: cards.find((t) => t.includes(normalized)) };
370
- }
371
-
372
- // As a last resort, treat file inputs that hold the target name as anchored. Some UIs delay chip rendering.
373
- const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
374
- Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
375
- );
376
- if (inputHit) {
377
- return { found: true, text: 'input-only' };
519
+ if (cards.some(matchesExpected)) {
520
+ return { found: true, text: cards.find(matchesExpected) };
378
521
  }
379
522
  return { found: false };
380
523
  })()`;
@@ -18,7 +18,13 @@ export async function ensureModelSelection(Runtime, desiredModel, logger) {
18
18
  }
19
19
  case 'option-not-found': {
20
20
  await logDomFailure(Runtime, logger, 'model-switcher-option');
21
- throw new Error(`Unable to find model option matching "${desiredModel}" in the model switcher.`);
21
+ const isTemporary = result.hint?.temporaryChat ?? false;
22
+ const available = (result.hint?.availableOptions ?? []).filter(Boolean);
23
+ const availableHint = available.length > 0 ? ` Available: ${available.join(', ')}.` : '';
24
+ const tempHint = isTemporary && /\bpro\b/i.test(desiredModel)
25
+ ? ' You are in Temporary Chat mode; Pro models are not available there. Remove "temporary-chat=true" from --chatgpt-url or use a non-Pro model (e.g. gpt-5.2).'
26
+ : '';
27
+ throw new Error(`Unable to find model option matching "${desiredModel}" in the model switcher.${availableHint}${tempHint}`);
22
28
  }
23
29
  default: {
24
30
  await logDomFailure(Runtime, logger, 'model-switcher-button');
@@ -237,6 +243,28 @@ function buildModelSelectionExpression(targetModel) {
237
243
 
238
244
  return new Promise((resolve) => {
239
245
  const start = performance.now();
246
+ const detectTemporaryChat = () => {
247
+ try {
248
+ const url = new URL(window.location.href);
249
+ const flag = (url.searchParams.get('temporary-chat') ?? '').toLowerCase();
250
+ if (flag === 'true' || flag === '1' || flag === 'yes') return true;
251
+ } catch {}
252
+ const title = (document.title || '').toLowerCase();
253
+ if (title.includes('temporary chat')) return true;
254
+ const body = (document.body?.innerText || '').toLowerCase();
255
+ return body.includes('temporary chat');
256
+ };
257
+ const collectAvailableOptions = () => {
258
+ const menuRoots = Array.from(document.querySelectorAll(${menuContainerLiteral}));
259
+ const nodes = menuRoots.length > 0
260
+ ? menuRoots.flatMap((root) => Array.from(root.querySelectorAll(${menuItemLiteral})))
261
+ : Array.from(document.querySelectorAll(${menuItemLiteral}));
262
+ const labels = nodes
263
+ .map((node) => (node?.textContent ?? '').trim())
264
+ .filter(Boolean)
265
+ .filter((label, index, arr) => arr.indexOf(label) === index);
266
+ return labels.slice(0, 12);
267
+ };
240
268
  const ensureMenuOpen = () => {
241
269
  const menuOpen = document.querySelector('[role="menu"], [data-radix-collection-root]');
242
270
  if (!menuOpen && performance.now() - lastPointerClick > REOPEN_INTERVAL_MS) {
@@ -279,7 +307,10 @@ function buildModelSelectionExpression(targetModel) {
279
307
  return;
280
308
  }
281
309
  if (performance.now() - start > MAX_WAIT_MS) {
282
- resolve({ status: 'option-not-found' });
310
+ resolve({
311
+ status: 'option-not-found',
312
+ hint: { temporaryChat: detectTemporaryChat(), availableOptions: collectAvailableOptions() },
313
+ });
283
314
  return;
284
315
  }
285
316
  setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
@@ -135,7 +135,6 @@ export async function submitPrompt(deps, prompt, logger) {
135
135
  logger('Clicked send button');
136
136
  }
137
137
  await verifyPromptCommitted(runtime, prompt, 30_000, logger);
138
- await clickAnswerNowIfPresent(runtime, logger);
139
138
  }
140
139
  export async function clearPromptComposer(Runtime, logger) {
141
140
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
@@ -186,6 +185,43 @@ async function waitForDomReady(Runtime, logger) {
186
185
  }
187
186
  logger?.('Page did not reach ready/composer state within 10s; continuing cautiously.');
188
187
  }
188
+ function buildAttachmentReadyExpression(attachmentNames) {
189
+ const namesLiteral = JSON.stringify(attachmentNames.map((name) => name.toLowerCase()));
190
+ return `(() => {
191
+ const names = ${namesLiteral};
192
+ const composer =
193
+ document.querySelector('[data-testid*="composer"]') ||
194
+ document.querySelector('form') ||
195
+ document.body ||
196
+ document;
197
+ const match = (node, name) => (node?.textContent || '').toLowerCase().includes(name);
198
+
199
+ // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
200
+ const attachmentSelectors = [
201
+ '[data-testid*="chip"]',
202
+ '[data-testid*="attachment"]',
203
+ '[data-testid*="upload"]',
204
+ '[aria-label="Remove file"]',
205
+ 'button[aria-label="Remove file"]',
206
+ ];
207
+
208
+ const chipsReady = names.every((name) =>
209
+ Array.from(composer.querySelectorAll(attachmentSelectors.join(','))).some((node) => match(node, name)),
210
+ );
211
+ const inputsReady = names.every((name) =>
212
+ Array.from(composer.querySelectorAll('input[type="file"]')).some((el) =>
213
+ Array.from((el instanceof HTMLInputElement ? el.files : []) || []).some((file) =>
214
+ file?.name?.toLowerCase?.().includes(name),
215
+ ),
216
+ ),
217
+ );
218
+
219
+ return chipsReady || inputsReady;
220
+ })()`;
221
+ }
222
+ export function buildAttachmentReadyExpressionForTest(attachmentNames) {
223
+ return buildAttachmentReadyExpression(attachmentNames);
224
+ }
189
225
  async function attemptSendButton(Runtime, _logger, attachmentNames) {
190
226
  const script = `(() => {
191
227
  ${buildClickDispatcher()}
@@ -215,19 +251,7 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
215
251
  const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
216
252
  if (needAttachment) {
217
253
  const ready = await Runtime.evaluate({
218
- expression: `(() => {
219
- const names = ${JSON.stringify(attachmentNames.map((n) => n.toLowerCase()))};
220
- const match = (n, name) => (n?.textContent || '').toLowerCase().includes(name);
221
- const chipsReady = names.every((name) =>
222
- Array.from(document.querySelectorAll('[data-testid*="chip"],[data-testid*="attachment"],a,div,span')).some((node) => match(node, name)),
223
- );
224
- const inputsReady = names.every((name) =>
225
- Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
226
- Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(name)),
227
- ),
228
- );
229
- return chipsReady || inputsReady;
230
- })()`,
254
+ expression: buildAttachmentReadyExpression(attachmentNames),
231
255
  returnByValue: true,
232
256
  });
233
257
  if (!ready?.result?.value) {
@@ -246,37 +270,6 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
246
270
  }
247
271
  return false;
248
272
  }
249
- async function clickAnswerNowIfPresent(Runtime, logger) {
250
- const script = `(() => {
251
- ${buildClickDispatcher()}
252
- const matchesText = (el) => (el?.textContent || '').trim().toLowerCase() === 'answer now';
253
- const candidate = Array.from(document.querySelectorAll('button,span')).find(matchesText);
254
- if (!candidate) return 'missing';
255
- const button = candidate.closest('button') ?? candidate;
256
- const style = window.getComputedStyle(button);
257
- const disabled =
258
- button.hasAttribute('disabled') ||
259
- button.getAttribute('aria-disabled') === 'true' ||
260
- style.pointerEvents === 'none' ||
261
- style.display === 'none';
262
- if (disabled) return 'disabled';
263
- dispatchClickSequence(button);
264
- return 'clicked';
265
- })()`;
266
- const deadline = Date.now() + 3_000;
267
- while (Date.now() < deadline) {
268
- const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
269
- const status = result.value;
270
- if (status === 'clicked') {
271
- logger?.('Clicked "Answer now" gate');
272
- await delay(500);
273
- return;
274
- }
275
- if (status === 'missing')
276
- return;
277
- await delay(100);
278
- }
279
- }
280
273
  async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
281
274
  const deadline = Date.now() + timeoutMs;
282
275
  const encodedPrompt = JSON.stringify(prompt.trim());
@@ -1,5 +1,5 @@
1
1
  import { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
2
- import { normalizeChatgptUrl } from './utils.js';
2
+ import { isTemporaryChatUrl, normalizeChatgptUrl } from './utils.js';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  export const DEFAULT_BROWSER_CONFIG = {
@@ -32,6 +32,11 @@ export function resolveBrowserConfig(config) {
32
32
  (process.env.ORACLE_BROWSER_ALLOW_COOKIE_ERRORS ?? '').trim() === '1';
33
33
  const rawUrl = config?.chatgptUrl ?? config?.url ?? DEFAULT_BROWSER_CONFIG.url;
34
34
  const normalizedUrl = normalizeChatgptUrl(rawUrl ?? DEFAULT_BROWSER_CONFIG.url, DEFAULT_BROWSER_CONFIG.url);
35
+ const desiredModel = config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel ?? DEFAULT_MODEL_TARGET;
36
+ if (isTemporaryChatUrl(normalizedUrl) && /\bpro\b/i.test(desiredModel)) {
37
+ throw new Error('Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
38
+ 'Remove "temporary-chat=true" from your browser URL, or use a non-Pro model label (e.g. "GPT-5.2").');
39
+ }
35
40
  const isWindows = process.platform === 'win32';
36
41
  const manualLogin = config?.manualLogin ?? (isWindows ? true : DEFAULT_BROWSER_CONFIG.manualLogin);
37
42
  const cookieSyncDefault = isWindows ? false : DEFAULT_BROWSER_CONFIG.cookieSync;
@@ -53,7 +58,7 @@ export function resolveBrowserConfig(config) {
53
58
  headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
54
59
  keepBrowser: config?.keepBrowser ?? DEFAULT_BROWSER_CONFIG.keepBrowser,
55
60
  hideWindow: config?.hideWindow ?? DEFAULT_BROWSER_CONFIG.hideWindow,
56
- desiredModel: config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel,
61
+ desiredModel,
57
62
  chromeProfile: config?.chromeProfile ?? DEFAULT_BROWSER_CONFIG.chromeProfile,
58
63
  chromePath: config?.chromePath ?? DEFAULT_BROWSER_CONFIG.chromePath,
59
64
  chromeCookiePath: config?.chromeCookiePath ?? DEFAULT_BROWSER_CONFIG.chromeCookiePath,
@@ -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, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
8
+ import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
9
9
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
10
10
  import { ensureExtendedThinking } from './actions/thinkingTime.js';
11
11
  import { estimateTokenCount, withRetries, delay } from './utils.js';
@@ -14,7 +14,7 @@ import { CHATGPT_URL } from './constants.js';
14
14
  import { BrowserAutomationError } from '../oracle/errors.js';
15
15
  import { cleanupStaleProfileState, readChromePid, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
16
16
  export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
17
- export { parseDuration, delay, normalizeChatgptUrl } from './utils.js';
17
+ export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
18
18
  export async function runBrowserMode(options) {
19
19
  const promptText = options.prompt?.trim();
20
20
  if (!promptText) {
@@ -293,6 +293,10 @@ export async function runBrowserMode(options) {
293
293
  logger('All attachments uploaded');
294
294
  }
295
295
  await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, prompt, logger);
296
+ if (attachmentNames.length > 0) {
297
+ await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
298
+ logger('Verified attachments present on sent user message');
299
+ }
296
300
  };
297
301
  try {
298
302
  await raceWithDisconnect(submitOnce(promptText, attachments));
@@ -1,5 +1,5 @@
1
1
  export { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady } from './actions/navigation.js';
2
2
  export { ensureModelSelection } from './actions/modelSelection.js';
3
3
  export { submitPrompt, clearPromptComposer } from './actions/promptComposer.js';
4
- export { uploadAttachmentFile, waitForAttachmentCompletion } from './actions/attachments.js';
4
+ export { uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments } from './actions/attachments.js';
5
5
  export { waitForAssistantResponse, readAssistantSnapshot, captureAssistantMarkdown, buildAssistantExtractorForTest, buildConversationDebugExpressionForTest, } from './actions/assistantResponse.js';
@@ -110,3 +110,13 @@ export function normalizeChatgptUrl(raw, fallback) {
110
110
  // Preserve user-provided path/query; URL#toString will normalize trailing slashes appropriately.
111
111
  return parsed.toString();
112
112
  }
113
+ export function isTemporaryChatUrl(url) {
114
+ try {
115
+ const parsed = new URL(url);
116
+ const value = (parsed.searchParams.get('temporary-chat') ?? '').trim().toLowerCase();
117
+ return value === 'true' || value === '1' || value === 'yes';
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
@@ -1 +1 @@
1
- export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, } from './browser/index.js';
1
+ export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, isTemporaryChatUrl, } from './browser/index.js';
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { CHATGPT_URL, DEFAULT_MODEL_TARGET, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
3
+ import { CHATGPT_URL, DEFAULT_MODEL_TARGET, isTemporaryChatUrl, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
4
4
  import { getOracleHomeDir } from '../oracleHome.js';
5
5
  const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
6
6
  const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 30_000;
@@ -54,6 +54,15 @@ export async function buildBrowserConfig(options) {
54
54
  }
55
55
  const rawUrl = options.chatgptUrl ?? options.browserUrl;
56
56
  const url = rawUrl ? normalizeChatgptUrl(rawUrl, CHATGPT_URL) : undefined;
57
+ const desiredModel = isChatGptModel
58
+ ? mapModelToBrowserLabel(options.model)
59
+ : shouldUseOverride
60
+ ? desiredModelOverride
61
+ : mapModelToBrowserLabel(options.model);
62
+ if (url && isTemporaryChatUrl(url) && /\bpro\b/i.test(desiredModel ?? '')) {
63
+ throw new Error('Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
64
+ 'Remove "temporary-chat=true" from --chatgpt-url (or omit --chatgpt-url), or use a non-Pro model (e.g. --model gpt-5.2).');
65
+ }
57
66
  return {
58
67
  chromeProfile: options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE,
59
68
  chromePath: options.browserChromePath ?? null,
@@ -72,11 +81,7 @@ export async function buildBrowserConfig(options) {
72
81
  keepBrowser: options.browserKeepBrowser ? true : undefined,
73
82
  manualLogin: options.browserManualLogin ? true : undefined,
74
83
  hideWindow: options.browserHideWindow ? true : undefined,
75
- desiredModel: isChatGptModel
76
- ? mapModelToBrowserLabel(options.model)
77
- : shouldUseOverride
78
- ? desiredModelOverride
79
- : mapModelToBrowserLabel(options.model),
84
+ desiredModel,
80
85
  debug: options.verbose ? true : undefined,
81
86
  // Allow cookie failures by default so runs can continue without Chrome/Keychain secrets.
82
87
  allowCookieErrors: options.browserAllowCookieErrors ?? true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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",