@youdie006/prodex 0.40.0 → 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.
@@ -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,6 +1667,76 @@ 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;
1674
+ /**
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.
1682
+ *
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.
1689
+ */
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";
1739
+ }
1605
1740
  export function powerSliderPresentExpression() {
1606
1741
  return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"]'))`;
1607
1742
  }
@@ -2465,13 +2600,52 @@ async function createChatGptProject(cdp, name) {
2465
2600
  throw error;
2466
2601
  }
2467
2602
  }
2468
- async function selectProject(cdp, options) {
2469
- if (options.projectNew) {
2470
- await createChatGptProject(cdp, options.projectNew);
2471
- return;
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);
2472
2643
  }
2473
- if (!options.project)
2474
- return;
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) {
2475
2649
  const hrefBefore = await cdp.evaluate("location.href");
2476
2650
  // Poll for the project row instead of a single check: right after a
2477
2651
  // --new-chat navigation the sidebar's Projects section has not hydrated yet
@@ -2481,7 +2655,7 @@ async function selectProject(cdp, options) {
2481
2655
  let hit = { ok: false };
2482
2656
  const projectDeadline = Date.now() + 6_000;
2483
2657
  for (;;) {
2484
- hit = await cdp.evaluate(projectItemRectExpression(options.project));
2658
+ hit = await cdp.evaluate(projectItemRectExpression(project));
2485
2659
  if (hit.ok && hit.x !== undefined && hit.y !== undefined)
2486
2660
  break;
2487
2661
  if (Date.now() >= projectDeadline)
@@ -2490,9 +2664,9 @@ async function selectProject(cdp, options) {
2490
2664
  }
2491
2665
  if (!hit.ok || hit.x === undefined || hit.y === undefined) {
2492
2666
  const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
2493
- throw new Error(`ChatGPT project not found in sidebar: ${options.project}${detail} List the visible names with \`prodex pro browser projects\`.`);
2667
+ throw new Error(`ChatGPT project not found in sidebar: ${project}${detail} List the visible names with \`prodex pro browser projects\`.`);
2494
2668
  }
2495
- await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(options.project)), `project ${options.project}`);
2669
+ await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(project)), `project ${project}`);
2496
2670
  const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
2497
2671
  if (!navigated) {
2498
2672
  // The href staying put is fine ONLY when the tab was already on THIS
@@ -2502,7 +2676,7 @@ async function selectProject(cdp, options) {
2502
2676
  // project - which would silently send the prompt into the wrong project.
2503
2677
  const alreadyInRequestedProject = await cdp.evaluate(`(() => {
2504
2678
  if (!/^https:\\/\\/chatgpt\\.com\\/g\\/g-p-/.test(location.href)) return false;
2505
- const name = ${JSON.stringify(options.project)}.toLowerCase();
2679
+ const name = ${JSON.stringify(project)}.toLowerCase();
2506
2680
  // Case-insensitive EQUALITY (not substring): matches the case-insensitive
2507
2681
  // sidebar-row lookup (so "codex" is accepted while sitting on "Codex"),
2508
2682
  // but a stalled cross-project navigation must NOT be accepted just because
@@ -2513,28 +2687,98 @@ async function selectProject(cdp, options) {
2513
2687
  return [...document.querySelectorAll('h1,[role="heading"]')].some((h) => (h.innerText || "").trim().toLowerCase() === name);
2514
2688
  })()`);
2515
2689
  if (!alreadyInRequestedProject) {
2516
- 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.`);
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.`);
2517
2691
  }
2518
2692
  }
2519
- if (navigated) {
2520
- // A sidebar SPA navigation moves the URL to the target project while the
2521
- // composer can stay bound to the PREVIOUS project's conversation target, so
2522
- // the send silently creates the thread in the OLD project (reproduced live
2523
- // via PRODEX_DEBUG_SEND: baseline URL on the requested project, yet the
2524
- // prompt posted into the project the tab came from). A hard reload of the
2525
- // project home rebinds the composer to THIS project before we send.
2526
- const projectHome = await cdp.evaluate("location.href");
2527
- // Polled with no delay, the first check could run before the reload had
2528
- // committed, on the old document, whose composer and URL both still
2529
- // matched. The stamp keeps that document from passing as the new one.
2530
- const rebound = await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS, `location.href === ${JSON.stringify(projectHome)}`);
2531
- if (!rebound) {
2532
- throw new Error(`ChatGPT composer did not rebind after entering project "${options.project}"`);
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)}`;
2533
2762
  }
2534
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
+ }
2535
2779
  const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
2536
2780
  if (!composerReady) {
2537
- throw new Error(`ChatGPT composer did not appear after entering project "${options.project}"`);
2781
+ throw new Error(`ChatGPT composer did not appear after entering project "${wanted}"`);
2538
2782
  }
2539
2783
  }
2540
2784
  // Read the finished answer from an existing ChatGPT thread WITHOUT sending a new
@@ -2794,6 +3038,40 @@ export async function sendChatGptPrompt(options) {
2794
3038
  busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
2795
3039
  }
2796
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
+ }
2797
3075
  // A thread can be left rendered with no composer at all - measured after a
2798
3076
  // send, zero contenteditables and zero textareas on the page - and every
2799
3077
  // retry then lands on the same dead page and reports the same "missing a
@@ -2940,6 +3218,22 @@ export async function sendChatGptPrompt(options) {
2940
3218
  // into; a --project/--project-new hop lands on a page with its own counts.
2941
3219
  beforeSubmit = await evaluateOnPage(page, answerExpression());
2942
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
+ }
2943
3237
  // Attach BEFORE typing: the upload is the slow part, and a file that
2944
3238
  // arrives after the prompt is submitted is a file ChatGPT never saw.
2945
3239
  if (options.attachments && options.attachments.length > 0) {
package/dist/cli-pro.js CHANGED
@@ -1110,6 +1110,12 @@ export async function runAskProCommand(rest, io) {
1110
1110
  // Requiring --new-chat keeps that explicit rather than quietly turning a
1111
1111
  // continuation into a throwaway.
1112
1112
  const temporary = parsedAskPro.optionArgs.includes("--temporary");
1113
+ const temporaryConflict = temporaryProjectConflict({
1114
+ temporary,
1115
+ ...(explicitProject !== undefined ? { explicitProject } : {})
1116
+ });
1117
+ if (temporaryConflict)
1118
+ throw new Error(temporaryConflict);
1113
1119
  if (temporary && !newChat) {
1114
1120
  throw new Error("--temporary starts a throwaway chat, so it needs --new-chat. A temporary chat cannot be continued or recovered later.");
1115
1121
  }
@@ -1141,9 +1147,17 @@ export async function runAskProCommand(rest, io) {
1141
1147
  // fresh chat inside the project is exactly what "--new-chat + project"
1142
1148
  // produces, and the whole point of pinning a default project is that
1143
1149
  // consults stop landing in the general chat list. Only --target-url
1144
- // (pinned tab) and --project-new suppress it.
1150
+ // (pinned tab), --project-new, and --temporary suppress it.
1151
+ //
1152
+ // --temporary suppresses rather than conflicts: a pinned project must not
1153
+ // turn every throwaway send into an error, and attempting both is what
1154
+ // produced "composer did not rebind after entering project" - a temporary
1155
+ // chat is never saved, a project chat is, and entering a project leaves
1156
+ // temporary mode.
1145
1157
  const selectionProject = explicitProject ??
1146
- (normalizedTargetUrl || selectionProjectNew !== undefined || suppressProject ? undefined : browserDefaults?.project);
1158
+ (normalizedTargetUrl || selectionProjectNew !== undefined || suppressProject || temporary
1159
+ ? undefined
1160
+ : browserDefaults?.project);
1147
1161
  const reasoningAxisChosen = explicitProMode !== undefined || explicitEffort !== undefined;
1148
1162
  const selectionProMode = explicitProMode ?? (reasoningAxisChosen ? undefined : browserDefaults?.pro_mode);
1149
1163
  const selectionEffort = explicitEffort ?? (reasoningAxisChosen ? undefined : browserDefaults?.effort);
@@ -1796,6 +1810,19 @@ export function proSelectionVerified(selection) {
1796
1810
  return undefined;
1797
1811
  return /pro/i.test(selection.modelSlug);
1798
1812
  }
1813
+ /**
1814
+ * A temporary chat is not saved; a project chat is. Entering a project leaves
1815
+ * temporary mode, so asking for both is asking for two different things, and
1816
+ * prodex used to attempt both and die inside the project step with "composer
1817
+ * did not rebind". A pinned default project is suppressed instead of refused:
1818
+ * the per-call flag is the more specific instruction.
1819
+ */
1820
+ export function temporaryProjectConflict(input) {
1821
+ if (!input.temporary || input.explicitProject === undefined)
1822
+ return undefined;
1823
+ return (`--temporary and --project cannot be combined: a temporary chat is never saved, and a chat inside a project is. ` +
1824
+ `Drop --temporary to send into "${input.explicitProject}", or drop --project to send a throwaway chat.`);
1825
+ }
1799
1826
  export function browserSendBlockerFromError(error) {
1800
1827
  const blocker = typeof error === "object" && error !== null && "blocker" in error ? error.blocker : undefined;
1801
1828
  if (typeof blocker === "object" &&
@@ -1861,6 +1888,18 @@ export function browserSendBlockerFromError(error) {
1861
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."
1862
1889
  };
1863
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
+ }
1864
1903
  // The picker could not provide the step that was asked for. Retrying asks
1865
1904
  // the same picker the same question, so this is not retryable; the caller
1866
1905
  // either picks a step it offers or opts into whatever the slider is on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.0",
3
+ "version": "0.40.2",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",