@youdie006/prodex 0.17.0 → 0.19.0
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 +24 -3
- package/dist/chatgpt-browser.js +217 -10
- package/dist/cli-help.js +4 -4
- package/dist/cli-pro.js +105 -13
- package/dist/cli.js +6 -2
- package/dist/schema.js +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ prodex ask --file src/auth.ts "Review this for security holes"
|
|
|
35
35
|
|
|
36
36
|
`prodex ask` is the short form of `prodex pro browser ask`; the full form and every flag work identically. In an interactive terminal, `login` keeps watching the opened window and tells you exactly which manual step is still missing (log in, clear a check, open a chat) until it reports READY. If you skip `login` and the browser is not running, an interactive `ask` recovers on its own: it launches the dedicated browser, waits for your saved session to be READY, and retries the send once (disable with `--no-auto-login`; scripts opt in with `--auto-login`). While ChatGPT thinks, `prodex` prints progress to stderr (connecting, prompt sent, elapsed seconds while generating), so a multi-minute Pro answer never looks frozen.
|
|
37
37
|
|
|
38
|
-
The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and
|
|
38
|
+
The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and will not send into a tab it cannot read, so leave the dedicated window on a ChatGPT tab; it sends quietly in the background without stealing focus. Prefer no window at all? See [virtual display](#no-window-at-all-virtual-display-recommended). Pin per-repo defaults once - `prodex setup --model Pro --project "your-project"` - so every ask runs Pro (20-minute timeout) inside that project instead of whatever the ChatGPT UI last had selected; list exact sidebar project names with `prodex pro browser projects`. Pass `--file` more than once to attach several files. When the thread is still generating a previous answer (common right after a timed-out Pro send), the send automatically queues behind it up to the timeout budget; tune that with `--busy-wait-ms` (0 fails fast with a `response_in_progress` blocker). See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
|
|
39
39
|
|
|
40
40
|
## Core Shape
|
|
41
41
|
|
|
@@ -256,14 +256,35 @@ prodex setup --interactive # asks model / Pro sub-mode or effort / project
|
|
|
256
256
|
|
|
257
257
|
The saved default above lives in the repo's `.bridge/config.local.json`, so it only applies when `prodex` runs from that repo. A coding agent often starts the MCP as `prodex mcp` with no `--cwd` (it reads whatever directory the agent launched in), so a per-repo default is missed and consults land in the general chat. For a default that applies from **any** directory, set environment variables instead — `PRODEX_DEFAULT_PROJECT` and `PRODEX_DEFAULT_MODEL` (also `PRODEX_DEFAULT_PRO_MODE`, `PRODEX_DEFAULT_EFFORT`) — in the agent's MCP `env` block or your shell. Use your own project name (list them with `prodex pro browser projects`); with no project set, consults simply go to the general chat. A per-repo config still wins field-by-field over the env fallback.
|
|
258
258
|
|
|
259
|
-
### No window:
|
|
259
|
+
### No window at all: virtual display (recommended)
|
|
260
|
+
|
|
261
|
+
Log in once, then never see the browser again:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
prodex pro browser login # once, headed - sign in
|
|
265
|
+
prodex pro browser login --virtual-display # from now on: no window anywhere
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`--virtual-display` (or `PRODEX_VIRTUAL_DISPLAY=1`, which also covers the MCP server and its auto-recovery) starts an X virtual framebuffer and runs the dedicated Chrome on it. It is a **real headed browser**, so Cloudflare treats it as an ordinary one — measured end to end: the signed-in profile loaded chatgpt.com with no challenge and a real Pro send returned in 31 seconds, with nothing on the desktop and nothing in the taskbar. Headless, by contrast, never gets past Cloudflare at all (see below).
|
|
269
|
+
|
|
270
|
+
Requires `Xvfb` and `xauth` (`sudo apt install -y xvfb x11-xkb-utils xauth`); prodex names the package if they are missing. Linux and WSL only. The display is served over loopback TCP because WSLg mounts `/tmp/.X11-unix` read-only, and it is protected by a per-display xauth cookie under `~/.local/share/prodex/xvfb/` — never `-ac`, so no other process can watch your signed-in window. The X server outlives the CLI on purpose (the browser runs on it) and is reused by later commands; `PRODEX_VIRTUAL_DISPLAY_NUM` picks the display number if `:99` is taken.
|
|
271
|
+
|
|
272
|
+
A browser already running on your desktop cannot be moved onto a virtual display by reusing it, so prodex refuses the switch and tells you to close it first (`pkill -f "remote-debugging-port=9333"`).
|
|
273
|
+
|
|
274
|
+
### Keeping the window, just out of the way
|
|
275
|
+
|
|
276
|
+
`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 is the point, because Cloudflare admits headed browsers and rejects headless ones — but nothing sits on your desktop.
|
|
277
|
+
|
|
278
|
+
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.
|
|
279
|
+
|
|
280
|
+
### Headless mode (not usable against ChatGPT today)
|
|
260
281
|
|
|
261
282
|
`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:
|
|
262
283
|
|
|
263
284
|
- **Sign in headed first.** Nobody can log in to a window that does not exist, so headless reuses a profile you already signed into. The headless login verifies the saved session and tells you to run the headed login once if it is not there.
|
|
264
285
|
- **One mode at a time.** A single Chrome profile cannot serve a headed and a headless instance simultaneously; close the running one before switching (prodex refuses the switch instead of silently reusing the wrong mode).
|
|
265
286
|
|
|
266
|
-
Cloudflare
|
|
287
|
+
**Cloudflare is the catch, and it is not theoretical.** Measured on a real signed-in profile: headless Chrome lands on the "Just a moment..." interstitial and stays there past 60 seconds, so ChatGPT never loads. A signed-in profile does not buy a pass — the challenge keys on the headless browser itself. Treat `--headless` as available-but-unproven against ChatGPT: try it, and if `prodex pro browser check` reports the challenge, run headed. Only the window is optional; the login is not.
|
|
267
288
|
|
|
268
289
|
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.
|
|
269
290
|
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
import { accessSync, constants, statSync } from "node:fs";
|
|
4
|
+
import net from "node:net";
|
|
3
5
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
6
|
import path from "node:path";
|
|
5
7
|
import os from "node:os";
|
|
@@ -168,7 +170,11 @@ export async function readLastBrowserLoginLaunch() {
|
|
|
168
170
|
return {
|
|
169
171
|
...(typeof parsed.profile_dir === "string" && parsed.profile_dir.length > 0 ? { profile_dir: parsed.profile_dir } : {}),
|
|
170
172
|
...(typeof parsed.port === "number" && Number.isInteger(parsed.port) ? { port: parsed.port } : {}),
|
|
171
|
-
...(typeof parsed.headless === "boolean" ? { headless: parsed.headless } : {})
|
|
173
|
+
...(typeof parsed.headless === "boolean" ? { headless: parsed.headless } : {}),
|
|
174
|
+
...(typeof parsed.minimized === "boolean" ? { minimized: parsed.minimized } : {}),
|
|
175
|
+
...(typeof parsed.virtual_display === "number" && Number.isInteger(parsed.virtual_display)
|
|
176
|
+
? { virtual_display: parsed.virtual_display }
|
|
177
|
+
: {})
|
|
172
178
|
};
|
|
173
179
|
}
|
|
174
180
|
catch {
|
|
@@ -187,6 +193,14 @@ export function buildChromeLaunchArgs(options) {
|
|
|
187
193
|
`--user-data-dir=${options.profileDir}`,
|
|
188
194
|
"--no-first-run",
|
|
189
195
|
"--no-default-browser-check",
|
|
196
|
+
// Keep the renderer running at full speed when the window is not on top.
|
|
197
|
+
// Chrome backgrounds occluded and minimized windows, and prodex's own
|
|
198
|
+
// advice is to leave the dedicated window behind your editor (or
|
|
199
|
+
// minimized) - a throttled renderer makes ChatGPT stream slowly or stall,
|
|
200
|
+
// which reaches the user as an unexplained send timeout.
|
|
201
|
+
"--disable-backgrounding-occluded-windows",
|
|
202
|
+
"--disable-renderer-backgrounding",
|
|
203
|
+
"--disable-background-timer-throttling",
|
|
190
204
|
...(options.headless ? ["--headless=new", `--window-size=${HEADLESS_WINDOW_SIZE}`] : ["--new-window"]),
|
|
191
205
|
options.url
|
|
192
206
|
];
|
|
@@ -196,6 +210,159 @@ export function buildChromeLaunchArgs(options) {
|
|
|
196
210
|
* (1/true/yes) decides. The env var is the practical switch because the MCP
|
|
197
211
|
* server and its auto-recovery launch the browser with no CLI flags.
|
|
198
212
|
*/
|
|
213
|
+
/**
|
|
214
|
+
* Minimize the dedicated browser window and report whether the tab is still
|
|
215
|
+
* readable afterwards.
|
|
216
|
+
*
|
|
217
|
+
* Measured live under WSLg: a minimized Chrome keeps reporting
|
|
218
|
+
* visibilityState "visible", so the window can be out of the way while the
|
|
219
|
+
* send path (which refuses hidden tabs) still works - and unlike headless,
|
|
220
|
+
* it is a real headed browser, which is what Cloudflare admits. A normal
|
|
221
|
+
* Linux desktop instead marks minimized windows hidden; the caller restores
|
|
222
|
+
* the window in that case rather than leaving a browser prodex cannot use.
|
|
223
|
+
*/
|
|
224
|
+
export async function minimizeChatGptWindow(options = {}) {
|
|
225
|
+
const port = resolveCdpPort(options.port);
|
|
226
|
+
const found = await findChatGptPage(port, options.timeoutMs ?? 5_000);
|
|
227
|
+
if (!found.ok || !found.page)
|
|
228
|
+
throw new ChatGptBrowserBlockerError(found.blocker ?? chatGptPageMissingBlocker());
|
|
229
|
+
const cdp = await connectCdp(found.page.webSocketDebuggerUrl, options.timeoutMs);
|
|
230
|
+
try {
|
|
231
|
+
const window = await cdp.send("Browser.getWindowForTarget");
|
|
232
|
+
const windowId = window.result?.windowId;
|
|
233
|
+
if (windowId === undefined)
|
|
234
|
+
return { minimized: false, visibilityState: "unknown" };
|
|
235
|
+
await cdp.send("Browser.setWindowBounds", { windowId, bounds: { windowState: "minimized" } });
|
|
236
|
+
await sleep(1_500);
|
|
237
|
+
const state = await cdp.evaluate("document.visibilityState");
|
|
238
|
+
if (state === "visible")
|
|
239
|
+
return { minimized: true, visibilityState: state };
|
|
240
|
+
// Hidden means the send path would refuse this browser - undo it.
|
|
241
|
+
await cdp.send("Browser.setWindowBounds", { windowId, bounds: { windowState: "normal" } });
|
|
242
|
+
return { minimized: false, visibilityState: state ?? "unknown" };
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
cdp.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Virtual display (Xvfb)
|
|
250
|
+
//
|
|
251
|
+
// The way to run with NO window that ChatGPT actually accepts. Headless Chrome
|
|
252
|
+
// is rejected by Cloudflare by design (their docs list it as unsupported, and
|
|
253
|
+
// a live test sat on "Just a moment..." past 60s even with a signed-in
|
|
254
|
+
// profile). A real headed Chrome on a virtual display is an ordinary browser
|
|
255
|
+
// to Cloudflare - verified live: a cookie-less profile loaded chatgpt.com with
|
|
256
|
+
// no challenge - while nothing appears on the desktop.
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
const VIRTUAL_DISPLAY_SCREEN = "1440x900x24";
|
|
259
|
+
/**
|
|
260
|
+
* X server arguments. The display is served over loopback TCP because WSLg
|
|
261
|
+
* mounts /tmp/.X11-unix read-only, so the usual unix socket cannot be created;
|
|
262
|
+
* an xauth cookie (never -ac) keeps other local processes off a display that
|
|
263
|
+
* shows a signed-in ChatGPT window.
|
|
264
|
+
*/
|
|
265
|
+
export function virtualDisplayServerArgs(displayNumber, xauthority) {
|
|
266
|
+
return [
|
|
267
|
+
`:${displayNumber}`,
|
|
268
|
+
"-screen",
|
|
269
|
+
"0",
|
|
270
|
+
VIRTUAL_DISPLAY_SCREEN,
|
|
271
|
+
"-listen",
|
|
272
|
+
"tcp",
|
|
273
|
+
"-nolisten",
|
|
274
|
+
"unix",
|
|
275
|
+
"-auth",
|
|
276
|
+
xauthority
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
export function virtualDisplayEnv(displayNumber, xauthority, env = process.env) {
|
|
280
|
+
return { ...env, DISPLAY: `127.0.0.1:${displayNumber}`, XAUTHORITY: xauthority };
|
|
281
|
+
}
|
|
282
|
+
export function resolveVirtualDisplayPreference(explicit, env = process.env) {
|
|
283
|
+
if (typeof explicit === "boolean")
|
|
284
|
+
return explicit;
|
|
285
|
+
const raw = (env.PRODEX_VIRTUAL_DISPLAY ?? "").trim().toLowerCase();
|
|
286
|
+
return raw === "1" || raw === "true" || raw === "yes";
|
|
287
|
+
}
|
|
288
|
+
export function assertVirtualDisplayToolingAvailable(hasCommand = isCommandOnPath) {
|
|
289
|
+
const missing = ["Xvfb", "xauth"].filter((command) => !hasCommand(command));
|
|
290
|
+
if (missing.length === 0)
|
|
291
|
+
return;
|
|
292
|
+
throw new Error(`Virtual display needs ${missing.join(" and ")}, which ${missing.length > 1 ? "are" : "is"} not installed. Install with \`sudo apt install -y xvfb x11-xkb-utils xauth\` (or your distro's equivalent), then retry. Without it, use --minimized to keep the window off your screen.`);
|
|
293
|
+
}
|
|
294
|
+
function virtualDisplayStateDir() {
|
|
295
|
+
return path.join(os.homedir(), ".local", "share", "prodex", "xvfb");
|
|
296
|
+
}
|
|
297
|
+
async function tcpPortAccepts(port, timeoutMs = 500) {
|
|
298
|
+
return new Promise((resolve) => {
|
|
299
|
+
const socket = net.connect({ host: "127.0.0.1", port });
|
|
300
|
+
const done = (result) => {
|
|
301
|
+
socket.destroy();
|
|
302
|
+
resolve(result);
|
|
303
|
+
};
|
|
304
|
+
socket.setTimeout(timeoutMs);
|
|
305
|
+
socket.once("connect", () => done(true));
|
|
306
|
+
socket.once("timeout", () => done(false));
|
|
307
|
+
socket.once("error", () => done(false));
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Start (or reuse) the prodex virtual display and return how to reach it.
|
|
312
|
+
* The X server outlives the CLI process on purpose: the dedicated browser runs
|
|
313
|
+
* on it, so tearing it down at exit would kill the browser.
|
|
314
|
+
*/
|
|
315
|
+
export async function ensureVirtualDisplay(options = {}) {
|
|
316
|
+
assertVirtualDisplayToolingAvailable();
|
|
317
|
+
const stateDir = virtualDisplayStateDir();
|
|
318
|
+
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
319
|
+
const requested = options.displayNumber ?? Number(process.env.PRODEX_VIRTUAL_DISPLAY_NUM ?? 99);
|
|
320
|
+
const first = Number.isInteger(requested) && requested > 0 && requested < 1000 ? requested : 99;
|
|
321
|
+
// Walk display numbers: a listening display is only reusable when OUR cookie
|
|
322
|
+
// for it exists, otherwise it belongs to something else (another tool, or a
|
|
323
|
+
// stale server started with a different key) and we could not authenticate
|
|
324
|
+
// to it - that produced "Authorization required" and a browser that died on
|
|
325
|
+
// launch. A free number gets a fresh server.
|
|
326
|
+
for (let displayNumber = first; displayNumber < first + 10; displayNumber += 1) {
|
|
327
|
+
const xauthority = path.join(stateDir, `Xauthority-${displayNumber}`);
|
|
328
|
+
const listening = await tcpPortAccepts(6000 + displayNumber);
|
|
329
|
+
if (listening) {
|
|
330
|
+
let ours = false;
|
|
331
|
+
try {
|
|
332
|
+
ours = (await readFile(xauthority)).length > 0;
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
ours = false;
|
|
336
|
+
}
|
|
337
|
+
if (ours)
|
|
338
|
+
return { displayNumber, xauthority, startedNow: false };
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const cookie = randomBytes(16).toString("hex");
|
|
342
|
+
await writeFile(xauthority, "", { mode: 0o600 });
|
|
343
|
+
const auth = spawnSync("xauth", ["-f", xauthority, "add", `127.0.0.1:${displayNumber}`, ".", cookie], {
|
|
344
|
+
encoding: "utf8",
|
|
345
|
+
timeout: 10_000
|
|
346
|
+
});
|
|
347
|
+
if (auth.status !== 0) {
|
|
348
|
+
throw new Error(`Could not create the X authority cookie: ${(auth.stderr || auth.stdout || "xauth failed").trim()}`);
|
|
349
|
+
}
|
|
350
|
+
const child = spawn("Xvfb", virtualDisplayServerArgs(displayNumber, xauthority), {
|
|
351
|
+
detached: true,
|
|
352
|
+
stdio: "ignore",
|
|
353
|
+
env: process.env
|
|
354
|
+
});
|
|
355
|
+
child.unref();
|
|
356
|
+
const deadline = Date.now() + 10_000;
|
|
357
|
+
while (Date.now() < deadline) {
|
|
358
|
+
if (await tcpPortAccepts(6000 + displayNumber))
|
|
359
|
+
return { displayNumber, xauthority, startedNow: true };
|
|
360
|
+
await sleep(250);
|
|
361
|
+
}
|
|
362
|
+
throw new Error(`Xvfb did not start listening for display :${displayNumber} within 10s.`);
|
|
363
|
+
}
|
|
364
|
+
throw new Error(`No free X display between :${first} and :${first + 9}. Set PRODEX_VIRTUAL_DISPLAY_NUM to a free number.`);
|
|
365
|
+
}
|
|
199
366
|
export function resolveHeadlessPreference(explicit, env = process.env) {
|
|
200
367
|
if (typeof explicit === "boolean")
|
|
201
368
|
return explicit;
|
|
@@ -310,6 +477,23 @@ async function waitForFreshChatGptPage(page, timeoutMs) {
|
|
|
310
477
|
export function hasChatGptPromptAcceptance(previous, state) {
|
|
311
478
|
return state.userMessageCount > previous.userMessageCount || state.assistantMessageCount > previous.assistantMessageCount;
|
|
312
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* Compare the model that was ASKED for against the model that actually
|
|
482
|
+
* produced the answer (ChatGPT tags each message with data-message-model-slug).
|
|
483
|
+
* prodex used to record only the request, so a model click that silently did
|
|
484
|
+
* not take - or no model pinned at all - was invisible, and the user believed
|
|
485
|
+
* they were getting Pro reasoning when they were not.
|
|
486
|
+
*/
|
|
487
|
+
export function modelSelectionWarning(requestedModel, modelSlug) {
|
|
488
|
+
if (!requestedModel || !modelSlug)
|
|
489
|
+
return undefined;
|
|
490
|
+
const wantsPro = /\bpro\b/i.test(requestedModel);
|
|
491
|
+
if (!wantsPro)
|
|
492
|
+
return undefined;
|
|
493
|
+
if (/pro/i.test(modelSlug))
|
|
494
|
+
return undefined;
|
|
495
|
+
return `model_mismatch: you asked for ${requestedModel}, but the answer came from "${modelSlug}". Check the model picker in the browser; the selection did not take.`;
|
|
496
|
+
}
|
|
313
497
|
export function chatGptBusyBlocker(generating) {
|
|
314
498
|
if (!generating)
|
|
315
499
|
return undefined;
|
|
@@ -331,7 +515,10 @@ export function isLikelyChatGptGeneratingControl(label) {
|
|
|
331
515
|
}
|
|
332
516
|
export function detectChatGptBlocker(text, visibleButtonLabels = []) {
|
|
333
517
|
const haystack = `${text}\n${visibleButtonLabels.join("\n")}`.toLowerCase();
|
|
334
|
-
|
|
518
|
+
// Match what the interstitial actually renders (measured live): the body
|
|
519
|
+
// says "Verifying you are human." and "<site> needs to review the security
|
|
520
|
+
// of your connection", while only the TITLE says "Just a moment...".
|
|
521
|
+
if (/just a moment|checking if the site connection is secure|verif(?:y|ying) you are human|needs to review the security of your connection|잠시만 기다려|연결이 안전한지|사람인지 확인/i.test(haystack)) {
|
|
335
522
|
return {
|
|
336
523
|
code: "cloudflare_check",
|
|
337
524
|
message: "ChatGPT is showing a Cloudflare or human-verification interstitial.",
|
|
@@ -662,7 +849,10 @@ export function openChatGptBrowser(options = {}) {
|
|
|
662
849
|
url: options.url ?? "https://chatgpt.com/",
|
|
663
850
|
...(headless ? { headless } : {})
|
|
664
851
|
});
|
|
665
|
-
const
|
|
852
|
+
const launchEnv = options.virtualDisplay
|
|
853
|
+
? virtualDisplayEnv(options.virtualDisplay.displayNumber, options.virtualDisplay.xauthority)
|
|
854
|
+
: browserLaunchEnv();
|
|
855
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", env: launchEnv });
|
|
666
856
|
let earlyExit;
|
|
667
857
|
const earlyExitWaiters = new Set();
|
|
668
858
|
const recordEarlyExit = (exit) => {
|
|
@@ -1397,6 +1587,7 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
1397
1587
|
title: state.title,
|
|
1398
1588
|
answer: state.answer.trim(),
|
|
1399
1589
|
modelHints: state.modelHints,
|
|
1590
|
+
...(state.modelSlug ? { modelSlug: state.modelSlug } : {}),
|
|
1400
1591
|
warnings: []
|
|
1401
1592
|
};
|
|
1402
1593
|
}
|
|
@@ -1669,7 +1860,8 @@ export async function sendChatGptPrompt(options) {
|
|
|
1669
1860
|
title: completed.title,
|
|
1670
1861
|
answer: completed.answer.trim(),
|
|
1671
1862
|
modelHints: completed.modelHints,
|
|
1672
|
-
|
|
1863
|
+
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
1864
|
+
warnings: [...sendWarnings, modelSelectionWarning(options.model, completed.modelSlug)].filter((warning) => Boolean(warning))
|
|
1673
1865
|
};
|
|
1674
1866
|
}
|
|
1675
1867
|
// Timed out while the answer was still streaming: salvage the partial text
|
|
@@ -1682,14 +1874,19 @@ export async function sendChatGptPrompt(options) {
|
|
|
1682
1874
|
title: completed.title,
|
|
1683
1875
|
answer: completed.answer.trim(),
|
|
1684
1876
|
modelHints: completed.modelHints,
|
|
1877
|
+
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
1685
1878
|
warnings: [
|
|
1686
1879
|
...sendWarnings,
|
|
1880
|
+
...(modelSelectionWarning(options.model, completed.modelSlug) ? [modelSelectionWarning(options.model, completed.modelSlug)] : []),
|
|
1687
1881
|
`answer_incomplete: ChatGPT was still generating after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms), so the answer below may be truncated. Raise --timeout-ms and retry for the full response.`
|
|
1688
1882
|
]
|
|
1689
1883
|
};
|
|
1690
1884
|
}
|
|
1691
|
-
|
|
1692
|
-
|
|
1885
|
+
// Carry the thread the prompt landed in: ChatGPT usually finishes the answer
|
|
1886
|
+
// after prodex gives up, and `pro browser recover --target-url` exists to
|
|
1887
|
+
// fetch it - but only if the caller knows which thread to point at.
|
|
1888
|
+
throw Object.assign(new Error(`Timed out after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms) waiting for ChatGPT to respond. ` +
|
|
1889
|
+
"Pro reasoning can run many minutes. Raise --timeout-ms and retry."), completed?.url ? { thread: completed.url } : {});
|
|
1693
1890
|
}
|
|
1694
1891
|
export function modelMenuOptionsExpression() {
|
|
1695
1892
|
return `(() => {
|
|
@@ -2236,10 +2433,17 @@ export function answerExpression() {
|
|
|
2236
2433
|
return parts.join(String.fromCharCode(10));
|
|
2237
2434
|
};
|
|
2238
2435
|
const lines = text.split(String.fromCharCode(10)).map((line) => line.trim()).filter(Boolean);
|
|
2239
|
-
const messages = [...document.querySelectorAll('[data-message-author-role]')].map((node) =>
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2436
|
+
const messages = [...document.querySelectorAll('[data-message-author-role]')].map((node) => {
|
|
2437
|
+
// ChatGPT tags each message with the model that produced it, on the
|
|
2438
|
+
// message node or an ancestor depending on the build. This is the only
|
|
2439
|
+
// ground truth for "did the Pro selection actually take".
|
|
2440
|
+
let modelSlug = node.getAttribute('data-message-model-slug') || undefined;
|
|
2441
|
+
if (!modelSlug && typeof node.closest === "function") {
|
|
2442
|
+
const tagged = node.closest('[data-message-model-slug]');
|
|
2443
|
+
if (tagged) modelSlug = tagged.getAttribute('data-message-model-slug') || undefined;
|
|
2444
|
+
}
|
|
2445
|
+
return { role: node.getAttribute('data-message-author-role'), text: node.innerText || "", modelSlug };
|
|
2446
|
+
});
|
|
2243
2447
|
const assistantMessages = messages.filter((message) => message.role === "assistant");
|
|
2244
2448
|
const userMessages = messages.filter((message) => message.role === "user");
|
|
2245
2449
|
const assistant = assistantMessages.at(-1);
|
|
@@ -2263,6 +2467,9 @@ export function answerExpression() {
|
|
|
2263
2467
|
generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || buttons.some((label) => generatingControlPattern.test(label)),
|
|
2264
2468
|
assistantMessageCount: assistantMessages.length,
|
|
2265
2469
|
userMessageCount: userMessages.length,
|
|
2470
|
+
// ChatGPT tags each assistant message with the model that produced it -
|
|
2471
|
+
// the only ground truth for "did the Pro selection actually take".
|
|
2472
|
+
modelSlug: assistant ? assistant.modelSlug : undefined,
|
|
2266
2473
|
modelHints: lines.filter((line) => /GPT|Pro|Thinking|ChatGPT|Extra High|Auto/i.test(line)).slice(0, 30)
|
|
2267
2474
|
};
|
|
2268
2475
|
})()`;
|
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] "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] [--headless] [--wait-timeout-ms 300000] # preview/open visible 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] [--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] # preview/open visible 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]
|
|
@@ -162,7 +162,7 @@ Commands:
|
|
|
162
162
|
prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] "prompt"
|
|
163
163
|
prodex pro debate-prompt [--topic "..."] [--rounds 2] [--source-cli /absolute/path/to/dist/cli.js]
|
|
164
164
|
prodex pro browser help [--source-cli /absolute/path/to/dist/cli.js]
|
|
165
|
-
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] [--headless] [--wait-timeout-ms 300000]
|
|
165
|
+
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] [--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000]
|
|
166
166
|
prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
|
|
167
167
|
prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
|
|
168
168
|
prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js]
|
|
@@ -242,8 +242,8 @@ export function printProBrowserHelp(stdout, sourceCli) {
|
|
|
242
242
|
const cli = formatCliCommand(sourceCli);
|
|
243
243
|
const sourceCliOption = formatSourceCliOption(sourceCli);
|
|
244
244
|
const loginUsage = sourceCli
|
|
245
|
-
? `${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] [--headless] [--wait-timeout-ms 300000]`
|
|
246
|
-
: "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] [--headless] [--wait-timeout-ms 300000]";
|
|
245
|
+
? `${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] [--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000]`
|
|
246
|
+
: "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] [--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000]";
|
|
247
247
|
const checkUsage = sourceCli
|
|
248
248
|
? `${cli} pro browser check${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 1500]`
|
|
249
249
|
: "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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { buildDryRunBundle } from "./bundle.js";
|
|
4
|
-
import { DEFAULT_CDP_PORT, resolveCdpPort, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, parseProMode, parseReasoningEffort, readLastBrowserLoginLaunch, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
|
|
4
|
+
import { DEFAULT_CDP_PORT, resolveCdpPort, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, parseProMode, parseReasoningEffort, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
|
|
5
5
|
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";
|
|
6
6
|
import { printProBrowserHelp, printProHelp } from "./cli-help.js";
|
|
7
7
|
import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
|
|
@@ -185,11 +185,11 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
185
185
|
if (browserSubcommand === "login") {
|
|
186
186
|
if (printProBrowserHelpIfRequested(browserArgs, "pro browser login", io, {
|
|
187
187
|
valueFlags: ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"],
|
|
188
|
-
booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headless"]
|
|
188
|
+
booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headless", "--minimized", "--virtual-display"]
|
|
189
189
|
})) {
|
|
190
190
|
return 0;
|
|
191
191
|
}
|
|
192
|
-
assertOnlyOptions(browserArgs, "pro browser login", ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"], ["--dry-run", "--wait", "--no-wait", "--headless"]);
|
|
192
|
+
assertOnlyOptions(browserArgs, "pro browser login", ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"], ["--dry-run", "--wait", "--no-wait", "--headless", "--minimized", "--virtual-display"]);
|
|
193
193
|
if (browserArgs.includes("--wait") && browserArgs.includes("--no-wait")) {
|
|
194
194
|
throw new Error("pro browser login cannot combine --wait and --no-wait");
|
|
195
195
|
}
|
|
@@ -222,6 +222,13 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
222
222
|
// "extra windows" problem, which then blocks sends as
|
|
223
223
|
// ambiguous_chatgpt_tabs). Reuse the running instance instead.
|
|
224
224
|
const headless = resolveHeadlessPreference(browserArgs.includes("--headless") ? true : undefined);
|
|
225
|
+
// A real browser on a virtual X display: no window anywhere, and
|
|
226
|
+
// Cloudflare sees an ordinary headed Chrome (headless it rejects).
|
|
227
|
+
const wantsVirtualDisplay = resolveVirtualDisplayPreference(browserArgs.includes("--virtual-display") ? true : undefined);
|
|
228
|
+
if (wantsVirtualDisplay && headless) {
|
|
229
|
+
throw new Error("pro browser login cannot combine --headless and --virtual-display (a virtual display already hides the window).");
|
|
230
|
+
}
|
|
231
|
+
const virtualDisplay = wantsVirtualDisplay ? await ensureVirtualDisplay() : undefined;
|
|
225
232
|
const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
|
|
226
233
|
if (alreadyRunning) {
|
|
227
234
|
// One Chrome profile cannot serve a headed and a headless instance at
|
|
@@ -232,16 +239,56 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
232
239
|
if (runningHeadless !== undefined && runningHeadless !== headless) {
|
|
233
240
|
throw new Error(`A ${runningHeadless ? "headless" : "headed"} ChatGPT browser is already running on port ${port}, but ${headless ? "headless" : "headed"} was requested. Close it first (\`pkill -f "remote-debugging-port=${port}"\`), then rerun.`);
|
|
234
241
|
}
|
|
242
|
+
// Same for the display: a browser already on your desktop cannot be
|
|
243
|
+
// moved onto a virtual display by reusing it, and silently reusing
|
|
244
|
+
// it would leave the window exactly where the user asked it not to be.
|
|
245
|
+
const runningVirtual = previous?.port === port ? previous.virtual_display !== undefined : undefined;
|
|
246
|
+
if (runningVirtual !== undefined && runningVirtual !== wantsVirtualDisplay) {
|
|
247
|
+
throw new Error(`A ChatGPT browser is already running on port ${port} on ${runningVirtual ? "a virtual display" : "your desktop"}, but ${wantsVirtualDisplay ? "a virtual display" : "your desktop"} was requested. Close it first (\`pkill -f "remote-debugging-port=${port}"\`), then rerun.`);
|
|
248
|
+
}
|
|
235
249
|
}
|
|
236
250
|
const opened = alreadyRunning
|
|
237
251
|
? { profileDir: profileDir ?? defaultChatGptProfileDir(), port }
|
|
238
|
-
: openChatGptBrowser({
|
|
252
|
+
: openChatGptBrowser({
|
|
253
|
+
port,
|
|
254
|
+
profileDir,
|
|
255
|
+
url: loginUrl,
|
|
256
|
+
...(headless ? { headless } : {}),
|
|
257
|
+
...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
|
|
258
|
+
});
|
|
239
259
|
if (!alreadyRunning) {
|
|
240
260
|
await assertBrowserLaunchStayedAlive(opened, launchTimeoutMs);
|
|
241
261
|
}
|
|
242
262
|
// Remember this launch so ask auto-recovery reuses the same profile
|
|
243
263
|
// AND the same window mode.
|
|
244
|
-
|
|
264
|
+
// Minimize BEFORE recording, so the record reflects what actually
|
|
265
|
+
// happened (a desktop that hides minimized windows gets restored and
|
|
266
|
+
// recorded as a normal window).
|
|
267
|
+
const wantsMinimized = browserArgs.includes("--minimized") || resolveMinimizeWindowPreference();
|
|
268
|
+
let minimized = false;
|
|
269
|
+
let minimizeNote;
|
|
270
|
+
if (wantsMinimized && !headless) {
|
|
271
|
+
try {
|
|
272
|
+
const outcome = await minimizeChatGptWindow({ port: opened.port });
|
|
273
|
+
minimized = outcome.minimized;
|
|
274
|
+
minimizeNote = outcome.minimized
|
|
275
|
+
? "window: minimized out of the way - the tab still reads visible, so consults keep working."
|
|
276
|
+
: `window: this desktop reports a minimized window as ${outcome.visibilityState}, and prodex will not send into a tab it cannot read - the window was restored. Keep it open behind your editor instead.`;
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
minimizeNote = `window: could not minimize (${errorMessage(error)}); leaving it as it is.`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
await recordBrowserLoginLaunch({
|
|
283
|
+
profile_dir: opened.profileDir,
|
|
284
|
+
port: opened.port,
|
|
285
|
+
headless,
|
|
286
|
+
minimized,
|
|
287
|
+
...(virtualDisplay ? { virtual_display: virtualDisplay.displayNumber } : {})
|
|
288
|
+
});
|
|
289
|
+
if (virtualDisplay) {
|
|
290
|
+
io.stdout(`window: none - running on virtual display :${virtualDisplay.displayNumber}${virtualDisplay.startedNow ? " (started now)" : " (reused)"}.`);
|
|
291
|
+
}
|
|
245
292
|
printBrowserLoginGuide(io.stdout, {
|
|
246
293
|
opened: !alreadyRunning,
|
|
247
294
|
reused: alreadyRunning,
|
|
@@ -252,15 +299,26 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
252
299
|
sourceCli,
|
|
253
300
|
commandOptions
|
|
254
301
|
});
|
|
302
|
+
if (minimizeNote)
|
|
303
|
+
io.stdout(minimizeNote);
|
|
255
304
|
if (headless) {
|
|
256
305
|
// Nobody can sign in to a window that does not exist, so a headless
|
|
257
306
|
// launch is only useful when the profile is already logged in.
|
|
258
307
|
// Verify it here (bounded, no human to wait for) instead of letting
|
|
259
308
|
// the first consult fail with a confusing not-logged-in blocker.
|
|
260
|
-
const
|
|
309
|
+
const headlessWaitMs = readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 30_000;
|
|
310
|
+
const headlessReady = await waitForChatGptLoginReady(io.stderr, { port: opened.port, timeoutMs: headlessWaitMs });
|
|
261
311
|
if (!headlessReady) {
|
|
312
|
+
// Name the real cause. Cloudflare rejects headless Chrome by
|
|
313
|
+
// design (its docs list headless browsers as unsupported), and
|
|
314
|
+
// reporting that as "not signed in" sent users to re-login over
|
|
315
|
+
// and over instead of back to a headed window.
|
|
316
|
+
const finalStatus = await getChatGptBrowserStatus({ port: opened.port, timeoutMs: 5_000 }).catch(() => undefined);
|
|
317
|
+
const challenged = finalStatus?.blocker?.code === "cloudflare_check" || /just a moment/i.test(finalStatus?.title ?? "");
|
|
262
318
|
io.stdout("");
|
|
263
|
-
io.stdout(
|
|
319
|
+
io.stdout(challenged
|
|
320
|
+
? "headless: Cloudflare challenged the headless browser and never let ChatGPT load. This is expected - Cloudflare lists headless browsers as unsupported. Run without --headless, or run a real headed Chrome on a virtual display (Xvfb) and point prodex at it with DISPLAY."
|
|
321
|
+
: `headless: the profile is not signed in. Run \`${formatBrowserLoginCommand(sourceCli, commandOptions)}\` WITHOUT --headless once, sign in, close that window, then rerun with --headless.`);
|
|
264
322
|
return 1;
|
|
265
323
|
}
|
|
266
324
|
io.stdout("headless: signed-in session confirmed - consults will run with no visible window.");
|
|
@@ -560,6 +618,12 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
560
618
|
export async function runConsultsCommand(rest, io) {
|
|
561
619
|
throw new Error("The legacy `consults` alias is retired. Use `prodex pro list`, `prodex pro latest`, or `prodex pro show <task-id|latest>`.");
|
|
562
620
|
}
|
|
621
|
+
// PRODEX_MINIMIZE_WINDOW=1 gives the no-window setup to every entry point,
|
|
622
|
+
// including the MCP server's auto-recovery, without a CLI flag.
|
|
623
|
+
export function resolveMinimizeWindowPreference(env = process.env) {
|
|
624
|
+
const raw = (env.PRODEX_MINIMIZE_WINDOW ?? "").trim().toLowerCase();
|
|
625
|
+
return raw === "1" || raw === "true" || raw === "yes";
|
|
626
|
+
}
|
|
563
627
|
function autoLoginDisabledByEnv(env = process.env) {
|
|
564
628
|
const raw = (env.PRODEX_NO_AUTO_LOGIN ?? "").trim().toLowerCase();
|
|
565
629
|
return raw === "1" || raw === "true" || raw === "yes";
|
|
@@ -898,7 +962,9 @@ export async function runAskProCommand(rest, io) {
|
|
|
898
962
|
// after the 2026-07 update reset that to Medium, consults meant for Pro
|
|
899
963
|
// quietly ran on a mid-tier model. Warn loudly and record it.
|
|
900
964
|
if (!selectionModel && !selectionProMode && !selectionEffort) {
|
|
901
|
-
persistenceWarnings.push("model_selection_warning: no model/effort was selected for this send (no per-ask flag, no saved default), so it used whatever the ChatGPT UI last had selected
|
|
965
|
+
persistenceWarnings.push("model_selection_warning: no model/effort was selected for this send (no per-ask flag, no saved default), so it used whatever the ChatGPT UI last had selected" +
|
|
966
|
+
(consult.modelSlug ? ` - it answered as "${consult.modelSlug}"` : "") +
|
|
967
|
+
". Pin one with `prodex setup --model Pro` or pass --model/--effort.");
|
|
902
968
|
}
|
|
903
969
|
// In-project threads carry the project slug in their URL
|
|
904
970
|
// (/g/g-p-<project>/c/<id>); a bare /c/<id> after requesting a project
|
|
@@ -912,6 +978,8 @@ export async function runAskProCommand(rest, io) {
|
|
|
912
978
|
// would otherwise treat a cut-off answer as complete.
|
|
913
979
|
for (const warning of persistenceWarnings)
|
|
914
980
|
io.stderr(warning);
|
|
981
|
+
if (consult.modelSlug)
|
|
982
|
+
io.stderr(`model_used: ${consult.modelSlug}`);
|
|
915
983
|
let answerArtifactPath;
|
|
916
984
|
const answerArtifactBytes = Buffer.byteLength(answerArtifactText, "utf8");
|
|
917
985
|
if (answerArtifactBytes > MAX_FETCHABLE_RESULT_ARTIFACT_BYTES) {
|
|
@@ -939,6 +1007,9 @@ export async function runAskProCommand(rest, io) {
|
|
|
939
1007
|
...(answerArtifactPath ? { artifact_path: answerArtifactPath } : {}),
|
|
940
1008
|
thread: consult.url,
|
|
941
1009
|
...(Object.keys(selectionMetadata).length > 0 ? { selection: selectionMetadata } : {}),
|
|
1010
|
+
// What actually answered, straight from ChatGPT's own tag - the
|
|
1011
|
+
// receipt used to record only what prodex asked for.
|
|
1012
|
+
...(consult.modelSlug ? { model_used: consult.modelSlug } : {}),
|
|
942
1013
|
warnings: persistenceWarnings
|
|
943
1014
|
}
|
|
944
1015
|
});
|
|
@@ -1119,16 +1190,30 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
|
1119
1190
|
// visible window for someone running headless would be exactly the
|
|
1120
1191
|
// surprise window they turned headless to avoid.
|
|
1121
1192
|
const headless = resolveHeadlessPreference(lastLogin?.headless);
|
|
1193
|
+
// Rejoin the same virtual display the user set up, so recovery does not
|
|
1194
|
+
// put a window back on a desktop they deliberately keep empty.
|
|
1195
|
+
const virtualDisplay = lastLogin?.virtual_display !== undefined || resolveVirtualDisplayPreference()
|
|
1196
|
+
? await ensureVirtualDisplay(lastLogin?.virtual_display !== undefined ? { displayNumber: lastLogin.virtual_display } : {}).catch(() => undefined)
|
|
1197
|
+
: undefined;
|
|
1122
1198
|
const opened = openChatGptBrowser({
|
|
1123
1199
|
...(options.port !== undefined ? { port: options.port } : {}),
|
|
1124
1200
|
...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {}),
|
|
1125
|
-
...(headless ? { headless } : {})
|
|
1201
|
+
...(headless ? { headless } : {}),
|
|
1202
|
+
...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
|
|
1126
1203
|
});
|
|
1127
1204
|
await assertBrowserLaunchStayedAlive(opened);
|
|
1128
1205
|
const ready = await waitForChatGptLoginReady(stderr, { port: opened.port, timeoutMs: 120_000 });
|
|
1129
|
-
if (ready)
|
|
1130
|
-
|
|
1131
|
-
|
|
1206
|
+
if (!ready)
|
|
1207
|
+
return false;
|
|
1208
|
+
// Restore the no-window setup too: someone who runs minimized does not
|
|
1209
|
+
// want recovery to leave a window sitting on their desktop.
|
|
1210
|
+
if (!headless && (lastLogin?.minimized === true || resolveMinimizeWindowPreference())) {
|
|
1211
|
+
const outcome = await minimizeChatGptWindow({ port: opened.port }).catch(() => undefined);
|
|
1212
|
+
if (outcome?.minimized)
|
|
1213
|
+
stderr("recover: window minimized again");
|
|
1214
|
+
}
|
|
1215
|
+
stderr("recover: browser READY - retrying the send...");
|
|
1216
|
+
return true;
|
|
1132
1217
|
}
|
|
1133
1218
|
catch (error) {
|
|
1134
1219
|
stderr(`recover: failed - ${errorMessage(error)}`);
|
|
@@ -1214,6 +1299,9 @@ export function browserSendBlockerFromError(error) {
|
|
|
1214
1299
|
}
|
|
1215
1300
|
// Match the raw ms whether the message uses the old "after 90000ms" form or
|
|
1216
1301
|
// the newer human-readable "after 20 min (1200000ms)" form.
|
|
1302
|
+
const thread = typeof error === "object" && error !== null && "thread" in error && typeof error.thread === "string"
|
|
1303
|
+
? (error.thread)
|
|
1304
|
+
: undefined;
|
|
1217
1305
|
const timedOut = message.match(/Timed out after [\s\S]*?(\d+)\s*ms/);
|
|
1218
1306
|
if (timedOut) {
|
|
1219
1307
|
// Suggest a concrete doubled budget so the user can paste a rerun command
|
|
@@ -1224,7 +1312,11 @@ export function browserSendBlockerFromError(error) {
|
|
|
1224
1312
|
code: "send_timeout",
|
|
1225
1313
|
message,
|
|
1226
1314
|
retryable: true,
|
|
1227
|
-
|
|
1315
|
+
...(thread ? { thread } : {}),
|
|
1316
|
+
next_step: `Rerun with a bigger budget (${formatDurationMs(suggestedMs)}): \`prodex pro browser ask --timeout-ms ${suggestedMs} "<same prompt>"\`.` +
|
|
1317
|
+
(thread
|
|
1318
|
+
? ` ChatGPT often finishes after prodex gives up - fetch that answer instead of re-asking: \`prodex pro browser recover --target-url ${thread}\`.`
|
|
1319
|
+
: "")
|
|
1228
1320
|
};
|
|
1229
1321
|
}
|
|
1230
1322
|
// A CDP command timeout means the page's renderer stalled, which in the
|
package/dist/cli.js
CHANGED
|
@@ -303,9 +303,13 @@ repo: ${cwd}
|
|
|
303
303
|
${cli} pro browser login${sourceCliOption} # opens visible browser
|
|
304
304
|
${cli} pro browser login --dry-run${sourceCliOption} # preview, no browser opens
|
|
305
305
|
In an interactive terminal, login waits and narrates until your ChatGPT session is READY.
|
|
306
|
+
Sign in once, then keep the browser off your screen for good:
|
|
307
|
+
${cli} pro browser login --virtual-display${sourceCliOption} # no window anywhere (needs Xvfb: sudo apt install -y xvfb x11-xkb-utils xauth)
|
|
308
|
+
${cli} pro browser login --minimized${sourceCliOption} # no install; keeps the window minimized
|
|
309
|
+
Not --headless: Cloudflare rejects headless browsers, so ChatGPT never loads in one.
|
|
306
310
|
Pin per-repo defaults first - otherwise sends silently use whatever the ChatGPT UI last had selected:
|
|
307
311
|
${cli} pro browser projects${sourceCliOption} # read-only: exact sidebar project names
|
|
308
|
-
${cli} setup --cwd ${quotedCwd} --model Pro --project "your-project" # every ask: Pro (
|
|
312
|
+
${cli} setup --cwd ${quotedCwd} --model Pro --project "your-project" # every ask: Pro (20-minute timeout) inside that project
|
|
309
313
|
cd ${quotedCwd}
|
|
310
314
|
${cli} ask --new-chat "Review this repo"${sourceCliOption} # short form of pro browser ask
|
|
311
315
|
${proAskCommand} # dry-run/manual preview
|
|
@@ -314,7 +318,7 @@ repo: ${cwd}
|
|
|
314
318
|
${cli} pro browser help${sourceCliOption}
|
|
315
319
|
${cli} pro browser check${sourceCliOption} --cwd ${quotedCwd}
|
|
316
320
|
${cli} pro browser smoke${sourceCliOption} --cwd ${quotedCwd}
|
|
317
|
-
Sharing the browser with other agents?
|
|
321
|
+
Sharing the browser with other agents? Sends queue behind an in-flight response automatically; pass --busy-wait-ms 0 to fail fast instead.
|
|
318
322
|
|
|
319
323
|
2. Let coding agents consult ChatGPT (stdio MCP: Claude, Codex, Cursor, ...):
|
|
320
324
|
${cli} claude config --cwd ${quotedCwd}${sourceCliOption}
|
package/dist/schema.js
CHANGED
|
@@ -35,7 +35,11 @@ export const BlockerSchema = z.object({
|
|
|
35
35
|
code: z.string(),
|
|
36
36
|
message: z.string(),
|
|
37
37
|
retryable: z.boolean().default(false),
|
|
38
|
-
next_step: z.string().optional()
|
|
38
|
+
next_step: z.string().optional(),
|
|
39
|
+
// The ChatGPT thread the prompt landed in, recorded on send failures so
|
|
40
|
+
// `pro browser recover --target-url` has something to point at: ChatGPT
|
|
41
|
+
// usually finishes the answer after prodex has given up waiting.
|
|
42
|
+
thread: z.string().optional()
|
|
39
43
|
});
|
|
40
44
|
export const TaskSchema = z.object({
|
|
41
45
|
schema_version: z.literal(SCHEMA_VERSION),
|