@youdie006/prodex 0.25.0 → 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
  /**
@@ -439,6 +486,26 @@ export function classifyTranscriptRead(state, sentPrompt) {
439
486
  ? "pending"
440
487
  : "unavailable";
441
488
  }
489
+ /**
490
+ * Should prodex drag the tab back to the thread it pinned?
491
+ *
492
+ * Only when the pin is a real conversation, and only when the page is the only
493
+ * way left to read the answer. Pinning a project or new-chat page (the url a
494
+ * send starts on, before ChatGPT rewrites it to /c/<id>) made prodex treat its
495
+ * OWN conversation as a stray tab and navigate away from the answer it was
496
+ * waiting for. And while the transcript can answer, moving someone else's tab
497
+ * back buys nothing.
498
+ */
499
+ export function shouldRecoverThreadNavigation(args) {
500
+ const { pinnedThreadUrl, currentUrl, lastTranscriptClassification } = args;
501
+ if (!pinnedThreadUrl || !currentUrl)
502
+ return false;
503
+ if (!conversationIdFromThreadUrl(pinnedThreadUrl))
504
+ return false;
505
+ if (lastTranscriptClassification === "pending" || lastTranscriptClassification === "answer")
506
+ return false;
507
+ return !chatGptUrlsReferToSameTarget(currentUrl, pinnedThreadUrl);
508
+ }
442
509
  export function hasFreshChatGptAnswer(previousAssistantMessageCount, state) {
443
510
  return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer) && !state.generating;
444
511
  }
@@ -1725,6 +1792,26 @@ export async function recoverChatGptAnswerFromThread(options) {
1725
1792
  warnings: []
1726
1793
  };
1727
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
+ }
1728
1815
  }
1729
1816
  catch {
1730
1817
  // Not a research thread, or the transcript API is unavailable: fall
@@ -2108,12 +2195,21 @@ export async function sendChatGptPrompt(options) {
2108
2195
  if (!conversationId)
2109
2196
  throw new ChatGptBrowserBlockerError(deepResearchUnreadableBlocker(pinnedThreadUrl ?? "https://chatgpt.com/"));
2110
2197
  let lastState;
2198
+ let consecutiveResearchReadFailures = 0;
2111
2199
  while (Date.now() - started < timeoutMs) {
2112
2200
  try {
2113
2201
  lastState = await evaluateOnPage(page, deepResearchReportExpression(conversationId), { timeoutMs: 60_000 });
2202
+ consecutiveResearchReadFailures = 0;
2114
2203
  }
2115
2204
  catch {
2116
- // 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
+ }
2117
2213
  await sleep(5_000);
2118
2214
  continue;
2119
2215
  }
@@ -2143,11 +2239,14 @@ export async function sendChatGptPrompt(options) {
2143
2239
  });
2144
2240
  }
2145
2241
  let recoveredNavigations = 0;
2242
+ let lastTranscriptClassification;
2243
+ let consecutiveReadFailures = 0;
2146
2244
  const answerIsStable = createChatGptAnswerStabilityTracker();
2147
2245
  while (Date.now() - started < timeoutMs) {
2148
2246
  await sleep(1000);
2149
2247
  try {
2150
2248
  finalState = await evaluateOnPage(page, answerExpression());
2249
+ consecutiveReadFailures = 0;
2151
2250
  // First conversation id wins. Re-deriving it every poll would let a tab
2152
2251
  // that wandered to another thread redirect the read to a stranger's
2153
2252
  // conversation - and the prompt check below is the second line of defence,
@@ -2159,12 +2258,11 @@ export async function sendChatGptPrompt(options) {
2159
2258
  // instead of caret heuristics, and the model that actually answered.
2160
2259
  if (transcriptConversationId && !finalState.generating) {
2161
2260
  const transcript = await readTranscriptAnswer(page, transcriptConversationId, options.prompt);
2261
+ lastTranscriptClassification = transcript.classification;
2162
2262
  if (transcript.answer)
2163
2263
  return transcriptResult(transcript.answer);
2164
2264
  }
2165
- // Only the DOM reader depends on which thread the tab is showing; once the
2166
- // conversation id is known, a wandering tab is harmless.
2167
- if (!transcriptConversationId && pinnedThreadUrl && finalState?.url && !chatGptUrlsReferToSameTarget(finalState.url, pinnedThreadUrl)) {
2265
+ if (shouldRecoverThreadNavigation({ pinnedThreadUrl, currentUrl: finalState?.url, lastTranscriptClassification })) {
2168
2266
  if (recoveredNavigations >= 2) {
2169
2267
  throw new ChatGptBrowserBlockerError({
2170
2268
  code: "thread_navigated_away",
@@ -2186,7 +2284,13 @@ export async function sendChatGptPrompt(options) {
2186
2284
  throw error;
2187
2285
  // Transient CDP failure while the answer is streaming: retry. A throw here
2188
2286
  // would discard an already-streamed partial answer and skip the salvage
2189
- // 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
+ }
2190
2294
  continue;
2191
2295
  }
2192
2296
  const runtimeBlocker = chatGptBlockerFromAnswerState(finalState);
@@ -2205,6 +2309,7 @@ export async function sendChatGptPrompt(options) {
2205
2309
  // code that innerText flattens) and the model that actually answered.
2206
2310
  if (transcriptConversationId) {
2207
2311
  const transcript = await readTranscriptAnswer(page, transcriptConversationId, options.prompt);
2312
+ lastTranscriptClassification = transcript.classification;
2208
2313
  if (transcript.answer)
2209
2314
  return transcriptResult(transcript.answer);
2210
2315
  // The transcript can read this conversation and says it is not done:
@@ -3052,6 +3157,32 @@ export function chunkComposerText(text, size = COMPOSER_INSERT_CHUNK_CHARS) {
3052
3157
  * so hand the run back rather than waiting on a page that never renders it -
3053
3158
  * deep research draws into a widget iframe, leaving the thread DOM empty.
3054
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
+ }
3055
3186
  export function deepResearchUnreadableBlocker(threadUrl) {
3056
3187
  return {
3057
3188
  code: "deep_research_not_readable",
@@ -3135,7 +3266,14 @@ const NORMALIZED_PROMPT_MATCH_CHARS = 120;
3135
3266
  * tool prefixes it ("@Deep research ...") and attachments append to it.
3136
3267
  */
3137
3268
  export function transcriptMatchesSentPrompt(userText, sentPrompt) {
3138
- const normalize = (value) => value.replace(/\s+/g, " ").trim();
3269
+ // The composer escapes markdown when it stores what was typed ("## File" is
3270
+ // kept as "\\## File", fences as escaped backticks), so undo that before
3271
+ // comparing - otherwise every prompt carrying markdown, which is every
3272
+ // --file send, looks like a different conversation.
3273
+ const normalize = (value) => value
3274
+ .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
3275
+ .replace(/\s+/g, " ")
3276
+ .trim();
3139
3277
  const seen = normalize(userText);
3140
3278
  const sent = normalize(sentPrompt);
3141
3279
  if (seen.length === 0 || sent.length === 0)
@@ -3370,17 +3508,11 @@ export function answerExpression() {
3370
3508
  });
3371
3509
  const assistantMessages = messages.filter((message) => message.role === "assistant");
3372
3510
  const userMessages = messages.filter((message) => message.role === "user");
3373
- // Deep research renders no assistant-role node at all: the thread is
3374
- // conversation-turn sections, the prompt in the first and the report in a
3375
- // later one (measured live - roles were ["user"] only while a research ran).
3376
- // Fall back to the last turn that is NOT the user's, so such an answer is
3377
- // readable instead of looking like "no answer" forever.
3378
- const turnAnswers = assistantMessages.length > 0 ? [] : [...document.querySelectorAll('[data-testid^="conversation-turn"]')]
3379
- .filter((turn) => !turn.querySelector('[data-message-author-role="user"]'))
3380
- .map((turn) => ({ role: "assistant", text: (turn.innerText || "").trim(), modelSlug: undefined }))
3381
- .filter((turn) => turn.text.length > 0);
3382
- const effectiveAssistants = assistantMessages.length > 0 ? assistantMessages : turnAnswers;
3383
- const assistant = effectiveAssistants.at(-1);
3511
+ // A turn without an assistant message is NOT an answer. Treating one as an
3512
+ // answer (a 0.21.3 fallback for deep research, which is read from the
3513
+ // transcript now) turned a tool's progress panel into a 28-character
3514
+ // "answer" that a consult returned as its result.
3515
+ const assistant = assistantMessages.at(-1);
3384
3516
  const buttons = [...document.querySelectorAll('button,[role="button"]')]
3385
3517
  .filter((node) => !!(node.offsetWidth || node.offsetHeight || node.getClientRects().length))
3386
3518
  .filter((node) => !node.closest(excludedTextSelector))
@@ -3393,13 +3525,16 @@ export function answerExpression() {
3393
3525
  return {
3394
3526
  title: document.title,
3395
3527
  url: location.href,
3396
- answer: assistant ? answer : text.slice(-4000),
3528
+ // No assistant message means no answer. This used to hand back the last
3529
+ // 4000 characters of the page, which reads as sidebar and navigation text
3530
+ // dressed up as a reply.
3531
+ answer,
3397
3532
  textSample: text.slice(0, 12000),
3398
3533
  blockerTextSample: visibleTextOutsideMessages(excludedTextSelector).slice(0, 12000),
3399
3534
  blockerScanTextSample: visibleTextOutsideMessages(blockerScanExcludedSelector).slice(0, 12000),
3400
3535
  visibleButtonLabels: buttons,
3401
3536
  generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || buttons.some((label) => generatingControlPattern.test(label)),
3402
- assistantMessageCount: effectiveAssistants.length,
3537
+ assistantMessageCount: assistantMessages.length,
3403
3538
  userMessageCount: userMessages.length,
3404
3539
  // ChatGPT tags each assistant message with the model that produced it -
3405
3540
  // the only ground truth for "did the Pro selection actually take".
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.0",
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",