@youdie006/prodex 0.25.1 → 0.26.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.
@@ -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
  /**
@@ -943,6 +990,31 @@ export function openChatGptBrowser(options = {}) {
943
990
  }
944
991
  };
945
992
  }
993
+ /**
994
+ * Open a ChatGPT tab in the already-running dedicated Chrome.
995
+ *
996
+ * Reported from a live machine: Chrome was up but had no chatgpt.com tab, so
997
+ * login reused it ("no new window opened"), told the user to finish logging in
998
+ * "in the opened window", and then blocked forever on a tab that nobody was
999
+ * going to create. Opening it is the whole fix.
1000
+ */
1001
+ export async function openChatGptTab(port = DEFAULT_CDP_PORT, url = "https://chatgpt.com/") {
1002
+ const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(url)}`;
1003
+ // Chrome moved /json/new from GET to PUT; try the current verb first and fall
1004
+ // back so this keeps working on either.
1005
+ for (const method of ["PUT", "GET"]) {
1006
+ try {
1007
+ const response = await fetch(endpoint, { method, signal: AbortSignal.timeout(5_000) });
1008
+ if (response.ok)
1009
+ return true;
1010
+ }
1011
+ catch {
1012
+ // Try the next verb, then give up quietly: the caller keeps polling and
1013
+ // the user can still open the tab by hand.
1014
+ }
1015
+ }
1016
+ return false;
1017
+ }
946
1018
  export async function getChatGptBrowserStatus(options = {}) {
947
1019
  const port = resolveCdpPort(options.port);
948
1020
  const page = await findChatGptPage(port, options.timeoutMs ?? 1500);
@@ -1745,6 +1817,26 @@ export async function recoverChatGptAnswerFromThread(options) {
1745
1817
  warnings: []
1746
1818
  };
1747
1819
  }
1820
+ // Not a research thread: read the ordinary answer from the transcript
1821
+ // too. Recover used to be page-only, so it inherited everything the
1822
+ // page loses - flattened markdown, dropped citation urls - and it once
1823
+ // saved ChatGPT's "Connection interrupted" notice as the answer.
1824
+ const transcript = await evaluateOnPage(page.page, transcriptAnswerExpression(conversationId), {
1825
+ timeoutMs: 60_000
1826
+ });
1827
+ if (transcript.ok && transcript.text.trim().length > 0) {
1828
+ const recovered = resolveTranscriptCitations(transcript.text, transcript.references).trim();
1829
+ if (recovered.length > 0) {
1830
+ return {
1831
+ url,
1832
+ title: "",
1833
+ answer: recovered,
1834
+ modelHints: [],
1835
+ ...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : {}),
1836
+ warnings: []
1837
+ };
1838
+ }
1839
+ }
1748
1840
  }
1749
1841
  catch {
1750
1842
  // Not a research thread, or the transcript API is unavailable: fall
@@ -2128,12 +2220,21 @@ export async function sendChatGptPrompt(options) {
2128
2220
  if (!conversationId)
2129
2221
  throw new ChatGptBrowserBlockerError(deepResearchUnreadableBlocker(pinnedThreadUrl ?? "https://chatgpt.com/"));
2130
2222
  let lastState;
2223
+ let consecutiveResearchReadFailures = 0;
2131
2224
  while (Date.now() - started < timeoutMs) {
2132
2225
  try {
2133
2226
  lastState = await evaluateOnPage(page, deepResearchReportExpression(conversationId), { timeoutMs: 60_000 });
2227
+ consecutiveResearchReadFailures = 0;
2134
2228
  }
2135
2229
  catch {
2136
- // Transient CDP/network failure: keep polling until the budget runs out.
2230
+ // A few failures in a row mean the browser is gone, not busy. Waiting
2231
+ // out a 30-minute budget on a dead browser helps nobody: the research
2232
+ // finishes on ChatGPT's side anyway, so hand back the thread and let
2233
+ // recover collect the report.
2234
+ consecutiveResearchReadFailures += 1;
2235
+ if (consecutiveResearchReadFailures >= CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP) {
2236
+ throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(pinnedThreadUrl));
2237
+ }
2137
2238
  await sleep(5_000);
2138
2239
  continue;
2139
2240
  }
@@ -2164,11 +2265,13 @@ export async function sendChatGptPrompt(options) {
2164
2265
  }
2165
2266
  let recoveredNavigations = 0;
2166
2267
  let lastTranscriptClassification;
2268
+ let consecutiveReadFailures = 0;
2167
2269
  const answerIsStable = createChatGptAnswerStabilityTracker();
2168
2270
  while (Date.now() - started < timeoutMs) {
2169
2271
  await sleep(1000);
2170
2272
  try {
2171
2273
  finalState = await evaluateOnPage(page, answerExpression());
2274
+ consecutiveReadFailures = 0;
2172
2275
  // First conversation id wins. Re-deriving it every poll would let a tab
2173
2276
  // that wandered to another thread redirect the read to a stranger's
2174
2277
  // conversation - and the prompt check below is the second line of defence,
@@ -2206,7 +2309,13 @@ export async function sendChatGptPrompt(options) {
2206
2309
  throw error;
2207
2310
  // Transient CDP failure while the answer is streaming: retry. A throw here
2208
2311
  // 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.
2312
+ // path below, so keep the last good state and poll again. But a run of
2313
+ // failures is a browser that went away rather than a busy one, and
2314
+ // sitting out the whole budget on it only delays the recovery.
2315
+ consecutiveReadFailures += 1;
2316
+ if (consecutiveReadFailures >= CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP) {
2317
+ throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(finalState?.url ?? pinnedThreadUrl));
2318
+ }
2210
2319
  continue;
2211
2320
  }
2212
2321
  const runtimeBlocker = chatGptBlockerFromAnswerState(finalState);
@@ -3073,6 +3182,32 @@ export function chunkComposerText(text, size = COMPOSER_INSERT_CHUNK_CHARS) {
3073
3182
  * so hand the run back rather than waiting on a page that never renders it -
3074
3183
  * deep research draws into a widget iframe, leaving the thread DOM empty.
3075
3184
  */
3185
+ /**
3186
+ * The dedicated browser died while a send was waiting for its answer.
3187
+ *
3188
+ * Observed live: Chrome went away mid deep-research run, every poll threw, the
3189
+ * loop swallowed each failure, and the send sat silent for the rest of a
3190
+ * 30-minute budget - while the research finished on ChatGPT's side. Nothing is
3191
+ * lost except the ability to read it, so fail fast and hand back the thread.
3192
+ */
3193
+ // Reading the page can fail for a moment (a navigation, a busy renderer). Five
3194
+ // failures in a row is not a moment - it is a browser that went away.
3195
+ const CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP = 5;
3196
+ export function browserLostMidWaitBlocker(threadUrl) {
3197
+ // Killing the browser aborts a streaming answer too, so promise nothing
3198
+ // about the answer itself - only say where to look. Deep research is the one
3199
+ // case that genuinely keeps going without us.
3200
+ const where = threadUrl ? ` The consult landed in ${threadUrl}` : "";
3201
+ return {
3202
+ code: "browser_unreachable",
3203
+ message: `The dedicated ChatGPT browser stopped responding while this consult was waiting for its answer.${where}`,
3204
+ retryable: true,
3205
+ next_step: threadUrl
3206
+ ? `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}).`
3207
+ : "Run `prodex pro browser login` to reopen the browser, then retry.",
3208
+ ...(threadUrl ? { thread: threadUrl } : {})
3209
+ };
3210
+ }
3076
3211
  export function deepResearchUnreadableBlocker(threadUrl) {
3077
3212
  return {
3078
3213
  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, 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";
@@ -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
@@ -1286,13 +1302,24 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
1286
1302
  const statusFn = deps.statusFn ?? getChatGptBrowserStatus;
1287
1303
  const sleepFn = deps.sleepFn ?? sleep;
1288
1304
  const now = deps.now ?? Date.now;
1305
+ const openTabFn = deps.openTabFn ?? openChatGptTab;
1289
1306
  const timeoutMs = options.timeoutMs ?? 300_000;
1290
1307
  const pollMs = options.pollMs ?? 2_000;
1291
1308
  const startedAt = now();
1292
- stderr("login: waiting for a logged-in ChatGPT tab (finish login in the opened window; Ctrl+C stops waiting)...");
1309
+ stderr("login: waiting for a logged-in ChatGPT tab (finish login in the dedicated Chrome window; Ctrl+C stops waiting)...");
1293
1310
  let lastState = "";
1311
+ let openedMissingTab = false;
1294
1312
  while (now() - startedAt < timeoutMs) {
1295
1313
  const status = await statusFn({ port: options.port, timeoutMs: 1_500 });
1314
+ // A running Chrome with no chatgpt.com tab leaves the user nothing to log
1315
+ // into. Open the tab once rather than waiting for one to appear.
1316
+ if (status.reachable && status.blocker?.code === "chatgpt_page_missing" && !openedMissingTab) {
1317
+ openedMissingTab = true;
1318
+ stderr("login: the running Chrome had no ChatGPT tab - opening a ChatGPT tab in it...");
1319
+ await openTabFn(options.port);
1320
+ await sleepFn(pollMs);
1321
+ continue;
1322
+ }
1296
1323
  const state = !status.reachable
1297
1324
  ? "login: browser starting..."
1298
1325
  : status.blocker
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.25.1",
3
+ "version": "0.26.1",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",