@youdie006/prodex 0.40.5 → 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 } : {}),
@@ -1352,15 +1394,21 @@ export async function runAskProCommand(rest, io) {
1352
1394
  const autoLoginAllowed = !parsedAskPro.optionArgs.includes("--no-auto-login") &&
1353
1395
  (parsedAskPro.optionArgs.includes("--auto-login") || io.isInteractive === true);
1354
1396
  let consult;
1397
+ // Declared out here so a retry that fails still reports what recovery
1398
+ // did: the notes used to live inside the inner catch and were attached
1399
+ // to the ANSWER, so a recovered browser whose retry then died recorded
1400
+ // nothing about the recovery at all.
1401
+ const recoveryNotes = [];
1355
1402
  try {
1356
1403
  try {
1357
1404
  consult = await sendOnce();
1358
1405
  }
1359
1406
  catch (error) {
1360
1407
  const firstBlocker = browserSendBlockerFromError(error);
1361
- 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)
1362
1411
  throw error;
1363
- const recoveryNotes = [];
1364
1412
  const recovered = await attemptBrowserAutoRecovery(io.stderr, {
1365
1413
  ...(browserPort !== undefined ? { port: browserPort } : {}),
1366
1414
  notes: recoveryNotes
@@ -1387,7 +1435,7 @@ export async function runAskProCommand(rest, io) {
1387
1435
  // What the send had already noticed before it died. These used to go
1388
1436
  // out with the result, so a failure dropped them - including the note
1389
1437
  // that would explain it, like having just moved off the Work surface.
1390
- const blockedWarnings = sendWarningsFromError(error).map(redactProject);
1438
+ const blockedWarnings = [...recoveryNotes, ...sendWarningsFromError(error)].map(redactProject);
1391
1439
  for (const warning of blockedWarnings)
1392
1440
  io.stderr(warning);
1393
1441
  const persistedBlocker = {
@@ -1797,6 +1845,26 @@ function autoClearDisabledByEnv(env = process.env) {
1797
1845
  const raw = (env.PRODEX_NO_AUTO_CLEAR ?? "").trim().toLowerCase();
1798
1846
  return raw === "1" || raw === "true" || raw === "yes";
1799
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
+ }
1854
+ /**
1855
+ * What recovery did to the browser, in the words the receipt keeps.
1856
+ *
1857
+ * Both halves are recorded because both change what the answer came from. The
1858
+ * ending case takes someone's running browser with it; the launch case is the
1859
+ * quieter one and used to record nothing at all - measured, killing the
1860
+ * dedicated browser and sending with --auto-login recovered in three seconds
1861
+ * and left warnings: [] on the receipt, the result and the task.
1862
+ */
1863
+ export function browserRecoveredNote(ended) {
1864
+ return ended.length > 0
1865
+ ? `browser_recovered: the dedicated browser stopped answering its control port and prodex ended it (pid ${ended.join(", ")}) and started a fresh one before sending. Anything it was doing at the time is gone; the profile and login were kept.`
1866
+ : "browser_recovered: the dedicated browser was not running, so prodex started it with the saved profile before sending. The login was kept; anything the old browser had open is gone.";
1867
+ }
1800
1868
  export async function attemptBrowserAutoRecovery(stderr, options) {
1801
1869
  // Launching is right when the browser is gone and wrong when it is only deaf:
1802
1870
  // a second Chrome on the same profile joins the wedged one rather than
@@ -1807,9 +1875,26 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1807
1875
  // scanning the DEFAULT profile for a custom-profile user put a second,
1808
1876
  // healthy browser's renderers on the list this function kills.
1809
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
+ }
1810
1895
  const scanFor = {
1811
1896
  ...(options.port !== undefined ? { port: options.port } : {}),
1812
- ...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {})
1897
+ ...(lastLoginForPort?.profile_dir ? { profileDir: lastLoginForPort.profile_dir } : {})
1813
1898
  };
1814
1899
  const wedged = findWedgedBrowser(scanFor);
1815
1900
  if (wedged.length > 0) {
@@ -1829,17 +1914,25 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1829
1914
  return false;
1830
1915
  }
1831
1916
  stderr(`recover: the browser stopped answering; ending it (pid ${wedged.join(", ")}) and starting a fresh one...`);
1832
- // Ending someone's browser is not a progress line to scroll past: it goes on
1833
- // the receipt, where an agent or a person reading `pro latest` will see it.
1834
- options.notes?.push(`browser_recovered: the dedicated browser stopped answering its control port and prodex ended it (pid ${wedged.join(", ")}) and started a fresh one before sending. Anything it was doing at the time is gone; the profile and login were kept.`);
1835
- 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
+ }
1836
1923
  // Wait for the profile lock to actually clear rather than guessing at a
1837
1924
  // delay: the replacement launch fails outright if the old process still
1838
1925
  // holds it, which is how the first self-heal attempt ended.
1926
+ let residualPids = [];
1839
1927
  for (let attempt = 0; attempt < 10; attempt += 1) {
1840
- if (findWedgedBrowser(scanFor).length === 0)
1928
+ residualPids = findWedgedBrowser(scanFor);
1929
+ if (residualPids.length === 0)
1841
1930
  break;
1842
- 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);
1843
1936
  }
1844
1937
  }
1845
1938
  stderr("recover: browser is not running - launching the dedicated ChatGPT browser (Ctrl+C aborts)...");
@@ -1847,33 +1940,50 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1847
1940
  // Reuse the profile the user last logged in with; launching the default
1848
1941
  // profile for a custom-profile user would wait on the wrong (logged-out)
1849
1942
  // profile or, worse, silently send to a different account.
1850
- // Relaunch in the SAME window mode the user chose: silently reopening a
1851
- // visible window for someone running headless would be exactly the
1852
- // surprise window they turned headless to avoid.
1853
- const headless = resolveHeadlessPreference(lastLogin?.headless);
1854
- // Rejoin the same virtual display the user set up, so recovery does not
1855
- // put a window back on a desktop they deliberately keep empty.
1856
- const virtualDisplay = lastLogin?.virtual_display !== undefined || resolveVirtualDisplayPreference()
1857
- ? 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 } : {})
1858
1948
  : undefined;
1859
1949
  const opened = openChatGptBrowser({
1860
1950
  ...(options.port !== undefined ? { port: options.port } : {}),
1861
- ...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {}),
1862
- ...(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,
1863
1955
  ...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
1864
1956
  });
1865
1957
  await assertBrowserLaunchStayedAlive(opened);
1866
- const ready = await waitForChatGptLoginReady(stderr, { port: opened.port, timeoutMs: 120_000 });
1867
- if (!ready)
1868
- return false;
1869
- // Restore the no-window setup too: someone who runs minimized does not
1870
- // want recovery to leave a window sitting on their desktop.
1871
- if (!headless && (lastLogin?.minimized === true || resolveMinimizeWindowPreference())) {
1958
+ let minimized = false;
1959
+ if (windowMode.minimized) {
1872
1960
  const outcome = await minimizeChatGptWindow({ port: opened.port }).catch(() => undefined);
1873
- if (outcome?.minimized)
1961
+ minimized = outcome?.minimized === true;
1962
+ if (minimized)
1874
1963
  stderr("recover: window minimized again");
1875
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;
1876
1985
  stderr("recover: browser READY - retrying the send...");
1986
+ options.notes?.push(browserRecoveredNote(wedged));
1877
1987
  return true;
1878
1988
  }
1879
1989
  catch (error) {
@@ -1893,8 +2003,13 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
1893
2003
  const openTabFn = deps.openTabFn ?? openChatGptTab;
1894
2004
  const timeoutMs = options.timeoutMs ?? 300_000;
1895
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.`;
1896
2009
  const startedAt = now();
1897
- 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)...`);
1898
2013
  let lastState = "";
1899
2014
  let openMissingTabAttempts = 0;
1900
2015
  while (now() - startedAt < timeoutMs) {
@@ -1911,19 +2026,26 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
1911
2026
  ? "login: the running Chrome had no ChatGPT tab - opening a ChatGPT tab in it..."
1912
2027
  : `login: still no ChatGPT tab - opening one again (attempt ${attempt})...`);
1913
2028
  const opened = await openTabFn(options.port);
1914
- if (opened === false)
1915
- 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
+ }
1916
2034
  await sleepFn(pollMs);
1917
2035
  continue;
1918
2036
  }
1919
2037
  const state = !status.reachable
1920
2038
  ? "login: browser starting..."
1921
2039
  : status.blocker
1922
- ? `login: blocked - ${status.blocker.message}`
2040
+ ? `login: blocked - ${status.blocker.message}${hasInteractiveWindow ? "" : headedLoginHint}`
1923
2041
  : !status.loggedInLikely
1924
- ? "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}`
1925
2045
  : !status.hasComposer
1926
- ? "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}`
1927
2049
  : "";
1928
2050
  if (state === "") {
1929
2051
  stderr(`login: READY - logged-in ChatGPT tab with composer detected (${Math.round((now() - startedAt) / 1000)}s).`);
@@ -1938,7 +2060,9 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
1938
2060
  break;
1939
2061
  await sleepFn(Math.min(pollMs, Math.max(1, remainingMs)));
1940
2062
  }
1941
- 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.`);
1942
2066
  return false;
1943
2067
  }
1944
2068
  export async function assertBrowserLaunchStayedAlive(opened, timeoutMs) {
@@ -2529,9 +2653,14 @@ export async function listConsultListEntries(store, options = { readOnly: true }
2529
2653
  }
2530
2654
  return entries;
2531
2655
  }
2656
+ function formatHeadedBrowserLoginCommand(sourceCli, options = {}) {
2657
+ return `${formatBrowserLoginCommand(sourceCli, options)} --headed`;
2658
+ }
2532
2659
  export function printBrowserLoginGuide(stdout, input) {
2533
- 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;
2534
2662
  const loginCommand = formatBrowserLoginCommand(input.sourceCli, input.commandOptions);
2663
+ const headedLoginCommand = formatHeadedBrowserLoginCommand(input.sourceCli, input.commandOptions);
2535
2664
  const runtimeCommandOptions = {
2536
2665
  ...(input.commandOptions?.cwd ? { cwd: input.commandOptions.cwd } : {}),
2537
2666
  ...(input.commandOptions?.port ? { port: input.commandOptions.port } : {})
@@ -2543,13 +2672,18 @@ export function printBrowserLoginGuide(stdout, input) {
2543
2672
  ? input.reused
2544
2673
  ? "Headless ChatGPT browser is already running - reusing it (no window)."
2545
2674
  : "Started the dedicated ChatGPT browser headless (no window). It reuses the profile you signed in with."
2546
- : input.reused
2547
- ? "Chrome is already running for ChatGPT - reusing it (no new window opened)."
2548
- : input.opened
2549
- ? "Opened the dedicated Chrome window for ChatGPT."
2550
- : "Dry run: no browser was opened.");
2551
- 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)) {
2552
2685
  stdout("");
2686
+ stdout(`If ChatGPT needs login, captcha, or human verification, run \`${headedLoginCommand}\` to handle it in a visible window.`);
2553
2687
  stdout(`Next: run \`${checkCommand}\` to confirm the session, then consult as usual.`);
2554
2688
  return;
2555
2689
  }
@@ -2565,7 +2699,9 @@ export function printBrowserLoginGuide(stdout, input) {
2565
2699
  stdout(`7. Run \`${smokeCommand}\` to verify a real Pro response path.`);
2566
2700
  }
2567
2701
  else {
2568
- 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.`);
2569
2705
  stdout(`2. Log in manually at ${input.loginUrl} in that Chrome window.`);
2570
2706
  stdout("3. If ChatGPT asks for captcha, Cloudflare/human verification, permission, or account verification, complete it in the browser.");
2571
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);