@youdie006/prodex 0.25.1 → 0.26.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.
@@ -363,6 +363,48 @@ export async function ensureVirtualDisplay(options = {}) {
363
363
  }
364
364
  throw new Error(`No free X display between :${first} and :${first + 9}. Set PRODEX_VIRTUAL_DISPLAY_NUM to a free number.`);
365
365
  }
366
+ /**
367
+ * How should the dedicated browser be opened?
368
+ *
369
+ * An explicit flag or environment variable wins; otherwise reopen it the way it
370
+ * was last opened. Without the saved fallback, `pro browser login` - the exact
371
+ * command every `browser_unreachable` blocker tells people to run - put a
372
+ * VISIBLE window back on the desktop of someone who had set up a virtual
373
+ * display, which is the surprise window they went to that trouble to avoid.
374
+ *
375
+ * Choosing a mode explicitly replaces the saved one wholesale rather than
376
+ * merging with it: asking for headless must not also rejoin an old virtual
377
+ * display.
378
+ */
379
+ export function resolveBrowserWindowMode(args) {
380
+ const env = args.env ?? process.env;
381
+ const flags = args.flags ?? {};
382
+ const fromEnv = (name) => {
383
+ const raw = (env[name] ?? "").trim().toLowerCase();
384
+ if (raw === "")
385
+ return undefined;
386
+ return raw === "1" || raw === "true" || raw === "yes";
387
+ };
388
+ const explicit = {
389
+ headless: flags.headless ?? fromEnv("PRODEX_HEADLESS"),
390
+ virtualDisplay: flags.virtualDisplay ?? fromEnv("PRODEX_VIRTUAL_DISPLAY"),
391
+ minimized: flags.minimized ?? fromEnv("PRODEX_MINIMIZE_WINDOW")
392
+ };
393
+ const chosen = Object.values(explicit).some((value) => value === true);
394
+ if (chosen) {
395
+ return {
396
+ headless: explicit.headless === true,
397
+ virtualDisplay: explicit.virtualDisplay === true,
398
+ minimized: explicit.minimized === true
399
+ };
400
+ }
401
+ const saved = args.lastLogin;
402
+ return {
403
+ headless: saved?.headless === true,
404
+ virtualDisplay: saved?.virtual_display !== undefined,
405
+ minimized: saved?.minimized === true
406
+ };
407
+ }
366
408
  export function resolveHeadlessPreference(explicit, env = process.env) {
367
409
  if (typeof explicit === "boolean")
368
410
  return explicit;
@@ -416,6 +458,11 @@ export function isUsableChatGptAnswer(answer) {
416
458
  if (toolPanelOnly)
417
459
  return false;
418
460
  }
461
+ // ChatGPT's own interruption notice, which is what a thread shows after the
462
+ // browser dies mid-stream. Measured: recover saved "Pro thinking / Connection
463
+ // interrupted. Waiting for the complete answer" as a recovered answer.
464
+ if (/connection interrupted|연결이\s*중단/i.test(normalized) && normalized.length < 200)
465
+ return false;
419
466
  return true;
420
467
  }
421
468
  /**
@@ -1745,6 +1792,26 @@ export async function recoverChatGptAnswerFromThread(options) {
1745
1792
  warnings: []
1746
1793
  };
1747
1794
  }
1795
+ // Not a research thread: read the ordinary answer from the transcript
1796
+ // too. Recover used to be page-only, so it inherited everything the
1797
+ // page loses - flattened markdown, dropped citation urls - and it once
1798
+ // saved ChatGPT's "Connection interrupted" notice as the answer.
1799
+ const transcript = await evaluateOnPage(page.page, transcriptAnswerExpression(conversationId), {
1800
+ timeoutMs: 60_000
1801
+ });
1802
+ if (transcript.ok && transcript.text.trim().length > 0) {
1803
+ const recovered = resolveTranscriptCitations(transcript.text, transcript.references).trim();
1804
+ if (recovered.length > 0) {
1805
+ return {
1806
+ url,
1807
+ title: "",
1808
+ answer: recovered,
1809
+ modelHints: [],
1810
+ ...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : {}),
1811
+ warnings: []
1812
+ };
1813
+ }
1814
+ }
1748
1815
  }
1749
1816
  catch {
1750
1817
  // Not a research thread, or the transcript API is unavailable: fall
@@ -2128,12 +2195,21 @@ export async function sendChatGptPrompt(options) {
2128
2195
  if (!conversationId)
2129
2196
  throw new ChatGptBrowserBlockerError(deepResearchUnreadableBlocker(pinnedThreadUrl ?? "https://chatgpt.com/"));
2130
2197
  let lastState;
2198
+ let consecutiveResearchReadFailures = 0;
2131
2199
  while (Date.now() - started < timeoutMs) {
2132
2200
  try {
2133
2201
  lastState = await evaluateOnPage(page, deepResearchReportExpression(conversationId), { timeoutMs: 60_000 });
2202
+ consecutiveResearchReadFailures = 0;
2134
2203
  }
2135
2204
  catch {
2136
- // Transient CDP/network failure: keep polling until the budget runs out.
2205
+ // A few failures in a row mean the browser is gone, not busy. Waiting
2206
+ // out a 30-minute budget on a dead browser helps nobody: the research
2207
+ // finishes on ChatGPT's side anyway, so hand back the thread and let
2208
+ // recover collect the report.
2209
+ consecutiveResearchReadFailures += 1;
2210
+ if (consecutiveResearchReadFailures >= CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP) {
2211
+ throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(pinnedThreadUrl));
2212
+ }
2137
2213
  await sleep(5_000);
2138
2214
  continue;
2139
2215
  }
@@ -2164,11 +2240,13 @@ export async function sendChatGptPrompt(options) {
2164
2240
  }
2165
2241
  let recoveredNavigations = 0;
2166
2242
  let lastTranscriptClassification;
2243
+ let consecutiveReadFailures = 0;
2167
2244
  const answerIsStable = createChatGptAnswerStabilityTracker();
2168
2245
  while (Date.now() - started < timeoutMs) {
2169
2246
  await sleep(1000);
2170
2247
  try {
2171
2248
  finalState = await evaluateOnPage(page, answerExpression());
2249
+ consecutiveReadFailures = 0;
2172
2250
  // First conversation id wins. Re-deriving it every poll would let a tab
2173
2251
  // that wandered to another thread redirect the read to a stranger's
2174
2252
  // conversation - and the prompt check below is the second line of defence,
@@ -2206,7 +2284,13 @@ export async function sendChatGptPrompt(options) {
2206
2284
  throw error;
2207
2285
  // Transient CDP failure while the answer is streaming: retry. A throw here
2208
2286
  // would discard an already-streamed partial answer and skip the salvage
2209
- // path below, so keep the last good state and poll again until timeout.
2287
+ // path below, so keep the last good state and poll again. But a run of
2288
+ // failures is a browser that went away rather than a busy one, and
2289
+ // sitting out the whole budget on it only delays the recovery.
2290
+ consecutiveReadFailures += 1;
2291
+ if (consecutiveReadFailures >= CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP) {
2292
+ throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(finalState?.url ?? pinnedThreadUrl));
2293
+ }
2210
2294
  continue;
2211
2295
  }
2212
2296
  const runtimeBlocker = chatGptBlockerFromAnswerState(finalState);
@@ -3073,6 +3157,32 @@ export function chunkComposerText(text, size = COMPOSER_INSERT_CHUNK_CHARS) {
3073
3157
  * so hand the run back rather than waiting on a page that never renders it -
3074
3158
  * deep research draws into a widget iframe, leaving the thread DOM empty.
3075
3159
  */
3160
+ /**
3161
+ * The dedicated browser died while a send was waiting for its answer.
3162
+ *
3163
+ * Observed live: Chrome went away mid deep-research run, every poll threw, the
3164
+ * loop swallowed each failure, and the send sat silent for the rest of a
3165
+ * 30-minute budget - while the research finished on ChatGPT's side. Nothing is
3166
+ * lost except the ability to read it, so fail fast and hand back the thread.
3167
+ */
3168
+ // Reading the page can fail for a moment (a navigation, a busy renderer). Five
3169
+ // failures in a row is not a moment - it is a browser that went away.
3170
+ const CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP = 5;
3171
+ export function browserLostMidWaitBlocker(threadUrl) {
3172
+ // Killing the browser aborts a streaming answer too, so promise nothing
3173
+ // about the answer itself - only say where to look. Deep research is the one
3174
+ // case that genuinely keeps going without us.
3175
+ const where = threadUrl ? ` The consult landed in ${threadUrl}` : "";
3176
+ return {
3177
+ code: "browser_unreachable",
3178
+ message: `The dedicated ChatGPT browser stopped responding while this consult was waiting for its answer.${where}`,
3179
+ retryable: true,
3180
+ next_step: threadUrl
3181
+ ? `Run \`prodex pro browser login\` to reopen the browser, then collect the answer with \`prodex pro browser recover --target-url ${threadUrl}\` (MCP: pro_recover with thread ${threadUrl}).`
3182
+ : "Run `prodex pro browser login` to reopen the browser, then retry.",
3183
+ ...(threadUrl ? { thread: threadUrl } : {})
3184
+ };
3185
+ }
3076
3186
  export function deepResearchUnreadableBlocker(threadUrl) {
3077
3187
  return {
3078
3188
  code: "deep_research_not_readable",
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, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
5
+ import { DEFAULT_CDP_PORT, resolveCdpPort, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, 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";
@@ -222,14 +222,30 @@ export async function runProCommand(rest, io, runCliFn) {
222
222
  // again: Chrome's singleton would just open ANOTHER window (the recurring
223
223
  // "extra windows" problem, which then blocks sends as
224
224
  // ambiguous_chatgpt_tabs). Reuse the running instance instead.
225
- const headless = resolveHeadlessPreference(browserArgs.includes("--headless") ? true : undefined);
226
- // A real browser on a virtual X display: no window anywhere, and
227
- // Cloudflare sees an ordinary headed Chrome (headless it rejects).
228
- const wantsVirtualDisplay = resolveVirtualDisplayPreference(browserArgs.includes("--virtual-display") ? true : undefined);
229
- if (wantsVirtualDisplay && headless) {
225
+ if (browserArgs.includes("--virtual-display") && browserArgs.includes("--headless")) {
230
226
  throw new Error("pro browser login cannot combine --headless and --virtual-display (a virtual display already hides the window).");
231
227
  }
232
- const virtualDisplay = wantsVirtualDisplay ? await ensureVirtualDisplay() : undefined;
228
+ // Reopen the browser the way it was last opened unless a flag or the
229
+ // environment says otherwise. A virtual-display user who follows the
230
+ // `browser_unreachable` advice used to get a visible window back.
231
+ const savedLaunch = await readLastBrowserLoginLaunch();
232
+ const windowMode = resolveBrowserWindowMode({
233
+ flags: {
234
+ ...(browserArgs.includes("--headless") ? { headless: true } : {}),
235
+ ...(browserArgs.includes("--virtual-display") ? { virtualDisplay: true } : {}),
236
+ ...(browserArgs.includes("--minimized") ? { minimized: true } : {})
237
+ },
238
+ ...(savedLaunch ? { lastLogin: savedLaunch } : {})
239
+ });
240
+ const headless = windowMode.headless;
241
+ // A real browser on a virtual X display: no window anywhere, and
242
+ // Cloudflare sees an ordinary headed Chrome (headless it rejects).
243
+ const wantsVirtualDisplay = windowMode.virtualDisplay;
244
+ const virtualDisplay = wantsVirtualDisplay
245
+ ? await ensureVirtualDisplay(savedLaunch?.virtual_display !== undefined && !browserArgs.includes("--virtual-display")
246
+ ? { displayNumber: savedLaunch.virtual_display }
247
+ : {})
248
+ : undefined;
233
249
  const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
234
250
  if (alreadyRunning) {
235
251
  // One Chrome profile cannot serve a headed and a headless instance at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",