@youdie006/prodex 0.40.13 → 0.40.15

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/README.md CHANGED
@@ -73,10 +73,16 @@ For a one-time visible login followed by a verified headless handoff, use `prode
73
73
 
74
74
  Finish any native browser or OS confirmation before requesting the handoff: Chrome does not expose every native dialog through its page-control interface. The guard detects page targets and rendered dialogs, not every OS prompt. Incognito, guest, and explicitly selected Chrome sub-profiles are refused.
75
75
 
76
+ A Chrome account-connection chooser is separate from ChatGPT sign-in. If prodex confirms that chooser is displayed, choose for yourself whether to connect Chrome to the account; "Use Chrome without an account" declines that separate connection. A hidden or unreadable internal target still blocks the handoff but is not proof that a prompt is visible or that ChatGPT is logged out.
77
+
78
+ "Login readiness is not yet confirmed" does not mean the saved login was lost. Inspect the dedicated browser before signing in again. An explicit `login_required`, `cloudflare_check`, `captcha_required`, or `permission_required` during a headless/virtual-display wait stops promptly. For a running headless browser, use `prodex pro browser login --headed --recover-visible` to open that same profile and page visibly. This explicit recovery refuses other tabs, active work, filled inputs, and uncertain browser identity; it never clicks a security check or sends a prompt. A usage limit or missing composer has its own next step instead of a blanket login instruction.
79
+
76
80
  While Pro thinks, progress goes to stderr: connecting, prompt sent, elapsed time while generating. A Pro selection raises the send budget to twenty minutes on its own; `--timeout-ms` overrides it. Answers are read from the rendered page, so formatting can differ from the original message. If the dedicated browser is not running, an interactive `ask` starts it, waits for your saved session, and retries once (`--no-auto-login` turns that off; scripts opt in with `--auto-login`).
77
81
 
78
82
  If the browser stops responding after your question was sent, prodex stops without sending it again. Use the `thread` and `request_id` from the error with `prodex pro browser recover --target-url <thread-url> --request-id <32hex>` (MCP: `pro_recover`). The request ID verifies that the recovered assistant answer follows that exact marked user turn. Legacy recovery without it remains available but returns `request_verified: false` and a warning.
79
83
 
84
+ If submission itself is unconfirmed, do not resend or simply increase the timeout: first inspect the original conversation for the `[prodex-request:...]` marker in the error. Missing login signals or text still in the composer do not prove that the question was never submitted.
85
+
80
86
  Useful flags on every send:
81
87
 
82
88
  | Flag | What it does |
@@ -185,7 +191,7 @@ The last recorded window mode is reused by later `login` commands and by CLI/MCP
185
191
 
186
192
  `--headless` may be blocked even with a saved login: a previous test on a signed-in profile stayed on Cloudflare's interstitial past sixty seconds. `--background` verifies the actual handoff and only reports READY when the signed-in composer works headless. A challenge does not prove that the saved login was lost. Only the window is optional; authentication and protection checks still apply.
187
193
 
188
- If a hidden or virtual browser needs login, captcha, Cloudflare, or account verification, close that browser yourself and run `prodex pro browser login --headed` to complete the interactive step. Merely omitting `--headless` does not switch modes because the saved mode persists. prodex does not bypass protection or force-kill a browser for a mode change; `--background` permits only the guarded graceful handoff described above.
194
+ If a running headless browser needs login, captcha, Cloudflare, or account verification, run `prodex pro browser login --headed --recover-visible`. This is an explicit, guarded switch for a known authentication/protection blocker, not an automatic fallback or a general browser reset. It preserves the profile and page, waits for a clean shutdown, and opens a visible browser for any required manual step. That window is temporary: after it closes, the next launch remains headless. Another challenge stops instead of opening a visible window automatically; an ordinary explicit `login --headed` selects headed mode permanently. A virtual-display browser must still be closed manually before `login --headed`; it is not covered by this headless-only recovery. Merely omitting `--headless` does not switch modes because the saved preference persists. prodex never bypasses protection or force-kills a browser for a mode change.
189
195
 
190
196
  Before a prompt is submitted, a browser confirmed to have stopped answering its control port can be ended and started fresh, and the receipt says so (`PRODEX_NO_AUTO_CLEAR=1` turns that off). A browser that is merely slow is left alone. After submission, prodex never auto-resends a lost prompt.
191
197
 
@@ -3,10 +3,11 @@ import { realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import WebSocket from "ws";
5
5
  import { ChatGptBrowserBlockerError, attachmentPresenceExpression, composerTextStateExpression, detectChatGptPageBlocker, findLaunchedBrowserProcesses, inferChatGptPageLoggedInLikely, statusExpression } from "./chatgpt-browser.js";
6
+ const VISIBLE_AUTH_BLOCKERS = new Set(["login_required", "cloudflare_check", "captcha_required", "permission_required"]);
6
7
  function blocked(message) {
7
8
  throw new ChatGptBrowserBlockerError({
8
9
  code: "browser_handoff_blocked", message, retryable: false,
9
- next_step: "Keep only the intended idle ChatGPT tab open, finish any Chrome account or permission confirmation yourself, and retry `prodex pro browser login --background`. Do not close active work or log in again solely because the handoff stopped."
10
+ next_step: "Inspect the dedicated browser and keep only the intended idle ChatGPT tab open, then retry `prodex pro browser login --background`. Complete a login or permission step only if it is actually shown. Do not close active work or log in again solely because the handoff stopped."
10
11
  });
11
12
  }
12
13
  function browserIdentity(port, profileDir) {
@@ -62,8 +63,32 @@ async function singlePage(port) {
62
63
  if (!Array.isArray(response))
63
64
  blocked("The browser page list is invalid.");
64
65
  const pages = response.filter((page) => page?.type === "page");
65
- if (pages.length !== 1)
66
- blocked("The dedicated browser has other tabs or a Chrome confirmation surface; nothing was closed.");
66
+ if (pages.length !== 1) {
67
+ const chooser = pages.find((page) => page.url === "chrome://signin-dice-web-intercept.top-chrome/chrome-signin");
68
+ if (chooser) {
69
+ let state;
70
+ try {
71
+ const socket = localSocket(chooser.webSocketDebuggerUrl, port, "page");
72
+ const reply = await request(socket, "Runtime.evaluate", {
73
+ expression: `(() => { const r = document.body?.getBoundingClientRect(); return { visibilityState: document.visibilityState, width: r?.width, height: r?.height, isChooser: !!document.querySelector('chrome-signin-app') }; })()`,
74
+ returnByValue: true
75
+ });
76
+ if (!reply.exceptionDetails)
77
+ state = reply.result?.value;
78
+ }
79
+ catch { /* An unreadable internal target is not proof of a visible prompt. */ }
80
+ if (state?.visibilityState === "visible" && state.isChooser === true &&
81
+ typeof state.width === "number" && state.width > 0 && typeof state.height === "number" && state.height > 0) {
82
+ throw new ChatGptBrowserBlockerError({
83
+ code: "browser_account_confirmation", retryable: false,
84
+ message: "Chrome is showing its browser-account connection chooser. This is separate from ChatGPT login; no browser was closed.",
85
+ next_step: "Choose in the dedicated Chrome window yourself. 'Use Chrome without an account' declines Chrome account connection if you do not want it. Keep the ChatGPT tab open, then retry `prodex pro browser login --background`."
86
+ });
87
+ }
88
+ blocked("Chrome has an internal account target, but its chooser visibility was not confirmed. This does not establish a ChatGPT login problem; nothing was closed.");
89
+ }
90
+ blocked("The dedicated browser has additional page targets; nothing was closed.");
91
+ }
67
92
  const page = pages[0];
68
93
  const url = new URL(page.url);
69
94
  if (url.protocol !== "https:" || url.hostname !== "chatgpt.com" || url.username || url.password || url.port ||
@@ -143,6 +168,37 @@ async function verifyIdle(page) {
143
168
  blocked("The page has active work, unfinished input, attachments, or a dialog; no browser was closed.");
144
169
  }
145
170
  }
171
+ async function verifyVisibleAuthRecovery(page) {
172
+ const expression = `(() => {
173
+ const status = ${statusExpression()};
174
+ const visible = (e) => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden' && getComputedStyle(e).display !== 'none';
175
+ const textControls = [...document.querySelectorAll('textarea,[contenteditable="true"],[role="textbox"],input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=file]):not([type=button]):not([type=submit]):not([type=reset]):not([type=image]):not([type=range]):not([type=color])')];
176
+ const filledTextControl = textControls.some((e) => {
177
+ if (!visible(e)) return false;
178
+ const text = e instanceof HTMLInputElement || e instanceof HTMLTextAreaElement ? e.value : e.innerText || e.textContent || '';
179
+ return text.trim().length > 0;
180
+ });
181
+ const attachmentState = ${attachmentPresenceExpression()};
182
+ return { status, filledTextControl,
183
+ dialog: [...document.querySelectorAll('dialog[open],[role="dialog"],[aria-modal="true"]')].some(visible),
184
+ attachments: attachmentState.removed > 0 || [...document.querySelectorAll('input[type="file"]')].some(e => e.files?.length > 0) || [...document.querySelectorAll('[role="progressbar"]')].some(visible),
185
+ unpersisted: !location.pathname.includes('/c/') && !!document.querySelector('[data-message-author-role]')
186
+ };
187
+ })()`;
188
+ const reply = await request(page.webSocketDebuggerUrl, "Runtime.evaluate", { expression, returnByValue: true });
189
+ const value = reply?.result?.value;
190
+ if (reply?.exceptionDetails || !value?.status || [value.filledTextControl, value.dialog, value.attachments, value.unpersisted].some((v) => typeof v !== "boolean")) {
191
+ blocked("Could not verify the blocked page's input and dialog state.");
192
+ }
193
+ const state = value.status;
194
+ const blocker = detectChatGptPageBlocker(state);
195
+ if (state.url !== page.url || !blocker || !VISIBLE_AUTH_BLOCKERS.has(blocker.code)) {
196
+ blocked("Visible recovery requires an explicit login, Cloudflare, captcha, or permission blocker; no browser was closed.");
197
+ }
198
+ if (state.generating || state.awaitingResponseChoice || state.openDialogText || value.filledTextControl || value.dialog || value.attachments || value.unpersisted) {
199
+ blocked("The page has active work, filled input, attachments, or a dialog; no browser was closed.");
200
+ }
201
+ }
146
202
  function alive(pid) {
147
203
  try {
148
204
  process.kill(pid, 0);
@@ -152,6 +208,39 @@ function alive(pid) {
152
208
  return error.code !== "ESRCH";
153
209
  }
154
210
  }
211
+ function matchingBrowserPids(port, profileDir) {
212
+ const listed = spawnSync("ps", ["-Ao", "user,pid,command"], { encoding: "utf8", timeout: 5_000 });
213
+ if (listed.status !== 0 || typeof listed.stdout !== "string")
214
+ blocked("Could not verify that the dedicated browser stayed closed.");
215
+ return findLaunchedBrowserProcesses(listed.stdout, { port, profileDir });
216
+ }
217
+ async function waitForQuietShutdown(identity, port, profileDir) {
218
+ const deadline = Date.now() + 12_000;
219
+ let quietSince;
220
+ while (Date.now() < deadline) {
221
+ if (!identity.pids.some(alive)) {
222
+ const replacement = matchingBrowserPids(port, profileDir).filter((pid) => !identity.pids.includes(pid));
223
+ if (replacement.length > 0)
224
+ blocked("A new matching browser process appeared during shutdown; no replacement was launched by prodex.");
225
+ try {
226
+ await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(2_000) });
227
+ blocked("The old browser exited, but its control port is still active; no replacement was launched.");
228
+ }
229
+ catch (error) {
230
+ if (error instanceof ChatGptBrowserBlockerError)
231
+ throw error;
232
+ const cause = error.cause;
233
+ if (cause?.code !== "ECONNREFUSED")
234
+ blocked("The old browser exited, but its control port could not be verified closed.");
235
+ }
236
+ quietSince ??= Date.now();
237
+ if (Date.now() - quietSince >= 2_000)
238
+ return;
239
+ }
240
+ await new Promise((resolve) => setTimeout(resolve, 250));
241
+ }
242
+ blocked("The dedicated browser did not remain fully closed. It was not force-killed and no replacement was launched.");
243
+ }
155
244
  /** Caller holds the shared browser send lock through this close AND relaunch. */
156
245
  export async function closeIdleChatGptBrowserForHandoff(options) {
157
246
  const { port, profileDir } = options;
@@ -170,20 +259,29 @@ export async function closeIdleChatGptBrowserForHandoff(options) {
170
259
  }
171
260
  await verifyIdle(current);
172
261
  await request(socket, "Browser.close");
173
- const deadline = Date.now() + 10_000;
174
- while (Date.now() < deadline) {
175
- if (!identity.pids.some(alive)) {
176
- try {
177
- await readJson(port, "version");
178
- }
179
- catch (error) {
180
- const cause = error.cause;
181
- if (cause?.code === "ECONNREFUSED")
182
- return { url: page.url };
183
- blocked("The old browser exited, but its control port could not be verified closed.");
184
- }
185
- }
186
- await new Promise((resolve) => setTimeout(resolve, 250));
262
+ await waitForQuietShutdown(identity, port, profileDir);
263
+ return { url: page.url };
264
+ }
265
+ /** Caller holds the shared browser send lock through this close AND headed relaunch. */
266
+ export async function closeBlockedHeadlessBrowserForVisibleAuth(options) {
267
+ const { port, profileDir } = options;
268
+ if (!Number.isInteger(port) || port < 1 || port > 65535 || !path.isAbsolute(profileDir))
269
+ blocked("Invalid browser handoff identity.");
270
+ const identity = browserIdentity(port, profileDir);
271
+ if (!identity.headless)
272
+ blocked("Visible recovery requires an actually headless dedicated browser; no browser was closed.");
273
+ const page = await singlePage(port);
274
+ const version = await readJson(port, "version");
275
+ const socket = localSocket(version?.webSocketDebuggerUrl, port, "browser");
276
+ await verifyVisibleAuthRecovery(page);
277
+ const current = await singlePage(port);
278
+ const currentVersion = await readJson(port, "version");
279
+ if (current.id !== page.id || current.url !== page.url || current.webSocketDebuggerUrl !== page.webSocketDebuggerUrl ||
280
+ currentVersion.webSocketDebuggerUrl !== socket || browserIdentity(port, profileDir).main !== identity.main) {
281
+ blocked("The browser or page changed during visible recovery verification.");
187
282
  }
188
- blocked("The dedicated browser did not finish closing. It was not force-killed and no replacement was launched.");
283
+ await verifyVisibleAuthRecovery(current);
284
+ await request(socket, "Browser.close");
285
+ await waitForQuietShutdown(identity, port, profileDir);
286
+ return { url: page.url };
189
287
  }
@@ -6,7 +6,7 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import WsWebSocket from "ws";
8
8
  import { captureBrowserDiagnostics, diagnosticsEnabled, diagnosticsNote } from "./browser-diagnostics.js";
9
- import { withCrossProcessFileLock } from "./safe-file.js";
9
+ import { withCrossProcessFileLock, writeVerifiedUtf8File } from "./safe-file.js";
10
10
  import os from "node:os";
11
11
  import { answeredDialogWarning, chatSurfaceState, effortNeedsWorkSurface, javascriptDialogResponse, menuKeyboardStep, readPowerSliderSelection, sliderRestoreStep, surfaceFromProbe, sliderPressOutcome, sliderDidNotRespond } from "./picker-interaction.js";
12
12
  import { projectsWithIdsExpression, recentConversationTitlesExpression } from "./tui.js";
@@ -209,7 +209,7 @@ export async function recordBrowserLoginLaunch(record) {
209
209
  try {
210
210
  const file = lastBrowserLoginPath();
211
211
  await mkdir(path.dirname(file), { recursive: true });
212
- await writeFile(file, `${JSON.stringify(record, null, 2)}\n`, "utf8");
212
+ await writeVerifiedUtf8File(file, `${JSON.stringify(record, null, 2)}\n`, async () => { }, { mode: 0o600 });
213
213
  }
214
214
  catch {
215
215
  // Advisory record only.
@@ -223,6 +223,7 @@ export async function readLastBrowserLoginLaunch() {
223
223
  ...(typeof parsed.port === "number" && Number.isInteger(parsed.port) ? { port: parsed.port } : {}),
224
224
  ...(typeof parsed.headless === "boolean" ? { headless: parsed.headless } : {}),
225
225
  ...(typeof parsed.minimized === "boolean" ? { minimized: parsed.minimized } : {}),
226
+ ...(typeof parsed.resume_headless === "boolean" ? { resume_headless: parsed.resume_headless } : {}),
226
227
  ...(typeof parsed.virtual_display === "number" && Number.isInteger(parsed.virtual_display)
227
228
  ? { virtual_display: parsed.virtual_display }
228
229
  : {})
@@ -528,6 +529,9 @@ export function resolveBrowserWindowMode(args) {
528
529
  return selected;
529
530
  }
530
531
  const saved = args.lastLogin;
532
+ if (args.forRelaunch && saved?.resume_headless === true) {
533
+ return { headless: true, virtualDisplay: false, minimized: false };
534
+ }
531
535
  const selected = {
532
536
  headless: saved?.headless === true,
533
537
  virtualDisplay: saved?.virtual_display !== undefined,
@@ -702,6 +706,7 @@ export function formatDurationMs(ms) {
702
706
  export function acceptanceTimeoutError(ctx) {
703
707
  const uiLikelyChanged = ctx.composerStillHasText || !ctx.submitButtonFound;
704
708
  const took = `${formatDurationMs(ctx.timeoutMs)} (${ctx.timeoutMs}ms)`;
709
+ const inspect = `Do not resend automatically: submission was attempted but acceptance is unconfirmed. Inspect the original conversation${ctx.requestId ? ` for [prodex-request:${ctx.requestId}]` : ""} before another request.`;
705
710
  if (uiLikelyChanged) {
706
711
  const detail = [
707
712
  ctx.composerStillHasText ? "the composer still holds the prompt" : undefined,
@@ -709,13 +714,16 @@ export function acceptanceTimeoutError(ctx) {
709
714
  ]
710
715
  .filter(Boolean)
711
716
  .join(" and ");
712
- return new Error(`Timed out after ${took} and ChatGPT never registered the prompt (${detail}). ` +
713
- "The ChatGPT web UI may have changed, so prodex could not submit. Update prodex " +
714
- "(npm i -g @youdie006/prodex@latest); if it persists, report it at " +
715
- "https://github.com/youdie006/prodex/issues. You can also paste the prompt manually in the visible browser.");
717
+ return new ChatGptBrowserBlockerError({
718
+ code: "send_ui_changed", retryable: false,
719
+ message: `Timed out after ${took} without confirming prompt acceptance (${detail}). The ChatGPT web UI may have changed.`,
720
+ next_step: `${inspect} Update prodex (npm i -g @youdie006/prodex@latest); if it persists, report it at https://github.com/youdie006/prodex/issues.`
721
+ });
716
722
  }
717
- return new Error(`Timed out after ${took} waiting for ChatGPT to accept the prompt. ` +
718
- "Pro reasoning can run many minutes. Raise --timeout-ms and retry.");
723
+ return new ChatGptBrowserBlockerError({
724
+ code: "prompt_acceptance_unconfirmed", retryable: false,
725
+ message: `Timed out after ${took} waiting for ChatGPT to accept the prompt.`, next_step: inspect
726
+ });
719
727
  }
720
728
  export function hasPartialChatGptAnswer(previousAssistantMessageCount, state) {
721
729
  return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer);
@@ -948,7 +956,7 @@ export function detectChatGptBlocker(text, visibleButtonLabels = []) {
948
956
  code: "cloudflare_check",
949
957
  message: "ChatGPT is showing a Cloudflare or human-verification interstitial.",
950
958
  retryable: true,
951
- next_step: "Complete the visible browser check manually, then retry."
959
+ next_step: "Complete the visible browser check manually."
952
960
  };
953
961
  }
954
962
  if (hasLikelyChatGptLoginPrompt(haystack)) {
@@ -956,7 +964,7 @@ export function detectChatGptBlocker(text, visibleButtonLabels = []) {
956
964
  code: "login_required",
957
965
  message: "ChatGPT is asking you to log in.",
958
966
  retryable: true,
959
- next_step: "Log in manually in the visible browser, then retry."
967
+ next_step: "Log in manually in the visible browser."
960
968
  };
961
969
  }
962
970
  // Match real captcha / human-verification phrasing only. Bare words like "robot"/"로봇"/"자동화"
@@ -967,7 +975,7 @@ export function detectChatGptBlocker(text, visibleButtonLabels = []) {
967
975
  code: "captcha_required",
968
976
  message: "ChatGPT is asking for captcha or human verification.",
969
977
  retryable: true,
970
- next_step: "Solve it manually in the visible browser, then retry."
978
+ next_step: "Solve it manually in the visible browser."
971
979
  };
972
980
  }
973
981
  if (/message limit|usage limit|model limit|rate limit|you.?ve reached|try again later|limit resets|사용 한도|메시지 한도|모델 한도|요금 제한|나중에 다시/i.test(haystack)) {
@@ -983,7 +991,7 @@ export function detectChatGptBlocker(text, visibleButtonLabels = []) {
983
991
  code: "permission_required",
984
992
  message: "ChatGPT requires account verification or permission handling.",
985
993
  retryable: true,
986
- next_step: "Complete the visible account or permission prompt manually, then retry."
994
+ next_step: "Complete the visible account or permission prompt manually."
987
995
  };
988
996
  }
989
997
  return undefined;
@@ -992,7 +1000,15 @@ export function detectChatGptPageBlocker(state) {
992
1000
  // Blocker scan uses the nav-excluded sample so a sidebar chat title cannot
993
1001
  // fake a blocker; fall back to the nav-included sample / full text when the
994
1002
  // scan sample is absent (older callers).
995
- return detectChatGptBlocker(state.blockerScanTextSample ?? state.blockerTextSample ?? state.textSample, state.visibleButtonLabels);
1003
+ const rendered = detectChatGptBlocker(state.blockerScanTextSample ?? state.blockerTextSample ?? state.textSample, state.visibleButtonLabels);
1004
+ if (rendered)
1005
+ return rendered;
1006
+ // The interstitial can have an empty body. Never use a conversation title
1007
+ // alone when the composer exists or its state was not actually checked.
1008
+ if (state.hasComposer === false && /^(?:just a moment|잠시만 기다리십시오)(?:\.{0,3}|…)$/i.test(state.title?.trim() ?? "")) {
1009
+ return detectChatGptBlocker("Just a moment", []);
1010
+ }
1011
+ return undefined;
996
1012
  }
997
1013
  export function inferChatGptPageLoggedInLikely(state) {
998
1014
  // The logged-in signals live in the sidebar - "New chat", "Projects", the
@@ -1026,6 +1042,17 @@ export function chatGptBlockerErrorFromAnswerState(state) {
1026
1042
  export function chatGptBlockerFromAnswerState(state) {
1027
1043
  return detectChatGptPageBlocker(state);
1028
1044
  }
1045
+ function submittedRequestBlocker(blocker, requestId, thread) {
1046
+ return {
1047
+ ...blocker,
1048
+ retryable: false,
1049
+ ...(thread ? { thread } : {}),
1050
+ next_step: `${blocker.next_step ?? "Inspect the visible browser."} Do not automatically resend. ` +
1051
+ (thread
1052
+ ? `Collect the original answer with \`prodex pro browser recover --target-url ${thread} --request-id ${requestId}\` after resolving the blocker.`
1053
+ : `Submission is unconfirmed; inspect the original conversation for [prodex-request:${requestId}] before another request.`)
1054
+ };
1055
+ }
1029
1056
  export function computePromptAcceptanceDeadline(timeoutMs, startedAt) {
1030
1057
  return startedAt + Math.max(1, timeoutMs);
1031
1058
  }
@@ -3508,6 +3535,7 @@ export async function sendChatGptPrompt(options) {
3508
3535
  let beforeSubmit;
3509
3536
  let boundProjectId;
3510
3537
  let submitButtonFound = false;
3538
+ let submissionAttempted = false;
3511
3539
  const sendWarnings = [...preflightWarnings];
3512
3540
  // Anything the page put in front of prodex was answered on the caller's
3513
3541
  // behalf. The note is read at return time - there are several return paths -
@@ -3654,6 +3682,8 @@ export async function sendChatGptPrompt(options) {
3654
3682
  const last = [...document.querySelectorAll('[data-message-author-role="user"]')].at(-1);
3655
3683
  return Boolean(last && (last.innerText || "").includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
3656
3684
  })()`;
3685
+ // A dispatch can reach Chrome even if its acknowledgement is lost.
3686
+ submissionAttempted = true;
3657
3687
  await cdp.send("Input.dispatchKeyEvent", enterKeyEvent("keyDown"));
3658
3688
  await cdp.send("Input.dispatchKeyEvent", enterKeyEvent("keyUp"));
3659
3689
  let promptPosted = await waitForExpressionTrue(cdp, promptPostedExpression, 1_500);
@@ -3677,6 +3707,13 @@ export async function sendChatGptPrompt(options) {
3677
3707
  await captureOnFailure(`send-${new Date().toISOString().replace(/[:.]/g, "-")}`);
3678
3708
  if (isCrashedTabError(error))
3679
3709
  throw attachSendWarnings(new ChatGptBrowserBlockerError(crashedTabBlocker(undefined, requestId)), sendWarnings);
3710
+ if (submissionAttempted) {
3711
+ const blocker = error instanceof ChatGptBrowserBlockerError ? error.blocker : {
3712
+ code: "prompt_acceptance_unconfirmed", retryable: false,
3713
+ message: `Browser control failed after submission was attempted: ${error instanceof Error ? error.message : String(error)}`
3714
+ };
3715
+ throw attachSendWarnings(new ChatGptBrowserBlockerError(submittedRequestBlocker(blocker, requestId)), sendWarnings);
3716
+ }
3680
3717
  throw attachSendWarnings(error, sendWarnings);
3681
3718
  }
3682
3719
  finally {
@@ -3701,7 +3738,7 @@ export async function sendChatGptPrompt(options) {
3701
3738
  }
3702
3739
  const runtimeBlocker = chatGptBlockerFromAnswerState(finalState);
3703
3740
  if (runtimeBlocker)
3704
- throw new ChatGptBrowserBlockerError(runtimeBlocker);
3741
+ throw new ChatGptBrowserBlockerError(submittedRequestBlocker(runtimeBlocker, requestId));
3705
3742
  dbgSend(`accept-poll url=${finalState.url} user=${finalState.userMessageCount} assistant=${finalState.assistantMessageCount} generating=${finalState.generating}`);
3706
3743
  if (requestMatches(finalState)) {
3707
3744
  if (normalizedTargetUrl)
@@ -3714,19 +3751,13 @@ export async function sendChatGptPrompt(options) {
3714
3751
  emitProgress("waiting", "prompt posting");
3715
3752
  }
3716
3753
  if (!accepted) {
3717
- // The session can expire mid-send (logged out during a long Pro wait), which
3718
- // otherwise surfaces as a cryptic "raise --timeout-ms" failure. Re-check the
3719
- // login state first and, if the session is gone, say so clearly so the user
3720
- // re-logs in instead of chasing a timeout.
3754
+ // Missing logged-in signals do not prove session expiry. Only rendered
3755
+ // blockers justify an authentication diagnosis after uncertain submission.
3721
3756
  try {
3722
3757
  const status = await evaluateOnPage(page, statusExpression());
3723
- if (!inferChatGptPageLoggedInLikely(status)) {
3724
- throw new ChatGptBrowserBlockerError({
3725
- code: "session_expired",
3726
- message: "The ChatGPT session is no longer logged in - it likely expired during the send.",
3727
- retryable: true,
3728
- next_step: "Run `prodex pro browser login`, log in, then retry."
3729
- });
3758
+ const blocker = detectChatGptPageBlocker(status);
3759
+ if (blocker) {
3760
+ throw new ChatGptBrowserBlockerError(submittedRequestBlocker(blocker, requestId));
3730
3761
  }
3731
3762
  }
3732
3763
  catch (error) {
@@ -3734,8 +3765,7 @@ export async function sendChatGptPrompt(options) {
3734
3765
  throw error;
3735
3766
  // best effort: a CDP eval failure here falls through to the generic timeout
3736
3767
  }
3737
- // A successful submit clears the composer, so text still sitting there means
3738
- // the send control did not register the prompt — the UI-changed signature.
3768
+ // Composer text is only a UI hint, not proof that submission failed.
3739
3769
  let composerStillHasText = false;
3740
3770
  try {
3741
3771
  const composerState = await evaluateOnPage(page, composerTextStateExpression());
@@ -3744,7 +3774,7 @@ export async function sendChatGptPrompt(options) {
3744
3774
  catch {
3745
3775
  // best effort: fall back to submit-button signal only
3746
3776
  }
3747
- throw acceptanceTimeoutError({ timeoutMs, composerStillHasText, submitButtonFound });
3777
+ throw acceptanceTimeoutError({ timeoutMs, composerStillHasText, submitButtonFound, requestId });
3748
3778
  }
3749
3779
  // Pin the conversation the prompt actually landed in. The browser is shared
3750
3780
  // (other agents, the user, tooling), and a tab that moves mid-wait made
@@ -3807,7 +3837,7 @@ export async function sendChatGptPrompt(options) {
3807
3837
  }
3808
3838
  const runtimeBlocker = chatGptBlockerFromAnswerState(finalState);
3809
3839
  if (runtimeBlocker)
3810
- throw new ChatGptBrowserBlockerError(runtimeBlocker);
3840
+ throw new ChatGptBrowserBlockerError(submittedRequestBlocker(runtimeBlocker, requestId, pinnedThreadUrl));
3811
3841
  emitProgress("waiting", finalState.generating ? "generating" : "stabilizing");
3812
3842
  if (!hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, finalState))
3813
3843
  continue;
@@ -4448,14 +4478,18 @@ export function fetchTimedOut(error) {
4448
4478
  * and ending a busy browser takes the consult it is writing with it.
4449
4479
  */
4450
4480
  export function statusMeansBrowserDead(status) {
4451
- return !status.reachable && status.blocker?.code !== "browser_slow";
4481
+ return !status.reachable && status.blocker?.code === "browser_unreachable";
4452
4482
  }
4453
4483
  async function findChatGptPage(port, timeoutMs, targetUrl) {
4484
+ let receivedResponse = false;
4454
4485
  try {
4455
4486
  const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
4487
+ receivedResponse = true;
4456
4488
  if (!response.ok)
4457
4489
  throw new Error(`HTTP ${response.status}`);
4458
4490
  const pages = (await response.json());
4491
+ if (!Array.isArray(pages))
4492
+ throw new Error("Invalid Chrome DevTools page list");
4459
4493
  const visibilityByPage = await getChatGptPageVisibility(pages);
4460
4494
  const blocker = chatGptPageSelectionBlocker(pages, targetUrl, visibilityByPage);
4461
4495
  if (blocker)
@@ -4463,17 +4497,11 @@ async function findChatGptPage(port, timeoutMs, targetUrl) {
4463
4497
  return { ok: true, page: selectChatGptPage(pages, targetUrl, visibilityByPage) };
4464
4498
  }
4465
4499
  catch (error) {
4466
- // A port that answers too slowly is a browser that is BUSY, not one that is
4467
- // gone, and the two must not share a verdict: the silence check that
4468
- // decides whether to end a browser counted three slow answers on a loaded
4469
- // machine as three dead ones - the exact failure the comment above
4470
- // confirmBrowserSilence was written to prevent.
4471
- // A timeout on its own is not proof of life: with a small budget the
4472
- // timer fires before the connection reports anything, and a port nothing
4473
- // listens on would be called "busy". A raw TCP connect settles it, and
4474
- // only an ACCEPT counts - measured on WSL2, a closed loopback port does not
4475
- // refuse at all, it times out (1.5s), while a listening one accepts in 1ms.
4476
- if (fetchTimedOut(error) && (await portAccepts(port)) === "accepted") {
4500
+ // An HTTP error or malformed reply proves the endpoint answered. Only a
4501
+ // refused TCP connection permits stopped-browser recovery; an uncertain
4502
+ // timeout must never authorize terminating an existing process.
4503
+ const connection = receivedResponse ? "accepted" : await portAccepts(port);
4504
+ if (fetchTimedOut(error) && connection === "accepted") {
4477
4505
  return {
4478
4506
  ok: false,
4479
4507
  blocker: {
@@ -4485,6 +4513,18 @@ async function findChatGptPage(port, timeoutMs, targetUrl) {
4485
4513
  }
4486
4514
  };
4487
4515
  }
4516
+ if (connection !== "refused") {
4517
+ return {
4518
+ ok: false,
4519
+ blocker: {
4520
+ code: "browser_control_unavailable",
4521
+ message: `The Chrome DevTools endpoint on 127.0.0.1:${port} could not be inspected safely; this does not establish that Chrome stopped.`,
4522
+ retryable: false,
4523
+ next_step: "Leave the existing browser open and check its control connection. No automatic restart or prompt retry is allowed for this uncertain state.",
4524
+ ...(error instanceof Error ? { detail: error.message } : {})
4525
+ }
4526
+ };
4527
+ }
4488
4528
  return {
4489
4529
  ok: false,
4490
4530
  blocker: {
package/dist/cli-help.js CHANGED
@@ -19,7 +19,7 @@ Ask / consult commands:
19
19
  prodex ask [same flags as pro browser ask] "prompt" # top-level shortcut for pro browser ask
20
20
  prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] [--attach path] [--tool deep-research|web-search|create-image] "prompt" # dry-run preview
21
21
  prodex pro debate-prompt [--topic "..."] [--rounds 2] [--source-cli /absolute/path/to/dist/cli.js] # print an agent prompt for a structured GPT Pro debate
22
- prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background] # preview/open browser login
22
+ prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background] [--recover-visible] # preview/open browser login
23
23
  prodex pro browser help [--source-cli /absolute/path/to/dist/cli.js]
24
24
  prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 1500]
25
25
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]
@@ -166,7 +166,7 @@ Commands:
166
166
  prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] [--attach path] [--tool deep-research|web-search|create-image] "prompt"
167
167
  prodex pro debate-prompt [--topic "..."] [--rounds 2] [--source-cli /absolute/path/to/dist/cli.js]
168
168
  prodex pro browser help [--source-cli /absolute/path/to/dist/cli.js]
169
- prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background]
169
+ prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background] [--recover-visible]
170
170
  prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
171
171
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
172
172
  prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js]
@@ -251,8 +251,8 @@ export function printProBrowserHelp(stdout, sourceCli) {
251
251
  const cli = formatCliCommand(sourceCli);
252
252
  const sourceCliOption = formatSourceCliOption(sourceCli);
253
253
  const loginUsage = sourceCli
254
- ? `${cli} pro browser login${sourceCliOption} [--cwd /absolute/path/to/repo] [--dry-run] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background]`
255
- : "prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background]";
254
+ ? `${cli} pro browser login${sourceCliOption} [--cwd /absolute/path/to/repo] [--dry-run] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background] [--recover-visible]`
255
+ : "prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background] [--recover-visible]";
256
256
  const checkUsage = sourceCli
257
257
  ? `${cli} pro browser check${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 1500]`
258
258
  : "prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 1500]";
package/dist/cli-pro.js CHANGED
@@ -206,12 +206,19 @@ export async function runProCommand(rest, io, runCliFn) {
206
206
  if (browserSubcommand === "login") {
207
207
  if (printProBrowserHelpIfRequested(browserArgs, "pro browser login", io, {
208
208
  valueFlags: ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"],
209
- booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headed", "--headless", "--minimized", "--virtual-display", "--background"]
209
+ booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headed", "--headless", "--minimized", "--virtual-display", "--background", "--recover-visible"]
210
210
  })) {
211
211
  return 0;
212
212
  }
213
- 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", "--background"]);
213
+ 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", "--background", "--recover-visible"]);
214
214
  const background = browserArgs.includes("--background");
215
+ const recoverVisible = browserArgs.includes("--recover-visible");
216
+ if (recoverVisible && !browserArgs.includes("--headed")) {
217
+ throw new Error("--recover-visible requires explicit --headed mode.");
218
+ }
219
+ if (recoverVisible && browserArgs.some((arg) => ["--headless", "--minimized", "--virtual-display", "--background"].includes(arg))) {
220
+ throw new Error("--recover-visible cannot combine with --headless, --minimized, --virtual-display, or --background.");
221
+ }
215
222
  if (background && browserArgs.some((arg) => ["--headed", "--headless", "--minimized", "--virtual-display", "--no-wait"].includes(arg))) {
216
223
  throw new Error("--background cannot combine with another window mode or --no-wait: it waits for login, then verifies the headless handoff.");
217
224
  }
@@ -235,7 +242,7 @@ export async function runProCommand(rest, io, runCliFn) {
235
242
  ? resolveBrowserProfileDirForLaunch(savedLaunchForPort.profile_dir)
236
243
  : undefined;
237
244
  const profileDir = requestedProfileDir ?? savedProfileDir;
238
- let windowMode = resolveBrowserWindowMode({
245
+ const windowModeOptions = {
239
246
  flags: background ? (savedLaunchForPort?.headless === true ? { headless: true } : { headed: true }) : {
240
247
  ...(browserArgs.includes("--headed") ? { headed: true } : {}),
241
248
  ...(browserArgs.includes("--headless") ? { headless: true } : {}),
@@ -245,7 +252,8 @@ export async function runProCommand(rest, io, runCliFn) {
245
252
  // Window mode is a user preference across launches; only the saved
246
253
  // profile and display identity are scoped to this resolved port.
247
254
  ...(savedLaunch ? { lastLogin: savedLaunch } : {})
248
- });
255
+ };
256
+ let windowMode = resolveBrowserWindowMode({ ...windowModeOptions, forRelaunch: true });
249
257
  const commandOptions = {
250
258
  ...(targetCwd ? { cwd: targetCwd } : {}),
251
259
  ...(requestedProfileDir ? { profileDir: requestedProfileDir } : {}),
@@ -262,10 +270,13 @@ export async function runProCommand(rest, io, runCliFn) {
262
270
  profileDir: profileDir ?? defaultChatGptProfileDir(),
263
271
  port,
264
272
  sourceCli,
265
- commandOptions
273
+ commandOptions,
274
+ reusedProfile: savedProfileDir !== undefined
266
275
  });
267
276
  if (background)
268
277
  io.stdout("background: verify login, then hand the same profile to headless Chrome under the shared send lock. No prompt is sent.");
278
+ if (recoverVisible)
279
+ io.stdout(`recovery: preview only; no browser will be closed or opened. Run \`${formatVisibleAuthRecoveryCommand(sourceCli, { ...commandOptions, profileDir: profileDir ?? defaultChatGptProfileDir(), port })}\` to perform the guarded switch.`);
269
280
  return 0;
270
281
  }
271
282
  // If the dedicated Chrome is already reachable on this port, do NOT spawn
@@ -273,6 +284,8 @@ export async function runProCommand(rest, io, runCliFn) {
273
284
  // "extra windows" problem, which then blocks sends as
274
285
  // ambiguous_chatgpt_tabs). Reuse the running instance instead.
275
286
  const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
287
+ if (alreadyRunning)
288
+ windowMode = resolveBrowserWindowMode(windowModeOptions);
276
289
  if (background && alreadyRunning) {
277
290
  const { getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
278
291
  windowMode = { headless: getDedicatedBrowserHeadlessMode({ port, profileDir: profileDir ?? defaultChatGptProfileDir() }), virtualDisplay: false, minimized: false };
@@ -285,6 +298,16 @@ export async function runProCommand(rest, io, runCliFn) {
285
298
  requestedProfileDir !== savedProfileDir) {
286
299
  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.`);
287
300
  }
301
+ if (recoverVisible) {
302
+ return completeVisibleAuthRecovery(io, {
303
+ port,
304
+ profileDir: profileDir ?? defaultChatGptProfileDir(),
305
+ sourceCli,
306
+ commandOptions,
307
+ shouldWait: !browserArgs.includes("--no-wait") && (browserArgs.includes("--wait") || io.isInteractive === true),
308
+ timeoutMs: readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 300_000
309
+ });
310
+ }
288
311
  // One Chrome profile cannot serve a headed and a headless instance at
289
312
  // once, and reusing the running one would silently ignore the
290
313
  // requested mode. Say so instead of pretending the switch took.
@@ -303,6 +326,9 @@ export async function runProCommand(rest, io, runCliFn) {
303
326
  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.`);
304
327
  }
305
328
  }
329
+ if (recoverVisible) {
330
+ throw new Error(`--recover-visible requires a running headless ChatGPT browser on port ${port}; no browser was launched.`);
331
+ }
306
332
  // Allocate/rejoin a display only for a new browser. An already-running
307
333
  // virtual browser owns its saved display identity and needs no new X
308
334
  // server just because login was invoked again.
@@ -365,6 +391,10 @@ export async function runProCommand(rest, io, runCliFn) {
365
391
  port: opened.port,
366
392
  headless,
367
393
  minimized,
394
+ ...(alreadyRunning && savedLaunchForPort?.resume_headless === true &&
395
+ !Object.values(windowModeOptions.flags).some((value) => typeof value === "boolean") &&
396
+ !["PRODEX_HEADLESS", "PRODEX_VIRTUAL_DISPLAY", "PRODEX_MINIMIZE_WINDOW"].some((key) => (process.env[key] ?? "").trim() !== "")
397
+ ? { resume_headless: true } : {}),
368
398
  ...(virtualDisplay
369
399
  ? { virtual_display: virtualDisplay.displayNumber }
370
400
  : wantsVirtualDisplay && savedLaunchForPort?.virtual_display !== undefined
@@ -385,7 +415,8 @@ export async function runProCommand(rest, io, runCliFn) {
385
415
  profileDir: opened.profileDir,
386
416
  port: opened.port,
387
417
  sourceCli,
388
- commandOptions
418
+ commandOptions,
419
+ reusedProfile: savedProfileDir !== undefined
389
420
  });
390
421
  if (minimizeNote)
391
422
  io.stdout(minimizeNote);
@@ -397,21 +428,26 @@ export async function runProCommand(rest, io, runCliFn) {
397
428
  // Verify it here (bounded, no human to wait for) instead of letting
398
429
  // the first consult fail with a confusing not-logged-in blocker.
399
430
  const headlessWaitMs = readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 30_000;
431
+ const visibleRecoveryCommand = formatVisibleAuthRecoveryCommand(sourceCli, { ...commandOptions, profileDir: opened.profileDir, port: opened.port });
400
432
  const headlessReady = await waitForChatGptLoginReady(io.stderr, {
401
433
  port: opened.port,
402
434
  timeoutMs: headlessWaitMs,
403
435
  windowMode,
404
- headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
436
+ headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions),
437
+ headlessRecoveryCommand: visibleRecoveryCommand
405
438
  });
406
439
  if (!headlessReady) {
407
440
  // A challenge is not evidence of a lost login. Report the observed
408
441
  // blocker without promising that another login fixes it.
409
442
  const finalStatus = await getChatGptBrowserStatus({ port: opened.port, timeoutMs: 5_000 }).catch(() => undefined);
410
- const challenged = finalStatus?.blocker?.code === "cloudflare_check" || /just a moment/i.test(finalStatus?.title ?? "");
443
+ const challenged = finalStatus?.blocker?.code === "cloudflare_check";
444
+ const visibleStep = needsVisibleAuthRecovery(finalStatus?.blocker?.code)
445
+ ? `Run \`${visibleRecoveryCommand}\` for a visible interactive check.`
446
+ : `Follow the reported readiness or limit step. ${manualVisibleInspectionStep(formatHeadedBrowserLoginCommand(sourceCli, { ...commandOptions, profileDir: opened.profileDir, port: opened.port }))}`;
411
447
  io.stdout("");
412
448
  io.stdout(challenged
413
- ? `headless: Cloudflare challenged this headless browser and ChatGPT did not become ready. Run \`${formatHeadedBrowserLoginCommand(sourceCli, commandOptions)}\` for a visible interactive check. This does not prove that the saved login was lost.`
414
- : `headless: readiness was not confirmed (${finalStatus?.blocker?.code ?? "not_ready"}). This does not prove that the saved login was lost. Inspect the dedicated browser with \`${formatHeadedBrowserLoginCommand(sourceCli, commandOptions)}\`; complete an interactive step only if requested.`);
449
+ ? `headless: Cloudflare challenged this headless browser and ChatGPT did not become ready. Run \`${visibleRecoveryCommand}\` for a visible interactive check. This does not prove that the saved login was lost.`
450
+ : `headless: readiness was not confirmed (${finalStatus?.blocker?.code ?? "not_ready"}). This does not prove that the saved login was lost. ${visibleStep}`);
415
451
  return 1;
416
452
  }
417
453
  if (background) {
@@ -442,7 +478,8 @@ export async function runProCommand(rest, io, runCliFn) {
442
478
  port: opened.port,
443
479
  profileDir: opened.profileDir,
444
480
  timeoutMs: readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 30_000,
445
- headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
481
+ headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions),
482
+ headlessRecoveryCommand: formatVisibleAuthRecoveryCommand(sourceCli, { ...commandOptions, profileDir: opened.profileDir, port: opened.port })
446
483
  });
447
484
  }
448
485
  return ready ? 0 : 1;
@@ -2066,6 +2103,16 @@ async function attemptMissingChatGptTabRecovery(stderr, options) {
2066
2103
  });
2067
2104
  }
2068
2105
  export async function attemptBrowserAutoRecovery(stderr, options) {
2106
+ try {
2107
+ return await withBrowserSendLock(30_000, (detail) => stderr(`recover: ${detail}`), () => recoverBrowserUnderSendLock(stderr, options));
2108
+ }
2109
+ catch (error) {
2110
+ stderr(`recover: failed - ${errorMessage(error)}`);
2111
+ return false;
2112
+ }
2113
+ }
2114
+ async function recoverBrowserUnderSendLock(stderr, options) {
2115
+ const recoveryPort = resolveCdpPort(options.port);
2069
2116
  // Launching is right when the browser is gone and wrong when it is only deaf:
2070
2117
  // a second Chrome on the same profile joins the wedged one rather than
2071
2118
  // replacing it, and the wedged one keeps burning CPU while nobody looks. This
@@ -2075,18 +2122,33 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
2075
2122
  // scanning the DEFAULT profile for a custom-profile user put a second,
2076
2123
  // healthy browser's renderers on the list this function kills.
2077
2124
  const lastLogin = await readLastBrowserLoginLaunch().catch(() => undefined);
2078
- const recoveryPort = resolveCdpPort(options.port);
2079
2125
  const savedIdentityBelongsToAnotherPort = lastLogin?.port !== undefined &&
2080
- lastLogin.port !== recoveryPort &&
2081
- (lastLogin.profile_dir !== undefined || lastLogin.virtual_display !== undefined);
2126
+ lastLogin.port !== recoveryPort;
2082
2127
  if (savedIdentityBelongsToAnotherPort) {
2083
2128
  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.`);
2084
2129
  return false;
2085
2130
  }
2131
+ const current = await getChatGptBrowserStatus({ port: recoveryPort, timeoutMs: 2_000 });
2132
+ if (current.reachable && current.loggedInLikely && current.hasComposer && !current.blocker) {
2133
+ if (!lastLogin?.profile_dir) {
2134
+ stderr("recover: stopped - a READY browser has no saved profile identity; no automatic retry is allowed.");
2135
+ return false;
2136
+ }
2137
+ const { getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
2138
+ // The process check also verifies canonical profile ownership.
2139
+ getDedicatedBrowserHeadlessMode({ port: recoveryPort, profileDir: lastLogin.profile_dir });
2140
+ stderr("recover: browser is READY - reusing it without relaunching...");
2141
+ return true;
2142
+ }
2143
+ if (current.reachable || !statusMeansBrowserDead(current)) {
2144
+ const state = current.blocker?.code ?? "not_ready";
2145
+ stderr(`recover: stopped - browser is not confirmed stopped or ready (${state}); no browser was ended, launched, or changed.`);
2146
+ return false;
2147
+ }
2086
2148
  const lastLoginForPort = lastLogin?.port === undefined || lastLogin.port === recoveryPort ? lastLogin : undefined;
2087
2149
  let windowMode;
2088
2150
  try {
2089
- windowMode = resolveBrowserWindowMode({ ...(lastLogin ? { lastLogin } : {}) });
2151
+ windowMode = resolveBrowserWindowMode({ ...(lastLogin ? { lastLogin } : {}), forRelaunch: true });
2090
2152
  }
2091
2153
  catch (error) {
2092
2154
  stderr(`recover: failed - ${errorMessage(error)}`);
@@ -2177,8 +2239,9 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
2177
2239
  windowMode,
2178
2240
  headedLoginCommand: formatHeadedBrowserLoginCommand(undefined, {
2179
2241
  profileDir: opened.profileDir,
2180
- ...(opened.port !== DEFAULT_CDP_PORT ? { port: opened.port } : {})
2181
- })
2242
+ port: opened.port
2243
+ }),
2244
+ headlessRecoveryCommand: formatVisibleAuthRecoveryCommand(undefined, { profileDir: opened.profileDir, port: opened.port })
2182
2245
  });
2183
2246
  if (!ready)
2184
2247
  return false;
@@ -2191,6 +2254,46 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
2191
2254
  return false;
2192
2255
  }
2193
2256
  }
2257
+ async function completeVisibleAuthRecovery(io, options) {
2258
+ return withBrowserSendLock(5_000, (detail) => io.stderr(`recovery: ${detail}`), async () => {
2259
+ const { closeBlockedHeadlessBrowserForVisibleAuth, getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
2260
+ io.stderr("recovery: verifying the blocked headless browser before graceful visible handoff...");
2261
+ let url;
2262
+ try {
2263
+ ({ url } = await closeBlockedHeadlessBrowserForVisibleAuth({ port: options.port, profileDir: options.profileDir }));
2264
+ }
2265
+ catch (error) {
2266
+ if (error instanceof ChatGptBrowserBlockerError) {
2267
+ throw new ChatGptBrowserBlockerError({
2268
+ ...error.blocker,
2269
+ next_step: `Resolve only the reported visible state, then retry \`${formatVisibleAuthRecoveryCommand(options.sourceCli, { ...options.commandOptions, profileDir: options.profileDir, port: options.port })}\`. No browser was force-killed or replaced.`
2270
+ });
2271
+ }
2272
+ throw error;
2273
+ }
2274
+ const opened = openChatGptBrowser({ port: options.port, profileDir: options.profileDir, headless: false, url });
2275
+ await assertBrowserLaunchStayedAlive(opened, options.commandOptions.launchTimeoutMs);
2276
+ if (getDedicatedBrowserHeadlessMode({ port: opened.port, profileDir: opened.profileDir })) {
2277
+ throw new Error("The replacement browser is still headless; visible recovery was not recorded.");
2278
+ }
2279
+ await recordBrowserLoginLaunch({ port: opened.port, profile_dir: opened.profileDir, headless: false, minimized: false, resume_headless: true });
2280
+ printBrowserLoginGuide(io.stdout, {
2281
+ opened: true, reusedProfile: true, loginUrl: url, profileDir: opened.profileDir, port: opened.port,
2282
+ sourceCli: options.sourceCli, commandOptions: options.commandOptions
2283
+ });
2284
+ io.stdout("recovery: visible browser opened with the same profile and page. Complete a manual step only if ChatGPT requests it; no prompt was sent.");
2285
+ io.stdout("recovery: this visible window is temporary. After it closes, the next launch remains headless; another challenge stops without opening a visible window automatically.");
2286
+ if (!options.shouldWait)
2287
+ return 0;
2288
+ const ready = await waitForChatGptLoginReady(io.stderr, {
2289
+ port: opened.port,
2290
+ timeoutMs: options.timeoutMs,
2291
+ windowMode: { headless: false, virtualDisplay: false, minimized: false },
2292
+ headedLoginCommand: formatHeadedBrowserLoginCommand(options.sourceCli, { ...options.commandOptions, profileDir: opened.profileDir, port: opened.port })
2293
+ });
2294
+ return ready ? 0 : 1;
2295
+ });
2296
+ }
2194
2297
  async function completeBackgroundBrowserLogin(io, options) {
2195
2298
  return withBrowserSendLock(5_000, (detail) => io.stderr(`background: ${detail}`), async () => {
2196
2299
  const { closeIdleChatGptBrowserForHandoff, getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
@@ -2207,7 +2310,8 @@ async function completeBackgroundBrowserLogin(io, options) {
2207
2310
  port: opened.port,
2208
2311
  timeoutMs: options.timeoutMs,
2209
2312
  windowMode: { headless: true, virtualDisplay: false, minimized: false },
2210
- headedLoginCommand: options.headedLoginCommand
2313
+ headedLoginCommand: options.headedLoginCommand,
2314
+ headlessRecoveryCommand: options.headlessRecoveryCommand
2211
2315
  });
2212
2316
  if (!ready) {
2213
2317
  const status = await getChatGptBrowserStatus({ port: opened.port, timeoutMs: 5_000 }).catch(() => undefined);
@@ -2220,6 +2324,12 @@ async function completeBackgroundBrowserLogin(io, options) {
2220
2324
  return 0;
2221
2325
  });
2222
2326
  }
2327
+ function needsVisibleAuthRecovery(code) {
2328
+ return code !== undefined && ["login_required", "cloudflare_check", "captcha_required", "permission_required"].includes(code);
2329
+ }
2330
+ function manualVisibleInspectionStep(headedLoginCommand) {
2331
+ return `For visible inspection, close the dedicated browser yourself only when no work is active, then run \`${headedLoginCommand}\`.`;
2332
+ }
2223
2333
  /**
2224
2334
  * Guided login: poll the visible browser until a logged-in ChatGPT tab with a
2225
2335
  * usable composer appears, narrating each state change so the user knows what
@@ -2234,15 +2344,17 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
2234
2344
  const pollMs = options.pollMs ?? 2_000;
2235
2345
  const hasInteractiveWindow = options.windowMode?.headless !== true && options.windowMode?.virtualDisplay !== true;
2236
2346
  const headedLoginCommand = options.headedLoginCommand ?? "prodex pro browser login --headed";
2237
- const headedLoginHint = ` No interactive window is available; run \`${headedLoginCommand}\` to complete login, captcha, or human verification visibly.`;
2347
+ const manualInspection = manualVisibleInspectionStep(headedLoginCommand);
2238
2348
  const startedAt = now();
2239
2349
  stderr(hasInteractiveWindow
2240
- ? "login: waiting for a logged-in ChatGPT tab (finish login in the dedicated Chrome browser; Ctrl+C stops waiting)..."
2241
- : `login: waiting for a logged-in ChatGPT tab (no interactive window; use \`${headedLoginCommand}\` if login or verification is required; Ctrl+C stops waiting)...`);
2350
+ ? "login: waiting for ChatGPT readiness in the dedicated Chrome browser (complete any visible step it requests; Ctrl+C stops waiting)..."
2351
+ : "login: waiting for ChatGPT readiness (no interactive window; the observed blocker determines the next step; Ctrl+C stops waiting)...");
2242
2352
  let lastState = "";
2353
+ let lastStatus;
2243
2354
  let openMissingTabAttempts = 0;
2244
2355
  while (now() - startedAt < timeoutMs) {
2245
2356
  const status = await statusFn({ port: options.port, timeoutMs: 1_500 });
2357
+ lastStatus = status;
2246
2358
  // A running Chrome with no chatgpt.com tab leaves the user nothing to log
2247
2359
  // into, so prodex opens one. Keep trying while the tab is still missing:
2248
2360
  // one silent attempt that fails looks exactly like no attempt, which is how
@@ -2258,23 +2370,33 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
2258
2370
  if (opened === false) {
2259
2371
  stderr(hasInteractiveWindow
2260
2372
  ? "login: could not open a ChatGPT tab through the debug port; open https://chatgpt.com/ in that browser."
2261
- : `login: could not open a ChatGPT tab through the debug port; run \`${headedLoginCommand}\` to open it visibly.`);
2373
+ : `login: could not open a ChatGPT tab through the debug port. ${manualInspection}`);
2262
2374
  }
2263
2375
  await sleepFn(pollMs);
2264
2376
  continue;
2265
2377
  }
2378
+ const blocker = status.blocker;
2379
+ const blockerNextStep = blocker?.next_step ? ` Next: ${blocker.next_step}` : "";
2380
+ if (!hasInteractiveWindow && blocker && needsVisibleAuthRecovery(blocker.code)) {
2381
+ const visibleStep = options.windowMode?.headless === true && options.headlessRecoveryCommand
2382
+ ? `run \`${options.headlessRecoveryCommand}\` to handle it visibly.`
2383
+ : `${manualInspection} Handle the reported step visibly.`;
2384
+ stderr(`login: blocked - ${blocker.message}${blockerNextStep}`);
2385
+ stderr(`login: NOT READY - ${blocker.code} requires visible manual handling. No interactive window is available; ${visibleStep}`);
2386
+ return false;
2387
+ }
2266
2388
  const state = !status.reachable
2267
2389
  ? "login: browser starting..."
2268
2390
  : status.blocker
2269
- ? `login: blocked - ${status.blocker.message}${hasInteractiveWindow ? "" : headedLoginHint}`
2391
+ ? `login: blocked - ${status.blocker.message}${blockerNextStep}`
2270
2392
  : !status.loggedInLikely
2271
2393
  ? hasInteractiveWindow
2272
- ? "login: waiting for ChatGPT login in the dedicated Chrome browser..."
2273
- : `login: waiting for a saved ChatGPT login.${headedLoginHint}`
2394
+ ? "login: ChatGPT is reachable, but login readiness is not yet confirmed; review any visible browser prompt..."
2395
+ : "login: ChatGPT is reachable, but login readiness is not yet confirmed; waiting for page readiness evidence..."
2274
2396
  : !status.hasComposer
2275
2397
  ? hasInteractiveWindow
2276
- ? "login: logged in; open a chat so the prompt composer is visible..."
2277
- : `login: logged in, but no prompt composer is ready.${headedLoginHint}`
2398
+ ? "login: login looks active; open a chat so the prompt composer is visible..."
2399
+ : "login: login looks active, but no prompt composer is ready; waiting for a usable chat..."
2278
2400
  : "";
2279
2401
  if (state === "") {
2280
2402
  stderr(`login: READY - logged-in ChatGPT tab with composer detected (${Math.round((now() - startedAt) / 1000)}s).`);
@@ -2289,9 +2411,26 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
2289
2411
  break;
2290
2412
  await sleepFn(Math.min(pollMs, Math.max(1, remainingMs)));
2291
2413
  }
2292
- stderr(hasInteractiveWindow
2293
- ? `login: not ready after ${Math.round(timeoutMs / 1000)}s. Finish login in the browser, then verify with \`prodex pro browser check\`.`
2294
- : `login: not ready after ${Math.round(timeoutMs / 1000)}s. Run \`${headedLoginCommand}\` to complete login, captcha, or human verification visibly, then retry.`);
2414
+ const timeoutPrefix = `login: not ready after ${Math.round(timeoutMs / 1000)}s`;
2415
+ if (lastStatus?.blocker) {
2416
+ const nextStep = lastStatus.blocker.next_step ? ` Next: ${lastStatus.blocker.next_step}` : "";
2417
+ stderr(`${timeoutPrefix}; last blocker ${lastStatus.blocker.code}: ${lastStatus.blocker.message}${nextStep}`);
2418
+ }
2419
+ else if (!lastStatus?.reachable) {
2420
+ stderr(hasInteractiveWindow
2421
+ ? `${timeoutPrefix}; browser startup was not confirmed. Check the dedicated Chrome browser, then verify with \`prodex pro browser check\`.`
2422
+ : `${timeoutPrefix}; browser startup was not confirmed. ${manualInspection}`);
2423
+ }
2424
+ else if (lastStatus.loggedInLikely && !lastStatus.hasComposer) {
2425
+ stderr(hasInteractiveWindow
2426
+ ? `${timeoutPrefix}; login looked active, but the prompt composer was not detected. Open a normal ChatGPT chat or Project thread, then verify with \`prodex pro browser check\`.`
2427
+ : `${timeoutPrefix}; login looked active, but the prompt composer was not detected. ${manualInspection}`);
2428
+ }
2429
+ else {
2430
+ stderr(hasInteractiveWindow
2431
+ ? `${timeoutPrefix}; ChatGPT was reachable, but login readiness was not yet confirmed. Review the dedicated Chrome browser, then verify with \`prodex pro browser check\`.`
2432
+ : `${timeoutPrefix}; ChatGPT was reachable, but login readiness was not yet confirmed. ${manualInspection}`);
2433
+ }
2295
2434
  return false;
2296
2435
  }
2297
2436
  export async function assertBrowserLaunchStayedAlive(opened, timeoutMs) {
@@ -2895,11 +3034,19 @@ export async function listConsultListEntries(store, options = { readOnly: true }
2895
3034
  function formatHeadedBrowserLoginCommand(sourceCli, options = {}) {
2896
3035
  return `${formatBrowserLoginCommand(sourceCli, options)} --headed`;
2897
3036
  }
3037
+ function formatVisibleAuthRecoveryCommand(sourceCli, options = {}) {
3038
+ return `${formatBrowserLoginCommand(sourceCli, options)} --headed --recover-visible`;
3039
+ }
2898
3040
  export function printBrowserLoginGuide(stdout, input) {
2899
3041
  const noInteractiveWindow = input.headless === true || input.virtualDisplay === true;
2900
3042
  const windowAvailable = (input.opened || input.reused === true) && !noInteractiveWindow;
2901
3043
  const loginCommand = formatBrowserLoginCommand(input.sourceCli, input.commandOptions);
2902
3044
  const headedLoginCommand = formatHeadedBrowserLoginCommand(input.sourceCli, input.commandOptions);
3045
+ const visibleRecoveryCommand = formatVisibleAuthRecoveryCommand(input.sourceCli, {
3046
+ ...input.commandOptions,
3047
+ profileDir: input.profileDir,
3048
+ port: input.port
3049
+ });
2903
3050
  const runtimeCommandOptions = {
2904
3051
  ...(input.commandOptions?.cwd ? { cwd: input.commandOptions.cwd } : {}),
2905
3052
  ...(input.commandOptions?.port ? { port: input.commandOptions.port } : {})
@@ -2922,14 +3069,18 @@ export function printBrowserLoginGuide(stdout, input) {
2922
3069
  : "Dry run: no browser was opened.");
2923
3070
  if (noInteractiveWindow && (input.opened || input.reused)) {
2924
3071
  stdout("");
2925
- stdout(`If ChatGPT needs login, captcha, or human verification, run \`${headedLoginCommand}\` to handle it in a visible window.`);
3072
+ stdout(input.headless
3073
+ ? `If ChatGPT needs login, captcha, or human verification, run \`${visibleRecoveryCommand}\` to perform the explicit guarded switch to a visible window.`
3074
+ : `If ChatGPT needs login, captcha, or human verification, close the virtual-display browser yourself, then run \`${headedLoginCommand}\` to handle it in a visible window.`);
2926
3075
  stdout(`Next: run \`${checkCommand}\` to confirm the session, then consult as usual.`);
2927
3076
  return;
2928
3077
  }
2929
3078
  stdout("");
2930
3079
  stdout("Steps:");
2931
3080
  if (windowAvailable) {
2932
- stdout(`1. Log in manually at ${input.loginUrl} in the dedicated Chrome window.`);
3081
+ stdout(input.reusedProfile || input.reused === true
3082
+ ? `1. The existing profile is open at ${input.loginUrl}; log in manually only if ChatGPT requests it.`
3083
+ : `1. Log in manually at ${input.loginUrl} in the dedicated Chrome window.`);
2933
3084
  stdout("2. If ChatGPT asks for captcha, Cloudflare/human verification, permission, or account verification, complete it in the browser.");
2934
3085
  stdout("3. For usage limit, message limit, model limit, or rate limit, wait for the reset or choose an available model in the browser.");
2935
3086
  stdout("4. Open a normal ChatGPT chat or the intended Project/thread so the prompt composer is visible.");
@@ -2939,9 +3090,13 @@ export function printBrowserLoginGuide(stdout, input) {
2939
3090
  }
2940
3091
  else {
2941
3092
  stdout(noInteractiveWindow
2942
- ? `1. Run \`${headedLoginCommand}\` to open a visible dedicated Chrome window for login or verification.`
3093
+ ? input.headless
3094
+ ? `1. Run \`${visibleRecoveryCommand}\` to perform the guarded switch to a visible dedicated Chrome window.`
3095
+ : `1. Close the virtual-display browser yourself, then run \`${headedLoginCommand}\` to open a visible dedicated Chrome window.`
2943
3096
  : `1. Run \`${loginCommand}\` without \`--dry-run\` to open the dedicated Chrome window.`);
2944
- stdout(`2. Log in manually at ${input.loginUrl} in that Chrome window.`);
3097
+ stdout(input.reusedProfile
3098
+ ? `2. Log in manually only if ChatGPT requests it at ${input.loginUrl}.`
3099
+ : `2. Log in manually at ${input.loginUrl} in that Chrome window.`);
2945
3100
  stdout("3. If ChatGPT asks for captcha, Cloudflare/human verification, permission, or account verification, complete it in the browser.");
2946
3101
  stdout("4. For usage limit, message limit, model limit, or rate limit, wait for the reset or choose an available model in the browser.");
2947
3102
  stdout("5. Open a normal ChatGPT chat or the intended Project/thread so the prompt composer is visible.");
@@ -3047,7 +3202,9 @@ export async function printProductCheck(store, io, args, configCwd = io.cwd) {
3047
3202
  // report both as "not running": the browser really is gone, or it is still
3048
3203
  // there and has stopped answering. The second keeps burning CPU until
3049
3204
  // somebody notices, and nobody notices a message that says it is absent.
3050
- const wedged = findWedgedBrowser({ ...(browserCommandOptions.port !== undefined ? { port: browserCommandOptions.port } : {}) });
3205
+ const wedged = statusMeansBrowserDead(browserStatus)
3206
+ ? findWedgedBrowser({ ...(browserCommandOptions.port !== undefined ? { port: browserCommandOptions.port } : {}) })
3207
+ : [];
3051
3208
  const blocker = wedged.length > 0 ? wedgedBrowserBlocker(wedged, browserCommandOptions.port ?? DEFAULT_CDP_PORT) : browserStatus.blocker;
3052
3209
  io.stdout(`chatgpt: ${blocker?.code ?? "unreachable"} - ${blocker?.message ?? "browser is not reachable"}`);
3053
3210
  const nextStep = productCheckBrowserNextStep(blocker?.next_step, sourceCli, browserCommandOptions);
@@ -3150,7 +3307,7 @@ export function assertNoOrphanConsultResults(tasksById, results) {
3150
3307
  }
3151
3308
  export function browserReadinessNextStep(input) {
3152
3309
  if (!input.loggedInLikely) {
3153
- return "Log in manually in the visible ChatGPT browser, then retry.";
3310
+ return "ChatGPT login readiness is not yet confirmed. Review the visible ChatGPT browser state, then retry.";
3154
3311
  }
3155
3312
  if (!input.hasComposer) {
3156
3313
  return "Open a normal ChatGPT chat or Project thread, select the Pro/Thinking model, and retry.";
@@ -82,7 +82,8 @@ Use this only when you explicitly want to use your logged-in ChatGPT Pro web ses
82
82
  ```bash
83
83
  prodex pro browser login --dry-run
84
84
  prodex pro browser login
85
- prodex pro browser login --headed # force a visible window for interactive reauthentication
85
+ prodex pro browser login --headed # visible mode when no incompatible browser is running
86
+ prodex pro browser login --headed --recover-visible # guarded recovery of a blocked headless browser
86
87
  prodex pro browser help
87
88
  prodex pro browser check
88
89
  prodex pro browser smoke --cwd /absolute/path/to/your/repo
@@ -107,7 +108,7 @@ What happens:
107
108
  - `login --dry-run` prints the dedicated Chrome profile, debug URL, and next commands without opening a browser.
108
109
  - `login` opens that dedicated Chrome profile at ChatGPT. In an interactive terminal it then waits (default 5 minutes; `--no-wait` skips, `--wait-timeout-ms` tunes) and narrates which manual step is still missing until it reports READY; scripts and agents get the immediate return unless they pass `--wait`.
109
110
  - `login` reuses the last profile recorded for the resolved debug port when `--profile-dir` is omitted. It does not reuse a saved custom port implicitly: `--port` / `PRODEX_CDP_PORT` / the normal `9333` default still resolve the port exactly as before.
110
- - You log in manually in the visible browser.
111
+ - You log in manually only if the visible browser asks; an already signed-in profile is reused.
111
112
  - If ChatGPT asks for captcha, Cloudflare/human verification, permission, or account verification, handle it in that browser.
112
113
  - If ChatGPT shows a usage limit, message limit, model limit, or rate limit, wait for the reset or choose an available model in the browser.
113
114
  - Open a normal ChatGPT chat or the intended Project/thread so the prompt composer is visible.
@@ -122,6 +123,10 @@ Closing that Chrome window does not switch to headless mode or prove that the sa
122
123
 
123
124
  For a one-time interactive login followed by a no-window handoff, run `prodex pro browser login --background`. It waits even without a terminal, verifies readiness, then under the shared send lock gracefully closes the idle dedicated browser and launches the same profile and conversation headless. Wait for `background: READY`. The resulting mode is recorded for future CLI/MCP relaunches. This option cannot be combined with other window modes or `--no-wait`; `--dry-run` previews without opening or closing anything. Other page targets (including Chrome account prompts), dialogs, drafts, attachments, active responses, and uncertain browser identity/shutdown stop the handoff. Headless authentication and protection checks are never bypassed.
124
125
 
126
+ To inspect an authentication/protection blocker in a running headless browser, explicitly use `prodex pro browser login --headed --recover-visible`. The command requires one verified dedicated headless browser and one ChatGPT page reporting `login_required`, `cloudflare_check`, `captcha_required`, or `permission_required`. It preserves the exact profile and page under the shared send lock, refuses unfinished inputs, active work, dialogs, attachments, extra tabs, and ambiguous identity, and waits for a verified shutdown before launching headed. It does not click, solve, or suppress a security check. Use `--wait` to wait for manual handling or `--no-wait` to return after launch; `--dry-run` performs no browser change. This is headless-only, not a generic reset or a virtual-display switch.
127
+
128
+ The visible recovery window does not change the headless relaunch preference. Once it closes, both a new `login` launch and an automatic browser restart use headless mode again. Reusing the still-open window with `login --wait` keeps that preference. An ordinary explicit window-mode option or environment override replaces it. There is no automatic headed fallback when the next headless launch encounters another challenge.
129
+
125
130
  If only the ChatGPT tab was closed, MCP and auto-login-enabled CLI requests can reopen one tab in the existing browser before an unsent request. They verify the saved login before sending and never use this path to resend an accepted or uncertain request.
126
131
 
127
132
  Before requesting `--background`, finish native browser and OS confirmations yourself. Page targets and rendered dialogs are checked, but Chrome does not expose every native prompt through CDP. Incognito/guest mode and an explicit `--profile-directory` are refused because their login identity cannot be safely handed off by this launcher.
@@ -214,8 +219,8 @@ The catch is what "minimized" means to your desktop. Under WSLg a minimized Chro
214
219
 
215
220
  `prodex pro browser login --headless` (or `PRODEX_HEADLESS=1`, which also covers the MCP server and its auto-recovery) runs the dedicated browser with no visible window. Two constraints are real, not cosmetic:
216
221
 
217
- - **Sign in headed first.** Nobody can log in to a window that does not exist, so headless reuses a profile you already signed into. If login, captcha, Cloudflare, permission, or account verification is needed, close the hidden browser yourself and run `prodex pro browser login --headed` for a visible interactive window. Do not merely omit `--headless`: saved modes persist.
218
- - **One mode at a time.** A single Chrome profile cannot serve a headed and a headless instance simultaneously. Use the guarded `--background` handoff for headed-to-headless operation, or close the browser yourself before an explicit mode switch.
222
+ - **Sign in headed first.** Nobody can log in to a window that does not exist, so headless reuses a profile you already signed into. If the running headless browser reports login, captcha, Cloudflare, permission, or account verification, use `prodex pro browser login --headed --recover-visible` for a guarded switch to a visible interactive window. Do not merely omit `--headless`: saved modes persist.
223
+ - **One mode at a time.** A single Chrome profile cannot serve a headed and a headless instance simultaneously. Use the guarded `--background` handoff for headed-to-headless operation or the explicit `--headed --recover-visible` recovery for a blocked headless browser. Other mode changes require closing the browser yourself.
219
224
 
220
225
  **A saved login does not guarantee headless readiness.** A previous test on a signed-in profile remained on the "Just a moment..." interstitial past 60 seconds. That observation does not establish why the challenge appeared or prove that the saved login was lost. Require an actual READY result; if a protection check blocks the browser, stop and inspect it visibly. prodex does not invoke a hidden API or bypass login/protection. Only the window is optional; the login is not.
221
226
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.13",
3
+ "version": "0.40.15",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",