@youdie006/prodex 0.16.33 → 0.18.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 +32 -0
- package/dist/chatgpt-browser.js +237 -7
- package/dist/cli-help.js +4 -4
- package/dist/cli-pro.js +185 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -256,6 +256,38 @@ 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 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)
|
|
281
|
+
|
|
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:
|
|
283
|
+
|
|
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.
|
|
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).
|
|
286
|
+
|
|
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.
|
|
288
|
+
|
|
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.
|
|
290
|
+
|
|
259
291
|
Whatever selection is applied is recorded on the consult receipt (`metadata.selection`); receipt display output redacts the project name, keeping only the model axes visible. `prodex` only clicks the picker you can see; it never selects a model, effort, or project silently outside the visible browser.
|
|
260
292
|
|
|
261
293
|
For a source checkout, keep the explicit send and inspection commands source-aware too:
|
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";
|
|
@@ -167,13 +169,23 @@ export async function readLastBrowserLoginLaunch() {
|
|
|
167
169
|
const parsed = JSON.parse(await readFile(lastBrowserLoginPath(), "utf8"));
|
|
168
170
|
return {
|
|
169
171
|
...(typeof parsed.profile_dir === "string" && parsed.profile_dir.length > 0 ? { profile_dir: parsed.profile_dir } : {}),
|
|
170
|
-
...(typeof parsed.port === "number" && Number.isInteger(parsed.port) ? { port: parsed.port } : {})
|
|
172
|
+
...(typeof parsed.port === "number" && Number.isInteger(parsed.port) ? { port: parsed.port } : {}),
|
|
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
|
+
: {})
|
|
171
178
|
};
|
|
172
179
|
}
|
|
173
180
|
catch {
|
|
174
181
|
return undefined;
|
|
175
182
|
}
|
|
176
183
|
}
|
|
184
|
+
// Headless Chrome defaults to an 800x600 viewport, at which ChatGPT collapses
|
|
185
|
+
// the sidebar - and the sidebar carries the logged-in signals that
|
|
186
|
+
// inferLoggedInLikely reads ("New chat"/"Projects"). Pin a desktop width so a
|
|
187
|
+
// headless session is not misread as logged out.
|
|
188
|
+
const HEADLESS_WINDOW_SIZE = "1440,900";
|
|
177
189
|
export function buildChromeLaunchArgs(options) {
|
|
178
190
|
return [
|
|
179
191
|
"--remote-debugging-address=127.0.0.1",
|
|
@@ -181,10 +193,182 @@ export function buildChromeLaunchArgs(options) {
|
|
|
181
193
|
`--user-data-dir=${options.profileDir}`,
|
|
182
194
|
"--no-first-run",
|
|
183
195
|
"--no-default-browser-check",
|
|
184
|
-
|
|
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",
|
|
204
|
+
...(options.headless ? ["--headless=new", `--window-size=${HEADLESS_WINDOW_SIZE}`] : ["--new-window"]),
|
|
185
205
|
options.url
|
|
186
206
|
];
|
|
187
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Headless is opt-in: an explicit option wins, otherwise PRODEX_HEADLESS
|
|
210
|
+
* (1/true/yes) decides. The env var is the practical switch because the MCP
|
|
211
|
+
* server and its auto-recovery launch the browser with no CLI flags.
|
|
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
|
+
}
|
|
366
|
+
export function resolveHeadlessPreference(explicit, env = process.env) {
|
|
367
|
+
if (typeof explicit === "boolean")
|
|
368
|
+
return explicit;
|
|
369
|
+
const raw = (env.PRODEX_HEADLESS ?? "").trim().toLowerCase();
|
|
370
|
+
return raw === "1" || raw === "true" || raw === "yes";
|
|
371
|
+
}
|
|
188
372
|
export function inferLoggedInLikely(text, visibleButtonLabels = []) {
|
|
189
373
|
// Only sign-up prompts and explicit login/sign-up buttons count as logged-out signals. Bare
|
|
190
374
|
// "Log in"/"로그인" substrings appear in the menus and footers of a logged-in page, so matching
|
|
@@ -314,7 +498,10 @@ export function isLikelyChatGptGeneratingControl(label) {
|
|
|
314
498
|
}
|
|
315
499
|
export function detectChatGptBlocker(text, visibleButtonLabels = []) {
|
|
316
500
|
const haystack = `${text}\n${visibleButtonLabels.join("\n")}`.toLowerCase();
|
|
317
|
-
|
|
501
|
+
// Match what the interstitial actually renders (measured live): the body
|
|
502
|
+
// says "Verifying you are human." and "<site> needs to review the security
|
|
503
|
+
// of your connection", while only the TITLE says "Just a moment...".
|
|
504
|
+
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)) {
|
|
318
505
|
return {
|
|
319
506
|
code: "cloudflare_check",
|
|
320
507
|
message: "ChatGPT is showing a Cloudflare or human-verification interstitial.",
|
|
@@ -638,12 +825,17 @@ export function openChatGptBrowser(options = {}) {
|
|
|
638
825
|
const command = resolveChromeCommand();
|
|
639
826
|
const port = resolveCdpPort(options.port);
|
|
640
827
|
const profileDir = options.profileDir ?? defaultChatGptProfileDir();
|
|
828
|
+
const headless = resolveHeadlessPreference(options.headless);
|
|
641
829
|
const args = buildChromeLaunchArgs({
|
|
642
830
|
port,
|
|
643
831
|
profileDir,
|
|
644
|
-
url: options.url ?? "https://chatgpt.com/"
|
|
832
|
+
url: options.url ?? "https://chatgpt.com/",
|
|
833
|
+
...(headless ? { headless } : {})
|
|
645
834
|
});
|
|
646
|
-
const
|
|
835
|
+
const launchEnv = options.virtualDisplay
|
|
836
|
+
? virtualDisplayEnv(options.virtualDisplay.displayNumber, options.virtualDisplay.xauthority)
|
|
837
|
+
: browserLaunchEnv();
|
|
838
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", env: launchEnv });
|
|
647
839
|
let earlyExit;
|
|
648
840
|
const earlyExitWaiters = new Set();
|
|
649
841
|
const recordEarlyExit = (exit) => {
|
|
@@ -709,7 +901,14 @@ export async function getChatGptBrowserStatus(options = {}) {
|
|
|
709
901
|
blocker: chatGptPageMissingBlocker()
|
|
710
902
|
};
|
|
711
903
|
}
|
|
712
|
-
|
|
904
|
+
// Bound the status read by the CALLER's budget, not the 20s default CDP
|
|
905
|
+
// command timeout: a very heavy ChatGPT thread answers slowly, and
|
|
906
|
+
// `pro browser check --timeout-ms 5000` measured 65 seconds because every
|
|
907
|
+
// evaluate silently used the default. Agents read that silence as a hung
|
|
908
|
+
// bridge and start "recovering" a browser that is merely busy.
|
|
909
|
+
const state = await evaluateOnPage(page.page, statusExpression(), {
|
|
910
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
|
|
911
|
+
});
|
|
713
912
|
const loggedInLikely = inferChatGptPageLoggedInLikely(state);
|
|
714
913
|
const blocker = chatGptVisibilityBlocker(state.visibilityState, state.url) ?? detectChatGptPageBlocker(state) ?? chatGptBusyBlocker(state.generating);
|
|
715
914
|
return {
|
|
@@ -2076,12 +2275,43 @@ async function insertComposerTextViaCdp(cdp, text) {
|
|
|
2076
2275
|
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
|
|
2077
2276
|
await sleep(100);
|
|
2078
2277
|
}
|
|
2079
|
-
|
|
2278
|
+
// Insert in bounded chunks: a single multi-KB Input.insertText makes
|
|
2279
|
+
// ProseMirror do one huge transaction, which on a heavy thread stalls past
|
|
2280
|
+
// the 20s CDP command timeout and kills the send with "Chrome DevTools
|
|
2281
|
+
// command timed out: Input.insertText" (field failure on long prompts,
|
|
2282
|
+
// twice in one session). Each chunk gets its own command budget.
|
|
2283
|
+
for (const chunk of chunkComposerText(text)) {
|
|
2284
|
+
await cdp.send("Input.insertText", { text: chunk });
|
|
2285
|
+
}
|
|
2080
2286
|
await sleep(200);
|
|
2081
2287
|
const state = await cdp.evaluate(composerTextStateExpression(text));
|
|
2082
2288
|
if (!state.ok)
|
|
2083
2289
|
throw new Error(state.reason ?? "Composer stayed empty after text insertion");
|
|
2084
2290
|
}
|
|
2291
|
+
export const COMPOSER_INSERT_CHUNK_CHARS = 4_000;
|
|
2292
|
+
/**
|
|
2293
|
+
* Split composer text into insertText-sized pieces. Splits on code points (not
|
|
2294
|
+
* UTF-16 units) so a surrogate pair - an emoji, or any astral character - can
|
|
2295
|
+
* never be cut in half and arrive as two replacement characters.
|
|
2296
|
+
*/
|
|
2297
|
+
export function chunkComposerText(text, size = COMPOSER_INSERT_CHUNK_CHARS) {
|
|
2298
|
+
if (text.length === 0)
|
|
2299
|
+
return [];
|
|
2300
|
+
if (text.length <= size)
|
|
2301
|
+
return [text];
|
|
2302
|
+
const chunks = [];
|
|
2303
|
+
let current = "";
|
|
2304
|
+
for (const character of text) {
|
|
2305
|
+
if (current.length + character.length > size) {
|
|
2306
|
+
chunks.push(current);
|
|
2307
|
+
current = "";
|
|
2308
|
+
}
|
|
2309
|
+
current += character;
|
|
2310
|
+
}
|
|
2311
|
+
if (current.length > 0)
|
|
2312
|
+
chunks.push(current);
|
|
2313
|
+
return chunks;
|
|
2314
|
+
}
|
|
2085
2315
|
export function submitExpression() {
|
|
2086
2316
|
return `(() => {
|
|
2087
2317
|
${composerExpressionHelpers()}
|
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] [--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] [--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] [--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] [--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, 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"]
|
|
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"]);
|
|
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
|
}
|
|
@@ -221,24 +221,109 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
221
221
|
// again: Chrome's singleton would just open ANOTHER window (the recurring
|
|
222
222
|
// "extra windows" problem, which then blocks sends as
|
|
223
223
|
// ambiguous_chatgpt_tabs). Reuse the running instance instead.
|
|
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;
|
|
224
232
|
const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
|
|
233
|
+
if (alreadyRunning) {
|
|
234
|
+
// One Chrome profile cannot serve a headed and a headless instance at
|
|
235
|
+
// once, and reusing the running one would silently ignore the
|
|
236
|
+
// requested mode. Say so instead of pretending the switch took.
|
|
237
|
+
const previous = await readLastBrowserLoginLaunch();
|
|
238
|
+
const runningHeadless = previous?.port === port ? previous.headless === true : undefined;
|
|
239
|
+
if (runningHeadless !== undefined && runningHeadless !== headless) {
|
|
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.`);
|
|
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
|
+
}
|
|
249
|
+
}
|
|
225
250
|
const opened = alreadyRunning
|
|
226
251
|
? { profileDir: profileDir ?? defaultChatGptProfileDir(), port }
|
|
227
|
-
: openChatGptBrowser({
|
|
252
|
+
: openChatGptBrowser({
|
|
253
|
+
port,
|
|
254
|
+
profileDir,
|
|
255
|
+
url: loginUrl,
|
|
256
|
+
...(headless ? { headless } : {}),
|
|
257
|
+
...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
|
|
258
|
+
});
|
|
228
259
|
if (!alreadyRunning) {
|
|
229
260
|
await assertBrowserLaunchStayedAlive(opened, launchTimeoutMs);
|
|
230
261
|
}
|
|
231
|
-
// Remember this launch so ask auto-recovery reuses the same profile
|
|
232
|
-
|
|
262
|
+
// Remember this launch so ask auto-recovery reuses the same profile
|
|
263
|
+
// AND the same window mode.
|
|
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
|
+
}
|
|
233
292
|
printBrowserLoginGuide(io.stdout, {
|
|
234
293
|
opened: !alreadyRunning,
|
|
235
294
|
reused: alreadyRunning,
|
|
295
|
+
headless,
|
|
236
296
|
loginUrl,
|
|
237
297
|
profileDir: opened.profileDir,
|
|
238
298
|
port: opened.port,
|
|
239
299
|
sourceCli,
|
|
240
300
|
commandOptions
|
|
241
301
|
});
|
|
302
|
+
if (minimizeNote)
|
|
303
|
+
io.stdout(minimizeNote);
|
|
304
|
+
if (headless) {
|
|
305
|
+
// Nobody can sign in to a window that does not exist, so a headless
|
|
306
|
+
// launch is only useful when the profile is already logged in.
|
|
307
|
+
// Verify it here (bounded, no human to wait for) instead of letting
|
|
308
|
+
// the first consult fail with a confusing not-logged-in blocker.
|
|
309
|
+
const headlessWaitMs = readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 30_000;
|
|
310
|
+
const headlessReady = await waitForChatGptLoginReady(io.stderr, { port: opened.port, timeoutMs: headlessWaitMs });
|
|
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 ?? "");
|
|
318
|
+
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.`);
|
|
322
|
+
return 1;
|
|
323
|
+
}
|
|
324
|
+
io.stdout("headless: signed-in session confirmed - consults will run with no visible window.");
|
|
325
|
+
return 0;
|
|
326
|
+
}
|
|
242
327
|
// Guided wait: interactive terminals walk the user to a verified READY
|
|
243
328
|
// state instead of returning while login is still unfinished. Scripts
|
|
244
329
|
// and agents (non-TTY) keep the immediate return unless --wait is
|
|
@@ -533,6 +618,16 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
533
618
|
export async function runConsultsCommand(rest, io) {
|
|
534
619
|
throw new Error("The legacy `consults` alias is retired. Use `prodex pro list`, `prodex pro latest`, or `prodex pro show <task-id|latest>`.");
|
|
535
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
|
+
}
|
|
627
|
+
function autoLoginDisabledByEnv(env = process.env) {
|
|
628
|
+
const raw = (env.PRODEX_NO_AUTO_LOGIN ?? "").trim().toLowerCase();
|
|
629
|
+
return raw === "1" || raw === "true" || raw === "yes";
|
|
630
|
+
}
|
|
536
631
|
// Retired browser subcommands map to the one that replaced them. Every value
|
|
537
632
|
// here must be a subcommand that actually exists, so the error is a single hop
|
|
538
633
|
// to a runnable command.
|
|
@@ -1034,6 +1129,12 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
|
1034
1129
|
const stderrLines = [];
|
|
1035
1130
|
const argv = [
|
|
1036
1131
|
"--send",
|
|
1132
|
+
// MCP callers have no terminal, so the interactive auto-recovery gate
|
|
1133
|
+
// never fired for them: a closed browser made every pro_consult fail with
|
|
1134
|
+
// a step the agent had to shell out for (the single most common field
|
|
1135
|
+
// failure). Recovery reuses the saved profile and window mode, and
|
|
1136
|
+
// PRODEX_NO_AUTO_LOGIN=1 turns it off.
|
|
1137
|
+
...(autoLoginDisabledByEnv() ? ["--no-auto-login"] : ["--auto-login"]),
|
|
1037
1138
|
...(input.model !== undefined ? ["--model", input.model] : []),
|
|
1038
1139
|
...(input.pro_mode !== undefined ? ["--pro-mode", input.pro_mode] : []),
|
|
1039
1140
|
...(input.effort !== undefined ? ["--effort", input.effort] : []),
|
|
@@ -1078,15 +1179,34 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
|
1078
1179
|
// profile for a custom-profile user would wait on the wrong (logged-out)
|
|
1079
1180
|
// profile or, worse, silently send to a different account.
|
|
1080
1181
|
const lastLogin = await readLastBrowserLoginLaunch();
|
|
1182
|
+
// Relaunch in the SAME window mode the user chose: silently reopening a
|
|
1183
|
+
// visible window for someone running headless would be exactly the
|
|
1184
|
+
// surprise window they turned headless to avoid.
|
|
1185
|
+
const headless = resolveHeadlessPreference(lastLogin?.headless);
|
|
1186
|
+
// Rejoin the same virtual display the user set up, so recovery does not
|
|
1187
|
+
// put a window back on a desktop they deliberately keep empty.
|
|
1188
|
+
const virtualDisplay = lastLogin?.virtual_display !== undefined || resolveVirtualDisplayPreference()
|
|
1189
|
+
? await ensureVirtualDisplay(lastLogin?.virtual_display !== undefined ? { displayNumber: lastLogin.virtual_display } : {}).catch(() => undefined)
|
|
1190
|
+
: undefined;
|
|
1081
1191
|
const opened = openChatGptBrowser({
|
|
1082
1192
|
...(options.port !== undefined ? { port: options.port } : {}),
|
|
1083
|
-
...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {})
|
|
1193
|
+
...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {}),
|
|
1194
|
+
...(headless ? { headless } : {}),
|
|
1195
|
+
...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
|
|
1084
1196
|
});
|
|
1085
1197
|
await assertBrowserLaunchStayedAlive(opened);
|
|
1086
1198
|
const ready = await waitForChatGptLoginReady(stderr, { port: opened.port, timeoutMs: 120_000 });
|
|
1087
|
-
if (ready)
|
|
1088
|
-
|
|
1089
|
-
|
|
1199
|
+
if (!ready)
|
|
1200
|
+
return false;
|
|
1201
|
+
// Restore the no-window setup too: someone who runs minimized does not
|
|
1202
|
+
// want recovery to leave a window sitting on their desktop.
|
|
1203
|
+
if (!headless && (lastLogin?.minimized === true || resolveMinimizeWindowPreference())) {
|
|
1204
|
+
const outcome = await minimizeChatGptWindow({ port: opened.port }).catch(() => undefined);
|
|
1205
|
+
if (outcome?.minimized)
|
|
1206
|
+
stderr("recover: window minimized again");
|
|
1207
|
+
}
|
|
1208
|
+
stderr("recover: browser READY - retrying the send...");
|
|
1209
|
+
return true;
|
|
1090
1210
|
}
|
|
1091
1211
|
catch (error) {
|
|
1092
1212
|
stderr(`recover: failed - ${errorMessage(error)}`);
|
|
@@ -1185,6 +1305,18 @@ export function browserSendBlockerFromError(error) {
|
|
|
1185
1305
|
next_step: `Rerun with a bigger budget (${formatDurationMs(suggestedMs)}): \`prodex pro browser ask --timeout-ms ${suggestedMs} "<same prompt>"\`.`
|
|
1186
1306
|
};
|
|
1187
1307
|
}
|
|
1308
|
+
// A CDP command timeout means the page's renderer stalled, which in the
|
|
1309
|
+
// field means a very long thread (or a long prompt landing on one). Generic
|
|
1310
|
+
// "resolve the browser issue manually" advice gave the caller nothing to do.
|
|
1311
|
+
const cdpTimeout = message.match(/Chrome DevTools command timed out: (\S+)/);
|
|
1312
|
+
if (cdpTimeout) {
|
|
1313
|
+
return {
|
|
1314
|
+
code: "browser_cdp_timeout",
|
|
1315
|
+
message,
|
|
1316
|
+
retryable: true,
|
|
1317
|
+
next_step: "The ChatGPT tab stopped responding (usually a very long thread). Retry with `--new-chat` for a fresh, light thread, or reload the tab in the browser first."
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1188
1320
|
return {
|
|
1189
1321
|
code: "browser_send_failed",
|
|
1190
1322
|
message,
|
|
@@ -1260,33 +1392,52 @@ export async function getConsult(store, taskId, options = {}) {
|
|
|
1260
1392
|
return isConsultRecord(record) ? record : undefined;
|
|
1261
1393
|
}
|
|
1262
1394
|
export async function latestTrustedConsult(store, options = { readOnly: true }) {
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1395
|
+
// Verify lazily, newest first, and stop at the first trusted consult.
|
|
1396
|
+
// Verifying the whole history to report ONE record made `pro browser check`
|
|
1397
|
+
// spend 42 of its 46 seconds in latest_pro on a repo with real history
|
|
1398
|
+
// (measured live) - each record is a full receipt scan - and agents read
|
|
1399
|
+
// that silence as a hung bridge.
|
|
1400
|
+
const records = await listConsultRecordsNewestFirst(store, options);
|
|
1401
|
+
let firstUntrusted;
|
|
1402
|
+
for (const record of records) {
|
|
1403
|
+
try {
|
|
1404
|
+
return { ...record, result: await store.getFinalizedResultReadOnly(record.result.task_id) };
|
|
1405
|
+
}
|
|
1406
|
+
catch (error) {
|
|
1407
|
+
if (isUntrustedResultError(error)) {
|
|
1408
|
+
firstUntrusted ??= error;
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
throw error;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
if (firstUntrusted)
|
|
1415
|
+
throw firstUntrusted;
|
|
1270
1416
|
return undefined;
|
|
1271
1417
|
}
|
|
1272
1418
|
export function legacyChatGptNamespaceError(subcommand) {
|
|
1273
1419
|
const prefix = subcommand ? `Unknown legacy chatgpt subcommand: ${subcommand}.` : "The legacy `chatgpt` namespace is hidden.";
|
|
1274
1420
|
return new Error(`${prefix} Use \`prodex pro browser help\` for visible-browser commands.`);
|
|
1275
1421
|
}
|
|
1276
|
-
|
|
1422
|
+
// Consult records (unverified) newest first, with the ledger-integrity checks
|
|
1423
|
+
// that must run regardless of how many records the caller ends up verifying.
|
|
1424
|
+
async function listConsultRecordsNewestFirst(store, options = { readOnly: true }) {
|
|
1277
1425
|
const [tasks, results] = options.readOnly === false
|
|
1278
1426
|
? await Promise.all([store.listTasks(), store.listResults()])
|
|
1279
1427
|
: await Promise.all([listTasksForInspection(store), listRawResultsForInspection(store)]);
|
|
1280
1428
|
const tasksById = new Map(tasks.map((task) => [task.id, task]));
|
|
1281
1429
|
assertNoMissingTerminalConsultResults(tasks, results);
|
|
1282
1430
|
assertNoOrphanConsultResults(tasksById, results);
|
|
1283
|
-
|
|
1431
|
+
return results
|
|
1284
1432
|
.map((result) => {
|
|
1285
1433
|
const task = tasksById.get(result.task_id);
|
|
1286
1434
|
return task ? { task, result } : undefined;
|
|
1287
1435
|
})
|
|
1288
1436
|
.filter((record) => Boolean(record && isConsultRecord(record)))
|
|
1289
1437
|
.sort((a, b) => b.result.created_at.localeCompare(a.result.created_at));
|
|
1438
|
+
}
|
|
1439
|
+
export async function listConsultListEntries(store, options = { readOnly: true }) {
|
|
1440
|
+
const records = await listConsultRecordsNewestFirst(store, options);
|
|
1290
1441
|
const entries = [];
|
|
1291
1442
|
for (const record of records) {
|
|
1292
1443
|
try {
|
|
@@ -1303,7 +1454,7 @@ export async function listConsultListEntries(store, options = { readOnly: true }
|
|
|
1303
1454
|
return entries;
|
|
1304
1455
|
}
|
|
1305
1456
|
export function printBrowserLoginGuide(stdout, input) {
|
|
1306
|
-
const windowAvailable = input.opened || input.reused === true;
|
|
1457
|
+
const windowAvailable = (input.opened || input.reused === true) && input.headless !== true;
|
|
1307
1458
|
const loginCommand = formatBrowserLoginCommand(input.sourceCli, input.commandOptions);
|
|
1308
1459
|
const runtimeCommandOptions = {
|
|
1309
1460
|
...(input.commandOptions?.cwd ? { cwd: input.commandOptions.cwd } : {}),
|
|
@@ -1312,11 +1463,20 @@ export function printBrowserLoginGuide(stdout, input) {
|
|
|
1312
1463
|
const checkCommand = formatBrowserCheckCommand(input.sourceCli, runtimeCommandOptions);
|
|
1313
1464
|
const smokeCommand = formatBrowserSmokeCommand(input.sourceCli, runtimeCommandOptions);
|
|
1314
1465
|
stdout("ChatGPT Pro browser login");
|
|
1315
|
-
stdout(input.reused
|
|
1316
|
-
?
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1466
|
+
stdout(input.headless && (input.opened || input.reused)
|
|
1467
|
+
? input.reused
|
|
1468
|
+
? "Headless ChatGPT browser is already running - reusing it (no window)."
|
|
1469
|
+
: "Started the dedicated ChatGPT browser headless (no window). It reuses the profile you signed in with."
|
|
1470
|
+
: input.reused
|
|
1471
|
+
? "Chrome is already running for ChatGPT - reusing it (no new window opened)."
|
|
1472
|
+
: input.opened
|
|
1473
|
+
? "Opened the dedicated Chrome window for ChatGPT."
|
|
1474
|
+
: "Dry run: no browser was opened.");
|
|
1475
|
+
if (input.headless && (input.opened || input.reused)) {
|
|
1476
|
+
stdout("");
|
|
1477
|
+
stdout(`Next: run \`${checkCommand}\` to confirm the session, then consult as usual.`);
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1320
1480
|
stdout("");
|
|
1321
1481
|
stdout("Steps:");
|
|
1322
1482
|
if (windowAvailable) {
|