@youdie006/prodex 0.39.0 → 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 { menuKeyboardStep, readPowerSliderSelection } 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,17 +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
- // Two more rungs than before. Measured live: with the model row chosen rather
26
- // than the recommended set, the slider reads Light / Medium / High / Extra High
27
- // / Max / Ultra, and Ultra is the top of the machine. Pro is kept because older
28
- // pickers still name the top step that way.
24
+ /** Enough presses to cross any slider this code accepts, twice over. */
25
+ const POWER_SLIDER_RESTORE_ATTEMPTS = 26;
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.
29
32
  const REASONING_EFFORTS = ["즉시", "중간", "높음", "매우 높음", "Max", "Ultra", "Pro"];
30
33
  const PRO_MODES = ["기본", "확장"];
31
34
  // Aliases map friendly CLI input onto the exact Korean menu labels the picker
@@ -706,7 +709,7 @@ export function pickerSelectionPlan(input) {
706
709
  (input.effort === undefined
707
710
  ? "applied to the effort slider instead. Say --effort Pro to be explicit"
708
711
  : `ignored in favour of --effort ${input.effort}`) +
709
- ", or clear a saved default with `prodex setup --model \"\"`.";
712
+ ", or clear a saved default with `prodex setup --clear-model`.";
710
713
  }
711
714
  return plan;
712
715
  }
@@ -1037,6 +1040,21 @@ decidedBusyBlocker, busyVerdictDecided = false) {
1037
1040
  throw new ChatGptBrowserBlockerError(busyBlocker);
1038
1041
  assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
1039
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
+ }
1040
1058
  export function assertChatGptReadyForPrompt(loggedInLikely, hasComposer, openDialogText) {
1041
1059
  if (loggedInLikely && hasComposer)
1042
1060
  return;
@@ -1410,8 +1428,11 @@ export function powerLabelMatches(requested, rendered) {
1410
1428
  /** Slider position plus the Model/Effort readout next to it. */
1411
1429
  export function powerSliderStateExpression() {
1412
1430
  return `(() => {
1413
- 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.
1414
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"]'));
1415
1436
  const lines = menu ? (menu.innerText || "").split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean) : [];
1416
1437
  if (!slider) return { ok: false, reason: "power slider not found", lines };
1417
1438
  const readPowerSliderSelection = ${readPowerSliderSelection.toString()};
@@ -1445,6 +1466,124 @@ export function activeMenuItemExpression() {
1445
1466
  return { role: a.getAttribute("role"), label };
1446
1467
  })()`;
1447
1468
  }
1469
+ /** Which surface is live. Reads only - no scrolling, no attributes written. */
1470
+ export function chatSurfaceProbeExpression() {
1471
+ return `(() => {
1472
+ const buttons = [...document.querySelectorAll('[role="radio"],[role="tab"]')].filter((el) => {
1473
+ const t = ((el.innerText || el.textContent || "").trim());
1474
+ return t === "Chat" || t === "Work";
1475
+ });
1476
+ const surfaces = buttons.map((el) => ({
1477
+ label: ((el.innerText || el.textContent || "").trim()),
1478
+ checked: el.getAttribute("aria-checked") === "true" || el.getAttribute("aria-selected") === "true"
1479
+ }));
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 };
1495
+ })()`;
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
+ }
1448
1587
  export function powerSliderPresentExpression() {
1449
1588
  return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"]'))`;
1450
1589
  }
@@ -1469,7 +1608,11 @@ export function focusPickerMenuExpression() {
1469
1608
  }
1470
1609
  export function focusPowerSliderExpression() {
1471
1610
  return `(() => {
1472
- 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"]'));
1473
1616
  if (!slider) return { ok: false, reason: "power slider not found" };
1474
1617
  slider.focus();
1475
1618
  return { ok: document.activeElement === slider };
@@ -1647,9 +1790,20 @@ export function modelSelectionUnavailableWarning(requested, reason, available) {
1647
1790
  if (!/menu item not found|not clickable/i.test(reason))
1648
1791
  return undefined;
1649
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
+ }
1650
1804
  return (`model_not_applied: this ChatGPT picker does not offer "${requested}" as a selectable model, ` +
1651
1805
  `so the send used whatever the composer already had.${offers} ` +
1652
- '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`.');
1653
1807
  }
1654
1808
  /**
1655
1809
  * A step the slider does not have.
@@ -1782,6 +1936,82 @@ async function selectPickerModel(cdp, requested, warnings = []) {
1782
1936
  * without this the step that follows reported "the picker did not expose its
1783
1937
  * power slider" on a picker that was simply shut.
1784
1938
  */
1939
+ /**
1940
+ * Put the browser back on ChatGPT's Chat surface when it has drifted onto Work.
1941
+ *
1942
+ * The two have different pickers - Work's offers no Pro at all - and nothing on
1943
+ * the page announces which one is live, so a drifted browser silently drives
1944
+ * the wrong picker. Returns a warning to carry to the caller when it moved.
1945
+ */
1946
+ async function ensureChatSurface(cdp) {
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
+ };
1986
+ try {
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
+ }
1993
+ }
1994
+ catch (error) {
1995
+ if (cdpCommandTimedOut(error))
1996
+ throw error;
1997
+ // fall through to the stored preference, which works without the toggle
1998
+ }
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;
2007
+ }
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.";
2014
+ }
1785
2015
  async function ensurePickerOpen(cdp) {
1786
2016
  // The slider, not the menu, is what the caller needs - and an open menu that
1787
2017
  // has not painted its slider yet looks identical to one that has.
@@ -2212,8 +2442,10 @@ async function selectProject(cdp, options) {
2212
2442
  // prompt posted into the project the tab came from). A hard reload of the
2213
2443
  // project home rebinds the composer to THIS project before we send.
2214
2444
  const projectHome = await cdp.evaluate("location.href");
2215
- await cdp.evaluate("location.reload()");
2216
- 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)}`);
2217
2449
  if (!rebound) {
2218
2450
  throw new Error(`ChatGPT composer did not rebind after entering project "${options.project}"`);
2219
2451
  }
@@ -2387,6 +2619,8 @@ async function findLandedConversation(page, prompt) {
2387
2619
  export async function sendChatGptPrompt(options) {
2388
2620
  const port = resolveCdpPort(options.port);
2389
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 = [];
2390
2624
  const sendStartedAt = Date.now();
2391
2625
  const emitProgress = (phase, detail) => {
2392
2626
  if (!options.onProgress)
@@ -2478,6 +2712,45 @@ export async function sendChatGptPrompt(options) {
2478
2712
  busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
2479
2713
  }
2480
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
+ }
2481
2754
  // A thread ChatGPT has parked on "which response do you prefer?" will not
2482
2755
  // unpark on its own. Sends that navigate away - a fresh chat, a project home
2483
2756
  // - never touch it; a send that means to continue THAT thread has to say so
@@ -2517,6 +2790,13 @@ export async function sendChatGptPrompt(options) {
2517
2790
  let submitButtonFound = false;
2518
2791
  let wantsDeepResearch = false;
2519
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
+ };
2520
2800
  const temporaryNote = temporaryChatWarning(options.temporary);
2521
2801
  if (temporaryNote)
2522
2802
  sendWarnings.push(temporaryNote);
@@ -2546,6 +2826,16 @@ export async function sendChatGptPrompt(options) {
2546
2826
  };
2547
2827
  try {
2548
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
+ }
2549
2839
  await selectProject(cdp, options);
2550
2840
  try {
2551
2841
  await selectModelReasoning(cdp, options, sendWarnings);
@@ -2762,7 +3052,7 @@ export async function sendChatGptPrompt(options) {
2762
3052
  answer: transcript.answer,
2763
3053
  modelHints: finalState?.modelHints ?? [],
2764
3054
  ...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
2765
- 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))
2766
3056
  };
2767
3057
  };
2768
3058
  // Deep research never reaches the DOM answer wait below: the report is
@@ -2801,7 +3091,7 @@ export async function sendChatGptPrompt(options) {
2801
3091
  answer: report,
2802
3092
  modelHints: finalState?.modelHints ?? [],
2803
3093
  ...(finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
2804
- warnings: sendWarnings
3094
+ warnings: withDialogNote(sendWarnings)
2805
3095
  };
2806
3096
  }
2807
3097
  emitProgress("waiting", `deep research ${lastState.status || lastState.reason} (${formatDurationMs(Date.now() - started)})`);
@@ -2910,7 +3200,7 @@ export async function sendChatGptPrompt(options) {
2910
3200
  answer: completed.answer.trim(),
2911
3201
  modelHints: completed.modelHints,
2912
3202
  ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
2913
- warnings: [...sendWarnings, modelSelectionWarning(options.model, completed.modelSlug)].filter((warning) => Boolean(warning))
3203
+ warnings: withDialogNote([...sendWarnings, modelSelectionWarning(options.model, completed.modelSlug)]).filter((warning) => Boolean(warning))
2914
3204
  };
2915
3205
  }
2916
3206
  // Timed out while the answer was still streaming: salvage the partial text
@@ -2924,11 +3214,11 @@ export async function sendChatGptPrompt(options) {
2924
3214
  answer: completed.answer.trim(),
2925
3215
  modelHints: completed.modelHints,
2926
3216
  ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
2927
- warnings: [
3217
+ warnings: withDialogNote([
2928
3218
  ...sendWarnings,
2929
3219
  ...(modelSelectionWarning(options.model, completed.modelSlug) ? [modelSelectionWarning(options.model, completed.modelSlug)] : []),
2930
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.`
2931
- ]
3221
+ ])
2932
3222
  };
2933
3223
  }
2934
3224
  // Carry the thread the prompt landed in: ChatGPT usually finishes the answer
@@ -2955,11 +3245,14 @@ export function modelMenuOptionsExpression() {
2955
3245
  return `(() => {
2956
3246
  const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
2957
3247
  if (!m) return [];
2958
- // Only the radios are models. The plain menuitems above them are the power
2959
- // slider's own rows - its track, and the label pair naming the current model
2960
- // and effort - and listing those put GPT-6 Astra in twice and Extra High in
2961
- // as if it were a model.
2962
- 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")
2963
3256
  .map((it) => {
2964
3257
  // A submenu row renders as label over value ("Model" / "GPT-5.6 Sol"),
2965
3258
  // and the value is the part a person actually wants to read.
@@ -3380,25 +3673,25 @@ export async function listChatGptSidebarProjects(input = {}) {
3380
3673
  * listing exactly as it was rather than failing a read-only command.
3381
3674
  */
3382
3675
  async function readEffortSteps(cdp) {
3676
+ const focused = await cdp.evaluate(focusPowerSliderExpression());
3677
+ if (!focused?.ok)
3678
+ return undefined;
3679
+ const start = await cdp.evaluate(powerSliderStateExpression());
3680
+ if (!start?.ok)
3681
+ return undefined;
3682
+ const plan = sliderWalkPlan({ position: start.position, min: start.min, max: start.max });
3683
+ if (!plan)
3684
+ return undefined;
3685
+ const current = start.effort ?? null;
3686
+ const startedAt = start.position;
3383
3687
  try {
3384
- const focused = await cdp.evaluate(focusPowerSliderExpression());
3385
- if (!focused?.ok)
3386
- return undefined;
3387
- const start = await cdp.evaluate(powerSliderStateExpression());
3388
- if (!start?.ok)
3389
- return undefined;
3390
- const plan = sliderWalkPlan({ position: start.position, min: start.min, max: start.max });
3391
- if (!plan)
3392
- return undefined;
3393
- const current = start.effort ?? null;
3394
3688
  for (let i = 0; i < plan.toBottom; i += 1) {
3395
3689
  await dispatchArrowKey(cdp, "ArrowLeft");
3396
3690
  await sleep(120);
3397
3691
  }
3398
- // Every position is recorded, including repeats. One slider now walks a
3399
- // ladder of model-and-effort pairs, so "Light" is three different rungs -
3400
- // collapsing by label turned six positions into three and lost which model
3401
- // each one sends with.
3692
+ // Every position is recorded, including repeats. The slider can walk a
3693
+ // ladder of model-and-effort pairs, so one effort name is several rungs -
3694
+ // collapsing by label lost which model each one would send with.
3402
3695
  const rungs = [];
3403
3696
  const record = async () => {
3404
3697
  const state = await cdp.evaluate(powerSliderStateExpression());
@@ -3415,20 +3708,51 @@ async function readEffortSteps(cdp) {
3415
3708
  await sleep(220);
3416
3709
  await record();
3417
3710
  }
3418
- // Put it back. Reading someone's setting must not change it.
3419
- for (let i = 0; i < plan.climb - plan.back; i += 1) {
3420
- await dispatchArrowKey(cdp, "ArrowLeft");
3421
- await sleep(120);
3422
- }
3423
3711
  return rungs.length > 0 ? { rungs, current } : undefined;
3424
3712
  }
3425
3713
  catch {
3426
3714
  return undefined;
3427
3715
  }
3716
+ finally {
3717
+ // Whatever happened above, the setting goes back. Replaying a precomputed
3718
+ // count skipped this whenever the walk was interrupted, and one run left a
3719
+ // slider on High that had been on Pro.
3720
+ await restorePowerSlider(cdp, startedAt);
3721
+ }
3722
+ }
3723
+ async function restorePowerSlider(cdp, target) {
3724
+ for (let guard = 0; guard < POWER_SLIDER_RESTORE_ATTEMPTS; guard += 1) {
3725
+ let state;
3726
+ try {
3727
+ state = await cdp.evaluate(powerSliderStateExpression());
3728
+ }
3729
+ catch {
3730
+ return;
3731
+ }
3732
+ if (!state?.ok)
3733
+ return;
3734
+ const move = sliderRestoreStep({ ...(state.position !== undefined ? { current: state.position } : {}), ...(target !== undefined ? { target } : {}) });
3735
+ if (move === "done")
3736
+ return;
3737
+ try {
3738
+ await dispatchArrowKey(cdp, move === "left" ? "ArrowLeft" : "ArrowRight");
3739
+ }
3740
+ catch {
3741
+ return;
3742
+ }
3743
+ await sleep(150);
3744
+ }
3428
3745
  }
3429
3746
  export async function listChatGptModelOptions(input = {}) {
3430
3747
  const port = resolveCdpPort(input.port);
3431
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();
3432
3756
  const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), undefined);
3433
3757
  if (!pageResult.ok) {
3434
3758
  throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
@@ -3446,7 +3770,17 @@ export async function listChatGptModelOptions(input = {}) {
3446
3770
  throw new ChatGptBrowserBlockerError(blocker);
3447
3771
  assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
3448
3772
  assertVisibleChatGptTab(status.visibilityState, status.url, undefined);
3773
+ if (input.signal?.aborted)
3774
+ throw abandoned();
3449
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 });
3450
3784
  try {
3451
3785
  await cdp.send("Runtime.enable");
3452
3786
  const button = await cdp.evaluate(modelButtonRectExpression());
@@ -3459,8 +3793,25 @@ export async function listChatGptModelOptions(input = {}) {
3459
3793
  if (!opened)
3460
3794
  throw new Error("ChatGPT model menu did not open after clicking the selector");
3461
3795
  const options = await cdp.evaluate(modelMenuOptionsExpression());
3462
- const effortSteps = await readEffortSteps(cdp);
3463
- return { url: status.url, options, ...(effortSteps ? { effortSteps } : {}) };
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);
3801
+ // Chat and Work have different pickers - Work's offers no Pro - and a
3802
+ // listing that does not say which one it read is how a browser sitting on
3803
+ // Work convinced two machines that Pro had been removed from the account.
3804
+ let surface;
3805
+ try {
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);
3810
+ }
3811
+ catch {
3812
+ // the toggle predates some builds; saying nothing is right there
3813
+ }
3814
+ return { url: status.url, options, ...(effortSteps ? { effortSteps } : {}), ...(surface ? { surface } : {}) };
3464
3815
  }
3465
3816
  finally {
3466
3817
  try {
@@ -3474,9 +3825,42 @@ export async function listChatGptModelOptions(input = {}) {
3474
3825
  }
3475
3826
  }
3476
3827
  finally {
3828
+ input.signal?.removeEventListener("abort", abandon);
3477
3829
  cdp.close();
3478
3830
  }
3479
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
+ }
3480
3864
  async function findChatGptPage(port, timeoutMs, targetUrl) {
3481
3865
  try {
3482
3866
  const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
@@ -3490,6 +3874,28 @@ async function findChatGptPage(port, timeoutMs, targetUrl) {
3490
3874
  return { ok: true, page: selectChatGptPage(pages, targetUrl, visibilityByPage) };
3491
3875
  }
3492
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
+ }
3493
3899
  return {
3494
3900
  ok: false,
3495
3901
  blocker: {
@@ -3559,6 +3965,8 @@ async function connectCdp(webSocketUrl, timeoutMs) {
3559
3965
  const ws = new WebSocket(webSocketUrl);
3560
3966
  let id = 0;
3561
3967
  const pending = new Map();
3968
+ /** Types of JavaScript dialog answered on this connection. */
3969
+ const dialogsAnswered = [];
3562
3970
  ws.addEventListener("message", (event) => {
3563
3971
  let message;
3564
3972
  try {
@@ -3577,6 +3985,19 @@ async function connectCdp(webSocketUrl, timeoutMs) {
3577
3985
  clearTimeout(waiter.timer);
3578
3986
  waiter.resolve(message);
3579
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 }));
3580
4001
  }
3581
4002
  });
3582
4003
  ws.addEventListener("close", () => {
@@ -3619,18 +4040,42 @@ async function connectCdp(webSocketUrl, timeoutMs) {
3619
4040
  reject(new Error("Chrome DevTools websocket closed during connect"));
3620
4041
  }, { once: true });
3621
4042
  });
4043
+ /** The timeout that closed this socket, so later commands can name it rather than a bare closed socket. */
4044
+ let closedByTimeout;
3622
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
+ }
3623
4057
  const messageId = ++id;
3624
4058
  return new Promise((resolve, reject) => {
3625
4059
  const timer = setTimeout(() => {
3626
4060
  pending.delete(messageId);
4061
+ closedByTimeout = `Chrome DevTools command timed out: ${method}`;
3627
4062
  ws.close();
3628
- reject(new Error(`Chrome DevTools command timed out: ${method}`));
4063
+ reject(new Error(closedByTimeout));
3629
4064
  }, Math.max(1, effectiveTimeoutMs));
3630
4065
  pending.set(messageId, { resolve, reject, timer });
3631
4066
  ws.send(JSON.stringify({ id: messageId, method, params }));
3632
4067
  });
3633
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: {} }));
3634
4079
  const evaluate = async (expression) => {
3635
4080
  const response = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
3636
4081
  if (response.error?.message)
@@ -3639,7 +4084,7 @@ async function connectCdp(webSocketUrl, timeoutMs) {
3639
4084
  throw new Error("Runtime.evaluate failed");
3640
4085
  return response.result?.result?.value;
3641
4086
  };
3642
- return { send, evaluate, close: () => ws.close() };
4087
+ return { send, evaluate, dialogsAnswered, close: () => ws.close() };
3643
4088
  }
3644
4089
  // In-page reasoning-header placeholder test, shared by statusExpression and
3645
4090
  // answerExpression. MUST stay in sync with isUsableChatGptAnswer: a header