@youdie006/prodex 0.40.1 → 0.40.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/dist/chatgpt-browser.js +316 -47
- package/dist/cli-pro.js +12 -0
- package/package.json +1 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -1056,8 +1056,29 @@ decidedBusyBlocker, busyVerdictDecided = false) {
|
|
|
1056
1056
|
const busyBlocker = busyVerdictDecided ? decidedBusyBlocker : chatGptBusyBlocker(status);
|
|
1057
1057
|
if (busyBlocker)
|
|
1058
1058
|
throw new ChatGptBrowserBlockerError(busyBlocker);
|
|
1059
|
+
// ChatGPT's own error page carries no composer and none of the logged-in
|
|
1060
|
+
// furniture the login check reads, so the assert below calls a perfectly
|
|
1061
|
+
// good session logged out and sends the person looking for a login screen
|
|
1062
|
+
// that is not there. Measured live: a project home that failed to load left
|
|
1063
|
+
// the tab on that page, and the next send - the retry the project blocker
|
|
1064
|
+
// asks for - reported "missing a clear logged-in ChatGPT session".
|
|
1065
|
+
if (looksLikeChatGptErrorPage({ bodyText: status.textSample, hasComposer: status.hasComposer })) {
|
|
1066
|
+
throw new ChatGptBrowserBlockerError(chatGptErrorPageBlocker());
|
|
1067
|
+
}
|
|
1059
1068
|
assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
|
|
1060
1069
|
}
|
|
1070
|
+
/** The tab is on ChatGPT's error page: the page failed to load, and says nothing about the session. */
|
|
1071
|
+
export function chatGptErrorPageBlocker() {
|
|
1072
|
+
return {
|
|
1073
|
+
code: "chatgpt_error_page",
|
|
1074
|
+
message: "ChatGPT browser is reachable, but the tab is on ChatGPT's own error page, which has no prompt composer.",
|
|
1075
|
+
retryable: true,
|
|
1076
|
+
// Not "the session is fine": this page carries none of the furniture that
|
|
1077
|
+
// would show it either way. Not "reload it" either - measured, a project
|
|
1078
|
+
// home reloads straight back into this page, while the site root loads.
|
|
1079
|
+
next_step: "The page failed to load, which says nothing about the session. Open a normal chat in the visible browser, then retry."
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1061
1082
|
/**
|
|
1062
1083
|
* Whether a page with no composer is worth one reload before giving up.
|
|
1063
1084
|
*
|
|
@@ -1302,6 +1323,36 @@ async function dispatchEscapeKey(cdp) {
|
|
|
1302
1323
|
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
1303
1324
|
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
1304
1325
|
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Open a fresh ChatGPT root document, and prove that is where the tab landed.
|
|
1328
|
+
*
|
|
1329
|
+
* The recovery for a composer bound to the wrong project: only a new document
|
|
1330
|
+
* sheds a stale binding, and the site root loads where a project home does
|
|
1331
|
+
* not. Checked rather than best-effort, because the caller clicks a sidebar
|
|
1332
|
+
* row on whatever page this leaves behind - and clicking it on the OLD
|
|
1333
|
+
* document walks straight back into the binding it is trying to shed. The
|
|
1334
|
+
* stamp is what proves the old document is gone; the URL alone can be read off
|
|
1335
|
+
* the very page we are trying to leave.
|
|
1336
|
+
*/
|
|
1337
|
+
async function openFreshChatGptHome(cdp) {
|
|
1338
|
+
await cdp.evaluate(markDocumentForReloadExpression());
|
|
1339
|
+
await cdp.evaluate(`location.assign("https://chatgpt.com/")`);
|
|
1340
|
+
const deadline = Date.now() + RELOAD_SETTLE_TIMEOUT_MS;
|
|
1341
|
+
while (Date.now() < deadline) {
|
|
1342
|
+
await sleep(250);
|
|
1343
|
+
try {
|
|
1344
|
+
if (await cdp.evaluate(freshChatGptHomeReadyExpression()))
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
catch (error) {
|
|
1348
|
+
// The execution context is gone between documents; the next poll lands
|
|
1349
|
+
// on the new one. A command timeout is different: it closed the socket.
|
|
1350
|
+
if (cdpCommandTimedOut(error))
|
|
1351
|
+
throw error;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
throw new Error("ChatGPT did not open a fresh home page with a composer");
|
|
1355
|
+
}
|
|
1305
1356
|
// Poll a boolean page expression instead of sleeping a fixed duration, so slow
|
|
1306
1357
|
// renders wait longer and fast ones do not waste time.
|
|
1307
1358
|
async function waitForExpressionTrue(cdp, expression, timeoutMs, intervalMs = 150) {
|
|
@@ -1555,6 +1606,20 @@ export function reloadedDocumentReadyExpression(extraCondition = "true") {
|
|
|
1555
1606
|
return Boolean(${extraCondition});
|
|
1556
1607
|
})()`;
|
|
1557
1608
|
}
|
|
1609
|
+
/**
|
|
1610
|
+
* True only on a NEWLY loaded chatgpt.com root that has rendered a composer.
|
|
1611
|
+
*
|
|
1612
|
+
* Every clause answers a way the old check could pass on the page we are
|
|
1613
|
+
* trying to leave: the stamp proves the document is not the one that asked
|
|
1614
|
+
* for the navigation, the route proves it is the root rather than the project
|
|
1615
|
+
* home that fails to load, and the composer candidate is the real editor
|
|
1616
|
+
* rather than the broad selector that also matches a hidden fallback.
|
|
1617
|
+
*/
|
|
1618
|
+
export function freshChatGptHomeReadyExpression() {
|
|
1619
|
+
return reloadedDocumentReadyExpression(`/^https:\\/\\/chatgpt\\.com\\/?(?:[?#].*)?$/.test(location.href) && (() => {${composerExpressionHelpers()}
|
|
1620
|
+
return Boolean(findChatGptComposerCandidate());
|
|
1621
|
+
})()`);
|
|
1622
|
+
}
|
|
1558
1623
|
/** Polled reloads return as soon as the new document has its composer; this only bounds a page that never gets there. */
|
|
1559
1624
|
const RELOAD_SETTLE_TIMEOUT_MS = 12_000;
|
|
1560
1625
|
/**
|
|
@@ -1602,17 +1667,75 @@ async function reloadPageAndAwaitComposer(page) {
|
|
|
1602
1667
|
cdp.close();
|
|
1603
1668
|
}
|
|
1604
1669
|
}
|
|
1670
|
+
// Every alternative is wording measured on the error page itself, anchored so
|
|
1671
|
+
// the whole body has to be made of them and nothing else. It may repeat one:
|
|
1672
|
+
// the heading and the button carry the same words.
|
|
1673
|
+
const CHATGPT_ERROR_PAGE_BODY = /^(?:(?:something went wrong|please try again later|please try again|try again|다시\s*시도)[.!]?(?:\s+|$))+$/i;
|
|
1605
1674
|
/**
|
|
1606
|
-
*
|
|
1675
|
+
* ChatGPT's error page: a document whose whole body is a retry affordance.
|
|
1676
|
+
*
|
|
1677
|
+
* Measured live - a direct load of a project home came back with a body of
|
|
1678
|
+
* exactly "Try again", one button, and no composer, while the same route
|
|
1679
|
+
* reached by clicking the sidebar worked and the rest of ChatGPT loaded fine.
|
|
1680
|
+
* Reporting that as a missing composer describes a symptom and names nothing
|
|
1681
|
+
* to do about it.
|
|
1607
1682
|
*
|
|
1608
|
-
* The
|
|
1609
|
-
*
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1683
|
+
* The body has to BE the error, not merely contain a retry word somewhere: a
|
|
1684
|
+
* positive now navigates the visible tab away, so "Retry settings" and a
|
|
1685
|
+
* transient "Retrying..." must not qualify. Wording decides, not length - the
|
|
1686
|
+
* old 40-character ceiling turned down "Something went wrong. Please try again
|
|
1687
|
+
* later." for being one sentence too long. An error page prodex does not
|
|
1688
|
+
* recognise stays a missing composer: a worse message, not a wrong action.
|
|
1613
1689
|
*/
|
|
1614
|
-
export function
|
|
1615
|
-
|
|
1690
|
+
export function looksLikeChatGptErrorPage(input) {
|
|
1691
|
+
if (input.hasComposer)
|
|
1692
|
+
return false;
|
|
1693
|
+
// The DOM read leaves the heading and the button separated by whitespace.
|
|
1694
|
+
const text = input.bodyText.replace(/\s+/g, " ").trim();
|
|
1695
|
+
if (!text)
|
|
1696
|
+
return false;
|
|
1697
|
+
// A backstop for the alternation below rather than a classifier of its own:
|
|
1698
|
+
// the longest body it can accept is well under this, so anything longer is a
|
|
1699
|
+
// page with content and is not worth matching against.
|
|
1700
|
+
if (text.length > 200)
|
|
1701
|
+
return false;
|
|
1702
|
+
return CHATGPT_ERROR_PAGE_BODY.test(text);
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Which project the composer will post into, read from its own placeholder.
|
|
1706
|
+
*
|
|
1707
|
+
* ChatGPT labels a project composer with the project it belongs to ("New chat
|
|
1708
|
+
* in <name>"), and measured live that label follows a sidebar navigation from
|
|
1709
|
+
* one project to another and back. That makes it the cheap answer to the
|
|
1710
|
+
* question the old hard reload tried to force: is this composer the project's,
|
|
1711
|
+
* or the one the tab arrived with.
|
|
1712
|
+
*
|
|
1713
|
+
* Only a recognised phrasing decides anything. A placeholder that merely
|
|
1714
|
+
* CONTAINS the name is not evidence - "New chat in Notes Archive" contains
|
|
1715
|
+
* "Notes" - and neither is an unrecognised one, which is why a locale this
|
|
1716
|
+
* cannot read comes back "unknown" rather than "bound". What "unknown" is
|
|
1717
|
+
* worth is the caller's to decide, not this function's.
|
|
1718
|
+
*/
|
|
1719
|
+
export function composerProjectBinding(input) {
|
|
1720
|
+
const placeholder = (input.placeholder ?? "").trim();
|
|
1721
|
+
if (!placeholder)
|
|
1722
|
+
return "unknown";
|
|
1723
|
+
const wanted = input.projectName.trim().toLowerCase();
|
|
1724
|
+
if (!wanted)
|
|
1725
|
+
return "unknown";
|
|
1726
|
+
// Equality where the phrasing is known, because sidebar rows are matched by
|
|
1727
|
+
// exact name: "Notes" and "Notes Archive" are two projects, and a composer
|
|
1728
|
+
// belonging to one must not pass for the other. The spacing of the template
|
|
1729
|
+
// is loose because that is the page's to choose; the NAME is compared as it
|
|
1730
|
+
// is, since two projects may differ by exactly the spacing in it.
|
|
1731
|
+
const named = /^new\s+chat\s+in\s+(.+)$/i.exec(placeholder)?.[1] ?? /^(.+?)\uc5d0\uc11c\s*\uc0c8\s*\ucc44\ud305$/.exec(placeholder)?.[1];
|
|
1732
|
+
if (named)
|
|
1733
|
+
return named.trim().toLowerCase() === wanted ? "bound" : "elsewhere";
|
|
1734
|
+
// The placeholder a plain new chat carries: recognised, and it names no
|
|
1735
|
+
// project, so the composer belongs to none.
|
|
1736
|
+
if (/^ask\s+chatgpt$/i.test(placeholder))
|
|
1737
|
+
return "elsewhere";
|
|
1738
|
+
return "unknown";
|
|
1616
1739
|
}
|
|
1617
1740
|
export function powerSliderPresentExpression() {
|
|
1618
1741
|
return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"]'))`;
|
|
@@ -2477,13 +2600,52 @@ async function createChatGptProject(cdp, name) {
|
|
|
2477
2600
|
throw error;
|
|
2478
2601
|
}
|
|
2479
2602
|
}
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2603
|
+
/** The placeholder of the composer the send will actually type into. */
|
|
2604
|
+
function composerProjectBindingExpression() {
|
|
2605
|
+
return `(() => {${composerExpressionHelpers()}
|
|
2606
|
+
const node = findChatGptComposerCandidate();
|
|
2607
|
+
if (!node) return { found: false };
|
|
2608
|
+
return { found: true, placeholder: node.getAttribute("data-placeholder") || node.getAttribute("placeholder") || "" };
|
|
2609
|
+
})()`;
|
|
2610
|
+
}
|
|
2611
|
+
/**
|
|
2612
|
+
* Wait for the composer to belong to the project we just entered.
|
|
2613
|
+
*
|
|
2614
|
+
* The binding arrives with the project page rather than with the URL, so this
|
|
2615
|
+
* polls rather than reading once. A composer that never appears, or one whose
|
|
2616
|
+
* placeholder cannot be read, comes back "unknown" - which the caller treats
|
|
2617
|
+
* as a failure, so this must not report it lightly.
|
|
2618
|
+
*/
|
|
2619
|
+
async function waitForComposerProjectBinding(cdp, project, timeoutMs) {
|
|
2620
|
+
const deadline = Date.now() + timeoutMs;
|
|
2621
|
+
let verdict = "unknown";
|
|
2622
|
+
for (;;) {
|
|
2623
|
+
const read = await cdp
|
|
2624
|
+
.evaluate(composerProjectBindingExpression())
|
|
2625
|
+
.catch(() => ({ found: false }));
|
|
2626
|
+
if (read.found) {
|
|
2627
|
+
const sample = composerProjectBinding({
|
|
2628
|
+
...(read.placeholder !== undefined ? { placeholder: read.placeholder } : {}),
|
|
2629
|
+
projectName: project
|
|
2630
|
+
});
|
|
2631
|
+
if (sample === "bound")
|
|
2632
|
+
return sample;
|
|
2633
|
+
// Keep the worse reading. A composer seen belonging somewhere else stays
|
|
2634
|
+
// evidence of that even if the next sample lands mid-render with no
|
|
2635
|
+
// placeholder to read - overwriting it turned a known wrong destination
|
|
2636
|
+
// into an unknown one, which reads like the softer failure it is not.
|
|
2637
|
+
if (sample === "elsewhere" || verdict === "unknown")
|
|
2638
|
+
verdict = sample;
|
|
2639
|
+
}
|
|
2640
|
+
if (Date.now() >= deadline)
|
|
2641
|
+
return verdict;
|
|
2642
|
+
await sleep(250);
|
|
2484
2643
|
}
|
|
2485
|
-
|
|
2486
|
-
|
|
2644
|
+
}
|
|
2645
|
+
// Enter an EXISTING project by clicking its sidebar row. Leaves the tab on
|
|
2646
|
+
// that project's page; whether the composer came with it is the caller's
|
|
2647
|
+
// question, not this one's.
|
|
2648
|
+
async function navigateToExistingProject(cdp, project) {
|
|
2487
2649
|
const hrefBefore = await cdp.evaluate("location.href");
|
|
2488
2650
|
// Poll for the project row instead of a single check: right after a
|
|
2489
2651
|
// --new-chat navigation the sidebar's Projects section has not hydrated yet
|
|
@@ -2493,7 +2655,7 @@ async function selectProject(cdp, options) {
|
|
|
2493
2655
|
let hit = { ok: false };
|
|
2494
2656
|
const projectDeadline = Date.now() + 6_000;
|
|
2495
2657
|
for (;;) {
|
|
2496
|
-
hit = await cdp.evaluate(projectItemRectExpression(
|
|
2658
|
+
hit = await cdp.evaluate(projectItemRectExpression(project));
|
|
2497
2659
|
if (hit.ok && hit.x !== undefined && hit.y !== undefined)
|
|
2498
2660
|
break;
|
|
2499
2661
|
if (Date.now() >= projectDeadline)
|
|
@@ -2502,9 +2664,9 @@ async function selectProject(cdp, options) {
|
|
|
2502
2664
|
}
|
|
2503
2665
|
if (!hit.ok || hit.x === undefined || hit.y === undefined) {
|
|
2504
2666
|
const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
|
|
2505
|
-
throw new Error(`ChatGPT project not found in sidebar: ${
|
|
2667
|
+
throw new Error(`ChatGPT project not found in sidebar: ${project}${detail} List the visible names with \`prodex pro browser projects\`.`);
|
|
2506
2668
|
}
|
|
2507
|
-
await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(
|
|
2669
|
+
await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(project)), `project ${project}`);
|
|
2508
2670
|
const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2509
2671
|
if (!navigated) {
|
|
2510
2672
|
// The href staying put is fine ONLY when the tab was already on THIS
|
|
@@ -2514,7 +2676,7 @@ async function selectProject(cdp, options) {
|
|
|
2514
2676
|
// project - which would silently send the prompt into the wrong project.
|
|
2515
2677
|
const alreadyInRequestedProject = await cdp.evaluate(`(() => {
|
|
2516
2678
|
if (!/^https:\\/\\/chatgpt\\.com\\/g\\/g-p-/.test(location.href)) return false;
|
|
2517
|
-
const name = ${JSON.stringify(
|
|
2679
|
+
const name = ${JSON.stringify(project)}.toLowerCase();
|
|
2518
2680
|
// Case-insensitive EQUALITY (not substring): matches the case-insensitive
|
|
2519
2681
|
// sidebar-row lookup (so "codex" is accepted while sitting on "Codex"),
|
|
2520
2682
|
// but a stalled cross-project navigation must NOT be accepted just because
|
|
@@ -2525,41 +2687,98 @@ async function selectProject(cdp, options) {
|
|
|
2525
2687
|
return [...document.querySelectorAll('h1,[role="heading"]')].some((h) => (h.innerText || "").trim().toLowerCase() === name);
|
|
2526
2688
|
})()`);
|
|
2527
2689
|
if (!alreadyInRequestedProject) {
|
|
2528
|
-
throw new Error(`Clicking project "${
|
|
2690
|
+
throw new Error(`Clicking project "${project}" did not navigate the visible tab. If the tab is already inside this project, omit --project and retry.`);
|
|
2529
2691
|
}
|
|
2530
2692
|
}
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2693
|
+
}
|
|
2694
|
+
/**
|
|
2695
|
+
* The project name the composer has to agree with before anything is typed,
|
|
2696
|
+
* or undefined for a send that pins no project.
|
|
2697
|
+
*
|
|
2698
|
+
* A project the send just created is exactly as able to post into the wrong
|
|
2699
|
+
* place as one it navigated to - the create flow leaves the tab on a project
|
|
2700
|
+
* home like any other - so both answer here, and the binding gate reads this
|
|
2701
|
+
* rather than the pinned name alone.
|
|
2702
|
+
*/
|
|
2703
|
+
export function composerBindingTarget(options) {
|
|
2704
|
+
return options.projectNew ?? options.project;
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Put the tab in the project this send is for, and refuse unless the composer
|
|
2708
|
+
* agrees that is where it posts.
|
|
2709
|
+
*
|
|
2710
|
+
* Both ways in share the gate. A project prodex just created is no safer than
|
|
2711
|
+
* one it navigated to: the create flow waits for A composer, and the composer
|
|
2712
|
+
* that answers can still be the one the tab arrived with - the same silent
|
|
2713
|
+
* wrong-project send, with the new project's name in the receipt.
|
|
2714
|
+
*/
|
|
2715
|
+
async function selectProject(cdp, options) {
|
|
2716
|
+
const wanted = composerBindingTarget(options);
|
|
2717
|
+
if (!wanted)
|
|
2718
|
+
return;
|
|
2719
|
+
if (options.projectNew)
|
|
2720
|
+
await createChatGptProject(cdp, options.projectNew);
|
|
2721
|
+
else
|
|
2722
|
+
await navigateToExistingProject(cdp, options.project);
|
|
2723
|
+
// A sidebar SPA navigation moves the URL to the target project while the
|
|
2724
|
+
// composer can stay bound to the PREVIOUS project's conversation target, so
|
|
2725
|
+
// the send silently creates the thread in the OLD project (reproduced live
|
|
2726
|
+
// via PRODEX_DEBUG_SEND: baseline URL on the requested project, yet the
|
|
2727
|
+
// prompt posted into the project the tab came from).
|
|
2728
|
+
//
|
|
2729
|
+
// Hard-reloading the project home used to rebind it, and that stopped
|
|
2730
|
+
// working: measured on two different projects, EVERY hard load of a project
|
|
2731
|
+
// home - Page.reload and location.assign alike - comes back as ChatGPT's
|
|
2732
|
+
// error page with no composer, while the sidebar navigation that got us
|
|
2733
|
+
// here renders in under two seconds. So read the binding instead of forcing
|
|
2734
|
+
// it - the composer says which project it posts into.
|
|
2735
|
+
//
|
|
2736
|
+
// Read on every path in. A URL that never moved is not proof about the
|
|
2737
|
+
// composer either: the route and the title can already be this project's
|
|
2738
|
+
// while the composer still belongs to the thread the tab was left on, and
|
|
2739
|
+
// that path used to skip this check entirely - as did creating a project,
|
|
2740
|
+
// which reached the send with nothing checked at all.
|
|
2741
|
+
let binding = await waitForComposerProjectBinding(cdp, wanted, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2742
|
+
let recoveryNote = "";
|
|
2743
|
+
if (binding !== "bound") {
|
|
2744
|
+
// The one recovery that cannot inherit a stale binding, and the only one
|
|
2745
|
+
// still available: a fresh document - the site root loads fine, unlike a
|
|
2746
|
+
// project home - and then the same sidebar navigation over again.
|
|
2747
|
+
try {
|
|
2748
|
+
await openFreshChatGptHome(cdp);
|
|
2749
|
+
await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(wanted)), `project ${wanted}`);
|
|
2750
|
+
// The click's navigation has to land before the placeholder means
|
|
2751
|
+
// anything; read on the page we came from, it answers about the wrong
|
|
2752
|
+
// document.
|
|
2753
|
+
const entered = await waitForExpressionTrue(cdp, `/\\/g\\/g-p-/.test(location.href)`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2754
|
+
if (!entered)
|
|
2755
|
+
throw new Error("the sidebar click did not reach a project page");
|
|
2756
|
+
binding = await waitForComposerProjectBinding(cdp, wanted, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2757
|
+
}
|
|
2758
|
+
catch (recoveryError) {
|
|
2759
|
+
// Dropping why the recovery failed would leave the refusal below saying
|
|
2760
|
+
// only that the binding is still wrong, which is the less useful half.
|
|
2761
|
+
recoveryNote = ` Recovery failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`;
|
|
2558
2762
|
}
|
|
2559
2763
|
}
|
|
2764
|
+
if (binding !== "bound") {
|
|
2765
|
+
// Refuse on "unknown" as well as "elsewhere". A placeholder prodex cannot
|
|
2766
|
+
// read is not evidence that the composer is this project's, and what it
|
|
2767
|
+
// guards against - a prompt posted into another project, recorded under
|
|
2768
|
+
// the requested one - costs far more than a send the caller can retry.
|
|
2769
|
+
//
|
|
2770
|
+
// Naming the project it offered instead would put ANOTHER project's name
|
|
2771
|
+
// in a persisted receipt, which redaction only covers for the requested
|
|
2772
|
+
// one, so say what happened and leave that name out.
|
|
2773
|
+
const detail = binding === "elsewhere"
|
|
2774
|
+
? "the composer still offers a chat that belongs somewhere else"
|
|
2775
|
+
: "the composer's placeholder could not be read, so where the prompt would land is unknown";
|
|
2776
|
+
throw new Error(`ChatGPT composer did not bind to project "${wanted}": after entering it, ${detail}, ` +
|
|
2777
|
+
`so nothing was sent.${recoveryNote}`);
|
|
2778
|
+
}
|
|
2560
2779
|
const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2561
2780
|
if (!composerReady) {
|
|
2562
|
-
throw new Error(`ChatGPT composer did not appear after entering project "${
|
|
2781
|
+
throw new Error(`ChatGPT composer did not appear after entering project "${wanted}"`);
|
|
2563
2782
|
}
|
|
2564
2783
|
}
|
|
2565
2784
|
// Read the finished answer from an existing ChatGPT thread WITHOUT sending a new
|
|
@@ -2819,6 +3038,40 @@ export async function sendChatGptPrompt(options) {
|
|
|
2819
3038
|
busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
|
|
2820
3039
|
}
|
|
2821
3040
|
}
|
|
3041
|
+
// ChatGPT's error page does not come back on a reload - measured: a project
|
|
3042
|
+
// home that failed reloaded straight back into it - and the tab then stays
|
|
3043
|
+
// there for every later send, including the retry its own blocker asks for.
|
|
3044
|
+
// Going home is the recovery that works, and an unpinned send has nothing to
|
|
3045
|
+
// lose by leaving an error page.
|
|
3046
|
+
if (looksLikeChatGptErrorPage({ bodyText: status.textSample, hasComposer: status.hasComposer })) {
|
|
3047
|
+
// A send pinned to a thread is not one of those. Navigating to the root
|
|
3048
|
+
// would trade the page that explains the failure for a target mismatch
|
|
3049
|
+
// that does not, and leave the pinned tab somewhere it was not asked to
|
|
3050
|
+
// go, so report the error page and keep the tab where it is.
|
|
3051
|
+
if (normalizedTargetUrl)
|
|
3052
|
+
throw new ChatGptBrowserBlockerError(chatGptErrorPageBlocker());
|
|
3053
|
+
emitProgress("waiting", "tab on ChatGPT's error page; opening a working page");
|
|
3054
|
+
try {
|
|
3055
|
+
await evaluateOnPage(page, `location.assign("https://chatgpt.com/")`);
|
|
3056
|
+
await waitForFreshChatGptPage(page, RELOAD_SETTLE_TIMEOUT_MS);
|
|
3057
|
+
let fresh = await readSettledChatGptPageStatus(page);
|
|
3058
|
+
fresh = await ensureVisibleChatGptPage(port, page, fresh);
|
|
3059
|
+
const blockerAfterHome = detectChatGptPageBlocker(fresh);
|
|
3060
|
+
if (blockerAfterHome)
|
|
3061
|
+
throw new ChatGptBrowserBlockerError(blockerAfterHome);
|
|
3062
|
+
// The busy verdict above was decided about the page we just left, and it
|
|
3063
|
+
// is handed to the readiness assert as already decided. Carrying it over
|
|
3064
|
+
// would let a root page that is generating an answer be typed into.
|
|
3065
|
+
busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(fresh), await readTranscriptCompletion(page, fresh.url));
|
|
3066
|
+
status = fresh;
|
|
3067
|
+
}
|
|
3068
|
+
catch (error) {
|
|
3069
|
+
// A blocker is the answer; anything else leaves the original status, and
|
|
3070
|
+
// the readiness assert below reports the error page it still sees.
|
|
3071
|
+
if (error instanceof ChatGptBrowserBlockerError || cdpCommandTimedOut(error))
|
|
3072
|
+
throw error;
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
2822
3075
|
// A thread can be left rendered with no composer at all - measured after a
|
|
2823
3076
|
// send, zero contenteditables and zero textareas on the page - and every
|
|
2824
3077
|
// retry then lands on the same dead page and reports the same "missing a
|
|
@@ -2965,6 +3218,22 @@ export async function sendChatGptPrompt(options) {
|
|
|
2965
3218
|
// into; a --project/--project-new hop lands on a page with its own counts.
|
|
2966
3219
|
beforeSubmit = await evaluateOnPage(page, answerExpression());
|
|
2967
3220
|
dbgSend(`baseline url=${beforeSubmit.url} user=${beforeSubmit.userMessageCount} assistant=${beforeSubmit.assistantMessageCount}`);
|
|
3221
|
+
// Read the binding once more, on the composer this send is about to type
|
|
3222
|
+
// into. Everything between selectProject and here - the model picker, the
|
|
3223
|
+
// power slider - opens and closes over the composer, and a re-render is
|
|
3224
|
+
// exactly when it can come back bound to the project the tab arrived with.
|
|
3225
|
+
// This is not atomic and does not pretend to be; it closes a window that
|
|
3226
|
+
// measurably existed. It has to run BEFORE the attachments and the text,
|
|
3227
|
+
// because a composer holding a prompt no longer shows a placeholder.
|
|
3228
|
+
const boundProject = composerBindingTarget(options);
|
|
3229
|
+
if (boundProject) {
|
|
3230
|
+
const stillBound = await waitForComposerProjectBinding(cdp, boundProject, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
3231
|
+
dbgSend(`project binding before typing=${stillBound}`);
|
|
3232
|
+
if (stillBound !== "bound") {
|
|
3233
|
+
throw new Error(`ChatGPT composer did not bind to project "${boundProject}": it read as this project's after entering it and ` +
|
|
3234
|
+
`no longer does, so nothing was sent.`);
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
2968
3237
|
// Attach BEFORE typing: the upload is the slow part, and a file that
|
|
2969
3238
|
// arrives after the prompt is submitted is a file ChatGPT never saw.
|
|
2970
3239
|
if (options.attachments && options.attachments.length > 0) {
|
package/dist/cli-pro.js
CHANGED
|
@@ -1888,6 +1888,18 @@ export function browserSendBlockerFromError(error) {
|
|
|
1888
1888
|
next_step: "Another prodex send holds the browser. Wait for it to finish and retry, or pass a longer --timeout-ms, which is also the queue budget."
|
|
1889
1889
|
};
|
|
1890
1890
|
}
|
|
1891
|
+
// The composer would have posted somewhere other than the project that was
|
|
1892
|
+
// asked for, so nothing was sent. A prompt that lands in another project is
|
|
1893
|
+
// worse than a blocker: the receipt records the project the caller asked for,
|
|
1894
|
+
// and the answer is somewhere nobody is looking.
|
|
1895
|
+
if (/composer did not bind to project/.test(message)) {
|
|
1896
|
+
return {
|
|
1897
|
+
code: "project_not_bound",
|
|
1898
|
+
message,
|
|
1899
|
+
retryable: true,
|
|
1900
|
+
next_step: "Nothing was sent, so nothing landed in the wrong project. Retry - the composer normally binds on the next navigation - or open the project once in the visible browser and send again."
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1891
1903
|
// The picker could not provide the step that was asked for. Retrying asks
|
|
1892
1904
|
// the same picker the same question, so this is not retryable; the caller
|
|
1893
1905
|
// either picks a step it offers or opts into whatever the slider is on.
|