@youdie006/prodex 0.39.1 → 0.39.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.
@@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { captureBrowserDiagnostics, diagnosticsEnabled, diagnosticsNote } from "./browser-diagnostics.js";
8
8
  import os from "node:os";
9
- import { chatSurfaceChoice, menuKeyboardStep, readPowerSliderSelection, sliderRestoreStep } from "./picker-interaction.js";
9
+ import { answeredDialogWarning, chatSurfaceState, effortNeedsWorkSurface, javascriptDialogResponse, menuKeyboardStep, readPowerSliderSelection, sliderRestoreStep, surfaceFromProbe } from "./picker-interaction.js";
10
10
  export class ChatGptBrowserBlockerError extends Error {
11
11
  blocker;
12
12
  constructor(blocker) {
@@ -15,19 +15,20 @@ export class ChatGptBrowserBlockerError extends Error {
15
15
  this.blocker = blocker;
16
16
  }
17
17
  }
18
- // Five steps, not four. Measured at both ends of the live slider: position 0 of
19
- // 4 reads "Instant, 1 of 5" and position 4 reads "Pro, 5 of 5", so Pro is a step
20
- // of this control rather than a model. Leaving it out meant --effort could not
21
- // reach the top of the slider at all, and the only route there - --model Pro -
22
- // answered with advice to "say --effort Pro", which then failed.
18
+ /** Where ChatGPT persists the Chat/Work choice, readable on every page. */
19
+ const CHAT_SURFACE_STORAGE_KEY = "oai/apps/tpp/chat-surface-mode";
20
+ /** ~10s: measured, the surface took seconds to re-render after switching. */
21
+ const CHAT_SURFACE_SETTLE_ATTEMPTS = 14;
23
22
  /** A full trip round the menu is far more than any real picker needs. */
24
23
  const MENU_KEYBOARD_WALK_LIMIT = 16;
25
24
  /** Enough presses to cross any slider this code accepts, twice over. */
26
25
  const POWER_SLIDER_RESTORE_ATTEMPTS = 26;
27
- // Two more rungs than before. Measured live: with the model row chosen rather
28
- // than the recommended set, the slider reads Light / Medium / High / Extra High
29
- // / Max / Ultra, and Ultra is the top of the machine. Pro is kept because older
30
- // pickers still name the top step that way.
26
+ // The vocabulary spans both of ChatGPT's surfaces, because prodex has met both.
27
+ // Chat's slider is five steps and its top is Pro - measured at either end,
28
+ // position 0 of 4 reads "Instant, 1 of 5" and position 4 reads "Pro, 5 of 5",
29
+ // so Pro is a STEP of the control and not a model. Work's is six, and its top
30
+ // two, Max and Ultra, have no counterpart on Chat. A send asking for one of
31
+ // those stays on Work; everything else is sent on Chat.
31
32
  const REASONING_EFFORTS = ["즉시", "중간", "높음", "매우 높음", "Max", "Ultra", "Pro"];
32
33
  const PRO_MODES = ["기본", "확장"];
33
34
  // Aliases map friendly CLI input onto the exact Korean menu labels the picker
@@ -708,7 +709,7 @@ export function pickerSelectionPlan(input) {
708
709
  (input.effort === undefined
709
710
  ? "applied to the effort slider instead. Say --effort Pro to be explicit"
710
711
  : `ignored in favour of --effort ${input.effort}`) +
711
- ", or clear a saved default with `prodex setup --model \"\"`.";
712
+ ", or clear a saved default with `prodex setup --clear-model`.";
712
713
  }
713
714
  return plan;
714
715
  }
@@ -1039,6 +1040,21 @@ decidedBusyBlocker, busyVerdictDecided = false) {
1039
1040
  throw new ChatGptBrowserBlockerError(busyBlocker);
1040
1041
  assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
1041
1042
  }
1043
+ /**
1044
+ * Whether a page with no composer is worth one reload before giving up.
1045
+ *
1046
+ * A thread can be left rendered with no composer at all, and every retry then
1047
+ * lands on the same dead page with the same blocker. A reload cures that - but
1048
+ * a logged-out tab has no composer either, and a modal covering the composer
1049
+ * has its own message asking the person to close it.
1050
+ */
1051
+ export function shouldReloadForMissingComposer(input) {
1052
+ if (input.hasComposer || !input.loggedInLikely || input.hasBlocker)
1053
+ return false;
1054
+ if (input.busy || input.awaitingResponseChoice)
1055
+ return false;
1056
+ return (input.openDialogText ?? "").trim().length === 0;
1057
+ }
1042
1058
  export function assertChatGptReadyForPrompt(loggedInLikely, hasComposer, openDialogText) {
1043
1059
  if (loggedInLikely && hasComposer)
1044
1060
  return;
@@ -1412,8 +1428,11 @@ export function powerLabelMatches(requested, rendered) {
1412
1428
  /** Slider position plus the Model/Effort readout next to it. */
1413
1429
  export function powerSliderStateExpression() {
1414
1430
  return `(() => {
1415
- const slider = document.querySelector('[role="slider"]');
1431
+ // Scoped to the picker, with a document-wide fallback for pickers that
1432
+ // predate the testid. Asking the whole document first meant any other
1433
+ // slider on the page became the one that got read and driven.
1416
1434
  const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
1435
+ const slider = (menu && menu.querySelector('[role="slider"]')) || (menu ? null : document.querySelector('[role="slider"]'));
1417
1436
  const lines = menu ? (menu.innerText || "").split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean) : [];
1418
1437
  if (!slider) return { ok: false, reason: "power slider not found", lines };
1419
1438
  const readPowerSliderSelection = ${readPowerSliderSelection.toString()};
@@ -1447,8 +1466,9 @@ export function activeMenuItemExpression() {
1447
1466
  return { role: a.getAttribute("role"), label };
1448
1467
  })()`;
1449
1468
  }
1450
- export function chatWorkSurfaceExpression() {
1451
- return `(() => {${CLICK_POINT_SNIPPET}
1469
+ /** Which surface is live. Reads only - no scrolling, no attributes written. */
1470
+ export function chatSurfaceProbeExpression() {
1471
+ return `(() => {
1452
1472
  const buttons = [...document.querySelectorAll('[role="radio"],[role="tab"]')].filter((el) => {
1453
1473
  const t = ((el.innerText || el.textContent || "").trim());
1454
1474
  return t === "Chat" || t === "Work";
@@ -1457,10 +1477,113 @@ export function chatWorkSurfaceExpression() {
1457
1477
  label: ((el.innerText || el.textContent || "").trim()),
1458
1478
  checked: el.getAttribute("aria-checked") === "true" || el.getAttribute("aria-selected") === "true"
1459
1479
  }));
1460
- const chat = buttons.find((el) => ((el.innerText || el.textContent || "").trim()) === "Chat");
1461
- return { surfaces, chat: chat ? clickPoint(chat) : { ok: false, reason: "no Chat toggle" } };
1480
+ // The toggle is only on the home screen, but the choice is persisted where
1481
+ // every page can read it.
1482
+ let storedMode = "";
1483
+ try {
1484
+ storedMode = localStorage.getItem(${JSON.stringify(CHAT_SURFACE_STORAGE_KEY)}) || "";
1485
+ } catch (error) {
1486
+ storedMode = "";
1487
+ }
1488
+ if (!storedMode) {
1489
+ // Anchored to the start of a cookie, so a name that merely ENDS with this
1490
+ // one cannot answer for it.
1491
+ const m = document.cookie.match(/(?:^|;)\\s*oai-chat-surface-mode=([^;]*)/);
1492
+ storedMode = m ? decodeURIComponent(m[1]) : "";
1493
+ }
1494
+ return { surfaces, storedMode };
1462
1495
  })()`;
1463
1496
  }
1497
+ /** Where to click to go back to Chat. Only asked once a switch is decided. */
1498
+ export function chatSurfaceToggleRectExpression() {
1499
+ return `(() => {${CLICK_POINT_SNIPPET}
1500
+ const chat = [...document.querySelectorAll('[role="radio"],[role="tab"]')].find(
1501
+ (el) => ((el.innerText || el.textContent || "").trim()) === "Chat"
1502
+ );
1503
+ return chat ? clickPoint(chat) : { ok: false, reason: "no Chat toggle" };
1504
+ })()`;
1505
+ }
1506
+ export function selectChatSurfaceExpression() {
1507
+ return `(() => {
1508
+ try {
1509
+ localStorage.setItem(${JSON.stringify(CHAT_SURFACE_STORAGE_KEY)}, JSON.stringify("chat"));
1510
+ } catch (error) {
1511
+ // a blocked store is not fatal: the cookie below is what the app reads
1512
+ }
1513
+ // Host-only, the way the app writes it. Setting a domain-wide copy instead
1514
+ // left two oai-chat-surface-mode cookies in play - one chat, one work - and
1515
+ // which of them won was anyone's guess.
1516
+ document.cookie = "oai-chat-surface-mode=chat; path=/; max-age=" + (60 * 60 * 24 * 365);
1517
+ document.cookie = "oai-chat-surface-mode=; path=/; domain=.chatgpt.com; max-age=0";
1518
+ return true;
1519
+ })()`;
1520
+ }
1521
+ /**
1522
+ * Name of the stamp put on a document that is about to be reloaded. A stamp
1523
+ * cannot survive a navigation, so its absence is what tells the reloaded
1524
+ * document from the one it replaced. readyState is "complete" and the
1525
+ * composer is present on the OLD document for as long as the reload takes to
1526
+ * commit, so a poll of those alone confirmed the page being left.
1527
+ */
1528
+ const RELOAD_MARK = "__prodexReloadMark";
1529
+ export function markDocumentForReloadExpression() {
1530
+ return `(() => { window[${JSON.stringify(RELOAD_MARK)}] = true; return true; })()`;
1531
+ }
1532
+ export function reloadedDocumentReadyExpression(extraCondition = "true") {
1533
+ return `(() => {
1534
+ if (window[${JSON.stringify(RELOAD_MARK)}]) return false;
1535
+ if (document.readyState !== "complete") return false;
1536
+ if (!document.querySelector('#prompt-textarea,[contenteditable="true"],textarea')) return false;
1537
+ return Boolean(${extraCondition});
1538
+ })()`;
1539
+ }
1540
+ /** Polled reloads return as soon as the new document has its composer; this only bounds a page that never gets there. */
1541
+ const RELOAD_SETTLE_TIMEOUT_MS = 12_000;
1542
+ /**
1543
+ * Reloads the page and resolves once the NEW document has rendered its
1544
+ * composer (and satisfies extraCondition, if given). Mid-navigation evaluates
1545
+ * fail rather than answer; those are retried until the deadline.
1546
+ */
1547
+ async function reloadAndAwaitComposer(cdp, timeoutMs, extraCondition) {
1548
+ await cdp.evaluate(markDocumentForReloadExpression());
1549
+ const reply = await cdp.send("Page.reload", {});
1550
+ // A reload the browser refused would otherwise be polled for until the ceiling.
1551
+ if (reply.error?.message)
1552
+ throw new Error(`Page.reload failed: ${reply.error.message}`);
1553
+ const deadline = Date.now() + timeoutMs;
1554
+ while (Date.now() < deadline) {
1555
+ await sleep(250);
1556
+ try {
1557
+ if (await cdp.evaluate(reloadedDocumentReadyExpression(extraCondition)))
1558
+ return true;
1559
+ }
1560
+ catch (error) {
1561
+ // The execution context is gone between documents; the next poll lands
1562
+ // on the new one. A command timeout is different: it closed the socket.
1563
+ if (cdpCommandTimedOut(error))
1564
+ throw error;
1565
+ }
1566
+ }
1567
+ return false;
1568
+ }
1569
+ /**
1570
+ * Reloads a page on a connection held open through the navigation, so a
1571
+ * beforeunload the page raises is answered on it. A throwaway connection that
1572
+ * closed right after asking for the reload left such a dialog to a later
1573
+ * client, which the browser will not let dismiss it. Returns the dialogs
1574
+ * answered, for the receipt.
1575
+ */
1576
+ async function reloadPageAndAwaitComposer(page) {
1577
+ const cdp = await connectCdp(page.webSocketDebuggerUrl);
1578
+ try {
1579
+ await cdp.send("Runtime.enable");
1580
+ const rendered = await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS);
1581
+ return { rendered, dialogsAnswered: [...cdp.dialogsAnswered] };
1582
+ }
1583
+ finally {
1584
+ cdp.close();
1585
+ }
1586
+ }
1464
1587
  export function powerSliderPresentExpression() {
1465
1588
  return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"]'))`;
1466
1589
  }
@@ -1485,7 +1608,11 @@ export function focusPickerMenuExpression() {
1485
1608
  }
1486
1609
  export function focusPowerSliderExpression() {
1487
1610
  return `(() => {
1488
- const slider = document.querySelector('[role="slider"]');
1611
+ // Scoped to the picker, with a document-wide fallback for pickers that
1612
+ // predate the testid. Asking the whole document first meant any other
1613
+ // slider on the page became the one that got read and driven.
1614
+ const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
1615
+ const slider = (menu && menu.querySelector('[role="slider"]')) || (menu ? null : document.querySelector('[role="slider"]'));
1489
1616
  if (!slider) return { ok: false, reason: "power slider not found" };
1490
1617
  slider.focus();
1491
1618
  return { ok: document.activeElement === slider };
@@ -1663,9 +1790,20 @@ export function modelSelectionUnavailableWarning(requested, reason, available) {
1663
1790
  if (!/menu item not found|not clickable/i.test(reason))
1664
1791
  return undefined;
1665
1792
  const offers = available?.length ? ` It offers: ${available.join(", ")}.` : "";
1793
+ // A listed row that refuses the click is a different thing from a model the
1794
+ // picker never had, and saying "does not offer X" while listing X two clauses
1795
+ // later reads as a bug in prodex. Measured on both surfaces: the model rows
1796
+ // carry pointer-events: none and no coordinate reaches them, so the slider is
1797
+ // the only lever prodex has on the model.
1798
+ if (/not clickable/i.test(reason)) {
1799
+ return (`model_not_applied: this ChatGPT picker lists "${requested}" but its model rows cannot be clicked, ` +
1800
+ `so the send used whatever the composer already had.${offers} ` +
1801
+ "The power slider is the only lever prodex has on the model - pick a step with --effort, " +
1802
+ 'or clear a saved default with `prodex setup --clear-model`.');
1803
+ }
1666
1804
  return (`model_not_applied: this ChatGPT picker does not offer "${requested}" as a selectable model, ` +
1667
1805
  `so the send used whatever the composer already had.${offers} ` +
1668
- 'Pick an effort with --effort instead, or clear a saved default with `prodex setup --model ""`.');
1806
+ 'Pick an effort with --effort instead, or clear a saved default with `prodex setup --clear-model`.');
1669
1807
  }
1670
1808
  /**
1671
1809
  * A step the slider does not have.
@@ -1806,24 +1944,73 @@ async function selectPickerModel(cdp, requested, warnings = []) {
1806
1944
  * the wrong picker. Returns a warning to carry to the caller when it moved.
1807
1945
  */
1808
1946
  async function ensureChatSurface(cdp) {
1809
- let probe;
1947
+ const read = async () => {
1948
+ try {
1949
+ return await cdp.evaluate(chatSurfaceProbeExpression());
1950
+ }
1951
+ catch (error) {
1952
+ // A stalled tab is the send failing, not the surface being unknown. This
1953
+ // probe is the first command of most sends, and swallowing its timeout
1954
+ // left the next command to report a closed socket with no cause.
1955
+ if (cdpCommandTimedOut(error))
1956
+ throw error;
1957
+ return undefined;
1958
+ }
1959
+ };
1960
+ const settled = (probe) => probe
1961
+ ? chatSurfaceState({
1962
+ ...(probe.storedMode !== undefined ? { storedMode: probe.storedMode } : {}),
1963
+ surfaces: probe.surfaces ?? []
1964
+ })
1965
+ : "unknown";
1966
+ if (settled(await read()) !== "switch-to-chat")
1967
+ return undefined;
1968
+ const note = "ChatGPT was on its Work surface, whose picker offers different models and no Pro. Switched back to Chat.";
1969
+ // Confirm the surface actually flipped rather than trusting a click. It took
1970
+ // seconds to re-render when measured, and returning early meant the picker
1971
+ // read straight afterwards was still Work's.
1972
+ const confirm = async () => {
1973
+ for (let attempt = 0; attempt < CHAT_SURFACE_SETTLE_ATTEMPTS; attempt += 1) {
1974
+ await sleep(700);
1975
+ const probe = await read();
1976
+ // A probe that cannot be read at all is a connection that is gone; the
1977
+ // remaining attempts would each wait out a full command timeout for the
1978
+ // same answer.
1979
+ if (!probe)
1980
+ return false;
1981
+ if (settled(probe) === "already-chat")
1982
+ return true;
1983
+ }
1984
+ return false;
1985
+ };
1810
1986
  try {
1811
- probe = await cdp.evaluate(chatWorkSurfaceExpression());
1987
+ const chat = await cdp.evaluate(chatSurfaceToggleRectExpression());
1988
+ if (chat?.ok && chat.x !== undefined && chat.y !== undefined) {
1989
+ await verifiedClickAt(cdp, chat.x, chat.y, "Chat surface toggle");
1990
+ if (await confirm())
1991
+ return note;
1992
+ }
1812
1993
  }
1813
- catch {
1814
- return undefined;
1994
+ catch (error) {
1995
+ if (cdpCommandTimedOut(error))
1996
+ throw error;
1997
+ // fall through to the stored preference, which works without the toggle
1815
1998
  }
1816
- if (!probe)
1817
- return undefined;
1818
- if (chatSurfaceChoice(probe.surfaces ?? []) !== "switch-to-chat")
1819
- return undefined;
1820
- const chat = probe.chat;
1821
- if (!chat?.ok || chat.x === undefined || chat.y === undefined) {
1822
- return "ChatGPT is on its Work surface, whose picker offers different models and no Pro, and the Chat toggle could not be clicked.";
1999
+ // Threads and project pages do not render the toggle at all, so the surface
2000
+ // is set the way the app itself stores it and the page is reloaded onto it.
2001
+ try {
2002
+ await cdp.evaluate(selectChatSurfaceExpression());
2003
+ // Reading the persisted value back right after writing it proves nothing;
2004
+ // the reloaded document rendering its composer is what proves the switch.
2005
+ if ((await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS)) && (await confirm()))
2006
+ return note;
1823
2007
  }
1824
- await verifiedClickAt(cdp, chat.x, chat.y, "Chat surface toggle");
1825
- await sleep(1_500);
1826
- return "ChatGPT was on its Work surface, whose picker offers different models and no Pro. Switched back to Chat.";
2008
+ catch (error) {
2009
+ if (cdpCommandTimedOut(error))
2010
+ throw error;
2011
+ // reported below
2012
+ }
2013
+ return "ChatGPT is on its Work surface, whose picker offers different models and no Pro, and it could not be switched back to Chat.";
1827
2014
  }
1828
2015
  async function ensurePickerOpen(cdp) {
1829
2016
  // The slider, not the menu, is what the caller needs - and an open menu that
@@ -1913,11 +2100,6 @@ export function sliderWalkPlan(state) {
1913
2100
  async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1914
2101
  if (!options.model && !options.proMode && !options.effort)
1915
2102
  return;
1916
- // Which surface is live decides which picker exists at all, so settle that
1917
- // before looking for anything in it.
1918
- const surfaceWarning = await ensureChatSurface(cdp);
1919
- if (surfaceWarning)
1920
- selectionWarnings.push(surfaceWarning);
1921
2103
  // --pro-mode selects a Pro sub-mode, so it is meaningless with a non-Pro
1922
2104
  // --model. Fail loudly instead of silently dropping the requested sub-mode.
1923
2105
  if (options.proMode && options.model && !/pro/i.test(options.model)) {
@@ -2260,8 +2442,10 @@ async function selectProject(cdp, options) {
2260
2442
  // prompt posted into the project the tab came from). A hard reload of the
2261
2443
  // project home rebinds the composer to THIS project before we send.
2262
2444
  const projectHome = await cdp.evaluate("location.href");
2263
- await cdp.evaluate("location.reload()");
2264
- const rebound = await waitForExpressionTrue(cdp, `location.href === ${JSON.stringify(projectHome)} && Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
2445
+ // Polled with no delay, the first check could run before the reload had
2446
+ // committed, on the old document, whose composer and URL both still
2447
+ // matched. The stamp keeps that document from passing as the new one.
2448
+ const rebound = await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS, `location.href === ${JSON.stringify(projectHome)}`);
2265
2449
  if (!rebound) {
2266
2450
  throw new Error(`ChatGPT composer did not rebind after entering project "${options.project}"`);
2267
2451
  }
@@ -2435,6 +2619,8 @@ async function findLandedConversation(page, prompt) {
2435
2619
  export async function sendChatGptPrompt(options) {
2436
2620
  const port = resolveCdpPort(options.port);
2437
2621
  const timeoutMs = options.timeoutMs ?? 90_000;
2622
+ /** Dialogs answered on the reload connection, which comes and goes before the send's own, so the receipt still says so. */
2623
+ const earlyDialogsAnswered = [];
2438
2624
  const sendStartedAt = Date.now();
2439
2625
  const emitProgress = (phase, detail) => {
2440
2626
  if (!options.onProgress)
@@ -2526,6 +2712,45 @@ export async function sendChatGptPrompt(options) {
2526
2712
  busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
2527
2713
  }
2528
2714
  }
2715
+ // A thread can be left rendered with no composer at all - measured after a
2716
+ // send, zero contenteditables and zero textareas on the page - and every
2717
+ // retry then lands on the same dead page and reports the same "missing a
2718
+ // visible prompt composer". One reload brings it back, so spend it here
2719
+ // rather than handing the caller a blocker it cannot act on. Decided after
2720
+ // the busy check on purpose: a streaming thread hides its composer too, and
2721
+ // so does a dead page whose stop button never went away, so the raw
2722
+ // generating flag tells them apart in neither direction - the transcript
2723
+ // check above does.
2724
+ if (shouldReloadForMissingComposer({
2725
+ hasComposer: status.hasComposer,
2726
+ loggedInLikely: inferChatGptPageLoggedInLikely(status),
2727
+ hasBlocker: Boolean(detectChatGptPageBlocker(status)),
2728
+ busy: Boolean(busyBlocker),
2729
+ ...(status.awaitingResponseChoice !== undefined ? { awaitingResponseChoice: status.awaitingResponseChoice } : {}),
2730
+ ...(status.openDialogText !== undefined ? { openDialogText: status.openDialogText } : {})
2731
+ })) {
2732
+ try {
2733
+ const reloaded = await reloadPageAndAwaitComposer(page);
2734
+ earlyDialogsAnswered.push(...reloaded.dialogsAnswered);
2735
+ // Read into a local and adopt it only once every step has passed, so a
2736
+ // failure part way leaves the pre-reload status and verdict intact
2737
+ // rather than a fresh status with the checks after it skipped.
2738
+ let fresh = await readSettledChatGptPageStatus(page);
2739
+ fresh = await ensureVisibleChatGptPage(port, page, fresh);
2740
+ const blockerAfterReload = detectChatGptPageBlocker(fresh);
2741
+ if (blockerAfterReload)
2742
+ throw new ChatGptBrowserBlockerError(blockerAfterReload);
2743
+ busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(fresh), await readTranscriptCompletion(page, fresh.url));
2744
+ status = fresh;
2745
+ }
2746
+ catch (error) {
2747
+ // A blocker is the answer; a stalled tab is the send failing, and its
2748
+ // guidance is worth more than "missing composer" after a wait.
2749
+ if (error instanceof ChatGptBrowserBlockerError || cdpCommandTimedOut(error))
2750
+ throw error;
2751
+ // keep the original status; the assertions below still report it
2752
+ }
2753
+ }
2529
2754
  // A thread ChatGPT has parked on "which response do you prefer?" will not
2530
2755
  // unpark on its own. Sends that navigate away - a fresh chat, a project home
2531
2756
  // - never touch it; a send that means to continue THAT thread has to say so
@@ -2565,6 +2790,13 @@ export async function sendChatGptPrompt(options) {
2565
2790
  let submitButtonFound = false;
2566
2791
  let wantsDeepResearch = false;
2567
2792
  const sendWarnings = [];
2793
+ // Anything the page put in front of prodex was answered on the caller's
2794
+ // behalf. The note is read at return time - there are several return paths -
2795
+ // so it is folded into the array rather than pushed from each of them.
2796
+ const withDialogNote = (warnings) => {
2797
+ const note = answeredDialogWarning([...earlyDialogsAnswered, ...cdp.dialogsAnswered]);
2798
+ return [...warnings, ...(note ? [note] : [])].filter((warning) => Boolean(warning));
2799
+ };
2568
2800
  const temporaryNote = temporaryChatWarning(options.temporary);
2569
2801
  if (temporaryNote)
2570
2802
  sendWarnings.push(temporaryNote);
@@ -2594,6 +2826,16 @@ export async function sendChatGptPrompt(options) {
2594
2826
  };
2595
2827
  try {
2596
2828
  await cdp.send("Runtime.enable");
2829
+ // Which surface is live decides which picker exists and which models answer,
2830
+ // so settle it before navigating anywhere: a project page opened under Work
2831
+ // keeps Work's composer, and switching afterwards does not move it.
2832
+ // Max and Ultra are rungs of Work's slider, so asking for one means staying
2833
+ // there; anything else belongs on Chat, whose top step is Pro.
2834
+ if (!effortNeedsWorkSurface(options.effort)) {
2835
+ const surfaceWarning = await ensureChatSurface(cdp);
2836
+ if (surfaceWarning)
2837
+ sendWarnings.push(surfaceWarning);
2838
+ }
2597
2839
  await selectProject(cdp, options);
2598
2840
  try {
2599
2841
  await selectModelReasoning(cdp, options, sendWarnings);
@@ -2810,7 +3052,7 @@ export async function sendChatGptPrompt(options) {
2810
3052
  answer: transcript.answer,
2811
3053
  modelHints: finalState?.modelHints ?? [],
2812
3054
  ...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
2813
- warnings: [...sendWarnings, modelSelectionWarning(options.model, transcript.modelSlug || finalState?.modelSlug)].filter((warning) => Boolean(warning))
3055
+ warnings: withDialogNote([...sendWarnings, modelSelectionWarning(options.model, transcript.modelSlug || finalState?.modelSlug)]).filter((warning) => Boolean(warning))
2814
3056
  };
2815
3057
  };
2816
3058
  // Deep research never reaches the DOM answer wait below: the report is
@@ -2849,7 +3091,7 @@ export async function sendChatGptPrompt(options) {
2849
3091
  answer: report,
2850
3092
  modelHints: finalState?.modelHints ?? [],
2851
3093
  ...(finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
2852
- warnings: sendWarnings
3094
+ warnings: withDialogNote(sendWarnings)
2853
3095
  };
2854
3096
  }
2855
3097
  emitProgress("waiting", `deep research ${lastState.status || lastState.reason} (${formatDurationMs(Date.now() - started)})`);
@@ -2958,7 +3200,7 @@ export async function sendChatGptPrompt(options) {
2958
3200
  answer: completed.answer.trim(),
2959
3201
  modelHints: completed.modelHints,
2960
3202
  ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
2961
- warnings: [...sendWarnings, modelSelectionWarning(options.model, completed.modelSlug)].filter((warning) => Boolean(warning))
3203
+ warnings: withDialogNote([...sendWarnings, modelSelectionWarning(options.model, completed.modelSlug)]).filter((warning) => Boolean(warning))
2962
3204
  };
2963
3205
  }
2964
3206
  // Timed out while the answer was still streaming: salvage the partial text
@@ -2972,11 +3214,11 @@ export async function sendChatGptPrompt(options) {
2972
3214
  answer: completed.answer.trim(),
2973
3215
  modelHints: completed.modelHints,
2974
3216
  ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
2975
- warnings: [
3217
+ warnings: withDialogNote([
2976
3218
  ...sendWarnings,
2977
3219
  ...(modelSelectionWarning(options.model, completed.modelSlug) ? [modelSelectionWarning(options.model, completed.modelSlug)] : []),
2978
3220
  `answer_incomplete: ChatGPT was still generating after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms), so the answer below may be truncated. Raise --timeout-ms and retry for the full response.`
2979
- ]
3221
+ ])
2980
3222
  };
2981
3223
  }
2982
3224
  // Carry the thread the prompt landed in: ChatGPT usually finishes the answer
@@ -3003,11 +3245,14 @@ export function modelMenuOptionsExpression() {
3003
3245
  return `(() => {
3004
3246
  const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
3005
3247
  if (!m) return [];
3006
- // Only the radios are models. The plain menuitems above them are the power
3007
- // slider's own rows - its track, and the label pair naming the current model
3008
- // and effort - and listing those put GPT-6 Astra in twice and Extra High in
3009
- // as if it were a model.
3010
- return [...m.querySelectorAll('[role="menuitemradio"]')]
3248
+ // Radios are models, and so are the submenu rows an earlier picker kept them
3249
+ // behind. What is NOT a model is the power slider's own rows - its track,
3250
+ // and the pair naming the live model and effort - which are plain menuitems
3251
+ // with nothing to open, and which put GPT-6 Astra in the list twice and
3252
+ // Extra High in as if it were a model. Dropping every menuitem instead
3253
+ // emptied the listing on the submenu picker entirely.
3254
+ return [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')]
3255
+ .filter((it) => it.getAttribute("role") === "menuitemradio" || it.getAttribute("aria-haspopup") === "menu")
3011
3256
  .map((it) => {
3012
3257
  // A submenu row renders as label over value ("Model" / "GPT-5.6 Sol"),
3013
3258
  // and the value is the part a person actually wants to read.
@@ -3501,6 +3746,13 @@ async function restorePowerSlider(cdp, target) {
3501
3746
  export async function listChatGptModelOptions(input = {}) {
3502
3747
  const port = resolveCdpPort(input.port);
3503
3748
  const timeoutMs = input.timeoutMs ?? 15_000;
3749
+ // The steps before a connection is held - finding the page, settling its
3750
+ // status - are bounded by their own ceilings and cannot be cut short from
3751
+ // outside; the signal is honoured between them, and closes the connection
3752
+ // once there is one.
3753
+ const abandoned = () => new Error("Model listing abandoned: the caller stopped waiting for it");
3754
+ if (input.signal?.aborted)
3755
+ throw abandoned();
3504
3756
  const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), undefined);
3505
3757
  if (!pageResult.ok) {
3506
3758
  throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
@@ -3518,7 +3770,17 @@ export async function listChatGptModelOptions(input = {}) {
3518
3770
  throw new ChatGptBrowserBlockerError(blocker);
3519
3771
  assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
3520
3772
  assertVisibleChatGptTab(status.visibilityState, status.url, undefined);
3773
+ if (input.signal?.aborted)
3774
+ throw abandoned();
3521
3775
  const cdp = await connectCdp(page.webSocketDebuggerUrl);
3776
+ // A caller that has stopped waiting (setup's deadline) closes the connection
3777
+ // here, which rejects whatever is in flight and lets the send lock go; left
3778
+ // alone, the listing ran on unattended holding both until its own timeouts.
3779
+ // The picker may be left open in that case; the next send copes with one.
3780
+ const abandon = () => cdp.close();
3781
+ if (input.signal?.aborted)
3782
+ abandon();
3783
+ input.signal?.addEventListener("abort", abandon, { once: true });
3522
3784
  try {
3523
3785
  await cdp.send("Runtime.enable");
3524
3786
  const button = await cdp.evaluate(modelButtonRectExpression());
@@ -3531,14 +3793,20 @@ export async function listChatGptModelOptions(input = {}) {
3531
3793
  if (!opened)
3532
3794
  throw new Error("ChatGPT model menu did not open after clicking the selector");
3533
3795
  const options = await cdp.evaluate(modelMenuOptionsExpression());
3534
- const effortSteps = await readEffortSteps(cdp);
3796
+ // Walking the ladder moves someone's slider and puts it back. That is
3797
+ // worth it when the ladder is what was asked for, and not worth it for a
3798
+ // caller that only needs the model names - `setup` was walking the live
3799
+ // slider as a side effect of saving a config file.
3800
+ const effortSteps = input.walkPowerSlider === false ? undefined : await readEffortSteps(cdp);
3535
3801
  // Chat and Work have different pickers - Work's offers no Pro - and a
3536
3802
  // listing that does not say which one it read is how a browser sitting on
3537
3803
  // Work convinced two machines that Pro had been removed from the account.
3538
3804
  let surface;
3539
3805
  try {
3540
- const probe = await cdp.evaluate(chatWorkSurfaceExpression());
3541
- surface = probe?.surfaces?.find((entry) => entry.checked)?.label;
3806
+ const probe = await cdp.evaluate(chatSurfaceProbeExpression());
3807
+ // Threads and project pages draw no toggle; the persisted choice is
3808
+ // what the app itself reads to decide, so it answers there.
3809
+ surface = surfaceFromProbe(probe);
3542
3810
  }
3543
3811
  catch {
3544
3812
  // the toggle predates some builds; saying nothing is right there
@@ -3557,9 +3825,42 @@ export async function listChatGptModelOptions(input = {}) {
3557
3825
  }
3558
3826
  }
3559
3827
  finally {
3828
+ input.signal?.removeEventListener("abort", abandon);
3560
3829
  cdp.close();
3561
3830
  }
3562
3831
  }
3832
+ /**
3833
+ * Whether anything is listening on the local port, decided at the TCP level.
3834
+ * Treat only "accepted" as proof: a closed port may time out rather than refuse.
3835
+ */
3836
+ export function portAccepts(port, timeoutMs = 250) {
3837
+ return new Promise((resolve) => {
3838
+ const socket = net.createConnection({ host: "127.0.0.1", port });
3839
+ const done = (verdict) => {
3840
+ socket.destroy();
3841
+ resolve(verdict);
3842
+ };
3843
+ socket.setTimeout(timeoutMs, () => done("timeout"));
3844
+ socket.once("connect", () => done("accepted"));
3845
+ socket.once("error", (error) => done(error.code === "ECONNREFUSED" ? "refused" : "timeout"));
3846
+ });
3847
+ }
3848
+ /** True when a fetch failed because its AbortSignal.timeout fired, not because nothing was listening. */
3849
+ /** Whether an error is a DevTools command that got no answer in time - the one failure that has closed the socket. */
3850
+ export function cdpCommandTimedOut(error) {
3851
+ return error instanceof Error && /Chrome DevTools command timed out/.test(error.message);
3852
+ }
3853
+ export function fetchTimedOut(error) {
3854
+ return typeof error === "object" && error !== null && error.name === "TimeoutError";
3855
+ }
3856
+ /**
3857
+ * Whether a status probe is evidence that the browser is DEAD, as opposed to
3858
+ * merely slow. Only a refused connection counts; a timeout is a busy browser,
3859
+ * and ending a busy browser takes the consult it is writing with it.
3860
+ */
3861
+ export function statusMeansBrowserDead(status) {
3862
+ return !status.reachable && status.blocker?.code !== "browser_slow";
3863
+ }
3563
3864
  async function findChatGptPage(port, timeoutMs, targetUrl) {
3564
3865
  try {
3565
3866
  const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
@@ -3573,6 +3874,28 @@ async function findChatGptPage(port, timeoutMs, targetUrl) {
3573
3874
  return { ok: true, page: selectChatGptPage(pages, targetUrl, visibilityByPage) };
3574
3875
  }
3575
3876
  catch (error) {
3877
+ // A port that answers too slowly is a browser that is BUSY, not one that is
3878
+ // gone, and the two must not share a verdict: the silence check that
3879
+ // decides whether to end a browser counted three slow answers on a loaded
3880
+ // machine as three dead ones - the exact failure the comment above
3881
+ // confirmBrowserSilence was written to prevent.
3882
+ // A timeout on its own is not proof of life: with a small budget the
3883
+ // timer fires before the connection reports anything, and a port nothing
3884
+ // listens on would be called "busy". A raw TCP connect settles it, and
3885
+ // only an ACCEPT counts - measured on WSL2, a closed loopback port does not
3886
+ // refuse at all, it times out (1.5s), while a listening one accepts in 1ms.
3887
+ if (fetchTimedOut(error) && (await portAccepts(port)) === "accepted") {
3888
+ return {
3889
+ ok: false,
3890
+ blocker: {
3891
+ code: "browser_slow",
3892
+ message: `The Chrome DevTools endpoint on 127.0.0.1:${port} did not answer within ${timeoutMs}ms. The browser is running but busy.`,
3893
+ retryable: true,
3894
+ next_step: "Wait a moment and retry. If the machine is under load (a test suite, a build), let it finish first. A tab wedged by a dialog looks like this too - check the visible window.",
3895
+ ...(error instanceof Error ? { detail: error.message } : {})
3896
+ }
3897
+ };
3898
+ }
3576
3899
  return {
3577
3900
  ok: false,
3578
3901
  blocker: {
@@ -3642,6 +3965,8 @@ async function connectCdp(webSocketUrl, timeoutMs) {
3642
3965
  const ws = new WebSocket(webSocketUrl);
3643
3966
  let id = 0;
3644
3967
  const pending = new Map();
3968
+ /** Types of JavaScript dialog answered on this connection. */
3969
+ const dialogsAnswered = [];
3645
3970
  ws.addEventListener("message", (event) => {
3646
3971
  let message;
3647
3972
  try {
@@ -3660,6 +3985,19 @@ async function connectCdp(webSocketUrl, timeoutMs) {
3660
3985
  clearTimeout(waiter.timer);
3661
3986
  waiter.resolve(message);
3662
3987
  pending.delete(message.id);
3988
+ return;
3989
+ }
3990
+ // A JavaScript dialog halts the page's main thread: every evaluate after it
3991
+ // waits forever, and Page.enable stops answering too, so a client that had
3992
+ // not enabled the domain BEFORE the dialog appeared cannot dismiss it - the
3993
+ // browser answers "no dialog is showing". Measured live, that is the whole
3994
+ // of the "Chrome DevTools command timed out" wedge. Answering it here is
3995
+ // the only cure, and it has to be armed in advance.
3996
+ if (message.method === "Page.javascriptDialogOpening") {
3997
+ const type = message.params?.type;
3998
+ const answer = javascriptDialogResponse(type);
3999
+ dialogsAnswered.push(type ?? "unknown");
4000
+ ws.send(JSON.stringify({ id: ++id, method: "Page.handleJavaScriptDialog", params: answer }));
3663
4001
  }
3664
4002
  });
3665
4003
  ws.addEventListener("close", () => {
@@ -3702,18 +4040,42 @@ async function connectCdp(webSocketUrl, timeoutMs) {
3702
4040
  reject(new Error("Chrome DevTools websocket closed during connect"));
3703
4041
  }, { once: true });
3704
4042
  });
4043
+ /** The timeout that closed this socket, so later commands can name it rather than a bare closed socket. */
4044
+ let closedByTimeout;
3705
4045
  const send = (method, params = {}) => {
4046
+ // Once a timeout has closed the socket, every later command would sit out
4047
+ // its own full timeout for an answer that cannot come. Measured cost of not
4048
+ // checking: a 14-probe confirmation loop turning into minutes of silence.
4049
+ // The rejection carries the timeout that did the closing: a caller that
4050
+ // swallowed that first error and moved on would otherwise report "socket
4051
+ // not open", which names neither the stalled tab nor the dialog cure.
4052
+ if (ws.readyState !== WebSocket.OPEN) {
4053
+ return Promise.reject(new Error(closedByTimeout
4054
+ ? `${closedByTimeout} (the connection was closed by that timeout before ${method})`
4055
+ : `Chrome DevTools websocket is not open (${method})`));
4056
+ }
3706
4057
  const messageId = ++id;
3707
4058
  return new Promise((resolve, reject) => {
3708
4059
  const timer = setTimeout(() => {
3709
4060
  pending.delete(messageId);
4061
+ closedByTimeout = `Chrome DevTools command timed out: ${method}`;
3710
4062
  ws.close();
3711
- reject(new Error(`Chrome DevTools command timed out: ${method}`));
4063
+ reject(new Error(closedByTimeout));
3712
4064
  }, Math.max(1, effectiveTimeoutMs));
3713
4065
  pending.set(messageId, { resolve, reject, timer });
3714
4066
  ws.send(JSON.stringify({ id: messageId, method, params }));
3715
4067
  });
3716
4068
  };
4069
+ // Armed before anything else is asked of the page, because after a dialog
4070
+ // opens it is too late.
4071
+ //
4072
+ // Fire and forget rather than awaited: on a page that is ALREADY wedged,
4073
+ // Page.enable never answers either, and the ordinary send path closes the
4074
+ // socket when a command times out. Awaiting it there would have turned the
4075
+ // caller's diagnosis from "command timed out: Runtime.enable" - which at
4076
+ // least names what was being attempted - into a bare "websocket closed".
4077
+ // Nothing waits on the arming, so a build without the domain is no worse off.
4078
+ ws.send(JSON.stringify({ id: ++id, method: "Page.enable", params: {} }));
3717
4079
  const evaluate = async (expression) => {
3718
4080
  const response = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
3719
4081
  if (response.error?.message)
@@ -3722,7 +4084,7 @@ async function connectCdp(webSocketUrl, timeoutMs) {
3722
4084
  throw new Error("Runtime.evaluate failed");
3723
4085
  return response.result?.result?.value;
3724
4086
  };
3725
- return { send, evaluate, close: () => ws.close() };
4087
+ return { send, evaluate, dialogsAnswered, close: () => ws.close() };
3726
4088
  }
3727
4089
  // In-page reasoning-header placeholder test, shared by statusExpression and
3728
4090
  // answerExpression. MUST stay in sync with isUsableChatGptAnswer: a header