@youdie006/prodex 0.40.6 → 0.40.8

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/dist/cli-pro.js CHANGED
@@ -1,8 +1,8 @@
1
- import { existsSync, statSync } from "node:fs";
1
+ import { existsSync, realpathSync, 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, 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, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl } 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, resolveBrowserWindowMode, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl } 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";
@@ -16,6 +16,15 @@ import { readBridgeRoots } from "./registry.js";
16
16
  import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
17
17
  import { CLI_VERSION } from "./cli-help.js";
18
18
  import { PRODEX_ISSUE_REPO, buildIssueReport, fileGitHubIssue } from "./issue-report.js";
19
+ function resolveBrowserProfileDirForLaunch(profileDir, launchCwd = process.cwd()) {
20
+ const resolved = path.resolve(launchCwd, profileDir);
21
+ try {
22
+ return statSync(resolved).isDirectory() ? realpathSync(resolved) : resolved;
23
+ }
24
+ catch {
25
+ return resolved;
26
+ }
27
+ }
19
28
  export async function runChatgptCommand(rest, io) {
20
29
  const [subcommand, ...chatgptArgs] = rest;
21
30
  if (!subcommand || isHelpSubcommand(subcommand)) {
@@ -195,23 +204,45 @@ export async function runProCommand(rest, io, runCliFn) {
195
204
  if (browserSubcommand === "login") {
196
205
  if (printProBrowserHelpIfRequested(browserArgs, "pro browser login", io, {
197
206
  valueFlags: ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"],
198
- booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headless", "--minimized", "--virtual-display"]
207
+ booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headed", "--headless", "--minimized", "--virtual-display"]
199
208
  })) {
200
209
  return 0;
201
210
  }
202
- assertOnlyOptions(browserArgs, "pro browser login", ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"], ["--dry-run", "--wait", "--no-wait", "--headless", "--minimized", "--virtual-display"]);
211
+ assertOnlyOptions(browserArgs, "pro browser login", ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"], ["--dry-run", "--wait", "--no-wait", "--headed", "--headless", "--minimized", "--virtual-display"]);
203
212
  if (browserArgs.includes("--wait") && browserArgs.includes("--no-wait")) {
204
213
  throw new Error("pro browser login cannot combine --wait and --no-wait");
205
214
  }
206
215
  const loginUrl = readChatGptBrowserUrlFlag(browserArgs);
207
216
  const sourceCli = resolveOptionalFileFlag(io.cwd, browserArgs, "--source-cli");
208
217
  const targetCwd = readFlag(browserArgs, "--cwd") ? resolveCwdFlag(io.cwd, browserArgs) : undefined;
209
- const profileDir = readFlag(browserArgs, "--profile-dir");
218
+ const requestedProfileFlag = readFlag(browserArgs, "--profile-dir");
219
+ const requestedProfileDir = requestedProfileFlag
220
+ ? resolveBrowserProfileDirForLaunch(requestedProfileFlag)
221
+ : undefined;
210
222
  const port = resolveCdpPort(readPortFlag(browserArgs, "--port"));
211
223
  const launchTimeoutMs = readPositiveIntegerFlag(browserArgs, "--launch-timeout-ms");
224
+ const savedLaunch = await readLastBrowserLoginLaunch();
225
+ // A saved launch belongs to a control port. Reusing its profile for a
226
+ // different port would silently widen the existing custom-port rules.
227
+ const savedLaunchForPort = savedLaunch?.port === port ? savedLaunch : undefined;
228
+ const savedProfileDir = savedLaunchForPort?.profile_dir
229
+ ? resolveBrowserProfileDirForLaunch(savedLaunchForPort.profile_dir)
230
+ : undefined;
231
+ const profileDir = requestedProfileDir ?? savedProfileDir;
232
+ const windowMode = resolveBrowserWindowMode({
233
+ flags: {
234
+ ...(browserArgs.includes("--headed") ? { headed: true } : {}),
235
+ ...(browserArgs.includes("--headless") ? { headless: true } : {}),
236
+ ...(browserArgs.includes("--virtual-display") ? { virtualDisplay: true } : {}),
237
+ ...(browserArgs.includes("--minimized") ? { minimized: true } : {})
238
+ },
239
+ // Window mode is a user preference across launches; only the saved
240
+ // profile and display identity are scoped to this resolved port.
241
+ ...(savedLaunch ? { lastLogin: savedLaunch } : {})
242
+ });
212
243
  const commandOptions = {
213
244
  ...(targetCwd ? { cwd: targetCwd } : {}),
214
- ...(profileDir ? { profileDir } : {}),
245
+ ...(requestedProfileDir ? { profileDir: requestedProfileDir } : {}),
215
246
  ...(port !== DEFAULT_CDP_PORT ? { port } : {}),
216
247
  ...(readFlag(browserArgs, "--url") ? { url: loginUrl } : {}),
217
248
  ...(launchTimeoutMs !== undefined ? { launchTimeoutMs } : {})
@@ -219,6 +250,8 @@ export async function runProCommand(rest, io, runCliFn) {
219
250
  if (browserArgs.includes("--dry-run")) {
220
251
  printBrowserLoginGuide(io.stdout, {
221
252
  opened: false,
253
+ headless: windowMode.headless,
254
+ virtualDisplay: windowMode.virtualDisplay,
222
255
  loginUrl,
223
256
  profileDir: profileDir ?? defaultChatGptProfileDir(),
224
257
  port,
@@ -231,48 +264,41 @@ export async function runProCommand(rest, io, runCliFn) {
231
264
  // again: Chrome's singleton would just open ANOTHER window (the recurring
232
265
  // "extra windows" problem, which then blocks sends as
233
266
  // ambiguous_chatgpt_tabs). Reuse the running instance instead.
234
- if (browserArgs.includes("--virtual-display") && browserArgs.includes("--headless")) {
235
- throw new Error("pro browser login cannot combine --headless and --virtual-display (a virtual display already hides the window).");
236
- }
237
- // Reopen the browser the way it was last opened unless a flag or the
238
- // environment says otherwise. A virtual-display user who follows the
239
- // `browser_unreachable` advice used to get a visible window back.
240
- const savedLaunch = await readLastBrowserLoginLaunch();
241
- const windowMode = resolveBrowserWindowMode({
242
- flags: {
243
- ...(browserArgs.includes("--headless") ? { headless: true } : {}),
244
- ...(browserArgs.includes("--virtual-display") ? { virtualDisplay: true } : {}),
245
- ...(browserArgs.includes("--minimized") ? { minimized: true } : {})
246
- },
247
- ...(savedLaunch ? { lastLogin: savedLaunch } : {})
248
- });
249
267
  const headless = windowMode.headless;
250
- // A real browser on a virtual X display: no window anywhere, and
251
- // Cloudflare sees an ordinary headed Chrome (headless it rejects).
252
268
  const wantsVirtualDisplay = windowMode.virtualDisplay;
253
- const virtualDisplay = wantsVirtualDisplay
254
- ? await ensureVirtualDisplay(savedLaunch?.virtual_display !== undefined && !browserArgs.includes("--virtual-display")
255
- ? { displayNumber: savedLaunch.virtual_display }
256
- : {})
257
- : undefined;
258
269
  const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
259
270
  if (alreadyRunning) {
271
+ if (requestedProfileDir &&
272
+ savedProfileDir &&
273
+ requestedProfileDir !== savedProfileDir) {
274
+ throw new Error(`A ChatGPT browser is already running on port ${port} with profile ${savedProfileDir}; a different profile was requested. Close the existing browser yourself, then rerun with the intended profile.`);
275
+ }
260
276
  // One Chrome profile cannot serve a headed and a headless instance at
261
277
  // once, and reusing the running one would silently ignore the
262
278
  // requested mode. Say so instead of pretending the switch took.
263
- const previous = await readLastBrowserLoginLaunch();
264
- const runningHeadless = previous?.port === port ? previous.headless === true : undefined;
279
+ const runningHeadless = savedLaunchForPort ? savedLaunchForPort.headless === true : undefined;
265
280
  if (runningHeadless !== undefined && runningHeadless !== headless) {
266
- throw new Error(`A ${runningHeadless ? "headless" : "headed"} ChatGPT browser is already running on port ${port}, but ${headless ? "headless" : "headed"} was requested. Close it first (\`pkill -f "remote-debugging-port=${port}"\`), then rerun.`);
281
+ throw new Error(`A ${runningHeadless ? "headless" : "headed"} ChatGPT browser is already running on port ${port}, but ${headless ? "headless" : "headed"} was requested. Close the existing browser yourself, then rerun; prodex will not end it.`);
267
282
  }
268
283
  // Same for the display: a browser already on your desktop cannot be
269
284
  // moved onto a virtual display by reusing it, and silently reusing
270
285
  // it would leave the window exactly where the user asked it not to be.
271
- const runningVirtual = previous?.port === port ? previous.virtual_display !== undefined : undefined;
286
+ const runningVirtual = savedLaunchForPort ? savedLaunchForPort.virtual_display !== undefined : undefined;
272
287
  if (runningVirtual !== undefined && runningVirtual !== wantsVirtualDisplay) {
273
- throw new Error(`A ChatGPT browser is already running on port ${port} on ${runningVirtual ? "a virtual display" : "your desktop"}, but ${wantsVirtualDisplay ? "a virtual display" : "your desktop"} was requested. Close it first (\`pkill -f "remote-debugging-port=${port}"\`), then rerun.`);
288
+ throw new Error(`A ChatGPT browser is already running on port ${port} on ${runningVirtual ? "a virtual display" : "your desktop"}, but ${wantsVirtualDisplay ? "a virtual display" : "your desktop"} was requested. Close the existing browser yourself, then rerun; prodex will not end it.`);
289
+ }
290
+ if (savedLaunchForPort?.minimized === true && !windowMode.minimized) {
291
+ throw new Error(`A minimized ChatGPT browser is already running on port ${port}, but a visible headed browser was requested. Close the existing browser yourself, then rerun with --headed; prodex will not end it or pretend the minimized window was restored.`);
274
292
  }
275
293
  }
294
+ // Allocate/rejoin a display only for a new browser. An already-running
295
+ // virtual browser owns its saved display identity and needs no new X
296
+ // server just because login was invoked again.
297
+ const virtualDisplay = wantsVirtualDisplay && !alreadyRunning
298
+ ? await ensureVirtualDisplay(savedLaunchForPort?.virtual_display !== undefined
299
+ ? { displayNumber: savedLaunchForPort.virtual_display }
300
+ : {})
301
+ : undefined;
276
302
  // A wedged browser is unreachable, so without this login would launch a
277
303
  // second Chrome onto the same profile and leave the first one burning
278
304
  // CPU - the same mistake the unattended recovery path made. Checked
@@ -291,7 +317,9 @@ export async function runProCommand(rest, io, runCliFn) {
291
317
  port,
292
318
  profileDir,
293
319
  url: loginUrl,
294
- ...(headless ? { headless } : {}),
320
+ // Pass false too: openChatGptBrowser otherwise consults the env
321
+ // again and can resurrect a mode the shared resolver disabled.
322
+ headless,
295
323
  ...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
296
324
  });
297
325
  if (!alreadyRunning) {
@@ -302,10 +330,9 @@ export async function runProCommand(rest, io, runCliFn) {
302
330
  // Minimize BEFORE recording, so the record reflects what actually
303
331
  // happened (a desktop that hides minimized windows gets restored and
304
332
  // recorded as a normal window).
305
- const wantsMinimized = browserArgs.includes("--minimized") || resolveMinimizeWindowPreference();
306
333
  let minimized = false;
307
334
  let minimizeNote;
308
- if (wantsMinimized && !headless) {
335
+ if (windowMode.minimized && !headless) {
309
336
  try {
310
337
  const outcome = await minimizeChatGptWindow({ port: opened.port });
311
338
  minimized = outcome.minimized;
@@ -317,20 +344,31 @@ export async function runProCommand(rest, io, runCliFn) {
317
344
  minimizeNote = `window: could not minimize (${errorMessage(error)}); leaving it as it is.`;
318
345
  }
319
346
  }
320
- await recordBrowserLoginLaunch({
321
- profile_dir: opened.profileDir,
322
- port: opened.port,
323
- headless,
324
- minimized,
325
- ...(virtualDisplay ? { virtual_display: virtualDisplay.displayNumber } : {})
326
- });
327
- if (virtualDisplay) {
328
- io.stdout(`window: none - running on virtual display :${virtualDisplay.displayNumber}${virtualDisplay.startedNow ? " (started now)" : " (reused)"}.`);
347
+ // A reused browser with neither an explicit nor saved profile is
348
+ // reachable, but its actual profile cannot be inferred from the port.
349
+ // Do not overwrite a useful launch record with an invented default.
350
+ if (!alreadyRunning || profileDir) {
351
+ await recordBrowserLoginLaunch({
352
+ profile_dir: opened.profileDir,
353
+ port: opened.port,
354
+ headless,
355
+ minimized,
356
+ ...(virtualDisplay
357
+ ? { virtual_display: virtualDisplay.displayNumber }
358
+ : wantsVirtualDisplay && savedLaunchForPort?.virtual_display !== undefined
359
+ ? { virtual_display: savedLaunchForPort.virtual_display }
360
+ : {})
361
+ });
362
+ }
363
+ const virtualDisplayNumber = virtualDisplay?.displayNumber ?? savedLaunchForPort?.virtual_display;
364
+ if (wantsVirtualDisplay && virtualDisplayNumber !== undefined) {
365
+ io.stdout(`window: none - running on virtual display :${virtualDisplayNumber}${virtualDisplay?.startedNow ? " (started now)" : " (reused)"}.`);
329
366
  }
330
367
  printBrowserLoginGuide(io.stdout, {
331
368
  opened: !alreadyRunning,
332
369
  reused: alreadyRunning,
333
370
  headless,
371
+ virtualDisplay: wantsVirtualDisplay,
334
372
  loginUrl,
335
373
  profileDir: opened.profileDir,
336
374
  port: opened.port,
@@ -345,7 +383,12 @@ export async function runProCommand(rest, io, runCliFn) {
345
383
  // Verify it here (bounded, no human to wait for) instead of letting
346
384
  // the first consult fail with a confusing not-logged-in blocker.
347
385
  const headlessWaitMs = readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 30_000;
348
- const headlessReady = await waitForChatGptLoginReady(io.stderr, { port: opened.port, timeoutMs: headlessWaitMs });
386
+ const headlessReady = await waitForChatGptLoginReady(io.stderr, {
387
+ port: opened.port,
388
+ timeoutMs: headlessWaitMs,
389
+ windowMode,
390
+ headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
391
+ });
349
392
  if (!headlessReady) {
350
393
  // Name the real cause. Cloudflare rejects headless Chrome by
351
394
  // design (its docs list headless browsers as unsupported), and
@@ -355,8 +398,8 @@ export async function runProCommand(rest, io, runCliFn) {
355
398
  const challenged = finalStatus?.blocker?.code === "cloudflare_check" || /just a moment/i.test(finalStatus?.title ?? "");
356
399
  io.stdout("");
357
400
  io.stdout(challenged
358
- ? "headless: Cloudflare challenged the headless browser and never let ChatGPT load. This is expected - Cloudflare lists headless browsers as unsupported. Run without --headless, or run a real headed Chrome on a virtual display (Xvfb) and point prodex at it with DISPLAY."
359
- : `headless: the profile is not signed in. Run \`${formatBrowserLoginCommand(sourceCli, commandOptions)}\` WITHOUT --headless once, sign in, close that window, then rerun with --headless.`);
401
+ ? `headless: Cloudflare challenged the headless browser and never let ChatGPT load. Run \`${formatHeadedBrowserLoginCommand(sourceCli, commandOptions)}\` for a visible interactive check; headless browsers are not supported by Cloudflare.`
402
+ : `headless: the profile is not signed in. Run \`${formatHeadedBrowserLoginCommand(sourceCli, commandOptions)}\` for a visible interactive login, sign in, close that browser yourself, then rerun with --headless.`);
360
403
  return 1;
361
404
  }
362
405
  io.stdout("headless: signed-in session confirmed - consults will run with no visible window.");
@@ -370,7 +413,12 @@ export async function runProCommand(rest, io, runCliFn) {
370
413
  if (!shouldWaitForReady)
371
414
  return 0;
372
415
  const waitTimeoutMs = readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 300_000;
373
- const ready = await waitForChatGptLoginReady(io.stderr, { port: opened.port, timeoutMs: waitTimeoutMs });
416
+ const ready = await waitForChatGptLoginReady(io.stderr, {
417
+ port: opened.port,
418
+ timeoutMs: waitTimeoutMs,
419
+ windowMode,
420
+ headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
421
+ });
374
422
  return ready ? 0 : 1;
375
423
  }
376
424
  if (browserSubcommand === "ask") {
@@ -933,12 +981,6 @@ export async function runProCommand(rest, io, runCliFn) {
933
981
  export async function runConsultsCommand(rest, io) {
934
982
  throw new Error("The legacy `consults` alias is retired. Use `prodex pro list`, `prodex pro latest`, or `prodex pro show <task-id|latest>`.");
935
983
  }
936
- // PRODEX_MINIMIZE_WINDOW=1 gives the no-window setup to every entry point,
937
- // including the MCP server's auto-recovery, without a CLI flag.
938
- export function resolveMinimizeWindowPreference(env = process.env) {
939
- const raw = (env.PRODEX_MINIMIZE_WINDOW ?? "").trim().toLowerCase();
940
- return raw === "1" || raw === "true" || raw === "yes";
941
- }
942
984
  function autoLoginDisabledByEnv(env = process.env) {
943
985
  const raw = (env.PRODEX_NO_AUTO_LOGIN ?? "").trim().toLowerCase();
944
986
  return raw === "1" || raw === "true" || raw === "yes";
@@ -1328,9 +1370,9 @@ export async function runAskProCommand(rest, io) {
1328
1370
  port: browserPort,
1329
1371
  prompt: bundle.sendText,
1330
1372
  targetUrl: normalizedTargetUrl,
1331
- // A thread prodex resolved from its own records is reached by
1332
- // navigating; a --target-url the person confirmed is not moved.
1333
- ...(continuedFromTaskId ? { navigateToTargetUrl: true } : {}),
1373
+ // Continuations and explicit TUI picks navigate while this lock is
1374
+ // held. Direct CLI targets keep their existing verification-only semantics.
1375
+ ...(continuedFromTaskId || (io.navigateInteractiveTarget === true && normalizedTargetUrl) ? { navigateToTargetUrl: true } : {}),
1334
1376
  timeoutMs: browserTimeoutMs,
1335
1377
  ...(attachments.length > 0 ? { attachments } : {}),
1336
1378
  ...(tools.length > 0 ? { tools } : {}),
@@ -1363,7 +1405,9 @@ export async function runAskProCommand(rest, io) {
1363
1405
  }
1364
1406
  catch (error) {
1365
1407
  const firstBlocker = browserSendBlockerFromError(error);
1366
- if (firstBlocker.code !== "browser_unreachable" || !autoLoginAllowed)
1408
+ // Loss while reading an already-submitted prompt must not send it again.
1409
+ if (firstBlocker.code !== "browser_unreachable" ||
1410
+ !firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed)
1367
1411
  throw error;
1368
1412
  const recovered = await attemptBrowserAutoRecovery(io.stderr, {
1369
1413
  ...(browserPort !== undefined ? { port: browserPort } : {}),
@@ -1801,6 +1845,12 @@ function autoClearDisabledByEnv(env = process.env) {
1801
1845
  const raw = (env.PRODEX_NO_AUTO_CLEAR ?? "").trim().toLowerCase();
1802
1846
  return raw === "1" || raw === "true" || raw === "yes";
1803
1847
  }
1848
+ function reportResidualBrowserRecoveryBlocker(stderr, pids, port) {
1849
+ const blocker = wedgedBrowserBlocker([...new Set(pids)].sort((left, right) => left - right), port);
1850
+ stderr(`recover: failed - ${blocker.message}`);
1851
+ stderr(`recover: ${blocker.next_step}`);
1852
+ return false;
1853
+ }
1804
1854
  /**
1805
1855
  * What recovery did to the browser, in the words the receipt keeps.
1806
1856
  *
@@ -1825,9 +1875,26 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1825
1875
  // scanning the DEFAULT profile for a custom-profile user put a second,
1826
1876
  // healthy browser's renderers on the list this function kills.
1827
1877
  const lastLogin = await readLastBrowserLoginLaunch().catch(() => undefined);
1878
+ const recoveryPort = resolveCdpPort(options.port);
1879
+ const savedIdentityBelongsToAnotherPort = lastLogin?.port !== undefined &&
1880
+ lastLogin.port !== recoveryPort &&
1881
+ (lastLogin.profile_dir !== undefined || lastLogin.virtual_display !== undefined);
1882
+ if (savedIdentityBelongsToAnotherPort) {
1883
+ stderr(`recover: failed - saved browser identity belongs to port ${lastLogin.port}, not requested port ${recoveryPort}. Run \`prodex pro browser login --port ${recoveryPort} --headed\` with the intended profile before retrying; prodex will not launch an unknown account.`);
1884
+ return false;
1885
+ }
1886
+ const lastLoginForPort = lastLogin?.port === undefined || lastLogin.port === recoveryPort ? lastLogin : undefined;
1887
+ let windowMode;
1888
+ try {
1889
+ windowMode = resolveBrowserWindowMode({ ...(lastLogin ? { lastLogin } : {}) });
1890
+ }
1891
+ catch (error) {
1892
+ stderr(`recover: failed - ${errorMessage(error)}`);
1893
+ return false;
1894
+ }
1828
1895
  const scanFor = {
1829
1896
  ...(options.port !== undefined ? { port: options.port } : {}),
1830
- ...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {})
1897
+ ...(lastLoginForPort?.profile_dir ? { profileDir: lastLoginForPort.profile_dir } : {})
1831
1898
  };
1832
1899
  const wedged = findWedgedBrowser(scanFor);
1833
1900
  if (wedged.length > 0) {
@@ -1847,17 +1914,25 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1847
1914
  return false;
1848
1915
  }
1849
1916
  stderr(`recover: the browser stopped answering; ending it (pid ${wedged.join(", ")}) and starting a fresh one...`);
1850
- // Ending someone's browser is not a progress line to scroll past: it goes on
1851
- // the receipt, where an agent or a person reading `pro latest` will see it.
1852
- options.notes?.push(browserRecoveredNote(wedged));
1853
- await endWedgedBrowser(wedged);
1917
+ // Keep the attempt even if launch fails; completion is recorded after READY.
1918
+ options.notes?.push(`browser_recovery_started: prodex began restarting the unresponsive dedicated browser (pid ${wedged.join(", ")}).`);
1919
+ const termination = await endWedgedBrowser(wedged);
1920
+ if (termination.failed.length > 0) {
1921
+ return reportResidualBrowserRecoveryBlocker(stderr, termination.failed, recoveryPort);
1922
+ }
1854
1923
  // Wait for the profile lock to actually clear rather than guessing at a
1855
1924
  // delay: the replacement launch fails outright if the old process still
1856
1925
  // holds it, which is how the first self-heal attempt ended.
1926
+ let residualPids = [];
1857
1927
  for (let attempt = 0; attempt < 10; attempt += 1) {
1858
- if (findWedgedBrowser(scanFor).length === 0)
1928
+ residualPids = findWedgedBrowser(scanFor);
1929
+ if (residualPids.length === 0)
1859
1930
  break;
1860
- await sleep(1_000);
1931
+ if (attempt < 9)
1932
+ await sleep(1_000);
1933
+ }
1934
+ if (residualPids.length > 0) {
1935
+ return reportResidualBrowserRecoveryBlocker(stderr, residualPids, recoveryPort);
1861
1936
  }
1862
1937
  }
1863
1938
  stderr("recover: browser is not running - launching the dedicated ChatGPT browser (Ctrl+C aborts)...");
@@ -1865,37 +1940,50 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1865
1940
  // Reuse the profile the user last logged in with; launching the default
1866
1941
  // profile for a custom-profile user would wait on the wrong (logged-out)
1867
1942
  // profile or, worse, silently send to a different account.
1868
- // Relaunch in the SAME window mode the user chose: silently reopening a
1869
- // visible window for someone running headless would be exactly the
1870
- // surprise window they turned headless to avoid.
1871
- const headless = resolveHeadlessPreference(lastLogin?.headless);
1872
- // Rejoin the same virtual display the user set up, so recovery does not
1873
- // put a window back on a desktop they deliberately keep empty.
1874
- const virtualDisplay = lastLogin?.virtual_display !== undefined || resolveVirtualDisplayPreference()
1875
- ? await ensureVirtualDisplay(lastLogin?.virtual_display !== undefined ? { displayNumber: lastLogin.virtual_display } : {}).catch(() => undefined)
1943
+ // Recovery follows the same precedence as login. In particular, one
1944
+ // supplied false env setting selects the env group and disables saved
1945
+ // modes instead of being mistaken for "not configured".
1946
+ const virtualDisplay = windowMode.virtualDisplay
1947
+ ? await ensureVirtualDisplay(lastLoginForPort?.virtual_display !== undefined ? { displayNumber: lastLoginForPort.virtual_display } : {})
1876
1948
  : undefined;
1877
1949
  const opened = openChatGptBrowser({
1878
1950
  ...(options.port !== undefined ? { port: options.port } : {}),
1879
- ...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {}),
1880
- ...(headless ? { headless } : {}),
1951
+ ...(lastLoginForPort?.profile_dir ? { profileDir: lastLoginForPort.profile_dir } : {}),
1952
+ // Always pass the resolved boolean. Omitting false lets the browser
1953
+ // boundary re-read PRODEX_HEADLESS and revive stale environment state.
1954
+ headless: windowMode.headless,
1881
1955
  ...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
1882
1956
  });
1883
1957
  await assertBrowserLaunchStayedAlive(opened);
1884
- const ready = await waitForChatGptLoginReady(stderr, { port: opened.port, timeoutMs: 120_000 });
1885
- if (!ready)
1886
- return false;
1887
- // Restore the no-window setup too: someone who runs minimized does not
1888
- // want recovery to leave a window sitting on their desktop.
1889
- if (!headless && (lastLogin?.minimized === true || resolveMinimizeWindowPreference())) {
1958
+ let minimized = false;
1959
+ if (windowMode.minimized) {
1890
1960
  const outcome = await minimizeChatGptWindow({ port: opened.port }).catch(() => undefined);
1891
- if (outcome?.minimized)
1961
+ minimized = outcome?.minimized === true;
1962
+ if (minimized)
1892
1963
  stderr("recover: window minimized again");
1893
1964
  }
1965
+ // Record the actual launch before waiting for authentication. A manual
1966
+ // login must target this profile and display even if readiness is blocked.
1967
+ await recordBrowserLoginLaunch({
1968
+ profile_dir: opened.profileDir,
1969
+ port: opened.port,
1970
+ headless: windowMode.headless,
1971
+ minimized,
1972
+ ...(virtualDisplay ? { virtual_display: virtualDisplay.displayNumber } : {})
1973
+ });
1974
+ const ready = await waitForChatGptLoginReady(stderr, {
1975
+ port: opened.port,
1976
+ timeoutMs: 120_000,
1977
+ windowMode,
1978
+ headedLoginCommand: formatHeadedBrowserLoginCommand(undefined, {
1979
+ profileDir: opened.profileDir,
1980
+ ...(opened.port !== DEFAULT_CDP_PORT ? { port: opened.port } : {})
1981
+ })
1982
+ });
1983
+ if (!ready)
1984
+ return false;
1894
1985
  stderr("recover: browser READY - retrying the send...");
1895
- // The wedged branch above records what it ended; this branch records that
1896
- // the browser was gone and this answer came from one prodex started.
1897
- if (wedged.length === 0)
1898
- options.notes?.push(browserRecoveredNote([]));
1986
+ options.notes?.push(browserRecoveredNote(wedged));
1899
1987
  return true;
1900
1988
  }
1901
1989
  catch (error) {
@@ -1915,8 +2003,13 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
1915
2003
  const openTabFn = deps.openTabFn ?? openChatGptTab;
1916
2004
  const timeoutMs = options.timeoutMs ?? 300_000;
1917
2005
  const pollMs = options.pollMs ?? 2_000;
2006
+ const hasInteractiveWindow = options.windowMode?.headless !== true && options.windowMode?.virtualDisplay !== true;
2007
+ const headedLoginCommand = options.headedLoginCommand ?? "prodex pro browser login --headed";
2008
+ const headedLoginHint = ` No interactive window is available; run \`${headedLoginCommand}\` to complete login, captcha, or human verification visibly.`;
1918
2009
  const startedAt = now();
1919
- stderr("login: waiting for a logged-in ChatGPT tab (finish login in the dedicated Chrome window; Ctrl+C stops waiting)...");
2010
+ stderr(hasInteractiveWindow
2011
+ ? "login: waiting for a logged-in ChatGPT tab (finish login in the dedicated Chrome browser; Ctrl+C stops waiting)..."
2012
+ : `login: waiting for a logged-in ChatGPT tab (no interactive window; use \`${headedLoginCommand}\` if login or verification is required; Ctrl+C stops waiting)...`);
1920
2013
  let lastState = "";
1921
2014
  let openMissingTabAttempts = 0;
1922
2015
  while (now() - startedAt < timeoutMs) {
@@ -1933,19 +2026,26 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
1933
2026
  ? "login: the running Chrome had no ChatGPT tab - opening a ChatGPT tab in it..."
1934
2027
  : `login: still no ChatGPT tab - opening one again (attempt ${attempt})...`);
1935
2028
  const opened = await openTabFn(options.port);
1936
- if (opened === false)
1937
- stderr("login: could not open a ChatGPT tab through the debug port; open https://chatgpt.com/ in that window.");
2029
+ if (opened === false) {
2030
+ stderr(hasInteractiveWindow
2031
+ ? "login: could not open a ChatGPT tab through the debug port; open https://chatgpt.com/ in that browser."
2032
+ : `login: could not open a ChatGPT tab through the debug port; run \`${headedLoginCommand}\` to open it visibly.`);
2033
+ }
1938
2034
  await sleepFn(pollMs);
1939
2035
  continue;
1940
2036
  }
1941
2037
  const state = !status.reachable
1942
2038
  ? "login: browser starting..."
1943
2039
  : status.blocker
1944
- ? `login: blocked - ${status.blocker.message}`
2040
+ ? `login: blocked - ${status.blocker.message}${hasInteractiveWindow ? "" : headedLoginHint}`
1945
2041
  : !status.loggedInLikely
1946
- ? "login: waiting for ChatGPT login in the opened window..."
2042
+ ? hasInteractiveWindow
2043
+ ? "login: waiting for ChatGPT login in the dedicated Chrome browser..."
2044
+ : `login: waiting for a saved ChatGPT login.${headedLoginHint}`
1947
2045
  : !status.hasComposer
1948
- ? "login: logged in; open a chat so the prompt composer is visible..."
2046
+ ? hasInteractiveWindow
2047
+ ? "login: logged in; open a chat so the prompt composer is visible..."
2048
+ : `login: logged in, but no prompt composer is ready.${headedLoginHint}`
1949
2049
  : "";
1950
2050
  if (state === "") {
1951
2051
  stderr(`login: READY - logged-in ChatGPT tab with composer detected (${Math.round((now() - startedAt) / 1000)}s).`);
@@ -1960,7 +2060,9 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
1960
2060
  break;
1961
2061
  await sleepFn(Math.min(pollMs, Math.max(1, remainingMs)));
1962
2062
  }
1963
- stderr(`login: not ready after ${Math.round(timeoutMs / 1000)}s. Finish login in the browser, then verify with \`prodex pro browser check\`.`);
2063
+ stderr(hasInteractiveWindow
2064
+ ? `login: not ready after ${Math.round(timeoutMs / 1000)}s. Finish login in the browser, then verify with \`prodex pro browser check\`.`
2065
+ : `login: not ready after ${Math.round(timeoutMs / 1000)}s. Run \`${headedLoginCommand}\` to complete login, captcha, or human verification visibly, then retry.`);
1964
2066
  return false;
1965
2067
  }
1966
2068
  export async function assertBrowserLaunchStayedAlive(opened, timeoutMs) {
@@ -2551,9 +2653,14 @@ export async function listConsultListEntries(store, options = { readOnly: true }
2551
2653
  }
2552
2654
  return entries;
2553
2655
  }
2656
+ function formatHeadedBrowserLoginCommand(sourceCli, options = {}) {
2657
+ return `${formatBrowserLoginCommand(sourceCli, options)} --headed`;
2658
+ }
2554
2659
  export function printBrowserLoginGuide(stdout, input) {
2555
- const windowAvailable = (input.opened || input.reused === true) && input.headless !== true;
2660
+ const noInteractiveWindow = input.headless === true || input.virtualDisplay === true;
2661
+ const windowAvailable = (input.opened || input.reused === true) && !noInteractiveWindow;
2556
2662
  const loginCommand = formatBrowserLoginCommand(input.sourceCli, input.commandOptions);
2663
+ const headedLoginCommand = formatHeadedBrowserLoginCommand(input.sourceCli, input.commandOptions);
2557
2664
  const runtimeCommandOptions = {
2558
2665
  ...(input.commandOptions?.cwd ? { cwd: input.commandOptions.cwd } : {}),
2559
2666
  ...(input.commandOptions?.port ? { port: input.commandOptions.port } : {})
@@ -2565,13 +2672,18 @@ export function printBrowserLoginGuide(stdout, input) {
2565
2672
  ? input.reused
2566
2673
  ? "Headless ChatGPT browser is already running - reusing it (no window)."
2567
2674
  : "Started the dedicated ChatGPT browser headless (no window). It reuses the profile you signed in with."
2568
- : input.reused
2569
- ? "Chrome is already running for ChatGPT - reusing it (no new window opened)."
2570
- : input.opened
2571
- ? "Opened the dedicated Chrome window for ChatGPT."
2572
- : "Dry run: no browser was opened.");
2573
- if (input.headless && (input.opened || input.reused)) {
2675
+ : input.virtualDisplay && (input.opened || input.reused)
2676
+ ? input.reused
2677
+ ? "ChatGPT browser is already running on its saved virtual display - reusing it (no visible window)."
2678
+ : "Started the dedicated ChatGPT browser on a virtual display (no visible window)."
2679
+ : input.reused
2680
+ ? "Chrome is already running for ChatGPT - reusing it (no new window opened)."
2681
+ : input.opened
2682
+ ? "Opened the dedicated Chrome window for ChatGPT."
2683
+ : "Dry run: no browser was opened.");
2684
+ if (noInteractiveWindow && (input.opened || input.reused)) {
2574
2685
  stdout("");
2686
+ stdout(`If ChatGPT needs login, captcha, or human verification, run \`${headedLoginCommand}\` to handle it in a visible window.`);
2575
2687
  stdout(`Next: run \`${checkCommand}\` to confirm the session, then consult as usual.`);
2576
2688
  return;
2577
2689
  }
@@ -2587,7 +2699,9 @@ export function printBrowserLoginGuide(stdout, input) {
2587
2699
  stdout(`7. Run \`${smokeCommand}\` to verify a real Pro response path.`);
2588
2700
  }
2589
2701
  else {
2590
- stdout(`1. Run \`${loginCommand}\` without \`--dry-run\` to open the dedicated Chrome window.`);
2702
+ stdout(noInteractiveWindow
2703
+ ? `1. Run \`${headedLoginCommand}\` to open a visible dedicated Chrome window for login or verification.`
2704
+ : `1. Run \`${loginCommand}\` without \`--dry-run\` to open the dedicated Chrome window.`);
2591
2705
  stdout(`2. Log in manually at ${input.loginUrl} in that Chrome window.`);
2592
2706
  stdout("3. If ChatGPT asks for captcha, Cloudflare/human verification, permission, or account verification, complete it in the browser.");
2593
2707
  stdout("4. For usage limit, message limit, model limit, or rate limit, wait for the reset or choose an available model in the browser.");
@@ -40,8 +40,8 @@ export async function runSetupCommand(rest, io) {
40
40
  ? await runBrowserDefaultsWizard(resolvePromptUser(io), io.stdout)
41
41
  : parseBrowserDefaultFlags(rest);
42
42
  const config = await writeLocalConfig(targetCwd, {
43
- host: readFlag(rest, "--host") ?? "127.0.0.1",
44
- port: readPortFlag(rest, "--port") ?? 8787,
43
+ host: readFlag(rest, "--host"),
44
+ port: readPortFlag(rest, "--port"),
45
45
  token: readFlag(rest, "--token"),
46
46
  tokenTtlHours: readPositiveNumberFlag(rest, "--token-ttl-hours"),
47
47
  browserDefaults
package/dist/cli.js CHANGED
@@ -82,10 +82,6 @@ async function runInteractiveUi(io) {
82
82
  const { listRecentChatGptConversations } = await import("./chatgpt-browser.js");
83
83
  return listRecentChatGptConversations({});
84
84
  },
85
- openThread: async (url) => {
86
- const { navigateChatGptTabTo } = await import("./chatgpt-browser.js");
87
- return navigateChatGptTabTo(url, {});
88
- },
89
85
  listProjectsWithIds: async () => {
90
86
  const { listChatGptProjectsWithIds } = await import("./chatgpt-browser.js");
91
87
  return listChatGptProjectsWithIds({});
@@ -97,6 +93,7 @@ async function runInteractiveUi(io) {
97
93
  },
98
94
  runConsult: (args, onProgress) => runCli(args, {
99
95
  ...io,
96
+ navigateInteractiveTarget: true,
100
97
  stderr: (line) => {
101
98
  if (line.startsWith("progress:"))
102
99
  onProgress(line);
package/dist/config.js CHANGED
@@ -102,9 +102,9 @@ export async function writeLocalConfig(cwd, input = {}) {
102
102
  await ensureBridgeLocalFiles(cwd);
103
103
  await assertLocalConfigTargetSafe(cwd);
104
104
  const now = new Date().toISOString();
105
- const host = normalizeLoopbackHttpHost(input.host ?? "127.0.0.1");
106
- const port = input.port ?? 8787;
107
105
  const existing = await readExistingConfig(cwd);
106
+ const host = normalizeLoopbackHttpHost(input.host ?? existing?.host ?? "127.0.0.1");
107
+ const port = input.port ?? existing?.port ?? 8787;
108
108
  // A setup re-run that only adjusts defaults/host/port must NOT rotate the
109
109
  // token - that would silently 401 every client holding the old MCP URL.
110
110
  // Rotation happens only when the caller explicitly asks for a token
@@ -275,7 +275,7 @@ export function getTokenExpiryStatus(config, now = new Date()) {
275
275
  ? {
276
276
  status: "expired",
277
277
  token_expires_at: config.token_expires_at,
278
- warning: `Token expired at ${config.token_expires_at}. Run \`prodex setup\` to create a new URL.`
278
+ warning: `Token expired at ${config.token_expires_at}. Run \`prodex setup --token-ttl-hours <hours>\` to create a new URL, then restart \`prodex start\`.`
279
279
  }
280
280
  : { status: "valid", token_expires_at: config.token_expires_at };
281
281
  }
package/dist/http-mcp.js CHANGED
@@ -27,7 +27,7 @@ export async function startHttpMcpServer(options) {
27
27
  // HOW to authorize without leaking anything token-specific.
28
28
  writeJson(res, 401, {
29
29
  error: "unauthorized",
30
- hint: "Provide a valid token via `?prodex_token=<token>` or `Authorization: Bearer <token>`. If your token expired, regenerate the profile with `prodex setup` and re-read the URL from `prodex status`."
30
+ hint: "Provide a valid token via `?prodex_token=<token>` or `Authorization: Bearer <token>`. If your token expired, run `prodex setup --token-ttl-hours <hours>`, restart `prodex start`, and read the new URL with `prodex status --show-token --url-only`."
31
31
  });
32
32
  return;
33
33
  }