@youdie006/prodex 0.40.6 → 0.40.8
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 +25 -10
- package/dist/browser-send-lock.js +10 -131
- package/dist/chatgpt-browser.js +275 -691
- package/dist/cli-help.js +20 -17
- package/dist/cli-ledger.js +2 -0
- package/dist/cli-pro.js +216 -102
- package/dist/cli-server.js +2 -2
- package/dist/cli.js +1 -4
- package/dist/config.js +3 -3
- 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 +2 -2
- package/dist/registry.js +54 -6
- package/dist/repo-write.js +22 -2
- package/dist/safe-file.js +249 -1
- package/dist/store.js +7 -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 +3 -1
- package/docs/cli-reference.md +17 -6
- package/docs/clients.md +4 -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.
|
|
@@ -794,23 +890,6 @@ export function busyBlockerAfterTranscriptCheck(busyBlocker, transcript) {
|
|
|
794
890
|
return undefined;
|
|
795
891
|
return transcript?.ok === true && transcript.isComplete === true ? undefined : busyBlocker;
|
|
796
892
|
}
|
|
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
893
|
export function isLikelyChatGptSubmitButton(label, dataTestId) {
|
|
815
894
|
const normalized = label.trim().toLowerCase();
|
|
816
895
|
return dataTestId === "send-button" || /\b(send|submit)\b|보내기|전송/.test(normalized);
|
|
@@ -1317,7 +1396,7 @@ export async function getChatGptBrowserStatus(options = {}) {
|
|
|
1317
1396
|
const blocker = chatGptVisibilityBlocker(state.visibilityState, state.url) ??
|
|
1318
1397
|
detectChatGptPageBlocker(state) ??
|
|
1319
1398
|
chatGptResponseChoiceBlocker(state.awaitingResponseChoice === true) ??
|
|
1320
|
-
|
|
1399
|
+
busyBlocker;
|
|
1321
1400
|
return {
|
|
1322
1401
|
reachable: true,
|
|
1323
1402
|
loggedInLikely,
|
|
@@ -1653,21 +1732,7 @@ export function chatSurfaceProbeExpression() {
|
|
|
1653
1732
|
label: ((el.innerText || el.textContent || "").trim()),
|
|
1654
1733
|
checked: el.getAttribute("aria-checked") === "true" || el.getAttribute("aria-selected") === "true"
|
|
1655
1734
|
}));
|
|
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 };
|
|
1735
|
+
return { surfaces };
|
|
1671
1736
|
})()`;
|
|
1672
1737
|
}
|
|
1673
1738
|
/** Where to click to go back to Chat. Only asked once a switch is decided. */
|
|
@@ -1679,21 +1744,6 @@ export function chatSurfaceToggleRectExpression() {
|
|
|
1679
1744
|
return chat ? clickPoint(chat) : { ok: false, reason: "no Chat toggle" };
|
|
1680
1745
|
})()`;
|
|
1681
1746
|
}
|
|
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
1747
|
/**
|
|
1698
1748
|
* Name of the stamp put on a document that is about to be reloaded. A stamp
|
|
1699
1749
|
* cannot survive a navigation, so its absence is what tells the reloaded
|
|
@@ -2295,35 +2345,6 @@ async function ensureChatSurface(cdp, options) {
|
|
|
2295
2345
|
return note;
|
|
2296
2346
|
}
|
|
2297
2347
|
}
|
|
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
2348
|
catch (error) {
|
|
2328
2349
|
if (cdpCommandTimedOut(error))
|
|
2329
2350
|
throw error;
|
|
@@ -3033,55 +3054,13 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
3033
3054
|
let generating = false;
|
|
3034
3055
|
let stableRuns = 0;
|
|
3035
3056
|
let lastAnswer = "";
|
|
3057
|
+
let lastObservedUrl = "";
|
|
3058
|
+
let completed = false;
|
|
3036
3059
|
try {
|
|
3037
3060
|
await cdp.send("Runtime.enable");
|
|
3038
3061
|
// In-tab navigation (location.assign, not Page.navigate which has crashed the
|
|
3039
3062
|
// instance) so we read the requested thread, not whatever was open.
|
|
3040
3063
|
await cdp.evaluate(`location.assign(${JSON.stringify(url)})`);
|
|
3041
|
-
// A deep research thread has no assistant message to recover - its report
|
|
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
|
-
}
|
|
3085
3064
|
const deadline = Date.now() + timeoutMs;
|
|
3086
3065
|
while (Date.now() < deadline) {
|
|
3087
3066
|
await sleep(500);
|
|
@@ -3092,9 +3071,19 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
3092
3071
|
continue;
|
|
3093
3072
|
}
|
|
3094
3073
|
generating = state.generating;
|
|
3074
|
+
if (state.url !== lastObservedUrl) {
|
|
3075
|
+
lastObservedUrl = state.url;
|
|
3076
|
+
stableRuns = 0;
|
|
3077
|
+
lastAnswer = "";
|
|
3078
|
+
}
|
|
3095
3079
|
const runtimeBlocker = chatGptBlockerFromAnswerState(state);
|
|
3096
3080
|
if (runtimeBlocker)
|
|
3097
3081
|
throw new ChatGptBrowserBlockerError(runtimeBlocker);
|
|
3082
|
+
if (!chatGptUrlsReferToSameTarget(state.url, url)) {
|
|
3083
|
+
stableRuns = 0;
|
|
3084
|
+
lastAnswer = "";
|
|
3085
|
+
continue;
|
|
3086
|
+
}
|
|
3098
3087
|
// Require a REAL assistant message, not answerExpression's page-chrome
|
|
3099
3088
|
// fallback (empty assistant returns sidebar/nav text): the thread's
|
|
3100
3089
|
// conversation loads asynchronously after navigation, so keep polling.
|
|
@@ -3103,8 +3092,10 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
3103
3092
|
// must not sneak into the recovered text.
|
|
3104
3093
|
stableRuns = state.answer === lastAnswer ? stableRuns + 1 : 0;
|
|
3105
3094
|
lastAnswer = state.answer;
|
|
3106
|
-
if (stableRuns >= 1)
|
|
3095
|
+
if (stableRuns >= 1) {
|
|
3096
|
+
completed = true;
|
|
3107
3097
|
break;
|
|
3098
|
+
}
|
|
3108
3099
|
}
|
|
3109
3100
|
else {
|
|
3110
3101
|
stableRuns = 0;
|
|
@@ -3115,20 +3106,35 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
3115
3106
|
finally {
|
|
3116
3107
|
cdp.close();
|
|
3117
3108
|
}
|
|
3118
|
-
if (!
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3109
|
+
if (!completed) {
|
|
3110
|
+
const targetMatched = Boolean(state && chatGptUrlsReferToSameTarget(state.url, url));
|
|
3111
|
+
const hasUsableAnswer = Boolean(state && state.assistantMessageCount > 0 && isUsableChatGptAnswer(state.answer));
|
|
3112
|
+
const code = !targetMatched
|
|
3113
|
+
? "thread_target_mismatch"
|
|
3114
|
+
: generating
|
|
3115
|
+
? "still_generating"
|
|
3116
|
+
: hasUsableAnswer
|
|
3117
|
+
? "answer_not_stable"
|
|
3118
|
+
: "no_recoverable_answer";
|
|
3119
|
+
const message = code === "thread_target_mismatch"
|
|
3120
|
+
? `The visible ChatGPT tab did not settle on the requested conversation. It remained at ${state?.url || "an unreadable page"}.`
|
|
3121
|
+
: code === "still_generating"
|
|
3122
3122
|
? "That thread is still generating - the answer is not complete yet."
|
|
3123
|
-
:
|
|
3123
|
+
: code === "answer_not_stable"
|
|
3124
|
+
? "That thread showed changing answer text through the recovery deadline, so prodex cannot mark it complete."
|
|
3125
|
+
: "No finished assistant answer loaded from that thread (the conversation may not have rendered, or the URL is not the consult thread).";
|
|
3126
|
+
throw new ChatGptBrowserBlockerError({
|
|
3127
|
+
code,
|
|
3128
|
+
message,
|
|
3124
3129
|
retryable: true,
|
|
3125
|
-
next_step:
|
|
3126
|
-
? "Wait for ChatGPT to finish, then rerun `prodex pro browser recover --target-url <url>`."
|
|
3127
|
-
: "
|
|
3130
|
+
next_step: code === "still_generating" || code === "answer_not_stable"
|
|
3131
|
+
? "Wait for ChatGPT to finish and settle, then rerun `prodex pro browser recover --target-url <url>`."
|
|
3132
|
+
: "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.",
|
|
3133
|
+
thread: url
|
|
3128
3134
|
});
|
|
3129
3135
|
}
|
|
3130
3136
|
return {
|
|
3131
|
-
url
|
|
3137
|
+
url,
|
|
3132
3138
|
title: state.title,
|
|
3133
3139
|
answer: state.answer.trim(),
|
|
3134
3140
|
modelHints: state.modelHints,
|
|
@@ -3136,42 +3142,11 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
3136
3142
|
warnings: []
|
|
3137
3143
|
};
|
|
3138
3144
|
}
|
|
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
3145
|
export async function sendChatGptPrompt(options) {
|
|
3146
|
+
const toolLabels = (options.tools ?? []).map(resolveComposerToolLabel);
|
|
3147
|
+
if (toolLabels.includes(DEEP_RESEARCH_TOOL_LABEL)) {
|
|
3148
|
+
throw unsupportedChatGptOperationError("Deep research", "Use Deep research directly in the visible ChatGPT UI, or send an ordinary prodex consult without the Deep research tool.");
|
|
3149
|
+
}
|
|
3175
3150
|
const port = resolveCdpPort(options.port);
|
|
3176
3151
|
const timeoutMs = options.timeoutMs ?? 90_000;
|
|
3177
3152
|
/** Dialogs answered on the reload connection, which comes and goes before the send's own, so the receipt still says so. */
|
|
@@ -3243,7 +3218,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3243
3218
|
// timeout: consults continue threads by default, so landing on a thread
|
|
3244
3219
|
// whose previous (often timed-out Pro) answer is still streaming is a when,
|
|
3245
3220
|
// not an if - queueing behind it beats failing.
|
|
3246
|
-
let busyBlocker =
|
|
3221
|
+
let busyBlocker = chatGptBusyBlocker(status);
|
|
3247
3222
|
const busyWaitBudgetMs = options.busyWaitMs ?? timeoutMs;
|
|
3248
3223
|
if (busyBlocker && busyWaitBudgetMs > 0) {
|
|
3249
3224
|
// Queue behind the in-flight response instead of failing: shared-tab
|
|
@@ -3257,7 +3232,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3257
3232
|
const midBlocker = detectChatGptPageBlocker(status);
|
|
3258
3233
|
if (midBlocker)
|
|
3259
3234
|
throw new ChatGptBrowserBlockerError(midBlocker);
|
|
3260
|
-
busyBlocker =
|
|
3235
|
+
busyBlocker = chatGptBusyBlocker(status);
|
|
3261
3236
|
if (busyBlocker)
|
|
3262
3237
|
emitProgress("waiting", "tab busy with another response; waiting");
|
|
3263
3238
|
}
|
|
@@ -3267,7 +3242,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3267
3242
|
// weigh that fresh reading the same way - a generation that started in
|
|
3268
3243
|
// the meantime must still hold the send back.
|
|
3269
3244
|
status = await readSettledChatGptPageStatus(page);
|
|
3270
|
-
busyBlocker =
|
|
3245
|
+
busyBlocker = chatGptBusyBlocker(status);
|
|
3271
3246
|
}
|
|
3272
3247
|
}
|
|
3273
3248
|
// ChatGPT's error page does not come back on a reload - measured: a project
|
|
@@ -3294,7 +3269,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3294
3269
|
// The busy verdict above was decided about the page we just left, and it
|
|
3295
3270
|
// is handed to the readiness assert as already decided. Carrying it over
|
|
3296
3271
|
// would let a root page that is generating an answer be typed into.
|
|
3297
|
-
busyBlocker =
|
|
3272
|
+
busyBlocker = chatGptBusyBlocker(fresh);
|
|
3298
3273
|
status = fresh;
|
|
3299
3274
|
}
|
|
3300
3275
|
catch (error) {
|
|
@@ -3332,7 +3307,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3332
3307
|
const blockerAfterReload = detectChatGptPageBlocker(fresh);
|
|
3333
3308
|
if (blockerAfterReload)
|
|
3334
3309
|
throw new ChatGptBrowserBlockerError(blockerAfterReload);
|
|
3335
|
-
busyBlocker =
|
|
3310
|
+
busyBlocker = chatGptBusyBlocker(fresh);
|
|
3336
3311
|
status = fresh;
|
|
3337
3312
|
}
|
|
3338
3313
|
catch (error) {
|
|
@@ -3388,7 +3363,6 @@ export async function sendChatGptPrompt(options) {
|
|
|
3388
3363
|
let beforeSubmit;
|
|
3389
3364
|
let boundProjectId;
|
|
3390
3365
|
let submitButtonFound = false;
|
|
3391
|
-
let wantsDeepResearch = false;
|
|
3392
3366
|
const sendWarnings = [];
|
|
3393
3367
|
// Anything the page put in front of prodex was answered on the caller's
|
|
3394
3368
|
// behalf. The note is read at return time - there are several return paths -
|
|
@@ -3488,8 +3462,6 @@ export async function sendChatGptPrompt(options) {
|
|
|
3488
3462
|
const uploaded = await attachFilesToComposer(cdp, options.attachments);
|
|
3489
3463
|
emitProgress("selecting", `attached ${uploaded.attached.join(", ")}`);
|
|
3490
3464
|
}
|
|
3491
|
-
const toolLabels = (options.tools ?? []).map(resolveComposerToolLabel);
|
|
3492
|
-
wantsDeepResearch = toolLabels.includes(DEEP_RESEARCH_TOOL_LABEL);
|
|
3493
3465
|
if (toolLabels.length > 0)
|
|
3494
3466
|
emitProgress("selecting", `tools=${toolLabels.join(", ")}`);
|
|
3495
3467
|
await insertComposerTextViaCdp(cdp, options.prompt, page, toolLabels);
|
|
@@ -3533,26 +3505,6 @@ export async function sendChatGptPrompt(options) {
|
|
|
3533
3505
|
}
|
|
3534
3506
|
}
|
|
3535
3507
|
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
3508
|
}
|
|
3557
3509
|
catch (error) {
|
|
3558
3510
|
// Capture before the connection closes: this is the moment the page still
|
|
@@ -3569,27 +3521,8 @@ export async function sendChatGptPrompt(options) {
|
|
|
3569
3521
|
const acceptDeadline = computePromptAcceptanceDeadline(timeoutMs, started);
|
|
3570
3522
|
let accepted = false;
|
|
3571
3523
|
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
3524
|
while (Date.now() < acceptDeadline) {
|
|
3581
3525
|
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
3526
|
try {
|
|
3594
3527
|
finalState = await evaluateOnPage(page, answerExpression());
|
|
3595
3528
|
}
|
|
@@ -3639,130 +3572,36 @@ export async function sendChatGptPrompt(options) {
|
|
|
3639
3572
|
catch {
|
|
3640
3573
|
// best effort: fall back to submit-button signal only
|
|
3641
3574
|
}
|
|
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 });
|
|
3575
|
+
throw acceptanceTimeoutError({ timeoutMs, composerStillHasText, submitButtonFound });
|
|
3655
3576
|
}
|
|
3656
3577
|
// Pin the conversation the prompt actually landed in. The browser is shared
|
|
3657
3578
|
// (other agents, the user, tooling), and a tab that moves mid-wait made
|
|
3658
3579
|
// prodex read a DIFFERENT conversation and save it as this consult's answer -
|
|
3659
3580
|
// silently, with a receipt (caught live). Nothing about that is recoverable
|
|
3660
3581
|
// 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
|
-
}
|
|
3582
|
+
let pinnedConversationId = conversationIdFromThreadUrl(normalizedTargetUrl ?? finalState?.url ?? "");
|
|
3583
|
+
let pinnedThreadUrl = pinnedConversationId
|
|
3584
|
+
? canonicalChatGptThreadUrl(pinnedConversationId, normalizedTargetUrl ?? finalState?.url)
|
|
3585
|
+
: undefined;
|
|
3734
3586
|
let recoveredNavigations = 0;
|
|
3735
|
-
let lastTranscriptClassification;
|
|
3736
3587
|
let consecutiveReadFailures = 0;
|
|
3588
|
+
let answerSettled = false;
|
|
3737
3589
|
const answerIsStable = createChatGptAnswerStabilityTracker();
|
|
3738
3590
|
while (Date.now() - started < timeoutMs) {
|
|
3739
3591
|
await sleep(1000);
|
|
3740
3592
|
try {
|
|
3741
|
-
|
|
3593
|
+
const observedState = await evaluateOnPage(page, answerExpression());
|
|
3742
3594
|
consecutiveReadFailures = 0;
|
|
3743
|
-
//
|
|
3744
|
-
//
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
// markdown instead of flattened innerText, an explicit finish state
|
|
3751
|
-
// instead of caret heuristics, and the model that actually answered.
|
|
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: "" });
|
|
3595
|
+
// Freeze the first conversation identity the accepted page exposes. A
|
|
3596
|
+
// later tab move must never rewrite result metadata to another thread.
|
|
3597
|
+
if (!pinnedConversationId) {
|
|
3598
|
+
const observedConversationId = conversationIdFromThreadUrl(observedState.url);
|
|
3599
|
+
if (observedConversationId) {
|
|
3600
|
+
pinnedConversationId = observedConversationId;
|
|
3601
|
+
pinnedThreadUrl = canonicalChatGptThreadUrl(observedConversationId, observedState.url);
|
|
3763
3602
|
}
|
|
3764
3603
|
}
|
|
3765
|
-
if (shouldRecoverThreadNavigation({ pinnedThreadUrl, currentUrl:
|
|
3604
|
+
if (shouldRecoverThreadNavigation({ pinnedThreadUrl, currentUrl: observedState.url })) {
|
|
3766
3605
|
if (recoveredNavigations >= 2) {
|
|
3767
3606
|
throw new ChatGptBrowserBlockerError({
|
|
3768
3607
|
code: "thread_navigated_away",
|
|
@@ -3778,6 +3617,9 @@ export async function sendChatGptPrompt(options) {
|
|
|
3778
3617
|
await sleep(3_000);
|
|
3779
3618
|
continue;
|
|
3780
3619
|
}
|
|
3620
|
+
// Only a state from the pinned conversation may become eligible for
|
|
3621
|
+
// completed or partial result salvage after the polling deadline.
|
|
3622
|
+
finalState = observedState;
|
|
3781
3623
|
}
|
|
3782
3624
|
catch (error) {
|
|
3783
3625
|
if (error instanceof ChatGptBrowserBlockerError)
|
|
@@ -3789,7 +3631,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3789
3631
|
// sitting out the whole budget on it only delays the recovery.
|
|
3790
3632
|
consecutiveReadFailures += 1;
|
|
3791
3633
|
if (consecutiveReadFailures >= CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP) {
|
|
3792
|
-
throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(finalState?.url
|
|
3634
|
+
throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(pinnedThreadUrl ?? finalState?.url));
|
|
3793
3635
|
}
|
|
3794
3636
|
continue;
|
|
3795
3637
|
}
|
|
@@ -3804,29 +3646,15 @@ export async function sendChatGptPrompt(options) {
|
|
|
3804
3646
|
// character that can outlive the stop button. The tracker requires extra
|
|
3805
3647
|
// confirmations for caret-suspect tails (see its doc comment).
|
|
3806
3648
|
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
|
-
}
|
|
3649
|
+
answerSettled = true;
|
|
3822
3650
|
break;
|
|
3823
3651
|
}
|
|
3824
3652
|
}
|
|
3825
3653
|
const completed = finalState;
|
|
3826
|
-
if (completed && hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
|
|
3654
|
+
if (answerSettled && completed && hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
|
|
3827
3655
|
emitProgress("answered");
|
|
3828
3656
|
return {
|
|
3829
|
-
url: completed.url,
|
|
3657
|
+
url: pinnedThreadUrl ?? completed.url,
|
|
3830
3658
|
title: completed.title,
|
|
3831
3659
|
answer: completed.answer.trim(),
|
|
3832
3660
|
modelHints: completed.modelHints,
|
|
@@ -3841,7 +3669,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3841
3669
|
if (completed && hasPartialChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
|
|
3842
3670
|
emitProgress("answered", "partial");
|
|
3843
3671
|
return {
|
|
3844
|
-
url: completed.url,
|
|
3672
|
+
url: pinnedThreadUrl ?? completed.url,
|
|
3845
3673
|
title: completed.title,
|
|
3846
3674
|
answer: completed.answer.trim(),
|
|
3847
3675
|
modelHints: completed.modelHints,
|
|
@@ -3850,7 +3678,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3850
3678
|
warnings: withDialogNote([
|
|
3851
3679
|
...sendWarnings,
|
|
3852
3680
|
...(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
|
|
3681
|
+
`answer_incomplete: ChatGPT's answer did not reach a stable completed state after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms), so the answer below may be truncated. Raise --timeout-ms and retry for the full response.`
|
|
3854
3682
|
])
|
|
3855
3683
|
};
|
|
3856
3684
|
}
|
|
@@ -3858,7 +3686,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3858
3686
|
// after prodex gives up, and `pro browser recover --target-url` exists to
|
|
3859
3687
|
// fetch it - but only if the caller knows which thread to point at.
|
|
3860
3688
|
throw Object.assign(new Error(`Timed out after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms) waiting for ChatGPT to respond. ` +
|
|
3861
|
-
"Pro reasoning can run many minutes. Raise --timeout-ms and retry."), completed?.url ? { thread: completed
|
|
3689
|
+
"Pro reasoning can run many minutes. Raise --timeout-ms and retry."), pinnedThreadUrl ?? completed?.url ? { thread: pinnedThreadUrl ?? completed?.url } : {});
|
|
3862
3690
|
}
|
|
3863
3691
|
/**
|
|
3864
3692
|
* One line of `pro browser models`.
|
|
@@ -4008,12 +3836,18 @@ export function findLaunchedBrowserProcesses(psOutput, input) {
|
|
|
4008
3836
|
// very tool running this scan can carry it on its command line, and this list
|
|
4009
3837
|
// is what gets SIGTERM. Caught live - the probe matched its own node process.
|
|
4010
3838
|
const isBrowserCommand = (line) => {
|
|
4011
|
-
const command = line.replace(/^\s*\S+\s+\d+\s+/, "");
|
|
4012
|
-
//
|
|
4013
|
-
//
|
|
4014
|
-
//
|
|
4015
|
-
const
|
|
4016
|
-
|
|
3839
|
+
const command = line.replace(/^\s*\S+\s+\d+\s+/, "").trim();
|
|
3840
|
+
// Linux/PATH executables cannot contain spaces, so only the first token is
|
|
3841
|
+
// eligible. This keeps a node/shell argument that names a browser from
|
|
3842
|
+
// becoming a process prodex may terminate.
|
|
3843
|
+
const firstToken = command.split(/\s+/, 1)[0];
|
|
3844
|
+
if (/(^|[/\\])(google[ -]?chrome(?:\.exe)?|chromium(?:-browser)?|chrome(?:\.exe)?|microsoft[ -]edge|msedge\.exe|brave[ -]browser)$/i.test(firstToken)) {
|
|
3845
|
+
return true;
|
|
3846
|
+
}
|
|
3847
|
+
// macOS app executables and helpers have spaces in their absolute path.
|
|
3848
|
+
// Match only anchored, known bundle layouts and require the next token to
|
|
3849
|
+
// be a flag (or end-of-line), never arbitrary argument text.
|
|
3850
|
+
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
3851
|
};
|
|
4018
3852
|
const lines = psOutput.split(/\r?\n/).filter((line) => !/\bgrep\b/.test(line) && isBrowserCommand(line));
|
|
4019
3853
|
// Exactly this port: a plain substring test let port 9 match 9333.
|
|
@@ -4134,49 +3968,9 @@ export function wedgedBrowserBlocker(pids, port) {
|
|
|
4134
3968
|
next_step: "Clear it with `prodex pro browser reset --confirm` (it previews first), then run `prodex pro browser login`."
|
|
4135
3969
|
};
|
|
4136
3970
|
}
|
|
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"}`);
|
|
3971
|
+
/** Conversation deletion has no bounded visible-DOM implementation. */
|
|
3972
|
+
export async function deleteChatGptConversation(_input) {
|
|
3973
|
+
throw unsupportedChatGptOperationError("Conversation deletion", "Delete the conversation from its menu in the visible ChatGPT UI.");
|
|
4180
3974
|
}
|
|
4181
3975
|
export function resolveProjectToDelete(projects, request) {
|
|
4182
3976
|
if (request.id) {
|
|
@@ -4199,78 +3993,33 @@ export function resolveProjectToDelete(projects, request) {
|
|
|
4199
3993
|
}
|
|
4200
3994
|
return { ok: true, id: matches[0].id, name: matches[0].name };
|
|
4201
3995
|
}
|
|
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
3996
|
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
|
-
}
|
|
3997
|
+
return listVisibleChatGptNavigation(input, projectsWithIdsExpression());
|
|
4240
3998
|
}
|
|
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"}`);
|
|
3999
|
+
/** Project deletion has no bounded visible-DOM implementation. */
|
|
4000
|
+
export async function deleteChatGptProject(_input) {
|
|
4001
|
+
throw unsupportedChatGptOperationError("Project deletion", "Delete the project from its menu in the visible ChatGPT UI.");
|
|
4258
4002
|
}
|
|
4259
4003
|
export async function listRecentChatGptConversations(input = {}) {
|
|
4004
|
+
return listVisibleChatGptNavigation(input, recentConversationTitlesExpression(input.limit));
|
|
4005
|
+
}
|
|
4006
|
+
async function listVisibleChatGptNavigation(input, expression) {
|
|
4260
4007
|
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 [];
|
|
4008
|
+
const timeoutMs = input.timeoutMs ?? 15_000;
|
|
4009
|
+
const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), undefined);
|
|
4010
|
+
if (!pageResult.ok)
|
|
4011
|
+
throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
|
|
4012
|
+
if (!pageResult.page) {
|
|
4013
|
+
if (pageResult.blocker)
|
|
4014
|
+
throw new ChatGptBrowserBlockerError(pageResult.blocker);
|
|
4015
|
+
assertChatGptPageAvailable();
|
|
4273
4016
|
}
|
|
4017
|
+
const page = pageResult.page;
|
|
4018
|
+
const status = await evaluateOnPage(page, statusExpression(), { timeoutMs });
|
|
4019
|
+
const blocker = chatGptVisibilityBlocker(status.visibilityState, status.url) ?? detectChatGptPageBlocker(status);
|
|
4020
|
+
if (blocker)
|
|
4021
|
+
throw new ChatGptBrowserBlockerError(blocker);
|
|
4022
|
+
return evaluateOnPage(page, expression, { timeoutMs });
|
|
4274
4023
|
}
|
|
4275
4024
|
export async function listChatGptSidebarProjects(input = {}) {
|
|
4276
4025
|
const port = resolveCdpPort(input.port);
|
|
@@ -5327,10 +5076,11 @@ export function browserLostMidWaitBlocker(threadUrl) {
|
|
|
5327
5076
|
return {
|
|
5328
5077
|
code: "browser_unreachable",
|
|
5329
5078
|
message: `The dedicated ChatGPT browser stopped responding while this consult was waiting for its answer.${where}`,
|
|
5330
|
-
|
|
5079
|
+
// Retrying the send would duplicate a prompt that has already posted.
|
|
5080
|
+
retryable: false,
|
|
5331
5081
|
next_step: threadUrl
|
|
5332
5082
|
? `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
|
|
5083
|
+
: "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
5084
|
...(threadUrl ? { thread: threadUrl } : {})
|
|
5335
5085
|
};
|
|
5336
5086
|
}
|
|
@@ -5343,70 +5093,6 @@ export function deepResearchUnreadableBlocker(threadUrl) {
|
|
|
5343
5093
|
thread: threadUrl
|
|
5344
5094
|
};
|
|
5345
5095
|
}
|
|
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
5096
|
/**
|
|
5411
5097
|
* Which of those conversations is the one this send posted into, if any.
|
|
5412
5098
|
*
|
|
@@ -5449,61 +5135,6 @@ export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
|
|
|
5449
5135
|
return false;
|
|
5450
5136
|
return seen.includes(sent);
|
|
5451
5137
|
}
|
|
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
5138
|
const NORMALIZED_PROMPT_MATCH_CHARS = 120;
|
|
5508
5139
|
/**
|
|
5509
5140
|
* Does this transcript belong to the consult that is waiting on it?
|
|
@@ -5597,60 +5228,6 @@ export function resolveTranscriptCitations(text, references = []) {
|
|
|
5597
5228
|
// reach a receipt, but the words between them still belong to the answer.
|
|
5598
5229
|
return resolved.replace(CITATION_MARKER_PATTERN, (marker) => citationMarkerText(marker));
|
|
5599
5230
|
}
|
|
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
5231
|
/**
|
|
5655
5232
|
* Thread urls come in a plain (`/c/<id>`) and a project (`/g/g-p-.../c/<id>`)
|
|
5656
5233
|
* shape; both end in the conversation id the backend API is keyed by.
|
|
@@ -5721,6 +5298,13 @@ export function conversationIdFromThreadUrl(url) {
|
|
|
5721
5298
|
const match = /\/c\/([0-9a-fA-F-]{16,})/.exec(url);
|
|
5722
5299
|
return match ? match[1] : undefined;
|
|
5723
5300
|
}
|
|
5301
|
+
/** Freeze a conversation id into stable result metadata, independent of later tab navigation. */
|
|
5302
|
+
export function canonicalChatGptThreadUrl(conversationId, observedUrl) {
|
|
5303
|
+
if (observedUrl && conversationIdFromThreadUrl(observedUrl)?.toLowerCase() === conversationId.toLowerCase()) {
|
|
5304
|
+
return normalizeChatGptTargetUrl(observedUrl);
|
|
5305
|
+
}
|
|
5306
|
+
return `https://chatgpt.com/c/${conversationId}`;
|
|
5307
|
+
}
|
|
5724
5308
|
export function deepResearchStartButtonRectExpression() {
|
|
5725
5309
|
return `(() => {${CLICK_POINT_SNIPPET}
|
|
5726
5310
|
const buttons = [...document.querySelectorAll('button,[role="button"]')];
|