@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/chatgpt-browser.js
CHANGED
|
@@ -2,11 +2,13 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { accessSync, constants, statSync } from "node:fs";
|
|
4
4
|
import net from "node:net";
|
|
5
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { captureBrowserDiagnostics, diagnosticsEnabled, diagnosticsNote } from "./browser-diagnostics.js";
|
|
8
|
+
import { withCrossProcessFileLock } from "./safe-file.js";
|
|
8
9
|
import os from "node:os";
|
|
9
10
|
import { answeredDialogWarning, chatSurfaceState, effortNeedsWorkSurface, javascriptDialogResponse, menuKeyboardStep, readPowerSliderSelection, sliderRestoreStep, surfaceFromProbe, sliderPressOutcome, sliderDidNotRespond } from "./picker-interaction.js";
|
|
11
|
+
import { projectsWithIdsExpression, recentConversationTitlesExpression } from "./tui.js";
|
|
10
12
|
export class ChatGptBrowserBlockerError extends Error {
|
|
11
13
|
blocker;
|
|
12
14
|
constructor(blocker) {
|
|
@@ -15,8 +17,14 @@ export class ChatGptBrowserBlockerError extends Error {
|
|
|
15
17
|
this.blocker = blocker;
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
function unsupportedChatGptOperationError(operation, nextStep) {
|
|
21
|
+
return new ChatGptBrowserBlockerError({
|
|
22
|
+
code: "unsupported_chatgpt_operation",
|
|
23
|
+
message: `${operation} is disabled because prodex cannot complete it through bounded, visible browser controls.`,
|
|
24
|
+
retryable: false,
|
|
25
|
+
next_step: nextStep
|
|
26
|
+
});
|
|
27
|
+
}
|
|
20
28
|
/** ~10s: measured, the surface took seconds to re-render after switching. */
|
|
21
29
|
const CHAT_SURFACE_SETTLE_ATTEMPTS = 14;
|
|
22
30
|
/** A full trip round the menu is far more than any real picker needs. */
|
|
@@ -231,11 +239,6 @@ export function buildChromeLaunchArgs(options) {
|
|
|
231
239
|
options.url
|
|
232
240
|
];
|
|
233
241
|
}
|
|
234
|
-
/**
|
|
235
|
-
* Headless is opt-in: an explicit option wins, otherwise PRODEX_HEADLESS
|
|
236
|
-
* (1/true/yes) decides. The env var is the practical switch because the MCP
|
|
237
|
-
* server and its auto-recovery launch the browser with no CLI flags.
|
|
238
|
-
*/
|
|
239
242
|
/**
|
|
240
243
|
* Minimize the dedicated browser window and report whether the tab is still
|
|
241
244
|
* readable afterwards.
|
|
@@ -283,10 +286,10 @@ export async function minimizeChatGptWindow(options = {}) {
|
|
|
283
286
|
// ---------------------------------------------------------------------------
|
|
284
287
|
const VIRTUAL_DISPLAY_SCREEN = "1440x900x24";
|
|
285
288
|
/**
|
|
286
|
-
* X server arguments.
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
289
|
+
* X server arguments. Linux still creates its abstract X11 socket when the
|
|
290
|
+
* filesystem Unix transport is disabled, which works with WSLg's read-only
|
|
291
|
+
* /tmp/.X11-unix mount without exposing the display over TCP. An xauth cookie
|
|
292
|
+
* (never -ac) keeps other local processes off the signed-in browser display.
|
|
290
293
|
*/
|
|
291
294
|
export function virtualDisplayServerArgs(displayNumber, xauthority) {
|
|
292
295
|
return [
|
|
@@ -294,22 +297,22 @@ export function virtualDisplayServerArgs(displayNumber, xauthority) {
|
|
|
294
297
|
"-screen",
|
|
295
298
|
"0",
|
|
296
299
|
VIRTUAL_DISPLAY_SCREEN,
|
|
297
|
-
"-
|
|
300
|
+
"-nolisten",
|
|
298
301
|
"tcp",
|
|
299
302
|
"-nolisten",
|
|
300
303
|
"unix",
|
|
301
304
|
"-auth",
|
|
302
|
-
xauthority
|
|
305
|
+
xauthority,
|
|
306
|
+
"-pn"
|
|
303
307
|
];
|
|
304
308
|
}
|
|
305
309
|
export function virtualDisplayEnv(displayNumber, xauthority, env = process.env) {
|
|
306
|
-
return { ...env, DISPLAY:
|
|
310
|
+
return { ...env, DISPLAY: `:${displayNumber}`, XAUTHORITY: xauthority };
|
|
307
311
|
}
|
|
308
312
|
export function resolveVirtualDisplayPreference(explicit, env = process.env) {
|
|
309
313
|
if (typeof explicit === "boolean")
|
|
310
314
|
return explicit;
|
|
311
|
-
|
|
312
|
-
return raw === "1" || raw === "true" || raw === "yes";
|
|
315
|
+
return browserModeSettingValue(env.PRODEX_VIRTUAL_DISPLAY);
|
|
313
316
|
}
|
|
314
317
|
export function assertVirtualDisplayToolingAvailable(hasCommand = isCommandOnPath) {
|
|
315
318
|
const missing = ["Xvfb", "xauth"].filter((command) => !hasCommand(command));
|
|
@@ -320,10 +323,21 @@ export function assertVirtualDisplayToolingAvailable(hasCommand = isCommandOnPat
|
|
|
320
323
|
function virtualDisplayStateDir() {
|
|
321
324
|
return path.join(os.homedir(), ".local", "share", "prodex", "xvfb");
|
|
322
325
|
}
|
|
323
|
-
async function
|
|
326
|
+
async function socketAccepts(connect, timeoutMs = 500) {
|
|
324
327
|
return new Promise((resolve) => {
|
|
325
|
-
|
|
328
|
+
let socket;
|
|
329
|
+
try {
|
|
330
|
+
socket = connect();
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
resolve(false);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
let settled = false;
|
|
326
337
|
const done = (result) => {
|
|
338
|
+
if (settled)
|
|
339
|
+
return;
|
|
340
|
+
settled = true;
|
|
327
341
|
socket.destroy();
|
|
328
342
|
resolve(result);
|
|
329
343
|
};
|
|
@@ -333,26 +347,47 @@ async function tcpPortAccepts(port, timeoutMs = 500) {
|
|
|
333
347
|
socket.once("error", () => done(false));
|
|
334
348
|
});
|
|
335
349
|
}
|
|
350
|
+
async function tcpPortAccepts(port, timeoutMs = 500) {
|
|
351
|
+
return socketAccepts(() => net.connect({ host: "127.0.0.1", port }), timeoutMs);
|
|
352
|
+
}
|
|
353
|
+
function virtualDisplayAbstractSocket(displayNumber) {
|
|
354
|
+
return `\0/tmp/.X11-unix/X${displayNumber}`;
|
|
355
|
+
}
|
|
356
|
+
async function virtualDisplaySocketAccepts(displayNumber, timeoutMs = 500) {
|
|
357
|
+
return socketAccepts(() => net.connect({ path: virtualDisplayAbstractSocket(displayNumber) }), timeoutMs);
|
|
358
|
+
}
|
|
336
359
|
/**
|
|
337
360
|
* Start (or reuse) the prodex virtual display and return how to reach it.
|
|
338
361
|
* The X server outlives the CLI process on purpose: the dedicated browser runs
|
|
339
362
|
* on it, so tearing it down at exit would kill the browser.
|
|
340
363
|
*/
|
|
341
364
|
export async function ensureVirtualDisplay(options = {}) {
|
|
365
|
+
if (process.platform !== "linux") {
|
|
366
|
+
throw new Error(`Virtual display abstract Unix sockets are supported on Linux/WSL only (current platform: ${process.platform}).`);
|
|
367
|
+
}
|
|
342
368
|
assertVirtualDisplayToolingAvailable();
|
|
343
369
|
const stateDir = virtualDisplayStateDir();
|
|
344
|
-
|
|
370
|
+
const allocationLock = path.join(stateDir, "allocation.lock");
|
|
371
|
+
return withCrossProcessFileLock(allocationLock, {
|
|
372
|
+
waitMs: 30_000,
|
|
373
|
+
privateParent: true,
|
|
374
|
+
busyError: () => new Error("Another prodex command is starting a virtual display. Wait for it to finish, then retry."),
|
|
375
|
+
unavailableError: () => new Error(`Virtual display allocation lock at ${allocationLock} is unavailable. Stop all prodex startups before removing that lock and its matching .reap claim, then retry.`)
|
|
376
|
+
}, () => ensureVirtualDisplayUnlocked(stateDir, options));
|
|
377
|
+
}
|
|
378
|
+
async function ensureVirtualDisplayUnlocked(stateDir, options) {
|
|
345
379
|
const requested = options.displayNumber ?? Number(process.env.PRODEX_VIRTUAL_DISPLAY_NUM ?? 99);
|
|
346
380
|
const first = Number.isInteger(requested) && requested > 0 && requested < 1000 ? requested : 99;
|
|
347
|
-
// Walk display numbers
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
381
|
+
// Walk display numbers. Any legacy TCP listener reserves its number even if
|
|
382
|
+
// our old auth file still exists: never migrate, kill, or overwrite it. An
|
|
383
|
+
// abstract-only display is reusable when our cookie for it exists; otherwise
|
|
384
|
+
// it belongs to something else and cannot be authenticated. A number with
|
|
385
|
+
// neither transport gets a fresh server.
|
|
352
386
|
for (let displayNumber = first; displayNumber < first + 10; displayNumber += 1) {
|
|
353
387
|
const xauthority = path.join(stateDir, `Xauthority-${displayNumber}`);
|
|
354
|
-
|
|
355
|
-
|
|
388
|
+
if (await tcpPortAccepts(6000 + displayNumber))
|
|
389
|
+
continue;
|
|
390
|
+
if (await virtualDisplaySocketAccepts(displayNumber)) {
|
|
356
391
|
let ours = false;
|
|
357
392
|
try {
|
|
358
393
|
ours = (await readFile(xauthority)).length > 0;
|
|
@@ -366,33 +401,78 @@ export async function ensureVirtualDisplay(options = {}) {
|
|
|
366
401
|
}
|
|
367
402
|
const cookie = randomBytes(16).toString("hex");
|
|
368
403
|
await writeFile(xauthority, "", { mode: 0o600 });
|
|
369
|
-
|
|
404
|
+
await chmod(xauthority, 0o600);
|
|
405
|
+
const auth = spawnSync("xauth", ["-f", xauthority, "add", `:${displayNumber}`, ".", cookie], {
|
|
370
406
|
encoding: "utf8",
|
|
371
407
|
timeout: 10_000
|
|
372
408
|
});
|
|
373
409
|
if (auth.status !== 0) {
|
|
374
410
|
throw new Error(`Could not create the X authority cookie: ${(auth.stderr || auth.stdout || "xauth failed").trim()}`);
|
|
375
411
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
412
|
+
await chmod(xauthority, 0o600);
|
|
413
|
+
let child;
|
|
414
|
+
try {
|
|
415
|
+
child = spawn("Xvfb", virtualDisplayServerArgs(displayNumber, xauthority), {
|
|
416
|
+
detached: true,
|
|
417
|
+
stdio: "ignore",
|
|
418
|
+
env: process.env
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
423
|
+
throw new Error(`Could not start Xvfb for display :${displayNumber}: ${message}`);
|
|
424
|
+
}
|
|
425
|
+
let rejectStartup;
|
|
426
|
+
const startupFailed = new Promise((_resolve, reject) => {
|
|
427
|
+
rejectStartup = reject;
|
|
428
|
+
});
|
|
429
|
+
child.on("error", (error) => {
|
|
430
|
+
rejectStartup(new Error(`Could not start Xvfb for display :${displayNumber}: ${error.message}`));
|
|
431
|
+
});
|
|
432
|
+
child.once("exit", (code, signal) => {
|
|
433
|
+
rejectStartup(new Error(`Xvfb for display :${displayNumber} exited before its abstract socket was ready (code ${code ?? "null"}, signal ${signal ?? "none"}).`));
|
|
380
434
|
});
|
|
381
435
|
child.unref();
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
436
|
+
try {
|
|
437
|
+
const deadline = Date.now() + 10_000;
|
|
438
|
+
while (Date.now() < deadline) {
|
|
439
|
+
if (await Promise.race([virtualDisplaySocketAccepts(displayNumber), startupFailed])) {
|
|
440
|
+
return { displayNumber, xauthority, startedNow: true };
|
|
441
|
+
}
|
|
442
|
+
await Promise.race([sleep(250), startupFailed]);
|
|
443
|
+
}
|
|
444
|
+
throw new Error(`Xvfb did not open its abstract socket for display :${displayNumber} within 10s.`);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
448
|
+
try {
|
|
449
|
+
child.kill("SIGTERM");
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// The exact child may already have disappeared between the state
|
|
453
|
+
// check and kill; never broaden cleanup beyond this invocation.
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
throw error;
|
|
387
457
|
}
|
|
388
|
-
throw new Error(`Xvfb did not start listening for display :${displayNumber} within 10s.`);
|
|
389
458
|
}
|
|
390
459
|
throw new Error(`No free X display between :${first} and :${first + 9}. Set PRODEX_VIRTUAL_DISPLAY_NUM to a free number.`);
|
|
391
460
|
}
|
|
461
|
+
function browserModeSettingValue(raw) {
|
|
462
|
+
const normalized = (raw ?? "").trim().toLowerCase();
|
|
463
|
+
return normalized === "1" || normalized === "true" || normalized === "yes";
|
|
464
|
+
}
|
|
465
|
+
function assertSingleBrowserWindowMode(modes) {
|
|
466
|
+
const enabled = modes.filter((mode) => mode.enabled).map((mode) => mode.label);
|
|
467
|
+
if (enabled.length > 1) {
|
|
468
|
+
throw new Error(`Browser window mode settings cannot combine ${enabled.join(" and ")}; choose exactly one.`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
392
471
|
/**
|
|
393
472
|
* How should the dedicated browser be opened?
|
|
394
473
|
*
|
|
395
|
-
* An
|
|
474
|
+
* An explicitly supplied flag group wins; otherwise any non-empty environment
|
|
475
|
+
* mode group wins, including false values. With neither, reopen it the way it
|
|
396
476
|
* was last opened. Without the saved fallback, `pro browser login` - the exact
|
|
397
477
|
* command every `browser_unreachable` blocker tells people to run - put a
|
|
398
478
|
* VISIBLE window back on the desktop of someone who had set up a virtual
|
|
@@ -405,37 +485,53 @@ export async function ensureVirtualDisplay(options = {}) {
|
|
|
405
485
|
export function resolveBrowserWindowMode(args) {
|
|
406
486
|
const env = args.env ?? process.env;
|
|
407
487
|
const flags = args.flags ?? {};
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
virtualDisplay: flags.virtualDisplay ?? fromEnv("PRODEX_VIRTUAL_DISPLAY"),
|
|
417
|
-
minimized: flags.minimized ?? fromEnv("PRODEX_MINIMIZE_WINDOW")
|
|
418
|
-
};
|
|
419
|
-
const chosen = Object.values(explicit).some((value) => value === true);
|
|
420
|
-
if (chosen) {
|
|
488
|
+
const flagGroupSupplied = Object.values(flags).some((value) => typeof value === "boolean");
|
|
489
|
+
if (flagGroupSupplied) {
|
|
490
|
+
assertSingleBrowserWindowMode([
|
|
491
|
+
{ enabled: flags.headless === true, label: "--headless" },
|
|
492
|
+
{ enabled: flags.virtualDisplay === true, label: "--virtual-display" },
|
|
493
|
+
{ enabled: flags.minimized === true, label: "--minimized" },
|
|
494
|
+
{ enabled: flags.headed === true, label: "--headed" }
|
|
495
|
+
]);
|
|
421
496
|
return {
|
|
422
|
-
headless:
|
|
423
|
-
virtualDisplay:
|
|
424
|
-
minimized:
|
|
497
|
+
headless: flags.headless === true,
|
|
498
|
+
virtualDisplay: flags.virtualDisplay === true,
|
|
499
|
+
minimized: flags.minimized === true
|
|
425
500
|
};
|
|
426
501
|
}
|
|
502
|
+
const envModes = [
|
|
503
|
+
{ key: "PRODEX_HEADLESS", mode: "headless" },
|
|
504
|
+
{ key: "PRODEX_VIRTUAL_DISPLAY", mode: "virtualDisplay" },
|
|
505
|
+
{ key: "PRODEX_MINIMIZE_WINDOW", mode: "minimized" }
|
|
506
|
+
];
|
|
507
|
+
const envGroupSupplied = envModes.some(({ key }) => (env[key] ?? "").trim() !== "");
|
|
508
|
+
if (envGroupSupplied) {
|
|
509
|
+
const selected = Object.fromEntries(envModes.map(({ key, mode }) => [mode, browserModeSettingValue(env[key])]));
|
|
510
|
+
assertSingleBrowserWindowMode(envModes.map(({ key, mode }) => ({ enabled: selected[mode], label: key })));
|
|
511
|
+
return selected;
|
|
512
|
+
}
|
|
427
513
|
const saved = args.lastLogin;
|
|
428
|
-
|
|
514
|
+
const selected = {
|
|
429
515
|
headless: saved?.headless === true,
|
|
430
516
|
virtualDisplay: saved?.virtual_display !== undefined,
|
|
431
517
|
minimized: saved?.minimized === true
|
|
432
518
|
};
|
|
519
|
+
assertSingleBrowserWindowMode([
|
|
520
|
+
{ enabled: selected.headless, label: "saved headless mode" },
|
|
521
|
+
{ enabled: selected.virtualDisplay, label: "saved virtual-display mode" },
|
|
522
|
+
{ enabled: selected.minimized, label: "saved minimized mode" }
|
|
523
|
+
]);
|
|
524
|
+
return selected;
|
|
433
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* Resolve only the low-level headless toggle for direct browser-boundary
|
|
528
|
+
* callers. Login and recovery use resolveBrowserWindowMode so all primary
|
|
529
|
+
* modes share one precedence and conflict policy.
|
|
530
|
+
*/
|
|
434
531
|
export function resolveHeadlessPreference(explicit, env = process.env) {
|
|
435
532
|
if (typeof explicit === "boolean")
|
|
436
533
|
return explicit;
|
|
437
|
-
|
|
438
|
-
return raw === "1" || raw === "true" || raw === "yes";
|
|
534
|
+
return browserModeSettingValue(env.PRODEX_HEADLESS);
|
|
439
535
|
}
|
|
440
536
|
/**
|
|
441
537
|
* The two verdicts read different text on purpose.
|
|
@@ -618,19 +714,43 @@ export function isFreshChatGptPage(state) {
|
|
|
618
714
|
const onRoot = /^https:\/\/chatgpt\.com\/?(?:[?#].*)?$/.test(state.url);
|
|
619
715
|
return onRoot && state.assistantMessageCount === 0 && state.userMessageCount === 0;
|
|
620
716
|
}
|
|
717
|
+
/** Check and navigate in one renderer task, so a late stop control prevents the move. */
|
|
718
|
+
export function idleChatGptNavigationExpression(url) {
|
|
719
|
+
return `(() => {
|
|
720
|
+
const status = ${statusExpression()};
|
|
721
|
+
if (status.generating && !status.awaitingResponseChoice) return false;
|
|
722
|
+
location.assign(${JSON.stringify(url)});
|
|
723
|
+
return true;
|
|
724
|
+
})()`;
|
|
725
|
+
}
|
|
726
|
+
async function navigateIdleChatGptPage(page, url) {
|
|
727
|
+
if (await evaluateOnPage(page, idleChatGptNavigationExpression(url)) !== true) {
|
|
728
|
+
throw new ChatGptBrowserBlockerError({
|
|
729
|
+
code: "response_in_progress",
|
|
730
|
+
message: "The shared ChatGPT tab became busy before navigation. Nothing was sent or moved.",
|
|
731
|
+
retryable: true,
|
|
732
|
+
next_step: "Wait for the current response to finish before retrying this operation."
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
621
736
|
/**
|
|
622
737
|
* Poll until the tab settles on a fresh empty chat (or the timeout elapses).
|
|
623
738
|
* Deterministically replaces a fixed post-navigation sleep so a slow SPA
|
|
624
|
-
* navigation cannot leave the old thread's state in place.
|
|
625
|
-
*
|
|
626
|
-
* guards), but the poll removes the common race.
|
|
739
|
+
* navigation cannot leave the old thread's state in place. A false result
|
|
740
|
+
* must not be used as permission to send into stale content.
|
|
627
741
|
*/
|
|
628
742
|
async function waitForFreshChatGptPage(page, timeoutMs) {
|
|
629
743
|
const deadline = Date.now() + timeoutMs;
|
|
630
744
|
while (Date.now() < deadline) {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
745
|
+
try {
|
|
746
|
+
const state = await evaluateOnPage(page, answerExpression());
|
|
747
|
+
if (isFreshChatGptPage(state))
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
if (!/execution context|cannot find context|Runtime\.evaluate failed/i.test(String(error)))
|
|
752
|
+
throw error;
|
|
753
|
+
}
|
|
634
754
|
await sleep(300);
|
|
635
755
|
}
|
|
636
756
|
return false;
|
|
@@ -794,23 +914,6 @@ export function busyBlockerAfterTranscriptCheck(busyBlocker, transcript) {
|
|
|
794
914
|
return undefined;
|
|
795
915
|
return transcript?.ok === true && transcript.isComplete === true ? undefined : busyBlocker;
|
|
796
916
|
}
|
|
797
|
-
/**
|
|
798
|
-
* Ask the transcript whether the conversation the tab is showing has finished.
|
|
799
|
-
* Undefined when there is nothing to ask about (a fresh chat or project home
|
|
800
|
-
* carries no conversation id) or the read fails.
|
|
801
|
-
*/
|
|
802
|
-
async function readTranscriptCompletion(page, url) {
|
|
803
|
-
const conversationId = conversationIdFromThreadUrl(url);
|
|
804
|
-
if (!conversationId)
|
|
805
|
-
return undefined;
|
|
806
|
-
try {
|
|
807
|
-
const state = await evaluateOnPage(page, transcriptAnswerExpression(conversationId));
|
|
808
|
-
return { ok: state.ok === true, ...(state.isComplete !== undefined ? { isComplete: state.isComplete } : {}) };
|
|
809
|
-
}
|
|
810
|
-
catch {
|
|
811
|
-
return undefined;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
917
|
export function isLikelyChatGptSubmitButton(label, dataTestId) {
|
|
815
918
|
const normalized = label.trim().toLowerCase();
|
|
816
919
|
return dataTestId === "send-button" || /\b(send|submit)\b|보내기|전송/.test(normalized);
|
|
@@ -1317,7 +1420,7 @@ export async function getChatGptBrowserStatus(options = {}) {
|
|
|
1317
1420
|
const blocker = chatGptVisibilityBlocker(state.visibilityState, state.url) ??
|
|
1318
1421
|
detectChatGptPageBlocker(state) ??
|
|
1319
1422
|
chatGptResponseChoiceBlocker(state.awaitingResponseChoice === true) ??
|
|
1320
|
-
|
|
1423
|
+
busyBlocker;
|
|
1321
1424
|
return {
|
|
1322
1425
|
reachable: true,
|
|
1323
1426
|
loggedInLikely,
|
|
@@ -1653,21 +1756,7 @@ export function chatSurfaceProbeExpression() {
|
|
|
1653
1756
|
label: ((el.innerText || el.textContent || "").trim()),
|
|
1654
1757
|
checked: el.getAttribute("aria-checked") === "true" || el.getAttribute("aria-selected") === "true"
|
|
1655
1758
|
}));
|
|
1656
|
-
|
|
1657
|
-
// every page can read it.
|
|
1658
|
-
let storedMode = "";
|
|
1659
|
-
try {
|
|
1660
|
-
storedMode = localStorage.getItem(${JSON.stringify(CHAT_SURFACE_STORAGE_KEY)}) || "";
|
|
1661
|
-
} catch (error) {
|
|
1662
|
-
storedMode = "";
|
|
1663
|
-
}
|
|
1664
|
-
if (!storedMode) {
|
|
1665
|
-
// Anchored to the start of a cookie, so a name that merely ENDS with this
|
|
1666
|
-
// one cannot answer for it.
|
|
1667
|
-
const m = document.cookie.match(/(?:^|;)\\s*oai-chat-surface-mode=([^;]*)/);
|
|
1668
|
-
storedMode = m ? decodeURIComponent(m[1]) : "";
|
|
1669
|
-
}
|
|
1670
|
-
return { surfaces, storedMode };
|
|
1759
|
+
return { surfaces };
|
|
1671
1760
|
})()`;
|
|
1672
1761
|
}
|
|
1673
1762
|
/** Where to click to go back to Chat. Only asked once a switch is decided. */
|
|
@@ -1679,21 +1768,6 @@ export function chatSurfaceToggleRectExpression() {
|
|
|
1679
1768
|
return chat ? clickPoint(chat) : { ok: false, reason: "no Chat toggle" };
|
|
1680
1769
|
})()`;
|
|
1681
1770
|
}
|
|
1682
|
-
export function selectChatSurfaceExpression() {
|
|
1683
|
-
return `(() => {
|
|
1684
|
-
try {
|
|
1685
|
-
localStorage.setItem(${JSON.stringify(CHAT_SURFACE_STORAGE_KEY)}, JSON.stringify("chat"));
|
|
1686
|
-
} catch (error) {
|
|
1687
|
-
// a blocked store is not fatal: the cookie below is what the app reads
|
|
1688
|
-
}
|
|
1689
|
-
// Host-only, the way the app writes it. Setting a domain-wide copy instead
|
|
1690
|
-
// left two oai-chat-surface-mode cookies in play - one chat, one work - and
|
|
1691
|
-
// which of them won was anyone's guess.
|
|
1692
|
-
document.cookie = "oai-chat-surface-mode=chat; path=/; max-age=" + (60 * 60 * 24 * 365);
|
|
1693
|
-
document.cookie = "oai-chat-surface-mode=; path=/; domain=.chatgpt.com; max-age=0";
|
|
1694
|
-
return true;
|
|
1695
|
-
})()`;
|
|
1696
|
-
}
|
|
1697
1771
|
/**
|
|
1698
1772
|
* Name of the stamp put on a document that is about to be reloaded. A stamp
|
|
1699
1773
|
* cannot survive a navigation, so its absence is what tells the reloaded
|
|
@@ -2295,35 +2369,6 @@ async function ensureChatSurface(cdp, options) {
|
|
|
2295
2369
|
return note;
|
|
2296
2370
|
}
|
|
2297
2371
|
}
|
|
2298
|
-
catch (error) {
|
|
2299
|
-
if (cdpCommandTimedOut(error))
|
|
2300
|
-
throw error;
|
|
2301
|
-
// fall through to the stored preference, which works without the toggle
|
|
2302
|
-
}
|
|
2303
|
-
// Threads and project pages do not render the toggle at all, so the surface
|
|
2304
|
-
// is set the way the app itself stores it and the page is reloaded onto it.
|
|
2305
|
-
try {
|
|
2306
|
-
await cdp.evaluate(selectChatSurfaceExpression());
|
|
2307
|
-
// Reading the persisted value back right after writing it proves nothing;
|
|
2308
|
-
// the new document rendering its composer is what proves the switch.
|
|
2309
|
-
const plan = chatSurfaceRecoveryPlan({
|
|
2310
|
-
href: await cdp.evaluate("location.href"),
|
|
2311
|
-
mayLeaveCurrentPage: options.mayLeaveCurrentPage
|
|
2312
|
-
});
|
|
2313
|
-
let applied;
|
|
2314
|
-
if (plan === "fresh-root") {
|
|
2315
|
-
// Throws when the root never rendered a composer, which the catch below
|
|
2316
|
-
// turns into the same "could not be switched back" warning as a reload
|
|
2317
|
-
// that never settled.
|
|
2318
|
-
await openFreshChatGptHome(cdp);
|
|
2319
|
-
applied = true;
|
|
2320
|
-
}
|
|
2321
|
-
else {
|
|
2322
|
-
applied = await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS);
|
|
2323
|
-
}
|
|
2324
|
-
if (applied && (await confirm()))
|
|
2325
|
-
return note;
|
|
2326
|
-
}
|
|
2327
2372
|
catch (error) {
|
|
2328
2373
|
if (cdpCommandTimedOut(error))
|
|
2329
2374
|
throw error;
|
|
@@ -2537,7 +2582,12 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
2537
2582
|
// GPT-5.6 Sol) instead of a model radio list, so "Pro" is the top EFFORT.
|
|
2538
2583
|
// Drive it when it is there and fall through to the legacy radio path when
|
|
2539
2584
|
// it is not, so both UI generations work.
|
|
2540
|
-
|
|
2585
|
+
let sliderState = await cdp.evaluate(powerSliderStateExpression());
|
|
2586
|
+
if (!sliderState?.ok && await waitForExpressionTrue(cdp, powerSliderPresentExpression(), MENU_OPEN_TIMEOUT_MS)) {
|
|
2587
|
+
// The container and model rows can paint before the effort control.
|
|
2588
|
+
// Do not toggle an already-open menu or guess that this is the old UI.
|
|
2589
|
+
sliderState = await cdp.evaluate(powerSliderStateExpression());
|
|
2590
|
+
}
|
|
2541
2591
|
if (sliderState?.ok) {
|
|
2542
2592
|
// The slider is the EFFORT control and the models are radios beside it.
|
|
2543
2593
|
// Sending a model name into the slider made it walk every step looking
|
|
@@ -3016,6 +3066,9 @@ async function selectProject(cdp, options) {
|
|
|
3016
3066
|
// sits in the thread the operator can see. Navigates the visible tab to the
|
|
3017
3067
|
// thread and waits for a stable, non-generating answer.
|
|
3018
3068
|
export async function recoverChatGptAnswerFromThread(options) {
|
|
3069
|
+
if (options.requestId !== undefined && !/^[a-f0-9]{32}$/.test(options.requestId)) {
|
|
3070
|
+
throw new Error("requestId must be the 32-character prodex request identifier.");
|
|
3071
|
+
}
|
|
3019
3072
|
const port = resolveCdpPort(options.port);
|
|
3020
3073
|
const timeoutMs = Math.max(1_000, options.timeoutMs ?? 60_000);
|
|
3021
3074
|
const url = normalizeChatGptTargetUrl(options.targetUrl);
|
|
@@ -3028,60 +3081,27 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
3028
3081
|
next_step: "Run `prodex pro browser login` to reopen the dedicated window - it reuses the saved session (no manual login unless it expired) and returns immediately when run non-interactively - then retry."
|
|
3029
3082
|
});
|
|
3030
3083
|
}
|
|
3084
|
+
const currentStatus = await readSettledChatGptPageStatus(page.page);
|
|
3085
|
+
const currentBlocker = detectChatGptPageBlocker(currentStatus);
|
|
3086
|
+
if (currentBlocker)
|
|
3087
|
+
throw new ChatGptBrowserBlockerError(currentBlocker);
|
|
3088
|
+
const alreadyOnTarget = chatGptUrlsReferToSameTarget(currentStatus.url, url);
|
|
3089
|
+
const currentBusy = chatGptBusyBlocker(currentStatus);
|
|
3090
|
+
if (!alreadyOnTarget && currentBusy)
|
|
3091
|
+
throw new ChatGptBrowserBlockerError(currentBusy);
|
|
3031
3092
|
const cdp = await connectCdp(page.page.webSocketDebuggerUrl);
|
|
3032
3093
|
let state;
|
|
3033
3094
|
let generating = false;
|
|
3034
3095
|
let stableRuns = 0;
|
|
3035
3096
|
let lastAnswer = "";
|
|
3097
|
+
let lastObservedUrl = "";
|
|
3098
|
+
let completed = false;
|
|
3036
3099
|
try {
|
|
3037
3100
|
await cdp.send("Runtime.enable");
|
|
3038
3101
|
// In-tab navigation (location.assign, not Page.navigate which has crashed the
|
|
3039
3102
|
// instance) so we read the requested thread, not whatever was open.
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
// lives in the widget state on the conversation transcript. Check that
|
|
3043
|
-
// first so `recover` works on research threads at all.
|
|
3044
|
-
const conversationId = conversationIdFromThreadUrl(url);
|
|
3045
|
-
if (conversationId) {
|
|
3046
|
-
try {
|
|
3047
|
-
const report = await evaluateOnPage(page.page, deepResearchReportExpression(conversationId), {
|
|
3048
|
-
timeoutMs: 60_000
|
|
3049
|
-
});
|
|
3050
|
-
if (report.ok && report.report.trim().length > 0) {
|
|
3051
|
-
return {
|
|
3052
|
-
url,
|
|
3053
|
-
title: "",
|
|
3054
|
-
answer: resolveTranscriptCitations(report.report, report.references).trim(),
|
|
3055
|
-
modelHints: [],
|
|
3056
|
-
warnings: []
|
|
3057
|
-
};
|
|
3058
|
-
}
|
|
3059
|
-
// Not a research thread: read the ordinary answer from the transcript
|
|
3060
|
-
// too. Recover used to be page-only, so it inherited everything the
|
|
3061
|
-
// page loses - flattened markdown, dropped citation urls - and it once
|
|
3062
|
-
// saved ChatGPT's "Connection interrupted" notice as the answer.
|
|
3063
|
-
const transcript = await evaluateOnPage(page.page, transcriptAnswerExpression(conversationId), {
|
|
3064
|
-
timeoutMs: 60_000
|
|
3065
|
-
});
|
|
3066
|
-
if (transcript.ok && transcript.text.trim().length > 0) {
|
|
3067
|
-
const recovered = resolveTranscriptCitations(transcript.text, transcript.references).trim();
|
|
3068
|
-
if (recovered.length > 0) {
|
|
3069
|
-
return {
|
|
3070
|
-
url,
|
|
3071
|
-
title: "",
|
|
3072
|
-
answer: recovered,
|
|
3073
|
-
modelHints: [],
|
|
3074
|
-
...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : {}),
|
|
3075
|
-
warnings: []
|
|
3076
|
-
};
|
|
3077
|
-
}
|
|
3078
|
-
}
|
|
3079
|
-
}
|
|
3080
|
-
catch {
|
|
3081
|
-
// Not a research thread, or the transcript API is unavailable: fall
|
|
3082
|
-
// through to the normal DOM recovery below.
|
|
3083
|
-
}
|
|
3084
|
-
}
|
|
3103
|
+
if (!alreadyOnTarget)
|
|
3104
|
+
await navigateIdleChatGptPage(page.page, url);
|
|
3085
3105
|
const deadline = Date.now() + timeoutMs;
|
|
3086
3106
|
while (Date.now() < deadline) {
|
|
3087
3107
|
await sleep(500);
|
|
@@ -3092,9 +3112,28 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
3092
3112
|
continue;
|
|
3093
3113
|
}
|
|
3094
3114
|
generating = state.generating;
|
|
3115
|
+
if (state.url !== lastObservedUrl) {
|
|
3116
|
+
lastObservedUrl = state.url;
|
|
3117
|
+
stableRuns = 0;
|
|
3118
|
+
lastAnswer = "";
|
|
3119
|
+
}
|
|
3095
3120
|
const runtimeBlocker = chatGptBlockerFromAnswerState(state);
|
|
3096
3121
|
if (runtimeBlocker)
|
|
3097
3122
|
throw new ChatGptBrowserBlockerError(runtimeBlocker);
|
|
3123
|
+
if (!chatGptUrlsReferToSameTarget(state.url, url)) {
|
|
3124
|
+
stableRuns = 0;
|
|
3125
|
+
lastAnswer = "";
|
|
3126
|
+
continue;
|
|
3127
|
+
}
|
|
3128
|
+
if (options.requestId && !chatGptRequestMarkerMatches(state.lastUserText ?? "", options.requestId)) {
|
|
3129
|
+
throw new ChatGptBrowserBlockerError({
|
|
3130
|
+
code: "request_mismatch",
|
|
3131
|
+
message: "The recovered conversation's latest user turn does not match the requested prodex request. No answer was returned.",
|
|
3132
|
+
retryable: false,
|
|
3133
|
+
next_step: "Inspect the original request in the browser. Do not treat a later turn in the same conversation as its answer.",
|
|
3134
|
+
thread: url
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3098
3137
|
// Require a REAL assistant message, not answerExpression's page-chrome
|
|
3099
3138
|
// fallback (empty assistant returns sidebar/nav text): the thread's
|
|
3100
3139
|
// conversation loads asynchronously after navigation, so keep polling.
|
|
@@ -3103,8 +3142,10 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
3103
3142
|
// must not sneak into the recovered text.
|
|
3104
3143
|
stableRuns = state.answer === lastAnswer ? stableRuns + 1 : 0;
|
|
3105
3144
|
lastAnswer = state.answer;
|
|
3106
|
-
if (stableRuns >= 1)
|
|
3145
|
+
if (stableRuns >= 1) {
|
|
3146
|
+
completed = true;
|
|
3107
3147
|
break;
|
|
3148
|
+
}
|
|
3108
3149
|
}
|
|
3109
3150
|
else {
|
|
3110
3151
|
stableRuns = 0;
|
|
@@ -3115,65 +3156,61 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
3115
3156
|
finally {
|
|
3116
3157
|
cdp.close();
|
|
3117
3158
|
}
|
|
3118
|
-
if (!
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3159
|
+
if (!completed) {
|
|
3160
|
+
const targetMatched = Boolean(state && chatGptUrlsReferToSameTarget(state.url, url));
|
|
3161
|
+
const hasUsableAnswer = Boolean(state && state.assistantMessageCount > 0 && isUsableChatGptAnswer(state.answer));
|
|
3162
|
+
const code = !targetMatched
|
|
3163
|
+
? "thread_target_mismatch"
|
|
3164
|
+
: generating
|
|
3165
|
+
? "still_generating"
|
|
3166
|
+
: hasUsableAnswer
|
|
3167
|
+
? "answer_not_stable"
|
|
3168
|
+
: "no_recoverable_answer";
|
|
3169
|
+
const message = code === "thread_target_mismatch"
|
|
3170
|
+
? `The visible ChatGPT tab did not settle on the requested conversation. It remained at ${state?.url || "an unreadable page"}.`
|
|
3171
|
+
: code === "still_generating"
|
|
3122
3172
|
? "That thread is still generating - the answer is not complete yet."
|
|
3123
|
-
:
|
|
3173
|
+
: code === "answer_not_stable"
|
|
3174
|
+
? "That thread showed changing answer text through the recovery deadline, so prodex cannot mark it complete."
|
|
3175
|
+
: "No finished assistant answer loaded from that thread (the conversation may not have rendered, or the URL is not the consult thread).";
|
|
3176
|
+
throw new ChatGptBrowserBlockerError({
|
|
3177
|
+
code,
|
|
3178
|
+
message,
|
|
3124
3179
|
retryable: true,
|
|
3125
|
-
next_step:
|
|
3126
|
-
? "Wait for ChatGPT to finish, then rerun `prodex pro browser recover --target-url <url>`."
|
|
3127
|
-
: "
|
|
3180
|
+
next_step: code === "still_generating" || code === "answer_not_stable"
|
|
3181
|
+
? "Wait for ChatGPT to finish and settle, then rerun `prodex pro browser recover --target-url <url>`."
|
|
3182
|
+
: "Keep the dedicated visible tab on the requested consult thread, confirm it shows a finished answer, and retry recovery with a larger --timeout-ms if needed.",
|
|
3183
|
+
thread: url
|
|
3128
3184
|
});
|
|
3129
3185
|
}
|
|
3130
3186
|
return {
|
|
3131
|
-
url
|
|
3187
|
+
url,
|
|
3132
3188
|
title: state.title,
|
|
3133
3189
|
answer: state.answer.trim(),
|
|
3134
3190
|
modelHints: state.modelHints,
|
|
3135
3191
|
...(state.modelSlug ? { modelSlug: state.modelSlug } : {}),
|
|
3136
|
-
|
|
3192
|
+
...(options.requestId ? { requestId: options.requestId } : {}),
|
|
3193
|
+
requestVerified: options.requestId !== undefined,
|
|
3194
|
+
warnings: options.requestId ? [] : ["request_unverified: recovered the latest answer in the named conversation without a request ID. Verify the preceding question before using this as a review."]
|
|
3137
3195
|
};
|
|
3138
3196
|
}
|
|
3139
|
-
async function readTranscriptAnswer(page, conversationId, sentPrompt) {
|
|
3140
|
-
let transcript;
|
|
3141
|
-
try {
|
|
3142
|
-
transcript = await evaluateOnPage(page, transcriptAnswerExpression(conversationId), { timeoutMs: 30_000 });
|
|
3143
|
-
}
|
|
3144
|
-
catch {
|
|
3145
|
-
// Transcript unreachable (endpoint changed, transient failure): the DOM
|
|
3146
|
-
// reader still runs, so this never blocks a send.
|
|
3147
|
-
return { classification: "unavailable" };
|
|
3148
|
-
}
|
|
3149
|
-
const classification = classifyTranscriptRead(transcript, sentPrompt);
|
|
3150
|
-
if (classification !== "answer")
|
|
3151
|
-
return { classification };
|
|
3152
|
-
const answer = resolveTranscriptCitations(transcript.text, transcript.references).trim();
|
|
3153
|
-
return answer.length > 0
|
|
3154
|
-
? { classification: "answer", answer: { answer, modelSlug: transcript.modelSlug } }
|
|
3155
|
-
: { classification: "no_text" };
|
|
3156
|
-
}
|
|
3157
|
-
// A page that has not reported the prompt posting within this long is worth
|
|
3158
|
-
// double-checking against the transcript; the probe is a couple of small fetches.
|
|
3159
|
-
const ACCEPTANCE_TRANSCRIPT_PROBE_AFTER_MS = 20_000;
|
|
3160
|
-
const ACCEPTANCE_TRANSCRIPT_PROBE_EVERY_MS = 10_000;
|
|
3161
|
-
/** Which conversation, if any, already holds the prompt this send posted. */
|
|
3162
|
-
async function findLandedConversation(page, prompt) {
|
|
3163
|
-
try {
|
|
3164
|
-
const candidates = await evaluateOnPage(page, recentConversationsExpression(4), {
|
|
3165
|
-
timeoutMs: 30_000
|
|
3166
|
-
});
|
|
3167
|
-
return pickLandedConversation(candidates ?? [], prompt);
|
|
3168
|
-
}
|
|
3169
|
-
catch {
|
|
3170
|
-
// Transcript unavailable: the caller falls back to the page.
|
|
3171
|
-
return undefined;
|
|
3172
|
-
}
|
|
3173
|
-
}
|
|
3174
3197
|
export async function sendChatGptPrompt(options) {
|
|
3198
|
+
const toolLabels = (options.tools ?? []).map(resolveComposerToolLabel);
|
|
3199
|
+
if (toolLabels.includes(DEEP_RESEARCH_TOOL_LABEL)) {
|
|
3200
|
+
throw unsupportedChatGptOperationError("Deep research", "Use Deep research directly in the visible ChatGPT UI, or send an ordinary prodex consult without the Deep research tool.");
|
|
3201
|
+
}
|
|
3175
3202
|
const port = resolveCdpPort(options.port);
|
|
3176
3203
|
const timeoutMs = options.timeoutMs ?? 90_000;
|
|
3204
|
+
const requestId = randomBytes(16).toString("hex");
|
|
3205
|
+
const sentPrompt = `${options.prompt}\n\n[prodex-request:${requestId}]`;
|
|
3206
|
+
const requestMatches = (state) => chatGptRequestMatchesUserTurn(state.lastUserText ?? "", sentPrompt, requestId);
|
|
3207
|
+
const requestMismatch = (thread) => new ChatGptBrowserBlockerError({
|
|
3208
|
+
code: "request_mismatch",
|
|
3209
|
+
message: "The visible user turn does not match this prodex request. No answer was returned because it may belong to another session.",
|
|
3210
|
+
retryable: false,
|
|
3211
|
+
next_step: `Do not resend automatically. Inspect the original chat for [prodex-request:${requestId}] before recovering its answer.`,
|
|
3212
|
+
...(thread ? { thread } : {})
|
|
3213
|
+
});
|
|
3177
3214
|
/** Dialogs answered on the reload connection, which comes and goes before the send's own, so the receipt still says so. */
|
|
3178
3215
|
const earlyDialogsAnswered = [];
|
|
3179
3216
|
const sendStartedAt = Date.now();
|
|
@@ -3209,27 +3246,6 @@ export async function sendChatGptPrompt(options) {
|
|
|
3209
3246
|
assertChatGptPageAvailable();
|
|
3210
3247
|
}
|
|
3211
3248
|
const page = pageResult.page;
|
|
3212
|
-
if (options.newChat && !options.project && !options.projectNew) {
|
|
3213
|
-
// Long accumulated threads eventually break acceptance detection, so
|
|
3214
|
-
// start from a clean chat. Wait for the tab to actually reach the fresh
|
|
3215
|
-
// empty chat (root URL, zero messages) rather than a fixed sleep: a slow
|
|
3216
|
-
// SPA navigation could otherwise leave the old thread rendered, poisoning
|
|
3217
|
-
// the answer-count baseline captured below and causing a false timeout.
|
|
3218
|
-
//
|
|
3219
|
-
// Skipped when a project is requested: the project home the selection step
|
|
3220
|
-
// navigates to IS the fresh composer for "a new chat in this project".
|
|
3221
|
-
// Navigating to the root new chat first leaves the SPA composer bound to
|
|
3222
|
-
// the ROOT conversation target even after entering the project, so the
|
|
3223
|
-
// thread silently lands outside the project (measured live: --new-chat
|
|
3224
|
-
// --project threads appeared in the root chat list, --project-only
|
|
3225
|
-
// threads appeared inside the project).
|
|
3226
|
-
// A temporary chat is reached by url rather than by clicking the control:
|
|
3227
|
-
// the same navigation this already does, one query parameter different, and
|
|
3228
|
-
// nothing to find on a page whose buttons keep moving.
|
|
3229
|
-
const freshUrl = options.temporary ? "https://chatgpt.com/?temporary-chat=true" : "https://chatgpt.com/";
|
|
3230
|
-
await evaluateOnPage(page, `location.assign(${JSON.stringify(freshUrl)})`);
|
|
3231
|
-
await waitForFreshChatGptPage(page, 8_000);
|
|
3232
|
-
}
|
|
3233
3249
|
let status = await readSettledChatGptPageStatus(page);
|
|
3234
3250
|
status = await ensureVisibleChatGptPage(port, page, status);
|
|
3235
3251
|
const blocker = detectChatGptPageBlocker(status);
|
|
@@ -3243,7 +3259,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3243
3259
|
// timeout: consults continue threads by default, so landing on a thread
|
|
3244
3260
|
// whose previous (often timed-out Pro) answer is still streaming is a when,
|
|
3245
3261
|
// not an if - queueing behind it beats failing.
|
|
3246
|
-
let busyBlocker =
|
|
3262
|
+
let busyBlocker = chatGptBusyBlocker(status);
|
|
3247
3263
|
const busyWaitBudgetMs = options.busyWaitMs ?? timeoutMs;
|
|
3248
3264
|
if (busyBlocker && busyWaitBudgetMs > 0) {
|
|
3249
3265
|
// Queue behind the in-flight response instead of failing: shared-tab
|
|
@@ -3257,7 +3273,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3257
3273
|
const midBlocker = detectChatGptPageBlocker(status);
|
|
3258
3274
|
if (midBlocker)
|
|
3259
3275
|
throw new ChatGptBrowserBlockerError(midBlocker);
|
|
3260
|
-
busyBlocker =
|
|
3276
|
+
busyBlocker = chatGptBusyBlocker(status);
|
|
3261
3277
|
if (busyBlocker)
|
|
3262
3278
|
emitProgress("waiting", "tab busy with another response; waiting");
|
|
3263
3279
|
}
|
|
@@ -3267,9 +3283,11 @@ export async function sendChatGptPrompt(options) {
|
|
|
3267
3283
|
// weigh that fresh reading the same way - a generation that started in
|
|
3268
3284
|
// the meantime must still hold the send back.
|
|
3269
3285
|
status = await readSettledChatGptPageStatus(page);
|
|
3270
|
-
busyBlocker =
|
|
3286
|
+
busyBlocker = chatGptBusyBlocker(status);
|
|
3271
3287
|
}
|
|
3272
3288
|
}
|
|
3289
|
+
if (busyBlocker)
|
|
3290
|
+
throw new ChatGptBrowserBlockerError(busyBlocker);
|
|
3273
3291
|
// ChatGPT's error page does not come back on a reload - measured: a project
|
|
3274
3292
|
// home that failed reloaded straight back into it - and the tab then stays
|
|
3275
3293
|
// there for every later send, including the retry its own blocker asks for.
|
|
@@ -3294,7 +3312,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3294
3312
|
// The busy verdict above was decided about the page we just left, and it
|
|
3295
3313
|
// is handed to the readiness assert as already decided. Carrying it over
|
|
3296
3314
|
// would let a root page that is generating an answer be typed into.
|
|
3297
|
-
busyBlocker =
|
|
3315
|
+
busyBlocker = chatGptBusyBlocker(fresh);
|
|
3298
3316
|
status = fresh;
|
|
3299
3317
|
}
|
|
3300
3318
|
catch (error) {
|
|
@@ -3332,7 +3350,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3332
3350
|
const blockerAfterReload = detectChatGptPageBlocker(fresh);
|
|
3333
3351
|
if (blockerAfterReload)
|
|
3334
3352
|
throw new ChatGptBrowserBlockerError(blockerAfterReload);
|
|
3335
|
-
busyBlocker =
|
|
3353
|
+
busyBlocker = chatGptBusyBlocker(fresh);
|
|
3336
3354
|
status = fresh;
|
|
3337
3355
|
}
|
|
3338
3356
|
catch (error) {
|
|
@@ -3359,6 +3377,26 @@ export async function sendChatGptPrompt(options) {
|
|
|
3359
3377
|
throw new ChatGptBrowserBlockerError(chatGptResponseChoiceBlocker(true));
|
|
3360
3378
|
}
|
|
3361
3379
|
assertChatGptIdleAndReadyForPrompt(status, busyBlocker, true);
|
|
3380
|
+
if (options.newChat && !options.project && !options.projectNew) {
|
|
3381
|
+
// Never navigate away from a prior in-flight request, including one whose
|
|
3382
|
+
// caller timed out and released the process lock. Project homes supply
|
|
3383
|
+
// their own fresh composer and must not pass through the root first.
|
|
3384
|
+
const freshUrl = options.temporary ? "https://chatgpt.com/?temporary-chat=true" : "https://chatgpt.com/";
|
|
3385
|
+
await navigateIdleChatGptPage(page, freshUrl);
|
|
3386
|
+
if (!await waitForFreshChatGptPage(page, 8_000)) {
|
|
3387
|
+
throw new ChatGptBrowserBlockerError({
|
|
3388
|
+
code: "fresh_chat_not_ready",
|
|
3389
|
+
message: "The new-chat page did not become an empty conversation. Nothing was sent.",
|
|
3390
|
+
retryable: true,
|
|
3391
|
+
next_step: "Wait for the dedicated browser to finish loading a new chat, then retry."
|
|
3392
|
+
});
|
|
3393
|
+
}
|
|
3394
|
+
status = await readSettledChatGptPageStatus(page);
|
|
3395
|
+
const freshBlocker = detectChatGptPageBlocker(status);
|
|
3396
|
+
if (freshBlocker)
|
|
3397
|
+
throw new ChatGptBrowserBlockerError(freshBlocker);
|
|
3398
|
+
assertChatGptIdleAndReadyForPrompt(status);
|
|
3399
|
+
}
|
|
3362
3400
|
// Only a PINNED target has to be under the tab already; a resolved thread is
|
|
3363
3401
|
// navigated to below, and asserting the match here would refuse the send for
|
|
3364
3402
|
// the tab merely being somewhere else - which is the whole reason a
|
|
@@ -3388,7 +3426,6 @@ export async function sendChatGptPrompt(options) {
|
|
|
3388
3426
|
let beforeSubmit;
|
|
3389
3427
|
let boundProjectId;
|
|
3390
3428
|
let submitButtonFound = false;
|
|
3391
|
-
let wantsDeepResearch = false;
|
|
3392
3429
|
const sendWarnings = [];
|
|
3393
3430
|
// Anything the page put in front of prodex was answered on the caller's
|
|
3394
3431
|
// behalf. The note is read at return time - there are several return paths -
|
|
@@ -3465,6 +3502,22 @@ export async function sendChatGptPrompt(options) {
|
|
|
3465
3502
|
// so assistant-message counts compare within the thread we actually send
|
|
3466
3503
|
// into; a --project/--project-new hop lands on a page with its own counts.
|
|
3467
3504
|
beforeSubmit = await evaluateOnPage(page, answerExpression());
|
|
3505
|
+
const beforeTyping = await readSettledChatGptPageStatus(page);
|
|
3506
|
+
const beforeTypingBlocker = detectChatGptPageBlocker(beforeTyping);
|
|
3507
|
+
if (beforeTypingBlocker)
|
|
3508
|
+
throw new ChatGptBrowserBlockerError(beforeTypingBlocker);
|
|
3509
|
+
assertChatGptIdleAndReadyForPrompt(beforeTyping);
|
|
3510
|
+
if (normalizedTargetUrl)
|
|
3511
|
+
assertChatGptTargetUrlMatches(beforeSubmit.url, normalizedTargetUrl);
|
|
3512
|
+
if ((options.newChat || options.project || options.projectNew) &&
|
|
3513
|
+
(beforeSubmit.userMessageCount !== 0 || beforeSubmit.assistantMessageCount !== 0 || conversationIdFromThreadUrl(beforeSubmit.url))) {
|
|
3514
|
+
throw new ChatGptBrowserBlockerError({
|
|
3515
|
+
code: "fresh_chat_not_ready",
|
|
3516
|
+
message: "The fresh-chat destination changed to an existing conversation before typing. Nothing was sent.",
|
|
3517
|
+
retryable: true,
|
|
3518
|
+
next_step: "Wait for other browser activity to finish before starting a new consult."
|
|
3519
|
+
});
|
|
3520
|
+
}
|
|
3468
3521
|
dbgSend(`baseline url=${beforeSubmit.url} user=${beforeSubmit.userMessageCount} assistant=${beforeSubmit.assistantMessageCount}`);
|
|
3469
3522
|
// Read the binding once more, on the composer this send is about to type
|
|
3470
3523
|
// into. Everything between selectProject and here - the model picker, the
|
|
@@ -3488,11 +3541,9 @@ export async function sendChatGptPrompt(options) {
|
|
|
3488
3541
|
const uploaded = await attachFilesToComposer(cdp, options.attachments);
|
|
3489
3542
|
emitProgress("selecting", `attached ${uploaded.attached.join(", ")}`);
|
|
3490
3543
|
}
|
|
3491
|
-
const toolLabels = (options.tools ?? []).map(resolveComposerToolLabel);
|
|
3492
|
-
wantsDeepResearch = toolLabels.includes(DEEP_RESEARCH_TOOL_LABEL);
|
|
3493
3544
|
if (toolLabels.length > 0)
|
|
3494
3545
|
emitProgress("selecting", `tools=${toolLabels.join(", ")}`);
|
|
3495
|
-
await insertComposerTextViaCdp(cdp,
|
|
3546
|
+
await insertComposerTextViaCdp(cdp, sentPrompt, page, toolLabels);
|
|
3496
3547
|
// The send button renders asynchronously after the prompt lands. Poll for it
|
|
3497
3548
|
// BEFORE submitting so (a) submitButtonFound reflects whether the control
|
|
3498
3549
|
// actually EXISTS - otherwise a successful Enter-key submit skips the fallback
|
|
@@ -3517,7 +3568,10 @@ export async function sendChatGptPrompt(options) {
|
|
|
3517
3568
|
// inserts a newline) fall back to clicking the send button, re-reading
|
|
3518
3569
|
// FRESH coordinates each attempt. Safe against double-submit: once the
|
|
3519
3570
|
// prompt posts the composer clears and no send button is found.
|
|
3520
|
-
const promptPostedExpression = `
|
|
3571
|
+
const promptPostedExpression = `(() => {
|
|
3572
|
+
const last = [...document.querySelectorAll('[data-message-author-role="user"]')].at(-1);
|
|
3573
|
+
return Boolean(last && (last.innerText || "").includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
|
|
3574
|
+
})()`;
|
|
3521
3575
|
await cdp.send("Input.dispatchKeyEvent", enterKeyEvent("keyDown"));
|
|
3522
3576
|
await cdp.send("Input.dispatchKeyEvent", enterKeyEvent("keyUp"));
|
|
3523
3577
|
let promptPosted = await waitForExpressionTrue(cdp, promptPostedExpression, 1_500);
|
|
@@ -3533,26 +3587,6 @@ export async function sendChatGptPrompt(options) {
|
|
|
3533
3587
|
}
|
|
3534
3588
|
}
|
|
3535
3589
|
dbgSend(`submit posted=${promptPosted} submitButtonFound=${submitButtonFound}`);
|
|
3536
|
-
// Deep research does not begin when the prompt posts: it shows a start
|
|
3537
|
-
// control with a countdown ring. Press it instead of trusting the timer -
|
|
3538
|
-
// a run left waiting sat with zero assistant messages for 30+ minutes.
|
|
3539
|
-
if (wantsDeepResearch) {
|
|
3540
|
-
const startDeadline = Date.now() + 60_000;
|
|
3541
|
-
let pressed = false;
|
|
3542
|
-
while (Date.now() < startDeadline) {
|
|
3543
|
-
const start = await cdp.evaluate(deepResearchStartButtonRectExpression());
|
|
3544
|
-
if (start.ok && start.x !== undefined && start.y !== undefined) {
|
|
3545
|
-
await dispatchMouseClickAt(cdp, start.x, start.y);
|
|
3546
|
-
pressed = true;
|
|
3547
|
-
emitProgress("selecting", "deep research started");
|
|
3548
|
-
break;
|
|
3549
|
-
}
|
|
3550
|
-
await sleep(1_000);
|
|
3551
|
-
}
|
|
3552
|
-
if (!pressed) {
|
|
3553
|
-
sendWarnings.push("deep_research_start_not_found: no start control appeared for the deep research run. If ChatGPT asked a clarifying question instead, answer it with a follow-up consult in the same thread.");
|
|
3554
|
-
}
|
|
3555
|
-
}
|
|
3556
3590
|
}
|
|
3557
3591
|
catch (error) {
|
|
3558
3592
|
// Capture before the connection closes: this is the moment the page still
|
|
@@ -3569,27 +3603,8 @@ export async function sendChatGptPrompt(options) {
|
|
|
3569
3603
|
const acceptDeadline = computePromptAcceptanceDeadline(timeoutMs, started);
|
|
3570
3604
|
let accepted = false;
|
|
3571
3605
|
let finalState;
|
|
3572
|
-
// Seeded either from the url once ChatGPT rewrites it, or - when the page
|
|
3573
|
-
// never showed the prompt post - from the transcript that proves it did.
|
|
3574
|
-
let transcriptConversationId;
|
|
3575
|
-
// Ask the transcript early rather than only at the deadline. Acceptance runs
|
|
3576
|
-
// on the full send budget, so a page that stops reporting the prompt posting
|
|
3577
|
-
// used to burn all twenty minutes before saying anything - while the prompt
|
|
3578
|
-
// sat in a conversation the whole time.
|
|
3579
|
-
let nextTranscriptProbeAt = started + ACCEPTANCE_TRANSCRIPT_PROBE_AFTER_MS;
|
|
3580
3606
|
while (Date.now() < acceptDeadline) {
|
|
3581
3607
|
await sleep(500);
|
|
3582
|
-
if (Date.now() >= nextTranscriptProbeAt) {
|
|
3583
|
-
nextTranscriptProbeAt = Date.now() + ACCEPTANCE_TRANSCRIPT_PROBE_EVERY_MS;
|
|
3584
|
-
const landed = await findLandedConversation(page, options.prompt);
|
|
3585
|
-
if (landed) {
|
|
3586
|
-
transcriptConversationId = landed;
|
|
3587
|
-
accepted = true;
|
|
3588
|
-
sendWarnings.push("prompt_acceptance_unreadable: the page never showed the prompt posting, but the transcript has it - continuing on the conversation the transcript names.");
|
|
3589
|
-
dbgSend(`acceptance recovered from transcript conversation=${landed}`);
|
|
3590
|
-
break;
|
|
3591
|
-
}
|
|
3592
|
-
}
|
|
3593
3608
|
try {
|
|
3594
3609
|
finalState = await evaluateOnPage(page, answerExpression());
|
|
3595
3610
|
}
|
|
@@ -3602,10 +3617,14 @@ export async function sendChatGptPrompt(options) {
|
|
|
3602
3617
|
if (runtimeBlocker)
|
|
3603
3618
|
throw new ChatGptBrowserBlockerError(runtimeBlocker);
|
|
3604
3619
|
dbgSend(`accept-poll url=${finalState.url} user=${finalState.userMessageCount} assistant=${finalState.assistantMessageCount} generating=${finalState.generating}`);
|
|
3605
|
-
if (
|
|
3620
|
+
if (requestMatches(finalState)) {
|
|
3621
|
+
if (normalizedTargetUrl)
|
|
3622
|
+
assertChatGptTargetUrlMatches(finalState.url, normalizedTargetUrl);
|
|
3606
3623
|
accepted = true;
|
|
3607
3624
|
break;
|
|
3608
3625
|
}
|
|
3626
|
+
if (hasChatGptPromptAcceptance(beforeSubmit, finalState))
|
|
3627
|
+
throw requestMismatch(normalizedTargetUrl);
|
|
3609
3628
|
emitProgress("waiting", "prompt posting");
|
|
3610
3629
|
}
|
|
3611
3630
|
if (!accepted) {
|
|
@@ -3639,145 +3658,50 @@ export async function sendChatGptPrompt(options) {
|
|
|
3639
3658
|
catch {
|
|
3640
3659
|
// best effort: fall back to submit-button signal only
|
|
3641
3660
|
}
|
|
3642
|
-
|
|
3643
|
-
// acceptance off the page means a changed DOM reports "never posted" for a
|
|
3644
|
-
// prompt that posted fine, and the caller's retry asks ChatGPT the same
|
|
3645
|
-
// question twice. The transcript is the ground truth.
|
|
3646
|
-
const landed = await findLandedConversation(page, options.prompt);
|
|
3647
|
-
if (landed) {
|
|
3648
|
-
transcriptConversationId = landed;
|
|
3649
|
-
accepted = true;
|
|
3650
|
-
sendWarnings.push("prompt_acceptance_unreadable: the page never showed the prompt posting, but the transcript has it - continuing on the conversation the transcript names.");
|
|
3651
|
-
dbgSend(`acceptance recovered from transcript conversation=${landed}`);
|
|
3652
|
-
}
|
|
3653
|
-
if (!accepted)
|
|
3654
|
-
throw acceptanceTimeoutError({ timeoutMs, composerStillHasText, submitButtonFound });
|
|
3661
|
+
throw acceptanceTimeoutError({ timeoutMs, composerStillHasText, submitButtonFound });
|
|
3655
3662
|
}
|
|
3656
3663
|
// Pin the conversation the prompt actually landed in. The browser is shared
|
|
3657
3664
|
// (other agents, the user, tooling), and a tab that moves mid-wait made
|
|
3658
3665
|
// prodex read a DIFFERENT conversation and save it as this consult's answer -
|
|
3659
3666
|
// silently, with a receipt (caught live). Nothing about that is recoverable
|
|
3660
3667
|
// after the fact, so the wait either stays on this thread or fails loudly.
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
// still matched the pin taken before ChatGPT rewrote it, and the DOM reader
|
|
3666
|
-
// sat on zero assistant messages until the budget ran out.
|
|
3667
|
-
// Acceptance may already have adopted one from the transcript; otherwise take
|
|
3668
|
-
// it from the url ChatGPT rewrote to.
|
|
3669
|
-
transcriptConversationId ??= pinnedThreadUrl ? conversationIdFromThreadUrl(pinnedThreadUrl) : undefined;
|
|
3670
|
-
const transcriptResult = (transcript) => {
|
|
3671
|
-
emitProgress("answered", `transcript (${transcript.answer.length} chars)`);
|
|
3672
|
-
return {
|
|
3673
|
-
url: finalState?.url ?? pinnedThreadUrl ?? "",
|
|
3674
|
-
title: finalState?.title ?? "",
|
|
3675
|
-
answer: transcript.answer,
|
|
3676
|
-
modelHints: finalState?.modelHints ?? [],
|
|
3677
|
-
...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
|
|
3678
|
-
...(boundProjectId ? { boundProjectId } : {}),
|
|
3679
|
-
warnings: withDialogNote([...sendWarnings, selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...((transcript.modelSlug || finalState?.modelSlug) ? { modelSlug: (transcript.modelSlug || finalState?.modelSlug) } : {}) })]).filter((warning) => Boolean(warning))
|
|
3680
|
-
};
|
|
3681
|
-
};
|
|
3682
|
-
// Deep research never reaches the DOM answer wait below: the report is
|
|
3683
|
-
// rendered by a widget app in an iframe, so the main frame stays empty even
|
|
3684
|
-
// when the run has finished. Read the run out of the conversation transcript
|
|
3685
|
-
// instead, which is where the widget keeps its state.
|
|
3686
|
-
if (wantsDeepResearch) {
|
|
3687
|
-
const conversationId = pinnedThreadUrl ? conversationIdFromThreadUrl(pinnedThreadUrl) : undefined;
|
|
3688
|
-
if (!conversationId)
|
|
3689
|
-
throw new ChatGptBrowserBlockerError(deepResearchUnreadableBlocker(pinnedThreadUrl ?? "https://chatgpt.com/"));
|
|
3690
|
-
let lastState;
|
|
3691
|
-
let consecutiveResearchReadFailures = 0;
|
|
3692
|
-
while (Date.now() - started < timeoutMs) {
|
|
3693
|
-
try {
|
|
3694
|
-
lastState = await evaluateOnPage(page, deepResearchReportExpression(conversationId), { timeoutMs: 60_000 });
|
|
3695
|
-
consecutiveResearchReadFailures = 0;
|
|
3696
|
-
}
|
|
3697
|
-
catch {
|
|
3698
|
-
// A few failures in a row mean the browser is gone, not busy. Waiting
|
|
3699
|
-
// out a 30-minute budget on a dead browser helps nobody: the research
|
|
3700
|
-
// finishes on ChatGPT's side anyway, so hand back the thread and let
|
|
3701
|
-
// recover collect the report.
|
|
3702
|
-
consecutiveResearchReadFailures += 1;
|
|
3703
|
-
if (consecutiveResearchReadFailures >= CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP) {
|
|
3704
|
-
throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(pinnedThreadUrl));
|
|
3705
|
-
}
|
|
3706
|
-
await sleep(5_000);
|
|
3707
|
-
continue;
|
|
3708
|
-
}
|
|
3709
|
-
if (lastState.ok && lastState.report.trim().length > 0 && transcriptMatchesSentPrompt(lastState.userText, options.prompt)) {
|
|
3710
|
-
const report = resolveTranscriptCitations(lastState.report, lastState.references).trim();
|
|
3711
|
-
emitProgress("answered", `deep research report (${report.length} chars)`);
|
|
3712
|
-
return {
|
|
3713
|
-
url: pinnedThreadUrl ?? "",
|
|
3714
|
-
title: finalState?.title ?? "",
|
|
3715
|
-
answer: report,
|
|
3716
|
-
modelHints: finalState?.modelHints ?? [],
|
|
3717
|
-
...(finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
|
|
3718
|
-
warnings: withDialogNote(sendWarnings)
|
|
3719
|
-
};
|
|
3720
|
-
}
|
|
3721
|
-
emitProgress("waiting", `deep research ${lastState.status || lastState.reason} (${formatDurationMs(Date.now() - started)})`);
|
|
3722
|
-
// Each poll pulls the whole transcript, which a research run grows into
|
|
3723
|
-
// the hundreds of KB - so poll on a calm cadence, not a tight one.
|
|
3724
|
-
await sleep(15_000);
|
|
3725
|
-
}
|
|
3726
|
-
throw new ChatGptBrowserBlockerError({
|
|
3727
|
-
code: "deep_research_still_running",
|
|
3728
|
-
message: `The deep research run was still ${lastState?.status || "in progress"} after ${formatDurationMs(timeoutMs)}.`,
|
|
3729
|
-
retryable: true,
|
|
3730
|
-
next_step: `Fetch the report once it finishes with \`prodex pro browser recover --target-url ${pinnedThreadUrl}\`, or read it in your browser: ${pinnedThreadUrl}`,
|
|
3731
|
-
...(pinnedThreadUrl ? { thread: pinnedThreadUrl } : {})
|
|
3732
|
-
});
|
|
3733
|
-
}
|
|
3734
|
-
let recoveredNavigations = 0;
|
|
3735
|
-
let lastTranscriptClassification;
|
|
3668
|
+
let pinnedConversationId = conversationIdFromThreadUrl(normalizedTargetUrl ?? finalState?.url ?? "");
|
|
3669
|
+
let pinnedThreadUrl = pinnedConversationId
|
|
3670
|
+
? canonicalChatGptThreadUrl(pinnedConversationId, normalizedTargetUrl ?? finalState?.url)
|
|
3671
|
+
: undefined;
|
|
3736
3672
|
let consecutiveReadFailures = 0;
|
|
3673
|
+
let answerSettled = false;
|
|
3737
3674
|
const answerIsStable = createChatGptAnswerStabilityTracker();
|
|
3738
3675
|
while (Date.now() - started < timeoutMs) {
|
|
3739
3676
|
await sleep(1000);
|
|
3740
3677
|
try {
|
|
3741
|
-
|
|
3678
|
+
const observedState = await evaluateOnPage(page, answerExpression());
|
|
3742
3679
|
consecutiveReadFailures = 0;
|
|
3743
|
-
//
|
|
3744
|
-
//
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
if (transcriptConversationId && !finalState.generating) {
|
|
3753
|
-
const transcript = await readTranscriptAnswer(page, transcriptConversationId, options.prompt);
|
|
3754
|
-
lastTranscriptClassification = transcript.classification;
|
|
3755
|
-
if (transcript.answer)
|
|
3756
|
-
return transcriptResult(transcript.answer);
|
|
3757
|
-
// A finished turn with no text is an answer of a different shape - an
|
|
3758
|
-
// image, measured - and waiting for words it will never write spends
|
|
3759
|
-
// the whole budget and then calls the result a timeout. The page must
|
|
3760
|
-
// agree it has stopped generating before this counts.
|
|
3761
|
-
if (transcript.classification === "no_text") {
|
|
3762
|
-
return transcriptResult({ answer: CHATGPT_NON_TEXT_ANSWER_NOTE, modelSlug: "" });
|
|
3680
|
+
// Freeze the first conversation identity the accepted page exposes. A
|
|
3681
|
+
// later tab move must never rewrite result metadata to another thread.
|
|
3682
|
+
if (!pinnedConversationId) {
|
|
3683
|
+
if (!requestMatches(observedState))
|
|
3684
|
+
throw requestMismatch();
|
|
3685
|
+
const observedConversationId = conversationIdFromThreadUrl(observedState.url);
|
|
3686
|
+
if (observedConversationId) {
|
|
3687
|
+
pinnedConversationId = observedConversationId;
|
|
3688
|
+
pinnedThreadUrl = canonicalChatGptThreadUrl(observedConversationId, observedState.url);
|
|
3763
3689
|
}
|
|
3764
3690
|
}
|
|
3765
|
-
if (shouldRecoverThreadNavigation({ pinnedThreadUrl, currentUrl:
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
});
|
|
3774
|
-
}
|
|
3775
|
-
recoveredNavigations += 1;
|
|
3776
|
-
sendWarnings.push(`thread_navigated_away_recovered: something moved the tab to another conversation mid-wait; prodex navigated back to ${pinnedThreadUrl}.`);
|
|
3777
|
-
await evaluateOnPage(page, `location.assign(${JSON.stringify(pinnedThreadUrl)})`);
|
|
3778
|
-
await sleep(3_000);
|
|
3779
|
-
continue;
|
|
3691
|
+
if (shouldRecoverThreadNavigation({ pinnedThreadUrl, currentUrl: observedState.url })) {
|
|
3692
|
+
throw new ChatGptBrowserBlockerError({
|
|
3693
|
+
code: "thread_navigated_away",
|
|
3694
|
+
message: "The browser tab was moved to a different ChatGPT conversation while this consult was waiting for its answer.",
|
|
3695
|
+
retryable: false,
|
|
3696
|
+
next_step: `Do not resend automatically. After other sessions finish, inspect the original request [prodex-request:${requestId}] in ${pinnedThreadUrl}.`,
|
|
3697
|
+
thread: pinnedThreadUrl
|
|
3698
|
+
});
|
|
3780
3699
|
}
|
|
3700
|
+
if (!requestMatches(observedState))
|
|
3701
|
+
throw requestMismatch(pinnedThreadUrl);
|
|
3702
|
+
// Only a state from the pinned conversation may become eligible for
|
|
3703
|
+
// completed or partial result salvage after the polling deadline.
|
|
3704
|
+
finalState = observedState;
|
|
3781
3705
|
}
|
|
3782
3706
|
catch (error) {
|
|
3783
3707
|
if (error instanceof ChatGptBrowserBlockerError)
|
|
@@ -3789,7 +3713,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3789
3713
|
// sitting out the whole budget on it only delays the recovery.
|
|
3790
3714
|
consecutiveReadFailures += 1;
|
|
3791
3715
|
if (consecutiveReadFailures >= CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP) {
|
|
3792
|
-
throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(finalState?.url
|
|
3716
|
+
throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(pinnedThreadUrl ?? finalState?.url));
|
|
3793
3717
|
}
|
|
3794
3718
|
continue;
|
|
3795
3719
|
}
|
|
@@ -3804,34 +3728,22 @@ export async function sendChatGptPrompt(options) {
|
|
|
3804
3728
|
// character that can outlive the stop button. The tracker requires extra
|
|
3805
3729
|
// confirmations for caret-suspect tails (see its doc comment).
|
|
3806
3730
|
if (answerIsStable(finalState.answer, finalState.generating)) {
|
|
3807
|
-
|
|
3808
|
-
// Give the transcript that beat: it carries markdown (tables and fenced
|
|
3809
|
-
// code that innerText flattens) and the model that actually answered.
|
|
3810
|
-
if (transcriptConversationId) {
|
|
3811
|
-
const transcript = await readTranscriptAnswer(page, transcriptConversationId, options.prompt);
|
|
3812
|
-
lastTranscriptClassification = transcript.classification;
|
|
3813
|
-
if (transcript.answer)
|
|
3814
|
-
return transcriptResult(transcript.answer);
|
|
3815
|
-
// The transcript can read this conversation and says it is not done:
|
|
3816
|
-
// believe it over a page that merely looks settled. A tool's progress
|
|
3817
|
-
// panel renders exactly like a two-line answer, and that is how a
|
|
3818
|
-
// consult once returned "Searching the web / Answer now" as its result.
|
|
3819
|
-
if (transcript.classification === "pending")
|
|
3820
|
-
continue;
|
|
3821
|
-
}
|
|
3731
|
+
answerSettled = true;
|
|
3822
3732
|
break;
|
|
3823
3733
|
}
|
|
3824
3734
|
}
|
|
3825
3735
|
const completed = finalState;
|
|
3826
|
-
if (completed && hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
|
|
3736
|
+
if (answerSettled && completed && hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
|
|
3827
3737
|
emitProgress("answered");
|
|
3828
3738
|
return {
|
|
3829
|
-
url: completed.url,
|
|
3739
|
+
url: pinnedThreadUrl ?? completed.url,
|
|
3830
3740
|
title: completed.title,
|
|
3831
3741
|
answer: completed.answer.trim(),
|
|
3832
3742
|
modelHints: completed.modelHints,
|
|
3833
3743
|
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
3834
3744
|
...(boundProjectId ? { boundProjectId } : {}),
|
|
3745
|
+
requestId,
|
|
3746
|
+
requestVerified: true,
|
|
3835
3747
|
warnings: withDialogNote([...sendWarnings, selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })]).filter((warning) => Boolean(warning))
|
|
3836
3748
|
};
|
|
3837
3749
|
}
|
|
@@ -3841,16 +3753,18 @@ export async function sendChatGptPrompt(options) {
|
|
|
3841
3753
|
if (completed && hasPartialChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
|
|
3842
3754
|
emitProgress("answered", "partial");
|
|
3843
3755
|
return {
|
|
3844
|
-
url: completed.url,
|
|
3756
|
+
url: pinnedThreadUrl ?? completed.url,
|
|
3845
3757
|
title: completed.title,
|
|
3846
3758
|
answer: completed.answer.trim(),
|
|
3847
3759
|
modelHints: completed.modelHints,
|
|
3848
3760
|
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
3849
3761
|
...(boundProjectId ? { boundProjectId } : {}),
|
|
3762
|
+
requestId,
|
|
3763
|
+
requestVerified: true,
|
|
3850
3764
|
warnings: withDialogNote([
|
|
3851
3765
|
...sendWarnings,
|
|
3852
3766
|
...(selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) }) ? [selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })] : []),
|
|
3853
|
-
`answer_incomplete: ChatGPT
|
|
3767
|
+
`answer_incomplete: ChatGPT's answer did not reach a stable completed state after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms), so the answer below may be truncated. Do not resend the question. Recover the original answer with --target-url ${pinnedThreadUrl ?? completed.url} --request-id ${requestId} once it finishes.`
|
|
3854
3768
|
])
|
|
3855
3769
|
};
|
|
3856
3770
|
}
|
|
@@ -3858,7 +3772,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3858
3772
|
// after prodex gives up, and `pro browser recover --target-url` exists to
|
|
3859
3773
|
// fetch it - but only if the caller knows which thread to point at.
|
|
3860
3774
|
throw Object.assign(new Error(`Timed out after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms) waiting for ChatGPT to respond. ` +
|
|
3861
|
-
"Pro reasoning can run many minutes.
|
|
3775
|
+
"Pro reasoning can run many minutes. Do not resend the question; recover the original answer once it finishes."), { requestId }, pinnedThreadUrl ?? completed?.url ? { thread: pinnedThreadUrl ?? completed?.url } : {});
|
|
3862
3776
|
}
|
|
3863
3777
|
/**
|
|
3864
3778
|
* One line of `pro browser models`.
|
|
@@ -4008,12 +3922,18 @@ export function findLaunchedBrowserProcesses(psOutput, input) {
|
|
|
4008
3922
|
// very tool running this scan can carry it on its command line, and this list
|
|
4009
3923
|
// is what gets SIGTERM. Caught live - the probe matched its own node process.
|
|
4010
3924
|
const isBrowserCommand = (line) => {
|
|
4011
|
-
const command = line.replace(/^\s*\S+\s+\d+\s+/, "");
|
|
4012
|
-
//
|
|
4013
|
-
//
|
|
4014
|
-
//
|
|
4015
|
-
const
|
|
4016
|
-
|
|
3925
|
+
const command = line.replace(/^\s*\S+\s+\d+\s+/, "").trim();
|
|
3926
|
+
// Linux/PATH executables cannot contain spaces, so only the first token is
|
|
3927
|
+
// eligible. This keeps a node/shell argument that names a browser from
|
|
3928
|
+
// becoming a process prodex may terminate.
|
|
3929
|
+
const firstToken = command.split(/\s+/, 1)[0];
|
|
3930
|
+
if (/(^|[/\\])(google[ -]?chrome(?:\.exe)?|chromium(?:-browser)?|chrome(?:\.exe)?|microsoft[ -]edge|msedge\.exe|brave[ -]browser)$/i.test(firstToken)) {
|
|
3931
|
+
return true;
|
|
3932
|
+
}
|
|
3933
|
+
// macOS app executables and helpers have spaces in their absolute path.
|
|
3934
|
+
// Match only anchored, known bundle layouts and require the next token to
|
|
3935
|
+
// be a flag (or end-of-line), never arbitrary argument text.
|
|
3936
|
+
return /^\/Applications\/(?:Google Chrome\.app\/Contents\/MacOS\/Google Chrome|Chromium\.app\/Contents\/MacOS\/Chromium|Microsoft Edge\.app\/Contents\/MacOS\/Microsoft Edge|Brave Browser\.app\/Contents\/MacOS\/Brave Browser|(?:Google Chrome|Chromium|Microsoft Edge|Brave Browser)\.app\/Contents\/Frameworks\/.*?\/Helpers\/(?:Google Chrome|Chromium|Microsoft Edge|Brave Browser) Helper(?: \([^)]*\))?)(?=\s--|$)/i.test(command);
|
|
4017
3937
|
};
|
|
4018
3938
|
const lines = psOutput.split(/\r?\n/).filter((line) => !/\bgrep\b/.test(line) && isBrowserCommand(line));
|
|
4019
3939
|
// Exactly this port: a plain substring test let port 9 match 9333.
|
|
@@ -4134,49 +4054,9 @@ export function wedgedBrowserBlocker(pids, port) {
|
|
|
4134
4054
|
next_step: "Clear it with `prodex pro browser reset --confirm` (it previews first), then run `prodex pro browser login`."
|
|
4135
4055
|
};
|
|
4136
4056
|
}
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
try {
|
|
4141
|
-
const session = await fetch("/api/auth/session", { credentials: "include" });
|
|
4142
|
-
if (!session.ok) return { ok: false, reason: "session_http_" + session.status };
|
|
4143
|
-
const parsed = await session.json();
|
|
4144
|
-
token = (parsed && parsed.accessToken) || "";
|
|
4145
|
-
} catch (error) {
|
|
4146
|
-
return { ok: false, reason: "session_error" };
|
|
4147
|
-
}
|
|
4148
|
-
try {
|
|
4149
|
-
const response = await fetch("/backend-api/conversation/" + ${JSON.stringify(conversationId)}, {
|
|
4150
|
-
method: "PATCH",
|
|
4151
|
-
credentials: "include",
|
|
4152
|
-
headers: token ? { Authorization: "Bearer " + token, "Content-Type": "application/json" } : { "Content-Type": "application/json" },
|
|
4153
|
-
body: JSON.stringify({ is_visible: false })
|
|
4154
|
-
});
|
|
4155
|
-
const body = await response.text();
|
|
4156
|
-
if (!response.ok) return { ok: false, reason: "delete_http_" + response.status + " " + body.slice(0, 120) };
|
|
4157
|
-
return { ok: true, reason: "" };
|
|
4158
|
-
} catch (error) {
|
|
4159
|
-
return { ok: false, reason: "delete_error" };
|
|
4160
|
-
}
|
|
4161
|
-
})()`;
|
|
4162
|
-
}
|
|
4163
|
-
/** Remove one conversation. Callers must have confirmed intent before calling. */
|
|
4164
|
-
export async function deleteChatGptConversation(input) {
|
|
4165
|
-
const port = resolveCdpPort(input.port);
|
|
4166
|
-
const page = await findChatGptPage(port, input.timeoutMs ?? 3_000);
|
|
4167
|
-
if (!page.ok || !page.page) {
|
|
4168
|
-
throw new ChatGptBrowserBlockerError(page.blocker ?? {
|
|
4169
|
-
code: "browser_unreachable",
|
|
4170
|
-
message: `No Chrome DevTools endpoint is reachable on 127.0.0.1:${port}.`,
|
|
4171
|
-
retryable: true,
|
|
4172
|
-
next_step: "Run `prodex pro browser login` to reopen the dedicated window, then retry."
|
|
4173
|
-
});
|
|
4174
|
-
}
|
|
4175
|
-
const result = await evaluateOnPage(page.page, deleteConversationExpression(input.conversationId), {
|
|
4176
|
-
timeoutMs: 30_000
|
|
4177
|
-
});
|
|
4178
|
-
if (!result?.ok)
|
|
4179
|
-
throw new Error(`ChatGPT refused to delete the conversation: ${result?.reason ?? "unknown reason"}`);
|
|
4057
|
+
/** Conversation deletion has no bounded visible-DOM implementation. */
|
|
4058
|
+
export async function deleteChatGptConversation(_input) {
|
|
4059
|
+
throw unsupportedChatGptOperationError("Conversation deletion", "Delete the conversation from its menu in the visible ChatGPT UI.");
|
|
4180
4060
|
}
|
|
4181
4061
|
export function resolveProjectToDelete(projects, request) {
|
|
4182
4062
|
if (request.id) {
|
|
@@ -4199,78 +4079,33 @@ export function resolveProjectToDelete(projects, request) {
|
|
|
4199
4079
|
}
|
|
4200
4080
|
return { ok: true, id: matches[0].id, name: matches[0].name };
|
|
4201
4081
|
}
|
|
4202
|
-
/** Delete one project by id. The caller is responsible for confirming intent. */
|
|
4203
|
-
export function deleteProjectExpression(projectId) {
|
|
4204
|
-
return `(async () => {
|
|
4205
|
-
let token = "";
|
|
4206
|
-
try {
|
|
4207
|
-
const session = await fetch("/api/auth/session", { credentials: "include" });
|
|
4208
|
-
if (!session.ok) return { ok: false, reason: "session_http_" + session.status };
|
|
4209
|
-
const parsed = await session.json();
|
|
4210
|
-
token = (parsed && parsed.accessToken) || "";
|
|
4211
|
-
} catch (error) {
|
|
4212
|
-
return { ok: false, reason: "session_error" };
|
|
4213
|
-
}
|
|
4214
|
-
try {
|
|
4215
|
-
const response = await fetch("/backend-api/gizmos/" + ${JSON.stringify(projectId)}, {
|
|
4216
|
-
method: "DELETE",
|
|
4217
|
-
credentials: "include",
|
|
4218
|
-
headers: token ? { Authorization: "Bearer " + token, "Content-Type": "application/json" } : { "Content-Type": "application/json" }
|
|
4219
|
-
});
|
|
4220
|
-
const body = await response.text();
|
|
4221
|
-
if (!response.ok) return { ok: false, reason: "delete_http_" + response.status + " " + body.slice(0, 120) };
|
|
4222
|
-
return { ok: true, reason: "" };
|
|
4223
|
-
} catch (error) {
|
|
4224
|
-
return { ok: false, reason: "delete_error" };
|
|
4225
|
-
}
|
|
4226
|
-
})()`;
|
|
4227
|
-
}
|
|
4228
4082
|
export async function listChatGptProjectsWithIds(input = {}) {
|
|
4229
|
-
|
|
4230
|
-
const page = await findChatGptPage(port, input.timeoutMs ?? 3_000);
|
|
4231
|
-
if (!page.ok || !page.page)
|
|
4232
|
-
return [];
|
|
4233
|
-
const { projectsWithIdsExpression } = await import("./tui.js");
|
|
4234
|
-
try {
|
|
4235
|
-
return (await evaluateOnPage(page.page, projectsWithIdsExpression(), { timeoutMs: 30_000 })) ?? [];
|
|
4236
|
-
}
|
|
4237
|
-
catch {
|
|
4238
|
-
return [];
|
|
4239
|
-
}
|
|
4083
|
+
return listVisibleChatGptNavigation(input, projectsWithIdsExpression());
|
|
4240
4084
|
}
|
|
4241
|
-
/**
|
|
4242
|
-
export async function deleteChatGptProject(
|
|
4243
|
-
|
|
4244
|
-
const page = await findChatGptPage(port, input.timeoutMs ?? 3_000);
|
|
4245
|
-
if (!page.ok || !page.page) {
|
|
4246
|
-
throw new ChatGptBrowserBlockerError(page.blocker ?? {
|
|
4247
|
-
code: "browser_unreachable",
|
|
4248
|
-
message: `No Chrome DevTools endpoint is reachable on 127.0.0.1:${port}.`,
|
|
4249
|
-
retryable: true,
|
|
4250
|
-
next_step: "Run `prodex pro browser login` to reopen the dedicated window, then retry."
|
|
4251
|
-
});
|
|
4252
|
-
}
|
|
4253
|
-
const result = await evaluateOnPage(page.page, deleteProjectExpression(input.projectId), {
|
|
4254
|
-
timeoutMs: 30_000
|
|
4255
|
-
});
|
|
4256
|
-
if (!result?.ok)
|
|
4257
|
-
throw new Error(`ChatGPT refused to delete the project: ${result?.reason ?? "unknown reason"}`);
|
|
4085
|
+
/** Project deletion has no bounded visible-DOM implementation. */
|
|
4086
|
+
export async function deleteChatGptProject(_input) {
|
|
4087
|
+
throw unsupportedChatGptOperationError("Project deletion", "Delete the project from its menu in the visible ChatGPT UI.");
|
|
4258
4088
|
}
|
|
4259
4089
|
export async function listRecentChatGptConversations(input = {}) {
|
|
4090
|
+
return listVisibleChatGptNavigation(input, recentConversationTitlesExpression(input.limit));
|
|
4091
|
+
}
|
|
4092
|
+
async function listVisibleChatGptNavigation(input, expression) {
|
|
4260
4093
|
const port = resolveCdpPort(input.port);
|
|
4261
|
-
const
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
}
|
|
4270
|
-
catch {
|
|
4271
|
-
// Nothing to continue from is a normal answer here, not a failure.
|
|
4272
|
-
return [];
|
|
4094
|
+
const timeoutMs = input.timeoutMs ?? 15_000;
|
|
4095
|
+
const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), undefined);
|
|
4096
|
+
if (!pageResult.ok)
|
|
4097
|
+
throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
|
|
4098
|
+
if (!pageResult.page) {
|
|
4099
|
+
if (pageResult.blocker)
|
|
4100
|
+
throw new ChatGptBrowserBlockerError(pageResult.blocker);
|
|
4101
|
+
assertChatGptPageAvailable();
|
|
4273
4102
|
}
|
|
4103
|
+
const page = pageResult.page;
|
|
4104
|
+
const status = await evaluateOnPage(page, statusExpression(), { timeoutMs });
|
|
4105
|
+
const blocker = chatGptVisibilityBlocker(status.visibilityState, status.url) ?? detectChatGptPageBlocker(status);
|
|
4106
|
+
if (blocker)
|
|
4107
|
+
throw new ChatGptBrowserBlockerError(blocker);
|
|
4108
|
+
return evaluateOnPage(page, expression, { timeoutMs });
|
|
4274
4109
|
}
|
|
4275
4110
|
export async function listChatGptSidebarProjects(input = {}) {
|
|
4276
4111
|
const port = resolveCdpPort(input.port);
|
|
@@ -5327,10 +5162,11 @@ export function browserLostMidWaitBlocker(threadUrl) {
|
|
|
5327
5162
|
return {
|
|
5328
5163
|
code: "browser_unreachable",
|
|
5329
5164
|
message: `The dedicated ChatGPT browser stopped responding while this consult was waiting for its answer.${where}`,
|
|
5330
|
-
|
|
5165
|
+
// Retrying the send would duplicate a prompt that has already posted.
|
|
5166
|
+
retryable: false,
|
|
5331
5167
|
next_step: threadUrl
|
|
5332
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}).`
|
|
5333
|
-
: "Run `prodex pro browser login` to reopen the browser, then
|
|
5169
|
+
: "Run `prodex pro browser login` to reopen the browser, then inspect the original chat before asking again. The prompt was already submitted; prodex did not capture its thread URL.",
|
|
5334
5170
|
...(threadUrl ? { thread: threadUrl } : {})
|
|
5335
5171
|
};
|
|
5336
5172
|
}
|
|
@@ -5343,70 +5179,6 @@ export function deepResearchUnreadableBlocker(threadUrl) {
|
|
|
5343
5179
|
thread: threadUrl
|
|
5344
5180
|
};
|
|
5345
5181
|
}
|
|
5346
|
-
/**
|
|
5347
|
-
* The most recently updated conversations, each with the prompt it opens with.
|
|
5348
|
-
*
|
|
5349
|
-
* Acceptance is otherwise read off the page: if the DOM changes shape, a prompt
|
|
5350
|
-
* that DID post looks like one that never left, the send fails, and the
|
|
5351
|
-
* caller's retry asks ChatGPT the same question twice. The transcript settles
|
|
5352
|
-
* it - the conversation either holds our prompt or it does not.
|
|
5353
|
-
*
|
|
5354
|
-
* Only the head of each prompt is returned: a research transcript runs to
|
|
5355
|
-
* hundreds of KB and none of that is needed to recognise it.
|
|
5356
|
-
*/
|
|
5357
|
-
export function recentConversationsExpression(limit = 4) {
|
|
5358
|
-
return `(async () => {
|
|
5359
|
-
const out = [];
|
|
5360
|
-
let token = "";
|
|
5361
|
-
try {
|
|
5362
|
-
const session = await fetch("/api/auth/session", { credentials: "include" });
|
|
5363
|
-
if (!session.ok) return out;
|
|
5364
|
-
const parsed = await session.json();
|
|
5365
|
-
token = (parsed && parsed.accessToken) || "";
|
|
5366
|
-
} catch (error) {
|
|
5367
|
-
return out;
|
|
5368
|
-
}
|
|
5369
|
-
const headers = token ? { Authorization: "Bearer " + token } : {};
|
|
5370
|
-
let items = [];
|
|
5371
|
-
try {
|
|
5372
|
-
const response = await fetch("/backend-api/conversations?offset=0&limit=${limit}&order=updated", {
|
|
5373
|
-
credentials: "include",
|
|
5374
|
-
headers
|
|
5375
|
-
});
|
|
5376
|
-
if (!response.ok) return out;
|
|
5377
|
-
const listed = await response.json();
|
|
5378
|
-
items = (listed && listed.items) || [];
|
|
5379
|
-
} catch (error) {
|
|
5380
|
-
return out;
|
|
5381
|
-
}
|
|
5382
|
-
for (const item of items) {
|
|
5383
|
-
if (!item || !item.id) continue;
|
|
5384
|
-
try {
|
|
5385
|
-
const response = await fetch("/backend-api/conversation/" + item.id, { credentials: "include", headers });
|
|
5386
|
-
if (!response.ok) continue;
|
|
5387
|
-
const conversation = await response.json();
|
|
5388
|
-
const mapping = (conversation && conversation.mapping) || {};
|
|
5389
|
-
const chain = [];
|
|
5390
|
-
let nodeId = conversation && conversation.current_node;
|
|
5391
|
-
let guard = 0;
|
|
5392
|
-
while (nodeId && mapping[nodeId] && guard < 2000) {
|
|
5393
|
-
guard += 1;
|
|
5394
|
-
if (mapping[nodeId].message) chain.push(mapping[nodeId].message);
|
|
5395
|
-
nodeId = mapping[nodeId].parent;
|
|
5396
|
-
}
|
|
5397
|
-
const user = chain.find((entry) => entry && entry.author && entry.author.role === "user" && entry.content);
|
|
5398
|
-
const text = user ? (user.content.parts || []).filter((part) => typeof part === "string").join("") : "";
|
|
5399
|
-
// Long enough that two consults sharing an opening can still be told
|
|
5400
|
-
// apart by the rest of the prompt; bounded so a huge --file send does
|
|
5401
|
-
// not drag its whole payload back through the bridge.
|
|
5402
|
-
out.push({ id: item.id, userText: text.slice(0, 4000) });
|
|
5403
|
-
} catch (error) {
|
|
5404
|
-
// A conversation we cannot read is simply not a match.
|
|
5405
|
-
}
|
|
5406
|
-
}
|
|
5407
|
-
return out;
|
|
5408
|
-
})()`;
|
|
5409
|
-
}
|
|
5410
5182
|
/**
|
|
5411
5183
|
* Which of those conversations is the one this send posted into, if any.
|
|
5412
5184
|
*
|
|
@@ -5449,62 +5221,19 @@ export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
|
|
|
5449
5221
|
return false;
|
|
5450
5222
|
return seen.includes(sent);
|
|
5451
5223
|
}
|
|
5452
|
-
export function transcriptAnswerExpression(conversationId) {
|
|
5453
|
-
return `(async () => {
|
|
5454
|
-
const fail = (reason, extra) => Object.assign({ ok: false, reason, status: "", endTurn: false, isComplete: false, text: "", modelSlug: "", references: [], userText: "" }, extra || {});
|
|
5455
|
-
let token = "";
|
|
5456
|
-
try {
|
|
5457
|
-
const session = await fetch("/api/auth/session", { credentials: "include" });
|
|
5458
|
-
if (!session.ok) return fail("session_http_" + session.status);
|
|
5459
|
-
const parsed = await session.json();
|
|
5460
|
-
token = (parsed && parsed.accessToken) || "";
|
|
5461
|
-
} catch (error) {
|
|
5462
|
-
return fail("session_error");
|
|
5463
|
-
}
|
|
5464
|
-
let conversation;
|
|
5465
|
-
try {
|
|
5466
|
-
const response = await fetch("/backend-api/conversation/" + ${JSON.stringify(conversationId)}, {
|
|
5467
|
-
credentials: "include",
|
|
5468
|
-
headers: token ? { Authorization: "Bearer " + token } : {}
|
|
5469
|
-
});
|
|
5470
|
-
if (!response.ok) return fail("conversation_http_" + response.status);
|
|
5471
|
-
conversation = await response.json();
|
|
5472
|
-
} catch (error) {
|
|
5473
|
-
return fail("conversation_error");
|
|
5474
|
-
}
|
|
5475
|
-
const mapping = (conversation && conversation.mapping) || {};
|
|
5476
|
-
const chain = [];
|
|
5477
|
-
let nodeId = conversation && conversation.current_node;
|
|
5478
|
-
let guard = 0;
|
|
5479
|
-
while (nodeId && mapping[nodeId] && guard < 2000) {
|
|
5480
|
-
guard += 1;
|
|
5481
|
-
if (mapping[nodeId].message) chain.push(mapping[nodeId].message);
|
|
5482
|
-
nodeId = mapping[nodeId].parent;
|
|
5483
|
-
}
|
|
5484
|
-
const message = chain.find(
|
|
5485
|
-
(entry) => entry && entry.author && entry.author.role === "assistant" && entry.content && entry.content.content_type === "text"
|
|
5486
|
-
);
|
|
5487
|
-
const userMessage = chain.find((entry) => entry && entry.author && entry.author.role === "user" && entry.content);
|
|
5488
|
-
const userText = userMessage ? (userMessage.content.parts || []).filter((part) => typeof part === "string").join("") : "";
|
|
5489
|
-
if (!message) return fail("no_assistant_message", { userText });
|
|
5490
|
-
const parts = (message.content.parts || []).filter((part) => typeof part === "string");
|
|
5491
|
-
const text = parts.join("");
|
|
5492
|
-
const metadata = message.metadata || {};
|
|
5493
|
-
const state = {
|
|
5494
|
-
status: message.status || "",
|
|
5495
|
-
endTurn: message.end_turn === true,
|
|
5496
|
-
isComplete: metadata.is_complete === true,
|
|
5497
|
-
text,
|
|
5498
|
-
modelSlug: metadata.model_slug || "",
|
|
5499
|
-
references: Array.isArray(metadata.content_references) ? metadata.content_references : [],
|
|
5500
|
-
userText
|
|
5501
|
-
};
|
|
5502
|
-
if (state.status !== "finished_successfully" || !state.endTurn) return fail("answer_not_finished", state);
|
|
5503
|
-
if (!text) return fail("answer_empty", state);
|
|
5504
|
-
return Object.assign({ ok: true, reason: "" }, state);
|
|
5505
|
-
})()`;
|
|
5506
|
-
}
|
|
5507
5224
|
const NORMALIZED_PROMPT_MATCH_CHARS = 120;
|
|
5225
|
+
function normalizeChatGptPromptText(value) {
|
|
5226
|
+
return value.replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1").replace(/\s+/g, " ").trim();
|
|
5227
|
+
}
|
|
5228
|
+
function chatGptRequestMarkerMatches(userText, requestId) {
|
|
5229
|
+
const markers = [...normalizeChatGptPromptText(userText).matchAll(/\[prodex-request:([a-f0-9]{32})\]/g)];
|
|
5230
|
+
return markers.at(-1)?.[1] === requestId && markers.filter((match) => match[1] === requestId).length === 1;
|
|
5231
|
+
}
|
|
5232
|
+
/** Full prompt and per-send identity; wrappers may contain tool/file labels. */
|
|
5233
|
+
export function chatGptRequestMatchesUserTurn(userText, sentPrompt, requestId) {
|
|
5234
|
+
return chatGptRequestMarkerMatches(userText, requestId) &&
|
|
5235
|
+
normalizeChatGptPromptText(userText).includes(normalizeChatGptPromptText(sentPrompt));
|
|
5236
|
+
}
|
|
5508
5237
|
/**
|
|
5509
5238
|
* Does this transcript belong to the consult that is waiting on it?
|
|
5510
5239
|
*
|
|
@@ -5520,12 +5249,8 @@ export function transcriptMatchesSentPrompt(userText, sentPrompt) {
|
|
|
5520
5249
|
// kept as "\\## File", fences as escaped backticks), so undo that before
|
|
5521
5250
|
// comparing - otherwise every prompt carrying markdown, which is every
|
|
5522
5251
|
// --file send, looks like a different conversation.
|
|
5523
|
-
const
|
|
5524
|
-
|
|
5525
|
-
.replace(/\s+/g, " ")
|
|
5526
|
-
.trim();
|
|
5527
|
-
const seen = normalize(userText);
|
|
5528
|
-
const sent = normalize(sentPrompt);
|
|
5252
|
+
const seen = normalizeChatGptPromptText(userText);
|
|
5253
|
+
const sent = normalizeChatGptPromptText(sentPrompt);
|
|
5529
5254
|
if (seen.length === 0 || sent.length === 0)
|
|
5530
5255
|
return false;
|
|
5531
5256
|
const expected = sent.slice(0, NORMALIZED_PROMPT_MATCH_CHARS);
|
|
@@ -5597,60 +5322,6 @@ export function resolveTranscriptCitations(text, references = []) {
|
|
|
5597
5322
|
// reach a receipt, but the words between them still belong to the answer.
|
|
5598
5323
|
return resolved.replace(CITATION_MARKER_PATTERN, (marker) => citationMarkerText(marker));
|
|
5599
5324
|
}
|
|
5600
|
-
export function deepResearchReportExpression(conversationId) {
|
|
5601
|
-
return `(async () => {
|
|
5602
|
-
const fail = (reason, status, userText) => ({ ok: false, reason, status: status || "", report: "", chars: 0, references: [], userText: userText || "" });
|
|
5603
|
-
let token = "";
|
|
5604
|
-
try {
|
|
5605
|
-
const session = await fetch("/api/auth/session", { credentials: "include" });
|
|
5606
|
-
if (!session.ok) return fail("session_http_" + session.status);
|
|
5607
|
-
const parsed = await session.json();
|
|
5608
|
-
token = (parsed && parsed.accessToken) || "";
|
|
5609
|
-
} catch (error) {
|
|
5610
|
-
return fail("session_error");
|
|
5611
|
-
}
|
|
5612
|
-
let conversation;
|
|
5613
|
-
try {
|
|
5614
|
-
const response = await fetch("/backend-api/conversation/" + ${JSON.stringify(conversationId)}, {
|
|
5615
|
-
credentials: "include",
|
|
5616
|
-
headers: token ? { Authorization: "Bearer " + token } : {}
|
|
5617
|
-
});
|
|
5618
|
-
if (!response.ok) return fail("conversation_http_" + response.status);
|
|
5619
|
-
conversation = await response.json();
|
|
5620
|
-
} catch (error) {
|
|
5621
|
-
return fail("conversation_error");
|
|
5622
|
-
}
|
|
5623
|
-
const mapping = (conversation && conversation.mapping) || {};
|
|
5624
|
-
const nodes = Object.keys(mapping).map((key) => mapping[key]);
|
|
5625
|
-
const widgetNode = nodes.find(
|
|
5626
|
-
(node) => node && node.message && node.message.metadata && node.message.metadata.chatgpt_sdk && node.message.metadata.chatgpt_sdk.widget_state
|
|
5627
|
-
);
|
|
5628
|
-
if (!widgetNode) return fail("no_widget_state");
|
|
5629
|
-
let state;
|
|
5630
|
-
try {
|
|
5631
|
-
state = JSON.parse(widgetNode.message.metadata.chatgpt_sdk.widget_state);
|
|
5632
|
-
} catch (error) {
|
|
5633
|
-
return fail("widget_state_unparsable");
|
|
5634
|
-
}
|
|
5635
|
-
const status = (state && state.status) || "";
|
|
5636
|
-
const chain = [];
|
|
5637
|
-
let walkId = conversation && conversation.current_node;
|
|
5638
|
-
let walkGuard = 0;
|
|
5639
|
-
while (walkId && mapping[walkId] && walkGuard < 2000) {
|
|
5640
|
-
walkGuard += 1;
|
|
5641
|
-
if (mapping[walkId].message) chain.push(mapping[walkId].message);
|
|
5642
|
-
walkId = mapping[walkId].parent;
|
|
5643
|
-
}
|
|
5644
|
-
const userNode = chain.find((entry) => entry && entry.author && entry.author.role === "user" && entry.content);
|
|
5645
|
-
const userText = userNode ? (userNode.content.parts || []).filter((part) => typeof part === "string").join("") : "";
|
|
5646
|
-
const message = (state && state.report_message) || null;
|
|
5647
|
-
const parts = message && message.content && message.content.parts;
|
|
5648
|
-
const report = Array.isArray(parts) ? parts.filter((part) => typeof part === "string").join("") : "";
|
|
5649
|
-
const references = message && message.metadata && Array.isArray(message.metadata.content_references) ? message.metadata.content_references : [];
|
|
5650
|
-
if (!report) return fail("report_not_ready", status, userText);
|
|
5651
|
-
return { ok: true, reason: "", status, report, chars: report.length, references, userText };
|
|
5652
|
-
})()`;
|
|
5653
|
-
}
|
|
5654
5325
|
/**
|
|
5655
5326
|
* Thread urls come in a plain (`/c/<id>`) and a project (`/g/g-p-.../c/<id>`)
|
|
5656
5327
|
* shape; both end in the conversation id the backend API is keyed by.
|
|
@@ -5721,6 +5392,13 @@ export function conversationIdFromThreadUrl(url) {
|
|
|
5721
5392
|
const match = /\/c\/([0-9a-fA-F-]{16,})/.exec(url);
|
|
5722
5393
|
return match ? match[1] : undefined;
|
|
5723
5394
|
}
|
|
5395
|
+
/** Freeze a conversation id into stable result metadata, independent of later tab navigation. */
|
|
5396
|
+
export function canonicalChatGptThreadUrl(conversationId, observedUrl) {
|
|
5397
|
+
if (observedUrl && conversationIdFromThreadUrl(observedUrl)?.toLowerCase() === conversationId.toLowerCase()) {
|
|
5398
|
+
return normalizeChatGptTargetUrl(observedUrl);
|
|
5399
|
+
}
|
|
5400
|
+
return `https://chatgpt.com/c/${conversationId}`;
|
|
5401
|
+
}
|
|
5724
5402
|
export function deepResearchStartButtonRectExpression() {
|
|
5725
5403
|
return `(() => {${CLICK_POINT_SNIPPET}
|
|
5726
5404
|
const buttons = [...document.querySelectorAll('button,[role="button"]')];
|
|
@@ -5851,7 +5529,10 @@ export function answerExpression() {
|
|
|
5851
5529
|
// answer (a 0.21.3 fallback for deep research, which is read from the
|
|
5852
5530
|
// transcript now) turned a tool's progress panel into a 28-character
|
|
5853
5531
|
// "answer" that a consult returned as its result.
|
|
5854
|
-
const
|
|
5532
|
+
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
|
5533
|
+
// Pair only within the latest user turn; a previous reply is not the
|
|
5534
|
+
// answer to a new question whose assistant node has not rendered yet.
|
|
5535
|
+
const assistant = lastUserIndex < 0 ? undefined : messages.slice(lastUserIndex + 1).filter((message) => message.role === "assistant").at(-1);
|
|
5855
5536
|
const buttons = [...document.querySelectorAll('button,[role="button"]')]
|
|
5856
5537
|
.filter((node) => !!(node.offsetWidth || node.offsetHeight || node.getClientRects().length))
|
|
5857
5538
|
.filter((node) => !node.closest(excludedTextSelector))
|
|
@@ -5876,6 +5557,7 @@ export function answerExpression() {
|
|
|
5876
5557
|
awaitingResponseChoice: Boolean(document.querySelector(${responseChoiceSelector})),
|
|
5877
5558
|
assistantMessageCount: assistantMessages.length,
|
|
5878
5559
|
userMessageCount: userMessages.length,
|
|
5560
|
+
lastUserText: userMessages.at(-1)?.text || "",
|
|
5879
5561
|
// ChatGPT tags each assistant message with the model that produced it -
|
|
5880
5562
|
// the only ground truth for "did the Pro selection actually take".
|
|
5881
5563
|
modelSlug: assistant ? assistant.modelSlug : undefined,
|