@youdie006/prodex 0.17.0 → 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 CHANGED
@@ -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: headless mode
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 treats a fresh headless profile as suspicious ("Just a moment..." interstitial). A profile with an established logged-in session normally passes; if yours gets challenged, run headed for that session `prodex pro browser check` reports it as a blocker rather than hanging.
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
 
@@ -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;
@@ -331,7 +498,10 @@ export function isLikelyChatGptGeneratingControl(label) {
331
498
  }
332
499
  export function detectChatGptBlocker(text, visibleButtonLabels = []) {
333
500
  const haystack = `${text}\n${visibleButtonLabels.join("\n")}`.toLowerCase();
334
- if (/just a moment|checking if the site connection is secure|verify you are human|잠시만 기다려|연결이 안전한지/i.test(haystack)) {
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)) {
335
505
  return {
336
506
  code: "cloudflare_check",
337
507
  message: "ChatGPT is showing a Cloudflare or human-verification interstitial.",
@@ -662,7 +832,10 @@ export function openChatGptBrowser(options = {}) {
662
832
  url: options.url ?? "https://chatgpt.com/",
663
833
  ...(headless ? { headless } : {})
664
834
  });
665
- const child = spawn(command, args, { detached: true, stdio: "ignore", env: browserLaunchEnv() });
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 });
666
839
  let earlyExit;
667
840
  const earlyExitWaiters = new Set();
668
841
  const recordEarlyExit = (exit) => {
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({ port, profileDir, url: loginUrl, ...(headless ? { headless } : {}) });
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
- await recordBrowserLoginLaunch({ profile_dir: opened.profileDir, port: opened.port, headless });
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 headlessReady = await waitForChatGptLoginReady(io.stderr, { port: opened.port, timeoutMs: 30_000 });
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(`headless: the profile is not signed in. Run \`${formatBrowserLoginCommand(sourceCli, commandOptions)}\` WITHOUT --headless once, sign in, close that window, then rerun with --headless.`);
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";
@@ -1119,16 +1183,30 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1119
1183
  // visible window for someone running headless would be exactly the
1120
1184
  // surprise window they turned headless to avoid.
1121
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;
1122
1191
  const opened = openChatGptBrowser({
1123
1192
  ...(options.port !== undefined ? { port: options.port } : {}),
1124
1193
  ...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {}),
1125
- ...(headless ? { headless } : {})
1194
+ ...(headless ? { headless } : {}),
1195
+ ...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
1126
1196
  });
1127
1197
  await assertBrowserLaunchStayedAlive(opened);
1128
1198
  const ready = await waitForChatGptLoginReady(stderr, { port: opened.port, timeoutMs: 120_000 });
1129
- if (ready)
1130
- stderr("recover: browser READY - retrying the send...");
1131
- return ready;
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;
1132
1210
  }
1133
1211
  catch (error) {
1134
1212
  stderr(`recover: failed - ${errorMessage(error)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",