@youdie006/prodex 0.35.1 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -440,6 +440,8 @@ npm run dev -- tasks list
440
440
 
441
441
  **Sends started failing after many consults in one chat.** Long accumulated threads eventually confuse prompt-acceptance detection (measured live around ten-plus messages). Send repeated consults into fresh chats with `--new-chat` (`new_chat: true` on the MCP tool) — the answer still lands in your account and in `.bridge/` receipts either way.
442
442
 
443
+ **Every send says "still generating" but nothing is being written.** ChatGPT sometimes parks a thread on "You're giving feedback on a new version of ChatGPT - which response do you prefer?", and while it waits the composer keeps its stop control. prodex reports that as a `response_choice_pending` blocker naming the two "I prefer this response" buttons, because waiting will never clear it. Sends that navigate away (`--new-chat`, or any send into a project) are unaffected. A stop control can also linger for a few seconds after an ordinary answer finishes; prodex checks the conversation transcript before believing it, so a finished thread is not mistaken for a busy one.
444
+
443
445
  **"menu item not found" on `--effort`/`--pro-mode`/`--project`.** Selection matches the visible menu labels, verified in the Korean and English (US) ChatGPT UI. On another display language, run `prodex pro browser models` to see your labels and pass `--model "<exact label>"`, which clicks any picker radio by exact text.
444
446
 
445
447
  **Does this risk my ChatGPT account?** `prodex` is deliberately not a stealth bot: it uses a real visible browser, you log in manually, and it stops on captcha/verification instead of solving it. It does drive chatgpt.com, though, so keep usage at the human, occasional-consult volume the auto-pacing enforces — do not build tight recurring loops on top of it. Automating a paid account is your responsibility under OpenAI's terms.
@@ -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);
@@ -2447,16 +2543,40 @@ export async function sendChatGptPrompt(options) {
2447
2543
  throw Object.assign(new Error(`Timed out after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms) waiting for ChatGPT to respond. ` +
2448
2544
  "Pro reasoning can run many minutes. Raise --timeout-ms and retry."), completed?.url ? { thread: completed.url } : {});
2449
2545
  }
2546
+ /**
2547
+ * One line of `pro browser models`.
2548
+ *
2549
+ * The listing used to append "not selectable via --model yet" to every submenu
2550
+ * row, which is now every row that matters - and it is not true: measured
2551
+ * against this same picker, --model Pro, --effort 즉시 and --pro-mode 확장 all
2552
+ * selected, because selection walks into the submenu. Saying what the row is
2553
+ * set to is the useful half; the false warning was the harmful half.
2554
+ */
2555
+ export function formatModelMenuOption(option) {
2556
+ const marker = option.checked ? "*" : " ";
2557
+ const value = option.kind === "submenu" && option.value ? ` -> ${option.value}` : "";
2558
+ return `${marker} ${option.label}${value}`;
2559
+ }
2450
2560
  export function modelMenuOptionsExpression() {
2451
2561
  return `(() => {
2452
2562
  const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
2453
2563
  if (!m) return [];
2454
2564
  return [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')]
2455
- .map((it) => ({
2456
- label: (((it.innerText || it.textContent || "").trim().split(String.fromCharCode(10))[0]) || "").trim(),
2457
- kind: it.getAttribute("aria-haspopup") === "menu" ? "submenu" : "radio",
2458
- checked: it.getAttribute("aria-checked") === "true"
2459
- }))
2565
+ .map((it) => {
2566
+ // A submenu row renders as label over value ("Model" / "GPT-5.6 Sol"),
2567
+ // and the value is the part a person actually wants to read.
2568
+ const lines = (it.innerText || it.textContent || "")
2569
+ .split(String.fromCharCode(10))
2570
+ .map((line) => line.trim())
2571
+ .filter((line) => line.length > 0);
2572
+ const value = lines[1] || "";
2573
+ return {
2574
+ label: lines[0] || "",
2575
+ kind: it.getAttribute("aria-haspopup") === "menu" ? "submenu" : "radio",
2576
+ checked: it.getAttribute("aria-checked") === "true",
2577
+ ...(value ? { value } : {})
2578
+ };
2579
+ })
2460
2580
  .filter((o) => o.label.length > 0);
2461
2581
  })()`;
2462
2582
  }
@@ -2580,7 +2700,15 @@ export function findLaunchedBrowserProcesses(psOutput, input) {
2580
2700
  // made a check against an unused port report a healthy Chrome as wedged.
2581
2701
  if (mains.length === 0)
2582
2702
  return [];
2583
- const helpers = input.profileDir.length > 0 ? lines.filter((line) => line.includes(input.profileDir) && !mains.includes(line)) : [];
2703
+ // Which profile the helpers belong to is the browser's answer, not the
2704
+ // caller's: `check` has only a port, and matching against the profile it
2705
+ // assumed both missed this browser's renderers and collected a stranger's.
2706
+ // Read it off the process that answered to the port; fall back to what the
2707
+ // caller passed only when the command line does not say.
2708
+ // Stop at the next flag, so a profile path containing spaces survives.
2709
+ const profileOf = (line) => /--user-data-dir=(.*?)(?=\s+-{1,2}\w|\s*$)/.exec(line)?.[1];
2710
+ const profileDir = profileOf(mains[0]) ?? input.profileDir;
2711
+ const helpers = profileDir.length > 0 ? lines.filter((line) => profileOf(line) === profileDir && !mains.includes(line)) : [];
2584
2712
  return [...mains, ...helpers].map(pidOf).filter((pid) => pid !== undefined);
2585
2713
  }
2586
2714
  /**
@@ -3074,6 +3202,7 @@ export function statusExpression() {
3074
3202
  const excludedTextSelector = JSON.stringify(CHATGPT_RUNTIME_BLOCKER_TEXT_EXCLUDED_ANCESTORS);
3075
3203
  const blockerScanExcludedSelector = JSON.stringify(CHATGPT_BLOCKER_SCAN_EXCLUDED_ANCESTORS);
3076
3204
  const streamingSelector = JSON.stringify(CHATGPT_STREAMING_SELECTOR);
3205
+ const responseChoiceSelector = JSON.stringify(CHATGPT_RESPONSE_CHOICE_SELECTOR);
3077
3206
  const generatingControlPattern = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.source);
3078
3207
  const generatingControlFlags = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.flags);
3079
3208
  return `(() => {
@@ -3127,6 +3256,7 @@ export function statusExpression() {
3127
3256
  visibleButtonLabels,
3128
3257
  hasComposer,
3129
3258
  generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || visibleButtonLabels.some((label) => generatingControlPattern.test(label)),
3259
+ awaitingResponseChoice: Boolean(document.querySelector(${responseChoiceSelector})),
3130
3260
  modelHints: lines.filter((line) => /GPT|Pro|Thinking|ChatGPT|Extra High|Auto/i.test(line)).slice(0, 30),
3131
3261
  openDialogText: (([...document.querySelectorAll('[role="dialog"]')].find((d) => d.offsetWidth || d.offsetHeight || d.getClientRects().length)?.innerText) || "").trim().slice(0, 200)
3132
3262
  };
@@ -3961,6 +4091,7 @@ export function answerExpression() {
3961
4091
  const excludedTextSelector = JSON.stringify(CHATGPT_RUNTIME_BLOCKER_TEXT_EXCLUDED_ANCESTORS);
3962
4092
  const blockerScanExcludedSelector = JSON.stringify(CHATGPT_BLOCKER_SCAN_EXCLUDED_ANCESTORS);
3963
4093
  const streamingSelector = JSON.stringify(CHATGPT_STREAMING_SELECTOR);
4094
+ const responseChoiceSelector = JSON.stringify(CHATGPT_RESPONSE_CHOICE_SELECTOR);
3964
4095
  const generatingControlPattern = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.source);
3965
4096
  const generatingControlFlags = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.flags);
3966
4097
  return `(() => {
@@ -4025,6 +4156,7 @@ export function answerExpression() {
4025
4156
  blockerScanTextSample: visibleTextOutsideMessages(blockerScanExcludedSelector).slice(0, 12000),
4026
4157
  visibleButtonLabels: buttons,
4027
4158
  generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || buttons.some((label) => generatingControlPattern.test(label)),
4159
+ awaitingResponseChoice: Boolean(document.querySelector(${responseChoiceSelector})),
4028
4160
  assistantMessageCount: assistantMessages.length,
4029
4161
  userMessageCount: userMessages.length,
4030
4162
  // ChatGPT tags each assistant message with the model that produced it -
package/dist/cli-help.js CHANGED
@@ -295,7 +295,7 @@ Commands:
295
295
  ${askUsage}
296
296
  ${recoverUsage}
297
297
 
298
- Visible-browser sends require a manual browser session and stop on login, captcha, Cloudflare, permission, rate-limit, or usage-limit blockers.
298
+ Visible-browser sends require a manual browser session and stop on login, captcha, Cloudflare, permission, rate-limit, or usage-limit blockers, plus response_choice_pending when ChatGPT is waiting for you to pick which of two answers you prefer.
299
299
  Model/project selection (ask):
300
300
  --model Composer model to pick by its exact menu label (verified: Pro). Models whose menu entry opens a submenu of variants are rejected with a clear error for now.
301
301
  --pro-mode Pro sub-mode: 기본 (standard) or 확장 (extended), used when the model is Pro. A Pro selection raises the default --timeout-ms to 1200000.
package/dist/cli-pro.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, statSync } from "node:fs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { buildDryRunBundle } from "./bundle.js";
5
- import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
5
+ import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
6
6
  import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
7
7
  import { printProBrowserHelp, printProHelp } from "./cli-help.js";
8
8
  import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
@@ -442,11 +442,9 @@ export async function runProCommand(rest, io, runCliFn) {
442
442
  }
443
443
  io.stdout("Model menu options in the visible ChatGPT tab (read-only; nothing was selected):");
444
444
  for (const option of listed.options) {
445
- const marker = option.checked ? "*" : " ";
446
- const suffix = option.kind === "submenu" ? " (has sub-variants; not selectable via --model yet)" : "";
447
- io.stdout(`${marker} ${option.label}${suffix}`);
445
+ io.stdout(formatModelMenuOption(option));
448
446
  }
449
- io.stdout("Use radio entries with `pro browser ask --model/--effort` (e.g. --model Pro).");
447
+ io.stdout("An arrow shows what that row is set to now; --model / --effort reach into those submenus (e.g. --model Pro).");
450
448
  return 0;
451
449
  }
452
450
  if (browserSubcommand === "projects") {
@@ -1473,7 +1471,16 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1473
1471
  // a second Chrome on the same profile joins the wedged one rather than
1474
1472
  // replacing it, and the wedged one keeps burning CPU while nobody looks. This
1475
1473
  // runs unattended for agents, so it is the path that let four days pass.
1476
- const wedged = findWedgedBrowser({ ...(options.port !== undefined ? { port: options.port } : {}) });
1474
+ // The same record the relaunch below reads, read once and used for both: the
1475
+ // scan matches the main process by port but its helpers by profile, so
1476
+ // scanning the DEFAULT profile for a custom-profile user put a second,
1477
+ // healthy browser's renderers on the list this function kills.
1478
+ const lastLogin = await readLastBrowserLoginLaunch().catch(() => undefined);
1479
+ const scanFor = {
1480
+ ...(options.port !== undefined ? { port: options.port } : {}),
1481
+ ...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {})
1482
+ };
1483
+ const wedged = findWedgedBrowser(scanFor);
1477
1484
  if (wedged.length > 0) {
1478
1485
  // Confirm the silence before ending anything: one missed poll is a busy
1479
1486
  // browser, several in a row is a dead one.
@@ -1496,7 +1503,7 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1496
1503
  // delay: the replacement launch fails outright if the old process still
1497
1504
  // holds it, which is how the first self-heal attempt ended.
1498
1505
  for (let attempt = 0; attempt < 10; attempt += 1) {
1499
- if (findWedgedBrowser({ ...(options.port !== undefined ? { port: options.port } : {}) }).length === 0)
1506
+ if (findWedgedBrowser(scanFor).length === 0)
1500
1507
  break;
1501
1508
  await sleep(1_000);
1502
1509
  }
@@ -1506,7 +1513,6 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1506
1513
  // Reuse the profile the user last logged in with; launching the default
1507
1514
  // profile for a custom-profile user would wait on the wrong (logged-out)
1508
1515
  // profile or, worse, silently send to a different account.
1509
- const lastLogin = await readLastBrowserLoginLaunch();
1510
1516
  // Relaunch in the SAME window mode the user chose: silently reopening a
1511
1517
  // visible window for someone running headless would be exactly the
1512
1518
  // surprise window they turned headless to avoid.
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())
@@ -433,7 +436,7 @@ repo: ${cwd}
433
436
 
434
437
  Safety notes:
435
438
  - This command only prints commands; it does not start servers, open browsers, or write files.
436
- - Visible-browser sends require a manual, visible browser session and stop on login, captcha, Cloudflare, permission, rate-limit, or usage-limit blockers.`;
439
+ - Visible-browser sends require a manual, visible browser session and stop on login, captcha, Cloudflare, permission, rate-limit, or usage-limit blockers, plus response_choice_pending when ChatGPT is waiting for you to pick which of two answers you prefer.`;
437
440
  }
438
441
  async function hasOnboardingReadme(cwd) {
439
442
  try {
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.1",
3
+ "version": "0.36.1",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",