@youdie006/prodex 0.35.0 → 0.36.0

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.
@@ -596,8 +596,46 @@ export function modelSelectionWarning(requestedModel, modelSlug) {
596
596
  return undefined;
597
597
  return `model_mismatch: you asked for ${requestedModel}, but the answer came from "${modelSlug}". Check the model picker in the browser; the selection did not take.`;
598
598
  }
599
- export function chatGptBusyBlocker(generating) {
600
- if (!generating)
599
+ /**
600
+ * The panel ChatGPT raises to ask which of two answers you prefer.
601
+ *
602
+ * Recognized by its testids rather than its wording, so a UI in any language
603
+ * still matches. Read off the live page: the title is `paragen-feedback-title`
604
+ * and both choices are `paragen-prefer-response-button`.
605
+ */
606
+ export const CHATGPT_RESPONSE_CHOICE_SELECTOR = '[data-testid="paragen-feedback-title"],[data-testid="paragen-prefer-response-button"]';
607
+ /**
608
+ * A thread parked on that question, which no amount of waiting will clear.
609
+ *
610
+ * Measured live: both candidate answers had finished, the page had not changed
611
+ * in twenty seconds, and the composer's submit control was still
612
+ * `data-testid="stop-button"` - which prodex read as "generating". Every send
613
+ * afterwards waited out its whole budget and then advised waiting some more.
614
+ */
615
+ export function chatGptResponseChoiceBlocker(awaitingResponseChoice) {
616
+ if (!awaitingResponseChoice)
617
+ return undefined;
618
+ return {
619
+ code: "response_choice_pending",
620
+ message: "ChatGPT is asking which of two answers you prefer, and this thread stays locked until one is picked.",
621
+ retryable: true,
622
+ next_step: "Pick one in the browser (the two \"I prefer this response\" buttons), " +
623
+ "or leave the thread alone and send into a new chat (`--new-chat`)."
624
+ };
625
+ }
626
+ /**
627
+ * Whether that question stands in the way of THIS send. A send that navigates
628
+ * away - a fresh chat, or a project home - never touches the parked thread.
629
+ */
630
+ export function responseChoiceBlocksThisSend(input) {
631
+ if (!input.awaitingResponseChoice)
632
+ return false;
633
+ return !input.newChat && input.project === undefined && input.projectNew === undefined;
634
+ }
635
+ export function chatGptBusyBlocker(state) {
636
+ // Waiting for a choice is not generating. Conflating them turned a click
637
+ // away from ready into a full send budget spent on "still generating".
638
+ if (!state.generating || state.awaitingResponseChoice === true)
601
639
  return undefined;
602
640
  return {
603
641
  code: "response_in_progress",
@@ -608,6 +646,39 @@ export function chatGptBusyBlocker(generating) {
608
646
  "A new topic can go to a new chat instead."
609
647
  };
610
648
  }
649
+ /**
650
+ * The busy verdict, re-decided against the transcript.
651
+ *
652
+ * Measured on the real page: an ordinary conversation whose answer was complete
653
+ * kept `data-testid="stop-button"` (aria-label "Stop answering") unchanged over
654
+ * thirty seconds, while the transcript for that same conversation reported
655
+ * finished_successfully with end_turn set. The page control is decoration once
656
+ * the turn is over; the transcript is the record. Only a transcript that can be
657
+ * read AND says the turn finished clears the blocker - an unreadable one is no
658
+ * evidence of anything, and typing into a live generation corrupts the thread.
659
+ */
660
+ export function busyBlockerAfterTranscriptCheck(busyBlocker, transcript) {
661
+ if (!busyBlocker)
662
+ return undefined;
663
+ return transcript?.ok === true && transcript.isComplete === true ? undefined : busyBlocker;
664
+ }
665
+ /**
666
+ * Ask the transcript whether the conversation the tab is showing has finished.
667
+ * Undefined when there is nothing to ask about (a fresh chat or project home
668
+ * carries no conversation id) or the read fails.
669
+ */
670
+ async function readTranscriptCompletion(page, url) {
671
+ const conversationId = conversationIdFromThreadUrl(url);
672
+ if (!conversationId)
673
+ return undefined;
674
+ try {
675
+ const state = await evaluateOnPage(page, transcriptAnswerExpression(conversationId));
676
+ return { ok: state.ok === true, ...(state.isComplete !== undefined ? { isComplete: state.isComplete } : {}) };
677
+ }
678
+ catch {
679
+ return undefined;
680
+ }
681
+ }
611
682
  export function isLikelyChatGptSubmitButton(label, dataTestId) {
612
683
  const normalized = label.trim().toLowerCase();
613
684
  return dataTestId === "send-button" || /\b(send|submit)\b|보내기|전송/.test(normalized);
@@ -878,8 +949,12 @@ function chatGptPageMissingBlocker() {
878
949
  // readiness assert - otherwise it is misreported as "missing a visible prompt
879
950
  // composer" (measured live: continue-by-default consults landing on a thread
880
951
  // still generating the previous prodex answer).
881
- export function assertChatGptIdleAndReadyForPrompt(status) {
882
- const busyBlocker = chatGptBusyBlocker(status.generating);
952
+ export function assertChatGptIdleAndReadyForPrompt(status,
953
+ // The caller may have already weighed the page against the transcript and
954
+ // found the stop control stale. Re-deriving the verdict from the DOM here
955
+ // threw away that answer and failed the send the queue had just cleared.
956
+ decidedBusyBlocker, busyVerdictDecided = false) {
957
+ const busyBlocker = busyVerdictDecided ? decidedBusyBlocker : chatGptBusyBlocker(status);
883
958
  if (busyBlocker)
884
959
  throw new ChatGptBrowserBlockerError(busyBlocker);
885
960
  assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
@@ -1054,7 +1129,13 @@ export async function getChatGptBrowserStatus(options = {}) {
1054
1129
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
1055
1130
  });
1056
1131
  const loggedInLikely = inferChatGptPageLoggedInLikely(state);
1057
- const blocker = chatGptVisibilityBlocker(state.visibilityState, state.url) ?? detectChatGptPageBlocker(state) ?? chatGptBusyBlocker(state.generating);
1132
+ // The busy verdict is checked against the transcript here too, so `check`
1133
+ // does not report a finished conversation as one still being written.
1134
+ const busyBlocker = chatGptBusyBlocker(state);
1135
+ const blocker = chatGptVisibilityBlocker(state.visibilityState, state.url) ??
1136
+ detectChatGptPageBlocker(state) ??
1137
+ chatGptResponseChoiceBlocker(state.awaitingResponseChoice === true) ??
1138
+ (busyBlocker ? busyBlockerAfterTranscriptCheck(busyBlocker, await readTranscriptCompletion(page.page, state.url)) : undefined);
1058
1139
  return {
1059
1140
  reachable: true,
1060
1141
  loggedInLikely,
@@ -2009,7 +2090,7 @@ export async function sendChatGptPrompt(options) {
2009
2090
  // timeout: consults continue threads by default, so landing on a thread
2010
2091
  // whose previous (often timed-out Pro) answer is still streaming is a when,
2011
2092
  // not an if - queueing behind it beats failing.
2012
- let busyBlocker = chatGptBusyBlocker(status.generating);
2093
+ let busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
2013
2094
  const busyWaitBudgetMs = options.busyWaitMs ?? timeoutMs;
2014
2095
  if (busyBlocker && busyWaitBudgetMs > 0) {
2015
2096
  // Queue behind the in-flight response instead of failing: shared-tab
@@ -2023,17 +2104,32 @@ export async function sendChatGptPrompt(options) {
2023
2104
  const midBlocker = detectChatGptPageBlocker(status);
2024
2105
  if (midBlocker)
2025
2106
  throw new ChatGptBrowserBlockerError(midBlocker);
2026
- busyBlocker = chatGptBusyBlocker(status.generating);
2107
+ busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
2027
2108
  if (busyBlocker)
2028
2109
  emitProgress("waiting", "tab busy with another response; waiting");
2029
2110
  }
2030
2111
  if (!busyBlocker) {
2031
2112
  // The composer takes a moment to unlock after generation ends; settle
2032
- // again so the readiness assert below sees the reopened composer.
2113
+ // again so the readiness assert below sees the reopened composer, and
2114
+ // weigh that fresh reading the same way - a generation that started in
2115
+ // the meantime must still hold the send back.
2033
2116
  status = await readSettledChatGptPageStatus(page);
2117
+ busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
2034
2118
  }
2035
2119
  }
2036
- assertChatGptIdleAndReadyForPrompt(status);
2120
+ // A thread ChatGPT has parked on "which response do you prefer?" will not
2121
+ // unpark on its own. Sends that navigate away - a fresh chat, a project home
2122
+ // - never touch it; a send that means to continue THAT thread has to say so
2123
+ // now, rather than type into a composer that will not post.
2124
+ if (responseChoiceBlocksThisSend({
2125
+ awaitingResponseChoice: status.awaitingResponseChoice === true,
2126
+ ...(options.newChat !== undefined ? { newChat: options.newChat } : {}),
2127
+ ...(options.project !== undefined ? { project: options.project } : {}),
2128
+ ...(options.projectNew !== undefined ? { projectNew: options.projectNew } : {})
2129
+ })) {
2130
+ throw new ChatGptBrowserBlockerError(chatGptResponseChoiceBlocker(true));
2131
+ }
2132
+ assertChatGptIdleAndReadyForPrompt(status, busyBlocker, true);
2037
2133
  if (normalizedTargetUrl)
2038
2134
  assertChatGptTargetUrlMatches(status.url, normalizedTargetUrl);
2039
2135
  assertVisibleChatGptTab(status.visibilityState, status.url, normalizedTargetUrl);
@@ -2580,7 +2676,15 @@ export function findLaunchedBrowserProcesses(psOutput, input) {
2580
2676
  // made a check against an unused port report a healthy Chrome as wedged.
2581
2677
  if (mains.length === 0)
2582
2678
  return [];
2583
- const helpers = input.profileDir.length > 0 ? lines.filter((line) => line.includes(input.profileDir) && !mains.includes(line)) : [];
2679
+ // Which profile the helpers belong to is the browser's answer, not the
2680
+ // caller's: `check` has only a port, and matching against the profile it
2681
+ // assumed both missed this browser's renderers and collected a stranger's.
2682
+ // Read it off the process that answered to the port; fall back to what the
2683
+ // caller passed only when the command line does not say.
2684
+ // Stop at the next flag, so a profile path containing spaces survives.
2685
+ const profileOf = (line) => /--user-data-dir=(.*?)(?=\s+-{1,2}\w|\s*$)/.exec(line)?.[1];
2686
+ const profileDir = profileOf(mains[0]) ?? input.profileDir;
2687
+ const helpers = profileDir.length > 0 ? lines.filter((line) => profileOf(line) === profileDir && !mains.includes(line)) : [];
2584
2688
  return [...mains, ...helpers].map(pidOf).filter((pid) => pid !== undefined);
2585
2689
  }
2586
2690
  /**
@@ -3074,6 +3178,7 @@ export function statusExpression() {
3074
3178
  const excludedTextSelector = JSON.stringify(CHATGPT_RUNTIME_BLOCKER_TEXT_EXCLUDED_ANCESTORS);
3075
3179
  const blockerScanExcludedSelector = JSON.stringify(CHATGPT_BLOCKER_SCAN_EXCLUDED_ANCESTORS);
3076
3180
  const streamingSelector = JSON.stringify(CHATGPT_STREAMING_SELECTOR);
3181
+ const responseChoiceSelector = JSON.stringify(CHATGPT_RESPONSE_CHOICE_SELECTOR);
3077
3182
  const generatingControlPattern = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.source);
3078
3183
  const generatingControlFlags = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.flags);
3079
3184
  return `(() => {
@@ -3127,6 +3232,7 @@ export function statusExpression() {
3127
3232
  visibleButtonLabels,
3128
3233
  hasComposer,
3129
3234
  generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || visibleButtonLabels.some((label) => generatingControlPattern.test(label)),
3235
+ awaitingResponseChoice: Boolean(document.querySelector(${responseChoiceSelector})),
3130
3236
  modelHints: lines.filter((line) => /GPT|Pro|Thinking|ChatGPT|Extra High|Auto/i.test(line)).slice(0, 30),
3131
3237
  openDialogText: (([...document.querySelectorAll('[role="dialog"]')].find((d) => d.offsetWidth || d.offsetHeight || d.getClientRects().length)?.innerText) || "").trim().slice(0, 200)
3132
3238
  };
@@ -3961,6 +4067,7 @@ export function answerExpression() {
3961
4067
  const excludedTextSelector = JSON.stringify(CHATGPT_RUNTIME_BLOCKER_TEXT_EXCLUDED_ANCESTORS);
3962
4068
  const blockerScanExcludedSelector = JSON.stringify(CHATGPT_BLOCKER_SCAN_EXCLUDED_ANCESTORS);
3963
4069
  const streamingSelector = JSON.stringify(CHATGPT_STREAMING_SELECTOR);
4070
+ const responseChoiceSelector = JSON.stringify(CHATGPT_RESPONSE_CHOICE_SELECTOR);
3964
4071
  const generatingControlPattern = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.source);
3965
4072
  const generatingControlFlags = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.flags);
3966
4073
  return `(() => {
@@ -4025,6 +4132,7 @@ export function answerExpression() {
4025
4132
  blockerScanTextSample: visibleTextOutsideMessages(blockerScanExcludedSelector).slice(0, 12000),
4026
4133
  visibleButtonLabels: buttons,
4027
4134
  generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || buttons.some((label) => generatingControlPattern.test(label)),
4135
+ awaitingResponseChoice: Boolean(document.querySelector(${responseChoiceSelector})),
4028
4136
  assistantMessageCount: assistantMessages.length,
4029
4137
  userMessageCount: userMessages.length,
4030
4138
  // ChatGPT tags each assistant message with the model that produced it -
package/dist/cli-pro.js CHANGED
@@ -1473,7 +1473,16 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1473
1473
  // a second Chrome on the same profile joins the wedged one rather than
1474
1474
  // replacing it, and the wedged one keeps burning CPU while nobody looks. This
1475
1475
  // runs unattended for agents, so it is the path that let four days pass.
1476
- const wedged = findWedgedBrowser({ ...(options.port !== undefined ? { port: options.port } : {}) });
1476
+ // The same record the relaunch below reads, read once and used for both: the
1477
+ // scan matches the main process by port but its helpers by profile, so
1478
+ // scanning the DEFAULT profile for a custom-profile user put a second,
1479
+ // healthy browser's renderers on the list this function kills.
1480
+ const lastLogin = await readLastBrowserLoginLaunch().catch(() => undefined);
1481
+ const scanFor = {
1482
+ ...(options.port !== undefined ? { port: options.port } : {}),
1483
+ ...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {})
1484
+ };
1485
+ const wedged = findWedgedBrowser(scanFor);
1477
1486
  if (wedged.length > 0) {
1478
1487
  // Confirm the silence before ending anything: one missed poll is a busy
1479
1488
  // browser, several in a row is a dead one.
@@ -1496,7 +1505,7 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1496
1505
  // delay: the replacement launch fails outright if the old process still
1497
1506
  // holds it, which is how the first self-heal attempt ended.
1498
1507
  for (let attempt = 0; attempt < 10; attempt += 1) {
1499
- if (findWedgedBrowser({ ...(options.port !== undefined ? { port: options.port } : {}) }).length === 0)
1508
+ if (findWedgedBrowser(scanFor).length === 0)
1500
1509
  break;
1501
1510
  await sleep(1_000);
1502
1511
  }
@@ -1506,7 +1515,6 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1506
1515
  // Reuse the profile the user last logged in with; launching the default
1507
1516
  // profile for a custom-profile user would wait on the wrong (logged-out)
1508
1517
  // profile or, worse, silently send to a different account.
1509
- const lastLogin = await readLastBrowserLoginLaunch();
1510
1518
  // Relaunch in the SAME window mode the user chose: silently reopening a
1511
1519
  // visible window for someone running headless would be exactly the
1512
1520
  // surprise window they turned headless to avoid.
@@ -1556,15 +1564,23 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
1556
1564
  const startedAt = now();
1557
1565
  stderr("login: waiting for a logged-in ChatGPT tab (finish login in the dedicated Chrome window; Ctrl+C stops waiting)...");
1558
1566
  let lastState = "";
1559
- let openedMissingTab = false;
1567
+ let openMissingTabAttempts = 0;
1560
1568
  while (now() - startedAt < timeoutMs) {
1561
1569
  const status = await statusFn({ port: options.port, timeoutMs: 1_500 });
1562
1570
  // A running Chrome with no chatgpt.com tab leaves the user nothing to log
1563
- // into. Open the tab once rather than waiting for one to appear.
1564
- if (status.reachable && status.blocker?.code === "chatgpt_page_missing" && !openedMissingTab) {
1565
- openedMissingTab = true;
1566
- stderr("login: the running Chrome had no ChatGPT tab - opening a ChatGPT tab in it...");
1567
- await openTabFn(options.port);
1571
+ // into, so prodex opens one. Keep trying while the tab is still missing:
1572
+ // one silent attempt that fails looks exactly like no attempt, which is how
1573
+ // a real machine sat for a minute reporting the missing tab and ended
1574
+ // not-ready with nothing to go on.
1575
+ if (status.reachable && status.blocker?.code === "chatgpt_page_missing") {
1576
+ const attempt = openMissingTabAttempts + 1;
1577
+ openMissingTabAttempts = attempt;
1578
+ stderr(attempt === 1
1579
+ ? "login: the running Chrome had no ChatGPT tab - opening a ChatGPT tab in it..."
1580
+ : `login: still no ChatGPT tab - opening one again (attempt ${attempt})...`);
1581
+ const opened = await openTabFn(options.port);
1582
+ if (opened === false)
1583
+ stderr("login: could not open a ChatGPT tab through the debug port; open https://chatgpt.com/ in that window.");
1568
1584
  await sleepFn(pollMs);
1569
1585
  continue;
1570
1586
  }
package/dist/cli.js CHANGED
@@ -116,7 +116,10 @@ export async function runCli(args, io = defaultIo()) {
116
116
  // A person typing `prodex` used to get the agent-facing command wall and no
117
117
  // way in. On a terminal, walk them through a consult instead; piped or
118
118
  // scripted callers still get the banner and the command list they parse.
119
- if (!command && io.isInteractive === true)
119
+ // A keyboard as well as a screen: with stdin redirected there is nothing to
120
+ // answer the picker's questions with, and `prodex` alone should still print
121
+ // the command list rather than refuse.
122
+ if (!command && io.isInteractive === true && process.stdin.isTTY === true)
120
123
  return runInteractiveUi(io);
121
124
  if (!command || command === "help" || command === "--help" || command === "-h") {
122
125
  if (shouldColorize())
package/dist/tui-run.js CHANGED
@@ -136,6 +136,17 @@ async function askLine(io, question) {
136
136
  * Returns the process exit code.
137
137
  */
138
138
  export async function runInteractiveConsult(io, deps) {
139
+ // Every question here is answered with a keystroke, so a stdin that cannot
140
+ // deliver one has to be turned away at the door. Measured with stdin
141
+ // redirected: the picker painted the alternate screen, hid the cursor, and
142
+ // waited forever for a key that could not arrive - and never returning meant
143
+ // the screen was never restored either, leaving a terminal that needed
144
+ // `reset`.
145
+ if (io.input.isTTY !== true) {
146
+ io.write("The picker reads single keystrokes, so it needs a terminal on stdin.\n");
147
+ io.write('Send without one: prodex pro browser ask "your prompt"\n');
148
+ return 1;
149
+ }
139
150
  const now = deps.now ?? Date.now;
140
151
  readline.emitKeypressEvents(io.input);
141
152
  io.input.setRawMode?.(true);
@@ -278,6 +289,12 @@ export async function runInteractiveConsult(io, deps) {
278
289
  // Leave the alternate screen before the send: the answer, the receipt id
279
290
  // and any blocker belong in the scrollback the user keeps.
280
291
  io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
292
+ // Hand the terminal back too. Raw mode is what let the pickers read single
293
+ // keys, and it also turns off the terminal's own ctrl-c: the key arrives as
294
+ // a keypress nothing is waiting for. The progress bar promises "ctrl-c to
295
+ // stop", and under raw mode that promise was false for the whole ten
296
+ // minutes a deep research send runs. No key is read from here on.
297
+ io.input.setRawMode?.(false);
281
298
  // --target-url confirms which conversation a send means; it deliberately
282
299
  // does not navigate. Picking one from a list IS a request to go there, so
283
300
  // move the tab first and let the flag confirm it landed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",