@youdie006/prodex 0.40.14 → 0.40.16

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
@@ -75,7 +75,7 @@ Finish any native browser or OS confirmation before requesting the handoff: Chro
75
75
 
76
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
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: use `prodex pro browser login --headed` to handle the reported step visibly. A usage limit or missing composer has its own next step instead of a blanket login instruction.
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
79
 
80
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`).
81
81
 
@@ -191,7 +191,7 @@ The last recorded window mode is reused by later `login` commands and by CLI/MCP
191
191
 
192
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.
193
193
 
194
- 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 the dedicated browser fully exits, the next launch remains headless. On macOS, closing a window alone may leave Chrome running; prodex stops rather than reopening that temporary visible window. Another challenge also 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.
195
195
 
196
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.
197
197
 
@@ -3,6 +3,7 @@ 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,
@@ -167,6 +168,37 @@ async function verifyIdle(page) {
167
168
  blocked("The page has active work, unfinished input, attachments, or a dialog; no browser was closed.");
168
169
  }
169
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
+ }
170
202
  function alive(pid) {
171
203
  try {
172
204
  process.kill(pid, 0);
@@ -176,6 +208,39 @@ function alive(pid) {
176
208
  return error.code !== "ESRCH";
177
209
  }
178
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
+ }
179
244
  /** Caller holds the shared browser send lock through this close AND relaunch. */
180
245
  export async function closeIdleChatGptBrowserForHandoff(options) {
181
246
  const { port, profileDir } = options;
@@ -194,20 +259,29 @@ export async function closeIdleChatGptBrowserForHandoff(options) {
194
259
  }
195
260
  await verifyIdle(current);
196
261
  await request(socket, "Browser.close");
197
- const deadline = Date.now() + 10_000;
198
- while (Date.now() < deadline) {
199
- if (!identity.pids.some(alive)) {
200
- try {
201
- await readJson(port, "version");
202
- }
203
- catch (error) {
204
- const cause = error.cause;
205
- if (cause?.code === "ECONNREFUSED")
206
- return { url: page.url };
207
- blocked("The old browser exited, but its control port could not be verified closed.");
208
- }
209
- }
210
- 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.");
211
282
  }
212
- 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 };
213
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,
@@ -1001,7 +1005,7 @@ export function detectChatGptPageBlocker(state) {
1001
1005
  return rendered;
1002
1006
  // The interstitial can have an empty body. Never use a conversation title
1003
1007
  // alone when the composer exists or its state was not actually checked.
1004
- if (state.hasComposer === false && /^just a moment\.{0,3}$/i.test(state.title?.trim() ?? "")) {
1008
+ if (state.hasComposer === false && /^(?:just a moment|잠시만 기다리십시오)(?:\.{0,3}|…)$/i.test(state.title?.trim() ?? "")) {
1005
1009
  return detectChatGptBlocker("Just a moment", []);
1006
1010
  }
1007
1011
  return undefined;
@@ -4474,14 +4478,18 @@ export function fetchTimedOut(error) {
4474
4478
  * and ending a busy browser takes the consult it is writing with it.
4475
4479
  */
4476
4480
  export function statusMeansBrowserDead(status) {
4477
- return !status.reachable && status.blocker?.code !== "browser_slow";
4481
+ return !status.reachable && status.blocker?.code === "browser_unreachable";
4478
4482
  }
4479
4483
  async function findChatGptPage(port, timeoutMs, targetUrl) {
4484
+ let receivedResponse = false;
4480
4485
  try {
4481
4486
  const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
4487
+ receivedResponse = true;
4482
4488
  if (!response.ok)
4483
4489
  throw new Error(`HTTP ${response.status}`);
4484
4490
  const pages = (await response.json());
4491
+ if (!Array.isArray(pages))
4492
+ throw new Error("Invalid Chrome DevTools page list");
4485
4493
  const visibilityByPage = await getChatGptPageVisibility(pages);
4486
4494
  const blocker = chatGptPageSelectionBlocker(pages, targetUrl, visibilityByPage);
4487
4495
  if (blocker)
@@ -4489,17 +4497,11 @@ async function findChatGptPage(port, timeoutMs, targetUrl) {
4489
4497
  return { ok: true, page: selectChatGptPage(pages, targetUrl, visibilityByPage) };
4490
4498
  }
4491
4499
  catch (error) {
4492
- // A port that answers too slowly is a browser that is BUSY, not one that is
4493
- // gone, and the two must not share a verdict: the silence check that
4494
- // decides whether to end a browser counted three slow answers on a loaded
4495
- // machine as three dead ones - the exact failure the comment above
4496
- // confirmBrowserSilence was written to prevent.
4497
- // A timeout on its own is not proof of life: with a small budget the
4498
- // timer fires before the connection reports anything, and a port nothing
4499
- // listens on would be called "busy". A raw TCP connect settles it, and
4500
- // only an ACCEPT counts - measured on WSL2, a closed loopback port does not
4501
- // refuse at all, it times out (1.5s), while a listening one accepts in 1ms.
4502
- 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") {
4503
4505
  return {
4504
4506
  ok: false,
4505
4507
  blocker: {
@@ -4511,6 +4513,18 @@ async function findChatGptPage(port, timeoutMs, targetUrl) {
4511
4513
  }
4512
4514
  };
4513
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
+ }
4514
4528
  return {
4515
4529
  ok: false,
4516
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;
@@ -2045,6 +2082,14 @@ async function attemptMissingChatGptTabRecovery(stderr, options) {
2045
2082
  throw new ChatGptBrowserBlockerError(status.blocker);
2046
2083
  return false;
2047
2084
  }
2085
+ if (saved?.headless !== true && resolveBrowserWindowMode({ lastLogin: saved, forRelaunch: true }).headless) {
2086
+ throw new ChatGptBrowserBlockerError({
2087
+ code: "browser_mode_transition_required",
2088
+ message: "The temporary visible browser is still running after its ChatGPT tab closed; reopening a tab would show another window.",
2089
+ retryable: false,
2090
+ next_step: "Fully quit only the dedicated browser, then retry to launch the saved profile headlessly. On macOS, closing a window alone does not quit Chrome. Do not log in again solely because this transition stopped."
2091
+ });
2092
+ }
2048
2093
  stderr("recover: the browser is running but its ChatGPT tab was closed; opening one tab and checking the saved login...");
2049
2094
  if (!await openChatGptTab(port))
2050
2095
  return false;
@@ -2066,6 +2111,16 @@ async function attemptMissingChatGptTabRecovery(stderr, options) {
2066
2111
  });
2067
2112
  }
2068
2113
  export async function attemptBrowserAutoRecovery(stderr, options) {
2114
+ try {
2115
+ return await withBrowserSendLock(30_000, (detail) => stderr(`recover: ${detail}`), () => recoverBrowserUnderSendLock(stderr, options));
2116
+ }
2117
+ catch (error) {
2118
+ stderr(`recover: failed - ${errorMessage(error)}`);
2119
+ return false;
2120
+ }
2121
+ }
2122
+ async function recoverBrowserUnderSendLock(stderr, options) {
2123
+ const recoveryPort = resolveCdpPort(options.port);
2069
2124
  // Launching is right when the browser is gone and wrong when it is only deaf:
2070
2125
  // a second Chrome on the same profile joins the wedged one rather than
2071
2126
  // replacing it, and the wedged one keeps burning CPU while nobody looks. This
@@ -2075,18 +2130,33 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
2075
2130
  // scanning the DEFAULT profile for a custom-profile user put a second,
2076
2131
  // healthy browser's renderers on the list this function kills.
2077
2132
  const lastLogin = await readLastBrowserLoginLaunch().catch(() => undefined);
2078
- const recoveryPort = resolveCdpPort(options.port);
2079
2133
  const savedIdentityBelongsToAnotherPort = lastLogin?.port !== undefined &&
2080
- lastLogin.port !== recoveryPort &&
2081
- (lastLogin.profile_dir !== undefined || lastLogin.virtual_display !== undefined);
2134
+ lastLogin.port !== recoveryPort;
2082
2135
  if (savedIdentityBelongsToAnotherPort) {
2083
2136
  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
2137
  return false;
2085
2138
  }
2139
+ const current = await getChatGptBrowserStatus({ port: recoveryPort, timeoutMs: 2_000 });
2140
+ if (current.reachable && current.loggedInLikely && current.hasComposer && !current.blocker) {
2141
+ if (!lastLogin?.profile_dir) {
2142
+ stderr("recover: stopped - a READY browser has no saved profile identity; no automatic retry is allowed.");
2143
+ return false;
2144
+ }
2145
+ const { getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
2146
+ // The process check also verifies canonical profile ownership.
2147
+ getDedicatedBrowserHeadlessMode({ port: recoveryPort, profileDir: lastLogin.profile_dir });
2148
+ stderr("recover: browser is READY - reusing it without relaunching...");
2149
+ return true;
2150
+ }
2151
+ if (current.reachable || !statusMeansBrowserDead(current)) {
2152
+ const state = current.blocker?.code ?? "not_ready";
2153
+ stderr(`recover: stopped - browser is not confirmed stopped or ready (${state}); no browser was ended, launched, or changed.`);
2154
+ return false;
2155
+ }
2086
2156
  const lastLoginForPort = lastLogin?.port === undefined || lastLogin.port === recoveryPort ? lastLogin : undefined;
2087
2157
  let windowMode;
2088
2158
  try {
2089
- windowMode = resolveBrowserWindowMode({ ...(lastLogin ? { lastLogin } : {}) });
2159
+ windowMode = resolveBrowserWindowMode({ ...(lastLogin ? { lastLogin } : {}), forRelaunch: true });
2090
2160
  }
2091
2161
  catch (error) {
2092
2162
  stderr(`recover: failed - ${errorMessage(error)}`);
@@ -2177,8 +2247,9 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
2177
2247
  windowMode,
2178
2248
  headedLoginCommand: formatHeadedBrowserLoginCommand(undefined, {
2179
2249
  profileDir: opened.profileDir,
2180
- ...(opened.port !== DEFAULT_CDP_PORT ? { port: opened.port } : {})
2181
- })
2250
+ port: opened.port
2251
+ }),
2252
+ headlessRecoveryCommand: formatVisibleAuthRecoveryCommand(undefined, { profileDir: opened.profileDir, port: opened.port })
2182
2253
  });
2183
2254
  if (!ready)
2184
2255
  return false;
@@ -2191,6 +2262,46 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
2191
2262
  return false;
2192
2263
  }
2193
2264
  }
2265
+ async function completeVisibleAuthRecovery(io, options) {
2266
+ return withBrowserSendLock(5_000, (detail) => io.stderr(`recovery: ${detail}`), async () => {
2267
+ const { closeBlockedHeadlessBrowserForVisibleAuth, getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
2268
+ io.stderr("recovery: verifying the blocked headless browser before graceful visible handoff...");
2269
+ let url;
2270
+ try {
2271
+ ({ url } = await closeBlockedHeadlessBrowserForVisibleAuth({ port: options.port, profileDir: options.profileDir }));
2272
+ }
2273
+ catch (error) {
2274
+ if (error instanceof ChatGptBrowserBlockerError) {
2275
+ throw new ChatGptBrowserBlockerError({
2276
+ ...error.blocker,
2277
+ 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.`
2278
+ });
2279
+ }
2280
+ throw error;
2281
+ }
2282
+ const opened = openChatGptBrowser({ port: options.port, profileDir: options.profileDir, headless: false, url });
2283
+ await assertBrowserLaunchStayedAlive(opened, options.commandOptions.launchTimeoutMs);
2284
+ if (getDedicatedBrowserHeadlessMode({ port: opened.port, profileDir: opened.profileDir })) {
2285
+ throw new Error("The replacement browser is still headless; visible recovery was not recorded.");
2286
+ }
2287
+ await recordBrowserLoginLaunch({ port: opened.port, profile_dir: opened.profileDir, headless: false, minimized: false, resume_headless: true });
2288
+ printBrowserLoginGuide(io.stdout, {
2289
+ opened: true, reusedProfile: true, loginUrl: url, profileDir: opened.profileDir, port: opened.port,
2290
+ sourceCli: options.sourceCli, commandOptions: options.commandOptions
2291
+ });
2292
+ 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.");
2293
+ io.stdout("recovery: this visible window is temporary. After the dedicated browser fully exits, the next launch remains headless. On macOS, closing its window alone may leave Chrome running; prodex will not reopen that temporary visible window automatically.");
2294
+ if (!options.shouldWait)
2295
+ return 0;
2296
+ const ready = await waitForChatGptLoginReady(io.stderr, {
2297
+ port: opened.port,
2298
+ timeoutMs: options.timeoutMs,
2299
+ windowMode: { headless: false, virtualDisplay: false, minimized: false },
2300
+ headedLoginCommand: formatHeadedBrowserLoginCommand(options.sourceCli, { ...options.commandOptions, profileDir: opened.profileDir, port: opened.port })
2301
+ });
2302
+ return ready ? 0 : 1;
2303
+ });
2304
+ }
2194
2305
  async function completeBackgroundBrowserLogin(io, options) {
2195
2306
  return withBrowserSendLock(5_000, (detail) => io.stderr(`background: ${detail}`), async () => {
2196
2307
  const { closeIdleChatGptBrowserForHandoff, getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
@@ -2207,7 +2318,8 @@ async function completeBackgroundBrowserLogin(io, options) {
2207
2318
  port: opened.port,
2208
2319
  timeoutMs: options.timeoutMs,
2209
2320
  windowMode: { headless: true, virtualDisplay: false, minimized: false },
2210
- headedLoginCommand: options.headedLoginCommand
2321
+ headedLoginCommand: options.headedLoginCommand,
2322
+ headlessRecoveryCommand: options.headlessRecoveryCommand
2211
2323
  });
2212
2324
  if (!ready) {
2213
2325
  const status = await getChatGptBrowserStatus({ port: opened.port, timeoutMs: 5_000 }).catch(() => undefined);
@@ -2220,6 +2332,12 @@ async function completeBackgroundBrowserLogin(io, options) {
2220
2332
  return 0;
2221
2333
  });
2222
2334
  }
2335
+ function needsVisibleAuthRecovery(code) {
2336
+ return code !== undefined && ["login_required", "cloudflare_check", "captcha_required", "permission_required"].includes(code);
2337
+ }
2338
+ function manualVisibleInspectionStep(headedLoginCommand) {
2339
+ return `For visible inspection, close the dedicated browser yourself only when no work is active, then run \`${headedLoginCommand}\`.`;
2340
+ }
2223
2341
  /**
2224
2342
  * Guided login: poll the visible browser until a logged-in ChatGPT tab with a
2225
2343
  * usable composer appears, narrating each state change so the user knows what
@@ -2234,10 +2352,11 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
2234
2352
  const pollMs = options.pollMs ?? 2_000;
2235
2353
  const hasInteractiveWindow = options.windowMode?.headless !== true && options.windowMode?.virtualDisplay !== true;
2236
2354
  const headedLoginCommand = options.headedLoginCommand ?? "prodex pro browser login --headed";
2355
+ const manualInspection = manualVisibleInspectionStep(headedLoginCommand);
2237
2356
  const startedAt = now();
2238
2357
  stderr(hasInteractiveWindow
2239
2358
  ? "login: waiting for ChatGPT readiness in the dedicated Chrome browser (complete any visible step it requests; Ctrl+C stops waiting)..."
2240
- : `login: waiting for ChatGPT readiness (no interactive window; use \`${headedLoginCommand}\` to inspect any requested browser step visibly; Ctrl+C stops waiting)...`);
2359
+ : "login: waiting for ChatGPT readiness (no interactive window; the observed blocker determines the next step; Ctrl+C stops waiting)...");
2241
2360
  let lastState = "";
2242
2361
  let lastStatus;
2243
2362
  let openMissingTabAttempts = 0;
@@ -2259,18 +2378,19 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
2259
2378
  if (opened === false) {
2260
2379
  stderr(hasInteractiveWindow
2261
2380
  ? "login: could not open a ChatGPT tab through the debug port; open https://chatgpt.com/ in that browser."
2262
- : `login: could not open a ChatGPT tab through the debug port; run \`${headedLoginCommand}\` to open it visibly.`);
2381
+ : `login: could not open a ChatGPT tab through the debug port. ${manualInspection}`);
2263
2382
  }
2264
2383
  await sleepFn(pollMs);
2265
2384
  continue;
2266
2385
  }
2267
2386
  const blocker = status.blocker;
2268
2387
  const blockerNextStep = blocker?.next_step ? ` Next: ${blocker.next_step}` : "";
2269
- const needsVisibleAuth = blocker !== undefined &&
2270
- ["login_required", "cloudflare_check", "captcha_required", "permission_required"].includes(blocker.code);
2271
- if (!hasInteractiveWindow && needsVisibleAuth) {
2388
+ if (!hasInteractiveWindow && blocker && needsVisibleAuthRecovery(blocker.code)) {
2389
+ const visibleStep = options.windowMode?.headless === true && options.headlessRecoveryCommand
2390
+ ? `run \`${options.headlessRecoveryCommand}\` to handle it visibly.`
2391
+ : `${manualInspection} Handle the reported step visibly.`;
2272
2392
  stderr(`login: blocked - ${blocker.message}${blockerNextStep}`);
2273
- stderr(`login: NOT READY - ${blocker.code} requires visible manual handling. No interactive window is available; run \`${headedLoginCommand}\` to handle it visibly.`);
2393
+ stderr(`login: NOT READY - ${blocker.code} requires visible manual handling. No interactive window is available; ${visibleStep}`);
2274
2394
  return false;
2275
2395
  }
2276
2396
  const state = !status.reachable
@@ -2280,11 +2400,11 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
2280
2400
  : !status.loggedInLikely
2281
2401
  ? hasInteractiveWindow
2282
2402
  ? "login: ChatGPT is reachable, but login readiness is not yet confirmed; review any visible browser prompt..."
2283
- : `login: ChatGPT is reachable, but login readiness is not yet confirmed; inspect it visibly with \`${headedLoginCommand}\` if needed.`
2403
+ : "login: ChatGPT is reachable, but login readiness is not yet confirmed; waiting for page readiness evidence..."
2284
2404
  : !status.hasComposer
2285
2405
  ? hasInteractiveWindow
2286
2406
  ? "login: login looks active; open a chat so the prompt composer is visible..."
2287
- : `login: login looks active, but no prompt composer is ready; inspect it visibly with \`${headedLoginCommand}\`.`
2407
+ : "login: login looks active, but no prompt composer is ready; waiting for a usable chat..."
2288
2408
  : "";
2289
2409
  if (state === "") {
2290
2410
  stderr(`login: READY - logged-in ChatGPT tab with composer detected (${Math.round((now() - startedAt) / 1000)}s).`);
@@ -2307,17 +2427,17 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
2307
2427
  else if (!lastStatus?.reachable) {
2308
2428
  stderr(hasInteractiveWindow
2309
2429
  ? `${timeoutPrefix}; browser startup was not confirmed. Check the dedicated Chrome browser, then verify with \`prodex pro browser check\`.`
2310
- : `${timeoutPrefix}; browser startup was not confirmed. Inspect it visibly with \`${headedLoginCommand}\`, then retry.`);
2430
+ : `${timeoutPrefix}; browser startup was not confirmed. ${manualInspection}`);
2311
2431
  }
2312
2432
  else if (lastStatus.loggedInLikely && !lastStatus.hasComposer) {
2313
2433
  stderr(hasInteractiveWindow
2314
2434
  ? `${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\`.`
2315
- : `${timeoutPrefix}; login looked active, but the prompt composer was not detected. Inspect it visibly with \`${headedLoginCommand}\`, then retry.`);
2435
+ : `${timeoutPrefix}; login looked active, but the prompt composer was not detected. ${manualInspection}`);
2316
2436
  }
2317
2437
  else {
2318
2438
  stderr(hasInteractiveWindow
2319
2439
  ? `${timeoutPrefix}; ChatGPT was reachable, but login readiness was not yet confirmed. Review the dedicated Chrome browser, then verify with \`prodex pro browser check\`.`
2320
- : `${timeoutPrefix}; ChatGPT was reachable, but login readiness was not yet confirmed. Inspect it visibly with \`${headedLoginCommand}\`; complete a manual step only if requested.`);
2440
+ : `${timeoutPrefix}; ChatGPT was reachable, but login readiness was not yet confirmed. ${manualInspection}`);
2321
2441
  }
2322
2442
  return false;
2323
2443
  }
@@ -2922,11 +3042,19 @@ export async function listConsultListEntries(store, options = { readOnly: true }
2922
3042
  function formatHeadedBrowserLoginCommand(sourceCli, options = {}) {
2923
3043
  return `${formatBrowserLoginCommand(sourceCli, options)} --headed`;
2924
3044
  }
3045
+ function formatVisibleAuthRecoveryCommand(sourceCli, options = {}) {
3046
+ return `${formatBrowserLoginCommand(sourceCli, options)} --headed --recover-visible`;
3047
+ }
2925
3048
  export function printBrowserLoginGuide(stdout, input) {
2926
3049
  const noInteractiveWindow = input.headless === true || input.virtualDisplay === true;
2927
3050
  const windowAvailable = (input.opened || input.reused === true) && !noInteractiveWindow;
2928
3051
  const loginCommand = formatBrowserLoginCommand(input.sourceCli, input.commandOptions);
2929
3052
  const headedLoginCommand = formatHeadedBrowserLoginCommand(input.sourceCli, input.commandOptions);
3053
+ const visibleRecoveryCommand = formatVisibleAuthRecoveryCommand(input.sourceCli, {
3054
+ ...input.commandOptions,
3055
+ profileDir: input.profileDir,
3056
+ port: input.port
3057
+ });
2930
3058
  const runtimeCommandOptions = {
2931
3059
  ...(input.commandOptions?.cwd ? { cwd: input.commandOptions.cwd } : {}),
2932
3060
  ...(input.commandOptions?.port ? { port: input.commandOptions.port } : {})
@@ -2949,14 +3077,18 @@ export function printBrowserLoginGuide(stdout, input) {
2949
3077
  : "Dry run: no browser was opened.");
2950
3078
  if (noInteractiveWindow && (input.opened || input.reused)) {
2951
3079
  stdout("");
2952
- stdout(`If ChatGPT needs login, captcha, or human verification, run \`${headedLoginCommand}\` to handle it in a visible window.`);
3080
+ stdout(input.headless
3081
+ ? `If ChatGPT needs login, captcha, or human verification, run \`${visibleRecoveryCommand}\` to perform the explicit guarded switch to a visible window.`
3082
+ : `If ChatGPT needs login, captcha, or human verification, close the virtual-display browser yourself, then run \`${headedLoginCommand}\` to handle it in a visible window.`);
2953
3083
  stdout(`Next: run \`${checkCommand}\` to confirm the session, then consult as usual.`);
2954
3084
  return;
2955
3085
  }
2956
3086
  stdout("");
2957
3087
  stdout("Steps:");
2958
3088
  if (windowAvailable) {
2959
- stdout(`1. Log in manually at ${input.loginUrl} in the dedicated Chrome window.`);
3089
+ stdout(input.reusedProfile || input.reused === true
3090
+ ? `1. The existing profile is open at ${input.loginUrl}; log in manually only if ChatGPT requests it.`
3091
+ : `1. Log in manually at ${input.loginUrl} in the dedicated Chrome window.`);
2960
3092
  stdout("2. If ChatGPT asks for captcha, Cloudflare/human verification, permission, or account verification, complete it in the browser.");
2961
3093
  stdout("3. For usage limit, message limit, model limit, or rate limit, wait for the reset or choose an available model in the browser.");
2962
3094
  stdout("4. Open a normal ChatGPT chat or the intended Project/thread so the prompt composer is visible.");
@@ -2966,9 +3098,13 @@ export function printBrowserLoginGuide(stdout, input) {
2966
3098
  }
2967
3099
  else {
2968
3100
  stdout(noInteractiveWindow
2969
- ? `1. Run \`${headedLoginCommand}\` to open a visible dedicated Chrome window for login or verification.`
3101
+ ? input.headless
3102
+ ? `1. Run \`${visibleRecoveryCommand}\` to perform the guarded switch to a visible dedicated Chrome window.`
3103
+ : `1. Close the virtual-display browser yourself, then run \`${headedLoginCommand}\` to open a visible dedicated Chrome window.`
2970
3104
  : `1. Run \`${loginCommand}\` without \`--dry-run\` to open the dedicated Chrome window.`);
2971
- stdout(`2. Log in manually at ${input.loginUrl} in that Chrome window.`);
3105
+ stdout(input.reusedProfile
3106
+ ? `2. Log in manually only if ChatGPT requests it at ${input.loginUrl}.`
3107
+ : `2. Log in manually at ${input.loginUrl} in that Chrome window.`);
2972
3108
  stdout("3. If ChatGPT asks for captcha, Cloudflare/human verification, permission, or account verification, complete it in the browser.");
2973
3109
  stdout("4. For usage limit, message limit, model limit, or rate limit, wait for the reset or choose an available model in the browser.");
2974
3110
  stdout("5. Open a normal ChatGPT chat or the intended Project/thread so the prompt composer is visible.");
@@ -3074,7 +3210,9 @@ export async function printProductCheck(store, io, args, configCwd = io.cwd) {
3074
3210
  // report both as "not running": the browser really is gone, or it is still
3075
3211
  // there and has stopped answering. The second keeps burning CPU until
3076
3212
  // somebody notices, and nobody notices a message that says it is absent.
3077
- const wedged = findWedgedBrowser({ ...(browserCommandOptions.port !== undefined ? { port: browserCommandOptions.port } : {}) });
3213
+ const wedged = statusMeansBrowserDead(browserStatus)
3214
+ ? findWedgedBrowser({ ...(browserCommandOptions.port !== undefined ? { port: browserCommandOptions.port } : {}) })
3215
+ : [];
3078
3216
  const blocker = wedged.length > 0 ? wedgedBrowserBlocker(wedged, browserCommandOptions.port ?? DEFAULT_CDP_PORT) : browserStatus.blocker;
3079
3217
  io.stdout(`chatgpt: ${blocker?.code ?? "unreachable"} - ${blocker?.message ?? "browser is not reachable"}`);
3080
3218
  const nextStep = productCheckBrowserNextStep(blocker?.next_step, sourceCli, browserCommandOptions);
@@ -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 the dedicated browser fully exits, both a new `login` launch and an automatic browser restart use headless mode again. On macOS, closing its last window may leave the process running: automatic missing-tab recovery stops with `browser_mode_transition_required` instead of opening another visible window. Fully quit only the dedicated browser before retrying; the closed tab alone is not a reason to log in again. Reusing a still-open window with `login --wait` keeps the 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.14",
3
+ "version": "0.40.16",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",