@youdie006/prodex 0.39.1 → 0.39.3
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 +157 -522
- package/dist/chatgpt-browser.js +476 -58
- package/dist/cli-help.js +10 -10
- package/dist/cli-pro.js +58 -7
- package/dist/cli-server.js +33 -1
- package/dist/mcp.js +1 -1
- package/dist/picker-interaction.js +107 -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 {
|
|
9
|
+
import { answeredDialogWarning, chatSurfaceState, effortNeedsWorkSurface, javascriptDialogResponse, menuKeyboardStep, readPowerSliderSelection, sliderRestoreStep, surfaceFromProbe, sliderPressOutcome, sliderDidNotRespond } 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
|
-
|
|
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
24
|
/** Enough presses to cross any slider this code accepts, twice over. */
|
|
26
25
|
const POWER_SLIDER_RESTORE_ATTEMPTS = 26;
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
1461
|
-
|
|
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;
|
|
1462
1519
|
})()`;
|
|
1463
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
|
-
|
|
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 };
|
|
@@ -1642,6 +1769,10 @@ async function assertSelectionCommitted(cdp, label) {
|
|
|
1642
1769
|
}
|
|
1643
1770
|
// Roughly three seconds of grace for a menu that is still painting.
|
|
1644
1771
|
const POWER_SLIDER_APPEAR_ATTEMPTS = 5;
|
|
1772
|
+
// Presses the walk will wait out while the focused slider ignores them: on a
|
|
1773
|
+
// page built moments ago the key handler attaches after focus is possible.
|
|
1774
|
+
// About six seconds all told, which is more than the measured gap.
|
|
1775
|
+
const POWER_SLIDER_SWALLOWED_PRESS_RETRIES = 6;
|
|
1645
1776
|
/**
|
|
1646
1777
|
* Move the power slider until its Effort readout is the requested step. The
|
|
1647
1778
|
* menu must already be open. Returns the quota line so the caller can warn
|
|
@@ -1663,9 +1794,20 @@ export function modelSelectionUnavailableWarning(requested, reason, available) {
|
|
|
1663
1794
|
if (!/menu item not found|not clickable/i.test(reason))
|
|
1664
1795
|
return undefined;
|
|
1665
1796
|
const offers = available?.length ? ` It offers: ${available.join(", ")}.` : "";
|
|
1797
|
+
// A listed row that refuses the click is a different thing from a model the
|
|
1798
|
+
// picker never had, and saying "does not offer X" while listing X two clauses
|
|
1799
|
+
// later reads as a bug in prodex. Measured on both surfaces: the model rows
|
|
1800
|
+
// carry pointer-events: none and no coordinate reaches them, so the slider is
|
|
1801
|
+
// the only lever prodex has on the model.
|
|
1802
|
+
if (/not clickable/i.test(reason)) {
|
|
1803
|
+
return (`model_not_applied: this ChatGPT picker lists "${requested}" but its model rows cannot be clicked, ` +
|
|
1804
|
+
`so the send used whatever the composer already had.${offers} ` +
|
|
1805
|
+
"The power slider is the only lever prodex has on the model - pick a step with --effort, " +
|
|
1806
|
+
'or clear a saved default with `prodex setup --clear-model`.');
|
|
1807
|
+
}
|
|
1666
1808
|
return (`model_not_applied: this ChatGPT picker does not offer "${requested}" as a selectable model, ` +
|
|
1667
1809
|
`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
|
|
1810
|
+
'Pick an effort with --effort instead, or clear a saved default with `prodex setup --clear-model`.');
|
|
1669
1811
|
}
|
|
1670
1812
|
/**
|
|
1671
1813
|
* A step the slider does not have.
|
|
@@ -1806,24 +1948,73 @@ async function selectPickerModel(cdp, requested, warnings = []) {
|
|
|
1806
1948
|
* the wrong picker. Returns a warning to carry to the caller when it moved.
|
|
1807
1949
|
*/
|
|
1808
1950
|
async function ensureChatSurface(cdp) {
|
|
1809
|
-
|
|
1951
|
+
const read = async () => {
|
|
1952
|
+
try {
|
|
1953
|
+
return await cdp.evaluate(chatSurfaceProbeExpression());
|
|
1954
|
+
}
|
|
1955
|
+
catch (error) {
|
|
1956
|
+
// A stalled tab is the send failing, not the surface being unknown. This
|
|
1957
|
+
// probe is the first command of most sends, and swallowing its timeout
|
|
1958
|
+
// left the next command to report a closed socket with no cause.
|
|
1959
|
+
if (cdpCommandTimedOut(error))
|
|
1960
|
+
throw error;
|
|
1961
|
+
return undefined;
|
|
1962
|
+
}
|
|
1963
|
+
};
|
|
1964
|
+
const settled = (probe) => probe
|
|
1965
|
+
? chatSurfaceState({
|
|
1966
|
+
...(probe.storedMode !== undefined ? { storedMode: probe.storedMode } : {}),
|
|
1967
|
+
surfaces: probe.surfaces ?? []
|
|
1968
|
+
})
|
|
1969
|
+
: "unknown";
|
|
1970
|
+
if (settled(await read()) !== "switch-to-chat")
|
|
1971
|
+
return undefined;
|
|
1972
|
+
const note = "ChatGPT was on its Work surface, whose picker offers different models and no Pro. Switched back to Chat.";
|
|
1973
|
+
// Confirm the surface actually flipped rather than trusting a click. It took
|
|
1974
|
+
// seconds to re-render when measured, and returning early meant the picker
|
|
1975
|
+
// read straight afterwards was still Work's.
|
|
1976
|
+
const confirm = async () => {
|
|
1977
|
+
for (let attempt = 0; attempt < CHAT_SURFACE_SETTLE_ATTEMPTS; attempt += 1) {
|
|
1978
|
+
await sleep(700);
|
|
1979
|
+
const probe = await read();
|
|
1980
|
+
// A probe that cannot be read at all is a connection that is gone; the
|
|
1981
|
+
// remaining attempts would each wait out a full command timeout for the
|
|
1982
|
+
// same answer.
|
|
1983
|
+
if (!probe)
|
|
1984
|
+
return false;
|
|
1985
|
+
if (settled(probe) === "already-chat")
|
|
1986
|
+
return true;
|
|
1987
|
+
}
|
|
1988
|
+
return false;
|
|
1989
|
+
};
|
|
1810
1990
|
try {
|
|
1811
|
-
|
|
1991
|
+
const chat = await cdp.evaluate(chatSurfaceToggleRectExpression());
|
|
1992
|
+
if (chat?.ok && chat.x !== undefined && chat.y !== undefined) {
|
|
1993
|
+
await verifiedClickAt(cdp, chat.x, chat.y, "Chat surface toggle");
|
|
1994
|
+
if (await confirm())
|
|
1995
|
+
return note;
|
|
1996
|
+
}
|
|
1812
1997
|
}
|
|
1813
|
-
catch {
|
|
1814
|
-
|
|
1998
|
+
catch (error) {
|
|
1999
|
+
if (cdpCommandTimedOut(error))
|
|
2000
|
+
throw error;
|
|
2001
|
+
// fall through to the stored preference, which works without the toggle
|
|
1815
2002
|
}
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
2003
|
+
// Threads and project pages do not render the toggle at all, so the surface
|
|
2004
|
+
// is set the way the app itself stores it and the page is reloaded onto it.
|
|
2005
|
+
try {
|
|
2006
|
+
await cdp.evaluate(selectChatSurfaceExpression());
|
|
2007
|
+
// Reading the persisted value back right after writing it proves nothing;
|
|
2008
|
+
// the reloaded document rendering its composer is what proves the switch.
|
|
2009
|
+
if ((await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS)) && (await confirm()))
|
|
2010
|
+
return note;
|
|
2011
|
+
}
|
|
2012
|
+
catch (error) {
|
|
2013
|
+
if (cdpCommandTimedOut(error))
|
|
2014
|
+
throw error;
|
|
2015
|
+
// reported below
|
|
1823
2016
|
}
|
|
1824
|
-
|
|
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.";
|
|
2017
|
+
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
2018
|
}
|
|
1828
2019
|
async function ensurePickerOpen(cdp) {
|
|
1829
2020
|
// The slider, not the menu, is what the caller needs - and an open menu that
|
|
@@ -1863,22 +2054,72 @@ async function selectPowerStep(cdp, requested) {
|
|
|
1863
2054
|
if (!state?.ok)
|
|
1864
2055
|
throw new Error(state?.reason ?? "Could not read ChatGPT's power slider");
|
|
1865
2056
|
const steps = (state.max ?? 4) - (state.min ?? 0) + 1;
|
|
1866
|
-
|
|
2057
|
+
let presses = 0;
|
|
2058
|
+
let swallowed = 0;
|
|
2059
|
+
let everMoved = false;
|
|
2060
|
+
while (presses <= steps * 2) {
|
|
1867
2061
|
if (state.effort && powerLabelMatches(requested, state.effort))
|
|
1868
2062
|
return { effort: state.effort };
|
|
1869
2063
|
// Walk upward first, then back down: the labels are ordered, but their
|
|
1870
2064
|
// exact set can change, so this never assumes a fixed index for a name.
|
|
1871
2065
|
const atTop = (state.position ?? 0) >= (state.max ?? 4);
|
|
1872
|
-
const key =
|
|
2066
|
+
const key = presses < steps && !atTop ? "ArrowRight" : "ArrowLeft";
|
|
2067
|
+
const before = state.position ?? 0;
|
|
1873
2068
|
await dispatchArrowKey(cdp, key);
|
|
1874
2069
|
await sleep(400);
|
|
1875
2070
|
state = await cdp.evaluate(powerSliderStateExpression());
|
|
1876
2071
|
if (!state?.ok)
|
|
1877
2072
|
throw new Error(state?.reason ?? "Could not read ChatGPT's power slider");
|
|
2073
|
+
const outcome = sliderPressOutcome({
|
|
2074
|
+
before,
|
|
2075
|
+
after: state.position ?? before,
|
|
2076
|
+
key,
|
|
2077
|
+
...(state.min !== undefined ? { min: state.min } : {}),
|
|
2078
|
+
...(state.max !== undefined ? { max: state.max } : {})
|
|
2079
|
+
});
|
|
2080
|
+
if (outcome === "swallowed") {
|
|
2081
|
+
// Focus succeeded but the press did nothing with room to move: the
|
|
2082
|
+
// handler is not attached yet. Two consults were blocked with "has no
|
|
2083
|
+
// Pro step. It showed: Instant, 1 of 5" after ten such presses, and a
|
|
2084
|
+
// build that treats that as the picker declining would send at Instant
|
|
2085
|
+
// instead. Wait for the handler rather than counting the press.
|
|
2086
|
+
swallowed += 1;
|
|
2087
|
+
if (swallowed <= POWER_SLIDER_SWALLOWED_PRESS_RETRIES) {
|
|
2088
|
+
await sleep(600);
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
2091
|
+
break;
|
|
2092
|
+
}
|
|
2093
|
+
if (outcome === "moved")
|
|
2094
|
+
everMoved = true;
|
|
2095
|
+
presses += 1;
|
|
2096
|
+
}
|
|
2097
|
+
if (!everMoved) {
|
|
2098
|
+
throw new Error(`ChatGPT's power slider did not respond to arrow keys: it stayed at "${state.effort ?? "?"}" ` +
|
|
2099
|
+
`(${(state.position ?? 0) + 1} of ${(state.max ?? 4) + 1}) while the picker was still hydrating, ` +
|
|
2100
|
+
`so the "${requested}" step could not be reached.`);
|
|
1878
2101
|
}
|
|
1879
2102
|
const available = (state.lines ?? []).join(" / ");
|
|
1880
2103
|
throw new Error(`ChatGPT's model picker has no "${requested}" step. It showed: ${available}`);
|
|
1881
2104
|
}
|
|
2105
|
+
/**
|
|
2106
|
+
* The slider walk, with one more try when the first found a slider that
|
|
2107
|
+
* ignored every press: by then the handler has had seconds to attach.
|
|
2108
|
+
*/
|
|
2109
|
+
async function selectPowerStepWithGrace(cdp, requested) {
|
|
2110
|
+
try {
|
|
2111
|
+
return await selectPowerStep(cdp, requested);
|
|
2112
|
+
}
|
|
2113
|
+
catch (error) {
|
|
2114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2115
|
+
if (!sliderDidNotRespond(message))
|
|
2116
|
+
throw error;
|
|
2117
|
+
await sleep(1_500);
|
|
2118
|
+
if (!(await ensurePickerOpen(cdp)))
|
|
2119
|
+
throw error;
|
|
2120
|
+
return await selectPowerStep(cdp, requested);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
1882
2123
|
async function dispatchArrowKey(cdp, key) {
|
|
1883
2124
|
const code = key;
|
|
1884
2125
|
const virtualKey = key === "ArrowLeft" ? 37 : 39;
|
|
@@ -1913,11 +2154,6 @@ export function sliderWalkPlan(state) {
|
|
|
1913
2154
|
async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
1914
2155
|
if (!options.model && !options.proMode && !options.effort)
|
|
1915
2156
|
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
2157
|
// --pro-mode selects a Pro sub-mode, so it is meaningless with a non-Pro
|
|
1922
2158
|
// --model. Fail loudly instead of silently dropping the requested sub-mode.
|
|
1923
2159
|
if (options.proMode && options.model && !/pro/i.test(options.model)) {
|
|
@@ -2001,11 +2237,13 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
2001
2237
|
throw new Error("ChatGPT's model picker would not reopen after the model was chosen.");
|
|
2002
2238
|
}
|
|
2003
2239
|
try {
|
|
2004
|
-
await
|
|
2240
|
+
await selectPowerStepWithGrace(cdp, plan.sliderLabel);
|
|
2005
2241
|
}
|
|
2006
2242
|
catch (error) {
|
|
2007
2243
|
// Only "this slider has no such step" is the picker declining. A
|
|
2008
|
-
// slider that will not open, or will not move
|
|
2244
|
+
// slider that will not open, or will not move - the hydrating case,
|
|
2245
|
+
// which reads as "did not respond to arrow keys" - is a real
|
|
2246
|
+
// failure, and must not turn into a send at whatever step it was on.
|
|
2009
2247
|
const message = error instanceof Error ? error.message : String(error);
|
|
2010
2248
|
const noSuchStep = /has no "[^"]*" step/.test(message);
|
|
2011
2249
|
if (!noSuchStep)
|
|
@@ -2260,8 +2498,10 @@ async function selectProject(cdp, options) {
|
|
|
2260
2498
|
// prompt posted into the project the tab came from). A hard reload of the
|
|
2261
2499
|
// project home rebinds the composer to THIS project before we send.
|
|
2262
2500
|
const projectHome = await cdp.evaluate("location.href");
|
|
2263
|
-
|
|
2264
|
-
|
|
2501
|
+
// Polled with no delay, the first check could run before the reload had
|
|
2502
|
+
// committed, on the old document, whose composer and URL both still
|
|
2503
|
+
// matched. The stamp keeps that document from passing as the new one.
|
|
2504
|
+
const rebound = await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS, `location.href === ${JSON.stringify(projectHome)}`);
|
|
2265
2505
|
if (!rebound) {
|
|
2266
2506
|
throw new Error(`ChatGPT composer did not rebind after entering project "${options.project}"`);
|
|
2267
2507
|
}
|
|
@@ -2435,6 +2675,8 @@ async function findLandedConversation(page, prompt) {
|
|
|
2435
2675
|
export async function sendChatGptPrompt(options) {
|
|
2436
2676
|
const port = resolveCdpPort(options.port);
|
|
2437
2677
|
const timeoutMs = options.timeoutMs ?? 90_000;
|
|
2678
|
+
/** Dialogs answered on the reload connection, which comes and goes before the send's own, so the receipt still says so. */
|
|
2679
|
+
const earlyDialogsAnswered = [];
|
|
2438
2680
|
const sendStartedAt = Date.now();
|
|
2439
2681
|
const emitProgress = (phase, detail) => {
|
|
2440
2682
|
if (!options.onProgress)
|
|
@@ -2526,6 +2768,45 @@ export async function sendChatGptPrompt(options) {
|
|
|
2526
2768
|
busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
|
|
2527
2769
|
}
|
|
2528
2770
|
}
|
|
2771
|
+
// A thread can be left rendered with no composer at all - measured after a
|
|
2772
|
+
// send, zero contenteditables and zero textareas on the page - and every
|
|
2773
|
+
// retry then lands on the same dead page and reports the same "missing a
|
|
2774
|
+
// visible prompt composer". One reload brings it back, so spend it here
|
|
2775
|
+
// rather than handing the caller a blocker it cannot act on. Decided after
|
|
2776
|
+
// the busy check on purpose: a streaming thread hides its composer too, and
|
|
2777
|
+
// so does a dead page whose stop button never went away, so the raw
|
|
2778
|
+
// generating flag tells them apart in neither direction - the transcript
|
|
2779
|
+
// check above does.
|
|
2780
|
+
if (shouldReloadForMissingComposer({
|
|
2781
|
+
hasComposer: status.hasComposer,
|
|
2782
|
+
loggedInLikely: inferChatGptPageLoggedInLikely(status),
|
|
2783
|
+
hasBlocker: Boolean(detectChatGptPageBlocker(status)),
|
|
2784
|
+
busy: Boolean(busyBlocker),
|
|
2785
|
+
...(status.awaitingResponseChoice !== undefined ? { awaitingResponseChoice: status.awaitingResponseChoice } : {}),
|
|
2786
|
+
...(status.openDialogText !== undefined ? { openDialogText: status.openDialogText } : {})
|
|
2787
|
+
})) {
|
|
2788
|
+
try {
|
|
2789
|
+
const reloaded = await reloadPageAndAwaitComposer(page);
|
|
2790
|
+
earlyDialogsAnswered.push(...reloaded.dialogsAnswered);
|
|
2791
|
+
// Read into a local and adopt it only once every step has passed, so a
|
|
2792
|
+
// failure part way leaves the pre-reload status and verdict intact
|
|
2793
|
+
// rather than a fresh status with the checks after it skipped.
|
|
2794
|
+
let fresh = await readSettledChatGptPageStatus(page);
|
|
2795
|
+
fresh = await ensureVisibleChatGptPage(port, page, fresh);
|
|
2796
|
+
const blockerAfterReload = detectChatGptPageBlocker(fresh);
|
|
2797
|
+
if (blockerAfterReload)
|
|
2798
|
+
throw new ChatGptBrowserBlockerError(blockerAfterReload);
|
|
2799
|
+
busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(fresh), await readTranscriptCompletion(page, fresh.url));
|
|
2800
|
+
status = fresh;
|
|
2801
|
+
}
|
|
2802
|
+
catch (error) {
|
|
2803
|
+
// A blocker is the answer; a stalled tab is the send failing, and its
|
|
2804
|
+
// guidance is worth more than "missing composer" after a wait.
|
|
2805
|
+
if (error instanceof ChatGptBrowserBlockerError || cdpCommandTimedOut(error))
|
|
2806
|
+
throw error;
|
|
2807
|
+
// keep the original status; the assertions below still report it
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2529
2810
|
// A thread ChatGPT has parked on "which response do you prefer?" will not
|
|
2530
2811
|
// unpark on its own. Sends that navigate away - a fresh chat, a project home
|
|
2531
2812
|
// - never touch it; a send that means to continue THAT thread has to say so
|
|
@@ -2565,6 +2846,13 @@ export async function sendChatGptPrompt(options) {
|
|
|
2565
2846
|
let submitButtonFound = false;
|
|
2566
2847
|
let wantsDeepResearch = false;
|
|
2567
2848
|
const sendWarnings = [];
|
|
2849
|
+
// Anything the page put in front of prodex was answered on the caller's
|
|
2850
|
+
// behalf. The note is read at return time - there are several return paths -
|
|
2851
|
+
// so it is folded into the array rather than pushed from each of them.
|
|
2852
|
+
const withDialogNote = (warnings) => {
|
|
2853
|
+
const note = answeredDialogWarning([...earlyDialogsAnswered, ...cdp.dialogsAnswered]);
|
|
2854
|
+
return [...warnings, ...(note ? [note] : [])].filter((warning) => Boolean(warning));
|
|
2855
|
+
};
|
|
2568
2856
|
const temporaryNote = temporaryChatWarning(options.temporary);
|
|
2569
2857
|
if (temporaryNote)
|
|
2570
2858
|
sendWarnings.push(temporaryNote);
|
|
@@ -2594,6 +2882,16 @@ export async function sendChatGptPrompt(options) {
|
|
|
2594
2882
|
};
|
|
2595
2883
|
try {
|
|
2596
2884
|
await cdp.send("Runtime.enable");
|
|
2885
|
+
// Which surface is live decides which picker exists and which models answer,
|
|
2886
|
+
// so settle it before navigating anywhere: a project page opened under Work
|
|
2887
|
+
// keeps Work's composer, and switching afterwards does not move it.
|
|
2888
|
+
// Max and Ultra are rungs of Work's slider, so asking for one means staying
|
|
2889
|
+
// there; anything else belongs on Chat, whose top step is Pro.
|
|
2890
|
+
if (!effortNeedsWorkSurface(options.effort)) {
|
|
2891
|
+
const surfaceWarning = await ensureChatSurface(cdp);
|
|
2892
|
+
if (surfaceWarning)
|
|
2893
|
+
sendWarnings.push(surfaceWarning);
|
|
2894
|
+
}
|
|
2597
2895
|
await selectProject(cdp, options);
|
|
2598
2896
|
try {
|
|
2599
2897
|
await selectModelReasoning(cdp, options, sendWarnings);
|
|
@@ -2810,7 +3108,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
2810
3108
|
answer: transcript.answer,
|
|
2811
3109
|
modelHints: finalState?.modelHints ?? [],
|
|
2812
3110
|
...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
|
|
2813
|
-
warnings: [...sendWarnings, modelSelectionWarning(options.model, transcript.modelSlug || finalState?.modelSlug)].filter((warning) => Boolean(warning))
|
|
3111
|
+
warnings: withDialogNote([...sendWarnings, modelSelectionWarning(options.model, transcript.modelSlug || finalState?.modelSlug)]).filter((warning) => Boolean(warning))
|
|
2814
3112
|
};
|
|
2815
3113
|
};
|
|
2816
3114
|
// Deep research never reaches the DOM answer wait below: the report is
|
|
@@ -2849,7 +3147,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
2849
3147
|
answer: report,
|
|
2850
3148
|
modelHints: finalState?.modelHints ?? [],
|
|
2851
3149
|
...(finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
|
|
2852
|
-
warnings: sendWarnings
|
|
3150
|
+
warnings: withDialogNote(sendWarnings)
|
|
2853
3151
|
};
|
|
2854
3152
|
}
|
|
2855
3153
|
emitProgress("waiting", `deep research ${lastState.status || lastState.reason} (${formatDurationMs(Date.now() - started)})`);
|
|
@@ -2958,7 +3256,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
2958
3256
|
answer: completed.answer.trim(),
|
|
2959
3257
|
modelHints: completed.modelHints,
|
|
2960
3258
|
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
2961
|
-
warnings: [...sendWarnings, modelSelectionWarning(options.model, completed.modelSlug)].filter((warning) => Boolean(warning))
|
|
3259
|
+
warnings: withDialogNote([...sendWarnings, modelSelectionWarning(options.model, completed.modelSlug)]).filter((warning) => Boolean(warning))
|
|
2962
3260
|
};
|
|
2963
3261
|
}
|
|
2964
3262
|
// Timed out while the answer was still streaming: salvage the partial text
|
|
@@ -2972,11 +3270,11 @@ export async function sendChatGptPrompt(options) {
|
|
|
2972
3270
|
answer: completed.answer.trim(),
|
|
2973
3271
|
modelHints: completed.modelHints,
|
|
2974
3272
|
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
2975
|
-
warnings: [
|
|
3273
|
+
warnings: withDialogNote([
|
|
2976
3274
|
...sendWarnings,
|
|
2977
3275
|
...(modelSelectionWarning(options.model, completed.modelSlug) ? [modelSelectionWarning(options.model, completed.modelSlug)] : []),
|
|
2978
3276
|
`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
|
-
]
|
|
3277
|
+
])
|
|
2980
3278
|
};
|
|
2981
3279
|
}
|
|
2982
3280
|
// Carry the thread the prompt landed in: ChatGPT usually finishes the answer
|
|
@@ -3003,11 +3301,14 @@ export function modelMenuOptionsExpression() {
|
|
|
3003
3301
|
return `(() => {
|
|
3004
3302
|
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
3005
3303
|
if (!m) return [];
|
|
3006
|
-
//
|
|
3007
|
-
// slider's own rows - its track,
|
|
3008
|
-
// and
|
|
3009
|
-
//
|
|
3010
|
-
|
|
3304
|
+
// Radios are models, and so are the submenu rows an earlier picker kept them
|
|
3305
|
+
// behind. What is NOT a model is the power slider's own rows - its track,
|
|
3306
|
+
// and the pair naming the live model and effort - which are plain menuitems
|
|
3307
|
+
// with nothing to open, and which put GPT-6 Astra in the list twice and
|
|
3308
|
+
// Extra High in as if it were a model. Dropping every menuitem instead
|
|
3309
|
+
// emptied the listing on the submenu picker entirely.
|
|
3310
|
+
return [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')]
|
|
3311
|
+
.filter((it) => it.getAttribute("role") === "menuitemradio" || it.getAttribute("aria-haspopup") === "menu")
|
|
3011
3312
|
.map((it) => {
|
|
3012
3313
|
// A submenu row renders as label over value ("Model" / "GPT-5.6 Sol"),
|
|
3013
3314
|
// and the value is the part a person actually wants to read.
|
|
@@ -3501,6 +3802,13 @@ async function restorePowerSlider(cdp, target) {
|
|
|
3501
3802
|
export async function listChatGptModelOptions(input = {}) {
|
|
3502
3803
|
const port = resolveCdpPort(input.port);
|
|
3503
3804
|
const timeoutMs = input.timeoutMs ?? 15_000;
|
|
3805
|
+
// The steps before a connection is held - finding the page, settling its
|
|
3806
|
+
// status - are bounded by their own ceilings and cannot be cut short from
|
|
3807
|
+
// outside; the signal is honoured between them, and closes the connection
|
|
3808
|
+
// once there is one.
|
|
3809
|
+
const abandoned = () => new Error("Model listing abandoned: the caller stopped waiting for it");
|
|
3810
|
+
if (input.signal?.aborted)
|
|
3811
|
+
throw abandoned();
|
|
3504
3812
|
const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), undefined);
|
|
3505
3813
|
if (!pageResult.ok) {
|
|
3506
3814
|
throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
|
|
@@ -3518,7 +3826,17 @@ export async function listChatGptModelOptions(input = {}) {
|
|
|
3518
3826
|
throw new ChatGptBrowserBlockerError(blocker);
|
|
3519
3827
|
assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
|
|
3520
3828
|
assertVisibleChatGptTab(status.visibilityState, status.url, undefined);
|
|
3829
|
+
if (input.signal?.aborted)
|
|
3830
|
+
throw abandoned();
|
|
3521
3831
|
const cdp = await connectCdp(page.webSocketDebuggerUrl);
|
|
3832
|
+
// A caller that has stopped waiting (setup's deadline) closes the connection
|
|
3833
|
+
// here, which rejects whatever is in flight and lets the send lock go; left
|
|
3834
|
+
// alone, the listing ran on unattended holding both until its own timeouts.
|
|
3835
|
+
// The picker may be left open in that case; the next send copes with one.
|
|
3836
|
+
const abandon = () => cdp.close();
|
|
3837
|
+
if (input.signal?.aborted)
|
|
3838
|
+
abandon();
|
|
3839
|
+
input.signal?.addEventListener("abort", abandon, { once: true });
|
|
3522
3840
|
try {
|
|
3523
3841
|
await cdp.send("Runtime.enable");
|
|
3524
3842
|
const button = await cdp.evaluate(modelButtonRectExpression());
|
|
@@ -3531,14 +3849,20 @@ export async function listChatGptModelOptions(input = {}) {
|
|
|
3531
3849
|
if (!opened)
|
|
3532
3850
|
throw new Error("ChatGPT model menu did not open after clicking the selector");
|
|
3533
3851
|
const options = await cdp.evaluate(modelMenuOptionsExpression());
|
|
3534
|
-
|
|
3852
|
+
// Walking the ladder moves someone's slider and puts it back. That is
|
|
3853
|
+
// worth it when the ladder is what was asked for, and not worth it for a
|
|
3854
|
+
// caller that only needs the model names - `setup` was walking the live
|
|
3855
|
+
// slider as a side effect of saving a config file.
|
|
3856
|
+
const effortSteps = input.walkPowerSlider === false ? undefined : await readEffortSteps(cdp);
|
|
3535
3857
|
// Chat and Work have different pickers - Work's offers no Pro - and a
|
|
3536
3858
|
// listing that does not say which one it read is how a browser sitting on
|
|
3537
3859
|
// Work convinced two machines that Pro had been removed from the account.
|
|
3538
3860
|
let surface;
|
|
3539
3861
|
try {
|
|
3540
|
-
const probe = await cdp.evaluate(
|
|
3541
|
-
|
|
3862
|
+
const probe = await cdp.evaluate(chatSurfaceProbeExpression());
|
|
3863
|
+
// Threads and project pages draw no toggle; the persisted choice is
|
|
3864
|
+
// what the app itself reads to decide, so it answers there.
|
|
3865
|
+
surface = surfaceFromProbe(probe);
|
|
3542
3866
|
}
|
|
3543
3867
|
catch {
|
|
3544
3868
|
// the toggle predates some builds; saying nothing is right there
|
|
@@ -3557,9 +3881,42 @@ export async function listChatGptModelOptions(input = {}) {
|
|
|
3557
3881
|
}
|
|
3558
3882
|
}
|
|
3559
3883
|
finally {
|
|
3884
|
+
input.signal?.removeEventListener("abort", abandon);
|
|
3560
3885
|
cdp.close();
|
|
3561
3886
|
}
|
|
3562
3887
|
}
|
|
3888
|
+
/**
|
|
3889
|
+
* Whether anything is listening on the local port, decided at the TCP level.
|
|
3890
|
+
* Treat only "accepted" as proof: a closed port may time out rather than refuse.
|
|
3891
|
+
*/
|
|
3892
|
+
export function portAccepts(port, timeoutMs = 250) {
|
|
3893
|
+
return new Promise((resolve) => {
|
|
3894
|
+
const socket = net.createConnection({ host: "127.0.0.1", port });
|
|
3895
|
+
const done = (verdict) => {
|
|
3896
|
+
socket.destroy();
|
|
3897
|
+
resolve(verdict);
|
|
3898
|
+
};
|
|
3899
|
+
socket.setTimeout(timeoutMs, () => done("timeout"));
|
|
3900
|
+
socket.once("connect", () => done("accepted"));
|
|
3901
|
+
socket.once("error", (error) => done(error.code === "ECONNREFUSED" ? "refused" : "timeout"));
|
|
3902
|
+
});
|
|
3903
|
+
}
|
|
3904
|
+
/** True when a fetch failed because its AbortSignal.timeout fired, not because nothing was listening. */
|
|
3905
|
+
/** Whether an error is a DevTools command that got no answer in time - the one failure that has closed the socket. */
|
|
3906
|
+
export function cdpCommandTimedOut(error) {
|
|
3907
|
+
return error instanceof Error && /Chrome DevTools command timed out/.test(error.message);
|
|
3908
|
+
}
|
|
3909
|
+
export function fetchTimedOut(error) {
|
|
3910
|
+
return typeof error === "object" && error !== null && error.name === "TimeoutError";
|
|
3911
|
+
}
|
|
3912
|
+
/**
|
|
3913
|
+
* Whether a status probe is evidence that the browser is DEAD, as opposed to
|
|
3914
|
+
* merely slow. Only a refused connection counts; a timeout is a busy browser,
|
|
3915
|
+
* and ending a busy browser takes the consult it is writing with it.
|
|
3916
|
+
*/
|
|
3917
|
+
export function statusMeansBrowserDead(status) {
|
|
3918
|
+
return !status.reachable && status.blocker?.code !== "browser_slow";
|
|
3919
|
+
}
|
|
3563
3920
|
async function findChatGptPage(port, timeoutMs, targetUrl) {
|
|
3564
3921
|
try {
|
|
3565
3922
|
const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
@@ -3573,6 +3930,28 @@ async function findChatGptPage(port, timeoutMs, targetUrl) {
|
|
|
3573
3930
|
return { ok: true, page: selectChatGptPage(pages, targetUrl, visibilityByPage) };
|
|
3574
3931
|
}
|
|
3575
3932
|
catch (error) {
|
|
3933
|
+
// A port that answers too slowly is a browser that is BUSY, not one that is
|
|
3934
|
+
// gone, and the two must not share a verdict: the silence check that
|
|
3935
|
+
// decides whether to end a browser counted three slow answers on a loaded
|
|
3936
|
+
// machine as three dead ones - the exact failure the comment above
|
|
3937
|
+
// confirmBrowserSilence was written to prevent.
|
|
3938
|
+
// A timeout on its own is not proof of life: with a small budget the
|
|
3939
|
+
// timer fires before the connection reports anything, and a port nothing
|
|
3940
|
+
// listens on would be called "busy". A raw TCP connect settles it, and
|
|
3941
|
+
// only an ACCEPT counts - measured on WSL2, a closed loopback port does not
|
|
3942
|
+
// refuse at all, it times out (1.5s), while a listening one accepts in 1ms.
|
|
3943
|
+
if (fetchTimedOut(error) && (await portAccepts(port)) === "accepted") {
|
|
3944
|
+
return {
|
|
3945
|
+
ok: false,
|
|
3946
|
+
blocker: {
|
|
3947
|
+
code: "browser_slow",
|
|
3948
|
+
message: `The Chrome DevTools endpoint on 127.0.0.1:${port} did not answer within ${timeoutMs}ms. The browser is running but busy.`,
|
|
3949
|
+
retryable: true,
|
|
3950
|
+
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.",
|
|
3951
|
+
...(error instanceof Error ? { detail: error.message } : {})
|
|
3952
|
+
}
|
|
3953
|
+
};
|
|
3954
|
+
}
|
|
3576
3955
|
return {
|
|
3577
3956
|
ok: false,
|
|
3578
3957
|
blocker: {
|
|
@@ -3642,6 +4021,8 @@ async function connectCdp(webSocketUrl, timeoutMs) {
|
|
|
3642
4021
|
const ws = new WebSocket(webSocketUrl);
|
|
3643
4022
|
let id = 0;
|
|
3644
4023
|
const pending = new Map();
|
|
4024
|
+
/** Types of JavaScript dialog answered on this connection. */
|
|
4025
|
+
const dialogsAnswered = [];
|
|
3645
4026
|
ws.addEventListener("message", (event) => {
|
|
3646
4027
|
let message;
|
|
3647
4028
|
try {
|
|
@@ -3660,6 +4041,19 @@ async function connectCdp(webSocketUrl, timeoutMs) {
|
|
|
3660
4041
|
clearTimeout(waiter.timer);
|
|
3661
4042
|
waiter.resolve(message);
|
|
3662
4043
|
pending.delete(message.id);
|
|
4044
|
+
return;
|
|
4045
|
+
}
|
|
4046
|
+
// A JavaScript dialog halts the page's main thread: every evaluate after it
|
|
4047
|
+
// waits forever, and Page.enable stops answering too, so a client that had
|
|
4048
|
+
// not enabled the domain BEFORE the dialog appeared cannot dismiss it - the
|
|
4049
|
+
// browser answers "no dialog is showing". Measured live, that is the whole
|
|
4050
|
+
// of the "Chrome DevTools command timed out" wedge. Answering it here is
|
|
4051
|
+
// the only cure, and it has to be armed in advance.
|
|
4052
|
+
if (message.method === "Page.javascriptDialogOpening") {
|
|
4053
|
+
const type = message.params?.type;
|
|
4054
|
+
const answer = javascriptDialogResponse(type);
|
|
4055
|
+
dialogsAnswered.push(type ?? "unknown");
|
|
4056
|
+
ws.send(JSON.stringify({ id: ++id, method: "Page.handleJavaScriptDialog", params: answer }));
|
|
3663
4057
|
}
|
|
3664
4058
|
});
|
|
3665
4059
|
ws.addEventListener("close", () => {
|
|
@@ -3702,18 +4096,42 @@ async function connectCdp(webSocketUrl, timeoutMs) {
|
|
|
3702
4096
|
reject(new Error("Chrome DevTools websocket closed during connect"));
|
|
3703
4097
|
}, { once: true });
|
|
3704
4098
|
});
|
|
4099
|
+
/** The timeout that closed this socket, so later commands can name it rather than a bare closed socket. */
|
|
4100
|
+
let closedByTimeout;
|
|
3705
4101
|
const send = (method, params = {}) => {
|
|
4102
|
+
// Once a timeout has closed the socket, every later command would sit out
|
|
4103
|
+
// its own full timeout for an answer that cannot come. Measured cost of not
|
|
4104
|
+
// checking: a 14-probe confirmation loop turning into minutes of silence.
|
|
4105
|
+
// The rejection carries the timeout that did the closing: a caller that
|
|
4106
|
+
// swallowed that first error and moved on would otherwise report "socket
|
|
4107
|
+
// not open", which names neither the stalled tab nor the dialog cure.
|
|
4108
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
4109
|
+
return Promise.reject(new Error(closedByTimeout
|
|
4110
|
+
? `${closedByTimeout} (the connection was closed by that timeout before ${method})`
|
|
4111
|
+
: `Chrome DevTools websocket is not open (${method})`));
|
|
4112
|
+
}
|
|
3706
4113
|
const messageId = ++id;
|
|
3707
4114
|
return new Promise((resolve, reject) => {
|
|
3708
4115
|
const timer = setTimeout(() => {
|
|
3709
4116
|
pending.delete(messageId);
|
|
4117
|
+
closedByTimeout = `Chrome DevTools command timed out: ${method}`;
|
|
3710
4118
|
ws.close();
|
|
3711
|
-
reject(new Error(
|
|
4119
|
+
reject(new Error(closedByTimeout));
|
|
3712
4120
|
}, Math.max(1, effectiveTimeoutMs));
|
|
3713
4121
|
pending.set(messageId, { resolve, reject, timer });
|
|
3714
4122
|
ws.send(JSON.stringify({ id: messageId, method, params }));
|
|
3715
4123
|
});
|
|
3716
4124
|
};
|
|
4125
|
+
// Armed before anything else is asked of the page, because after a dialog
|
|
4126
|
+
// opens it is too late.
|
|
4127
|
+
//
|
|
4128
|
+
// Fire and forget rather than awaited: on a page that is ALREADY wedged,
|
|
4129
|
+
// Page.enable never answers either, and the ordinary send path closes the
|
|
4130
|
+
// socket when a command times out. Awaiting it there would have turned the
|
|
4131
|
+
// caller's diagnosis from "command timed out: Runtime.enable" - which at
|
|
4132
|
+
// least names what was being attempted - into a bare "websocket closed".
|
|
4133
|
+
// Nothing waits on the arming, so a build without the domain is no worse off.
|
|
4134
|
+
ws.send(JSON.stringify({ id: ++id, method: "Page.enable", params: {} }));
|
|
3717
4135
|
const evaluate = async (expression) => {
|
|
3718
4136
|
const response = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
|
|
3719
4137
|
if (response.error?.message)
|
|
@@ -3722,7 +4140,7 @@ async function connectCdp(webSocketUrl, timeoutMs) {
|
|
|
3722
4140
|
throw new Error("Runtime.evaluate failed");
|
|
3723
4141
|
return response.result?.result?.value;
|
|
3724
4142
|
};
|
|
3725
|
-
return { send, evaluate, close: () => ws.close() };
|
|
4143
|
+
return { send, evaluate, dialogsAnswered, close: () => ws.close() };
|
|
3726
4144
|
}
|
|
3727
4145
|
// In-page reasoning-header placeholder test, shared by statusExpression and
|
|
3728
4146
|
// answerExpression. MUST stay in sync with isUsableChatGptAnswer: a header
|