@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.
@@ -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
- /** Where ChatGPT persists the Chat/Work choice, readable on every page. */
19
- const CHAT_SURFACE_STORAGE_KEY = "oai/apps/tpp/chat-surface-mode";
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. The display is served over loopback TCP because WSLg
287
- * mounts /tmp/.X11-unix read-only, so the usual unix socket cannot be created;
288
- * an xauth cookie (never -ac) keeps other local processes off a display that
289
- * shows a signed-in ChatGPT window.
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
- "-listen",
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: `127.0.0.1:${displayNumber}`, XAUTHORITY: xauthority };
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
- const raw = (env.PRODEX_VIRTUAL_DISPLAY ?? "").trim().toLowerCase();
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 tcpPortAccepts(port, timeoutMs = 500) {
326
+ async function socketAccepts(connect, timeoutMs = 500) {
324
327
  return new Promise((resolve) => {
325
- const socket = net.connect({ host: "127.0.0.1", port });
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
- await mkdir(stateDir, { recursive: true, mode: 0o700 });
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: a listening display is only reusable when OUR cookie
348
- // for it exists, otherwise it belongs to something else (another tool, or a
349
- // stale server started with a different key) and we could not authenticate
350
- // to it - that produced "Authorization required" and a browser that died on
351
- // launch. A free number gets a fresh server.
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
- const listening = await tcpPortAccepts(6000 + displayNumber);
355
- if (listening) {
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
- const auth = spawnSync("xauth", ["-f", xauthority, "add", `127.0.0.1:${displayNumber}`, ".", cookie], {
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
- const child = spawn("Xvfb", virtualDisplayServerArgs(displayNumber, xauthority), {
377
- detached: true,
378
- stdio: "ignore",
379
- env: process.env
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
- const deadline = Date.now() + 10_000;
383
- while (Date.now() < deadline) {
384
- if (await tcpPortAccepts(6000 + displayNumber))
385
- return { displayNumber, xauthority, startedNow: true };
386
- await sleep(250);
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 explicit flag or environment variable wins; otherwise reopen it the way it
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 fromEnv = (name) => {
409
- const raw = (env[name] ?? "").trim().toLowerCase();
410
- if (raw === "")
411
- return undefined;
412
- return raw === "1" || raw === "true" || raw === "yes";
413
- };
414
- const explicit = {
415
- headless: flags.headless ?? fromEnv("PRODEX_HEADLESS"),
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: explicit.headless === true,
423
- virtualDisplay: explicit.virtualDisplay === true,
424
- minimized: explicit.minimized === true
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
- return {
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
- const raw = (env.PRODEX_HEADLESS ?? "").trim().toLowerCase();
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. Best-effort: on
625
- * timeout it returns and the caller proceeds (the acceptance logic still
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
- const state = await evaluateOnPage(page, answerExpression());
632
- if (isFreshChatGptPage(state))
633
- return true;
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
- (busyBlocker ? busyBlockerAfterTranscriptCheck(busyBlocker, await readTranscriptCompletion(page.page, state.url)) : undefined);
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
- // The toggle is only on the home screen, but the choice is persisted where
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
- const sliderState = await cdp.evaluate(powerSliderStateExpression());
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
- 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
- }
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 (!state || state.assistantMessageCount < 1 || !isUsableChatGptAnswer(state.answer)) {
3119
- throw new ChatGptBrowserBlockerError({
3120
- code: generating ? "still_generating" : "no_recoverable_answer",
3121
- message: generating
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
- : "No finished assistant answer loaded from that thread (the conversation may not have rendered, or the URL is not the consult thread).",
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: generating
3126
- ? "Wait for ChatGPT to finish, then rerun `prodex pro browser recover --target-url <url>`."
3127
- : "Confirm the URL is the consult thread that shows a finished answer, raise --timeout-ms if the page loads slowly, or send a fresh consult."
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: state.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
- warnings: []
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 = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
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 = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
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 = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
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 = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(fresh), await readTranscriptCompletion(page, fresh.url));
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 = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(fresh), await readTranscriptCompletion(page, fresh.url));
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, options.prompt, page, toolLabels);
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 = `document.querySelectorAll('[data-message-author-role="user"]').length > ${beforeSubmit.userMessageCount}`;
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 (hasChatGptPromptAcceptance(beforeSubmit, finalState)) {
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
- // Before calling this a failed send: did the prompt actually land? Reading
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
- const pinnedThreadUrl = finalState?.url;
3662
- // Pin the CONVERSATION, not the tab. The transcript reader fetches by id, so
3663
- // it keeps working when the tab wanders off the thread - which is exactly how
3664
- // a finished answer was lost: the tab returned to the project page, the url
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
- finalState = await evaluateOnPage(page, answerExpression());
3678
+ const observedState = await evaluateOnPage(page, answerExpression());
3742
3679
  consecutiveReadFailures = 0;
3743
- // First conversation id wins. Re-deriving it every poll would let a tab
3744
- // that wandered to another thread redirect the read to a stranger's
3745
- // conversation - and the prompt check below is the second line of defence,
3746
- // not the first.
3747
- if (!transcriptConversationId && finalState?.url)
3748
- transcriptConversationId = conversationIdFromThreadUrl(finalState.url);
3749
- // The transcript is the same data the page renders, minus the rendering:
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: "" });
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: finalState?.url, lastTranscriptClassification })) {
3766
- if (recoveredNavigations >= 2) {
3767
- throw new ChatGptBrowserBlockerError({
3768
- code: "thread_navigated_away",
3769
- message: "The browser tab was moved to a different ChatGPT conversation while this consult was waiting for its answer.",
3770
- retryable: true,
3771
- next_step: `Keep the dedicated browser on the consult thread, then fetch the answer with \`prodex pro browser recover --target-url ${pinnedThreadUrl}\`.`,
3772
- thread: pinnedThreadUrl
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 ?? pinnedThreadUrl));
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
- // The rendered answer settles a beat before the server transcript does.
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 was still generating after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms), so the answer below may be truncated. Raise --timeout-ms and retry for the full response.`
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. Raise --timeout-ms and retry."), completed?.url ? { thread: completed.url } : {});
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
- // Only the executable counts, never the arguments: a process that merely
4013
- // quotes a Chrome path is not Chrome. Everything up to the first flag is
4014
- // the program, which keeps the spaces macOS puts in "Google Chrome".
4015
- const executable = command.split(/\s-{1,2}\w/)[0];
4016
- return /(^|[/\\])(google[ -]?chrome|chromium|chrome)( helper)?( \([^)]*\))?$/i.test(executable.trim());
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
- export function deleteConversationExpression(conversationId) {
4138
- return `(async () => {
4139
- let token = "";
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
- const port = resolveCdpPort(input.port);
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
- /** Delete one project. Callers must have confirmed intent before calling. */
4242
- export async function deleteChatGptProject(input) {
4243
- const port = resolveCdpPort(input.port);
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 page = await findChatGptPage(port, input.timeoutMs ?? 3_000);
4262
- if (!page.ok || !page.page)
4263
- return [];
4264
- const { recentConversationTitlesExpression } = await import("./tui.js");
4265
- try {
4266
- return ((await evaluateOnPage(page.page, recentConversationTitlesExpression(input.limit ?? 10), {
4267
- timeoutMs: 30_000
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
- retryable: true,
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 retry.",
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 normalize = (value) => value
5524
- .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
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 assistant = assistantMessages.at(-1);
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,