@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.
- package/README.md +161 -521
- package/dist/chatgpt-browser.js +494 -49
- package/dist/cli-help.js +10 -10
- package/dist/cli-pro.js +50 -7
- package/dist/cli-server.js +33 -1
- package/dist/mcp.js +1 -1
- package/dist/picker-interaction.js +112 -0
- package/docs/cli-reference.md +380 -0
- package/docs/releasing.md +83 -0
- package/package.json +3 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2216
|
-
|
|
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
|
-
//
|
|
2959
|
-
// slider's own rows - its track,
|
|
2960
|
-
// and
|
|
2961
|
-
//
|
|
2962
|
-
|
|
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.
|
|
3399
|
-
// ladder of model-and-effort pairs, so
|
|
3400
|
-
// collapsing by label
|
|
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
|
-
|
|
3463
|
-
|
|
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(
|
|
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
|