@youdie006/prodex 0.40.6 → 0.40.9
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 +31 -13
- package/dist/browser-send-lock.js +10 -131
- package/dist/chatgpt-browser.js +428 -746
- package/dist/cli-args.js +2 -0
- package/dist/cli-help.js +30 -24
- package/dist/cli-ledger.js +2 -0
- package/dist/cli-pro.js +328 -121
- package/dist/cli-server.js +2 -2
- package/dist/cli.js +1 -4
- package/dist/config.js +3 -3
- package/dist/continue-thread.js +9 -2
- package/dist/http-mcp.js +1 -1
- package/dist/issue-report.js +9 -4
- package/dist/mcp-tools.js +42 -10
- package/dist/mcp.js +11 -7
- package/dist/registry.js +54 -6
- package/dist/repo-write.js +22 -2
- package/dist/safe-file.js +249 -1
- package/dist/schema.js +8 -0
- package/dist/store.js +10 -1
- package/dist/tui-flow.js +0 -1
- package/dist/tui-run.js +62 -25
- package/dist/tui.js +68 -54
- package/docs/claude.md +6 -2
- package/docs/cli-reference.md +18 -7
- package/docs/clients.md +18 -6
- package/docs/http-mcp.md +5 -1
- package/docs/releasing.md +3 -1
- package/package.json +1 -1
package/dist/cli-pro.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { existsSync, statSync } from "node:fs";
|
|
1
|
+
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,
|
|
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";
|
|
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";
|
|
@@ -13,9 +13,19 @@ import { withBrowserSendLock } from "./browser-send-lock.js";
|
|
|
13
13
|
import { blockerCause, buildBlockerReport, CATCH_ALL_CODES } from "./blocker-report.js";
|
|
14
14
|
import { projectIdFromSidebar, resolveContinuationThread } from "./continue-thread.js";
|
|
15
15
|
import { readBridgeRoots } from "./registry.js";
|
|
16
|
+
import { ProdexRequestIdSchema, SessionKeySchema } from "./schema.js";
|
|
16
17
|
import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
|
|
17
18
|
import { CLI_VERSION } from "./cli-help.js";
|
|
18
19
|
import { PRODEX_ISSUE_REPO, buildIssueReport, fileGitHubIssue } from "./issue-report.js";
|
|
20
|
+
function resolveBrowserProfileDirForLaunch(profileDir, launchCwd = process.cwd()) {
|
|
21
|
+
const resolved = path.resolve(launchCwd, profileDir);
|
|
22
|
+
try {
|
|
23
|
+
return statSync(resolved).isDirectory() ? realpathSync(resolved) : resolved;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
19
29
|
export async function runChatgptCommand(rest, io) {
|
|
20
30
|
const [subcommand, ...chatgptArgs] = rest;
|
|
21
31
|
if (!subcommand || isHelpSubcommand(subcommand)) {
|
|
@@ -195,23 +205,45 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
195
205
|
if (browserSubcommand === "login") {
|
|
196
206
|
if (printProBrowserHelpIfRequested(browserArgs, "pro browser login", io, {
|
|
197
207
|
valueFlags: ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"],
|
|
198
|
-
booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headless", "--minimized", "--virtual-display"]
|
|
208
|
+
booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headed", "--headless", "--minimized", "--virtual-display"]
|
|
199
209
|
})) {
|
|
200
210
|
return 0;
|
|
201
211
|
}
|
|
202
|
-
assertOnlyOptions(browserArgs, "pro browser login", ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"], ["--dry-run", "--wait", "--no-wait", "--headless", "--minimized", "--virtual-display"]);
|
|
212
|
+
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"]);
|
|
203
213
|
if (browserArgs.includes("--wait") && browserArgs.includes("--no-wait")) {
|
|
204
214
|
throw new Error("pro browser login cannot combine --wait and --no-wait");
|
|
205
215
|
}
|
|
206
216
|
const loginUrl = readChatGptBrowserUrlFlag(browserArgs);
|
|
207
217
|
const sourceCli = resolveOptionalFileFlag(io.cwd, browserArgs, "--source-cli");
|
|
208
218
|
const targetCwd = readFlag(browserArgs, "--cwd") ? resolveCwdFlag(io.cwd, browserArgs) : undefined;
|
|
209
|
-
const
|
|
219
|
+
const requestedProfileFlag = readFlag(browserArgs, "--profile-dir");
|
|
220
|
+
const requestedProfileDir = requestedProfileFlag
|
|
221
|
+
? resolveBrowserProfileDirForLaunch(requestedProfileFlag)
|
|
222
|
+
: undefined;
|
|
210
223
|
const port = resolveCdpPort(readPortFlag(browserArgs, "--port"));
|
|
211
224
|
const launchTimeoutMs = readPositiveIntegerFlag(browserArgs, "--launch-timeout-ms");
|
|
225
|
+
const savedLaunch = await readLastBrowserLoginLaunch();
|
|
226
|
+
// A saved launch belongs to a control port. Reusing its profile for a
|
|
227
|
+
// different port would silently widen the existing custom-port rules.
|
|
228
|
+
const savedLaunchForPort = savedLaunch?.port === port ? savedLaunch : undefined;
|
|
229
|
+
const savedProfileDir = savedLaunchForPort?.profile_dir
|
|
230
|
+
? resolveBrowserProfileDirForLaunch(savedLaunchForPort.profile_dir)
|
|
231
|
+
: undefined;
|
|
232
|
+
const profileDir = requestedProfileDir ?? savedProfileDir;
|
|
233
|
+
const windowMode = resolveBrowserWindowMode({
|
|
234
|
+
flags: {
|
|
235
|
+
...(browserArgs.includes("--headed") ? { headed: true } : {}),
|
|
236
|
+
...(browserArgs.includes("--headless") ? { headless: true } : {}),
|
|
237
|
+
...(browserArgs.includes("--virtual-display") ? { virtualDisplay: true } : {}),
|
|
238
|
+
...(browserArgs.includes("--minimized") ? { minimized: true } : {})
|
|
239
|
+
},
|
|
240
|
+
// Window mode is a user preference across launches; only the saved
|
|
241
|
+
// profile and display identity are scoped to this resolved port.
|
|
242
|
+
...(savedLaunch ? { lastLogin: savedLaunch } : {})
|
|
243
|
+
});
|
|
212
244
|
const commandOptions = {
|
|
213
245
|
...(targetCwd ? { cwd: targetCwd } : {}),
|
|
214
|
-
...(
|
|
246
|
+
...(requestedProfileDir ? { profileDir: requestedProfileDir } : {}),
|
|
215
247
|
...(port !== DEFAULT_CDP_PORT ? { port } : {}),
|
|
216
248
|
...(readFlag(browserArgs, "--url") ? { url: loginUrl } : {}),
|
|
217
249
|
...(launchTimeoutMs !== undefined ? { launchTimeoutMs } : {})
|
|
@@ -219,6 +251,8 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
219
251
|
if (browserArgs.includes("--dry-run")) {
|
|
220
252
|
printBrowserLoginGuide(io.stdout, {
|
|
221
253
|
opened: false,
|
|
254
|
+
headless: windowMode.headless,
|
|
255
|
+
virtualDisplay: windowMode.virtualDisplay,
|
|
222
256
|
loginUrl,
|
|
223
257
|
profileDir: profileDir ?? defaultChatGptProfileDir(),
|
|
224
258
|
port,
|
|
@@ -231,48 +265,41 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
231
265
|
// again: Chrome's singleton would just open ANOTHER window (the recurring
|
|
232
266
|
// "extra windows" problem, which then blocks sends as
|
|
233
267
|
// ambiguous_chatgpt_tabs). Reuse the running instance instead.
|
|
234
|
-
if (browserArgs.includes("--virtual-display") && browserArgs.includes("--headless")) {
|
|
235
|
-
throw new Error("pro browser login cannot combine --headless and --virtual-display (a virtual display already hides the window).");
|
|
236
|
-
}
|
|
237
|
-
// Reopen the browser the way it was last opened unless a flag or the
|
|
238
|
-
// environment says otherwise. A virtual-display user who follows the
|
|
239
|
-
// `browser_unreachable` advice used to get a visible window back.
|
|
240
|
-
const savedLaunch = await readLastBrowserLoginLaunch();
|
|
241
|
-
const windowMode = resolveBrowserWindowMode({
|
|
242
|
-
flags: {
|
|
243
|
-
...(browserArgs.includes("--headless") ? { headless: true } : {}),
|
|
244
|
-
...(browserArgs.includes("--virtual-display") ? { virtualDisplay: true } : {}),
|
|
245
|
-
...(browserArgs.includes("--minimized") ? { minimized: true } : {})
|
|
246
|
-
},
|
|
247
|
-
...(savedLaunch ? { lastLogin: savedLaunch } : {})
|
|
248
|
-
});
|
|
249
268
|
const headless = windowMode.headless;
|
|
250
|
-
// A real browser on a virtual X display: no window anywhere, and
|
|
251
|
-
// Cloudflare sees an ordinary headed Chrome (headless it rejects).
|
|
252
269
|
const wantsVirtualDisplay = windowMode.virtualDisplay;
|
|
253
|
-
const virtualDisplay = wantsVirtualDisplay
|
|
254
|
-
? await ensureVirtualDisplay(savedLaunch?.virtual_display !== undefined && !browserArgs.includes("--virtual-display")
|
|
255
|
-
? { displayNumber: savedLaunch.virtual_display }
|
|
256
|
-
: {})
|
|
257
|
-
: undefined;
|
|
258
270
|
const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
|
|
259
271
|
if (alreadyRunning) {
|
|
272
|
+
if (requestedProfileDir &&
|
|
273
|
+
savedProfileDir &&
|
|
274
|
+
requestedProfileDir !== savedProfileDir) {
|
|
275
|
+
throw new Error(`A ChatGPT browser is already running on port ${port} with profile ${savedProfileDir}; a different profile was requested. Close the existing browser yourself, then rerun with the intended profile.`);
|
|
276
|
+
}
|
|
260
277
|
// One Chrome profile cannot serve a headed and a headless instance at
|
|
261
278
|
// once, and reusing the running one would silently ignore the
|
|
262
279
|
// requested mode. Say so instead of pretending the switch took.
|
|
263
|
-
const
|
|
264
|
-
const runningHeadless = previous?.port === port ? previous.headless === true : undefined;
|
|
280
|
+
const runningHeadless = savedLaunchForPort ? savedLaunchForPort.headless === true : undefined;
|
|
265
281
|
if (runningHeadless !== undefined && runningHeadless !== headless) {
|
|
266
|
-
throw new Error(`A ${runningHeadless ? "headless" : "headed"} ChatGPT browser is already running on port ${port}, but ${headless ? "headless" : "headed"} was requested. Close
|
|
282
|
+
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.`);
|
|
267
283
|
}
|
|
268
284
|
// Same for the display: a browser already on your desktop cannot be
|
|
269
285
|
// moved onto a virtual display by reusing it, and silently reusing
|
|
270
286
|
// it would leave the window exactly where the user asked it not to be.
|
|
271
|
-
const runningVirtual =
|
|
287
|
+
const runningVirtual = savedLaunchForPort ? savedLaunchForPort.virtual_display !== undefined : undefined;
|
|
272
288
|
if (runningVirtual !== undefined && runningVirtual !== wantsVirtualDisplay) {
|
|
273
|
-
throw new Error(`A ChatGPT browser is already running on port ${port} on ${runningVirtual ? "a virtual display" : "your desktop"}, but ${wantsVirtualDisplay ? "a virtual display" : "your desktop"} was requested. Close
|
|
289
|
+
throw new Error(`A ChatGPT browser is already running on port ${port} on ${runningVirtual ? "a virtual display" : "your desktop"}, but ${wantsVirtualDisplay ? "a virtual display" : "your desktop"} was requested. Close the existing browser yourself, then rerun; prodex will not end it.`);
|
|
290
|
+
}
|
|
291
|
+
if (savedLaunchForPort?.minimized === true && !windowMode.minimized) {
|
|
292
|
+
throw new Error(`A minimized ChatGPT browser is already running on port ${port}, but a visible headed browser was requested. Close the existing browser yourself, then rerun with --headed; prodex will not end it or pretend the minimized window was restored.`);
|
|
274
293
|
}
|
|
275
294
|
}
|
|
295
|
+
// Allocate/rejoin a display only for a new browser. An already-running
|
|
296
|
+
// virtual browser owns its saved display identity and needs no new X
|
|
297
|
+
// server just because login was invoked again.
|
|
298
|
+
const virtualDisplay = wantsVirtualDisplay && !alreadyRunning
|
|
299
|
+
? await ensureVirtualDisplay(savedLaunchForPort?.virtual_display !== undefined
|
|
300
|
+
? { displayNumber: savedLaunchForPort.virtual_display }
|
|
301
|
+
: {})
|
|
302
|
+
: undefined;
|
|
276
303
|
// A wedged browser is unreachable, so without this login would launch a
|
|
277
304
|
// second Chrome onto the same profile and leave the first one burning
|
|
278
305
|
// CPU - the same mistake the unattended recovery path made. Checked
|
|
@@ -291,7 +318,9 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
291
318
|
port,
|
|
292
319
|
profileDir,
|
|
293
320
|
url: loginUrl,
|
|
294
|
-
|
|
321
|
+
// Pass false too: openChatGptBrowser otherwise consults the env
|
|
322
|
+
// again and can resurrect a mode the shared resolver disabled.
|
|
323
|
+
headless,
|
|
295
324
|
...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
|
|
296
325
|
});
|
|
297
326
|
if (!alreadyRunning) {
|
|
@@ -302,10 +331,9 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
302
331
|
// Minimize BEFORE recording, so the record reflects what actually
|
|
303
332
|
// happened (a desktop that hides minimized windows gets restored and
|
|
304
333
|
// recorded as a normal window).
|
|
305
|
-
const wantsMinimized = browserArgs.includes("--minimized") || resolveMinimizeWindowPreference();
|
|
306
334
|
let minimized = false;
|
|
307
335
|
let minimizeNote;
|
|
308
|
-
if (
|
|
336
|
+
if (windowMode.minimized && !headless) {
|
|
309
337
|
try {
|
|
310
338
|
const outcome = await minimizeChatGptWindow({ port: opened.port });
|
|
311
339
|
minimized = outcome.minimized;
|
|
@@ -317,20 +345,31 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
317
345
|
minimizeNote = `window: could not minimize (${errorMessage(error)}); leaving it as it is.`;
|
|
318
346
|
}
|
|
319
347
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
348
|
+
// A reused browser with neither an explicit nor saved profile is
|
|
349
|
+
// reachable, but its actual profile cannot be inferred from the port.
|
|
350
|
+
// Do not overwrite a useful launch record with an invented default.
|
|
351
|
+
if (!alreadyRunning || profileDir) {
|
|
352
|
+
await recordBrowserLoginLaunch({
|
|
353
|
+
profile_dir: opened.profileDir,
|
|
354
|
+
port: opened.port,
|
|
355
|
+
headless,
|
|
356
|
+
minimized,
|
|
357
|
+
...(virtualDisplay
|
|
358
|
+
? { virtual_display: virtualDisplay.displayNumber }
|
|
359
|
+
: wantsVirtualDisplay && savedLaunchForPort?.virtual_display !== undefined
|
|
360
|
+
? { virtual_display: savedLaunchForPort.virtual_display }
|
|
361
|
+
: {})
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const virtualDisplayNumber = virtualDisplay?.displayNumber ?? savedLaunchForPort?.virtual_display;
|
|
365
|
+
if (wantsVirtualDisplay && virtualDisplayNumber !== undefined) {
|
|
366
|
+
io.stdout(`window: none - running on virtual display :${virtualDisplayNumber}${virtualDisplay?.startedNow ? " (started now)" : " (reused)"}.`);
|
|
329
367
|
}
|
|
330
368
|
printBrowserLoginGuide(io.stdout, {
|
|
331
369
|
opened: !alreadyRunning,
|
|
332
370
|
reused: alreadyRunning,
|
|
333
371
|
headless,
|
|
372
|
+
virtualDisplay: wantsVirtualDisplay,
|
|
334
373
|
loginUrl,
|
|
335
374
|
profileDir: opened.profileDir,
|
|
336
375
|
port: opened.port,
|
|
@@ -345,7 +384,12 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
345
384
|
// Verify it here (bounded, no human to wait for) instead of letting
|
|
346
385
|
// the first consult fail with a confusing not-logged-in blocker.
|
|
347
386
|
const headlessWaitMs = readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 30_000;
|
|
348
|
-
const headlessReady = await waitForChatGptLoginReady(io.stderr, {
|
|
387
|
+
const headlessReady = await waitForChatGptLoginReady(io.stderr, {
|
|
388
|
+
port: opened.port,
|
|
389
|
+
timeoutMs: headlessWaitMs,
|
|
390
|
+
windowMode,
|
|
391
|
+
headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
|
|
392
|
+
});
|
|
349
393
|
if (!headlessReady) {
|
|
350
394
|
// Name the real cause. Cloudflare rejects headless Chrome by
|
|
351
395
|
// design (its docs list headless browsers as unsupported), and
|
|
@@ -355,8 +399,8 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
355
399
|
const challenged = finalStatus?.blocker?.code === "cloudflare_check" || /just a moment/i.test(finalStatus?.title ?? "");
|
|
356
400
|
io.stdout("");
|
|
357
401
|
io.stdout(challenged
|
|
358
|
-
?
|
|
359
|
-
: `headless: the profile is not signed in. Run \`${
|
|
402
|
+
? `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.`
|
|
403
|
+
: `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.`);
|
|
360
404
|
return 1;
|
|
361
405
|
}
|
|
362
406
|
io.stdout("headless: signed-in session confirmed - consults will run with no visible window.");
|
|
@@ -370,7 +414,12 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
370
414
|
if (!shouldWaitForReady)
|
|
371
415
|
return 0;
|
|
372
416
|
const waitTimeoutMs = readPositiveIntegerFlag(browserArgs, "--wait-timeout-ms") ?? 300_000;
|
|
373
|
-
const ready = await waitForChatGptLoginReady(io.stderr, {
|
|
417
|
+
const ready = await waitForChatGptLoginReady(io.stderr, {
|
|
418
|
+
port: opened.port,
|
|
419
|
+
timeoutMs: waitTimeoutMs,
|
|
420
|
+
windowMode,
|
|
421
|
+
headedLoginCommand: formatHeadedBrowserLoginCommand(sourceCli, commandOptions)
|
|
422
|
+
});
|
|
374
423
|
return ready ? 0 : 1;
|
|
375
424
|
}
|
|
376
425
|
if (browserSubcommand === "ask") {
|
|
@@ -523,9 +572,9 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
523
572
|
return 0;
|
|
524
573
|
}
|
|
525
574
|
if (browserSubcommand === "recover") {
|
|
526
|
-
if (printProBrowserHelpIfRequested(browserArgs, "pro browser recover", io, { valueFlags: ["--cwd", "--port", "--timeout-ms", "--target-url", "--source-cli"] }))
|
|
575
|
+
if (printProBrowserHelpIfRequested(browserArgs, "pro browser recover", io, { valueFlags: ["--cwd", "--port", "--timeout-ms", "--target-url", "--request-id", "--source-cli"] }))
|
|
527
576
|
return 0;
|
|
528
|
-
assertOnlyOptions(browserArgs, "pro browser recover", ["--cwd", "--port", "--timeout-ms", "--target-url", "--source-cli"]);
|
|
577
|
+
assertOnlyOptions(browserArgs, "pro browser recover", ["--cwd", "--port", "--timeout-ms", "--target-url", "--request-id", "--source-cli"]);
|
|
529
578
|
const recoverCwd = resolveCwdFlag(io.cwd, browserArgs);
|
|
530
579
|
const recoverSourceCli = resolveOptionalFileFlag(io.cwd, browserArgs, "--source-cli");
|
|
531
580
|
const targetUrl = readFlag(browserArgs, "--target-url");
|
|
@@ -534,6 +583,10 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
534
583
|
}
|
|
535
584
|
const recoverPort = readPortFlag(browserArgs, "--port");
|
|
536
585
|
const recoverTimeoutMs = readPositiveIntegerFlag(browserArgs, "--timeout-ms");
|
|
586
|
+
const recoverRequestIdRaw = readFlag(browserArgs, "--request-id");
|
|
587
|
+
const recoverRequestId = recoverRequestIdRaw === undefined
|
|
588
|
+
? undefined
|
|
589
|
+
: validatedProdexRequestId(recoverRequestIdRaw, "--request-id");
|
|
537
590
|
const recoverResolvedPort = resolveCdpPort(recoverPort);
|
|
538
591
|
let consult;
|
|
539
592
|
try {
|
|
@@ -542,7 +595,12 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
542
595
|
// send is streaming into, which is the consult it would destroy - and
|
|
543
596
|
// recovery is exactly what gets run after a send appears to hang, so
|
|
544
597
|
// the two meeting is a when rather than an if.
|
|
545
|
-
consult = await withBrowserSendLock(recoverTimeoutMs ?? 60_000, (detail) => io.stderr(`progress: ${detail}`), () => recoverChatGptAnswerFromThread({
|
|
598
|
+
consult = await withBrowserSendLock(recoverTimeoutMs ?? 60_000, (detail) => io.stderr(`progress: ${detail}`), () => recoverChatGptAnswerFromThread({
|
|
599
|
+
port: recoverPort,
|
|
600
|
+
targetUrl,
|
|
601
|
+
timeoutMs: recoverTimeoutMs,
|
|
602
|
+
...(recoverRequestId ? { requestId: recoverRequestId } : {})
|
|
603
|
+
}));
|
|
546
604
|
}
|
|
547
605
|
catch (error) {
|
|
548
606
|
const blocker = sourceAwareBrowserBlocker(browserSendBlockerFromError(error), recoverSourceCli, {
|
|
@@ -568,6 +626,12 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
568
626
|
catch (error) {
|
|
569
627
|
io.stderr(`answer_artifact_warning: ${errorMessage(error)}`);
|
|
570
628
|
}
|
|
629
|
+
for (const warning of consult.warnings)
|
|
630
|
+
io.stderr(warning);
|
|
631
|
+
if (consult.requestId)
|
|
632
|
+
io.stderr(`request_id: ${consult.requestId}`);
|
|
633
|
+
if (consult.requestVerified !== undefined)
|
|
634
|
+
io.stderr(`request_verified: ${consult.requestVerified ? "yes" : "no"}`);
|
|
571
635
|
await recoverStore.completeTask(recoveredTask.id, {
|
|
572
636
|
status: "done",
|
|
573
637
|
summary: consult.answer,
|
|
@@ -575,8 +639,8 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
575
639
|
? [{ path: recoveredArtifactPath, role: "result", bytes: Buffer.byteLength(recoveredArtifactText, "utf8") }]
|
|
576
640
|
: [],
|
|
577
641
|
commands: ["recovered ChatGPT answer from thread"],
|
|
578
|
-
warnings:
|
|
579
|
-
provenance: { thread: consult.url, warnings:
|
|
642
|
+
warnings: consult.warnings,
|
|
643
|
+
provenance: { thread: consult.url, warnings: consult.warnings }
|
|
580
644
|
});
|
|
581
645
|
io.stdout(`${recoveredTask.id}\tdone\t${consult.url}`);
|
|
582
646
|
io.stdout("");
|
|
@@ -933,16 +997,31 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
933
997
|
export async function runConsultsCommand(rest, io) {
|
|
934
998
|
throw new Error("The legacy `consults` alias is retired. Use `prodex pro list`, `prodex pro latest`, or `prodex pro show <task-id|latest>`.");
|
|
935
999
|
}
|
|
936
|
-
// PRODEX_MINIMIZE_WINDOW=1 gives the no-window setup to every entry point,
|
|
937
|
-
// including the MCP server's auto-recovery, without a CLI flag.
|
|
938
|
-
export function resolveMinimizeWindowPreference(env = process.env) {
|
|
939
|
-
const raw = (env.PRODEX_MINIMIZE_WINDOW ?? "").trim().toLowerCase();
|
|
940
|
-
return raw === "1" || raw === "true" || raw === "yes";
|
|
941
|
-
}
|
|
942
1000
|
function autoLoginDisabledByEnv(env = process.env) {
|
|
943
1001
|
const raw = (env.PRODEX_NO_AUTO_LOGIN ?? "").trim().toLowerCase();
|
|
944
1002
|
return raw === "1" || raw === "true" || raw === "yes";
|
|
945
1003
|
}
|
|
1004
|
+
function validatedSessionKey(value, source) {
|
|
1005
|
+
const parsed = SessionKeySchema.safeParse(value);
|
|
1006
|
+
if (parsed.success)
|
|
1007
|
+
return parsed.data;
|
|
1008
|
+
throw new Error(`${source} must be a non-empty identifier of at most 128 characters using letters, digits, '.', '_', ':', '@', '/', or '-'.`);
|
|
1009
|
+
}
|
|
1010
|
+
export function resolveProdexSessionKey(explicit, env = process.env) {
|
|
1011
|
+
if (explicit !== undefined)
|
|
1012
|
+
return validatedSessionKey(explicit, "--session-key");
|
|
1013
|
+
if (env.PRODEX_SESSION_KEY !== undefined)
|
|
1014
|
+
return validatedSessionKey(env.PRODEX_SESSION_KEY, "PRODEX_SESSION_KEY");
|
|
1015
|
+
if (env.CODEX_THREAD_ID !== undefined)
|
|
1016
|
+
return validatedSessionKey(env.CODEX_THREAD_ID, "CODEX_THREAD_ID");
|
|
1017
|
+
return undefined;
|
|
1018
|
+
}
|
|
1019
|
+
function validatedProdexRequestId(value, source) {
|
|
1020
|
+
const parsed = ProdexRequestIdSchema.safeParse(value);
|
|
1021
|
+
if (parsed.success)
|
|
1022
|
+
return parsed.data;
|
|
1023
|
+
throw new Error(`${source} must be the 32-character lowercase hex request identifier returned by prodex.`);
|
|
1024
|
+
}
|
|
946
1025
|
// Retired browser subcommands map to the one that replaced them. Every value
|
|
947
1026
|
// here must be a subcommand that actually exists, so the error is a single hop
|
|
948
1027
|
// to a runnable command.
|
|
@@ -1094,6 +1173,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
1094
1173
|
promptText = prompt ? `${prompt}\n\n--- piped input (stdin) ---\n${piped}` : piped;
|
|
1095
1174
|
}
|
|
1096
1175
|
const browserDefaults = await loadBrowserDefaults(targetCwd);
|
|
1176
|
+
const sessionKey = resolveProdexSessionKey(readFlag(parsedAskPro.optionArgs, "--session-key"));
|
|
1097
1177
|
const explicitProject = readFlag(parsedAskPro.optionArgs, "--project");
|
|
1098
1178
|
const explicitProjectNew = readFlag(parsedAskPro.optionArgs, "--project-new");
|
|
1099
1179
|
// Opt out of a pinned default project for one send. Without this a repo
|
|
@@ -1117,6 +1197,9 @@ export async function runAskProCommand(rest, io) {
|
|
|
1117
1197
|
const continueRequested = parsedAskPro.optionArgs.includes("--continue");
|
|
1118
1198
|
const continueTaskId = readFlag(parsedAskPro.optionArgs, "--continue-task");
|
|
1119
1199
|
if (continueRequested || continueTaskId !== undefined) {
|
|
1200
|
+
if (continueTaskId === undefined && sessionKey === undefined) {
|
|
1201
|
+
throw new Error("--continue needs --session-key <id> (or PRODEX_SESSION_KEY/CODEX_THREAD_ID) so it cannot use another client's conversation. Use --continue-task <task_id> to name one explicitly.");
|
|
1202
|
+
}
|
|
1120
1203
|
const conflict = [
|
|
1121
1204
|
parsedAskPro.optionArgs.includes("--new-chat") ? "--new-chat" : undefined,
|
|
1122
1205
|
targetUrl !== undefined ? "--target-url" : undefined,
|
|
@@ -1146,12 +1229,14 @@ export async function runAskProCommand(rest, io) {
|
|
|
1146
1229
|
const resolved = resolveContinuationThread({
|
|
1147
1230
|
consults: (await targetStore.listSessionsReadOnly()).map((session) => ({
|
|
1148
1231
|
taskId: session.task_id ?? "",
|
|
1232
|
+
...(session.session_key ? { sessionKey: session.session_key } : {}),
|
|
1149
1233
|
...(session.thread ? { thread: session.thread } : {}),
|
|
1150
1234
|
status: session.status,
|
|
1151
1235
|
...(session.created_at ? { createdAt: session.created_at } : {})
|
|
1152
1236
|
})),
|
|
1153
1237
|
...(continuationProject ? { project: continuationProject } : {}),
|
|
1154
1238
|
...(continuationProjectId ? { projectId: continuationProjectId } : {}),
|
|
1239
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
1155
1240
|
...(continueTaskId !== undefined ? { taskId: continueTaskId } : {})
|
|
1156
1241
|
});
|
|
1157
1242
|
if ("error" in resolved)
|
|
@@ -1163,7 +1248,8 @@ export async function runAskProCommand(rest, io) {
|
|
|
1163
1248
|
normalizedTargetUrl = normalizeChatGptTargetUrl(resolved.target.thread);
|
|
1164
1249
|
io.stderr(`progress: continuing ${resolved.target.taskId}`);
|
|
1165
1250
|
}
|
|
1166
|
-
const
|
|
1251
|
+
const explicitlyRequestedNewChat = parsedAskPro.optionArgs.includes("--new-chat");
|
|
1252
|
+
const newChat = explicitlyRequestedNewChat || normalizedTargetUrl === undefined;
|
|
1167
1253
|
// A temporary chat is not saved, so there is nothing to come back to: the
|
|
1168
1254
|
// recovery path every timeout message points at cannot fetch it later.
|
|
1169
1255
|
// Requiring --new-chat keeps that explicit rather than quietly turning a
|
|
@@ -1178,7 +1264,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
1178
1264
|
if (temporary && !newChat) {
|
|
1179
1265
|
throw new Error("--temporary starts a throwaway chat, so it needs --new-chat. A temporary chat cannot be continued or recovered later.");
|
|
1180
1266
|
}
|
|
1181
|
-
if (
|
|
1267
|
+
if (explicitlyRequestedNewChat && normalizedTargetUrl) {
|
|
1182
1268
|
throw new Error("ask-pro cannot combine --new-chat with --target-url: --new-chat navigates to a fresh chat while --target-url pins the confirmed tab.");
|
|
1183
1269
|
}
|
|
1184
1270
|
const explicitModel = readFlag(parsedAskPro.optionArgs, "--model");
|
|
@@ -1293,6 +1379,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
1293
1379
|
id: bundle.id,
|
|
1294
1380
|
direction: "codex_to_chatgpt",
|
|
1295
1381
|
backend: "chatgpt-control",
|
|
1382
|
+
...(sessionKey ? { session_key: sessionKey } : {}),
|
|
1296
1383
|
task_id: task.id,
|
|
1297
1384
|
thread: normalizedTargetUrl,
|
|
1298
1385
|
status: "running",
|
|
@@ -1328,9 +1415,9 @@ export async function runAskProCommand(rest, io) {
|
|
|
1328
1415
|
port: browserPort,
|
|
1329
1416
|
prompt: bundle.sendText,
|
|
1330
1417
|
targetUrl: normalizedTargetUrl,
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
...(continuedFromTaskId ? { navigateToTargetUrl: true } : {}),
|
|
1418
|
+
// Continuations and explicit TUI picks navigate while this lock is
|
|
1419
|
+
// held. Direct CLI targets keep their existing verification-only semantics.
|
|
1420
|
+
...(continuedFromTaskId || (io.navigateInteractiveTarget === true && normalizedTargetUrl) ? { navigateToTargetUrl: true } : {}),
|
|
1334
1421
|
timeoutMs: browserTimeoutMs,
|
|
1335
1422
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
1336
1423
|
...(tools.length > 0 ? { tools } : {}),
|
|
@@ -1363,7 +1450,9 @@ export async function runAskProCommand(rest, io) {
|
|
|
1363
1450
|
}
|
|
1364
1451
|
catch (error) {
|
|
1365
1452
|
const firstBlocker = browserSendBlockerFromError(error);
|
|
1366
|
-
|
|
1453
|
+
// Loss while reading an already-submitted prompt must not send it again.
|
|
1454
|
+
if (firstBlocker.code !== "browser_unreachable" ||
|
|
1455
|
+
!firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed)
|
|
1367
1456
|
throw error;
|
|
1368
1457
|
const recovered = await attemptBrowserAutoRecovery(io.stderr, {
|
|
1369
1458
|
...(browserPort !== undefined ? { port: browserPort } : {}),
|
|
@@ -1420,6 +1509,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
1420
1509
|
id: bundle.id,
|
|
1421
1510
|
direction: "codex_to_chatgpt",
|
|
1422
1511
|
backend: "chatgpt-control",
|
|
1512
|
+
...(sessionKey ? { session_key: sessionKey } : {}),
|
|
1423
1513
|
task_id: task.id,
|
|
1424
1514
|
thread: blockedThread,
|
|
1425
1515
|
status: "blocked",
|
|
@@ -1433,7 +1523,15 @@ export async function runAskProCommand(rest, io) {
|
|
|
1433
1523
|
// Keep stdout machine-parseable for --json consumers on the blocked
|
|
1434
1524
|
// path too; the human-readable error still goes to stderr via throw.
|
|
1435
1525
|
if (jsonOutput) {
|
|
1436
|
-
io.stdout(JSON.stringify({
|
|
1526
|
+
io.stdout(JSON.stringify({
|
|
1527
|
+
task_id: task.id,
|
|
1528
|
+
status: "blocked",
|
|
1529
|
+
thread: blockedThread ?? null,
|
|
1530
|
+
answer: null,
|
|
1531
|
+
...(sessionKey ? { session_key: sessionKey } : {}),
|
|
1532
|
+
warnings: blockedWarnings,
|
|
1533
|
+
blocker
|
|
1534
|
+
}, null, 2));
|
|
1437
1535
|
}
|
|
1438
1536
|
throw new Error(formatBlockedConsultRecordedMessage(message, task.id, sourceCli, { cwd: targetCwd }));
|
|
1439
1537
|
}
|
|
@@ -1470,6 +1568,10 @@ export async function runAskProCommand(rest, io) {
|
|
|
1470
1568
|
io.stderr(warning);
|
|
1471
1569
|
if (consult.modelSlug)
|
|
1472
1570
|
io.stderr(`model_used: ${consult.modelSlug}`);
|
|
1571
|
+
if (consult.requestId)
|
|
1572
|
+
io.stderr(`request_id: ${consult.requestId}`);
|
|
1573
|
+
if (consult.requestVerified !== undefined)
|
|
1574
|
+
io.stderr(`request_verified: ${consult.requestVerified ? "yes" : "no"}`);
|
|
1473
1575
|
// Which conversation this followed, and where the answer actually landed.
|
|
1474
1576
|
// The progress line that says it is filtered out of MCP notes, so an
|
|
1475
1577
|
// agent asking for a follow-up had no way to know which thread it got -
|
|
@@ -1518,6 +1620,8 @@ export async function runAskProCommand(rest, io) {
|
|
|
1518
1620
|
// receipt used to record only what prodex asked for.
|
|
1519
1621
|
...(consult.modelSlug ? { model_used: consult.modelSlug } : {}),
|
|
1520
1622
|
...(proVerified !== undefined ? { pro_verified: proVerified } : {}),
|
|
1623
|
+
...(consult.requestId ? { request_id: consult.requestId } : {}),
|
|
1624
|
+
...(consult.requestVerified !== undefined ? { request_verified: consult.requestVerified } : {}),
|
|
1521
1625
|
// Intent and evidence, kept apart. `selection` is what was asked
|
|
1522
1626
|
// for; this is where the answer turned out to be, and whether
|
|
1523
1627
|
// anything actually confirmed it.
|
|
@@ -1562,6 +1666,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
1562
1666
|
id: bundle.id,
|
|
1563
1667
|
direction: "codex_to_chatgpt",
|
|
1564
1668
|
backend: "chatgpt-control",
|
|
1669
|
+
...(sessionKey ? { session_key: sessionKey } : {}),
|
|
1565
1670
|
task_id: task.id,
|
|
1566
1671
|
thread: consult.url,
|
|
1567
1672
|
status: "done",
|
|
@@ -1573,6 +1678,9 @@ export async function runAskProCommand(rest, io) {
|
|
|
1573
1678
|
status: result.status,
|
|
1574
1679
|
thread: consult.url,
|
|
1575
1680
|
answer: result.summary,
|
|
1681
|
+
...(sessionKey ? { session_key: sessionKey } : {}),
|
|
1682
|
+
...(consult.requestId ? { request_id: consult.requestId } : {}),
|
|
1683
|
+
...(consult.requestVerified !== undefined ? { request_verified: consult.requestVerified } : {}),
|
|
1576
1684
|
...(continuedFromTaskId ? { continued_from: continuedFromTaskId } : {}),
|
|
1577
1685
|
destination: {
|
|
1578
1686
|
observed: destination.destination,
|
|
@@ -1596,6 +1704,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
1596
1704
|
id: bundle.id,
|
|
1597
1705
|
direction: "codex_to_chatgpt",
|
|
1598
1706
|
backend: "manual",
|
|
1707
|
+
...(sessionKey ? { session_key: sessionKey } : {}),
|
|
1599
1708
|
status: "preview",
|
|
1600
1709
|
warnings: []
|
|
1601
1710
|
}, io);
|
|
@@ -1648,6 +1757,14 @@ Rules:
|
|
|
1648
1757
|
|
|
1649
1758
|
Write the debate in the language of the topic.`;
|
|
1650
1759
|
}
|
|
1760
|
+
function browserMetadataFromNotes(notes) {
|
|
1761
|
+
const requestId = notes.find((line) => line.startsWith("request_id: "))?.slice("request_id: ".length).trim();
|
|
1762
|
+
const verified = notes.find((line) => line.startsWith("request_verified: "))?.slice("request_verified: ".length).trim();
|
|
1763
|
+
return {
|
|
1764
|
+
...(requestId ? { request_id: requestId } : {}),
|
|
1765
|
+
...(verified === "yes" ? { request_verified: true } : verified === "no" ? { request_verified: false } : {})
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1651
1768
|
/**
|
|
1652
1769
|
* MCP-side counterpart to `pro browser recover`. A consult that outlives its
|
|
1653
1770
|
* budget hands back the thread it landed in, but until now the only way to act
|
|
@@ -1662,6 +1779,7 @@ export async function performBrowserRecoverForMcp(cwd, input) {
|
|
|
1662
1779
|
"recover",
|
|
1663
1780
|
"--target-url",
|
|
1664
1781
|
input.thread,
|
|
1782
|
+
...(input.request_id !== undefined ? ["--request-id", input.request_id] : []),
|
|
1665
1783
|
...(input.timeout_ms !== undefined ? ["--timeout-ms", String(input.timeout_ms)] : [])
|
|
1666
1784
|
];
|
|
1667
1785
|
await runProCommand(argv, {
|
|
@@ -1671,11 +1789,13 @@ export async function performBrowserRecoverForMcp(cwd, input) {
|
|
|
1671
1789
|
}, async () => 0);
|
|
1672
1790
|
const header = stdoutLines[0] ?? "";
|
|
1673
1791
|
const [taskId = "", status = "", thread = ""] = header.split("\t");
|
|
1792
|
+
const metadata = browserMetadataFromNotes(stderrLines);
|
|
1674
1793
|
return {
|
|
1675
1794
|
task_id: taskId,
|
|
1676
1795
|
status,
|
|
1677
1796
|
thread,
|
|
1678
1797
|
answer: stdoutLines.slice(2).join("\n"),
|
|
1798
|
+
...metadata,
|
|
1679
1799
|
notes: stderrLines
|
|
1680
1800
|
};
|
|
1681
1801
|
}
|
|
@@ -1706,6 +1826,7 @@ export function answerRescuedFromFailedPersistence(stdoutLines) {
|
|
|
1706
1826
|
export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
1707
1827
|
const stdoutLines = [];
|
|
1708
1828
|
const stderrLines = [];
|
|
1829
|
+
const sessionKey = resolveProdexSessionKey(input.session_key);
|
|
1709
1830
|
const argv = [
|
|
1710
1831
|
"--send",
|
|
1711
1832
|
// MCP callers have no terminal, so the interactive auto-recovery gate
|
|
@@ -1718,6 +1839,7 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
|
1718
1839
|
...(input.pro_mode !== undefined ? ["--pro-mode", input.pro_mode] : []),
|
|
1719
1840
|
...(input.effort !== undefined ? ["--effort", input.effort] : []),
|
|
1720
1841
|
...(input.project !== undefined ? ["--project", input.project] : []),
|
|
1842
|
+
...(sessionKey !== undefined ? ["--session-key", sessionKey] : []),
|
|
1721
1843
|
...(input.timeout_ms !== undefined ? ["--timeout-ms", String(input.timeout_ms)] : []),
|
|
1722
1844
|
...(input.files ?? []).flatMap((file) => ["--file", file]),
|
|
1723
1845
|
...(input.attach ?? []).flatMap((file) => ["--attach", file]),
|
|
@@ -1750,25 +1872,31 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
|
1750
1872
|
const rescued = answerRescuedFromFailedPersistence(stdoutLines);
|
|
1751
1873
|
if (!rescued)
|
|
1752
1874
|
throw error;
|
|
1875
|
+
const notes = [
|
|
1876
|
+
...stderrLines.filter((line) => !line.startsWith("progress:")),
|
|
1877
|
+
`answer_not_saved: ${errorMessage(error)}`
|
|
1878
|
+
];
|
|
1753
1879
|
return {
|
|
1754
1880
|
task_id: rescued.taskId,
|
|
1755
1881
|
status: "answered_not_saved",
|
|
1756
1882
|
thread: rescued.thread,
|
|
1757
1883
|
answer: rescued.answer,
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
]
|
|
1884
|
+
...(sessionKey ? { session_key: sessionKey } : {}),
|
|
1885
|
+
...browserMetadataFromNotes(notes),
|
|
1886
|
+
notes
|
|
1762
1887
|
};
|
|
1763
1888
|
}
|
|
1764
1889
|
const header = stdoutLines[0] ?? "";
|
|
1765
1890
|
const [taskId = "", status = "", thread = ""] = header.split("\t");
|
|
1891
|
+
const notes = stderrLines.filter((line) => !line.startsWith("progress:"));
|
|
1766
1892
|
return {
|
|
1767
1893
|
task_id: taskId,
|
|
1768
1894
|
status,
|
|
1769
1895
|
thread,
|
|
1770
1896
|
answer: stdoutLines.slice(2).join("\n"),
|
|
1771
|
-
|
|
1897
|
+
...(sessionKey ? { session_key: sessionKey } : {}),
|
|
1898
|
+
...browserMetadataFromNotes(notes),
|
|
1899
|
+
notes
|
|
1772
1900
|
};
|
|
1773
1901
|
}
|
|
1774
1902
|
/**
|
|
@@ -1801,6 +1929,12 @@ function autoClearDisabledByEnv(env = process.env) {
|
|
|
1801
1929
|
const raw = (env.PRODEX_NO_AUTO_CLEAR ?? "").trim().toLowerCase();
|
|
1802
1930
|
return raw === "1" || raw === "true" || raw === "yes";
|
|
1803
1931
|
}
|
|
1932
|
+
function reportResidualBrowserRecoveryBlocker(stderr, pids, port) {
|
|
1933
|
+
const blocker = wedgedBrowserBlocker([...new Set(pids)].sort((left, right) => left - right), port);
|
|
1934
|
+
stderr(`recover: failed - ${blocker.message}`);
|
|
1935
|
+
stderr(`recover: ${blocker.next_step}`);
|
|
1936
|
+
return false;
|
|
1937
|
+
}
|
|
1804
1938
|
/**
|
|
1805
1939
|
* What recovery did to the browser, in the words the receipt keeps.
|
|
1806
1940
|
*
|
|
@@ -1825,9 +1959,26 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
|
1825
1959
|
// scanning the DEFAULT profile for a custom-profile user put a second,
|
|
1826
1960
|
// healthy browser's renderers on the list this function kills.
|
|
1827
1961
|
const lastLogin = await readLastBrowserLoginLaunch().catch(() => undefined);
|
|
1962
|
+
const recoveryPort = resolveCdpPort(options.port);
|
|
1963
|
+
const savedIdentityBelongsToAnotherPort = lastLogin?.port !== undefined &&
|
|
1964
|
+
lastLogin.port !== recoveryPort &&
|
|
1965
|
+
(lastLogin.profile_dir !== undefined || lastLogin.virtual_display !== undefined);
|
|
1966
|
+
if (savedIdentityBelongsToAnotherPort) {
|
|
1967
|
+
stderr(`recover: failed - saved browser identity belongs to port ${lastLogin.port}, not requested port ${recoveryPort}. Run \`prodex pro browser login --port ${recoveryPort} --headed\` with the intended profile before retrying; prodex will not launch an unknown account.`);
|
|
1968
|
+
return false;
|
|
1969
|
+
}
|
|
1970
|
+
const lastLoginForPort = lastLogin?.port === undefined || lastLogin.port === recoveryPort ? lastLogin : undefined;
|
|
1971
|
+
let windowMode;
|
|
1972
|
+
try {
|
|
1973
|
+
windowMode = resolveBrowserWindowMode({ ...(lastLogin ? { lastLogin } : {}) });
|
|
1974
|
+
}
|
|
1975
|
+
catch (error) {
|
|
1976
|
+
stderr(`recover: failed - ${errorMessage(error)}`);
|
|
1977
|
+
return false;
|
|
1978
|
+
}
|
|
1828
1979
|
const scanFor = {
|
|
1829
1980
|
...(options.port !== undefined ? { port: options.port } : {}),
|
|
1830
|
-
...(
|
|
1981
|
+
...(lastLoginForPort?.profile_dir ? { profileDir: lastLoginForPort.profile_dir } : {})
|
|
1831
1982
|
};
|
|
1832
1983
|
const wedged = findWedgedBrowser(scanFor);
|
|
1833
1984
|
if (wedged.length > 0) {
|
|
@@ -1847,17 +1998,25 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
|
1847
1998
|
return false;
|
|
1848
1999
|
}
|
|
1849
2000
|
stderr(`recover: the browser stopped answering; ending it (pid ${wedged.join(", ")}) and starting a fresh one...`);
|
|
1850
|
-
//
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
2001
|
+
// Keep the attempt even if launch fails; completion is recorded after READY.
|
|
2002
|
+
options.notes?.push(`browser_recovery_started: prodex began restarting the unresponsive dedicated browser (pid ${wedged.join(", ")}).`);
|
|
2003
|
+
const termination = await endWedgedBrowser(wedged);
|
|
2004
|
+
if (termination.failed.length > 0) {
|
|
2005
|
+
return reportResidualBrowserRecoveryBlocker(stderr, termination.failed, recoveryPort);
|
|
2006
|
+
}
|
|
1854
2007
|
// Wait for the profile lock to actually clear rather than guessing at a
|
|
1855
2008
|
// delay: the replacement launch fails outright if the old process still
|
|
1856
2009
|
// holds it, which is how the first self-heal attempt ended.
|
|
2010
|
+
let residualPids = [];
|
|
1857
2011
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
1858
|
-
|
|
2012
|
+
residualPids = findWedgedBrowser(scanFor);
|
|
2013
|
+
if (residualPids.length === 0)
|
|
1859
2014
|
break;
|
|
1860
|
-
|
|
2015
|
+
if (attempt < 9)
|
|
2016
|
+
await sleep(1_000);
|
|
2017
|
+
}
|
|
2018
|
+
if (residualPids.length > 0) {
|
|
2019
|
+
return reportResidualBrowserRecoveryBlocker(stderr, residualPids, recoveryPort);
|
|
1861
2020
|
}
|
|
1862
2021
|
}
|
|
1863
2022
|
stderr("recover: browser is not running - launching the dedicated ChatGPT browser (Ctrl+C aborts)...");
|
|
@@ -1865,37 +2024,50 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
|
1865
2024
|
// Reuse the profile the user last logged in with; launching the default
|
|
1866
2025
|
// profile for a custom-profile user would wait on the wrong (logged-out)
|
|
1867
2026
|
// profile or, worse, silently send to a different account.
|
|
1868
|
-
//
|
|
1869
|
-
//
|
|
1870
|
-
//
|
|
1871
|
-
const
|
|
1872
|
-
|
|
1873
|
-
// put a window back on a desktop they deliberately keep empty.
|
|
1874
|
-
const virtualDisplay = lastLogin?.virtual_display !== undefined || resolveVirtualDisplayPreference()
|
|
1875
|
-
? await ensureVirtualDisplay(lastLogin?.virtual_display !== undefined ? { displayNumber: lastLogin.virtual_display } : {}).catch(() => undefined)
|
|
2027
|
+
// Recovery follows the same precedence as login. In particular, one
|
|
2028
|
+
// supplied false env setting selects the env group and disables saved
|
|
2029
|
+
// modes instead of being mistaken for "not configured".
|
|
2030
|
+
const virtualDisplay = windowMode.virtualDisplay
|
|
2031
|
+
? await ensureVirtualDisplay(lastLoginForPort?.virtual_display !== undefined ? { displayNumber: lastLoginForPort.virtual_display } : {})
|
|
1876
2032
|
: undefined;
|
|
1877
2033
|
const opened = openChatGptBrowser({
|
|
1878
2034
|
...(options.port !== undefined ? { port: options.port } : {}),
|
|
1879
|
-
...(
|
|
1880
|
-
|
|
2035
|
+
...(lastLoginForPort?.profile_dir ? { profileDir: lastLoginForPort.profile_dir } : {}),
|
|
2036
|
+
// Always pass the resolved boolean. Omitting false lets the browser
|
|
2037
|
+
// boundary re-read PRODEX_HEADLESS and revive stale environment state.
|
|
2038
|
+
headless: windowMode.headless,
|
|
1881
2039
|
...(virtualDisplay ? { virtualDisplay: { displayNumber: virtualDisplay.displayNumber, xauthority: virtualDisplay.xauthority } } : {})
|
|
1882
2040
|
});
|
|
1883
2041
|
await assertBrowserLaunchStayedAlive(opened);
|
|
1884
|
-
|
|
1885
|
-
if (
|
|
1886
|
-
return false;
|
|
1887
|
-
// Restore the no-window setup too: someone who runs minimized does not
|
|
1888
|
-
// want recovery to leave a window sitting on their desktop.
|
|
1889
|
-
if (!headless && (lastLogin?.minimized === true || resolveMinimizeWindowPreference())) {
|
|
2042
|
+
let minimized = false;
|
|
2043
|
+
if (windowMode.minimized) {
|
|
1890
2044
|
const outcome = await minimizeChatGptWindow({ port: opened.port }).catch(() => undefined);
|
|
1891
|
-
|
|
2045
|
+
minimized = outcome?.minimized === true;
|
|
2046
|
+
if (minimized)
|
|
1892
2047
|
stderr("recover: window minimized again");
|
|
1893
2048
|
}
|
|
2049
|
+
// Record the actual launch before waiting for authentication. A manual
|
|
2050
|
+
// login must target this profile and display even if readiness is blocked.
|
|
2051
|
+
await recordBrowserLoginLaunch({
|
|
2052
|
+
profile_dir: opened.profileDir,
|
|
2053
|
+
port: opened.port,
|
|
2054
|
+
headless: windowMode.headless,
|
|
2055
|
+
minimized,
|
|
2056
|
+
...(virtualDisplay ? { virtual_display: virtualDisplay.displayNumber } : {})
|
|
2057
|
+
});
|
|
2058
|
+
const ready = await waitForChatGptLoginReady(stderr, {
|
|
2059
|
+
port: opened.port,
|
|
2060
|
+
timeoutMs: 120_000,
|
|
2061
|
+
windowMode,
|
|
2062
|
+
headedLoginCommand: formatHeadedBrowserLoginCommand(undefined, {
|
|
2063
|
+
profileDir: opened.profileDir,
|
|
2064
|
+
...(opened.port !== DEFAULT_CDP_PORT ? { port: opened.port } : {})
|
|
2065
|
+
})
|
|
2066
|
+
});
|
|
2067
|
+
if (!ready)
|
|
2068
|
+
return false;
|
|
1894
2069
|
stderr("recover: browser READY - retrying the send...");
|
|
1895
|
-
|
|
1896
|
-
// the browser was gone and this answer came from one prodex started.
|
|
1897
|
-
if (wedged.length === 0)
|
|
1898
|
-
options.notes?.push(browserRecoveredNote([]));
|
|
2070
|
+
options.notes?.push(browserRecoveredNote(wedged));
|
|
1899
2071
|
return true;
|
|
1900
2072
|
}
|
|
1901
2073
|
catch (error) {
|
|
@@ -1915,8 +2087,13 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
|
|
|
1915
2087
|
const openTabFn = deps.openTabFn ?? openChatGptTab;
|
|
1916
2088
|
const timeoutMs = options.timeoutMs ?? 300_000;
|
|
1917
2089
|
const pollMs = options.pollMs ?? 2_000;
|
|
2090
|
+
const hasInteractiveWindow = options.windowMode?.headless !== true && options.windowMode?.virtualDisplay !== true;
|
|
2091
|
+
const headedLoginCommand = options.headedLoginCommand ?? "prodex pro browser login --headed";
|
|
2092
|
+
const headedLoginHint = ` No interactive window is available; run \`${headedLoginCommand}\` to complete login, captcha, or human verification visibly.`;
|
|
1918
2093
|
const startedAt = now();
|
|
1919
|
-
stderr(
|
|
2094
|
+
stderr(hasInteractiveWindow
|
|
2095
|
+
? "login: waiting for a logged-in ChatGPT tab (finish login in the dedicated Chrome browser; Ctrl+C stops waiting)..."
|
|
2096
|
+
: `login: waiting for a logged-in ChatGPT tab (no interactive window; use \`${headedLoginCommand}\` if login or verification is required; Ctrl+C stops waiting)...`);
|
|
1920
2097
|
let lastState = "";
|
|
1921
2098
|
let openMissingTabAttempts = 0;
|
|
1922
2099
|
while (now() - startedAt < timeoutMs) {
|
|
@@ -1933,19 +2110,26 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
|
|
|
1933
2110
|
? "login: the running Chrome had no ChatGPT tab - opening a ChatGPT tab in it..."
|
|
1934
2111
|
: `login: still no ChatGPT tab - opening one again (attempt ${attempt})...`);
|
|
1935
2112
|
const opened = await openTabFn(options.port);
|
|
1936
|
-
if (opened === false)
|
|
1937
|
-
stderr(
|
|
2113
|
+
if (opened === false) {
|
|
2114
|
+
stderr(hasInteractiveWindow
|
|
2115
|
+
? "login: could not open a ChatGPT tab through the debug port; open https://chatgpt.com/ in that browser."
|
|
2116
|
+
: `login: could not open a ChatGPT tab through the debug port; run \`${headedLoginCommand}\` to open it visibly.`);
|
|
2117
|
+
}
|
|
1938
2118
|
await sleepFn(pollMs);
|
|
1939
2119
|
continue;
|
|
1940
2120
|
}
|
|
1941
2121
|
const state = !status.reachable
|
|
1942
2122
|
? "login: browser starting..."
|
|
1943
2123
|
: status.blocker
|
|
1944
|
-
? `login: blocked - ${status.blocker.message}`
|
|
2124
|
+
? `login: blocked - ${status.blocker.message}${hasInteractiveWindow ? "" : headedLoginHint}`
|
|
1945
2125
|
: !status.loggedInLikely
|
|
1946
|
-
?
|
|
2126
|
+
? hasInteractiveWindow
|
|
2127
|
+
? "login: waiting for ChatGPT login in the dedicated Chrome browser..."
|
|
2128
|
+
: `login: waiting for a saved ChatGPT login.${headedLoginHint}`
|
|
1947
2129
|
: !status.hasComposer
|
|
1948
|
-
?
|
|
2130
|
+
? hasInteractiveWindow
|
|
2131
|
+
? "login: logged in; open a chat so the prompt composer is visible..."
|
|
2132
|
+
: `login: logged in, but no prompt composer is ready.${headedLoginHint}`
|
|
1949
2133
|
: "";
|
|
1950
2134
|
if (state === "") {
|
|
1951
2135
|
stderr(`login: READY - logged-in ChatGPT tab with composer detected (${Math.round((now() - startedAt) / 1000)}s).`);
|
|
@@ -1960,7 +2144,9 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
|
|
|
1960
2144
|
break;
|
|
1961
2145
|
await sleepFn(Math.min(pollMs, Math.max(1, remainingMs)));
|
|
1962
2146
|
}
|
|
1963
|
-
stderr(
|
|
2147
|
+
stderr(hasInteractiveWindow
|
|
2148
|
+
? `login: not ready after ${Math.round(timeoutMs / 1000)}s. Finish login in the browser, then verify with \`prodex pro browser check\`.`
|
|
2149
|
+
: `login: not ready after ${Math.round(timeoutMs / 1000)}s. Run \`${headedLoginCommand}\` to complete login, captcha, or human verification visibly, then retry.`);
|
|
1964
2150
|
return false;
|
|
1965
2151
|
}
|
|
1966
2152
|
export async function assertBrowserLaunchStayedAlive(opened, timeoutMs) {
|
|
@@ -2120,10 +2306,17 @@ export function browserSendBlockerFromError(error) {
|
|
|
2120
2306
|
const thread = typeof error === "object" && error !== null && "thread" in error && typeof error.thread === "string"
|
|
2121
2307
|
? (error.thread)
|
|
2122
2308
|
: undefined;
|
|
2309
|
+
const requestId = typeof error === "object" &&
|
|
2310
|
+
error !== null &&
|
|
2311
|
+
"requestId" in error &&
|
|
2312
|
+
typeof error.requestId === "string" &&
|
|
2313
|
+
ProdexRequestIdSchema.safeParse(error.requestId).success
|
|
2314
|
+
? error.requestId
|
|
2315
|
+
: undefined;
|
|
2123
2316
|
const timedOut = message.match(/Timed out after [\s\S]*?(\d+)\s*ms/);
|
|
2124
2317
|
if (timedOut) {
|
|
2125
|
-
//
|
|
2126
|
-
//
|
|
2318
|
+
// Marked sends recover the exact request. Legacy timeout errors retain a
|
|
2319
|
+
// concrete doubled budget so the user need not guess one.
|
|
2127
2320
|
const usedMs = Number(timedOut[1]);
|
|
2128
2321
|
const suggestedMs = Number.isFinite(usedMs) && usedMs > 0 ? usedMs * 2 : 600_000;
|
|
2129
2322
|
return {
|
|
@@ -2131,10 +2324,12 @@ export function browserSendBlockerFromError(error) {
|
|
|
2131
2324
|
message,
|
|
2132
2325
|
retryable: true,
|
|
2133
2326
|
...(thread ? { thread } : {}),
|
|
2134
|
-
next_step:
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2327
|
+
next_step: thread && requestId
|
|
2328
|
+
? `Do not resend automatically. ChatGPT often finishes after prodex gives up; recover that exact request instead: \`prodex pro browser recover --target-url ${thread} --request-id ${requestId}\`.`
|
|
2329
|
+
: `Rerun with a bigger budget (${formatDurationMs(suggestedMs)}): \`prodex pro browser ask --timeout-ms ${suggestedMs} "<same prompt>"\`.` +
|
|
2330
|
+
(thread
|
|
2331
|
+
? ` ChatGPT often finishes after prodex gives up - fetch that answer instead of re-asking: \`prodex pro browser recover --target-url ${thread}\`.`
|
|
2332
|
+
: "")
|
|
2138
2333
|
};
|
|
2139
2334
|
}
|
|
2140
2335
|
// A CDP command timeout means the page's renderer stalled, which in the
|
|
@@ -2551,9 +2746,14 @@ export async function listConsultListEntries(store, options = { readOnly: true }
|
|
|
2551
2746
|
}
|
|
2552
2747
|
return entries;
|
|
2553
2748
|
}
|
|
2749
|
+
function formatHeadedBrowserLoginCommand(sourceCli, options = {}) {
|
|
2750
|
+
return `${formatBrowserLoginCommand(sourceCli, options)} --headed`;
|
|
2751
|
+
}
|
|
2554
2752
|
export function printBrowserLoginGuide(stdout, input) {
|
|
2555
|
-
const
|
|
2753
|
+
const noInteractiveWindow = input.headless === true || input.virtualDisplay === true;
|
|
2754
|
+
const windowAvailable = (input.opened || input.reused === true) && !noInteractiveWindow;
|
|
2556
2755
|
const loginCommand = formatBrowserLoginCommand(input.sourceCli, input.commandOptions);
|
|
2756
|
+
const headedLoginCommand = formatHeadedBrowserLoginCommand(input.sourceCli, input.commandOptions);
|
|
2557
2757
|
const runtimeCommandOptions = {
|
|
2558
2758
|
...(input.commandOptions?.cwd ? { cwd: input.commandOptions.cwd } : {}),
|
|
2559
2759
|
...(input.commandOptions?.port ? { port: input.commandOptions.port } : {})
|
|
@@ -2565,13 +2765,18 @@ export function printBrowserLoginGuide(stdout, input) {
|
|
|
2565
2765
|
? input.reused
|
|
2566
2766
|
? "Headless ChatGPT browser is already running - reusing it (no window)."
|
|
2567
2767
|
: "Started the dedicated ChatGPT browser headless (no window). It reuses the profile you signed in with."
|
|
2568
|
-
: input.reused
|
|
2569
|
-
?
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2768
|
+
: input.virtualDisplay && (input.opened || input.reused)
|
|
2769
|
+
? input.reused
|
|
2770
|
+
? "ChatGPT browser is already running on its saved virtual display - reusing it (no visible window)."
|
|
2771
|
+
: "Started the dedicated ChatGPT browser on a virtual display (no visible window)."
|
|
2772
|
+
: input.reused
|
|
2773
|
+
? "Chrome is already running for ChatGPT - reusing it (no new window opened)."
|
|
2774
|
+
: input.opened
|
|
2775
|
+
? "Opened the dedicated Chrome window for ChatGPT."
|
|
2776
|
+
: "Dry run: no browser was opened.");
|
|
2777
|
+
if (noInteractiveWindow && (input.opened || input.reused)) {
|
|
2574
2778
|
stdout("");
|
|
2779
|
+
stdout(`If ChatGPT needs login, captcha, or human verification, run \`${headedLoginCommand}\` to handle it in a visible window.`);
|
|
2575
2780
|
stdout(`Next: run \`${checkCommand}\` to confirm the session, then consult as usual.`);
|
|
2576
2781
|
return;
|
|
2577
2782
|
}
|
|
@@ -2587,7 +2792,9 @@ export function printBrowserLoginGuide(stdout, input) {
|
|
|
2587
2792
|
stdout(`7. Run \`${smokeCommand}\` to verify a real Pro response path.`);
|
|
2588
2793
|
}
|
|
2589
2794
|
else {
|
|
2590
|
-
stdout(
|
|
2795
|
+
stdout(noInteractiveWindow
|
|
2796
|
+
? `1. Run \`${headedLoginCommand}\` to open a visible dedicated Chrome window for login or verification.`
|
|
2797
|
+
: `1. Run \`${loginCommand}\` without \`--dry-run\` to open the dedicated Chrome window.`);
|
|
2591
2798
|
stdout(`2. Log in manually at ${input.loginUrl} in that Chrome window.`);
|
|
2592
2799
|
stdout("3. If ChatGPT asks for captcha, Cloudflare/human verification, permission, or account verification, complete it in the browser.");
|
|
2593
2800
|
stdout("4. For usage limit, message limit, model limit, or rate limit, wait for the reset or choose an available model in the browser.");
|