@steipete/oracle 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +20 -9
  2. package/dist/bin/oracle-cli.js +63 -63
  3. package/dist/docs-site/.nojekyll +0 -0
  4. package/dist/docs-site/CNAME +1 -0
  5. package/dist/docs-site/RELEASING.html +410 -0
  6. package/dist/docs-site/agents.html +374 -0
  7. package/dist/docs-site/anthropic.html +368 -0
  8. package/dist/docs-site/bridge.html +400 -0
  9. package/dist/docs-site/browser-mode.html +593 -0
  10. package/dist/docs-site/chromium-forks.html +347 -0
  11. package/dist/docs-site/cli-reference.html +346 -0
  12. package/dist/docs-site/configuration.html +452 -0
  13. package/dist/docs-site/favicon.svg +14 -0
  14. package/dist/docs-site/followup.html +375 -0
  15. package/dist/docs-site/gemini.html +383 -0
  16. package/dist/docs-site/grok.html +325 -0
  17. package/dist/docs-site/index.html +360 -0
  18. package/dist/docs-site/install.html +335 -0
  19. package/dist/docs-site/linux.html +321 -0
  20. package/dist/docs-site/llms.txt +43 -0
  21. package/dist/docs-site/manual-tests.html +596 -0
  22. package/dist/docs-site/mcp.html +391 -0
  23. package/dist/docs-site/multimodel.html +364 -0
  24. package/dist/docs-site/mythical-pro-agents.html +360 -0
  25. package/dist/docs-site/notifier.html +338 -0
  26. package/dist/docs-site/openai-endpoints.html +387 -0
  27. package/dist/docs-site/openrouter.html +344 -0
  28. package/dist/docs-site/quickstart.html +369 -0
  29. package/dist/docs-site/refactor/ux.html +532 -0
  30. package/dist/docs-site/sessions.html +388 -0
  31. package/dist/docs-site/social-card.png +0 -0
  32. package/dist/docs-site/social-card.svg +79 -0
  33. package/dist/docs-site/spec.html +363 -0
  34. package/dist/docs-site/testing.html +320 -0
  35. package/dist/docs-site/tui-debug.html +326 -0
  36. package/dist/docs-site/windows-work.html +323 -0
  37. package/dist/docs-site/windows.html +320 -0
  38. package/dist/src/browser/actions/assistantResponse.js +91 -9
  39. package/dist/src/browser/actions/deepResearch.js +180 -52
  40. package/dist/src/browser/actions/modelSelection.js +13 -11
  41. package/dist/src/browser/actions/navigation.js +294 -65
  42. package/dist/src/browser/actions/promptComposer.js +50 -4
  43. package/dist/src/browser/actions/thinkingTime.js +308 -89
  44. package/dist/src/browser/artifacts.js +19 -0
  45. package/dist/src/browser/chatgptFiles.js +797 -0
  46. package/dist/src/browser/chatgptImages.js +130 -3
  47. package/dist/src/browser/chromeLifecycle.js +4 -0
  48. package/dist/src/browser/config.js +2 -0
  49. package/dist/src/browser/index.js +347 -39
  50. package/dist/src/browser/pageActions.js +1 -1
  51. package/dist/src/browser/policies.js +2 -6
  52. package/dist/src/browser/projectSourcesRunner.js +14 -2
  53. package/dist/src/browser/prompt.js +11 -15
  54. package/dist/src/browser/reattach.js +6 -2
  55. package/dist/src/browser/reattachability.js +22 -10
  56. package/dist/src/browser/recoverConversation.js +73 -0
  57. package/dist/src/browser/sessionRunner.js +4 -1
  58. package/dist/src/cli/browserConfig.js +4 -1
  59. package/dist/src/cli/browserTabs.js +129 -54
  60. package/dist/src/cli/followup.js +72 -0
  61. package/dist/src/cli/help.js +1 -1
  62. package/dist/src/cli/options.js +16 -0
  63. package/dist/src/cli/reattachGuidance.js +8 -0
  64. package/dist/src/cli/runOptions.js +2 -12
  65. package/dist/src/cli/sessionCommand.js +4 -0
  66. package/dist/src/cli/sessionDisplay.js +17 -3
  67. package/dist/src/cli/sessionLineage.js +7 -4
  68. package/dist/src/cli/sessionRunner.js +16 -1
  69. package/dist/src/gemini-web/client.js +5 -10
  70. package/dist/src/gemini-web/executor.js +1 -24
  71. package/dist/src/gemini-web/models.js +83 -0
  72. package/dist/src/mcp/server.js +2 -0
  73. package/dist/src/mcp/tools/chatgptImage.js +127 -0
  74. package/dist/src/mcp/tools/consult.js +261 -167
  75. package/dist/src/mcp/types.js +2 -0
  76. package/dist/src/mcp/utils.js +87 -1
  77. package/dist/src/oracle/config.js +27 -1
  78. package/dist/src/oracle/files.js +4 -6
  79. package/dist/src/oracle/geminiModels.js +2 -0
  80. package/dist/src/oracle/markdown.js +36 -3
  81. package/dist/src/oracle/modelResolver.js +21 -13
  82. package/dist/src/oracle/promptAssembly.js +2 -4
  83. package/dist/src/oracle/request.js +3 -5
  84. package/dist/src/oracle/tokenStats.js +5 -1
  85. package/dist/src/oracle.js +1 -1
  86. package/dist/src/sessionManager.js +1 -0
  87. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  88. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  89. package/package.json +19 -28
  90. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  91. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -1,4 +1,4 @@
1
- import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, INPUT_SELECTORS } from "../constants.js";
1
+ import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, } from "../constants.js";
2
2
  import { delay } from "../utils.js";
3
3
  import { logDomFailure } from "../domDebug.js";
4
4
  import { BrowserAutomationError } from "../../oracle/errors.js";
@@ -139,9 +139,14 @@ export async function ensureNotBlocked(Runtime, headless, logger) {
139
139
  }
140
140
  }
141
141
  const LOGIN_CHECK_TIMEOUT_MS = 5_000;
142
+ const CHATGPT_ACCOUNT_EMAIL_ENV = "ORACLE_CHATGPT_ACCOUNT_EMAIL";
143
+ function preferredChatGptAccountEmail() {
144
+ const email = process.env[CHATGPT_ACCOUNT_EMAIL_ENV]?.trim().toLowerCase();
145
+ return email ? email : null;
146
+ }
142
147
  export async function ensureLoggedIn(Runtime, logger, options = {}) {
143
148
  // Learned: ChatGPT can render the UI (project view) while auth silently failed.
144
- // A backend-api probe plus DOM login CTA check catches both cases.
149
+ // Session state plus DOM login signals catch both valid and stale app shells.
145
150
  const outcome = await Runtime.evaluate({
146
151
  expression: buildLoginProbeExpression(LOGIN_CHECK_TIMEOUT_MS),
147
152
  awaitPromise: true,
@@ -149,11 +154,11 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
149
154
  });
150
155
  const probe = normalizeLoginProbe(outcome.result?.value);
151
156
  if (probe.ok) {
152
- logger(`Login check passed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)}, appAuthenticated=${Boolean(probe.appAuthenticated)})`);
157
+ logger(`Login check passed (sessionStatus=${probe.status}, sessionAuthenticated=${Boolean(probe.sessionAuthenticated)}, backendStatus=${probe.backendStatus ?? "n/a"}, domLoginCta=${Boolean(probe.domLoginCta)}, appAuthenticated=${Boolean(probe.appAuthenticated)})`);
153
158
  return;
154
159
  }
155
- const accepted = await attemptWelcomeBackLogin(Runtime, logger);
156
- if (accepted) {
160
+ const welcomeBack = await attemptWelcomeBackLogin(Runtime, logger, preferredChatGptAccountEmail());
161
+ if (welcomeBack.accepted) {
157
162
  // Learned: "Welcome back" account picker needs a click even when cookies are valid,
158
163
  // and the redirect can lag, so re-probe before failing hard.
159
164
  await delay(1500);
@@ -167,46 +172,25 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
167
172
  logger("Login restored via Welcome back account picker");
168
173
  return;
169
174
  }
170
- logger(`Login retry after Welcome back failed (status=${retryProbe.status}, domLoginCta=${Boolean(retryProbe.domLoginCta)}, appAuthenticated=${Boolean(retryProbe.appAuthenticated)})`);
175
+ logger(`Login retry after Welcome back failed (sessionStatus=${retryProbe.status}, sessionAuthenticated=${Boolean(retryProbe.sessionAuthenticated)}, backendStatus=${retryProbe.backendStatus ?? "n/a"}, domLoginCta=${Boolean(retryProbe.domLoginCta)}, appAuthenticated=${Boolean(retryProbe.appAuthenticated)})`);
171
176
  }
172
- logger(`Login probe failed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)}, onAuthPage=${Boolean(probe.onAuthPage)}, appAuthenticated=${Boolean(probe.appAuthenticated)}, cfBlocked=${Boolean(probe.cfBlocked)}, url=${probe.pageUrl ?? "n/a"}, error=${probe.error ?? "none"})`);
177
+ logger(`Login probe failed (sessionStatus=${probe.status}, sessionAuthenticated=${Boolean(probe.sessionAuthenticated)}, sessionResolved=${Boolean(probe.sessionResolved)}, backendStatus=${probe.backendStatus ?? "n/a"}, domLoginCta=${Boolean(probe.domLoginCta)}, onAuthPage=${Boolean(probe.onAuthPage)}, appAuthenticated=${Boolean(probe.appAuthenticated)}, cfBlocked=${Boolean(probe.cfBlocked)}, url=${probe.pageUrl ?? "n/a"}, error=${probe.error ?? "none"})`);
173
178
  const domLabel = probe.domLoginCta ? " Login button detected on page." : "";
174
179
  const cookieHint = options.remoteSession
175
180
  ? "The remote Chrome session is not signed into ChatGPT. Sign in there, then rerun."
176
181
  : (options.appliedCookies ?? 0) === 0
177
182
  ? "No ChatGPT cookies were applied; sign in to chatgpt.com in Chrome or pass inline cookies (--browser-inline-cookies[(-file)] / ORACLE_BROWSER_COOKIES_JSON)."
178
183
  : "ChatGPT login appears missing; open chatgpt.com in Chrome to refresh the session or provide inline cookies (--browser-inline-cookies[(-file)] / ORACLE_BROWSER_COOKIES_JSON).";
179
- throw new Error(`ChatGPT session not detected.${domLabel} ${cookieHint}`);
184
+ const accountHint = welcomeBack.hint ? ` ${welcomeBack.hint}` : "";
185
+ throw new Error(`ChatGPT session not detected.${domLabel}${accountHint} ${cookieHint}`);
180
186
  }
181
- async function attemptWelcomeBackLogin(Runtime, logger) {
187
+ async function attemptWelcomeBackLogin(Runtime, logger, preferredEmail = null) {
182
188
  const deadline = Date.now() + 30_000;
183
189
  while (Date.now() < deadline) {
184
190
  let outcome;
185
191
  try {
186
192
  outcome = await Runtime.evaluate({
187
- expression: `(() => {
188
- // Learned: "Welcome back" shows as a modal with account chips; click the email chip.
189
- const getLabel = (node) =>
190
- (node?.textContent || node?.getAttribute?.('aria-label') || '').trim();
191
- const isAccount = (label) =>
192
- Boolean(label) &&
193
- label.includes('@') &&
194
- !/log in|sign up|create account|another account/i.test(label);
195
- const candidates = Array.from(document.querySelectorAll('[role="button"],button,a'));
196
- const account = candidates.find((node) => isAccount(getLabel(node))) || null;
197
- if (!account) {
198
- return { clicked: false, reason: 'not-found' };
199
- }
200
- const label = getLabel(account);
201
- setTimeout(() => {
202
- try {
203
- account.click();
204
- } catch {
205
- // ignore; caller will re-probe login state
206
- }
207
- }, 0);
208
- return { clicked: true, label };
209
- })()`,
193
+ expression: buildWelcomeBackAccountPickerExpression(preferredEmail),
210
194
  awaitPromise: false,
211
195
  returnByValue: true,
212
196
  });
@@ -215,10 +199,10 @@ async function attemptWelcomeBackLogin(Runtime, logger) {
215
199
  const message = error instanceof Error ? error.message : String(error);
216
200
  if (/navigated or closed|context was destroyed|target closed/i.test(message)) {
217
201
  logger("Welcome back account click triggered navigation.");
218
- return true;
202
+ return { accepted: true };
219
203
  }
220
204
  logger(`Welcome back auto-select probe failed: ${message}`);
221
- return false;
205
+ return { accepted: false };
222
206
  }
223
207
  if (outcome.exceptionDetails) {
224
208
  const details = outcome.exceptionDetails;
@@ -228,29 +212,93 @@ async function attemptWelcomeBackLogin(Runtime, logger) {
228
212
  details.text ||
229
213
  "unknown error";
230
214
  logger(`Welcome back auto-select probe failed: ${description}`);
231
- return false;
215
+ return { accepted: false };
232
216
  }
233
217
  const result = outcome.result?.value;
234
218
  if (!result) {
235
219
  logger("Welcome back auto-select probe returned no result.");
236
- return false;
220
+ return { accepted: false };
237
221
  }
238
222
  if (!("clicked" in result) && !("reason" in result)) {
239
223
  logger("Welcome back auto-select probe returned an unexpected result.");
240
- return false;
224
+ return { accepted: false };
241
225
  }
242
226
  if (result.clicked) {
243
- logger(`Welcome back modal detected; selected account ${result.label ?? "(unknown)"}`);
244
- return true;
227
+ logger(result.selection === "preferred"
228
+ ? "Welcome back modal detected; selected configured account."
229
+ : "Welcome back modal detected; selected only saved account.");
230
+ return { accepted: true };
231
+ }
232
+ if (result.reason === "preferred-not-found") {
233
+ logger(`Welcome back modal present but ${CHATGPT_ACCOUNT_EMAIL_ENV} did not match any saved account (${result.accountCount ?? 0} account chips found).`);
234
+ return {
235
+ accepted: false,
236
+ hint: `${CHATGPT_ACCOUNT_EMAIL_ENV} did not match a saved account. Set it to the exact account email on the browser host or sign in manually.`,
237
+ };
238
+ }
239
+ if (result.reason === "multiple-accounts") {
240
+ logger(`Welcome back modal present with multiple saved accounts; refusing to select one without ${CHATGPT_ACCOUNT_EMAIL_ENV}.`);
241
+ return {
242
+ accepted: false,
243
+ hint: `Multiple saved ChatGPT accounts were found. Set ${CHATGPT_ACCOUNT_EMAIL_ENV} to the exact account email on the browser host.`,
244
+ };
245
245
  }
246
246
  if (result.reason && result.reason !== "not-found") {
247
247
  logger(`Welcome back modal present but auto-select failed (${result.reason}).`);
248
- return false;
248
+ return { accepted: false };
249
249
  }
250
250
  await delay(500);
251
251
  }
252
252
  logger("Welcome back modal not detected after login probe failure.");
253
- return false;
253
+ return { accepted: false };
254
+ }
255
+ function buildWelcomeBackAccountPickerExpression(preferredEmail = null) {
256
+ const normalizedPreferredEmail = preferredEmail?.trim().toLowerCase() || null;
257
+ return `(() => {
258
+ // Learned: "Welcome back" can list several saved accounts; substring matching can select the wrong identity.
259
+ const preferredEmail = ${JSON.stringify(normalizedPreferredEmail)};
260
+ const getLabel = (node) =>
261
+ [node?.textContent, node?.getAttribute?.('aria-label')]
262
+ .filter((value) => typeof value === 'string' && value.trim())
263
+ .join(' ')
264
+ .trim();
265
+ const extractEmails = (label) =>
266
+ String(label || '')
267
+ .toLowerCase()
268
+ .match(/[a-z0-9.!#$%&'*+/=?^_{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*/g) || [];
269
+ const isAccount = (label) => !/log in|sign up|create account|another account/i.test(label);
270
+ const candidates = Array.from(document.querySelectorAll('[role="button"],button,a'));
271
+ const accounts = candidates
272
+ .map((node) => {
273
+ const label = getLabel(node);
274
+ return { node, emails: isAccount(label) ? extractEmails(label) : [] };
275
+ })
276
+ .filter((entry) => entry.emails.length > 0);
277
+ if (!accounts.length) {
278
+ return { clicked: false, reason: 'not-found' };
279
+ }
280
+ const savedEmails = Array.from(new Set(accounts.flatMap((entry) => entry.emails)));
281
+ if (!preferredEmail && savedEmails.length !== 1) {
282
+ return { clicked: false, reason: 'multiple-accounts', accountCount: savedEmails.length };
283
+ }
284
+ const selectedEmail = preferredEmail || savedEmails[0];
285
+ const account = accounts.find((entry) => entry.emails.includes(selectedEmail));
286
+ if (!account) {
287
+ return { clicked: false, reason: 'preferred-not-found', accountCount: savedEmails.length };
288
+ }
289
+ setTimeout(() => {
290
+ try {
291
+ account.node.click();
292
+ } catch {
293
+ // ignore; caller will re-probe login state
294
+ }
295
+ }, 0);
296
+ return {
297
+ clicked: true,
298
+ selection: preferredEmail ? 'preferred' : 'only-account',
299
+ accountCount: savedEmails.length,
300
+ };
301
+ })()`;
254
302
  }
255
303
  export async function ensurePromptReady(Runtime, timeoutMs, logger) {
256
304
  const ready = await waitForPrompt(Runtime, timeoutMs);
@@ -269,6 +317,89 @@ export async function ensurePromptReady(Runtime, timeoutMs, logger) {
269
317
  throw new Error("Prompt textarea did not appear before timeout");
270
318
  }
271
319
  }
320
+ function conversationIdFromUrl(value) {
321
+ if (!value)
322
+ return null;
323
+ try {
324
+ return new URL(value).pathname.match(/(?:^|\/)c\/([^/]+)/)?.[1] ?? null;
325
+ }
326
+ catch {
327
+ return null;
328
+ }
329
+ }
330
+ /**
331
+ * After navigating to a *resumed* ChatGPT conversation, its prior turns hydrate
332
+ * asynchronously and ChatGPT can reset the composer mid-hydration — wiping a
333
+ * freshly-typed prompt. A fresh chat has no history, so it never hits this race;
334
+ * a large resumed thread reliably does.
335
+ *
336
+ * Wait for the prior turns to render AND stop growing (a big thread keeps
337
+ * appending turns as it hydrates), let React settle, then re-confirm the
338
+ * composer is ready — before the caller types/submits. Shared by the local and
339
+ * remote browser execution paths so neither loses the submitted prompt.
340
+ *
341
+ * Returns the number of prior turns observed once hydration settled.
342
+ */
343
+ export async function waitForResumedConversationHydration(Runtime, timeoutMs, logger, deps = {}) {
344
+ const ensureReady = deps.ensurePromptReady ?? ensurePromptReady;
345
+ const hydrationDeadline = Date.now() + Math.min(timeoutMs || 30_000, 30_000);
346
+ let priorTurns = 0;
347
+ let stableChecks = 0;
348
+ let settled = false;
349
+ while (Date.now() < hydrationDeadline) {
350
+ let turns = 0;
351
+ try {
352
+ const { result } = await Runtime.evaluate({
353
+ expression: `document.querySelectorAll(${JSON.stringify(CONVERSATION_TURN_SELECTOR)}).length`,
354
+ returnByValue: true,
355
+ });
356
+ turns = typeof result?.value === "number" ? result.value : 0;
357
+ }
358
+ catch {
359
+ // keep polling until the conversation hydrates
360
+ }
361
+ if (turns > 0 && turns === priorTurns) {
362
+ stableChecks += 1;
363
+ if (stableChecks >= 3) {
364
+ settled = true;
365
+ break;
366
+ }
367
+ }
368
+ else {
369
+ stableChecks = 0;
370
+ }
371
+ priorTurns = turns;
372
+ await delay(250);
373
+ }
374
+ await delay(1_000); // final settle so React won't wipe the composer after we type
375
+ await ensureReady(Runtime, timeoutMs, logger);
376
+ if ((deps.requirePriorTurns ?? false) && (!settled || priorTurns <= 0)) {
377
+ throw new BrowserAutomationError("Saved ChatGPT conversation did not load stable prior turns; refusing to submit follow-up as a fresh chat.", {
378
+ stage: "resume-conversation",
379
+ priorTurns,
380
+ settled,
381
+ });
382
+ }
383
+ if (deps.expectedConversationUrl) {
384
+ const { result } = await Runtime.evaluate({
385
+ expression: "location.href",
386
+ returnByValue: true,
387
+ });
388
+ const actualUrl = typeof result?.value === "string" ? result.value : undefined;
389
+ const expectedConversationId = conversationIdFromUrl(deps.expectedConversationUrl);
390
+ const actualConversationId = conversationIdFromUrl(actualUrl);
391
+ if (!expectedConversationId || actualConversationId !== expectedConversationId) {
392
+ throw new BrowserAutomationError("Saved ChatGPT conversation redirected to a different thread; refusing to submit follow-up.", {
393
+ stage: "resume-conversation",
394
+ expectedConversationId,
395
+ actualConversationId,
396
+ actualUrl,
397
+ });
398
+ }
399
+ }
400
+ logger(`[browser] Resumed conversation hydrated (${priorTurns} prior turns); composer settled.`);
401
+ return priorTurns;
402
+ }
272
403
  async function waitForDocumentReady(Runtime, timeoutMs) {
273
404
  const start = Date.now();
274
405
  while (Date.now() - start < timeoutMs) {
@@ -360,8 +491,8 @@ async function isChatGptAccountSecurityBlock(Runtime) {
360
491
  }
361
492
  function buildLoginProbeExpression(timeoutMs) {
362
493
  return `(async () => {
363
- // Learned: /backend-api/me is the most reliable "am I logged in" signal.
364
- // Some UIs render without a session; use DOM + network for a robust answer.
494
+ // /api/auth/session remains cookie-authenticated and exposes user presence without
495
+ // requiring the bearer token used by /backend-api/*. Never return or log its token.
365
496
  const pageUrl = typeof location === 'object' && location?.href ? location.href : null;
366
497
  const onAuthPage =
367
498
  typeof location === 'object' &&
@@ -432,6 +563,68 @@ function buildLoginProbeExpression(timeoutMs) {
432
563
  (head.includes('<style global>') && head.includes('scale-appear'))
433
564
  );
434
565
  };
566
+ const readSessionDetail = async () => {
567
+ try {
568
+ if (typeof fetch !== 'function') {
569
+ return { status: 0, resolved: false, authenticated: false, cfBlocked: false, error: null };
570
+ }
571
+ const controller = new AbortController();
572
+ const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
573
+ try {
574
+ const response = await fetch('/api/auth/session', {
575
+ cache: 'no-store',
576
+ credentials: 'include',
577
+ signal: controller.signal,
578
+ });
579
+ let cfBlocked = false;
580
+ if (response.status === 403 || response.status === 503 || response.status === 429) {
581
+ try {
582
+ const text = await response.clone().text();
583
+ cfBlocked = isCloudflareBody(text);
584
+ } catch {}
585
+ }
586
+ if (response.status !== 200) {
587
+ return {
588
+ status: response.status || 0,
589
+ resolved: false,
590
+ authenticated: false,
591
+ cfBlocked,
592
+ error: null,
593
+ };
594
+ }
595
+ try {
596
+ const body = await response.json();
597
+ const resolved =
598
+ Boolean(body) && typeof body === 'object' && !Array.isArray(body);
599
+ return {
600
+ status: response.status || 0,
601
+ resolved,
602
+ authenticated: resolved && Boolean(body.user),
603
+ cfBlocked,
604
+ error: null,
605
+ };
606
+ } catch {
607
+ return {
608
+ status: response.status || 0,
609
+ resolved: false,
610
+ authenticated: false,
611
+ cfBlocked,
612
+ error: null,
613
+ };
614
+ }
615
+ } finally {
616
+ clearTimeout(timeout);
617
+ }
618
+ } catch (err) {
619
+ return {
620
+ status: 0,
621
+ resolved: false,
622
+ authenticated: false,
623
+ cfBlocked: false,
624
+ error: err ? String(err) : 'unknown',
625
+ };
626
+ }
627
+ };
435
628
  const readBackendDetail = async () => {
436
629
  try {
437
630
  if (typeof fetch !== 'function') return { status: 0, cfBlocked: false, error: null };
@@ -458,6 +651,15 @@ function buildLoginProbeExpression(timeoutMs) {
458
651
  return { status: 0, cfBlocked: false, error: err ? String(err) : 'unknown' };
459
652
  }
460
653
  };
654
+ const readAuthDetail = async () => {
655
+ const session = await readSessionDetail();
656
+ const sessionDenied =
657
+ !session.cfBlocked && (session.status === 401 || session.status === 403);
658
+ if (session.resolved || sessionDenied) {
659
+ return { session, backend: null };
660
+ }
661
+ return { session, backend: await readBackendDetail() };
662
+ };
461
663
 
462
664
  const hasAppAuthSignal = () => {
463
665
  // Composer must be present and visible — the auth/login page never renders one.
@@ -483,39 +685,58 @@ function buildLoginProbeExpression(timeoutMs) {
483
685
  return Boolean(profileButton || historyItem);
484
686
  };
485
687
 
486
- let backend = await readBackendDetail();
487
- let status = backend.status;
488
- let cfBlocked = backend.cfBlocked;
489
- let error = backend.error;
688
+ const classifyAuth = (auth, appSignal) => {
689
+ const sessionResolved = auth.session.status === 200 && auth.session.resolved;
690
+ const sessionDenied =
691
+ !auth.session.cfBlocked &&
692
+ (auth.session.status === 401 || auth.session.status === 403);
693
+ const sessionUnavailable = !sessionResolved && !sessionDenied;
694
+ const backendStatus = auth.backend ? auth.backend.status : null;
695
+ const backendUnavailable =
696
+ Boolean(auth.backend) &&
697
+ (auth.backend.cfBlocked ||
698
+ backendStatus === 0 ||
699
+ backendStatus === 401 ||
700
+ backendStatus === 403 ||
701
+ backendStatus === 429 ||
702
+ backendStatus === 503);
703
+ return {
704
+ authenticated:
705
+ auth.session.authenticated ||
706
+ (sessionUnavailable &&
707
+ (backendStatus === 200 || (backendUnavailable && appSignal))),
708
+ sessionResolved,
709
+ sessionUnavailable,
710
+ };
711
+ };
712
+
713
+ let auth = await readAuthDetail();
490
714
  let domLoginCta = hasLoginCta();
491
715
  let appAuthenticated = hasAppAuthSignal();
492
- const isRetryableStatus = () =>
493
- status === 0 || status === 401 || status === 403 || status === 503 || status === 429;
716
+ let classification = classifyAuth(auth, appAuthenticated);
494
717
  const settleDeadline = Date.now() + Math.min(${timeoutMs}, 2500);
495
- while (!domLoginCta && status !== 200 && isRetryableStatus() && Date.now() < settleDeadline) {
718
+ while (
719
+ !domLoginCta &&
720
+ !classification.authenticated &&
721
+ classification.sessionUnavailable &&
722
+ Date.now() < settleDeadline
723
+ ) {
496
724
  await new Promise((resolve) => setTimeout(resolve, 100));
497
725
  domLoginCta = hasLoginCta();
498
726
  appAuthenticated = hasAppAuthSignal();
499
- backend = await readBackendDetail();
500
- status = backend.status;
501
- cfBlocked = backend.cfBlocked;
502
- error = backend.error;
727
+ auth = await readAuthDetail();
728
+ classification = classifyAuth(auth, appAuthenticated);
503
729
  }
504
730
 
505
731
  const loginSignals = domLoginCta || onAuthPage;
506
- // Accept the SPA-level signal only when the API path is blocked or unavailable
507
- // (CF challenge, throttling, transient 5xx, network shaping) but the DOM shows
508
- // an authenticated logged-in shell. Plain 401/403 remain authoritative because
509
- // they can mean the ChatGPT session really expired.
510
- const apiBlocked =
511
- cfBlocked ||
512
- status === 429 ||
513
- status === 503 ||
514
- status === 0;
515
- const ok = !loginSignals && (status === 200 || (apiBlocked && appAuthenticated));
732
+ const backendStatus = auth.backend ? auth.backend.status : null;
733
+ const cfBlocked = auth.session.cfBlocked || Boolean(auth.backend?.cfBlocked);
734
+ const error = auth.session.error || auth.backend?.error || null;
735
+ const ok = !loginSignals && classification.authenticated;
516
736
  return {
517
737
  ok,
518
- status,
738
+ status: auth.session.status,
739
+ backendStatus,
519
740
  redirected: false,
520
741
  url: pageUrl,
521
742
  pageUrl,
@@ -523,6 +744,8 @@ function buildLoginProbeExpression(timeoutMs) {
523
744
  onAuthPage,
524
745
  appAuthenticated,
525
746
  cfBlocked,
747
+ sessionAuthenticated: auth.session.authenticated,
748
+ sessionResolved: classification.sessionResolved,
526
749
  error,
527
750
  };
528
751
  })()`;
@@ -548,9 +771,15 @@ function normalizeLoginProbe(raw) {
548
771
  domLoginCta: Boolean(value.domLoginCta),
549
772
  onAuthPage: Boolean(value.onAuthPage),
550
773
  appAuthenticated: Boolean(value.appAuthenticated),
774
+ backendStatus: typeof value.backendStatus === "number" ? value.backendStatus : null,
551
775
  cfBlocked: Boolean(value.cfBlocked),
776
+ sessionAuthenticated: Boolean(value.sessionAuthenticated),
777
+ sessionResolved: Boolean(value.sessionResolved),
552
778
  };
553
779
  }
554
780
  export function buildLoginProbeExpressionForTest(timeoutMs = LOGIN_CHECK_TIMEOUT_MS) {
555
781
  return buildLoginProbeExpression(timeoutMs);
556
782
  }
783
+ export function buildWelcomeBackAccountPickerExpressionForTest(preferredEmail = null) {
784
+ return buildWelcomeBackAccountPickerExpression(preferredEmail);
785
+ }
@@ -283,12 +283,14 @@ async function waitForDomReady(Runtime, logger, timeoutMs = 10_000) {
283
283
  logger?.(`Page did not reach ready/composer state within ${timeoutMs}ms; continuing cautiously.`);
284
284
  }
285
285
  function buildAttachmentReadyExpression(attachmentNames) {
286
- const attachmentExpectations = attachmentNames.map((name) => {
286
+ const attachmentExpectations = attachmentNames.map((attachment) => {
287
+ const name = typeof attachment === "string" ? attachment : attachment.name;
287
288
  const normalized = name.toLowerCase().replace(/\s+/g, " ").trim();
288
289
  return {
289
290
  name: normalized,
290
291
  stem: normalized.replace(/\.[a-z0-9]{1,10}$/i, ""),
291
292
  extension: normalized.match(/(\.[a-z0-9]{1,10})$/i)?.[1] ?? "",
293
+ generatedBundle: typeof attachment === "object" && attachment.generatedBundle === true,
292
294
  };
293
295
  });
294
296
  const namesLiteral = JSON.stringify(attachmentExpectations);
@@ -304,13 +306,43 @@ function buildAttachmentReadyExpression(attachmentNames) {
304
306
  if (index === -1) return false;
305
307
  const previous = text[index - 1] || '';
306
308
  const next = text[index + name.length] || '';
307
- const previousOk = !previous || !/[a-z0-9]/.test(previous);
308
- const nextOk = !next || !/[a-z0-9]/.test(next);
309
+ const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
310
+ const nextOk = !next || !/[a-z0-9._-]/.test(next);
309
311
  if (previousOk && nextOk) return true;
310
312
  from = index + name.length;
311
313
  }
312
314
  return false;
313
315
  };
316
+ const hasStemFileBoundary = (text, stem) => {
317
+ if (!stem) return false;
318
+ let from = 0;
319
+ while (from < text.length) {
320
+ const index = text.indexOf(stem, from);
321
+ if (index === -1) return false;
322
+ const previous = text[index - 1] || '';
323
+ const next = text[index + stem.length] || '';
324
+ const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
325
+ const nextOk = !next || !/[a-z0-9._-]/.test(next);
326
+ if (previousOk && nextOk) return true;
327
+ from = index + stem.length;
328
+ }
329
+ return false;
330
+ };
331
+ const hasBareStemBoundary = (text, stem) => {
332
+ if (!stem) return false;
333
+ let from = 0;
334
+ while (from < text.length) {
335
+ const index = text.indexOf(stem, from);
336
+ if (index === -1) return false;
337
+ const previous = text[index - 1] || '';
338
+ const next = text[index + stem.length] || '';
339
+ const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
340
+ const nextOk = !next || !/[a-z0-9._(-]/.test(next);
341
+ if (previousOk && nextOk) return true;
342
+ from = index + stem.length;
343
+ }
344
+ return false;
345
+ };
314
346
  const hasExtensionBoundary = (text, extension) => {
315
347
  if (!extension) return false;
316
348
  let from = 0;
@@ -327,6 +359,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
327
359
  const text = normalize(value);
328
360
  if (!text) return false;
329
361
  if (hasNameBoundary(text, item.name)) return true;
362
+ if (item.generatedBundle && hasBareStemBoundary(text, item.stem)) return true;
330
363
  if (
331
364
  item.stem &&
332
365
  item.stem.length >= 4 &&
@@ -364,6 +397,8 @@ function buildAttachmentReadyExpression(attachmentNames) {
364
397
  };
365
398
  // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
366
399
  const attachmentSelectors = [
400
+ // Current ChatGPT file tiles expose the filename through a role-group aria label.
401
+ '[role="group"][aria-label]',
367
402
  '[data-testid*="chip"]',
368
403
  '[data-testid*="attachment"]',
369
404
  '[data-testid*="upload"]',
@@ -476,6 +511,15 @@ function buildAttachmentReadyExpression(attachmentNames) {
476
511
  const visibleExtensionLabelsMatchExpected = chipOwnLabelsWithVisibleNames.every((label) =>
477
512
  expected.some((item) => matchesExpected(label, item)),
478
513
  );
514
+ const visibleStemOnlyMismatch = chipOwnLabels.some((label) =>
515
+ expected.some(
516
+ (item) =>
517
+ !item.generatedBundle &&
518
+ item.stem &&
519
+ hasStemFileBoundary(label, item.stem) &&
520
+ !matchesExpected(label, item),
521
+ ),
522
+ );
479
523
 
480
524
  const chipsReady = (() => {
481
525
  const used = new Set();
@@ -521,7 +565,9 @@ function buildAttachmentReadyExpression(attachmentNames) {
521
565
  }
522
566
  }
523
567
  const countReady =
524
- visibleExtensionLabelsMatchExpected && removeAffordances.length >= expected.length;
568
+ !visibleStemOnlyMismatch &&
569
+ visibleExtensionLabelsMatchExpected &&
570
+ removeAffordances.length >= expected.length;
525
571
 
526
572
  return chipsReady || inputsReady || countReady;
527
573
  })()`;