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