@youdie006/prodex 0.40.12 → 0.40.14
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 +14 -2
- package/dist/browser-handoff.js +213 -0
- package/dist/chatgpt-browser.js +53 -27
- package/dist/cli-help.js +4 -4
- package/dist/cli-pro.js +146 -29
- package/docs/cli-reference.md +11 -5
- package/docs/releasing.md +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,10 +69,20 @@ prodex pro latest # re-print the last answer
|
|
|
69
69
|
|
|
70
70
|
`prodex ask` is the short form of `prodex pro browser ask`; every flag works on both. The login opens its own Chrome profile (`~/.local/share/prodex/chrome-chatgpt-pro`), never your daily browser, and in a terminal it keeps watching the window and names the manual step still missing (sign in, clear a check, open a chat) until it reports READY.
|
|
71
71
|
|
|
72
|
+
For a one-time visible login followed by a verified headless handoff, use `prodex pro browser login --background`. Keep the window open until `background: READY`: prodex verifies login, gracefully closes only an idle dedicated browser, then reopens the same profile and conversation headless. Future CLI/MCP relaunches reuse that mode. Other tabs, Chrome account/permission confirmation windows, unfinished input, and active answers block the handoff. A verification challenge remains a blocker; this option does not guarantee that ChatGPT accepts headless Chrome.
|
|
73
|
+
|
|
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
|
+
|
|
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: use `prodex pro browser login --headed` to handle the reported step visibly. A usage limit or missing composer has its own next step instead of a blanket login instruction.
|
|
79
|
+
|
|
72
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`).
|
|
73
81
|
|
|
74
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.
|
|
75
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
|
+
|
|
76
86
|
Useful flags on every send:
|
|
77
87
|
|
|
78
88
|
| Flag | What it does |
|
|
@@ -179,9 +189,9 @@ The last recorded window mode is reused by later `login` commands and by CLI/MCP
|
|
|
179
189
|
|
|
180
190
|
`--minimized` keeps a window but minimizes it. Under WSLg a minimized Chrome still reports itself visible and consults keep working; a normal Linux desktop marks it hidden, and prodex refuses to send into a tab it cannot read, restores the window, and tells you.
|
|
181
191
|
|
|
182
|
-
`--headless`
|
|
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.
|
|
183
193
|
|
|
184
|
-
If a hidden or virtual browser needs login, captcha, Cloudflare, or account verification, close that browser yourself and run `prodex pro browser login --headed` to complete the interactive step. Merely omitting `--headless` does not switch modes because the saved mode persists. prodex does not bypass
|
|
194
|
+
If a hidden or virtual browser needs login, captcha, Cloudflare, or account verification, close that browser yourself and run `prodex pro browser login --headed` to complete the interactive step. Merely omitting `--headless` does not switch modes because the saved mode persists. prodex does not bypass protection or force-kill a browser for a mode change; `--background` permits only the guarded graceful handoff described above.
|
|
185
195
|
|
|
186
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.
|
|
187
197
|
|
|
@@ -249,6 +259,8 @@ Reports are deduplicated by blocker code, so something that stays broken adds to
|
|
|
249
259
|
|
|
250
260
|
**Can I close the terminal after login?** Yes, once login is READY: the dedicated Chrome is launched separately. Keep that browser running for consults. If a CLI or agent exits during a request, ChatGPT may still finish it; recover the original thread and request ID rather than automatically sending the question again.
|
|
251
261
|
|
|
262
|
+
**Does closing the Chrome window switch to headless?** No. Use `login --background` and wait for `background: READY`; ordinary headed login never silently changes mode. A closed window alone is not evidence of logout. Before an unsent request, MCP or an auto-login-enabled CLI can reopen one missing ChatGPT tab in the existing browser and recheck the saved login without restarting Chrome.
|
|
263
|
+
|
|
252
264
|
**It stopped with `browser_tab_crashed` or `Runtime.enable`.** Chrome can leave an "Aw, Snap!" tab listed on its control port even though that tab's renderer has crashed. Before typing a new prompt, prodex can reload a confirmed crashed tab once at the same conversation address and records `browser_tab_recovered`. A timeout alone never authorizes a reload. A crash after a prompt was submitted stops without resending; inspect the original conversation, then recover with both `--target-url` and `--request-id` from the blocker. Other tabs, the browser profile, and saved login are left alone.
|
|
253
265
|
|
|
254
266
|
**A send failed with `send_ui_changed`.** ChatGPT redesigned the composer or send control. Update (`npm i -g @youdie006/prodex@latest`); if it persists, `prodex pro report-issue`, and paste the prompt by hand meanwhile.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import WebSocket from "ws";
|
|
5
|
+
import { ChatGptBrowserBlockerError, attachmentPresenceExpression, composerTextStateExpression, detectChatGptPageBlocker, findLaunchedBrowserProcesses, inferChatGptPageLoggedInLikely, statusExpression } from "./chatgpt-browser.js";
|
|
6
|
+
function blocked(message) {
|
|
7
|
+
throw new ChatGptBrowserBlockerError({
|
|
8
|
+
code: "browser_handoff_blocked", message, retryable: false,
|
|
9
|
+
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
|
+
}
|
|
12
|
+
function browserIdentity(port, profileDir) {
|
|
13
|
+
const listed = spawnSync("ps", ["-Ao", "user,pid,command"], { encoding: "utf8", timeout: 5_000 });
|
|
14
|
+
if (listed.status !== 0 || typeof listed.stdout !== "string")
|
|
15
|
+
blocked("Could not verify the dedicated browser process.");
|
|
16
|
+
const pids = findLaunchedBrowserProcesses(listed.stdout, { port, profileDir });
|
|
17
|
+
const mains = listed.stdout.split(/\r?\n/).filter((line) => {
|
|
18
|
+
const pid = Number(/^\s*\S+\s+(\d+)\s/.exec(line)?.[1]);
|
|
19
|
+
return pids.includes(pid) && !/\s--type=/.test(line) && new RegExp(`--remote-debugging-port=${port}(?!\\d)`).test(line);
|
|
20
|
+
});
|
|
21
|
+
if (mains.length !== 1)
|
|
22
|
+
blocked("Could not identify exactly one dedicated browser for this port.");
|
|
23
|
+
if (/\s--(?:incognito|guest)(?:\s|=|$)/.test(mains[0]))
|
|
24
|
+
blocked("An incognito or guest browser cannot preserve its login through a restart.");
|
|
25
|
+
if (/\s--profile-directory(?:\s|=|$)/.test(mains[0]))
|
|
26
|
+
blocked("An explicitly selected Chrome sub-profile cannot be preserved by this handoff; no browser was closed.");
|
|
27
|
+
const actualProfile = /--user-data-dir=(.*?)(?=\s--|$)/.exec(mains[0])?.[1];
|
|
28
|
+
if (!actualProfile || !path.isAbsolute(actualProfile))
|
|
29
|
+
blocked("The browser profile could not be verified.");
|
|
30
|
+
try {
|
|
31
|
+
if (realpathSync(actualProfile) !== realpathSync(profileDir))
|
|
32
|
+
blocked("The actual browser profile differs from the requested profile.");
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error instanceof ChatGptBrowserBlockerError)
|
|
36
|
+
throw error;
|
|
37
|
+
blocked("The browser profile path could not be verified.");
|
|
38
|
+
}
|
|
39
|
+
return { main: Number(/^\s*\S+\s+(\d+)\s/.exec(mains[0])[1]), pids, headless: /\s--headless(?:\s|=|$)/.test(mains[0]) };
|
|
40
|
+
}
|
|
41
|
+
export function getDedicatedBrowserHeadlessMode(options) {
|
|
42
|
+
return browserIdentity(options.port, options.profileDir).headless;
|
|
43
|
+
}
|
|
44
|
+
async function readJson(port, resource) {
|
|
45
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/${resource}`, { signal: AbortSignal.timeout(2_000) });
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
blocked(`The browser did not answer the ${resource} identity check.`);
|
|
48
|
+
return response.json();
|
|
49
|
+
}
|
|
50
|
+
function localSocket(value, port, kind) {
|
|
51
|
+
if (typeof value !== "string")
|
|
52
|
+
blocked("Missing browser control socket.");
|
|
53
|
+
const url = new URL(value);
|
|
54
|
+
if (url.protocol !== "ws:" || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) ||
|
|
55
|
+
Number(url.port) !== port || url.username || url.password || !url.pathname.startsWith(`/devtools/${kind}/`)) {
|
|
56
|
+
blocked("The control socket is not bound to the expected local browser.");
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
async function singlePage(port) {
|
|
61
|
+
const response = await readJson(port, "list");
|
|
62
|
+
if (!Array.isArray(response))
|
|
63
|
+
blocked("The browser page list is invalid.");
|
|
64
|
+
const pages = response.filter((page) => page?.type === "page");
|
|
65
|
+
if (pages.length !== 1) {
|
|
66
|
+
const chooser = pages.find((page) => page.url === "chrome://signin-dice-web-intercept.top-chrome/chrome-signin");
|
|
67
|
+
if (chooser) {
|
|
68
|
+
let state;
|
|
69
|
+
try {
|
|
70
|
+
const socket = localSocket(chooser.webSocketDebuggerUrl, port, "page");
|
|
71
|
+
const reply = await request(socket, "Runtime.evaluate", {
|
|
72
|
+
expression: `(() => { const r = document.body?.getBoundingClientRect(); return { visibilityState: document.visibilityState, width: r?.width, height: r?.height, isChooser: !!document.querySelector('chrome-signin-app') }; })()`,
|
|
73
|
+
returnByValue: true
|
|
74
|
+
});
|
|
75
|
+
if (!reply.exceptionDetails)
|
|
76
|
+
state = reply.result?.value;
|
|
77
|
+
}
|
|
78
|
+
catch { /* An unreadable internal target is not proof of a visible prompt. */ }
|
|
79
|
+
if (state?.visibilityState === "visible" && state.isChooser === true &&
|
|
80
|
+
typeof state.width === "number" && state.width > 0 && typeof state.height === "number" && state.height > 0) {
|
|
81
|
+
throw new ChatGptBrowserBlockerError({
|
|
82
|
+
code: "browser_account_confirmation", retryable: false,
|
|
83
|
+
message: "Chrome is showing its browser-account connection chooser. This is separate from ChatGPT login; no browser was closed.",
|
|
84
|
+
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`."
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
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.");
|
|
88
|
+
}
|
|
89
|
+
blocked("The dedicated browser has additional page targets; nothing was closed.");
|
|
90
|
+
}
|
|
91
|
+
const page = pages[0];
|
|
92
|
+
const url = new URL(page.url);
|
|
93
|
+
if (url.protocol !== "https:" || url.hostname !== "chatgpt.com" || url.username || url.password || url.port ||
|
|
94
|
+
url.searchParams.has("temporary-chat") || !page.id) {
|
|
95
|
+
blocked("Handoff requires one normal, non-temporary ChatGPT page.");
|
|
96
|
+
}
|
|
97
|
+
localSocket(page.webSocketDebuggerUrl, port, "page");
|
|
98
|
+
return page;
|
|
99
|
+
}
|
|
100
|
+
// Unlike normal send connections this must not enable Page or answer dialogs.
|
|
101
|
+
async function request(socketUrl, method, params = {}) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const socket = new WebSocket(socketUrl);
|
|
104
|
+
let sent = false;
|
|
105
|
+
let finished = false;
|
|
106
|
+
const finish = (error, value) => {
|
|
107
|
+
if (finished)
|
|
108
|
+
return;
|
|
109
|
+
finished = true;
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
socket.removeAllListeners();
|
|
112
|
+
socket.on("error", () => { });
|
|
113
|
+
socket.terminate();
|
|
114
|
+
if (error)
|
|
115
|
+
reject(error);
|
|
116
|
+
else
|
|
117
|
+
resolve(value);
|
|
118
|
+
};
|
|
119
|
+
const timer = setTimeout(() => finish(new Error(`${method} timed out; browser handoff stopped.`)), 5_000);
|
|
120
|
+
socket.once("open", () => {
|
|
121
|
+
try {
|
|
122
|
+
socket.send(JSON.stringify({ id: 1, method, params }));
|
|
123
|
+
sent = true;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
socket.on("message", (data) => {
|
|
130
|
+
let reply;
|
|
131
|
+
try {
|
|
132
|
+
reply = JSON.parse(data.toString());
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (reply.id === 1)
|
|
138
|
+
finish(reply.error ? new Error(reply.error.message ?? "CDP command failed") : undefined, reply.result);
|
|
139
|
+
});
|
|
140
|
+
socket.once("error", (error) => finish(error));
|
|
141
|
+
socket.once("close", () => finish(method === "Browser.close" && sent ? undefined : new Error("Browser control socket closed before verification.")));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async function verifyIdle(page) {
|
|
145
|
+
const expression = `(() => {
|
|
146
|
+
const status = ${statusExpression()};
|
|
147
|
+
const composer = ${composerTextStateExpression()};
|
|
148
|
+
const visible = (e) => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden';
|
|
149
|
+
const attachmentState = ${attachmentPresenceExpression()};
|
|
150
|
+
return { status,
|
|
151
|
+
draft: composer.ok !== false || composer.reason !== 'Composer stayed empty after text insertion',
|
|
152
|
+
dialog: [...document.querySelectorAll('dialog[open],[role="dialog"],[aria-modal="true"]')].some(visible),
|
|
153
|
+
attachments: attachmentState.removed > 0 || [...document.querySelectorAll('input[type="file"]')].some(e => e.files?.length > 0) || [...document.querySelectorAll('[role="progressbar"]')].some(visible),
|
|
154
|
+
unpersisted: !location.pathname.includes('/c/') && !!document.querySelector('[data-message-author-role]')
|
|
155
|
+
};
|
|
156
|
+
})()`;
|
|
157
|
+
const reply = await request(page.webSocketDebuggerUrl, "Runtime.evaluate", { expression, returnByValue: true });
|
|
158
|
+
const value = reply?.result?.value;
|
|
159
|
+
if (reply?.exceptionDetails || !value?.status || [value.draft, value.dialog, value.attachments, value.unpersisted].some((v) => typeof v !== "boolean")) {
|
|
160
|
+
blocked("Could not verify the page's input and dialog state.");
|
|
161
|
+
}
|
|
162
|
+
const state = value.status;
|
|
163
|
+
if (state.url !== page.url || !state.hasComposer || !inferChatGptPageLoggedInLikely(state) || detectChatGptPageBlocker(state)) {
|
|
164
|
+
blocked("ChatGPT is not ready in the expected conversation; no browser was closed.");
|
|
165
|
+
}
|
|
166
|
+
if (state.generating || state.awaitingResponseChoice || state.openDialogText || value.draft || value.dialog || value.attachments || value.unpersisted) {
|
|
167
|
+
blocked("The page has active work, unfinished input, attachments, or a dialog; no browser was closed.");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function alive(pid) {
|
|
171
|
+
try {
|
|
172
|
+
process.kill(pid, 0);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
return error.code !== "ESRCH";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Caller holds the shared browser send lock through this close AND relaunch. */
|
|
180
|
+
export async function closeIdleChatGptBrowserForHandoff(options) {
|
|
181
|
+
const { port, profileDir } = options;
|
|
182
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || !path.isAbsolute(profileDir))
|
|
183
|
+
blocked("Invalid browser handoff identity.");
|
|
184
|
+
const identity = browserIdentity(port, profileDir);
|
|
185
|
+
const page = await singlePage(port);
|
|
186
|
+
const version = await readJson(port, "version");
|
|
187
|
+
const socket = localSocket(version?.webSocketDebuggerUrl, port, "browser");
|
|
188
|
+
await verifyIdle(page);
|
|
189
|
+
const current = await singlePage(port);
|
|
190
|
+
const currentVersion = await readJson(port, "version");
|
|
191
|
+
if (current.id !== page.id || current.url !== page.url || current.webSocketDebuggerUrl !== page.webSocketDebuggerUrl ||
|
|
192
|
+
currentVersion.webSocketDebuggerUrl !== socket || browserIdentity(port, profileDir).main !== identity.main) {
|
|
193
|
+
blocked("The browser or conversation changed during handoff verification.");
|
|
194
|
+
}
|
|
195
|
+
await verifyIdle(current);
|
|
196
|
+
await request(socket, "Browser.close");
|
|
197
|
+
const deadline = Date.now() + 10_000;
|
|
198
|
+
while (Date.now() < deadline) {
|
|
199
|
+
if (!identity.pids.some(alive)) {
|
|
200
|
+
try {
|
|
201
|
+
await readJson(port, "version");
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
const cause = error.cause;
|
|
205
|
+
if (cause?.code === "ECONNREFUSED")
|
|
206
|
+
return { url: page.url };
|
|
207
|
+
blocked("The old browser exited, but its control port could not be verified closed.");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
211
|
+
}
|
|
212
|
+
blocked("The dedicated browser did not finish closing. It was not force-killed and no replacement was launched.");
|
|
213
|
+
}
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -702,6 +702,7 @@ export function formatDurationMs(ms) {
|
|
|
702
702
|
export function acceptanceTimeoutError(ctx) {
|
|
703
703
|
const uiLikelyChanged = ctx.composerStillHasText || !ctx.submitButtonFound;
|
|
704
704
|
const took = `${formatDurationMs(ctx.timeoutMs)} (${ctx.timeoutMs}ms)`;
|
|
705
|
+
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
706
|
if (uiLikelyChanged) {
|
|
706
707
|
const detail = [
|
|
707
708
|
ctx.composerStillHasText ? "the composer still holds the prompt" : undefined,
|
|
@@ -709,13 +710,16 @@ export function acceptanceTimeoutError(ctx) {
|
|
|
709
710
|
]
|
|
710
711
|
.filter(Boolean)
|
|
711
712
|
.join(" and ");
|
|
712
|
-
return new
|
|
713
|
-
"
|
|
714
|
-
|
|
715
|
-
|
|
713
|
+
return new ChatGptBrowserBlockerError({
|
|
714
|
+
code: "send_ui_changed", retryable: false,
|
|
715
|
+
message: `Timed out after ${took} without confirming prompt acceptance (${detail}). The ChatGPT web UI may have changed.`,
|
|
716
|
+
next_step: `${inspect} Update prodex (npm i -g @youdie006/prodex@latest); if it persists, report it at https://github.com/youdie006/prodex/issues.`
|
|
717
|
+
});
|
|
716
718
|
}
|
|
717
|
-
return new
|
|
718
|
-
"
|
|
719
|
+
return new ChatGptBrowserBlockerError({
|
|
720
|
+
code: "prompt_acceptance_unconfirmed", retryable: false,
|
|
721
|
+
message: `Timed out after ${took} waiting for ChatGPT to accept the prompt.`, next_step: inspect
|
|
722
|
+
});
|
|
719
723
|
}
|
|
720
724
|
export function hasPartialChatGptAnswer(previousAssistantMessageCount, state) {
|
|
721
725
|
return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer);
|
|
@@ -948,7 +952,7 @@ export function detectChatGptBlocker(text, visibleButtonLabels = []) {
|
|
|
948
952
|
code: "cloudflare_check",
|
|
949
953
|
message: "ChatGPT is showing a Cloudflare or human-verification interstitial.",
|
|
950
954
|
retryable: true,
|
|
951
|
-
next_step: "Complete the visible browser check manually
|
|
955
|
+
next_step: "Complete the visible browser check manually."
|
|
952
956
|
};
|
|
953
957
|
}
|
|
954
958
|
if (hasLikelyChatGptLoginPrompt(haystack)) {
|
|
@@ -956,7 +960,7 @@ export function detectChatGptBlocker(text, visibleButtonLabels = []) {
|
|
|
956
960
|
code: "login_required",
|
|
957
961
|
message: "ChatGPT is asking you to log in.",
|
|
958
962
|
retryable: true,
|
|
959
|
-
next_step: "Log in manually in the visible browser
|
|
963
|
+
next_step: "Log in manually in the visible browser."
|
|
960
964
|
};
|
|
961
965
|
}
|
|
962
966
|
// Match real captcha / human-verification phrasing only. Bare words like "robot"/"로봇"/"자동화"
|
|
@@ -967,7 +971,7 @@ export function detectChatGptBlocker(text, visibleButtonLabels = []) {
|
|
|
967
971
|
code: "captcha_required",
|
|
968
972
|
message: "ChatGPT is asking for captcha or human verification.",
|
|
969
973
|
retryable: true,
|
|
970
|
-
next_step: "Solve it manually in the visible browser
|
|
974
|
+
next_step: "Solve it manually in the visible browser."
|
|
971
975
|
};
|
|
972
976
|
}
|
|
973
977
|
if (/message limit|usage limit|model limit|rate limit|you.?ve reached|try again later|limit resets|사용 한도|메시지 한도|모델 한도|요금 제한|나중에 다시/i.test(haystack)) {
|
|
@@ -983,7 +987,7 @@ export function detectChatGptBlocker(text, visibleButtonLabels = []) {
|
|
|
983
987
|
code: "permission_required",
|
|
984
988
|
message: "ChatGPT requires account verification or permission handling.",
|
|
985
989
|
retryable: true,
|
|
986
|
-
next_step: "Complete the visible account or permission prompt manually
|
|
990
|
+
next_step: "Complete the visible account or permission prompt manually."
|
|
987
991
|
};
|
|
988
992
|
}
|
|
989
993
|
return undefined;
|
|
@@ -992,7 +996,15 @@ export function detectChatGptPageBlocker(state) {
|
|
|
992
996
|
// Blocker scan uses the nav-excluded sample so a sidebar chat title cannot
|
|
993
997
|
// fake a blocker; fall back to the nav-included sample / full text when the
|
|
994
998
|
// scan sample is absent (older callers).
|
|
995
|
-
|
|
999
|
+
const rendered = detectChatGptBlocker(state.blockerScanTextSample ?? state.blockerTextSample ?? state.textSample, state.visibleButtonLabels);
|
|
1000
|
+
if (rendered)
|
|
1001
|
+
return rendered;
|
|
1002
|
+
// The interstitial can have an empty body. Never use a conversation title
|
|
1003
|
+
// alone when the composer exists or its state was not actually checked.
|
|
1004
|
+
if (state.hasComposer === false && /^just a moment\.{0,3}$/i.test(state.title?.trim() ?? "")) {
|
|
1005
|
+
return detectChatGptBlocker("Just a moment", []);
|
|
1006
|
+
}
|
|
1007
|
+
return undefined;
|
|
996
1008
|
}
|
|
997
1009
|
export function inferChatGptPageLoggedInLikely(state) {
|
|
998
1010
|
// The logged-in signals live in the sidebar - "New chat", "Projects", the
|
|
@@ -1026,6 +1038,17 @@ export function chatGptBlockerErrorFromAnswerState(state) {
|
|
|
1026
1038
|
export function chatGptBlockerFromAnswerState(state) {
|
|
1027
1039
|
return detectChatGptPageBlocker(state);
|
|
1028
1040
|
}
|
|
1041
|
+
function submittedRequestBlocker(blocker, requestId, thread) {
|
|
1042
|
+
return {
|
|
1043
|
+
...blocker,
|
|
1044
|
+
retryable: false,
|
|
1045
|
+
...(thread ? { thread } : {}),
|
|
1046
|
+
next_step: `${blocker.next_step ?? "Inspect the visible browser."} Do not automatically resend. ` +
|
|
1047
|
+
(thread
|
|
1048
|
+
? `Collect the original answer with \`prodex pro browser recover --target-url ${thread} --request-id ${requestId}\` after resolving the blocker.`
|
|
1049
|
+
: `Submission is unconfirmed; inspect the original conversation for [prodex-request:${requestId}] before another request.`)
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1029
1052
|
export function computePromptAcceptanceDeadline(timeoutMs, startedAt) {
|
|
1030
1053
|
return startedAt + Math.max(1, timeoutMs);
|
|
1031
1054
|
}
|
|
@@ -3508,6 +3531,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3508
3531
|
let beforeSubmit;
|
|
3509
3532
|
let boundProjectId;
|
|
3510
3533
|
let submitButtonFound = false;
|
|
3534
|
+
let submissionAttempted = false;
|
|
3511
3535
|
const sendWarnings = [...preflightWarnings];
|
|
3512
3536
|
// Anything the page put in front of prodex was answered on the caller's
|
|
3513
3537
|
// behalf. The note is read at return time - there are several return paths -
|
|
@@ -3654,6 +3678,8 @@ export async function sendChatGptPrompt(options) {
|
|
|
3654
3678
|
const last = [...document.querySelectorAll('[data-message-author-role="user"]')].at(-1);
|
|
3655
3679
|
return Boolean(last && (last.innerText || "").includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
|
|
3656
3680
|
})()`;
|
|
3681
|
+
// A dispatch can reach Chrome even if its acknowledgement is lost.
|
|
3682
|
+
submissionAttempted = true;
|
|
3657
3683
|
await cdp.send("Input.dispatchKeyEvent", enterKeyEvent("keyDown"));
|
|
3658
3684
|
await cdp.send("Input.dispatchKeyEvent", enterKeyEvent("keyUp"));
|
|
3659
3685
|
let promptPosted = await waitForExpressionTrue(cdp, promptPostedExpression, 1_500);
|
|
@@ -3677,6 +3703,13 @@ export async function sendChatGptPrompt(options) {
|
|
|
3677
3703
|
await captureOnFailure(`send-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
3678
3704
|
if (isCrashedTabError(error))
|
|
3679
3705
|
throw attachSendWarnings(new ChatGptBrowserBlockerError(crashedTabBlocker(undefined, requestId)), sendWarnings);
|
|
3706
|
+
if (submissionAttempted) {
|
|
3707
|
+
const blocker = error instanceof ChatGptBrowserBlockerError ? error.blocker : {
|
|
3708
|
+
code: "prompt_acceptance_unconfirmed", retryable: false,
|
|
3709
|
+
message: `Browser control failed after submission was attempted: ${error instanceof Error ? error.message : String(error)}`
|
|
3710
|
+
};
|
|
3711
|
+
throw attachSendWarnings(new ChatGptBrowserBlockerError(submittedRequestBlocker(blocker, requestId)), sendWarnings);
|
|
3712
|
+
}
|
|
3680
3713
|
throw attachSendWarnings(error, sendWarnings);
|
|
3681
3714
|
}
|
|
3682
3715
|
finally {
|
|
@@ -3701,7 +3734,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3701
3734
|
}
|
|
3702
3735
|
const runtimeBlocker = chatGptBlockerFromAnswerState(finalState);
|
|
3703
3736
|
if (runtimeBlocker)
|
|
3704
|
-
throw new ChatGptBrowserBlockerError(runtimeBlocker);
|
|
3737
|
+
throw new ChatGptBrowserBlockerError(submittedRequestBlocker(runtimeBlocker, requestId));
|
|
3705
3738
|
dbgSend(`accept-poll url=${finalState.url} user=${finalState.userMessageCount} assistant=${finalState.assistantMessageCount} generating=${finalState.generating}`);
|
|
3706
3739
|
if (requestMatches(finalState)) {
|
|
3707
3740
|
if (normalizedTargetUrl)
|
|
@@ -3714,19 +3747,13 @@ export async function sendChatGptPrompt(options) {
|
|
|
3714
3747
|
emitProgress("waiting", "prompt posting");
|
|
3715
3748
|
}
|
|
3716
3749
|
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.
|
|
3750
|
+
// Missing logged-in signals do not prove session expiry. Only rendered
|
|
3751
|
+
// blockers justify an authentication diagnosis after uncertain submission.
|
|
3721
3752
|
try {
|
|
3722
3753
|
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
|
-
});
|
|
3754
|
+
const blocker = detectChatGptPageBlocker(status);
|
|
3755
|
+
if (blocker) {
|
|
3756
|
+
throw new ChatGptBrowserBlockerError(submittedRequestBlocker(blocker, requestId));
|
|
3730
3757
|
}
|
|
3731
3758
|
}
|
|
3732
3759
|
catch (error) {
|
|
@@ -3734,8 +3761,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3734
3761
|
throw error;
|
|
3735
3762
|
// best effort: a CDP eval failure here falls through to the generic timeout
|
|
3736
3763
|
}
|
|
3737
|
-
//
|
|
3738
|
-
// the send control did not register the prompt — the UI-changed signature.
|
|
3764
|
+
// Composer text is only a UI hint, not proof that submission failed.
|
|
3739
3765
|
let composerStillHasText = false;
|
|
3740
3766
|
try {
|
|
3741
3767
|
const composerState = await evaluateOnPage(page, composerTextStateExpression());
|
|
@@ -3744,7 +3770,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3744
3770
|
catch {
|
|
3745
3771
|
// best effort: fall back to submit-button signal only
|
|
3746
3772
|
}
|
|
3747
|
-
throw acceptanceTimeoutError({ timeoutMs, composerStillHasText, submitButtonFound });
|
|
3773
|
+
throw acceptanceTimeoutError({ timeoutMs, composerStillHasText, submitButtonFound, requestId });
|
|
3748
3774
|
}
|
|
3749
3775
|
// Pin the conversation the prompt actually landed in. The browser is shared
|
|
3750
3776
|
// (other agents, the user, tooling), and a tab that moves mid-wait made
|
|
@@ -3807,7 +3833,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3807
3833
|
}
|
|
3808
3834
|
const runtimeBlocker = chatGptBlockerFromAnswerState(finalState);
|
|
3809
3835
|
if (runtimeBlocker)
|
|
3810
|
-
throw new ChatGptBrowserBlockerError(runtimeBlocker);
|
|
3836
|
+
throw new ChatGptBrowserBlockerError(submittedRequestBlocker(runtimeBlocker, requestId, pinnedThreadUrl));
|
|
3811
3837
|
emitProgress("waiting", finalState.generating ? "generating" : "stabilizing");
|
|
3812
3838
|
if (!hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, finalState))
|
|
3813
3839
|
continue;
|
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] # 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] # 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]
|
|
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]
|
|
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]`
|
|
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]";
|
|
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]";
|
|
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
|
@@ -2,7 +2,7 @@ import { existsSync, realpathSync, statSync } from "node:fs";
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { buildDryRunBundle } from "./bundle.js";
|
|
5
|
-
import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveBrowserWindowMode, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
|
|
5
|
+
import { ChatGptBrowserBlockerError, DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveBrowserWindowMode, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
|
|
6
6
|
import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
|
|
7
7
|
import { printProBrowserHelp, printProHelp } from "./cli-help.js";
|
|
8
8
|
import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
|
|
@@ -206,11 +206,15 @@ 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"]
|
|
209
|
+
booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headed", "--headless", "--minimized", "--virtual-display", "--background"]
|
|
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"]);
|
|
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"]);
|
|
214
|
+
const background = browserArgs.includes("--background");
|
|
215
|
+
if (background && browserArgs.some((arg) => ["--headed", "--headless", "--minimized", "--virtual-display", "--no-wait"].includes(arg))) {
|
|
216
|
+
throw new Error("--background cannot combine with another window mode or --no-wait: it waits for login, then verifies the headless handoff.");
|
|
217
|
+
}
|
|
214
218
|
if (browserArgs.includes("--wait") && browserArgs.includes("--no-wait")) {
|
|
215
219
|
throw new Error("pro browser login cannot combine --wait and --no-wait");
|
|
216
220
|
}
|
|
@@ -231,8 +235,8 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
231
235
|
? resolveBrowserProfileDirForLaunch(savedLaunchForPort.profile_dir)
|
|
232
236
|
: undefined;
|
|
233
237
|
const profileDir = requestedProfileDir ?? savedProfileDir;
|
|
234
|
-
|
|
235
|
-
flags: {
|
|
238
|
+
let windowMode = resolveBrowserWindowMode({
|
|
239
|
+
flags: background ? (savedLaunchForPort?.headless === true ? { headless: true } : { headed: true }) : {
|
|
236
240
|
...(browserArgs.includes("--headed") ? { headed: true } : {}),
|
|
237
241
|
...(browserArgs.includes("--headless") ? { headless: true } : {}),
|
|
238
242
|
...(browserArgs.includes("--virtual-display") ? { virtualDisplay: true } : {}),
|
|
@@ -260,15 +264,21 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
260
264
|
sourceCli,
|
|
261
265
|
commandOptions
|
|
262
266
|
});
|
|
267
|
+
if (background)
|
|
268
|
+
io.stdout("background: verify login, then hand the same profile to headless Chrome under the shared send lock. No prompt is sent.");
|
|
263
269
|
return 0;
|
|
264
270
|
}
|
|
265
271
|
// If the dedicated Chrome is already reachable on this port, do NOT spawn
|
|
266
272
|
// again: Chrome's singleton would just open ANOTHER window (the recurring
|
|
267
273
|
// "extra windows" problem, which then blocks sends as
|
|
268
274
|
// ambiguous_chatgpt_tabs). Reuse the running instance instead.
|
|
275
|
+
const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
|
|
276
|
+
if (background && alreadyRunning) {
|
|
277
|
+
const { getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
|
|
278
|
+
windowMode = { headless: getDedicatedBrowserHeadlessMode({ port, profileDir: profileDir ?? defaultChatGptProfileDir() }), virtualDisplay: false, minimized: false };
|
|
279
|
+
}
|
|
269
280
|
const headless = windowMode.headless;
|
|
270
281
|
const wantsVirtualDisplay = windowMode.virtualDisplay;
|
|
271
|
-
const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
|
|
272
282
|
if (alreadyRunning) {
|
|
273
283
|
if (requestedProfileDir &&
|
|
274
284
|
savedProfileDir &&
|
|
@@ -278,7 +288,7 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
278
288
|
// One Chrome profile cannot serve a headed and a headless instance at
|
|
279
289
|
// once, and reusing the running one would silently ignore the
|
|
280
290
|
// requested mode. Say so instead of pretending the switch took.
|
|
281
|
-
const runningHeadless = savedLaunchForPort ? savedLaunchForPort.headless === true : undefined;
|
|
291
|
+
const runningHeadless = background ? headless : savedLaunchForPort ? savedLaunchForPort.headless === true : undefined;
|
|
282
292
|
if (runningHeadless !== undefined && runningHeadless !== headless) {
|
|
283
293
|
throw new Error(`A ${runningHeadless ? "headless" : "headed"} ChatGPT browser is already running on port ${port}, but ${headless ? "headless" : "headed"} was requested. Close the existing browser yourself, then rerun; prodex will not end it.`);
|
|
284
294
|
}
|
|
@@ -379,6 +389,8 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
379
389
|
});
|
|
380
390
|
if (minimizeNote)
|
|
381
391
|
io.stdout(minimizeNote);
|
|
392
|
+
if (background && !headless)
|
|
393
|
+
io.stdout("background: keep this window open until login is ready; prodex will close the idle dedicated browser and verify the same profile headless.");
|
|
382
394
|
if (headless) {
|
|
383
395
|
// Nobody can sign in to a window that does not exist, so a headless
|
|
384
396
|
// launch is only useful when the profile is already logged in.
|
|
@@ -392,18 +404,22 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
392
404
|
headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
|
|
393
405
|
});
|
|
394
406
|
if (!headlessReady) {
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
// reporting that as "not signed in" sent users to re-login over
|
|
398
|
-
// and over instead of back to a headed window.
|
|
407
|
+
// A challenge is not evidence of a lost login. Report the observed
|
|
408
|
+
// blocker without promising that another login fixes it.
|
|
399
409
|
const finalStatus = await getChatGptBrowserStatus({ port: opened.port, timeoutMs: 5_000 }).catch(() => undefined);
|
|
400
410
|
const challenged = finalStatus?.blocker?.code === "cloudflare_check" || /just a moment/i.test(finalStatus?.title ?? "");
|
|
401
411
|
io.stdout("");
|
|
402
412
|
io.stdout(challenged
|
|
403
|
-
? `headless: Cloudflare challenged
|
|
404
|
-
: `headless:
|
|
413
|
+
? `headless: Cloudflare challenged this headless browser and ChatGPT did not become ready. Run \`${formatHeadedBrowserLoginCommand(sourceCli, commandOptions)}\` for a visible interactive check. This does not prove that the saved login was lost.`
|
|
414
|
+
: `headless: readiness was not confirmed (${finalStatus?.blocker?.code ?? "not_ready"}). This does not prove that the saved login was lost. Inspect the dedicated browser with \`${formatHeadedBrowserLoginCommand(sourceCli, commandOptions)}\`; complete an interactive step only if requested.`);
|
|
405
415
|
return 1;
|
|
406
416
|
}
|
|
417
|
+
if (background) {
|
|
418
|
+
const { getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
|
|
419
|
+
if (!getDedicatedBrowserHeadlessMode({ port: opened.port, profileDir: opened.profileDir }))
|
|
420
|
+
throw new Error("The browser is not actually headless; background readiness was not confirmed.");
|
|
421
|
+
io.stdout("background: READY - the saved login works headless. Later consults reuse this mode.");
|
|
422
|
+
}
|
|
407
423
|
io.stdout("headless: signed-in session confirmed - consults will run with no visible window.");
|
|
408
424
|
return 0;
|
|
409
425
|
}
|
|
@@ -411,7 +427,7 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
411
427
|
// state instead of returning while login is still unfinished. Scripts
|
|
412
428
|
// and agents (non-TTY) keep the immediate return unless --wait is
|
|
413
429
|
// passed; --no-wait always skips.
|
|
414
|
-
const shouldWaitForReady = !browserArgs.includes("--no-wait") && (browserArgs.includes("--wait") || io.isInteractive === true);
|
|
430
|
+
const shouldWaitForReady = background || (!browserArgs.includes("--no-wait") && (browserArgs.includes("--wait") || io.isInteractive === true));
|
|
415
431
|
if (!shouldWaitForReady)
|
|
416
432
|
return 0;
|
|
417
433
|
const waitTimeoutMs = readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 300_000;
|
|
@@ -421,6 +437,14 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
421
437
|
windowMode,
|
|
422
438
|
headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
|
|
423
439
|
});
|
|
440
|
+
if (ready && background) {
|
|
441
|
+
return completeBackgroundBrowserLogin(io, {
|
|
442
|
+
port: opened.port,
|
|
443
|
+
profileDir: opened.profileDir,
|
|
444
|
+
timeoutMs: readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 30_000,
|
|
445
|
+
headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
|
|
446
|
+
});
|
|
447
|
+
}
|
|
424
448
|
return ready ? 0 : 1;
|
|
425
449
|
}
|
|
426
450
|
if (browserSubcommand === "ask") {
|
|
@@ -1466,10 +1490,11 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1466
1490
|
catch (error) {
|
|
1467
1491
|
const firstBlocker = browserSendBlockerFromError(error);
|
|
1468
1492
|
// Loss while reading an already-submitted prompt must not send it again.
|
|
1469
|
-
if (firstBlocker.code
|
|
1493
|
+
if (!["browser_unreachable", "chatgpt_page_missing"].includes(firstBlocker.code) ||
|
|
1470
1494
|
!firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed)
|
|
1471
1495
|
throw error;
|
|
1472
|
-
const
|
|
1496
|
+
const recover = firstBlocker.code === "chatgpt_page_missing" ? attemptMissingChatGptTabRecovery : attemptBrowserAutoRecovery;
|
|
1497
|
+
const recovered = await recover(io.stderr, {
|
|
1473
1498
|
...(browserPort !== undefined ? { port: browserPort } : {}),
|
|
1474
1499
|
notes: recoveryNotes
|
|
1475
1500
|
});
|
|
@@ -2005,6 +2030,41 @@ export function browserRecoveredNote(ended) {
|
|
|
2005
2030
|
? `browser_recovered: the dedicated browser stopped answering its control port and prodex ended it (pid ${ended.join(", ")}) and started a fresh one before sending. Anything it was doing at the time is gone; the profile and login were kept.`
|
|
2006
2031
|
: "browser_recovered: the dedicated browser was not running, so prodex started it with the saved profile before sending. The login was kept; anything the old browser had open is gone.";
|
|
2007
2032
|
}
|
|
2033
|
+
async function attemptMissingChatGptTabRecovery(stderr, options) {
|
|
2034
|
+
const port = resolveCdpPort(options.port);
|
|
2035
|
+
return withBrowserSendLock(30_000, (detail) => stderr(`recover: ${detail}`), async () => {
|
|
2036
|
+
const saved = await readLastBrowserLoginLaunch();
|
|
2037
|
+
if (saved?.port !== undefined && saved.port !== port) {
|
|
2038
|
+
throw new Error("Saved browser identity belongs to a different port; no tab was opened.");
|
|
2039
|
+
}
|
|
2040
|
+
let status = await getChatGptBrowserStatus({ port, timeoutMs: 2_000 });
|
|
2041
|
+
if (status.loggedInLikely && status.hasComposer && !status.blocker)
|
|
2042
|
+
return true;
|
|
2043
|
+
if (!status.reachable || status.blocker?.code !== "chatgpt_page_missing") {
|
|
2044
|
+
if (status.blocker)
|
|
2045
|
+
throw new ChatGptBrowserBlockerError(status.blocker);
|
|
2046
|
+
return false;
|
|
2047
|
+
}
|
|
2048
|
+
stderr("recover: the browser is running but its ChatGPT tab was closed; opening one tab and checking the saved login...");
|
|
2049
|
+
if (!await openChatGptTab(port))
|
|
2050
|
+
return false;
|
|
2051
|
+
const deadline = Date.now() + 30_000;
|
|
2052
|
+
while (Date.now() < deadline) {
|
|
2053
|
+
status = await getChatGptBrowserStatus({ port, timeoutMs: 2_000 });
|
|
2054
|
+
if (status.loggedInLikely && status.hasComposer && !status.blocker) {
|
|
2055
|
+
options.notes?.push("browser_tab_reopened: reopened the closed ChatGPT tab in the running browser and verified the saved login before sending. No previous prompt was resent.");
|
|
2056
|
+
return true;
|
|
2057
|
+
}
|
|
2058
|
+
if (status.blocker && !["login_required", "chatgpt_page_missing", "composer_not_ready", "browser_slow"].includes(status.blocker.code)) {
|
|
2059
|
+
throw new ChatGptBrowserBlockerError(status.blocker);
|
|
2060
|
+
}
|
|
2061
|
+
await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
|
|
2062
|
+
}
|
|
2063
|
+
if (status.blocker)
|
|
2064
|
+
throw new ChatGptBrowserBlockerError(status.blocker);
|
|
2065
|
+
return false;
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2008
2068
|
export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
2009
2069
|
// Launching is right when the browser is gone and wrong when it is only deaf:
|
|
2010
2070
|
// a second Chrome on the same profile joins the wedged one rather than
|
|
@@ -2131,6 +2191,35 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
|
2131
2191
|
return false;
|
|
2132
2192
|
}
|
|
2133
2193
|
}
|
|
2194
|
+
async function completeBackgroundBrowserLogin(io, options) {
|
|
2195
|
+
return withBrowserSendLock(5_000, (detail) => io.stderr(`background: ${detail}`), async () => {
|
|
2196
|
+
const { closeIdleChatGptBrowserForHandoff, getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
|
|
2197
|
+
io.stderr("background: checking the dedicated browser before graceful handoff...");
|
|
2198
|
+
const { url } = await closeIdleChatGptBrowserForHandoff({ port: options.port, profileDir: options.profileDir });
|
|
2199
|
+
const opened = openChatGptBrowser({ port: options.port, profileDir: options.profileDir, headless: true, url });
|
|
2200
|
+
await assertBrowserLaunchStayedAlive(opened);
|
|
2201
|
+
// This records the actual process, not a claim that authentication succeeded.
|
|
2202
|
+
const actualHeadless = getDedicatedBrowserHeadlessMode({ port: opened.port, profileDir: opened.profileDir });
|
|
2203
|
+
await recordBrowserLoginLaunch({ port: opened.port, profile_dir: opened.profileDir, headless: actualHeadless, minimized: false });
|
|
2204
|
+
if (!actualHeadless)
|
|
2205
|
+
throw new Error("The replacement browser is not actually headless; background readiness was not confirmed.");
|
|
2206
|
+
const ready = await waitForChatGptLoginReady(io.stderr, {
|
|
2207
|
+
port: opened.port,
|
|
2208
|
+
timeoutMs: options.timeoutMs,
|
|
2209
|
+
windowMode: { headless: true, virtualDisplay: false, minimized: false },
|
|
2210
|
+
headedLoginCommand: options.headedLoginCommand
|
|
2211
|
+
});
|
|
2212
|
+
if (!ready) {
|
|
2213
|
+
const status = await getChatGptBrowserStatus({ port: opened.port, timeoutMs: 5_000 }).catch(() => undefined);
|
|
2214
|
+
io.stderr(`background: NOT READY (${status?.blocker?.code ?? "not_ready"}). The same profile was kept; this is not proof that your saved login was lost. No prompt was sent.`);
|
|
2215
|
+
return 1;
|
|
2216
|
+
}
|
|
2217
|
+
if (!getDedicatedBrowserHeadlessMode({ port: opened.port, profileDir: opened.profileDir }))
|
|
2218
|
+
throw new Error("The replacement browser is not actually headless; background readiness was not confirmed.");
|
|
2219
|
+
io.stdout("background: READY - the saved login works headless, with no visible window. You may close this terminal; later consults reuse this mode.");
|
|
2220
|
+
return 0;
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2134
2223
|
/**
|
|
2135
2224
|
* Guided login: poll the visible browser until a logged-in ChatGPT tab with a
|
|
2136
2225
|
* usable composer appears, narrating each state change so the user knows what
|
|
@@ -2145,15 +2234,16 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
|
|
|
2145
2234
|
const pollMs = options.pollMs ?? 2_000;
|
|
2146
2235
|
const hasInteractiveWindow = options.windowMode?.headless !== true && options.windowMode?.virtualDisplay !== true;
|
|
2147
2236
|
const headedLoginCommand = options.headedLoginCommand ?? "prodex pro browser login --headed";
|
|
2148
|
-
const headedLoginHint = ` No interactive window is available; run \`${headedLoginCommand}\` to complete login, captcha, or human verification visibly.`;
|
|
2149
2237
|
const startedAt = now();
|
|
2150
2238
|
stderr(hasInteractiveWindow
|
|
2151
|
-
? "login: waiting for
|
|
2152
|
-
: `login: waiting for
|
|
2239
|
+
? "login: waiting for ChatGPT readiness in the dedicated Chrome browser (complete any visible step it requests; Ctrl+C stops waiting)..."
|
|
2240
|
+
: `login: waiting for ChatGPT readiness (no interactive window; use \`${headedLoginCommand}\` to inspect any requested browser step visibly; Ctrl+C stops waiting)...`);
|
|
2153
2241
|
let lastState = "";
|
|
2242
|
+
let lastStatus;
|
|
2154
2243
|
let openMissingTabAttempts = 0;
|
|
2155
2244
|
while (now() - startedAt < timeoutMs) {
|
|
2156
2245
|
const status = await statusFn({ port: options.port, timeoutMs: 1_500 });
|
|
2246
|
+
lastStatus = status;
|
|
2157
2247
|
// A running Chrome with no chatgpt.com tab leaves the user nothing to log
|
|
2158
2248
|
// into, so prodex opens one. Keep trying while the tab is still missing:
|
|
2159
2249
|
// one silent attempt that fails looks exactly like no attempt, which is how
|
|
@@ -2174,18 +2264,27 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
|
|
|
2174
2264
|
await sleepFn(pollMs);
|
|
2175
2265
|
continue;
|
|
2176
2266
|
}
|
|
2267
|
+
const blocker = status.blocker;
|
|
2268
|
+
const blockerNextStep = blocker?.next_step ? ` Next: ${blocker.next_step}` : "";
|
|
2269
|
+
const needsVisibleAuth = blocker !== undefined &&
|
|
2270
|
+
["login_required", "cloudflare_check", "captcha_required", "permission_required"].includes(blocker.code);
|
|
2271
|
+
if (!hasInteractiveWindow && needsVisibleAuth) {
|
|
2272
|
+
stderr(`login: blocked - ${blocker.message}${blockerNextStep}`);
|
|
2273
|
+
stderr(`login: NOT READY - ${blocker.code} requires visible manual handling. No interactive window is available; run \`${headedLoginCommand}\` to handle it visibly.`);
|
|
2274
|
+
return false;
|
|
2275
|
+
}
|
|
2177
2276
|
const state = !status.reachable
|
|
2178
2277
|
? "login: browser starting..."
|
|
2179
2278
|
: status.blocker
|
|
2180
|
-
? `login: blocked - ${status.blocker.message}${
|
|
2279
|
+
? `login: blocked - ${status.blocker.message}${blockerNextStep}`
|
|
2181
2280
|
: !status.loggedInLikely
|
|
2182
2281
|
? hasInteractiveWindow
|
|
2183
|
-
? "login:
|
|
2184
|
-
: `login:
|
|
2282
|
+
? "login: ChatGPT is reachable, but login readiness is not yet confirmed; review any visible browser prompt..."
|
|
2283
|
+
: `login: ChatGPT is reachable, but login readiness is not yet confirmed; inspect it visibly with \`${headedLoginCommand}\` if needed.`
|
|
2185
2284
|
: !status.hasComposer
|
|
2186
2285
|
? hasInteractiveWindow
|
|
2187
|
-
? "login:
|
|
2188
|
-
: `login:
|
|
2286
|
+
? "login: login looks active; open a chat so the prompt composer is visible..."
|
|
2287
|
+
: `login: login looks active, but no prompt composer is ready; inspect it visibly with \`${headedLoginCommand}\`.`
|
|
2189
2288
|
: "";
|
|
2190
2289
|
if (state === "") {
|
|
2191
2290
|
stderr(`login: READY - logged-in ChatGPT tab with composer detected (${Math.round((now() - startedAt) / 1000)}s).`);
|
|
@@ -2200,9 +2299,26 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
|
|
|
2200
2299
|
break;
|
|
2201
2300
|
await sleepFn(Math.min(pollMs, Math.max(1, remainingMs)));
|
|
2202
2301
|
}
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2302
|
+
const timeoutPrefix = `login: not ready after ${Math.round(timeoutMs / 1000)}s`;
|
|
2303
|
+
if (lastStatus?.blocker) {
|
|
2304
|
+
const nextStep = lastStatus.blocker.next_step ? ` Next: ${lastStatus.blocker.next_step}` : "";
|
|
2305
|
+
stderr(`${timeoutPrefix}; last blocker ${lastStatus.blocker.code}: ${lastStatus.blocker.message}${nextStep}`);
|
|
2306
|
+
}
|
|
2307
|
+
else if (!lastStatus?.reachable) {
|
|
2308
|
+
stderr(hasInteractiveWindow
|
|
2309
|
+
? `${timeoutPrefix}; browser startup was not confirmed. Check the dedicated Chrome browser, then verify with \`prodex pro browser check\`.`
|
|
2310
|
+
: `${timeoutPrefix}; browser startup was not confirmed. Inspect it visibly with \`${headedLoginCommand}\`, then retry.`);
|
|
2311
|
+
}
|
|
2312
|
+
else if (lastStatus.loggedInLikely && !lastStatus.hasComposer) {
|
|
2313
|
+
stderr(hasInteractiveWindow
|
|
2314
|
+
? `${timeoutPrefix}; login looked active, but the prompt composer was not detected. Open a normal ChatGPT chat or Project thread, then verify with \`prodex pro browser check\`.`
|
|
2315
|
+
: `${timeoutPrefix}; login looked active, but the prompt composer was not detected. Inspect it visibly with \`${headedLoginCommand}\`, then retry.`);
|
|
2316
|
+
}
|
|
2317
|
+
else {
|
|
2318
|
+
stderr(hasInteractiveWindow
|
|
2319
|
+
? `${timeoutPrefix}; ChatGPT was reachable, but login readiness was not yet confirmed. Review the dedicated Chrome browser, then verify with \`prodex pro browser check\`.`
|
|
2320
|
+
: `${timeoutPrefix}; ChatGPT was reachable, but login readiness was not yet confirmed. Inspect it visibly with \`${headedLoginCommand}\`; complete a manual step only if requested.`);
|
|
2321
|
+
}
|
|
2206
2322
|
return false;
|
|
2207
2323
|
}
|
|
2208
2324
|
export async function assertBrowserLaunchStayedAlive(opened, timeoutMs) {
|
|
@@ -2864,7 +2980,8 @@ export function printBrowserLoginGuide(stdout, input) {
|
|
|
2864
2980
|
stdout(`Profile: ${input.profileDir}`);
|
|
2865
2981
|
stdout(`Debug: http://127.0.0.1:${input.port}`);
|
|
2866
2982
|
if (windowAvailable) {
|
|
2867
|
-
stdout("
|
|
2983
|
+
stdout("Closing this Chrome window does not switch it to headless mode. Keep it open for headed consults; closing the terminal is different from closing the browser.");
|
|
2984
|
+
stdout(`For a one-time login followed by a verified no-window handoff, run \`${loginCommand} --background\`. The same profile is reused; a closed tab alone does not mean you logged out.`);
|
|
2868
2985
|
}
|
|
2869
2986
|
else {
|
|
2870
2987
|
stdout("The dedicated profile path above will be reused by the real login command.");
|
|
@@ -3060,7 +3177,7 @@ export function assertNoOrphanConsultResults(tasksById, results) {
|
|
|
3060
3177
|
}
|
|
3061
3178
|
export function browserReadinessNextStep(input) {
|
|
3062
3179
|
if (!input.loggedInLikely) {
|
|
3063
|
-
return "
|
|
3180
|
+
return "ChatGPT login readiness is not yet confirmed. Review the visible ChatGPT browser state, then retry.";
|
|
3064
3181
|
}
|
|
3065
3182
|
if (!input.hasComposer) {
|
|
3066
3183
|
return "Open a normal ChatGPT chat or Project thread, select the Pro/Thinking model, and retry.";
|
package/docs/cli-reference.md
CHANGED
|
@@ -118,7 +118,13 @@ What happens:
|
|
|
118
118
|
~/.local/share/prodex/chrome-chatgpt-pro
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
Closing that Chrome window does not switch to headless mode or prove that the saved login was erased. Keep it open for headed consults. Closing the terminal after READY is safe because Chrome is detached; closing the CLI or agent during a consult may interrupt answer collection.
|
|
122
|
+
|
|
123
|
+
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
|
+
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
|
+
|
|
127
|
+
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.
|
|
122
128
|
|
|
123
129
|
Actual explicit visible-browser consult (`prodex ask` is the short form of `prodex pro browser ask`):
|
|
124
130
|
|
|
@@ -200,18 +206,18 @@ A browser already running on your desktop cannot be moved onto a virtual display
|
|
|
200
206
|
|
|
201
207
|
### Keeping the window, just out of the way
|
|
202
208
|
|
|
203
|
-
`prodex pro browser login --minimized` (or `PRODEX_MINIMIZE_WINDOW=1`) launches the dedicated browser and then minimizes it. It stays a **real headed Chrome
|
|
209
|
+
`prodex pro browser login --minimized` (or `PRODEX_MINIMIZE_WINDOW=1`) launches the dedicated browser and then minimizes it. It stays a **real headed Chrome**, which worked in the previous test where headless Chrome encountered a challenge. This is not a guarantee that future verification checks will pass.
|
|
204
210
|
|
|
205
211
|
The catch is what "minimized" means to your desktop. Under WSLg a minimized Chrome still reports `visibilityState: "visible"`, so consults keep working (measured: a real Pro send completed in 26s with the window minimized). A normal Linux desktop instead marks minimized windows hidden, and prodex refuses to send into a tab it cannot read — so it restores the window and tells you, rather than leaving you a browser it cannot use. Try it; the login says which case you are in.
|
|
206
212
|
|
|
207
|
-
### Headless mode (
|
|
213
|
+
### Headless mode (requires live readiness verification)
|
|
208
214
|
|
|
209
215
|
`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:
|
|
210
216
|
|
|
211
217
|
- **Sign in headed first.** Nobody can log in to a window that does not exist, so headless reuses a profile you already signed into. If login, captcha, Cloudflare, permission, or account verification is needed, close the hidden browser yourself and run `prodex pro browser login --headed` for a visible interactive window. Do not merely omit `--headless`: saved modes persist.
|
|
212
|
-
- **One mode at a time.** A single Chrome profile cannot serve a headed and a headless instance simultaneously
|
|
218
|
+
- **One mode at a time.** A single Chrome profile cannot serve a headed and a headless instance simultaneously. Use the guarded `--background` handoff for headed-to-headless operation, or close the browser yourself before an explicit mode switch.
|
|
213
219
|
|
|
214
|
-
**
|
|
220
|
+
**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.
|
|
215
221
|
|
|
216
222
|
If a consult finds the browser closed, prodex now relaunches it in the same mode you last used and retries once — including from the MCP server, which has no terminal to prompt in. `PRODEX_NO_AUTO_LOGIN=1` turns that off.
|
|
217
223
|
|
package/docs/releasing.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
How a version of `@youdie006/prodex` gets from `main` to npm, and the checks that guard it. Moved here from the README so the README can stay about using the tool.
|
|
4
4
|
|
|
5
|
+
## 0.40.13 Verification Record
|
|
6
|
+
|
|
7
|
+
Release target: `v0.40.13` on `main`. The GitHub Release records the resolved commit, publication workflow and final installation checks; an installed candidate is not proof of public publication or a restarted MCP process.
|
|
8
|
+
|
|
9
|
+
- PASS: `npm run release:verify` after updating the obsolete window-closing assertions. This ran the full tests, typecheck, build, installed-package CLI/HTTP-MCP/stdio-MCP smoke and doctor.
|
|
10
|
+
- PASS: `npx vitest run tests/browser-handoff.test.ts tests/background-login.test.ts --maxWorkers=2`, 30 tests. Targeted regressions first failed for stale mode, incognito/guest, explicit Chrome sub-profile and incorrect launch-mode recording, then passed with the fixes.
|
|
11
|
+
- PASS: Node 20 imports the built handoff module; background dry-run works without opening a browser. This is not a Node 20 live Pro test.
|
|
12
|
+
- PASS: M3 candidate CLI without tmux refused the existing Chrome account confirmation, returned exit 1, did not report READY, and preserved the same browser PIDs and page targets. Runtime modules matched the tested local build by SHA-256.
|
|
13
|
+
- BLOCKED: M3's native account confirmation requires manual action before the actual close/relaunch can be tested. Not every OS dialog is exposed through CDP; native confirmations must be finished before requesting a handoff.
|
|
14
|
+
- BLOCKED: local WSL headless startup with the saved profile reached `cloudflare_check`, not READY. No Pro request was sent and no protective check was bypassed.
|
|
15
|
+
- Earlier full checks failed on two outdated CLI guidance assertions and then one installed-document assertion. Those expectations were corrected; the subsequent complete verification passed. WSL packaging used normalized staging because mount file modes are not publishable directly; the user's unrelated untracked file was left untouched.
|
|
16
|
+
|
|
17
|
+
The release adds a guarded, opt-in transition and more accurate blocker reporting. It does not claim that headless Pro consultation is verified on either deployment target or that authentication can be retained indefinitely.
|
|
18
|
+
|
|
5
19
|
## Publishing
|
|
6
20
|
|
|
7
21
|
Publishing to npm runs entirely in CI with **no long-lived token** — auth is npm [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC), so nothing needs to store or paste an `NPM_TOKEN`, and every release carries a verifiable `--provenance` attestation.
|