@youdie006/prodex 0.40.6 → 0.40.9

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";
@@ -13,9 +13,19 @@ import { withBrowserSendLock } from "./browser-send-lock.js";
13
13
  import { blockerCause, buildBlockerReport, CATCH_ALL_CODES } from "./blocker-report.js";
14
14
  import { projectIdFromSidebar, resolveContinuationThread } from "./continue-thread.js";
15
15
  import { readBridgeRoots } from "./registry.js";
16
+ import { ProdexRequestIdSchema, SessionKeySchema } from "./schema.js";
16
17
  import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
17
18
  import { CLI_VERSION } from "./cli-help.js";
18
19
  import { PRODEX_ISSUE_REPO, buildIssueReport, fileGitHubIssue } from "./issue-report.js";
20
+ function resolveBrowserProfileDirForLaunch(profileDir, launchCwd = process.cwd()) {
21
+ const resolved = path.resolve(launchCwd, profileDir);
22
+ try {
23
+ return statSync(resolved).isDirectory() ? realpathSync(resolved) : resolved;
24
+ }
25
+ catch {
26
+ return resolved;
27
+ }
28
+ }
19
29
  export async function runChatgptCommand(rest, io) {
20
30
  const [subcommand, ...chatgptArgs] = rest;
21
31
  if (!subcommand || isHelpSubcommand(subcommand)) {
@@ -195,23 +205,45 @@ export async function runProCommand(rest, io, runCliFn) {
195
205
  if (browserSubcommand === "login") {
196
206
  if (printProBrowserHelpIfRequested(browserArgs, "pro browser login", io, {
197
207
  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"]
208
+ booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headed", "--headless", "--minimized", "--virtual-display"]
199
209
  })) {
200
210
  return 0;
201
211
  }
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"]);
212
+ 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
213
  if (browserArgs.includes("--wait") && browserArgs.includes("--no-wait")) {
204
214
  throw new Error("pro browser login cannot combine --wait and --no-wait");
205
215
  }
206
216
  const loginUrl = readChatGptBrowserUrlFlag(browserArgs);
207
217
  const sourceCli = resolveOptionalFileFlag(io.cwd, browserArgs, "--source-cli");
208
218
  const targetCwd = readFlag(browserArgs, "--cwd") ? resolveCwdFlag(io.cwd, browserArgs) : undefined;
209
- const profileDir = readFlag(browserArgs, "--profile-dir");
219
+ const requestedProfileFlag = readFlag(browserArgs, "--profile-dir");
220
+ const requestedProfileDir = requestedProfileFlag
221
+ ? resolveBrowserProfileDirForLaunch(requestedProfileFlag)
222
+ : undefined;
210
223
  const port = resolveCdpPort(readPortFlag(browserArgs, "--port"));
211
224
  const launchTimeoutMs = readPositiveIntegerFlag(browserArgs, "--launch-timeout-ms");
225
+ const savedLaunch = await readLastBrowserLoginLaunch();
226
+ // A saved launch belongs to a control port. Reusing its profile for a
227
+ // different port would silently widen the existing custom-port rules.
228
+ const savedLaunchForPort = savedLaunch?.port === port ? savedLaunch : undefined;
229
+ const savedProfileDir = savedLaunchForPort?.profile_dir
230
+ ? resolveBrowserProfileDirForLaunch(savedLaunchForPort.profile_dir)
231
+ : undefined;
232
+ const profileDir = requestedProfileDir ?? savedProfileDir;
233
+ const windowMode = resolveBrowserWindowMode({
234
+ flags: {
235
+ ...(browserArgs.includes("--headed") ? { headed: true } : {}),
236
+ ...(browserArgs.includes("--headless") ? { headless: true } : {}),
237
+ ...(browserArgs.includes("--virtual-display") ? { virtualDisplay: true } : {}),
238
+ ...(browserArgs.includes("--minimized") ? { minimized: true } : {})
239
+ },
240
+ // Window mode is a user preference across launches; only the saved
241
+ // profile and display identity are scoped to this resolved port.
242
+ ...(savedLaunch ? { lastLogin: savedLaunch } : {})
243
+ });
212
244
  const commandOptions = {
213
245
  ...(targetCwd ? { cwd: targetCwd } : {}),
214
- ...(profileDir ? { profileDir } : {}),
246
+ ...(requestedProfileDir ? { profileDir: requestedProfileDir } : {}),
215
247
  ...(port !== DEFAULT_CDP_PORT ? { port } : {}),
216
248
  ...(readFlag(browserArgs, "--url") ? { url: loginUrl } : {}),
217
249
  ...(launchTimeoutMs !== undefined ? { launchTimeoutMs } : {})
@@ -219,6 +251,8 @@ export async function runProCommand(rest, io, runCliFn) {
219
251
  if (browserArgs.includes("--dry-run")) {
220
252
  printBrowserLoginGuide(io.stdout, {
221
253
  opened: false,
254
+ headless: windowMode.headless,
255
+ virtualDisplay: windowMode.virtualDisplay,
222
256
  loginUrl,
223
257
  profileDir: profileDir ?? defaultChatGptProfileDir(),
224
258
  port,
@@ -231,48 +265,41 @@ export async function runProCommand(rest, io, runCliFn) {
231
265
  // again: Chrome's singleton would just open ANOTHER window (the recurring
232
266
  // "extra windows" problem, which then blocks sends as
233
267
  // 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
268
  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
269
  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
270
  const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
259
271
  if (alreadyRunning) {
272
+ if (requestedProfileDir &&
273
+ savedProfileDir &&
274
+ requestedProfileDir !== savedProfileDir) {
275
+ 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.`);
276
+ }
260
277
  // One Chrome profile cannot serve a headed and a headless instance at
261
278
  // once, and reusing the running one would silently ignore the
262
279
  // 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;
280
+ const runningHeadless = savedLaunchForPort ? savedLaunchForPort.headless === true : undefined;
265
281
  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.`);
282
+ 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
283
  }
268
284
  // Same for the display: a browser already on your desktop cannot be
269
285
  // moved onto a virtual display by reusing it, and silently reusing
270
286
  // 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;
287
+ const runningVirtual = savedLaunchForPort ? savedLaunchForPort.virtual_display !== undefined : undefined;
272
288
  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.`);
289
+ 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.`);
290
+ }
291
+ if (savedLaunchForPort?.minimized === true && !windowMode.minimized) {
292
+ 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
293
  }
275
294
  }
295
+ // Allocate/rejoin a display only for a new browser. An already-running
296
+ // virtual browser owns its saved display identity and needs no new X
297
+ // server just because login was invoked again.
298
+ const virtualDisplay = wantsVirtualDisplay && !alreadyRunning
299
+ ? await ensureVirtualDisplay(savedLaunchForPort?.virtual_display !== undefined
300
+ ? { displayNumber: savedLaunchForPort.virtual_display }
301
+ : {})
302
+ : undefined;
276
303
  // A wedged browser is unreachable, so without this login would launch a
277
304
  // second Chrome onto the same profile and leave the first one burning
278
305
  // CPU - the same mistake the unattended recovery path made. Checked
@@ -291,7 +318,9 @@ export async function runProCommand(rest, io, runCliFn) {
291
318
  port,
292
319
  profileDir,
293
320
  url: loginUrl,
294
- ...(headless ? { headless } : {}),
321
+ // Pass false too: openChatGptBrowser otherwise consults the env
322
+ // again and can resurrect a mode the shared resolver disabled.
323
+ headless,
295
324
  ...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
296
325
  });
297
326
  if (!alreadyRunning) {
@@ -302,10 +331,9 @@ export async function runProCommand(rest, io, runCliFn) {
302
331
  // Minimize BEFORE recording, so the record reflects what actually
303
332
  // happened (a desktop that hides minimized windows gets restored and
304
333
  // recorded as a normal window).
305
- const wantsMinimized = browserArgs.includes("--minimized") || resolveMinimizeWindowPreference();
306
334
  let minimized = false;
307
335
  let minimizeNote;
308
- if (wantsMinimized && !headless) {
336
+ if (windowMode.minimized && !headless) {
309
337
  try {
310
338
  const outcome = await minimizeChatGptWindow({ port: opened.port });
311
339
  minimized = outcome.minimized;
@@ -317,20 +345,31 @@ export async function runProCommand(rest, io, runCliFn) {
317
345
  minimizeNote = `window: could not minimize (${errorMessage(error)}); leaving it as it is.`;
318
346
  }
319
347
  }
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)"}.`);
348
+ // A reused browser with neither an explicit nor saved profile is
349
+ // reachable, but its actual profile cannot be inferred from the port.
350
+ // Do not overwrite a useful launch record with an invented default.
351
+ if (!alreadyRunning || profileDir) {
352
+ await recordBrowserLoginLaunch({
353
+ profile_dir: opened.profileDir,
354
+ port: opened.port,
355
+ headless,
356
+ minimized,
357
+ ...(virtualDisplay
358
+ ? { virtual_display: virtualDisplay.displayNumber }
359
+ : wantsVirtualDisplay && savedLaunchForPort?.virtual_display !== undefined
360
+ ? { virtual_display: savedLaunchForPort.virtual_display }
361
+ : {})
362
+ });
363
+ }
364
+ const virtualDisplayNumber = virtualDisplay?.displayNumber ?? savedLaunchForPort?.virtual_display;
365
+ if (wantsVirtualDisplay && virtualDisplayNumber !== undefined) {
366
+ io.stdout(`window: none - running on virtual display :${virtualDisplayNumber}${virtualDisplay?.startedNow ? " (started now)" : " (reused)"}.`);
329
367
  }
330
368
  printBrowserLoginGuide(io.stdout, {
331
369
  opened: !alreadyRunning,
332
370
  reused: alreadyRunning,
333
371
  headless,
372
+ virtualDisplay: wantsVirtualDisplay,
334
373
  loginUrl,
335
374
  profileDir: opened.profileDir,
336
375
  port: opened.port,
@@ -345,7 +384,12 @@ export async function runProCommand(rest, io, runCliFn) {
345
384
  // Verify it here (bounded, no human to wait for) instead of letting
346
385
  // the first consult fail with a confusing not-logged-in blocker.
347
386
  const headlessWaitMs = readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 30_000;
348
- const headlessReady = await waitForChatGptLoginReady(io.stderr, { port: opened.port, timeoutMs: headlessWaitMs });
387
+ const headlessReady = await waitForChatGptLoginReady(io.stderr, {
388
+ port: opened.port,
389
+ timeoutMs: headlessWaitMs,
390
+ windowMode,
391
+ headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
392
+ });
349
393
  if (!headlessReady) {
350
394
  // Name the real cause. Cloudflare rejects headless Chrome by
351
395
  // design (its docs list headless browsers as unsupported), and
@@ -355,8 +399,8 @@ export async function runProCommand(rest, io, runCliFn) {
355
399
  const challenged = finalStatus?.blocker?.code === "cloudflare_check" || /just a moment/i.test(finalStatus?.title ?? "");
356
400
  io.stdout("");
357
401
  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.`);
402
+ ? `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.`
403
+ : `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
404
  return 1;
361
405
  }
362
406
  io.stdout("headless: signed-in session confirmed - consults will run with no visible window.");
@@ -370,7 +414,12 @@ export async function runProCommand(rest, io, runCliFn) {
370
414
  if (!shouldWaitForReady)
371
415
  return 0;
372
416
  const waitTimeoutMs = readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 300_000;
373
- const ready = await waitForChatGptLoginReady(io.stderr, { port: opened.port, timeoutMs: waitTimeoutMs });
417
+ const ready = await waitForChatGptLoginReady(io.stderr, {
418
+ port: opened.port,
419
+ timeoutMs: waitTimeoutMs,
420
+ windowMode,
421
+ headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
422
+ });
374
423
  return ready ? 0 : 1;
375
424
  }
376
425
  if (browserSubcommand === "ask") {
@@ -523,9 +572,9 @@ export async function runProCommand(rest, io, runCliFn) {
523
572
  return 0;
524
573
  }
525
574
  if (browserSubcommand === "recover") {
526
- if (printProBrowserHelpIfRequested(browserArgs, "pro browser recover", io, { valueFlags: ["--cwd", "--port", "--timeout-ms", "--target-url", "--source-cli"] }))
575
+ if (printProBrowserHelpIfRequested(browserArgs, "pro browser recover", io, { valueFlags: ["--cwd", "--port", "--timeout-ms", "--target-url", "--request-id", "--source-cli"] }))
527
576
  return 0;
528
- assertOnlyOptions(browserArgs, "pro browser recover", ["--cwd", "--port", "--timeout-ms", "--target-url", "--source-cli"]);
577
+ assertOnlyOptions(browserArgs, "pro browser recover", ["--cwd", "--port", "--timeout-ms", "--target-url", "--request-id", "--source-cli"]);
529
578
  const recoverCwd = resolveCwdFlag(io.cwd, browserArgs);
530
579
  const recoverSourceCli = resolveOptionalFileFlag(io.cwd, browserArgs, "--source-cli");
531
580
  const targetUrl = readFlag(browserArgs, "--target-url");
@@ -534,6 +583,10 @@ export async function runProCommand(rest, io, runCliFn) {
534
583
  }
535
584
  const recoverPort = readPortFlag(browserArgs, "--port");
536
585
  const recoverTimeoutMs = readPositiveIntegerFlag(browserArgs, "--timeout-ms");
586
+ const recoverRequestIdRaw = readFlag(browserArgs, "--request-id");
587
+ const recoverRequestId = recoverRequestIdRaw === undefined
588
+ ? undefined
589
+ : validatedProdexRequestId(recoverRequestIdRaw, "--request-id");
537
590
  const recoverResolvedPort = resolveCdpPort(recoverPort);
538
591
  let consult;
539
592
  try {
@@ -542,7 +595,12 @@ export async function runProCommand(rest, io, runCliFn) {
542
595
  // send is streaming into, which is the consult it would destroy - and
543
596
  // recovery is exactly what gets run after a send appears to hang, so
544
597
  // the two meeting is a when rather than an if.
545
- consult = await withBrowserSendLock(recoverTimeoutMs ?? 60_000, (detail) => io.stderr(`progress: ${detail}`), () => recoverChatGptAnswerFromThread({ port: recoverPort, targetUrl, timeoutMs: recoverTimeoutMs }));
598
+ consult = await withBrowserSendLock(recoverTimeoutMs ?? 60_000, (detail) => io.stderr(`progress: ${detail}`), () => recoverChatGptAnswerFromThread({
599
+ port: recoverPort,
600
+ targetUrl,
601
+ timeoutMs: recoverTimeoutMs,
602
+ ...(recoverRequestId ? { requestId: recoverRequestId } : {})
603
+ }));
546
604
  }
547
605
  catch (error) {
548
606
  const blocker = sourceAwareBrowserBlocker(browserSendBlockerFromError(error), recoverSourceCli, {
@@ -568,6 +626,12 @@ export async function runProCommand(rest, io, runCliFn) {
568
626
  catch (error) {
569
627
  io.stderr(`answer_artifact_warning: ${errorMessage(error)}`);
570
628
  }
629
+ for (const warning of consult.warnings)
630
+ io.stderr(warning);
631
+ if (consult.requestId)
632
+ io.stderr(`request_id: ${consult.requestId}`);
633
+ if (consult.requestVerified !== undefined)
634
+ io.stderr(`request_verified: ${consult.requestVerified ? "yes" : "no"}`);
571
635
  await recoverStore.completeTask(recoveredTask.id, {
572
636
  status: "done",
573
637
  summary: consult.answer,
@@ -575,8 +639,8 @@ export async function runProCommand(rest, io, runCliFn) {
575
639
  ? [{ path: recoveredArtifactPath, role: "result", bytes: Buffer.byteLength(recoveredArtifactText, "utf8") }]
576
640
  : [],
577
641
  commands: ["recovered ChatGPT answer from thread"],
578
- warnings: [],
579
- provenance: { thread: consult.url, warnings: [] }
642
+ warnings: consult.warnings,
643
+ provenance: { thread: consult.url, warnings: consult.warnings }
580
644
  });
581
645
  io.stdout(`${recoveredTask.id}\tdone\t${consult.url}`);
582
646
  io.stdout("");
@@ -933,16 +997,31 @@ export async function runProCommand(rest, io, runCliFn) {
933
997
  export async function runConsultsCommand(rest, io) {
934
998
  throw new Error("The legacy `consults` alias is retired. Use `prodex pro list`, `prodex pro latest`, or `prodex pro show <task-id|latest>`.");
935
999
  }
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
1000
  function autoLoginDisabledByEnv(env = process.env) {
943
1001
  const raw = (env.PRODEX_NO_AUTO_LOGIN ?? "").trim().toLowerCase();
944
1002
  return raw === "1" || raw === "true" || raw === "yes";
945
1003
  }
1004
+ function validatedSessionKey(value, source) {
1005
+ const parsed = SessionKeySchema.safeParse(value);
1006
+ if (parsed.success)
1007
+ return parsed.data;
1008
+ throw new Error(`${source} must be a non-empty identifier of at most 128 characters using letters, digits, '.', '_', ':', '@', '/', or '-'.`);
1009
+ }
1010
+ export function resolveProdexSessionKey(explicit, env = process.env) {
1011
+ if (explicit !== undefined)
1012
+ return validatedSessionKey(explicit, "--session-key");
1013
+ if (env.PRODEX_SESSION_KEY !== undefined)
1014
+ return validatedSessionKey(env.PRODEX_SESSION_KEY, "PRODEX_SESSION_KEY");
1015
+ if (env.CODEX_THREAD_ID !== undefined)
1016
+ return validatedSessionKey(env.CODEX_THREAD_ID, "CODEX_THREAD_ID");
1017
+ return undefined;
1018
+ }
1019
+ function validatedProdexRequestId(value, source) {
1020
+ const parsed = ProdexRequestIdSchema.safeParse(value);
1021
+ if (parsed.success)
1022
+ return parsed.data;
1023
+ throw new Error(`${source} must be the 32-character lowercase hex request identifier returned by prodex.`);
1024
+ }
946
1025
  // Retired browser subcommands map to the one that replaced them. Every value
947
1026
  // here must be a subcommand that actually exists, so the error is a single hop
948
1027
  // to a runnable command.
@@ -1094,6 +1173,7 @@ export async function runAskProCommand(rest, io) {
1094
1173
  promptText = prompt ? `${prompt}\n\n--- piped input (stdin) ---\n${piped}` : piped;
1095
1174
  }
1096
1175
  const browserDefaults = await loadBrowserDefaults(targetCwd);
1176
+ const sessionKey = resolveProdexSessionKey(readFlag(parsedAskPro.optionArgs, "--session-key"));
1097
1177
  const explicitProject = readFlag(parsedAskPro.optionArgs, "--project");
1098
1178
  const explicitProjectNew = readFlag(parsedAskPro.optionArgs, "--project-new");
1099
1179
  // Opt out of a pinned default project for one send. Without this a repo
@@ -1117,6 +1197,9 @@ export async function runAskProCommand(rest, io) {
1117
1197
  const continueRequested = parsedAskPro.optionArgs.includes("--continue");
1118
1198
  const continueTaskId = readFlag(parsedAskPro.optionArgs, "--continue-task");
1119
1199
  if (continueRequested || continueTaskId !== undefined) {
1200
+ if (continueTaskId === undefined && sessionKey === undefined) {
1201
+ throw new Error("--continue needs --session-key <id> (or PRODEX_SESSION_KEY/CODEX_THREAD_ID) so it cannot use another client's conversation. Use --continue-task <task_id> to name one explicitly.");
1202
+ }
1120
1203
  const conflict = [
1121
1204
  parsedAskPro.optionArgs.includes("--new-chat") ? "--new-chat" : undefined,
1122
1205
  targetUrl !== undefined ? "--target-url" : undefined,
@@ -1146,12 +1229,14 @@ export async function runAskProCommand(rest, io) {
1146
1229
  const resolved = resolveContinuationThread({
1147
1230
  consults: (await targetStore.listSessionsReadOnly()).map((session) => ({
1148
1231
  taskId: session.task_id ?? "",
1232
+ ...(session.session_key ? { sessionKey: session.session_key } : {}),
1149
1233
  ...(session.thread ? { thread: session.thread } : {}),
1150
1234
  status: session.status,
1151
1235
  ...(session.created_at ? { createdAt: session.created_at } : {})
1152
1236
  })),
1153
1237
  ...(continuationProject ? { project: continuationProject } : {}),
1154
1238
  ...(continuationProjectId ? { projectId: continuationProjectId } : {}),
1239
+ ...(sessionKey ? { sessionKey } : {}),
1155
1240
  ...(continueTaskId !== undefined ? { taskId: continueTaskId } : {})
1156
1241
  });
1157
1242
  if ("error" in resolved)
@@ -1163,7 +1248,8 @@ export async function runAskProCommand(rest, io) {
1163
1248
  normalizedTargetUrl = normalizeChatGptTargetUrl(resolved.target.thread);
1164
1249
  io.stderr(`progress: continuing ${resolved.target.taskId}`);
1165
1250
  }
1166
- const newChat = parsedAskPro.optionArgs.includes("--new-chat");
1251
+ const explicitlyRequestedNewChat = parsedAskPro.optionArgs.includes("--new-chat");
1252
+ const newChat = explicitlyRequestedNewChat || normalizedTargetUrl === undefined;
1167
1253
  // A temporary chat is not saved, so there is nothing to come back to: the
1168
1254
  // recovery path every timeout message points at cannot fetch it later.
1169
1255
  // Requiring --new-chat keeps that explicit rather than quietly turning a
@@ -1178,7 +1264,7 @@ export async function runAskProCommand(rest, io) {
1178
1264
  if (temporary && !newChat) {
1179
1265
  throw new Error("--temporary starts a throwaway chat, so it needs --new-chat. A temporary chat cannot be continued or recovered later.");
1180
1266
  }
1181
- if (newChat && normalizedTargetUrl) {
1267
+ if (explicitlyRequestedNewChat && normalizedTargetUrl) {
1182
1268
  throw new Error("ask-pro cannot combine --new-chat with --target-url: --new-chat navigates to a fresh chat while --target-url pins the confirmed tab.");
1183
1269
  }
1184
1270
  const explicitModel = readFlag(parsedAskPro.optionArgs, "--model");
@@ -1293,6 +1379,7 @@ export async function runAskProCommand(rest, io) {
1293
1379
  id: bundle.id,
1294
1380
  direction: "codex_to_chatgpt",
1295
1381
  backend: "chatgpt-control",
1382
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1296
1383
  task_id: task.id,
1297
1384
  thread: normalizedTargetUrl,
1298
1385
  status: "running",
@@ -1328,9 +1415,9 @@ export async function runAskProCommand(rest, io) {
1328
1415
  port: browserPort,
1329
1416
  prompt: bundle.sendText,
1330
1417
  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 } : {}),
1418
+ // Continuations and explicit TUI picks navigate while this lock is
1419
+ // held. Direct CLI targets keep their existing verification-only semantics.
1420
+ ...(continuedFromTaskId || (io.navigateInteractiveTarget === true && normalizedTargetUrl) ? { navigateToTargetUrl: true } : {}),
1334
1421
  timeoutMs: browserTimeoutMs,
1335
1422
  ...(attachments.length > 0 ? { attachments } : {}),
1336
1423
  ...(tools.length > 0 ? { tools } : {}),
@@ -1363,7 +1450,9 @@ export async function runAskProCommand(rest, io) {
1363
1450
  }
1364
1451
  catch (error) {
1365
1452
  const firstBlocker = browserSendBlockerFromError(error);
1366
- if (firstBlocker.code !== "browser_unreachable" || !autoLoginAllowed)
1453
+ // Loss while reading an already-submitted prompt must not send it again.
1454
+ if (firstBlocker.code !== "browser_unreachable" ||
1455
+ !firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed)
1367
1456
  throw error;
1368
1457
  const recovered = await attemptBrowserAutoRecovery(io.stderr, {
1369
1458
  ...(browserPort !== undefined ? { port: browserPort } : {}),
@@ -1420,6 +1509,7 @@ export async function runAskProCommand(rest, io) {
1420
1509
  id: bundle.id,
1421
1510
  direction: "codex_to_chatgpt",
1422
1511
  backend: "chatgpt-control",
1512
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1423
1513
  task_id: task.id,
1424
1514
  thread: blockedThread,
1425
1515
  status: "blocked",
@@ -1433,7 +1523,15 @@ export async function runAskProCommand(rest, io) {
1433
1523
  // Keep stdout machine-parseable for --json consumers on the blocked
1434
1524
  // path too; the human-readable error still goes to stderr via throw.
1435
1525
  if (jsonOutput) {
1436
- io.stdout(JSON.stringify({ task_id: task.id, status: "blocked", thread: blockedThread ?? null, answer: null, warnings: blockedWarnings, blocker }, null, 2));
1526
+ io.stdout(JSON.stringify({
1527
+ task_id: task.id,
1528
+ status: "blocked",
1529
+ thread: blockedThread ?? null,
1530
+ answer: null,
1531
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1532
+ warnings: blockedWarnings,
1533
+ blocker
1534
+ }, null, 2));
1437
1535
  }
1438
1536
  throw new Error(formatBlockedConsultRecordedMessage(message, task.id, sourceCli, { cwd: targetCwd }));
1439
1537
  }
@@ -1470,6 +1568,10 @@ export async function runAskProCommand(rest, io) {
1470
1568
  io.stderr(warning);
1471
1569
  if (consult.modelSlug)
1472
1570
  io.stderr(`model_used: ${consult.modelSlug}`);
1571
+ if (consult.requestId)
1572
+ io.stderr(`request_id: ${consult.requestId}`);
1573
+ if (consult.requestVerified !== undefined)
1574
+ io.stderr(`request_verified: ${consult.requestVerified ? "yes" : "no"}`);
1473
1575
  // Which conversation this followed, and where the answer actually landed.
1474
1576
  // The progress line that says it is filtered out of MCP notes, so an
1475
1577
  // agent asking for a follow-up had no way to know which thread it got -
@@ -1518,6 +1620,8 @@ export async function runAskProCommand(rest, io) {
1518
1620
  // receipt used to record only what prodex asked for.
1519
1621
  ...(consult.modelSlug ? { model_used: consult.modelSlug } : {}),
1520
1622
  ...(proVerified !== undefined ? { pro_verified: proVerified } : {}),
1623
+ ...(consult.requestId ? { request_id: consult.requestId } : {}),
1624
+ ...(consult.requestVerified !== undefined ? { request_verified: consult.requestVerified } : {}),
1521
1625
  // Intent and evidence, kept apart. `selection` is what was asked
1522
1626
  // for; this is where the answer turned out to be, and whether
1523
1627
  // anything actually confirmed it.
@@ -1562,6 +1666,7 @@ export async function runAskProCommand(rest, io) {
1562
1666
  id: bundle.id,
1563
1667
  direction: "codex_to_chatgpt",
1564
1668
  backend: "chatgpt-control",
1669
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1565
1670
  task_id: task.id,
1566
1671
  thread: consult.url,
1567
1672
  status: "done",
@@ -1573,6 +1678,9 @@ export async function runAskProCommand(rest, io) {
1573
1678
  status: result.status,
1574
1679
  thread: consult.url,
1575
1680
  answer: result.summary,
1681
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1682
+ ...(consult.requestId ? { request_id: consult.requestId } : {}),
1683
+ ...(consult.requestVerified !== undefined ? { request_verified: consult.requestVerified } : {}),
1576
1684
  ...(continuedFromTaskId ? { continued_from: continuedFromTaskId } : {}),
1577
1685
  destination: {
1578
1686
  observed: destination.destination,
@@ -1596,6 +1704,7 @@ export async function runAskProCommand(rest, io) {
1596
1704
  id: bundle.id,
1597
1705
  direction: "codex_to_chatgpt",
1598
1706
  backend: "manual",
1707
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1599
1708
  status: "preview",
1600
1709
  warnings: []
1601
1710
  }, io);
@@ -1648,6 +1757,14 @@ Rules:
1648
1757
 
1649
1758
  Write the debate in the language of the topic.`;
1650
1759
  }
1760
+ function browserMetadataFromNotes(notes) {
1761
+ const requestId = notes.find((line) => line.startsWith("request_id: "))?.slice("request_id: ".length).trim();
1762
+ const verified = notes.find((line) => line.startsWith("request_verified: "))?.slice("request_verified: ".length).trim();
1763
+ return {
1764
+ ...(requestId ? { request_id: requestId } : {}),
1765
+ ...(verified === "yes" ? { request_verified: true } : verified === "no" ? { request_verified: false } : {})
1766
+ };
1767
+ }
1651
1768
  /**
1652
1769
  * MCP-side counterpart to `pro browser recover`. A consult that outlives its
1653
1770
  * budget hands back the thread it landed in, but until now the only way to act
@@ -1662,6 +1779,7 @@ export async function performBrowserRecoverForMcp(cwd, input) {
1662
1779
  "recover",
1663
1780
  "--target-url",
1664
1781
  input.thread,
1782
+ ...(input.request_id !== undefined ? ["--request-id", input.request_id] : []),
1665
1783
  ...(input.timeout_ms !== undefined ? ["--timeout-ms", String(input.timeout_ms)] : [])
1666
1784
  ];
1667
1785
  await runProCommand(argv, {
@@ -1671,11 +1789,13 @@ export async function performBrowserRecoverForMcp(cwd, input) {
1671
1789
  }, async () => 0);
1672
1790
  const header = stdoutLines[0] ?? "";
1673
1791
  const [taskId = "", status = "", thread = ""] = header.split("\t");
1792
+ const metadata = browserMetadataFromNotes(stderrLines);
1674
1793
  return {
1675
1794
  task_id: taskId,
1676
1795
  status,
1677
1796
  thread,
1678
1797
  answer: stdoutLines.slice(2).join("\n"),
1798
+ ...metadata,
1679
1799
  notes: stderrLines
1680
1800
  };
1681
1801
  }
@@ -1706,6 +1826,7 @@ export function answerRescuedFromFailedPersistence(stdoutLines) {
1706
1826
  export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1707
1827
  const stdoutLines = [];
1708
1828
  const stderrLines = [];
1829
+ const sessionKey = resolveProdexSessionKey(input.session_key);
1709
1830
  const argv = [
1710
1831
  "--send",
1711
1832
  // MCP callers have no terminal, so the interactive auto-recovery gate
@@ -1718,6 +1839,7 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1718
1839
  ...(input.pro_mode !== undefined ? ["--pro-mode", input.pro_mode] : []),
1719
1840
  ...(input.effort !== undefined ? ["--effort", input.effort] : []),
1720
1841
  ...(input.project !== undefined ? ["--project", input.project] : []),
1842
+ ...(sessionKey !== undefined ? ["--session-key", sessionKey] : []),
1721
1843
  ...(input.timeout_ms !== undefined ? ["--timeout-ms", String(input.timeout_ms)] : []),
1722
1844
  ...(input.files ?? []).flatMap((file) => ["--file", file]),
1723
1845
  ...(input.attach ?? []).flatMap((file) => ["--attach", file]),
@@ -1750,25 +1872,31 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1750
1872
  const rescued = answerRescuedFromFailedPersistence(stdoutLines);
1751
1873
  if (!rescued)
1752
1874
  throw error;
1875
+ const notes = [
1876
+ ...stderrLines.filter((line) => !line.startsWith("progress:")),
1877
+ `answer_not_saved: ${errorMessage(error)}`
1878
+ ];
1753
1879
  return {
1754
1880
  task_id: rescued.taskId,
1755
1881
  status: "answered_not_saved",
1756
1882
  thread: rescued.thread,
1757
1883
  answer: rescued.answer,
1758
- notes: [
1759
- ...stderrLines.filter((line) => !line.startsWith("progress:")),
1760
- `answer_not_saved: ${errorMessage(error)}`
1761
- ]
1884
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1885
+ ...browserMetadataFromNotes(notes),
1886
+ notes
1762
1887
  };
1763
1888
  }
1764
1889
  const header = stdoutLines[0] ?? "";
1765
1890
  const [taskId = "", status = "", thread = ""] = header.split("\t");
1891
+ const notes = stderrLines.filter((line) => !line.startsWith("progress:"));
1766
1892
  return {
1767
1893
  task_id: taskId,
1768
1894
  status,
1769
1895
  thread,
1770
1896
  answer: stdoutLines.slice(2).join("\n"),
1771
- notes: stderrLines.filter((line) => !line.startsWith("progress:"))
1897
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1898
+ ...browserMetadataFromNotes(notes),
1899
+ notes
1772
1900
  };
1773
1901
  }
1774
1902
  /**
@@ -1801,6 +1929,12 @@ function autoClearDisabledByEnv(env = process.env) {
1801
1929
  const raw = (env.PRODEX_NO_AUTO_CLEAR ?? "").trim().toLowerCase();
1802
1930
  return raw === "1" || raw === "true" || raw === "yes";
1803
1931
  }
1932
+ function reportResidualBrowserRecoveryBlocker(stderr, pids, port) {
1933
+ const blocker = wedgedBrowserBlocker([...new Set(pids)].sort((left, right) => left - right), port);
1934
+ stderr(`recover: failed - ${blocker.message}`);
1935
+ stderr(`recover: ${blocker.next_step}`);
1936
+ return false;
1937
+ }
1804
1938
  /**
1805
1939
  * What recovery did to the browser, in the words the receipt keeps.
1806
1940
  *
@@ -1825,9 +1959,26 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1825
1959
  // scanning the DEFAULT profile for a custom-profile user put a second,
1826
1960
  // healthy browser's renderers on the list this function kills.
1827
1961
  const lastLogin = await readLastBrowserLoginLaunch().catch(() => undefined);
1962
+ const recoveryPort = resolveCdpPort(options.port);
1963
+ const savedIdentityBelongsToAnotherPort = lastLogin?.port !== undefined &&
1964
+ lastLogin.port !== recoveryPort &&
1965
+ (lastLogin.profile_dir !== undefined || lastLogin.virtual_display !== undefined);
1966
+ if (savedIdentityBelongsToAnotherPort) {
1967
+ 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.`);
1968
+ return false;
1969
+ }
1970
+ const lastLoginForPort = lastLogin?.port === undefined || lastLogin.port === recoveryPort ? lastLogin : undefined;
1971
+ let windowMode;
1972
+ try {
1973
+ windowMode = resolveBrowserWindowMode({ ...(lastLogin ? { lastLogin } : {}) });
1974
+ }
1975
+ catch (error) {
1976
+ stderr(`recover: failed - ${errorMessage(error)}`);
1977
+ return false;
1978
+ }
1828
1979
  const scanFor = {
1829
1980
  ...(options.port !== undefined ? { port: options.port } : {}),
1830
- ...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {})
1981
+ ...(lastLoginForPort?.profile_dir ? { profileDir: lastLoginForPort.profile_dir } : {})
1831
1982
  };
1832
1983
  const wedged = findWedgedBrowser(scanFor);
1833
1984
  if (wedged.length > 0) {
@@ -1847,17 +1998,25 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1847
1998
  return false;
1848
1999
  }
1849
2000
  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);
2001
+ // Keep the attempt even if launch fails; completion is recorded after READY.
2002
+ options.notes?.push(`browser_recovery_started: prodex began restarting the unresponsive dedicated browser (pid ${wedged.join(", ")}).`);
2003
+ const termination = await endWedgedBrowser(wedged);
2004
+ if (termination.failed.length > 0) {
2005
+ return reportResidualBrowserRecoveryBlocker(stderr, termination.failed, recoveryPort);
2006
+ }
1854
2007
  // Wait for the profile lock to actually clear rather than guessing at a
1855
2008
  // delay: the replacement launch fails outright if the old process still
1856
2009
  // holds it, which is how the first self-heal attempt ended.
2010
+ let residualPids = [];
1857
2011
  for (let attempt = 0; attempt < 10; attempt += 1) {
1858
- if (findWedgedBrowser(scanFor).length === 0)
2012
+ residualPids = findWedgedBrowser(scanFor);
2013
+ if (residualPids.length === 0)
1859
2014
  break;
1860
- await sleep(1_000);
2015
+ if (attempt < 9)
2016
+ await sleep(1_000);
2017
+ }
2018
+ if (residualPids.length > 0) {
2019
+ return reportResidualBrowserRecoveryBlocker(stderr, residualPids, recoveryPort);
1861
2020
  }
1862
2021
  }
1863
2022
  stderr("recover: browser is not running - launching the dedicated ChatGPT browser (Ctrl+C aborts)...");
@@ -1865,37 +2024,50 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1865
2024
  // Reuse the profile the user last logged in with; launching the default
1866
2025
  // profile for a custom-profile user would wait on the wrong (logged-out)
1867
2026
  // 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)
2027
+ // Recovery follows the same precedence as login. In particular, one
2028
+ // supplied false env setting selects the env group and disables saved
2029
+ // modes instead of being mistaken for "not configured".
2030
+ const virtualDisplay = windowMode.virtualDisplay
2031
+ ? await ensureVirtualDisplay(lastLoginForPort?.virtual_display !== undefined ? { displayNumber: lastLoginForPort.virtual_display } : {})
1876
2032
  : undefined;
1877
2033
  const opened = openChatGptBrowser({
1878
2034
  ...(options.port !== undefined ? { port: options.port } : {}),
1879
- ...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {}),
1880
- ...(headless ? { headless } : {}),
2035
+ ...(lastLoginForPort?.profile_dir ? { profileDir: lastLoginForPort.profile_dir } : {}),
2036
+ // Always pass the resolved boolean. Omitting false lets the browser
2037
+ // boundary re-read PRODEX_HEADLESS and revive stale environment state.
2038
+ headless: windowMode.headless,
1881
2039
  ...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
1882
2040
  });
1883
2041
  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())) {
2042
+ let minimized = false;
2043
+ if (windowMode.minimized) {
1890
2044
  const outcome = await minimizeChatGptWindow({ port: opened.port }).catch(() => undefined);
1891
- if (outcome?.minimized)
2045
+ minimized = outcome?.minimized === true;
2046
+ if (minimized)
1892
2047
  stderr("recover: window minimized again");
1893
2048
  }
2049
+ // Record the actual launch before waiting for authentication. A manual
2050
+ // login must target this profile and display even if readiness is blocked.
2051
+ await recordBrowserLoginLaunch({
2052
+ profile_dir: opened.profileDir,
2053
+ port: opened.port,
2054
+ headless: windowMode.headless,
2055
+ minimized,
2056
+ ...(virtualDisplay ? { virtual_display: virtualDisplay.displayNumber } : {})
2057
+ });
2058
+ const ready = await waitForChatGptLoginReady(stderr, {
2059
+ port: opened.port,
2060
+ timeoutMs: 120_000,
2061
+ windowMode,
2062
+ headedLoginCommand: formatHeadedBrowserLoginCommand(undefined, {
2063
+ profileDir: opened.profileDir,
2064
+ ...(opened.port !== DEFAULT_CDP_PORT ? { port: opened.port } : {})
2065
+ })
2066
+ });
2067
+ if (!ready)
2068
+ return false;
1894
2069
  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([]));
2070
+ options.notes?.push(browserRecoveredNote(wedged));
1899
2071
  return true;
1900
2072
  }
1901
2073
  catch (error) {
@@ -1915,8 +2087,13 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
1915
2087
  const openTabFn = deps.openTabFn ?? openChatGptTab;
1916
2088
  const timeoutMs = options.timeoutMs ?? 300_000;
1917
2089
  const pollMs = options.pollMs ?? 2_000;
2090
+ const hasInteractiveWindow = options.windowMode?.headless !== true && options.windowMode?.virtualDisplay !== true;
2091
+ const headedLoginCommand = options.headedLoginCommand ?? "prodex pro browser login --headed";
2092
+ const headedLoginHint = ` No interactive window is available; run \`${headedLoginCommand}\` to complete login, captcha, or human verification visibly.`;
1918
2093
  const startedAt = now();
1919
- stderr("login: waiting for a logged-in ChatGPT tab (finish login in the dedicated Chrome window; Ctrl+C stops waiting)...");
2094
+ stderr(hasInteractiveWindow
2095
+ ? "login: waiting for a logged-in ChatGPT tab (finish login in the dedicated Chrome browser; Ctrl+C stops waiting)..."
2096
+ : `login: waiting for a logged-in ChatGPT tab (no interactive window; use \`${headedLoginCommand}\` if login or verification is required; Ctrl+C stops waiting)...`);
1920
2097
  let lastState = "";
1921
2098
  let openMissingTabAttempts = 0;
1922
2099
  while (now() - startedAt < timeoutMs) {
@@ -1933,19 +2110,26 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
1933
2110
  ? "login: the running Chrome had no ChatGPT tab - opening a ChatGPT tab in it..."
1934
2111
  : `login: still no ChatGPT tab - opening one again (attempt ${attempt})...`);
1935
2112
  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.");
2113
+ if (opened === false) {
2114
+ stderr(hasInteractiveWindow
2115
+ ? "login: could not open a ChatGPT tab through the debug port; open https://chatgpt.com/ in that browser."
2116
+ : `login: could not open a ChatGPT tab through the debug port; run \`${headedLoginCommand}\` to open it visibly.`);
2117
+ }
1938
2118
  await sleepFn(pollMs);
1939
2119
  continue;
1940
2120
  }
1941
2121
  const state = !status.reachable
1942
2122
  ? "login: browser starting..."
1943
2123
  : status.blocker
1944
- ? `login: blocked - ${status.blocker.message}`
2124
+ ? `login: blocked - ${status.blocker.message}${hasInteractiveWindow ? "" : headedLoginHint}`
1945
2125
  : !status.loggedInLikely
1946
- ? "login: waiting for ChatGPT login in the opened window..."
2126
+ ? hasInteractiveWindow
2127
+ ? "login: waiting for ChatGPT login in the dedicated Chrome browser..."
2128
+ : `login: waiting for a saved ChatGPT login.${headedLoginHint}`
1947
2129
  : !status.hasComposer
1948
- ? "login: logged in; open a chat so the prompt composer is visible..."
2130
+ ? hasInteractiveWindow
2131
+ ? "login: logged in; open a chat so the prompt composer is visible..."
2132
+ : `login: logged in, but no prompt composer is ready.${headedLoginHint}`
1949
2133
  : "";
1950
2134
  if (state === "") {
1951
2135
  stderr(`login: READY - logged-in ChatGPT tab with composer detected (${Math.round((now() - startedAt) / 1000)}s).`);
@@ -1960,7 +2144,9 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
1960
2144
  break;
1961
2145
  await sleepFn(Math.min(pollMs, Math.max(1, remainingMs)));
1962
2146
  }
1963
- stderr(`login: not ready after ${Math.round(timeoutMs / 1000)}s. Finish login in the browser, then verify with \`prodex pro browser check\`.`);
2147
+ stderr(hasInteractiveWindow
2148
+ ? `login: not ready after ${Math.round(timeoutMs / 1000)}s. Finish login in the browser, then verify with \`prodex pro browser check\`.`
2149
+ : `login: not ready after ${Math.round(timeoutMs / 1000)}s. Run \`${headedLoginCommand}\` to complete login, captcha, or human verification visibly, then retry.`);
1964
2150
  return false;
1965
2151
  }
1966
2152
  export async function assertBrowserLaunchStayedAlive(opened, timeoutMs) {
@@ -2120,10 +2306,17 @@ export function browserSendBlockerFromError(error) {
2120
2306
  const thread = typeof error === "object" && error !== null && "thread" in error && typeof error.thread === "string"
2121
2307
  ? (error.thread)
2122
2308
  : undefined;
2309
+ const requestId = typeof error === "object" &&
2310
+ error !== null &&
2311
+ "requestId" in error &&
2312
+ typeof error.requestId === "string" &&
2313
+ ProdexRequestIdSchema.safeParse(error.requestId).success
2314
+ ? error.requestId
2315
+ : undefined;
2123
2316
  const timedOut = message.match(/Timed out after [\s\S]*?(\d+)\s*ms/);
2124
2317
  if (timedOut) {
2125
- // Suggest a concrete doubled budget so the user can paste a rerun command
2126
- // instead of guessing what "raise --timeout-ms" means in milliseconds.
2318
+ // Marked sends recover the exact request. Legacy timeout errors retain a
2319
+ // concrete doubled budget so the user need not guess one.
2127
2320
  const usedMs = Number(timedOut[1]);
2128
2321
  const suggestedMs = Number.isFinite(usedMs) && usedMs > 0 ? usedMs * 2 : 600_000;
2129
2322
  return {
@@ -2131,10 +2324,12 @@ export function browserSendBlockerFromError(error) {
2131
2324
  message,
2132
2325
  retryable: true,
2133
2326
  ...(thread ? { thread } : {}),
2134
- next_step: `Rerun with a bigger budget (${formatDurationMs(suggestedMs)}): \`prodex pro browser ask --timeout-ms ${suggestedMs} "<same prompt>"\`.` +
2135
- (thread
2136
- ? ` ChatGPT often finishes after prodex gives up - fetch that answer instead of re-asking: \`prodex pro browser recover --target-url ${thread}\`.`
2137
- : "")
2327
+ next_step: thread && requestId
2328
+ ? `Do not resend automatically. ChatGPT often finishes after prodex gives up; recover that exact request instead: \`prodex pro browser recover --target-url ${thread} --request-id ${requestId}\`.`
2329
+ : `Rerun with a bigger budget (${formatDurationMs(suggestedMs)}): \`prodex pro browser ask --timeout-ms ${suggestedMs} "<same prompt>"\`.` +
2330
+ (thread
2331
+ ? ` ChatGPT often finishes after prodex gives up - fetch that answer instead of re-asking: \`prodex pro browser recover --target-url ${thread}\`.`
2332
+ : "")
2138
2333
  };
2139
2334
  }
2140
2335
  // A CDP command timeout means the page's renderer stalled, which in the
@@ -2551,9 +2746,14 @@ export async function listConsultListEntries(store, options = { readOnly: true }
2551
2746
  }
2552
2747
  return entries;
2553
2748
  }
2749
+ function formatHeadedBrowserLoginCommand(sourceCli, options = {}) {
2750
+ return `${formatBrowserLoginCommand(sourceCli, options)} --headed`;
2751
+ }
2554
2752
  export function printBrowserLoginGuide(stdout, input) {
2555
- const windowAvailable = (input.opened || input.reused === true) && input.headless !== true;
2753
+ const noInteractiveWindow = input.headless === true || input.virtualDisplay === true;
2754
+ const windowAvailable = (input.opened || input.reused === true) && !noInteractiveWindow;
2556
2755
  const loginCommand = formatBrowserLoginCommand(input.sourceCli, input.commandOptions);
2756
+ const headedLoginCommand = formatHeadedBrowserLoginCommand(input.sourceCli, input.commandOptions);
2557
2757
  const runtimeCommandOptions = {
2558
2758
  ...(input.commandOptions?.cwd ? { cwd: input.commandOptions.cwd } : {}),
2559
2759
  ...(input.commandOptions?.port ? { port: input.commandOptions.port } : {})
@@ -2565,13 +2765,18 @@ export function printBrowserLoginGuide(stdout, input) {
2565
2765
  ? input.reused
2566
2766
  ? "Headless ChatGPT browser is already running - reusing it (no window)."
2567
2767
  : "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)) {
2768
+ : input.virtualDisplay && (input.opened || input.reused)
2769
+ ? input.reused
2770
+ ? "ChatGPT browser is already running on its saved virtual display - reusing it (no visible window)."
2771
+ : "Started the dedicated ChatGPT browser on a virtual display (no visible window)."
2772
+ : input.reused
2773
+ ? "Chrome is already running for ChatGPT - reusing it (no new window opened)."
2774
+ : input.opened
2775
+ ? "Opened the dedicated Chrome window for ChatGPT."
2776
+ : "Dry run: no browser was opened.");
2777
+ if (noInteractiveWindow && (input.opened || input.reused)) {
2574
2778
  stdout("");
2779
+ stdout(`If ChatGPT needs login, captcha, or human verification, run \`${headedLoginCommand}\` to handle it in a visible window.`);
2575
2780
  stdout(`Next: run \`${checkCommand}\` to confirm the session, then consult as usual.`);
2576
2781
  return;
2577
2782
  }
@@ -2587,7 +2792,9 @@ export function printBrowserLoginGuide(stdout, input) {
2587
2792
  stdout(`7. Run \`${smokeCommand}\` to verify a real Pro response path.`);
2588
2793
  }
2589
2794
  else {
2590
- stdout(`1. Run \`${loginCommand}\` without \`--dry-run\` to open the dedicated Chrome window.`);
2795
+ stdout(noInteractiveWindow
2796
+ ? `1. Run \`${headedLoginCommand}\` to open a visible dedicated Chrome window for login or verification.`
2797
+ : `1. Run \`${loginCommand}\` without \`--dry-run\` to open the dedicated Chrome window.`);
2591
2798
  stdout(`2. Log in manually at ${input.loginUrl} in that Chrome window.`);
2592
2799
  stdout("3. If ChatGPT asks for captcha, Cloudflare/human verification, permission, or account verification, complete it in the browser.");
2593
2800
  stdout("4. For usage limit, message limit, model limit, or rate limit, wait for the reset or choose an available model in the browser.");