@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 +15 -3
- package/dist/browser-handoff.js +189 -0
- package/dist/chatgpt-browser.js +137 -17
- package/dist/cli-help.js +4 -4
- package/dist/cli-pro.js +121 -20
- package/dist/continue-thread.js +29 -4
- package/dist/issue-report.js +1 -3
- package/docs/cli-reference.md +11 -5
- package/docs/clients.md +11 -3
- package/docs/releasing.md +15 -1
- package/package.json +4 -2
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.
|
|
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`
|
|
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
|
|
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
|
+
}
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -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
|
-
|
|
1414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 !==
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
//
|
|
396
|
-
//
|
|
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
|
|
404
|
-
: `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
|
-
|
|
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
|
|
1493
|
+
if (!["browser_unreachable", "chatgpt_page_missing"].includes(firstBlocker.code) ||
|
|
1463
1494
|
!firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed)
|
|
1464
1495
|
throw error;
|
|
1465
|
-
const
|
|
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
|
|
2540
|
-
"
|
|
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("
|
|
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.");
|
package/dist/continue-thread.js
CHANGED
|
@@ -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
|
|
157
|
-
if (input.projectId)
|
|
158
|
-
|
|
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) =>
|
|
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
|
}
|
package/dist/issue-report.js
CHANGED
|
@@ -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),
|
package/docs/cli-reference.md
CHANGED
|
@@ -118,7 +118,13 @@ What happens:
|
|
|
118
118
|
~/.local/share/prodex/chrome-chatgpt-pro
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
**
|
|
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.
|
|
78
|
-
browser profile
|
|
79
|
-
|
|
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`,
|
|
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.
|
|
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.
|
|
70
|
+
"vitest": "^4.1.11"
|
|
69
71
|
}
|
|
70
72
|
}
|