@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 +2 -2
- package/dist/browser-handoff.js +89 -15
- package/dist/chatgpt-browser.js +29 -15
- package/dist/cli-help.js +4 -4
- package/dist/cli-pro.js +172 -34
- package/docs/cli-reference.md +9 -4
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
package/dist/browser-handoff.js
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
283
|
+
await verifyVisibleAuthRecovery(current);
|
|
284
|
+
await request(socket, "Browser.close");
|
|
285
|
+
await waitForQuietShutdown(identity, port, profileDir);
|
|
286
|
+
return { url: page.url };
|
|
213
287
|
}
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
4493
|
-
//
|
|
4494
|
-
//
|
|
4495
|
-
|
|
4496
|
-
|
|
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
|
-
|
|
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"
|
|
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 \`${
|
|
414
|
-
: `headless: readiness was not confirmed (${finalStatus?.blocker?.code ?? "not_ready"}). This does not prove that the saved login was lost.
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
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;
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
?
|
|
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(
|
|
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 =
|
|
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);
|
package/docs/cli-reference.md
CHANGED
|
@@ -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 #
|
|
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
|
|
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
|
|
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
|
|
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
|
|