@youdie006/prodex 0.40.12 → 0.40.13

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
@@ -69,6 +69,10 @@ prodex pro latest # re-print the last answer
69
69
 
70
70
  `prodex ask` is the short form of `prodex pro browser ask`; every flag works on both. The login opens its own Chrome profile (`~/.local/share/prodex/chrome-chatgpt-pro`), never your daily browser, and in a terminal it keeps watching the window and names the manual step still missing (sign in, clear a check, open a chat) until it reports READY.
71
71
 
72
+ For a one-time visible login followed by a verified headless handoff, use `prodex pro browser login --background`. Keep the window open until `background: READY`: prodex verifies login, gracefully closes only an idle dedicated browser, then reopens the same profile and conversation headless. Future CLI/MCP relaunches reuse that mode. Other tabs, Chrome account/permission confirmation windows, unfinished input, and active answers block the handoff. A verification challenge remains a blocker; this option does not guarantee that ChatGPT accepts headless Chrome.
73
+
74
+ Finish any native browser or OS confirmation before requesting the handoff: Chrome does not expose every native dialog through its page-control interface. The guard detects page targets and rendered dialogs, not every OS prompt. Incognito, guest, and explicitly selected Chrome sub-profiles are refused.
75
+
72
76
  While Pro thinks, progress goes to stderr: connecting, prompt sent, elapsed time while generating. A Pro selection raises the send budget to twenty minutes on its own; `--timeout-ms` overrides it. Answers are read from the rendered page, so formatting can differ from the original message. If the dedicated browser is not running, an interactive `ask` starts it, waits for your saved session, and retries once (`--no-auto-login` turns that off; scripts opt in with `--auto-login`).
73
77
 
74
78
  If the browser stops responding after your question was sent, prodex stops without sending it again. Use the `thread` and `request_id` from the error with `prodex pro browser recover --target-url <thread-url> --request-id <32hex>` (MCP: `pro_recover`). The request ID verifies that the recovered assistant answer follows that exact marked user turn. Legacy recovery without it remains available but returns `request_verified: false` and a warning.
@@ -179,9 +183,9 @@ The last recorded window mode is reused by later `login` commands and by CLI/MCP
179
183
 
180
184
  `--minimized` keeps a window but minimizes it. Under WSLg a minimized Chrome still reports itself visible and consults keep working; a normal Linux desktop marks it hidden, and prodex refuses to send into a tab it cannot read, restores the window, and tells you.
181
185
 
182
- `--headless` exists and is not usable against ChatGPT today: measured on a signed-in profile, headless Chrome stays on Cloudflare's interstitial past sixty seconds. Only the window is optional; the login is not.
186
+ `--headless` may be blocked even with a saved login: a previous test on a signed-in profile stayed on Cloudflare's interstitial past sixty seconds. `--background` verifies the actual handoff and only reports READY when the signed-in composer works headless. A challenge does not prove that the saved login was lost. Only the window is optional; authentication and protection checks still apply.
183
187
 
184
- If a hidden or virtual browser needs login, captcha, Cloudflare, or account verification, close that browser yourself and run `prodex pro browser login --headed` to complete the interactive step. Merely omitting `--headless` does not switch modes because the saved mode persists. prodex does not bypass the protection or kill a running browser to change its mode.
188
+ If a hidden or virtual browser needs login, captcha, Cloudflare, or account verification, close that browser yourself and run `prodex pro browser login --headed` to complete the interactive step. Merely omitting `--headless` does not switch modes because the saved mode persists. prodex does not bypass protection or force-kill a browser for a mode change; `--background` permits only the guarded graceful handoff described above.
185
189
 
186
190
  Before a prompt is submitted, a browser confirmed to have stopped answering its control port can be ended and started fresh, and the receipt says so (`PRODEX_NO_AUTO_CLEAR=1` turns that off). A browser that is merely slow is left alone. After submission, prodex never auto-resends a lost prompt.
187
191
 
@@ -249,6 +253,8 @@ Reports are deduplicated by blocker code, so something that stays broken adds to
249
253
 
250
254
  **Can I close the terminal after login?** Yes, once login is READY: the dedicated Chrome is launched separately. Keep that browser running for consults. If a CLI or agent exits during a request, ChatGPT may still finish it; recover the original thread and request ID rather than automatically sending the question again.
251
255
 
256
+ **Does closing the Chrome window switch to headless?** No. Use `login --background` and wait for `background: READY`; ordinary headed login never silently changes mode. A closed window alone is not evidence of logout. Before an unsent request, MCP or an auto-login-enabled CLI can reopen one missing ChatGPT tab in the existing browser and recheck the saved login without restarting Chrome.
257
+
252
258
  **It stopped with `browser_tab_crashed` or `Runtime.enable`.** Chrome can leave an "Aw, Snap!" tab listed on its control port even though that tab's renderer has crashed. Before typing a new prompt, prodex can reload a confirmed crashed tab once at the same conversation address and records `browser_tab_recovered`. A timeout alone never authorizes a reload. A crash after a prompt was submitted stops without resending; inspect the original conversation, then recover with both `--target-url` and `--request-id` from the blocker. Other tabs, the browser profile, and saved login are left alone.
253
259
 
254
260
  **A send failed with `send_ui_changed`.** ChatGPT redesigned the composer or send control. Update (`npm i -g @youdie006/prodex@latest`); if it persists, `prodex pro report-issue`, and paste the prompt by hand meanwhile.
@@ -0,0 +1,189 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ import WebSocket from "ws";
5
+ import { ChatGptBrowserBlockerError, attachmentPresenceExpression, composerTextStateExpression, detectChatGptPageBlocker, findLaunchedBrowserProcesses, inferChatGptPageLoggedInLikely, statusExpression } from "./chatgpt-browser.js";
6
+ function blocked(message) {
7
+ throw new ChatGptBrowserBlockerError({
8
+ code: "browser_handoff_blocked", message, retryable: false,
9
+ next_step: "Keep only the intended idle ChatGPT tab open, finish any Chrome account or permission confirmation yourself, and retry `prodex pro browser login --background`. Do not close active work or log in again solely because the handoff stopped."
10
+ });
11
+ }
12
+ function browserIdentity(port, profileDir) {
13
+ const listed = spawnSync("ps", ["-Ao", "user,pid,command"], { encoding: "utf8", timeout: 5_000 });
14
+ if (listed.status !== 0 || typeof listed.stdout !== "string")
15
+ blocked("Could not verify the dedicated browser process.");
16
+ const pids = findLaunchedBrowserProcesses(listed.stdout, { port, profileDir });
17
+ const mains = listed.stdout.split(/\r?\n/).filter((line) => {
18
+ const pid = Number(/^\s*\S+\s+(\d+)\s/.exec(line)?.[1]);
19
+ return pids.includes(pid) && !/\s--type=/.test(line) && new RegExp(`--remote-debugging-port=${port}(?!\\d)`).test(line);
20
+ });
21
+ if (mains.length !== 1)
22
+ blocked("Could not identify exactly one dedicated browser for this port.");
23
+ if (/\s--(?:incognito|guest)(?:\s|=|$)/.test(mains[0]))
24
+ blocked("An incognito or guest browser cannot preserve its login through a restart.");
25
+ if (/\s--profile-directory(?:\s|=|$)/.test(mains[0]))
26
+ blocked("An explicitly selected Chrome sub-profile cannot be preserved by this handoff; no browser was closed.");
27
+ const actualProfile = /--user-data-dir=(.*?)(?=\s--|$)/.exec(mains[0])?.[1];
28
+ if (!actualProfile || !path.isAbsolute(actualProfile))
29
+ blocked("The browser profile could not be verified.");
30
+ try {
31
+ if (realpathSync(actualProfile) !== realpathSync(profileDir))
32
+ blocked("The actual browser profile differs from the requested profile.");
33
+ }
34
+ catch (error) {
35
+ if (error instanceof ChatGptBrowserBlockerError)
36
+ throw error;
37
+ blocked("The browser profile path could not be verified.");
38
+ }
39
+ return { main: Number(/^\s*\S+\s+(\d+)\s/.exec(mains[0])[1]), pids, headless: /\s--headless(?:\s|=|$)/.test(mains[0]) };
40
+ }
41
+ export function getDedicatedBrowserHeadlessMode(options) {
42
+ return browserIdentity(options.port, options.profileDir).headless;
43
+ }
44
+ async function readJson(port, resource) {
45
+ const response = await fetch(`http://127.0.0.1:${port}/json/${resource}`, { signal: AbortSignal.timeout(2_000) });
46
+ if (!response.ok)
47
+ blocked(`The browser did not answer the ${resource} identity check.`);
48
+ return response.json();
49
+ }
50
+ function localSocket(value, port, kind) {
51
+ if (typeof value !== "string")
52
+ blocked("Missing browser control socket.");
53
+ const url = new URL(value);
54
+ if (url.protocol !== "ws:" || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) ||
55
+ Number(url.port) !== port || url.username || url.password || !url.pathname.startsWith(`/devtools/${kind}/`)) {
56
+ blocked("The control socket is not bound to the expected local browser.");
57
+ }
58
+ return value;
59
+ }
60
+ async function singlePage(port) {
61
+ const response = await readJson(port, "list");
62
+ if (!Array.isArray(response))
63
+ blocked("The browser page list is invalid.");
64
+ const pages = response.filter((page) => page?.type === "page");
65
+ if (pages.length !== 1)
66
+ blocked("The dedicated browser has other tabs or a Chrome confirmation surface; nothing was closed.");
67
+ const page = pages[0];
68
+ const url = new URL(page.url);
69
+ if (url.protocol !== "https:" || url.hostname !== "chatgpt.com" || url.username || url.password || url.port ||
70
+ url.searchParams.has("temporary-chat") || !page.id) {
71
+ blocked("Handoff requires one normal, non-temporary ChatGPT page.");
72
+ }
73
+ localSocket(page.webSocketDebuggerUrl, port, "page");
74
+ return page;
75
+ }
76
+ // Unlike normal send connections this must not enable Page or answer dialogs.
77
+ async function request(socketUrl, method, params = {}) {
78
+ return new Promise((resolve, reject) => {
79
+ const socket = new WebSocket(socketUrl);
80
+ let sent = false;
81
+ let finished = false;
82
+ const finish = (error, value) => {
83
+ if (finished)
84
+ return;
85
+ finished = true;
86
+ clearTimeout(timer);
87
+ socket.removeAllListeners();
88
+ socket.on("error", () => { });
89
+ socket.terminate();
90
+ if (error)
91
+ reject(error);
92
+ else
93
+ resolve(value);
94
+ };
95
+ const timer = setTimeout(() => finish(new Error(`${method} timed out; browser handoff stopped.`)), 5_000);
96
+ socket.once("open", () => {
97
+ try {
98
+ socket.send(JSON.stringify({ id: 1, method, params }));
99
+ sent = true;
100
+ }
101
+ catch (error) {
102
+ finish(error instanceof Error ? error : new Error(String(error)));
103
+ }
104
+ });
105
+ socket.on("message", (data) => {
106
+ let reply;
107
+ try {
108
+ reply = JSON.parse(data.toString());
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ if (reply.id === 1)
114
+ finish(reply.error ? new Error(reply.error.message ?? "CDP command failed") : undefined, reply.result);
115
+ });
116
+ socket.once("error", (error) => finish(error));
117
+ socket.once("close", () => finish(method === "Browser.close" && sent ? undefined : new Error("Browser control socket closed before verification.")));
118
+ });
119
+ }
120
+ async function verifyIdle(page) {
121
+ const expression = `(() => {
122
+ const status = ${statusExpression()};
123
+ const composer = ${composerTextStateExpression()};
124
+ const visible = (e) => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden';
125
+ const attachmentState = ${attachmentPresenceExpression()};
126
+ return { status,
127
+ draft: composer.ok !== false || composer.reason !== 'Composer stayed empty after text insertion',
128
+ dialog: [...document.querySelectorAll('dialog[open],[role="dialog"],[aria-modal="true"]')].some(visible),
129
+ attachments: attachmentState.removed > 0 || [...document.querySelectorAll('input[type="file"]')].some(e => e.files?.length > 0) || [...document.querySelectorAll('[role="progressbar"]')].some(visible),
130
+ unpersisted: !location.pathname.includes('/c/') && !!document.querySelector('[data-message-author-role]')
131
+ };
132
+ })()`;
133
+ const reply = await request(page.webSocketDebuggerUrl, "Runtime.evaluate", { expression, returnByValue: true });
134
+ const value = reply?.result?.value;
135
+ if (reply?.exceptionDetails || !value?.status || [value.draft, value.dialog, value.attachments, value.unpersisted].some((v) => typeof v !== "boolean")) {
136
+ blocked("Could not verify the page's input and dialog state.");
137
+ }
138
+ const state = value.status;
139
+ if (state.url !== page.url || !state.hasComposer || !inferChatGptPageLoggedInLikely(state) || detectChatGptPageBlocker(state)) {
140
+ blocked("ChatGPT is not ready in the expected conversation; no browser was closed.");
141
+ }
142
+ if (state.generating || state.awaitingResponseChoice || state.openDialogText || value.draft || value.dialog || value.attachments || value.unpersisted) {
143
+ blocked("The page has active work, unfinished input, attachments, or a dialog; no browser was closed.");
144
+ }
145
+ }
146
+ function alive(pid) {
147
+ try {
148
+ process.kill(pid, 0);
149
+ return true;
150
+ }
151
+ catch (error) {
152
+ return error.code !== "ESRCH";
153
+ }
154
+ }
155
+ /** Caller holds the shared browser send lock through this close AND relaunch. */
156
+ export async function closeIdleChatGptBrowserForHandoff(options) {
157
+ const { port, profileDir } = options;
158
+ if (!Number.isInteger(port) || port < 1 || port > 65535 || !path.isAbsolute(profileDir))
159
+ blocked("Invalid browser handoff identity.");
160
+ const identity = browserIdentity(port, profileDir);
161
+ const page = await singlePage(port);
162
+ const version = await readJson(port, "version");
163
+ const socket = localSocket(version?.webSocketDebuggerUrl, port, "browser");
164
+ await verifyIdle(page);
165
+ const current = await singlePage(port);
166
+ const currentVersion = await readJson(port, "version");
167
+ if (current.id !== page.id || current.url !== page.url || current.webSocketDebuggerUrl !== page.webSocketDebuggerUrl ||
168
+ currentVersion.webSocketDebuggerUrl !== socket || browserIdentity(port, profileDir).main !== identity.main) {
169
+ blocked("The browser or conversation changed during handoff verification.");
170
+ }
171
+ await verifyIdle(current);
172
+ await request(socket, "Browser.close");
173
+ const deadline = Date.now() + 10_000;
174
+ while (Date.now() < deadline) {
175
+ if (!identity.pids.some(alive)) {
176
+ try {
177
+ await readJson(port, "version");
178
+ }
179
+ catch (error) {
180
+ const cause = error.cause;
181
+ if (cause?.code === "ECONNREFUSED")
182
+ return { url: page.url };
183
+ blocked("The old browser exited, but its control port could not be verified closed.");
184
+ }
185
+ }
186
+ await new Promise((resolve) => setTimeout(resolve, 250));
187
+ }
188
+ blocked("The dedicated browser did not finish closing. It was not force-killed and no replacement was launched.");
189
+ }
package/dist/cli-help.js CHANGED
@@ -19,7 +19,7 @@ Ask / consult commands:
19
19
  prodex ask [same flags as pro browser ask] "prompt" # top-level shortcut for pro browser ask
20
20
  prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] [--attach path] [--tool deep-research|web-search|create-image] "prompt" # dry-run preview
21
21
  prodex pro debate-prompt [--topic "..."] [--rounds 2] [--source-cli /absolute/path/to/dist/cli.js] # print an agent prompt for a structured GPT Pro debate
22
- prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] # preview/open browser login
22
+ prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background] # preview/open browser login
23
23
  prodex pro browser help [--source-cli /absolute/path/to/dist/cli.js]
24
24
  prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 1500]
25
25
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]
@@ -166,7 +166,7 @@ Commands:
166
166
  prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] [--attach path] [--tool deep-research|web-search|create-image] "prompt"
167
167
  prodex pro debate-prompt [--topic "..."] [--rounds 2] [--source-cli /absolute/path/to/dist/cli.js]
168
168
  prodex pro browser help [--source-cli /absolute/path/to/dist/cli.js]
169
- prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000]
169
+ prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background]
170
170
  prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
171
171
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
172
172
  prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js]
@@ -251,8 +251,8 @@ export function printProBrowserHelp(stdout, sourceCli) {
251
251
  const cli = formatCliCommand(sourceCli);
252
252
  const sourceCliOption = formatSourceCliOption(sourceCli);
253
253
  const loginUsage = sourceCli
254
- ? `${cli} pro browser login${sourceCliOption} [--cwd /absolute/path/to/repo] [--dry-run] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000]`
255
- : "prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000]";
254
+ ? `${cli} pro browser login${sourceCliOption} [--cwd /absolute/path/to/repo] [--dry-run] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background]`
255
+ : "prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background]";
256
256
  const checkUsage = sourceCli
257
257
  ? `${cli} pro browser check${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 1500]`
258
258
  : "prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 1500]";
package/dist/cli-pro.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, realpathSync, statSync } from "node:fs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { buildDryRunBundle } from "./bundle.js";
5
- import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveBrowserWindowMode, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
5
+ import { ChatGptBrowserBlockerError, DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveBrowserWindowMode, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
6
6
  import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
7
7
  import { printProBrowserHelp, printProHelp } from "./cli-help.js";
8
8
  import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
@@ -206,11 +206,15 @@ export async function runProCommand(rest, io, runCliFn) {
206
206
  if (browserSubcommand === "login") {
207
207
  if (printProBrowserHelpIfRequested(browserArgs, "pro browser login", io, {
208
208
  valueFlags: ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"],
209
- booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headed", "--headless", "--minimized", "--virtual-display"]
209
+ booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headed", "--headless", "--minimized", "--virtual-display", "--background"]
210
210
  })) {
211
211
  return 0;
212
212
  }
213
- assertOnlyOptions(browserArgs, "pro browser login", ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"], ["--dry-run", "--wait", "--no-wait", "--headed", "--headless", "--minimized", "--virtual-display"]);
213
+ assertOnlyOptions(browserArgs, "pro browser login", ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"], ["--dry-run", "--wait", "--no-wait", "--headed", "--headless", "--minimized", "--virtual-display", "--background"]);
214
+ const background = browserArgs.includes("--background");
215
+ if (background && browserArgs.some((arg) => ["--headed", "--headless", "--minimized", "--virtual-display", "--no-wait"].includes(arg))) {
216
+ throw new Error("--background cannot combine with another window mode or --no-wait: it waits for login, then verifies the headless handoff.");
217
+ }
214
218
  if (browserArgs.includes("--wait") && browserArgs.includes("--no-wait")) {
215
219
  throw new Error("pro browser login cannot combine --wait and --no-wait");
216
220
  }
@@ -231,8 +235,8 @@ export async function runProCommand(rest, io, runCliFn) {
231
235
  ? resolveBrowserProfileDirForLaunch(savedLaunchForPort.profile_dir)
232
236
  : undefined;
233
237
  const profileDir = requestedProfileDir ?? savedProfileDir;
234
- const windowMode = resolveBrowserWindowMode({
235
- flags: {
238
+ let windowMode = resolveBrowserWindowMode({
239
+ flags: background ? (savedLaunchForPort?.headless === true ? { headless: true } : { headed: true }) : {
236
240
  ...(browserArgs.includes("--headed") ? { headed: true } : {}),
237
241
  ...(browserArgs.includes("--headless") ? { headless: true } : {}),
238
242
  ...(browserArgs.includes("--virtual-display") ? { virtualDisplay: true } : {}),
@@ -260,15 +264,21 @@ export async function runProCommand(rest, io, runCliFn) {
260
264
  sourceCli,
261
265
  commandOptions
262
266
  });
267
+ if (background)
268
+ io.stdout("background: verify login, then hand the same profile to headless Chrome under the shared send lock. No prompt is sent.");
263
269
  return 0;
264
270
  }
265
271
  // If the dedicated Chrome is already reachable on this port, do NOT spawn
266
272
  // again: Chrome's singleton would just open ANOTHER window (the recurring
267
273
  // "extra windows" problem, which then blocks sends as
268
274
  // ambiguous_chatgpt_tabs). Reuse the running instance instead.
275
+ const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
276
+ if (background && alreadyRunning) {
277
+ const { getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
278
+ windowMode = { headless: getDedicatedBrowserHeadlessMode({ port, profileDir: profileDir ?? defaultChatGptProfileDir() }), virtualDisplay: false, minimized: false };
279
+ }
269
280
  const headless = windowMode.headless;
270
281
  const wantsVirtualDisplay = windowMode.virtualDisplay;
271
- const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
272
282
  if (alreadyRunning) {
273
283
  if (requestedProfileDir &&
274
284
  savedProfileDir &&
@@ -278,7 +288,7 @@ export async function runProCommand(rest, io, runCliFn) {
278
288
  // One Chrome profile cannot serve a headed and a headless instance at
279
289
  // once, and reusing the running one would silently ignore the
280
290
  // requested mode. Say so instead of pretending the switch took.
281
- const runningHeadless = savedLaunchForPort ? savedLaunchForPort.headless === true : undefined;
291
+ const runningHeadless = background ? headless : savedLaunchForPort ? savedLaunchForPort.headless === true : undefined;
282
292
  if (runningHeadless !== undefined && runningHeadless !== headless) {
283
293
  throw new Error(`A ${runningHeadless ? "headless" : "headed"} ChatGPT browser is already running on port ${port}, but ${headless ? "headless" : "headed"} was requested. Close the existing browser yourself, then rerun; prodex will not end it.`);
284
294
  }
@@ -379,6 +389,8 @@ export async function runProCommand(rest, io, runCliFn) {
379
389
  });
380
390
  if (minimizeNote)
381
391
  io.stdout(minimizeNote);
392
+ if (background && !headless)
393
+ io.stdout("background: keep this window open until login is ready; prodex will close the idle dedicated browser and verify the same profile headless.");
382
394
  if (headless) {
383
395
  // Nobody can sign in to a window that does not exist, so a headless
384
396
  // launch is only useful when the profile is already logged in.
@@ -392,18 +404,22 @@ export async function runProCommand(rest, io, runCliFn) {
392
404
  headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
393
405
  });
394
406
  if (!headlessReady) {
395
- // Name the real cause. Cloudflare rejects headless Chrome by
396
- // design (its docs list headless browsers as unsupported), and
397
- // reporting that as "not signed in" sent users to re-login over
398
- // and over instead of back to a headed window.
407
+ // A challenge is not evidence of a lost login. Report the observed
408
+ // blocker without promising that another login fixes it.
399
409
  const finalStatus = await getChatGptBrowserStatus({ port: opened.port, timeoutMs: 5_000 }).catch(() => undefined);
400
410
  const challenged = finalStatus?.blocker?.code === "cloudflare_check" || /just a moment/i.test(finalStatus?.title ?? "");
401
411
  io.stdout("");
402
412
  io.stdout(challenged
403
- ? `headless: Cloudflare challenged the headless browser and never let ChatGPT load. Run \`${formatHeadedBrowserLoginCommand(sourceCli, commandOptions)}\` for a visible interactive check; headless browsers are not supported by Cloudflare.`
404
- : `headless: the profile is not signed in. Run \`${formatHeadedBrowserLoginCommand(sourceCli, commandOptions)}\` for a visible interactive login, sign in, close that browser yourself, then rerun with --headless.`);
413
+ ? `headless: Cloudflare challenged this headless browser and ChatGPT did not become ready. Run \`${formatHeadedBrowserLoginCommand(sourceCli, commandOptions)}\` for a visible interactive check. This does not prove that the saved login was lost.`
414
+ : `headless: readiness was not confirmed (${finalStatus?.blocker?.code ?? "not_ready"}). This does not prove that the saved login was lost. Inspect the dedicated browser with \`${formatHeadedBrowserLoginCommand(sourceCli, commandOptions)}\`; complete an interactive step only if requested.`);
405
415
  return 1;
406
416
  }
417
+ if (background) {
418
+ const { getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
419
+ if (!getDedicatedBrowserHeadlessMode({ port: opened.port, profileDir: opened.profileDir }))
420
+ throw new Error("The browser is not actually headless; background readiness was not confirmed.");
421
+ io.stdout("background: READY - the saved login works headless. Later consults reuse this mode.");
422
+ }
407
423
  io.stdout("headless: signed-in session confirmed - consults will run with no visible window.");
408
424
  return 0;
409
425
  }
@@ -411,7 +427,7 @@ export async function runProCommand(rest, io, runCliFn) {
411
427
  // state instead of returning while login is still unfinished. Scripts
412
428
  // and agents (non-TTY) keep the immediate return unless --wait is
413
429
  // passed; --no-wait always skips.
414
- const shouldWaitForReady = !browserArgs.includes("--no-wait") && (browserArgs.includes("--wait") || io.isInteractive === true);
430
+ const shouldWaitForReady = background || (!browserArgs.includes("--no-wait") && (browserArgs.includes("--wait") || io.isInteractive === true));
415
431
  if (!shouldWaitForReady)
416
432
  return 0;
417
433
  const waitTimeoutMs = readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 300_000;
@@ -421,6 +437,14 @@ export async function runProCommand(rest, io, runCliFn) {
421
437
  windowMode,
422
438
  headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
423
439
  });
440
+ if (ready && background) {
441
+ return completeBackgroundBrowserLogin(io, {
442
+ port: opened.port,
443
+ profileDir: opened.profileDir,
444
+ timeoutMs: readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 30_000,
445
+ headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
446
+ });
447
+ }
424
448
  return ready ? 0 : 1;
425
449
  }
426
450
  if (browserSubcommand === "ask") {
@@ -1466,10 +1490,11 @@ export async function runAskProCommand(rest, io, beforeSend) {
1466
1490
  catch (error) {
1467
1491
  const firstBlocker = browserSendBlockerFromError(error);
1468
1492
  // Loss while reading an already-submitted prompt must not send it again.
1469
- if (firstBlocker.code !== "browser_unreachable" ||
1493
+ if (!["browser_unreachable", "chatgpt_page_missing"].includes(firstBlocker.code) ||
1470
1494
  !firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed)
1471
1495
  throw error;
1472
- const recovered = await attemptBrowserAutoRecovery(io.stderr, {
1496
+ const recover = firstBlocker.code === "chatgpt_page_missing" ? attemptMissingChatGptTabRecovery : attemptBrowserAutoRecovery;
1497
+ const recovered = await recover(io.stderr, {
1473
1498
  ...(browserPort !== undefined ? { port: browserPort } : {}),
1474
1499
  notes: recoveryNotes
1475
1500
  });
@@ -2005,6 +2030,41 @@ export function browserRecoveredNote(ended) {
2005
2030
  ? `browser_recovered: the dedicated browser stopped answering its control port and prodex ended it (pid ${ended.join(", ")}) and started a fresh one before sending. Anything it was doing at the time is gone; the profile and login were kept.`
2006
2031
  : "browser_recovered: the dedicated browser was not running, so prodex started it with the saved profile before sending. The login was kept; anything the old browser had open is gone.";
2007
2032
  }
2033
+ async function attemptMissingChatGptTabRecovery(stderr, options) {
2034
+ const port = resolveCdpPort(options.port);
2035
+ return withBrowserSendLock(30_000, (detail) => stderr(`recover: ${detail}`), async () => {
2036
+ const saved = await readLastBrowserLoginLaunch();
2037
+ if (saved?.port !== undefined && saved.port !== port) {
2038
+ throw new Error("Saved browser identity belongs to a different port; no tab was opened.");
2039
+ }
2040
+ let status = await getChatGptBrowserStatus({ port, timeoutMs: 2_000 });
2041
+ if (status.loggedInLikely && status.hasComposer && !status.blocker)
2042
+ return true;
2043
+ if (!status.reachable || status.blocker?.code !== "chatgpt_page_missing") {
2044
+ if (status.blocker)
2045
+ throw new ChatGptBrowserBlockerError(status.blocker);
2046
+ return false;
2047
+ }
2048
+ stderr("recover: the browser is running but its ChatGPT tab was closed; opening one tab and checking the saved login...");
2049
+ if (!await openChatGptTab(port))
2050
+ return false;
2051
+ const deadline = Date.now() + 30_000;
2052
+ while (Date.now() < deadline) {
2053
+ status = await getChatGptBrowserStatus({ port, timeoutMs: 2_000 });
2054
+ if (status.loggedInLikely && status.hasComposer && !status.blocker) {
2055
+ options.notes?.push("browser_tab_reopened: reopened the closed ChatGPT tab in the running browser and verified the saved login before sending. No previous prompt was resent.");
2056
+ return true;
2057
+ }
2058
+ if (status.blocker && !["login_required", "chatgpt_page_missing", "composer_not_ready", "browser_slow"].includes(status.blocker.code)) {
2059
+ throw new ChatGptBrowserBlockerError(status.blocker);
2060
+ }
2061
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
2062
+ }
2063
+ if (status.blocker)
2064
+ throw new ChatGptBrowserBlockerError(status.blocker);
2065
+ return false;
2066
+ });
2067
+ }
2008
2068
  export async function attemptBrowserAutoRecovery(stderr, options) {
2009
2069
  // Launching is right when the browser is gone and wrong when it is only deaf:
2010
2070
  // a second Chrome on the same profile joins the wedged one rather than
@@ -2131,6 +2191,35 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
2131
2191
  return false;
2132
2192
  }
2133
2193
  }
2194
+ async function completeBackgroundBrowserLogin(io, options) {
2195
+ return withBrowserSendLock(5_000, (detail) => io.stderr(`background: ${detail}`), async () => {
2196
+ const { closeIdleChatGptBrowserForHandoff, getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
2197
+ io.stderr("background: checking the dedicated browser before graceful handoff...");
2198
+ const { url } = await closeIdleChatGptBrowserForHandoff({ port: options.port, profileDir: options.profileDir });
2199
+ const opened = openChatGptBrowser({ port: options.port, profileDir: options.profileDir, headless: true, url });
2200
+ await assertBrowserLaunchStayedAlive(opened);
2201
+ // This records the actual process, not a claim that authentication succeeded.
2202
+ const actualHeadless = getDedicatedBrowserHeadlessMode({ port: opened.port, profileDir: opened.profileDir });
2203
+ await recordBrowserLoginLaunch({ port: opened.port, profile_dir: opened.profileDir, headless: actualHeadless, minimized: false });
2204
+ if (!actualHeadless)
2205
+ throw new Error("The replacement browser is not actually headless; background readiness was not confirmed.");
2206
+ const ready = await waitForChatGptLoginReady(io.stderr, {
2207
+ port: opened.port,
2208
+ timeoutMs: options.timeoutMs,
2209
+ windowMode: { headless: true, virtualDisplay: false, minimized: false },
2210
+ headedLoginCommand: options.headedLoginCommand
2211
+ });
2212
+ if (!ready) {
2213
+ const status = await getChatGptBrowserStatus({ port: opened.port, timeoutMs: 5_000 }).catch(() => undefined);
2214
+ io.stderr(`background: NOT READY (${status?.blocker?.code ?? "not_ready"}). The same profile was kept; this is not proof that your saved login was lost. No prompt was sent.`);
2215
+ return 1;
2216
+ }
2217
+ if (!getDedicatedBrowserHeadlessMode({ port: opened.port, profileDir: opened.profileDir }))
2218
+ throw new Error("The replacement browser is not actually headless; background readiness was not confirmed.");
2219
+ io.stdout("background: READY - the saved login works headless, with no visible window. You may close this terminal; later consults reuse this mode.");
2220
+ return 0;
2221
+ });
2222
+ }
2134
2223
  /**
2135
2224
  * Guided login: poll the visible browser until a logged-in ChatGPT tab with a
2136
2225
  * usable composer appears, narrating each state change so the user knows what
@@ -2864,7 +2953,8 @@ export function printBrowserLoginGuide(stdout, input) {
2864
2953
  stdout(`Profile: ${input.profileDir}`);
2865
2954
  stdout(`Debug: http://127.0.0.1:${input.port}`);
2866
2955
  if (windowAvailable) {
2867
- stdout("You can close this Chrome window after check/smoke or when you are done. The dedicated profile is reused next time.");
2956
+ stdout("Closing this Chrome window does not switch it to headless mode. Keep it open for headed consults; closing the terminal is different from closing the browser.");
2957
+ stdout(`For a one-time login followed by a verified no-window handoff, run \`${loginCommand} --background\`. The same profile is reused; a closed tab alone does not mean you logged out.`);
2868
2958
  }
2869
2959
  else {
2870
2960
  stdout("The dedicated profile path above will be reused by the real login command.");
@@ -118,7 +118,13 @@ What happens:
118
118
  ~/.local/share/prodex/chrome-chatgpt-pro
119
119
  ```
120
120
 
121
- You can close that Chrome window after check/smoke or when you are done. The next time you need it, run `pro browser login` or `pro browser check` again. `check` will tell you what to do if the browser is closed.
121
+ Closing that Chrome window does not switch to headless mode or prove that the saved login was erased. Keep it open for headed consults. Closing the terminal after READY is safe because Chrome is detached; closing the CLI or agent during a consult may interrupt answer collection.
122
+
123
+ For a one-time interactive login followed by a no-window handoff, run `prodex pro browser login --background`. It waits even without a terminal, verifies readiness, then under the shared send lock gracefully closes the idle dedicated browser and launches the same profile and conversation headless. Wait for `background: READY`. The resulting mode is recorded for future CLI/MCP relaunches. This option cannot be combined with other window modes or `--no-wait`; `--dry-run` previews without opening or closing anything. Other page targets (including Chrome account prompts), dialogs, drafts, attachments, active responses, and uncertain browser identity/shutdown stop the handoff. Headless authentication and protection checks are never bypassed.
124
+
125
+ If only the ChatGPT tab was closed, MCP and auto-login-enabled CLI requests can reopen one tab in the existing browser before an unsent request. They verify the saved login before sending and never use this path to resend an accepted or uncertain request.
126
+
127
+ Before requesting `--background`, finish native browser and OS confirmations yourself. Page targets and rendered dialogs are checked, but Chrome does not expose every native prompt through CDP. Incognito/guest mode and an explicit `--profile-directory` are refused because their login identity cannot be safely handed off by this launcher.
122
128
 
123
129
  Actual explicit visible-browser consult (`prodex ask` is the short form of `prodex pro browser ask`):
124
130
 
@@ -200,18 +206,18 @@ A browser already running on your desktop cannot be moved onto a virtual display
200
206
 
201
207
  ### Keeping the window, just out of the way
202
208
 
203
- `prodex pro browser login --minimized` (or `PRODEX_MINIMIZE_WINDOW=1`) launches the dedicated browser and then minimizes it. It stays a **real headed Chrome** which is the point, because Cloudflare admits headed browsers and rejects headless ones but nothing sits on your desktop.
209
+ `prodex pro browser login --minimized` (or `PRODEX_MINIMIZE_WINDOW=1`) launches the dedicated browser and then minimizes it. It stays a **real headed Chrome**, which worked in the previous test where headless Chrome encountered a challenge. This is not a guarantee that future verification checks will pass.
204
210
 
205
211
  The catch is what "minimized" means to your desktop. Under WSLg a minimized Chrome still reports `visibilityState: "visible"`, so consults keep working (measured: a real Pro send completed in 26s with the window minimized). A normal Linux desktop instead marks minimized windows hidden, and prodex refuses to send into a tab it cannot read — so it restores the window and tells you, rather than leaving you a browser it cannot use. Try it; the login says which case you are in.
206
212
 
207
- ### Headless mode (not usable against ChatGPT today)
213
+ ### Headless mode (requires live readiness verification)
208
214
 
209
215
  `prodex pro browser login --headless` (or `PRODEX_HEADLESS=1`, which also covers the MCP server and its auto-recovery) runs the dedicated browser with no visible window. Two constraints are real, not cosmetic:
210
216
 
211
217
  - **Sign in headed first.** Nobody can log in to a window that does not exist, so headless reuses a profile you already signed into. If login, captcha, Cloudflare, permission, or account verification is needed, close the hidden browser yourself and run `prodex pro browser login --headed` for a visible interactive window. Do not merely omit `--headless`: saved modes persist.
212
- - **One mode at a time.** A single Chrome profile cannot serve a headed and a headless instance simultaneously; close the running one before switching (prodex refuses the switch instead of silently reusing the wrong mode).
218
+ - **One mode at a time.** A single Chrome profile cannot serve a headed and a headless instance simultaneously. Use the guarded `--background` handoff for headed-to-headless operation, or close the browser yourself before an explicit mode switch.
213
219
 
214
- **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: if `prodex pro browser check` reports the challenge, run `prodex pro browser login --headed` and complete it visibly. prodex does not invoke a hidden API or bypass login/protection. Only the window is optional; the login is not.
220
+ **A saved login does not guarantee headless readiness.** A previous test on a signed-in profile remained on the "Just a moment..." interstitial past 60 seconds. That observation does not establish why the challenge appeared or prove that the saved login was lost. Require an actual READY result; if a protection check blocks the browser, stop and inspect it visibly. prodex does not invoke a hidden API or bypass login/protection. Only the window is optional; the login is not.
215
221
 
216
222
  If a consult finds the browser closed, prodex now relaunches it in the same mode you last used and retries once — including from the MCP server, which has no terminal to prompt in. `PRODEX_NO_AUTO_LOGIN=1` turns that off.
217
223
 
package/docs/releasing.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  How a version of `@youdie006/prodex` gets from `main` to npm, and the checks that guard it. Moved here from the README so the README can stay about using the tool.
4
4
 
5
+ ## 0.40.13 Verification Record
6
+
7
+ Release target: `v0.40.13` on `main`. The GitHub Release records the resolved commit, publication workflow and final installation checks; an installed candidate is not proof of public publication or a restarted MCP process.
8
+
9
+ - PASS: `npm run release:verify` after updating the obsolete window-closing assertions. This ran the full tests, typecheck, build, installed-package CLI/HTTP-MCP/stdio-MCP smoke and doctor.
10
+ - PASS: `npx vitest run tests/browser-handoff.test.ts tests/background-login.test.ts --maxWorkers=2`, 30 tests. Targeted regressions first failed for stale mode, incognito/guest, explicit Chrome sub-profile and incorrect launch-mode recording, then passed with the fixes.
11
+ - PASS: Node 20 imports the built handoff module; background dry-run works without opening a browser. This is not a Node 20 live Pro test.
12
+ - PASS: M3 candidate CLI without tmux refused the existing Chrome account confirmation, returned exit 1, did not report READY, and preserved the same browser PIDs and page targets. Runtime modules matched the tested local build by SHA-256.
13
+ - BLOCKED: M3's native account confirmation requires manual action before the actual close/relaunch can be tested. Not every OS dialog is exposed through CDP; native confirmations must be finished before requesting a handoff.
14
+ - BLOCKED: local WSL headless startup with the saved profile reached `cloudflare_check`, not READY. No Pro request was sent and no protective check was bypassed.
15
+ - Earlier full checks failed on two outdated CLI guidance assertions and then one installed-document assertion. Those expectations were corrected; the subsequent complete verification passed. WSL packaging used normalized staging because mount file modes are not publishable directly; the user's unrelated untracked file was left untouched.
16
+
17
+ The release adds a guarded, opt-in transition and more accurate blocker reporting. It does not claim that headless Pro consultation is verified on either deployment target or that authentication can be retained indefinitely.
18
+
5
19
  ## Publishing
6
20
 
7
21
  Publishing to npm runs entirely in CI with **no long-lived token** — auth is npm [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC), so nothing needs to store or paste an `NPM_TOKEN`, and every release carries a verifiable `--provenance` attestation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.12",
3
+ "version": "0.40.13",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",