@youdie006/prodex 0.40.1 → 0.40.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.
@@ -437,17 +437,29 @@ export function resolveHeadlessPreference(explicit, env = process.env) {
437
437
  const raw = (env.PRODEX_HEADLESS ?? "").trim().toLowerCase();
438
438
  return raw === "1" || raw === "true" || raw === "yes";
439
439
  }
440
- export function inferLoggedInLikely(text, visibleButtonLabels = []) {
440
+ /**
441
+ * The two verdicts read different text on purpose.
442
+ *
443
+ * `text` answers "is this a login screen": it has to be the sample with chat
444
+ * MESSAGES excluded, or an old conversation quoting a signup page reports the
445
+ * session as logged out.
446
+ *
447
+ * `loggedInSignalText` answers "is the app here": the sidebar furniture, which
448
+ * survives in whichever sample happened to keep it. Measured live, that is not
449
+ * always the filtered one - on a project home the filter left 111 characters
450
+ * of a banner while document.body.innerText held the whole sidebar.
451
+ */
452
+ export function inferLoggedInLikely(text, visibleButtonLabels = [], loggedInSignalText = text) {
441
453
  // Only sign-up prompts and explicit login/sign-up buttons count as logged-out signals. Bare
442
454
  // "Log in"/"로그인" substrings appear in the menus and footers of a logged-in page, so matching
443
455
  // them against the full page text falsely reported logged-in Pro users as logged out.
444
456
  const hasLoginPrompt = text.includes("Sign up for free") ||
445
457
  text.includes("무료로 가입") ||
446
458
  visibleButtonLabels.some((label) => /^(log in|sign up|로그인|회원가입)$/i.test(label.trim()));
447
- const hasNewChat = text.includes("New chat") || text.includes("새 채팅");
448
- const hasProjectNav = text.includes("Projects") || text.includes("프로젝트");
459
+ const hasNewChat = loggedInSignalText.includes("New chat") || loggedInSignalText.includes("새 채팅");
460
+ const hasProjectNav = loggedInSignalText.includes("Projects") || loggedInSignalText.includes("프로젝트");
449
461
  const hasProfileButton = visibleButtonLabels.some((label) => /profile|account|프로필|계정/i.test(label));
450
- const hasPlanHint = /\bPro\b|Plus|Team|Enterprise|매우 높음|Extra High/i.test(text);
462
+ const hasPlanHint = /\bPro\b|Plus|Team|Enterprise|매우 높음|Extra High/i.test(loggedInSignalText);
451
463
  return !hasLoginPrompt && hasNewChat && (hasProfileButton || hasProjectNav || hasPlanHint);
452
464
  }
453
465
  export function isUsableChatGptAnswer(answer) {
@@ -842,9 +854,23 @@ export function detectChatGptPageBlocker(state) {
842
854
  return detectChatGptBlocker(state.blockerScanTextSample ?? state.blockerTextSample ?? state.textSample, state.visibleButtonLabels);
843
855
  }
844
856
  export function inferChatGptPageLoggedInLikely(state) {
845
- // Login detection uses the nav-INCLUDED sample: "New chat"/"Projects"/plan
846
- // hints live in the sidebar and are the primary logged-in signal.
847
- return inferLoggedInLikely(state.blockerTextSample ?? state.textSample, state.visibleButtonLabels);
857
+ // The logged-in signals live in the sidebar - "New chat", "Projects", the
858
+ // plan hint - so this needs the sample that HAS the sidebar in it. That was
859
+ // meant to be blockerTextSample, and measured live on a project home it is
860
+ // not: its text walk keeps only nodes whose own parent has a box, and what
861
+ // survived there was 111 characters of a promotional banner while
862
+ // document.body.innerText carried the whole sidebar. So a logged-in Pro
863
+ // account on a working page was told to go and log in.
864
+ //
865
+ // The logged-OUT question keeps the message-excluded sample, so a chat
866
+ // quoting a signup page cannot report the session as dead. The logged-IN
867
+ // question reads both, because the sidebar turns up in whichever one kept
868
+ // it - and being wrong in that direction is caught at once by the composer
869
+ // check beside it, while being wrong the other way tells someone with a
870
+ // working browser to go and log in.
871
+ const messageExcluded = state.blockerTextSample ?? state.textSample;
872
+ const anySample = [state.textSample, state.blockerTextSample].filter(Boolean).join(String.fromCharCode(10));
873
+ return inferLoggedInLikely(messageExcluded, state.visibleButtonLabels, anySample);
848
874
  }
849
875
  function hasLikelyChatGptLoginPrompt(haystack) {
850
876
  const hasSpecificSignup = /sign up for free|무료로 가입/i.test(haystack);
@@ -1056,8 +1082,29 @@ decidedBusyBlocker, busyVerdictDecided = false) {
1056
1082
  const busyBlocker = busyVerdictDecided ? decidedBusyBlocker : chatGptBusyBlocker(status);
1057
1083
  if (busyBlocker)
1058
1084
  throw new ChatGptBrowserBlockerError(busyBlocker);
1085
+ // ChatGPT's own error page carries no composer and none of the logged-in
1086
+ // furniture the login check reads, so the assert below calls a perfectly
1087
+ // good session logged out and sends the person looking for a login screen
1088
+ // that is not there. Measured live: a project home that failed to load left
1089
+ // the tab on that page, and the next send - the retry the project blocker
1090
+ // asks for - reported "missing a clear logged-in ChatGPT session".
1091
+ if (looksLikeChatGptErrorPage({ bodyText: status.textSample, hasComposer: status.hasComposer })) {
1092
+ throw new ChatGptBrowserBlockerError(chatGptErrorPageBlocker());
1093
+ }
1059
1094
  assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
1060
1095
  }
1096
+ /** The tab is on ChatGPT's error page: the page failed to load, and says nothing about the session. */
1097
+ export function chatGptErrorPageBlocker() {
1098
+ return {
1099
+ code: "chatgpt_error_page",
1100
+ message: "ChatGPT browser is reachable, but the tab is on ChatGPT's own error page, which has no prompt composer.",
1101
+ retryable: true,
1102
+ // Not "the session is fine": this page carries none of the furniture that
1103
+ // would show it either way. Not "reload it" either - measured, a project
1104
+ // home reloads straight back into this page, while the site root loads.
1105
+ next_step: "The page failed to load, which says nothing about the session. Open a normal chat in the visible browser, then retry."
1106
+ };
1107
+ }
1061
1108
  /**
1062
1109
  * Whether a page with no composer is worth one reload before giving up.
1063
1110
  *
@@ -1302,6 +1349,36 @@ async function dispatchEscapeKey(cdp) {
1302
1349
  await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
1303
1350
  await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
1304
1351
  }
1352
+ /**
1353
+ * Open a fresh ChatGPT root document, and prove that is where the tab landed.
1354
+ *
1355
+ * The recovery for a composer bound to the wrong project: only a new document
1356
+ * sheds a stale binding, and the site root loads where a project home does
1357
+ * not. Checked rather than best-effort, because the caller clicks a sidebar
1358
+ * row on whatever page this leaves behind - and clicking it on the OLD
1359
+ * document walks straight back into the binding it is trying to shed. The
1360
+ * stamp is what proves the old document is gone; the URL alone can be read off
1361
+ * the very page we are trying to leave.
1362
+ */
1363
+ async function openFreshChatGptHome(cdp) {
1364
+ await cdp.evaluate(markDocumentForReloadExpression());
1365
+ await cdp.evaluate(`location.assign("https://chatgpt.com/")`);
1366
+ const deadline = Date.now() + RELOAD_SETTLE_TIMEOUT_MS;
1367
+ while (Date.now() < deadline) {
1368
+ await sleep(250);
1369
+ try {
1370
+ if (await cdp.evaluate(freshChatGptHomeReadyExpression()))
1371
+ return;
1372
+ }
1373
+ catch (error) {
1374
+ // The execution context is gone between documents; the next poll lands
1375
+ // on the new one. A command timeout is different: it closed the socket.
1376
+ if (cdpCommandTimedOut(error))
1377
+ throw error;
1378
+ }
1379
+ }
1380
+ throw new Error("ChatGPT did not open a fresh home page with a composer");
1381
+ }
1305
1382
  // Poll a boolean page expression instead of sleeping a fixed duration, so slow
1306
1383
  // renders wait longer and fast ones do not waste time.
1307
1384
  async function waitForExpressionTrue(cdp, expression, timeoutMs, intervalMs = 150) {
@@ -1555,6 +1632,20 @@ export function reloadedDocumentReadyExpression(extraCondition = "true") {
1555
1632
  return Boolean(${extraCondition});
1556
1633
  })()`;
1557
1634
  }
1635
+ /**
1636
+ * True only on a NEWLY loaded chatgpt.com root that has rendered a composer.
1637
+ *
1638
+ * Every clause answers a way the old check could pass on the page we are
1639
+ * trying to leave: the stamp proves the document is not the one that asked
1640
+ * for the navigation, the route proves it is the root rather than the project
1641
+ * home that fails to load, and the composer candidate is the real editor
1642
+ * rather than the broad selector that also matches a hidden fallback.
1643
+ */
1644
+ export function freshChatGptHomeReadyExpression() {
1645
+ return reloadedDocumentReadyExpression(`/^https:\\/\\/chatgpt\\.com\\/?(?:[?#].*)?$/.test(location.href) && (() => {${composerExpressionHelpers()}
1646
+ return Boolean(findChatGptComposerCandidate());
1647
+ })()`);
1648
+ }
1558
1649
  /** Polled reloads return as soon as the new document has its composer; this only bounds a page that never gets there. */
1559
1650
  const RELOAD_SETTLE_TIMEOUT_MS = 12_000;
1560
1651
  /**
@@ -1602,17 +1693,75 @@ async function reloadPageAndAwaitComposer(page) {
1602
1693
  cdp.close();
1603
1694
  }
1604
1695
  }
1696
+ // Every alternative is wording measured on the error page itself, anchored so
1697
+ // the whole body has to be made of them and nothing else. It may repeat one:
1698
+ // the heading and the button carry the same words.
1699
+ const CHATGPT_ERROR_PAGE_BODY = /^(?:(?:something went wrong|please try again later|please try again|try again|다시\s*시도)[.!]?(?:\s+|$))+$/i;
1700
+ /**
1701
+ * ChatGPT's error page: a document whose whole body is a retry affordance.
1702
+ *
1703
+ * Measured live - a direct load of a project home came back with a body of
1704
+ * exactly "Try again", one button, and no composer, while the same route
1705
+ * reached by clicking the sidebar worked and the rest of ChatGPT loaded fine.
1706
+ * Reporting that as a missing composer describes a symptom and names nothing
1707
+ * to do about it.
1708
+ *
1709
+ * The body has to BE the error, not merely contain a retry word somewhere: a
1710
+ * positive now navigates the visible tab away, so "Retry settings" and a
1711
+ * transient "Retrying..." must not qualify. Wording decides, not length - the
1712
+ * old 40-character ceiling turned down "Something went wrong. Please try again
1713
+ * later." for being one sentence too long. An error page prodex does not
1714
+ * recognise stays a missing composer: a worse message, not a wrong action.
1715
+ */
1716
+ export function looksLikeChatGptErrorPage(input) {
1717
+ if (input.hasComposer)
1718
+ return false;
1719
+ // The DOM read leaves the heading and the button separated by whitespace.
1720
+ const text = input.bodyText.replace(/\s+/g, " ").trim();
1721
+ if (!text)
1722
+ return false;
1723
+ // A backstop for the alternation below rather than a classifier of its own:
1724
+ // the longest body it can accept is well under this, so anything longer is a
1725
+ // page with content and is not worth matching against.
1726
+ if (text.length > 200)
1727
+ return false;
1728
+ return CHATGPT_ERROR_PAGE_BODY.test(text);
1729
+ }
1605
1730
  /**
1606
- * The project a ChatGPT URL belongs to, as its `g-p-<id>` segment.
1731
+ * Which project the composer will post into, read from its own placeholder.
1732
+ *
1733
+ * ChatGPT labels a project composer with the project it belongs to ("New chat
1734
+ * in <name>"), and measured live that label follows a sidebar navigation from
1735
+ * one project to another and back. That makes it the cheap answer to the
1736
+ * question the old hard reload tried to force: is this composer the project's,
1737
+ * or the one the tab arrived with.
1607
1738
  *
1608
- * The rebind check used to compare whole URLs, which also carry mode in a
1609
- * query string - and mode is exactly what differs across the reload this
1610
- * failure showed up in. Identity is the thing the check actually cares about:
1611
- * that the composer came back bound to THIS project rather than the one the
1612
- * tab came from.
1739
+ * Only a recognised phrasing decides anything. A placeholder that merely
1740
+ * CONTAINS the name is not evidence - "New chat in Notes Archive" contains
1741
+ * "Notes" - and neither is an unrecognised one, which is why a locale this
1742
+ * cannot read comes back "unknown" rather than "bound". What "unknown" is
1743
+ * worth is the caller's to decide, not this function's.
1613
1744
  */
1614
- export function projectIdentity(url) {
1615
- return /\/g\/(g-p-[^/?#]+)/.exec(url)?.[1];
1745
+ export function composerProjectBinding(input) {
1746
+ const placeholder = (input.placeholder ?? "").trim();
1747
+ if (!placeholder)
1748
+ return "unknown";
1749
+ const wanted = input.projectName.trim().toLowerCase();
1750
+ if (!wanted)
1751
+ return "unknown";
1752
+ // Equality where the phrasing is known, because sidebar rows are matched by
1753
+ // exact name: "Notes" and "Notes Archive" are two projects, and a composer
1754
+ // belonging to one must not pass for the other. The spacing of the template
1755
+ // is loose because that is the page's to choose; the NAME is compared as it
1756
+ // is, since two projects may differ by exactly the spacing in it.
1757
+ const named = /^new\s+chat\s+in\s+(.+)$/i.exec(placeholder)?.[1] ?? /^(.+?)\uc5d0\uc11c\s*\uc0c8\s*\ucc44\ud305$/.exec(placeholder)?.[1];
1758
+ if (named)
1759
+ return named.trim().toLowerCase() === wanted ? "bound" : "elsewhere";
1760
+ // The placeholder a plain new chat carries: recognised, and it names no
1761
+ // project, so the composer belongs to none.
1762
+ if (/^ask\s+chatgpt$/i.test(placeholder))
1763
+ return "elsewhere";
1764
+ return "unknown";
1616
1765
  }
1617
1766
  export function powerSliderPresentExpression() {
1618
1767
  return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"]'))`;
@@ -1770,11 +1919,18 @@ export function projectItemRectExpression(name) {
1770
1919
  }
1771
1920
  let target = opt ? (opt.closest('a,[role="link"],li') || opt.parentElement) : null;
1772
1921
  if (!target) {
1922
+ // The fallback for a sidebar whose option buttons this cannot read. It
1923
+ // used to take the first row CONTAINING the name, which is the substring
1924
+ // match the exact comparison above exists to prevent: asking for "Codex"
1925
+ // took "Codex Review" and sent the prompt into a project nobody named.
1926
+ // A row's first line is its name; anything else here is a guess, and a
1927
+ // guess about which project to post into is the failure being fixed.
1773
1928
  const icons = [...document.querySelectorAll('[data-testid="project-folder-icon"]')];
1774
- for (const ic of icons) {
1775
- const row = ic.closest('a,li,[role="link"]') || ic.parentElement?.parentElement;
1776
- if (row && (row.textContent || "").includes(wanted)) { target = row; break; }
1777
- }
1929
+ const rowName = (row) => ((row.innerText || row.textContent || "").split("\\n").map((line) => line.trim()).find((line) => line.length > 0) || "");
1930
+ const rows = icons.map((ic) => ic.closest('a,li,[role="link"]') || ic.parentElement?.parentElement).filter(Boolean);
1931
+ const named = rows.filter((row) => rowName(row).toLowerCase() === wanted.toLowerCase());
1932
+ if (named.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects; rename one to disambiguate" };
1933
+ if (named.length === 1) target = named[0];
1778
1934
  }
1779
1935
  if (!target) {
1780
1936
  return { ok: false, reason: "project not found in sidebar (" + optionButtons.length + " projects visible; names are matched exactly first, then case-insensitively - check the exact sidebar spelling)" };
@@ -1970,6 +2126,21 @@ async function selectPickerModel(cdp, requested, warnings = []) {
1970
2126
  * without this the step that follows reported "the picker did not expose its
1971
2127
  * power slider" on a picker that was simply shut.
1972
2128
  */
2129
+ /**
2130
+ * How to apply the stored Chat-surface preference on a page that renders no
2131
+ * surface toggle at all.
2132
+ *
2133
+ * Reloading in place is the only option for a send that has to stay where it
2134
+ * is - a pinned thread, a continuation. On a project home it is the wrong one:
2135
+ * a hard load of one comes back as ChatGPT's error page (measured on two
2136
+ * projects), which leaves the send on a document with no sidebar, and the
2137
+ * project step then reports the project missing from a sidebar that was never
2138
+ * drawn. A send that is going to navigate anyway takes the root, which loads.
2139
+ */
2140
+ export function chatSurfaceRecoveryPlan(input) {
2141
+ const onProjectPage = /^https:\/\/chatgpt\.com\/g\/g-p-/.test(input.href);
2142
+ return input.mayLeaveCurrentPage && onProjectPage ? "fresh-root" : "reload";
2143
+ }
1973
2144
  /**
1974
2145
  * Put the browser back on ChatGPT's Chat surface when it has drifted onto Work.
1975
2146
  *
@@ -1977,7 +2148,7 @@ async function selectPickerModel(cdp, requested, warnings = []) {
1977
2148
  * the page announces which one is live, so a drifted browser silently drives
1978
2149
  * the wrong picker. Returns a warning to carry to the caller when it moved.
1979
2150
  */
1980
- async function ensureChatSurface(cdp) {
2151
+ async function ensureChatSurface(cdp, options) {
1981
2152
  const read = async () => {
1982
2153
  try {
1983
2154
  return await cdp.evaluate(chatSurfaceProbeExpression());
@@ -2035,8 +2206,23 @@ async function ensureChatSurface(cdp) {
2035
2206
  try {
2036
2207
  await cdp.evaluate(selectChatSurfaceExpression());
2037
2208
  // Reading the persisted value back right after writing it proves nothing;
2038
- // the reloaded document rendering its composer is what proves the switch.
2039
- if ((await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS)) && (await confirm()))
2209
+ // the new document rendering its composer is what proves the switch.
2210
+ const plan = chatSurfaceRecoveryPlan({
2211
+ href: await cdp.evaluate("location.href"),
2212
+ mayLeaveCurrentPage: options.mayLeaveCurrentPage
2213
+ });
2214
+ let applied;
2215
+ if (plan === "fresh-root") {
2216
+ // Throws when the root never rendered a composer, which the catch below
2217
+ // turns into the same "could not be switched back" warning as a reload
2218
+ // that never settled.
2219
+ await openFreshChatGptHome(cdp);
2220
+ applied = true;
2221
+ }
2222
+ else {
2223
+ applied = await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS);
2224
+ }
2225
+ if (applied && (await confirm()))
2040
2226
  return note;
2041
2227
  }
2042
2228
  catch (error) {
@@ -2477,13 +2663,79 @@ async function createChatGptProject(cdp, name) {
2477
2663
  throw error;
2478
2664
  }
2479
2665
  }
2480
- async function selectProject(cdp, options) {
2481
- if (options.projectNew) {
2482
- await createChatGptProject(cdp, options.projectNew);
2483
- return;
2666
+ /** The placeholder of the composer the send will actually type into. */
2667
+ /**
2668
+ * Read the label the composer carries, wherever it keeps it.
2669
+ *
2670
+ * Measured live on a project home: the editor prodex types into is the
2671
+ * contenteditable div, and it carries the label ONLY as `aria-label` -
2672
+ * `data-placeholder` is null on it. The 0x0 textarea beside it does carry
2673
+ * `placeholder`, but the composer finder rejects that one on size, exactly as
2674
+ * it should. Reading a single attribute meant the label was there and prodex
2675
+ * could not see it, so every project send refused with "the placeholder could
2676
+ * not be read" - the fail-closed branch doing its job on a page that was fine.
2677
+ */
2678
+ export function composerProjectBindingExpression() {
2679
+ return `(() => {${composerExpressionHelpers()}
2680
+ const node = findChatGptComposerCandidate();
2681
+ if (!node) return { found: false };
2682
+ const label =
2683
+ node.getAttribute("data-placeholder") ||
2684
+ node.getAttribute("placeholder") ||
2685
+ node.getAttribute("aria-label") ||
2686
+ "";
2687
+ return { found: true, placeholder: label };
2688
+ })()`;
2689
+ }
2690
+ /**
2691
+ * Wait for the composer to belong to the project we just entered.
2692
+ *
2693
+ * The binding arrives with the project page rather than with the URL, so this
2694
+ * polls rather than reading once. A composer that never appears, or one whose
2695
+ * placeholder cannot be read, comes back "unknown" - which the caller treats
2696
+ * as a failure, so this must not report it lightly.
2697
+ */
2698
+ async function waitForComposerProjectBinding(cdp, project, timeoutMs) {
2699
+ const deadline = Date.now() + timeoutMs;
2700
+ let verdict = "unknown";
2701
+ for (;;) {
2702
+ let read;
2703
+ try {
2704
+ read = await cdp.evaluate(composerProjectBindingExpression());
2705
+ }
2706
+ catch (error) {
2707
+ // A read that lands between documents answers about neither, and the next
2708
+ // poll lands on the new one. A command timeout is a different thing: the
2709
+ // tab stopped answering, and swallowing it spent this whole budget and
2710
+ // the recovery's before refusing with "the placeholder could not be
2711
+ // read" - a binding failure reported for a browser that was gone.
2712
+ if (cdpCommandTimedOut(error))
2713
+ throw error;
2714
+ read = { found: false };
2715
+ }
2716
+ if (read.found) {
2717
+ const sample = composerProjectBinding({
2718
+ ...(read.placeholder !== undefined ? { placeholder: read.placeholder } : {}),
2719
+ projectName: project
2720
+ });
2721
+ if (sample === "bound")
2722
+ return sample;
2723
+ // Keep the worse reading. A composer seen belonging somewhere else stays
2724
+ // evidence of that even if the next sample lands mid-render with no
2725
+ // placeholder to read - overwriting it turned a known wrong destination
2726
+ // into an unknown one, which reads like the softer failure it is not.
2727
+ if (sample === "elsewhere" || verdict === "unknown")
2728
+ verdict = sample;
2729
+ }
2730
+ if (Date.now() >= deadline)
2731
+ return verdict;
2732
+ await sleep(250);
2484
2733
  }
2485
- if (!options.project)
2486
- return;
2734
+ }
2735
+ // Enter an EXISTING project by clicking its sidebar row. Leaves the tab on
2736
+ // that project's page; whether the composer came with it is the caller's
2737
+ // question, not this one's.
2738
+ async function navigateToExistingProject(cdp, project) {
2487
2739
  const hrefBefore = await cdp.evaluate("location.href");
2488
2740
  // Poll for the project row instead of a single check: right after a
2489
2741
  // --new-chat navigation the sidebar's Projects section has not hydrated yet
@@ -2493,7 +2745,7 @@ async function selectProject(cdp, options) {
2493
2745
  let hit = { ok: false };
2494
2746
  const projectDeadline = Date.now() + 6_000;
2495
2747
  for (;;) {
2496
- hit = await cdp.evaluate(projectItemRectExpression(options.project));
2748
+ hit = await cdp.evaluate(projectItemRectExpression(project));
2497
2749
  if (hit.ok && hit.x !== undefined && hit.y !== undefined)
2498
2750
  break;
2499
2751
  if (Date.now() >= projectDeadline)
@@ -2502,9 +2754,9 @@ async function selectProject(cdp, options) {
2502
2754
  }
2503
2755
  if (!hit.ok || hit.x === undefined || hit.y === undefined) {
2504
2756
  const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
2505
- throw new Error(`ChatGPT project not found in sidebar: ${options.project}${detail} List the visible names with \`prodex pro browser projects\`.`);
2757
+ throw new Error(`ChatGPT project not found in sidebar: ${project}${detail} List the visible names with \`prodex pro browser projects\`.`);
2506
2758
  }
2507
- await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(options.project)), `project ${options.project}`);
2759
+ await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(project)), `project ${project}`);
2508
2760
  const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
2509
2761
  if (!navigated) {
2510
2762
  // The href staying put is fine ONLY when the tab was already on THIS
@@ -2514,7 +2766,7 @@ async function selectProject(cdp, options) {
2514
2766
  // project - which would silently send the prompt into the wrong project.
2515
2767
  const alreadyInRequestedProject = await cdp.evaluate(`(() => {
2516
2768
  if (!/^https:\\/\\/chatgpt\\.com\\/g\\/g-p-/.test(location.href)) return false;
2517
- const name = ${JSON.stringify(options.project)}.toLowerCase();
2769
+ const name = ${JSON.stringify(project)}.toLowerCase();
2518
2770
  // Case-insensitive EQUALITY (not substring): matches the case-insensitive
2519
2771
  // sidebar-row lookup (so "codex" is accepted while sitting on "Codex"),
2520
2772
  // but a stalled cross-project navigation must NOT be accepted just because
@@ -2525,42 +2777,132 @@ async function selectProject(cdp, options) {
2525
2777
  return [...document.querySelectorAll('h1,[role="heading"]')].some((h) => (h.innerText || "").trim().toLowerCase() === name);
2526
2778
  })()`);
2527
2779
  if (!alreadyInRequestedProject) {
2528
- throw new Error(`Clicking project "${options.project}" did not navigate the visible tab. If the tab is already inside this project, omit --project and retry.`);
2780
+ 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
2781
  }
2530
2782
  }
2531
- if (navigated) {
2532
- // A sidebar SPA navigation moves the URL to the target project while the
2533
- // composer can stay bound to the PREVIOUS project's conversation target, so
2534
- // the send silently creates the thread in the OLD project (reproduced live
2535
- // via PRODEX_DEBUG_SEND: baseline URL on the requested project, yet the
2536
- // prompt posted into the project the tab came from). A hard reload of the
2537
- // project home rebinds the composer to THIS project before we send.
2538
- const projectHome = await cdp.evaluate("location.href");
2539
- const home = projectIdentity(projectHome);
2540
- // Polled with no delay, the first check could run before the reload had
2541
- // committed, on the old document, whose composer and URL both still
2542
- // matched. The stamp keeps that document from passing as the new one.
2543
- //
2544
- // Identity, not the whole URL: the href also carries mode in a query
2545
- // string, and comparing it byte-for-byte failed a reload that had landed
2546
- // exactly where it was asked to.
2547
- const rebound = await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS, home ? `/\\/g\\/${home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![^/?#])/.test(location.href)` : "true");
2548
- if (!rebound) {
2549
- // Say what it saw. This failure is rare and was previously reported as
2550
- // four words, which named neither the cause nor anything to try.
2551
- const landed = await cdp.evaluate("location.href").catch(() => "unknown");
2552
- const hasComposer = await cdp
2553
- .evaluate(`Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`)
2554
- .catch(() => false);
2555
- throw new Error(`ChatGPT composer did not rebind after entering project "${options.project}": after reloading, the tab was on ` +
2556
- `${projectIdentity(landed) ?? "no project"} (expected ${home ?? "that project"}) and the composer was ` +
2557
- `${hasComposer ? "present but the page never settled" : "still missing"}.`);
2783
+ }
2784
+ /**
2785
+ * The refusal to post into a project prodex cannot confirm, as a blocker.
2786
+ *
2787
+ * It used to be a plain Error whose English prose a regex in the CLI matched to
2788
+ * recover the code - so the difference between "the composer belongs elsewhere"
2789
+ * and "the browser stopped answering" survived only as a sentence, and a
2790
+ * reworded message would have quietly become an unclassified send failure. The
2791
+ * wording still carries the phrase the classifier keys on, because older
2792
+ * senders and other paths still reach it as text.
2793
+ *
2794
+ * The project it OFFERED instead is deliberately absent: naming it would put
2795
+ * another project's name in a persisted record, which redaction covers only for
2796
+ * the ones this send asked for.
2797
+ */
2798
+ export function projectNotBoundBlocker(input) {
2799
+ const detail = input.reason === "elsewhere"
2800
+ ? "after entering it, the composer still offers a chat that belongs somewhere else"
2801
+ : input.reason === "unknown"
2802
+ ? "after entering it, the composer's placeholder could not be read, so where the prompt would land is unknown"
2803
+ : "it read as this project's after entering it and no longer does";
2804
+ return {
2805
+ code: "project_not_bound",
2806
+ message: `ChatGPT composer did not bind to project "${input.project}": ${detail}, so nothing was sent.` +
2807
+ (input.recoveryNote ?? ""),
2808
+ retryable: true,
2809
+ next_step: "Nothing was sent, so nothing landed in the wrong project. Retry - the composer normally binds on the next navigation - " +
2810
+ "or open the project once in the visible browser and send again."
2811
+ };
2812
+ }
2813
+ /**
2814
+ * The project name the composer has to agree with before anything is typed,
2815
+ * or undefined for a send that pins no project.
2816
+ *
2817
+ * A project the send just created is exactly as able to post into the wrong
2818
+ * place as one it navigated to - the create flow leaves the tab on a project
2819
+ * home like any other - so both answer here, and the binding gate reads this
2820
+ * rather than the pinned name alone.
2821
+ */
2822
+ export function composerBindingTarget(options) {
2823
+ return options.projectNew ?? options.project;
2824
+ }
2825
+ /**
2826
+ * Put the tab in the project this send is for, and refuse unless the composer
2827
+ * agrees that is where it posts.
2828
+ *
2829
+ * Both ways in share the gate. A project prodex just created is no safer than
2830
+ * one it navigated to: the create flow waits for A composer, and the composer
2831
+ * that answers can still be the one the tab arrived with - the same silent
2832
+ * wrong-project send, with the new project's name in the receipt.
2833
+ */
2834
+ async function selectProject(cdp, options) {
2835
+ const wanted = composerBindingTarget(options);
2836
+ if (!wanted)
2837
+ return undefined;
2838
+ if (options.projectNew)
2839
+ await createChatGptProject(cdp, options.projectNew);
2840
+ // Clicking the sidebar row when the composer already belongs to this project
2841
+ // is work that can only fail. Measured: a send into the project the tab was
2842
+ // already sitting in refused with "another element covers its click point" -
2843
+ // a promotional banner over the sidebar - on a page that was ready to accept
2844
+ // the prompt. The read below is the same evidence the gate accepts, so a
2845
+ // composer that already answers with this project needs no navigation.
2846
+ else if ((await waitForComposerProjectBinding(cdp, options.project, 0)) !== "bound") {
2847
+ await navigateToExistingProject(cdp, options.project);
2848
+ }
2849
+ // A sidebar SPA navigation moves the URL to the target project while the
2850
+ // composer can stay bound to the PREVIOUS project's conversation target, so
2851
+ // the send silently creates the thread in the OLD project (reproduced live
2852
+ // via PRODEX_DEBUG_SEND: baseline URL on the requested project, yet the
2853
+ // prompt posted into the project the tab came from).
2854
+ //
2855
+ // Hard-reloading the project home used to rebind it, and that stopped
2856
+ // working: measured on two different projects, EVERY hard load of a project
2857
+ // home - Page.reload and location.assign alike - comes back as ChatGPT's
2858
+ // error page with no composer, while the sidebar navigation that got us
2859
+ // here renders in under two seconds. So read the binding instead of forcing
2860
+ // it - the composer says which project it posts into.
2861
+ //
2862
+ // Read on every path in. A URL that never moved is not proof about the
2863
+ // composer either: the route and the title can already be this project's
2864
+ // while the composer still belongs to the thread the tab was left on, and
2865
+ // that path used to skip this check entirely - as did creating a project,
2866
+ // which reached the send with nothing checked at all.
2867
+ let binding = await waitForComposerProjectBinding(cdp, wanted, PROJECT_NAVIGATION_TIMEOUT_MS);
2868
+ let recoveryNote = "";
2869
+ if (binding !== "bound") {
2870
+ // The one recovery that cannot inherit a stale binding, and the only one
2871
+ // still available: a fresh document - the site root loads fine, unlike a
2872
+ // project home - and then the same sidebar navigation over again.
2873
+ try {
2874
+ await openFreshChatGptHome(cdp);
2875
+ await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(wanted)), `project ${wanted}`);
2876
+ // The click's navigation has to land before the placeholder means
2877
+ // anything; read on the page we came from, it answers about the wrong
2878
+ // document.
2879
+ const entered = await waitForExpressionTrue(cdp, `/\\/g\\/g-p-/.test(location.href)`, PROJECT_NAVIGATION_TIMEOUT_MS);
2880
+ if (!entered)
2881
+ throw new Error("the sidebar click did not reach a project page");
2882
+ binding = await waitForComposerProjectBinding(cdp, wanted, PROJECT_NAVIGATION_TIMEOUT_MS);
2883
+ }
2884
+ catch (recoveryError) {
2885
+ // Dropping why the recovery failed would leave the refusal below saying
2886
+ // only that the binding is still wrong, which is the less useful half.
2887
+ recoveryNote = ` Recovery failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`;
2558
2888
  }
2559
2889
  }
2890
+ if (binding !== "bound") {
2891
+ // Refuse on "unknown" as well as "elsewhere". A placeholder prodex cannot
2892
+ // read is not evidence that the composer is this project's, and what it
2893
+ // guards against - a prompt posted into another project, recorded under
2894
+ // the requested one - costs far more than a send the caller can retry.
2895
+ throw new ChatGptBrowserBlockerError(projectNotBoundBlocker({
2896
+ project: wanted,
2897
+ reason: binding,
2898
+ ...(recoveryNote ? { recoveryNote } : {})
2899
+ }));
2900
+ }
2560
2901
  const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
2561
2902
  if (!composerReady) {
2562
- throw new Error(`ChatGPT composer did not appear after entering project "${options.project}"`);
2903
+ throw new Error(`ChatGPT composer did not appear after entering project "${wanted}"`);
2563
2904
  }
2905
+ return chatGptProjectIdFromUrl(await cdp.evaluate("location.href"));
2564
2906
  }
2565
2907
  // Read the finished answer from an existing ChatGPT thread WITHOUT sending a new
2566
2908
  // prompt. Recovers a consult whose send timed out but whose answer ChatGPT
@@ -2819,6 +3161,40 @@ export async function sendChatGptPrompt(options) {
2819
3161
  busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
2820
3162
  }
2821
3163
  }
3164
+ // ChatGPT's error page does not come back on a reload - measured: a project
3165
+ // home that failed reloaded straight back into it - and the tab then stays
3166
+ // there for every later send, including the retry its own blocker asks for.
3167
+ // Going home is the recovery that works, and an unpinned send has nothing to
3168
+ // lose by leaving an error page.
3169
+ if (looksLikeChatGptErrorPage({ bodyText: status.textSample, hasComposer: status.hasComposer })) {
3170
+ // A send pinned to a thread is not one of those. Navigating to the root
3171
+ // would trade the page that explains the failure for a target mismatch
3172
+ // that does not, and leave the pinned tab somewhere it was not asked to
3173
+ // go, so report the error page and keep the tab where it is.
3174
+ if (normalizedTargetUrl)
3175
+ throw new ChatGptBrowserBlockerError(chatGptErrorPageBlocker());
3176
+ emitProgress("waiting", "tab on ChatGPT's error page; opening a working page");
3177
+ try {
3178
+ await evaluateOnPage(page, `location.assign("https://chatgpt.com/")`);
3179
+ await waitForFreshChatGptPage(page, RELOAD_SETTLE_TIMEOUT_MS);
3180
+ let fresh = await readSettledChatGptPageStatus(page);
3181
+ fresh = await ensureVisibleChatGptPage(port, page, fresh);
3182
+ const blockerAfterHome = detectChatGptPageBlocker(fresh);
3183
+ if (blockerAfterHome)
3184
+ throw new ChatGptBrowserBlockerError(blockerAfterHome);
3185
+ // The busy verdict above was decided about the page we just left, and it
3186
+ // is handed to the readiness assert as already decided. Carrying it over
3187
+ // would let a root page that is generating an answer be typed into.
3188
+ busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(fresh), await readTranscriptCompletion(page, fresh.url));
3189
+ status = fresh;
3190
+ }
3191
+ catch (error) {
3192
+ // A blocker is the answer; anything else leaves the original status, and
3193
+ // the readiness assert below reports the error page it still sees.
3194
+ if (error instanceof ChatGptBrowserBlockerError || cdpCommandTimedOut(error))
3195
+ throw error;
3196
+ }
3197
+ }
2822
3198
  // A thread can be left rendered with no composer at all - measured after a
2823
3199
  // send, zero contenteditables and zero textareas on the page - and every
2824
3200
  // retry then lands on the same dead page and reports the same "missing a
@@ -2894,6 +3270,7 @@ export async function sendChatGptPrompt(options) {
2894
3270
  process.stderr.write(`DBG-SEND +${Date.now() - sendStartedAt}ms ${msg}\n`);
2895
3271
  };
2896
3272
  let beforeSubmit;
3273
+ let boundProjectId;
2897
3274
  let submitButtonFound = false;
2898
3275
  let wantsDeepResearch = false;
2899
3276
  const sendWarnings = [];
@@ -2939,11 +3316,16 @@ export async function sendChatGptPrompt(options) {
2939
3316
  // Max and Ultra are rungs of Work's slider, so asking for one means staying
2940
3317
  // there; anything else belongs on Chat, whose top step is Pro.
2941
3318
  if (!effortNeedsWorkSurface(options.effort)) {
2942
- const surfaceWarning = await ensureChatSurface(cdp);
3319
+ // Leaving the current page is safe only for a send that was going to
3320
+ // navigate anyway; a continuation or a pinned tab has to be reloaded
3321
+ // where it stands, because that page IS the destination.
3322
+ const surfaceWarning = await ensureChatSurface(cdp, {
3323
+ mayLeaveCurrentPage: Boolean(options.newChat || options.project || options.projectNew)
3324
+ });
2943
3325
  if (surfaceWarning)
2944
3326
  sendWarnings.push(surfaceWarning);
2945
3327
  }
2946
- await selectProject(cdp, options);
3328
+ boundProjectId = await selectProject(cdp, options);
2947
3329
  try {
2948
3330
  await selectModelReasoning(cdp, options, sendWarnings);
2949
3331
  }
@@ -2965,6 +3347,21 @@ export async function sendChatGptPrompt(options) {
2965
3347
  // into; a --project/--project-new hop lands on a page with its own counts.
2966
3348
  beforeSubmit = await evaluateOnPage(page, answerExpression());
2967
3349
  dbgSend(`baseline url=${beforeSubmit.url} user=${beforeSubmit.userMessageCount} assistant=${beforeSubmit.assistantMessageCount}`);
3350
+ // Read the binding once more, on the composer this send is about to type
3351
+ // into. Everything between selectProject and here - the model picker, the
3352
+ // power slider - opens and closes over the composer, and a re-render is
3353
+ // exactly when it can come back bound to the project the tab arrived with.
3354
+ // This is not atomic and does not pretend to be; it closes a window that
3355
+ // measurably existed. It has to run BEFORE the attachments and the text,
3356
+ // because a composer holding a prompt no longer shows a placeholder.
3357
+ const boundProject = composerBindingTarget(options);
3358
+ if (boundProject) {
3359
+ const stillBound = await waitForComposerProjectBinding(cdp, boundProject, PROJECT_NAVIGATION_TIMEOUT_MS);
3360
+ dbgSend(`project binding before typing=${stillBound}`);
3361
+ if (stillBound !== "bound") {
3362
+ throw new ChatGptBrowserBlockerError(projectNotBoundBlocker({ project: boundProject, reason: "drifted" }));
3363
+ }
3364
+ }
2968
3365
  // Attach BEFORE typing: the upload is the slow part, and a file that
2969
3366
  // arrives after the prompt is submitted is a file ChatGPT never saw.
2970
3367
  if (options.attachments && options.attachments.length > 0) {
@@ -3159,6 +3556,7 @@ export async function sendChatGptPrompt(options) {
3159
3556
  answer: transcript.answer,
3160
3557
  modelHints: finalState?.modelHints ?? [],
3161
3558
  ...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
3559
+ ...(boundProjectId ? { boundProjectId } : {}),
3162
3560
  warnings: withDialogNote([...sendWarnings, selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...((transcript.modelSlug || finalState?.modelSlug) ? { modelSlug: (transcript.modelSlug || finalState?.modelSlug) } : {}) })]).filter((warning) => Boolean(warning))
3163
3561
  };
3164
3562
  };
@@ -3307,6 +3705,7 @@ export async function sendChatGptPrompt(options) {
3307
3705
  answer: completed.answer.trim(),
3308
3706
  modelHints: completed.modelHints,
3309
3707
  ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
3708
+ ...(boundProjectId ? { boundProjectId } : {}),
3310
3709
  warnings: withDialogNote([...sendWarnings, selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })]).filter((warning) => Boolean(warning))
3311
3710
  };
3312
3711
  }
@@ -3321,6 +3720,7 @@ export async function sendChatGptPrompt(options) {
3321
3720
  answer: completed.answer.trim(),
3322
3721
  modelHints: completed.modelHints,
3323
3722
  ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
3723
+ ...(boundProjectId ? { boundProjectId } : {}),
3324
3724
  warnings: withDialogNote([
3325
3725
  ...sendWarnings,
3326
3726
  ...(selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) }) ? [selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })] : []),
@@ -5030,6 +5430,68 @@ export function deepResearchReportExpression(conversationId) {
5030
5430
  * Thread urls come in a plain (`/c/<id>`) and a project (`/g/g-p-.../c/<id>`)
5031
5431
  * shape; both end in the conversation id the backend API is keyed by.
5032
5432
  */
5433
+ /**
5434
+ * The project a ChatGPT URL belongs to, as its stable id.
5435
+ *
5436
+ * Measured live within one send: the SAME project renders as
5437
+ * `/g/g-p-<hash>/project` on its home and `/g/g-p-<hash>-<name>/c/<id>` on the
5438
+ * thread that came out of it. Comparing the slugs whole would call those two
5439
+ * different projects, so only the hash - the part that does not depend on how
5440
+ * the page felt like writing the name - identifies it.
5441
+ */
5442
+ export function chatGptProjectIdFromUrl(url) {
5443
+ if (!url)
5444
+ return undefined;
5445
+ const match = /\/g\/g-p-([0-9a-f]+)(?:[-/?#]|$)/i.exec(url);
5446
+ return match ? match[1].toLowerCase() : undefined;
5447
+ }
5448
+ /**
5449
+ * Where the prompt actually landed, against where it was aimed.
5450
+ *
5451
+ * The receipt recorded the project the caller ASKED for, which is intent, not
5452
+ * evidence: a send that ended up somewhere else was recorded under the name of
5453
+ * the place it never reached. The answered thread's URL carries the project it
5454
+ * really belongs to, and the project step knows the id it bound to, so the two
5455
+ * can be compared instead of assumed. "unverified" is its own answer - better
5456
+ * than a receipt that certifies what nobody checked.
5457
+ */
5458
+ export function destinationVerification(input) {
5459
+ const answeredProjectId = chatGptProjectIdFromUrl(input.answeredUrl);
5460
+ const destination = !input.answeredUrl ? "unknown" : answeredProjectId ? "project" : "root";
5461
+ if (!input.requestedProject)
5462
+ return { destination };
5463
+ if (destination === "unknown")
5464
+ return { destination, verified: false };
5465
+ // Landing outside every project is wrong on its own evidence: it needs no id
5466
+ // to compare against, and requiring one would have retired a warning that
5467
+ // caught this in the field.
5468
+ if (destination === "root") {
5469
+ return {
5470
+ destination,
5471
+ verified: false,
5472
+ warning: "project_landing_warning: a project was requested but the answered thread is a root chat, so it landed OUTSIDE the project. " +
5473
+ "Move it via the thread menu (Move to project) or re-run; list projects with `prodex pro browser projects`."
5474
+ };
5475
+ }
5476
+ // In a project, with nothing to check it against: not a warning, but not a
5477
+ // verified landing either.
5478
+ if (!input.boundProjectId)
5479
+ return { destination, verified: false };
5480
+ const verified = answeredProjectId === input.boundProjectId;
5481
+ return {
5482
+ destination,
5483
+ verified,
5484
+ ...(verified
5485
+ ? {}
5486
+ : {
5487
+ // Naming the project it landed in would put another project's name in
5488
+ // a persisted record; the thread URL is already there for anyone who
5489
+ // needs to go look.
5490
+ warning: "project_landing_warning: the answered thread belongs to a different project than the one this send entered. " +
5491
+ "Open the thread URL in the receipt to see where it went."
5492
+ })
5493
+ };
5494
+ }
5033
5495
  export function conversationIdFromThreadUrl(url) {
5034
5496
  const match = /\/c\/([0-9a-fA-F-]{16,})/.exec(url);
5035
5497
  return match ? match[1] : undefined;