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