@youdie006/prodex 0.40.11 → 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.
@@ -115,7 +119,7 @@ a configurable approval checkpoint (default 5, not a target round count); an
115
119
  See [same-task dialogue](docs/clients.md#same-task-dialogue) for the budget and
116
120
  `user_approved` contract. New topics still start fresh chats.
117
121
 
118
- Updating the installed npm package does not reload an MCP process that is already running. Reconnect the MCP server or restart the Codex/Claude client to load the new build. The dedicated browser profile is separate and remains signed in, so this does not require ChatGPT authentication again.
122
+ Updating the installed npm package does not reload an MCP process that is already running. Reconnect the MCP server or restart the Codex/Claude client to load the new build. This preserves the separate dedicated browser profile and does not itself require another login. ChatGPT can still expire the saved session; a `login_required` blocker means you need to sign in manually again.
119
123
 
120
124
  An MCP server usually starts without `--cwd`, so a per-repo default can be missed. For defaults that apply from any directory, set `PRODEX_DEFAULT_PROJECT`, `PRODEX_DEFAULT_MODEL`, `PRODEX_DEFAULT_EFFORT` or `PRODEX_DEFAULT_PRO_MODE` in the agent's MCP `env` block; a per-repo config still wins field by field.
121
125
 
@@ -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
 
@@ -245,6 +249,14 @@ Reports are deduplicated by blocker code, so something that stays broken adds to
245
249
 
246
250
  ## FAQ
247
251
 
252
+ **Do I need tmux?** No. Explicit CLI commands work in a normal terminal, and stdio MCP works through pipes without a terminal. Only the interactive picker and `setup --interactive` need keyboard input from a terminal. Keep the calling CLI or agent running while waiting for an answer: closing its terminal or disconnecting SSH can interrupt collection. tmux is an optional way to keep that foreground session alive, not a requirement. `prodex start` is also a foreground process, not an installed service.
253
+
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.
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
+
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.
259
+
248
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.
249
261
 
250
262
  **It stopped with `tab_not_visible`.** A tab counts as watchable only while its window is not minimized and it is the active tab. Leave the dedicated window behind your editor and it sends in the background; prodex never steals focus (`PRODEX_ACTIVATE_TAB=1` if you want the tab pulled forward on a stopped send).
@@ -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
+ }
@@ -4,6 +4,7 @@ import { accessSync, constants, statSync } from "node:fs";
4
4
  import net from "node:net";
5
5
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
+ import WsWebSocket from "ws";
7
8
  import { captureBrowserDiagnostics, diagnosticsEnabled, diagnosticsNote } from "./browser-diagnostics.js";
8
9
  import { withCrossProcessFileLock } from "./safe-file.js";
9
10
  import os from "node:os";
@@ -17,6 +18,22 @@ export class ChatGptBrowserBlockerError extends Error {
17
18
  this.blocker = blocker;
18
19
  }
19
20
  }
21
+ function crashedTabBlocker(thread, requestId) {
22
+ return {
23
+ code: "browser_tab_crashed",
24
+ message: "Chrome reported that this ChatGPT tab's renderer crashed. The browser itself may still be running.",
25
+ retryable: false,
26
+ next_step: requestId
27
+ ? `Do not resend automatically. Reload the crashed tab at the same address, then ${thread
28
+ ? `collect the original answer with \`prodex pro browser recover --target-url ${thread} --request-id ${requestId}\`.`
29
+ : `find [prodex-request:${requestId}] in the original conversation before recovering its answer; its thread URL was not captured.`}`
30
+ : "Before typing, prodex can reload a confirmed crashed tab once at the same address. If it crashes again, inspect Chrome's error screen; do not restart other tabs or resend an uncertain request.",
31
+ ...(thread ? { thread } : {})
32
+ };
33
+ }
34
+ function isCrashedTabError(error) {
35
+ return error instanceof ChatGptBrowserBlockerError && error.blocker.code === "browser_tab_crashed";
36
+ }
20
37
  function unsupportedChatGptOperationError(operation, nextStep) {
21
38
  return new ChatGptBrowserBlockerError({
22
39
  code: "unsupported_chatgpt_operation",
@@ -1410,9 +1427,18 @@ export async function getChatGptBrowserStatus(options = {}) {
1410
1427
  // `pro browser check --timeout-ms 5000` measured 65 seconds because every
1411
1428
  // evaluate silently used the default. Agents read that silence as a hung
1412
1429
  // bridge and start "recovering" a browser that is merely busy.
1413
- const state = await evaluateOnPage(page.page, statusExpression(), {
1414
- ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
1415
- });
1430
+ let state;
1431
+ try {
1432
+ state = await evaluateOnPage(page.page, statusExpression(), {
1433
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
1434
+ });
1435
+ }
1436
+ catch (error) {
1437
+ if (!isCrashedTabError(error))
1438
+ throw error;
1439
+ return { reachable: true, loggedInLikely: false, hasComposer: false, modelHints: [],
1440
+ url: page.page.url, blocker: crashedTabBlocker(page.page.url) };
1441
+ }
1416
1442
  const loggedInLikely = inferChatGptPageLoggedInLikely(state);
1417
1443
  // The busy verdict is checked against the transcript here too, so `check`
1418
1444
  // does not report a finished conversation as one still being written.
@@ -1862,6 +1888,47 @@ async function reloadPageAndAwaitComposer(page) {
1862
1888
  cdp.close();
1863
1889
  }
1864
1890
  }
1891
+ /** A crashed renderer cannot mark its document or enable Runtime before reload. */
1892
+ async function recoverCrashedPageBeforeSend(port, page) {
1893
+ const cdp = await connectCdp(page.webSocketDebuggerUrl, 2_000);
1894
+ try {
1895
+ if (!cdp.crashed())
1896
+ return { state: await readSettledChatGptPageStatus(page), reloaded: false };
1897
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(2_000) });
1898
+ if (!response.ok)
1899
+ throw new Error("Could not verify the crashed tab before reload");
1900
+ const pages = await response.json();
1901
+ const current = pages.find((candidate) => candidate.webSocketDebuggerUrl === page.webSocketDebuggerUrl && candidate.id === page.id);
1902
+ if (!current)
1903
+ throw new Error("The crashed ChatGPT tab closed before recovery; nothing was reloaded");
1904
+ assertChatGptTargetUrlMatches(current.url, page.url);
1905
+ if (!cdp.crashed())
1906
+ return { state: await readSettledChatGptPageStatus(page), reloaded: false };
1907
+ const reply = await cdp.send("Page.reload");
1908
+ if (reply.error?.message)
1909
+ throw new Error(`Page.reload failed: ${reply.error.message}`);
1910
+ const deadline = Date.now() + RELOAD_SETTLE_TIMEOUT_MS;
1911
+ while (Date.now() < deadline) {
1912
+ await sleep(250);
1913
+ try {
1914
+ const state = await cdp.evaluate(statusExpression());
1915
+ if (detectChatGptPageBlocker(state))
1916
+ return { state, reloaded: true };
1917
+ assertChatGptTargetUrlMatches(state.url, page.url);
1918
+ if (state.hasComposer)
1919
+ return { state, reloaded: true };
1920
+ }
1921
+ catch (error) {
1922
+ if (!/execution context|cannot find context|Runtime\.evaluate failed/i.test(error instanceof Error ? error.message : String(error)))
1923
+ throw error;
1924
+ }
1925
+ }
1926
+ throw new ChatGptBrowserBlockerError(crashedTabBlocker(page.url));
1927
+ }
1928
+ finally {
1929
+ cdp.close();
1930
+ }
1931
+ }
1865
1932
  // Every alternative is wording measured on the error page itself, anchored so
1866
1933
  // the whole body has to be made of them and nothing else. It may repeat one:
1867
1934
  // the heading and the button carry the same words.
@@ -3246,7 +3313,22 @@ export async function sendChatGptPrompt(options) {
3246
3313
  assertChatGptPageAvailable();
3247
3314
  }
3248
3315
  const page = pageResult.page;
3249
- let status = await readSettledChatGptPageStatus(page);
3316
+ const preflightWarnings = [];
3317
+ let status;
3318
+ try {
3319
+ status = await readSettledChatGptPageStatus(page);
3320
+ }
3321
+ catch (error) {
3322
+ if (!isCrashedTabError(error))
3323
+ throw error;
3324
+ emitProgress("waiting", "Chrome reported a crashed tab; checking same-tab recovery before typing");
3325
+ const recovered = await recoverCrashedPageBeforeSend(port, page);
3326
+ status = recovered.state;
3327
+ if (recovered.reloaded) {
3328
+ preflightWarnings.push("browser_tab_recovered: Chrome reported a crashed tab; reloaded the same conversation before typing. No previous prompt was resent.");
3329
+ emitProgress("waiting", "crashed tab reloaded at the same address; rechecking readiness");
3330
+ }
3331
+ }
3250
3332
  status = await ensureVisibleChatGptPage(port, page, status);
3251
3333
  const blocker = detectChatGptPageBlocker(status);
3252
3334
  if (blocker) {
@@ -3426,7 +3508,7 @@ export async function sendChatGptPrompt(options) {
3426
3508
  let beforeSubmit;
3427
3509
  let boundProjectId;
3428
3510
  let submitButtonFound = false;
3429
- const sendWarnings = [];
3511
+ const sendWarnings = [...preflightWarnings];
3430
3512
  // Anything the page put in front of prodex was answered on the caller's
3431
3513
  // behalf. The note is read at return time - there are several return paths -
3432
3514
  // so it is folded into the array rather than pushed from each of them.
@@ -3593,6 +3675,8 @@ export async function sendChatGptPrompt(options) {
3593
3675
  // looks the way it looked when it refused, and the selection failures this
3594
3676
  // project keeps hitting are invisible in the error text alone.
3595
3677
  await captureOnFailure(`send-${new Date().toISOString().replace(/[:.]/g, "-")}`);
3678
+ if (isCrashedTabError(error))
3679
+ throw attachSendWarnings(new ChatGptBrowserBlockerError(crashedTabBlocker(undefined, requestId)), sendWarnings);
3596
3680
  throw attachSendWarnings(error, sendWarnings);
3597
3681
  }
3598
3682
  finally {
@@ -3608,7 +3692,9 @@ export async function sendChatGptPrompt(options) {
3608
3692
  try {
3609
3693
  finalState = await evaluateOnPage(page, answerExpression());
3610
3694
  }
3611
- catch {
3695
+ catch (error) {
3696
+ if (isCrashedTabError(error))
3697
+ throw attachSendWarnings(new ChatGptBrowserBlockerError(crashedTabBlocker(undefined, requestId)), sendWarnings);
3612
3698
  // Transient CDP failure (command timeout, mid-poll navigation): retry the
3613
3699
  // poll rather than aborting the whole send.
3614
3700
  continue;
@@ -3704,6 +3790,8 @@ export async function sendChatGptPrompt(options) {
3704
3790
  finalState = observedState;
3705
3791
  }
3706
3792
  catch (error) {
3793
+ if (isCrashedTabError(error))
3794
+ throw attachSendWarnings(new ChatGptBrowserBlockerError(crashedTabBlocker(pinnedThreadUrl, requestId)), sendWarnings);
3707
3795
  if (error instanceof ChatGptBrowserBlockerError)
3708
3796
  throw error;
3709
3797
  // Transient CDP failure while the answer is streaming: retry. A throw here
@@ -3713,7 +3801,7 @@ export async function sendChatGptPrompt(options) {
3713
3801
  // sitting out the whole budget on it only delays the recovery.
3714
3802
  consecutiveReadFailures += 1;
3715
3803
  if (consecutiveReadFailures >= CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP) {
3716
- throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(pinnedThreadUrl ?? finalState?.url));
3804
+ throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(pinnedThreadUrl ?? finalState?.url, requestId));
3717
3805
  }
3718
3806
  continue;
3719
3807
  }
@@ -4417,8 +4505,10 @@ async function getChatGptPageVisibility(pages) {
4417
4505
  try {
4418
4506
  visibilityByPage.set(page.webSocketDebuggerUrl, await evaluateOnPage(page, "document.visibilityState", { timeoutMs: PAGE_VISIBILITY_PROBE_TIMEOUT_MS }));
4419
4507
  }
4420
- catch {
4421
- // Leave visibility unknown; untargeted sends treat unknown ChatGPT pages conservatively.
4508
+ catch (error) {
4509
+ if (isCrashedTabError(error))
4510
+ visibilityByPage.set(page.webSocketDebuggerUrl, "crashed");
4511
+ // Other failures leave visibility unknown; untargeted sends treat them conservatively.
4422
4512
  }
4423
4513
  }));
4424
4514
  return visibilityByPage;
@@ -4463,9 +4553,11 @@ export function resolveCdpTimeoutMs(explicit) {
4463
4553
  }
4464
4554
  async function connectCdp(webSocketUrl, timeoutMs) {
4465
4555
  const effectiveTimeoutMs = resolveCdpTimeoutMs(timeoutMs);
4466
- const ws = new WebSocket(webSocketUrl);
4556
+ const WebSocketConstructor = globalThis.WebSocket ?? WsWebSocket;
4557
+ const ws = new WebSocketConstructor(webSocketUrl);
4467
4558
  let id = 0;
4468
4559
  const pending = new Map();
4560
+ let crashError;
4469
4561
  /** Types of JavaScript dialog answered on this connection. */
4470
4562
  const dialogsAnswered = [];
4471
4563
  ws.addEventListener("message", (event) => {
@@ -4480,6 +4572,23 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4480
4572
  // the per-command timeout fires.
4481
4573
  return;
4482
4574
  }
4575
+ const method = message.method;
4576
+ if (method === "Inspector.targetCrashed") {
4577
+ crashError = new ChatGptBrowserBlockerError(crashedTabBlocker());
4578
+ for (const [messageId, waiter] of pending) {
4579
+ if (waiter.method === "Inspector.enable" || waiter.method === "Page.reload")
4580
+ continue;
4581
+ if (waiter.timer)
4582
+ clearTimeout(waiter.timer);
4583
+ pending.delete(messageId);
4584
+ waiter.reject(crashError);
4585
+ }
4586
+ return;
4587
+ }
4588
+ if (method === "Inspector.targetReloadedAfterCrash") {
4589
+ crashError = undefined;
4590
+ return;
4591
+ }
4483
4592
  if (message.id && pending.has(message.id)) {
4484
4593
  const waiter = pending.get(message.id);
4485
4594
  if (waiter.timer)
@@ -4505,7 +4614,7 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4505
4614
  for (const [messageId, waiter] of pending) {
4506
4615
  if (waiter.timer)
4507
4616
  clearTimeout(waiter.timer);
4508
- waiter.reject(new Error("Chrome DevTools websocket closed"));
4617
+ waiter.reject(crashError ?? new Error("Chrome DevTools websocket closed"));
4509
4618
  pending.delete(messageId);
4510
4619
  }
4511
4620
  });
@@ -4544,13 +4653,15 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4544
4653
  /** The timeout that closed this socket, so later commands can name it rather than a bare closed socket. */
4545
4654
  let closedByTimeout;
4546
4655
  const send = (method, params = {}) => {
4656
+ if (crashError && method !== "Inspector.enable" && method !== "Page.reload")
4657
+ return Promise.reject(crashError);
4547
4658
  // Once a timeout has closed the socket, every later command would sit out
4548
4659
  // its own full timeout for an answer that cannot come. Measured cost of not
4549
4660
  // checking: a 14-probe confirmation loop turning into minutes of silence.
4550
4661
  // The rejection carries the timeout that did the closing: a caller that
4551
4662
  // swallowed that first error and moved on would otherwise report "socket
4552
4663
  // not open", which names neither the stalled tab nor the dialog cure.
4553
- if (ws.readyState !== WebSocket.OPEN) {
4664
+ if (ws.readyState !== WebSocketConstructor.OPEN) {
4554
4665
  return Promise.reject(new Error(closedByTimeout
4555
4666
  ? `${closedByTimeout} (the connection was closed by that timeout before ${method})`
4556
4667
  : `Chrome DevTools websocket is not open (${method})`));
@@ -4563,7 +4674,7 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4563
4674
  ws.close();
4564
4675
  reject(new Error(closedByTimeout));
4565
4676
  }, Math.max(1, effectiveTimeoutMs));
4566
- pending.set(messageId, { resolve, reject, timer });
4677
+ pending.set(messageId, { method, resolve, reject, timer });
4567
4678
  ws.send(JSON.stringify({ id: messageId, method, params }));
4568
4679
  });
4569
4680
  };
@@ -4576,6 +4687,15 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4576
4687
  // caller's diagnosis from "command timed out: Runtime.enable" - which at
4577
4688
  // least names what was being attempted - into a bare "websocket closed".
4578
4689
  // Nothing waits on the arming, so a build without the domain is no worse off.
4690
+ // Inspector is browser-side and replays an existing renderer crash. Runtime
4691
+ // and DOM commands cannot diagnose that state because the renderer is gone.
4692
+ try {
4693
+ await send("Inspector.enable");
4694
+ }
4695
+ catch (error) {
4696
+ ws.close();
4697
+ throw error;
4698
+ }
4579
4699
  ws.send(JSON.stringify({ id: ++id, method: "Page.enable", params: {} }));
4580
4700
  const evaluate = async (expression) => {
4581
4701
  const response = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
@@ -4585,7 +4705,7 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4585
4705
  throw new Error("Runtime.evaluate failed");
4586
4706
  return response.result?.result?.value;
4587
4707
  };
4588
- return { send, evaluate, dialogsAnswered, close: () => ws.close() };
4708
+ return { send, evaluate, dialogsAnswered, crashed: () => crashError !== undefined, close: () => ws.close() };
4589
4709
  }
4590
4710
  // In-page reasoning-header placeholder test, shared by statusExpression and
4591
4711
  // answerExpression. MUST stay in sync with isUsableChatGptAnswer: a header
@@ -5154,7 +5274,7 @@ export function chunkComposerText(text, size = COMPOSER_INSERT_CHUNK_CHARS) {
5154
5274
  // Reading the page can fail for a moment (a navigation, a busy renderer). Five
5155
5275
  // failures in a row is not a moment - it is a browser that went away.
5156
5276
  const CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP = 5;
5157
- export function browserLostMidWaitBlocker(threadUrl) {
5277
+ export function browserLostMidWaitBlocker(threadUrl, requestId) {
5158
5278
  // Killing the browser aborts a streaming answer too, so promise nothing
5159
5279
  // about the answer itself - only say where to look. Deep research is the one
5160
5280
  // case that genuinely keeps going without us.
@@ -5165,8 +5285,8 @@ export function browserLostMidWaitBlocker(threadUrl) {
5165
5285
  // Retrying the send would duplicate a prompt that has already posted.
5166
5286
  retryable: false,
5167
5287
  next_step: threadUrl
5168
- ? `Run \`prodex pro browser login\` to reopen the browser, then collect the answer with \`prodex pro browser recover --target-url ${threadUrl}\` (MCP: pro_recover with thread ${threadUrl}).`
5169
- : "Run `prodex pro browser login` to reopen the browser, then inspect the original chat before asking again. The prompt was already submitted; prodex did not capture its thread URL.",
5288
+ ? `Run \`prodex pro browser login\` to reopen the browser, then collect the answer with \`prodex pro browser recover --target-url ${threadUrl}${requestId ? ` --request-id ${requestId}` : ""}\` (MCP: pro_recover with thread ${threadUrl}${requestId ? ` and request_id ${requestId}` : ""}).`
5289
+ : `Run \`prodex pro browser login\` to reopen the browser, then inspect the original chat before asking again. The prompt was already submitted; prodex did not capture its thread URL.${requestId ? ` Find [prodex-request:${requestId}] in that conversation before recovering its answer; do not resend automatically.` : ""}`,
5170
5290
  ...(threadUrl ? { thread: threadUrl } : {})
5171
5291
  };
5172
5292
  }
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") {
@@ -1219,20 +1243,27 @@ export async function runAskProCommand(rest, io, beforeSend) {
1219
1243
  // the name matching to do what it can; the send would fail on the same
1220
1244
  // browser anyway.
1221
1245
  let continuationProjectId;
1246
+ let ambiguousProjectName = false;
1222
1247
  if (continuationProject) {
1223
1248
  try {
1224
- continuationProjectId = projectIdFromSidebar(await listChatGptProjectsWithIds({ port: resolveCdpPort(readPortFlag(parsedAskPro.optionArgs, "--port")) }), continuationProject);
1249
+ const projects = await listChatGptProjectsWithIds({ port: resolveCdpPort(readPortFlag(parsedAskPro.optionArgs, "--port")) });
1250
+ continuationProjectId = projectIdFromSidebar(projects, continuationProject);
1251
+ ambiguousProjectName = !continuationProjectId && projects.some((project) => project.name.trim().toLowerCase() === continuationProject.trim().toLowerCase());
1225
1252
  }
1226
1253
  catch {
1227
1254
  continuationProjectId = undefined;
1228
1255
  }
1229
1256
  }
1257
+ if (ambiguousProjectName && continueTaskId === undefined) {
1258
+ throw new Error(`Project name "${continuationProject}" is ambiguous in the sidebar. Name the intended conversation with --continue-task <task_id>.`);
1259
+ }
1230
1260
  const resolved = resolveContinuationThread({
1231
1261
  consults: (await targetStore.listSessionsReadOnly()).map((session) => ({
1232
1262
  taskId: session.task_id ?? "",
1233
1263
  ...(session.session_key ? { sessionKey: session.session_key } : {}),
1234
1264
  ...(session.thread ? { thread: session.thread } : {}),
1235
1265
  status: session.status,
1266
+ warnings: session.warnings,
1236
1267
  ...(session.created_at ? { createdAt: session.created_at } : {})
1237
1268
  })),
1238
1269
  ...(continuationProject ? { project: continuationProject } : {}),
@@ -1459,10 +1490,11 @@ export async function runAskProCommand(rest, io, beforeSend) {
1459
1490
  catch (error) {
1460
1491
  const firstBlocker = browserSendBlockerFromError(error);
1461
1492
  // Loss while reading an already-submitted prompt must not send it again.
1462
- if (firstBlocker.code !== "browser_unreachable" ||
1493
+ if (!["browser_unreachable", "chatgpt_page_missing"].includes(firstBlocker.code) ||
1463
1494
  !firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed)
1464
1495
  throw error;
1465
- const recovered = await attemptBrowserAutoRecovery(io.stderr, {
1496
+ const recover = firstBlocker.code === "chatgpt_page_missing" ? attemptMissingChatGptTabRecovery : attemptBrowserAutoRecovery;
1497
+ const recovered = await recover(io.stderr, {
1466
1498
  ...(browserPort !== undefined ? { port: browserPort } : {}),
1467
1499
  notes: recoveryNotes
1468
1500
  });
@@ -1569,6 +1601,9 @@ export async function runAskProCommand(rest, io, beforeSend) {
1569
1601
  });
1570
1602
  if (destination.warning)
1571
1603
  persistenceWarnings.push(destination.warning);
1604
+ if (consult.requestVerified === false && !persistenceWarnings.some((warning) => warning.startsWith("request_unverified:"))) {
1605
+ persistenceWarnings.push("request_unverified: the saved answer was not verified against its requested user turn. Review the original conversation before continuing.");
1606
+ }
1572
1607
  // Truncation and other send warnings must be visible at runtime, not
1573
1608
  // only inside the persisted receipt: a caller who never opens .bridge
1574
1609
  // would otherwise treat a cut-off answer as complete.
@@ -1995,6 +2030,41 @@ export function browserRecoveredNote(ended) {
1995
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.`
1996
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.";
1997
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
+ }
1998
2068
  export async function attemptBrowserAutoRecovery(stderr, options) {
1999
2069
  // Launching is right when the browser is gone and wrong when it is only deaf:
2000
2070
  // a second Chrome on the same profile joins the wedged one rather than
@@ -2121,6 +2191,35 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
2121
2191
  return false;
2122
2192
  }
2123
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
+ }
2124
2223
  /**
2125
2224
  * Guided login: poll the visible browser until a logged-in ChatGPT tab with a
2126
2225
  * usable composer appears, narrating each state change so the user knows what
@@ -2536,8 +2635,9 @@ export function browserSendBlockerFromError(error) {
2536
2635
  code: "browser_cdp_timeout",
2537
2636
  message,
2538
2637
  retryable: true,
2539
- next_step: "The ChatGPT tab stopped responding. A very long thread does it, and so does an open JavaScript dialog - that one halts the page outright, and no retry gets past it because the browser will not let a late client dismiss it. " +
2540
- "Look at the visible window and close any dialog sitting on it, or reopen the window with `prodex pro browser login`. For a heavy thread, retry with `--new-chat` for a fresh, light one."
2638
+ next_step: "The ChatGPT tab stopped responding; a timeout alone does not prove it crashed. A long thread or an open JavaScript dialog can also stall it. " +
2639
+ "Inspect the visible tab: if Chrome shows 'Aw, Snap!', reload only that tab at the same address. Close an ordinary dialog manually; login, verification, and permission prompts require your action. " +
2640
+ "If the browser is gone, reopen it with `prodex pro browser login`. Do not resend automatically when the prompt may already have posted; recover its original thread and request ID. Use `--new-chat` only for a new request after the original is accounted for."
2541
2641
  };
2542
2642
  }
2543
2643
  return {
@@ -2853,7 +2953,8 @@ export function printBrowserLoginGuide(stdout, input) {
2853
2953
  stdout(`Profile: ${input.profileDir}`);
2854
2954
  stdout(`Debug: http://127.0.0.1:${input.port}`);
2855
2955
  if (windowAvailable) {
2856
- 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.`);
2857
2958
  }
2858
2959
  else {
2859
2960
  stdout("The dedicated profile path above will be reused by the real login command.");
@@ -72,6 +72,9 @@ export function projectIdsByName(threadUrls) {
72
72
  }
73
73
  return byName;
74
74
  }
75
+ function projectIdFromThreadUrl(threadUrl) {
76
+ return /\/g\/g-p-([0-9a-f]+)(?:-|\/)/i.exec(threadUrl)?.[1]?.toLowerCase();
77
+ }
75
78
  /**
76
79
  * Whether a recorded thread belongs to the project this send is for.
77
80
  *
@@ -153,13 +156,22 @@ export function resolveContinuationThread(input) {
153
156
  "Pass --session-key <id> (or PRODEX_SESSION_KEY/CODEX_THREAD_ID), or name the intended consult with --continue-task <task_id>."
154
157
  };
155
158
  }
156
- const knownProjectIds = new Set(input.project ? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project)) ?? [] : []);
157
- if (input.projectId)
158
- knownProjectIds.add(input.projectId.toLowerCase());
159
+ const recordedProjectIds = new Set(input.project ? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project)) ?? [] : []);
160
+ if (input.project && !input.projectId && recordedProjectIds.size > 1) {
161
+ return {
162
+ error: `Recorded project name "${input.project}" is ambiguous because its consult threads use multiple project ids. ` +
163
+ "Name the intended conversation with --continue-task <task_id>."
164
+ };
165
+ }
166
+ const selectedProjectId = input.project
167
+ ? input.projectId?.replace(/^g-p-/i, "").toLowerCase() ?? recordedProjectIds.values().next().value
168
+ : undefined;
159
169
  const candidates = withThread
160
170
  .filter((consult) => consult.sessionKey === input.sessionKey)
161
171
  .filter((consult) => consult.status === "done")
162
- .filter((consult) => threadMatchesProject(consult.thread, input.project, knownProjectIds))
172
+ .filter((consult) => selectedProjectId
173
+ ? projectIdFromThreadUrl(consult.thread) === selectedProjectId
174
+ : threadMatchesProject(consult.thread, input.project))
163
175
  .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
164
176
  const latest = candidates[0];
165
177
  if (!latest) {
@@ -169,5 +181,18 @@ export function resolveContinuationThread(input) {
169
181
  `Send once without --continue, or name a consult with --continue-task <task_id>.`
170
182
  };
171
183
  }
184
+ const unreliableWarningPrefixes = [
185
+ "answer_incomplete:",
186
+ "request_unverified:",
187
+ "receipt_record_warning:",
188
+ "session_record_warning:"
189
+ ];
190
+ const unreliableWarning = latest.warnings?.find((warning) => unreliableWarningPrefixes.some((prefix) => warning.startsWith(prefix)));
191
+ if (unreliableWarning) {
192
+ return {
193
+ error: `Cannot implicitly continue consult "${latest.taskId}" because its record is unreliable: ${unreliableWarning} ` +
194
+ `Recover and verify the original answer first. After explicit user review, name it with --continue-task ${latest.taskId}. Do not resend automatically.`
195
+ };
196
+ }
172
197
  return { target: { taskId: latest.taskId, thread: latest.thread } };
173
198
  }
@@ -49,9 +49,7 @@ export function buildIssueReport(consult, environment) {
49
49
  `| platform | ${environment.platform} |`,
50
50
  `| node | ${environment.nodeVersion} |`,
51
51
  "",
52
- "Private error details and recovery instructions are omitted. Review the local receipt before sharing more context.",
53
- "",
54
- "Receipt (local, not attached): " + consult.task_id
52
+ "Private error details and recovery instructions are omitted. Review the local receipt before sharing more context."
55
53
  ].join("\n");
56
54
  return {
57
55
  title: `${code}: ${message || "blocked consult"}`.slice(0, 120),
@@ -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/clients.md CHANGED
@@ -74,9 +74,17 @@ already-resolved target). An answer that failed to save or is incomplete does no
74
74
  invite another automatic turn; report it and resolve the blocker first.
75
75
 
76
76
  After updating the installed package, reconnect the MCP server or restart the agent
77
- client. A running stdio process keeps the old code until it exits. The dedicated
78
- browser profile is unchanged, so restarting Codex/Claude does not require signing in
79
- to ChatGPT again.
77
+ client. A running stdio process keeps the old code until it exits. This preserves the
78
+ dedicated browser profile and does not itself require signing in again. A saved
79
+ ChatGPT session can still expire independently; stop on `login_required` and finish
80
+ the login manually.
81
+
82
+ Stdio MCP does not require tmux or a terminal. The client launches prodex and keeps
83
+ its stdin/stdout pipes open. Keep that client or its remote SSH session running
84
+ while a consult is pending; restarting it can interrupt answer collection even if
85
+ ChatGPT is still generating. Recover that marked request instead of resending it.
86
+ The separate `prodex start` HTTP server runs in the foreground and also needs its
87
+ own process kept alive; neither command installs a background service.
80
88
 
81
89
  ## Claude Code
82
90
 
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.
@@ -15,7 +29,7 @@ git tag v0.8.2
15
29
  git push origin v0.8.2
16
30
  ```
17
31
 
18
- `.github/workflows/publish.yml` fires on a `v*.*.*` tag: it checks out, installs, verifies the tag equals `package.json`'s version, runs `release:verify`, and publishes with `npm publish --provenance --access public`. The tag/version guard prevents publishing a mismatched version.
32
+ `.github/workflows/publish.yml` fires on a `v*.*.*` tag: it checks out, installs, verifies the tag equals `package.json`'s version, builds, runs `release:check -- --metadata-only` and `release:verify`, then publishes with `npm publish --provenance --access public --ignore-scripts`. The explicit metadata check also runs for manual workflow dispatches. It is required because `--ignore-scripts` skips `prepublishOnly`; a separate main-branch CI run is not a substitute for checking the commit being published.
19
33
 
20
34
  One-time setup (owner, on npmjs.com): open the package → Settings → Trusted Publishing → add a GitHub Actions publisher for repo `youdie006/prodex` and workflow `publish.yml`. After that, no npm tokens are needed anywhere; revoke any previously issued automation tokens.
21
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.11",
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",
@@ -59,12 +59,14 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@modelcontextprotocol/sdk": "^1.13.3",
62
+ "ws": "^8.21.3",
62
63
  "zod": "^3.25.67"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@types/node": "^22.15.32",
67
+ "@types/ws": "^8.18.1",
66
68
  "tsx": "^4.20.3",
67
69
  "typescript": "^5.8.3",
68
- "vitest": "^4.1.9"
70
+ "vitest": "^4.1.11"
69
71
  }
70
72
  }