@youdie006/prodex 0.16.30 → 0.16.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,7 @@ prodex ask --file src/auth.ts "Review this for security holes"
35
35
 
36
36
  `prodex ask` is the short form of `prodex pro browser ask`; the full form and every flag work identically. In an interactive terminal, `login` keeps watching the opened window and tells you exactly which manual step is still missing (log in, clear a check, open a chat) until it reports READY. If you skip `login` and the browser is not running, an interactive `ask` recovers on its own: it launches the dedicated browser, waits for your saved session to be READY, and retries the send once (disable with `--no-auto-login`; scripts opt in with `--auto-login`). While ChatGPT thinks, `prodex` prints progress to stderr (connecting, prompt sent, elapsed seconds while generating), so a multi-minute Pro answer never looks frozen.
37
37
 
38
- The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and deliberately will not send into a window you cannot watch — but a dedicated Chrome window left non-minimized (even behind your editor) counts as watchable, so it sends quietly in the background without stealing focus. Just don't minimize it or switch that window to another tab. Pin per-repo defaults once - `prodex setup --model Pro --project "your-project"` - so every ask runs Pro (15-minute timeout) inside that project instead of whatever the ChatGPT UI last had selected; list exact sidebar project names with `prodex pro browser projects`. Pass `--file` more than once to attach several files, and `--busy-wait-ms 600000` to queue behind an in-flight response when several agents share the browser. See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
38
+ The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and deliberately will not send into a window you cannot watch — but a dedicated Chrome window left non-minimized (even behind your editor) counts as watchable, so it sends quietly in the background without stealing focus. Just don't minimize it or switch that window to another tab. Pin per-repo defaults once - `prodex setup --model Pro --project "your-project"` - so every ask runs Pro (15-minute timeout) inside that project instead of whatever the ChatGPT UI last had selected; list exact sidebar project names with `prodex pro browser projects`. Pass `--file` more than once to attach several files. When the thread is still generating a previous answer (common right after a timed-out Pro send), the send automatically queues behind it up to the timeout budget; tune that with `--busy-wait-ms` (0 fails fast with a `response_in_progress` blocker). See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
39
39
 
40
40
  ## Core Shape
41
41
 
@@ -1,5 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { accessSync, constants, readFileSync, statSync } from "node:fs";
2
+ import { accessSync, constants, statSync } from "node:fs";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import os from "node:os";
@@ -298,9 +298,11 @@ export function chatGptBusyBlocker(generating) {
298
298
  return undefined;
299
299
  return {
300
300
  code: "response_in_progress",
301
- message: "ChatGPT is still generating a previous response.",
301
+ message: "ChatGPT is still generating a previous response in this thread.",
302
302
  retryable: true,
303
- next_step: "Wait for the visible response to finish, or stop it manually in the browser, then retry."
303
+ next_step: "Wait for it to finish and retry (pass --busy-wait-ms to queue behind it longer). " +
304
+ "If that in-flight answer is the one you need, fetch it once it settles: `prodex pro browser recover --target-url <thread-url>`. " +
305
+ "A new topic can go to a new chat instead."
304
306
  };
305
307
  }
306
308
  export function isLikelyChatGptSubmitButton(label, dataTestId) {
@@ -564,6 +566,18 @@ function chatGptPageMissingBlocker() {
564
566
  next_step: "Open https://chatgpt.com/ in the dedicated Chrome profile, or run `prodex pro browser login` to reopen it."
565
567
  };
566
568
  }
569
+ // Pre-send gate over a settled page status. Order matters: while ChatGPT
570
+ // streams a response the composer locks (hasComposer reads false), so a busy
571
+ // thread must be diagnosed as response_in_progress BEFORE the composer
572
+ // readiness assert - otherwise it is misreported as "missing a visible prompt
573
+ // composer" (measured live: continue-by-default consults landing on a thread
574
+ // still generating the previous prodex answer).
575
+ export function assertChatGptIdleAndReadyForPrompt(status) {
576
+ const busyBlocker = chatGptBusyBlocker(status.generating);
577
+ if (busyBlocker)
578
+ throw new ChatGptBrowserBlockerError(busyBlocker);
579
+ assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
580
+ }
567
581
  export function assertChatGptReadyForPrompt(loggedInLikely, hasComposer, openDialogText) {
568
582
  if (loggedInLikely && hasComposer)
569
583
  return;
@@ -1279,6 +1293,87 @@ async function selectProject(cdp, options) {
1279
1293
  throw new Error(`ChatGPT composer did not appear after entering project "${options.project}"`);
1280
1294
  }
1281
1295
  }
1296
+ // Read the finished answer from an existing ChatGPT thread WITHOUT sending a new
1297
+ // prompt. Recovers a consult whose send timed out but whose answer ChatGPT
1298
+ // completed afterwards: the durable receipt is "blocked", yet the full answer
1299
+ // sits in the thread the operator can see. Navigates the visible tab to the
1300
+ // thread and waits for a stable, non-generating answer.
1301
+ export async function recoverChatGptAnswerFromThread(options) {
1302
+ const port = resolveCdpPort(options.port);
1303
+ const timeoutMs = Math.max(1_000, options.timeoutMs ?? 60_000);
1304
+ const url = normalizeChatGptTargetUrl(options.targetUrl);
1305
+ const page = await findChatGptPage(port, 3_000);
1306
+ if (!page.ok || !page.page) {
1307
+ throw new ChatGptBrowserBlockerError(page.blocker ?? {
1308
+ code: "browser_unreachable",
1309
+ message: `No Chrome DevTools endpoint is reachable on 127.0.0.1:${port}.`,
1310
+ retryable: true,
1311
+ next_step: "Run `prodex pro browser login`, log in, then retry."
1312
+ });
1313
+ }
1314
+ const cdp = await connectCdp(page.page.webSocketDebuggerUrl);
1315
+ let state;
1316
+ let generating = false;
1317
+ let stableRuns = 0;
1318
+ let lastAnswer = "";
1319
+ try {
1320
+ await cdp.send("Runtime.enable");
1321
+ // In-tab navigation (location.assign, not Page.navigate which has crashed the
1322
+ // instance) so we read the requested thread, not whatever was open.
1323
+ await cdp.evaluate(`location.assign(${JSON.stringify(url)})`);
1324
+ const deadline = Date.now() + timeoutMs;
1325
+ while (Date.now() < deadline) {
1326
+ await sleep(500);
1327
+ try {
1328
+ state = await evaluateOnPage(page.page, answerExpression());
1329
+ }
1330
+ catch {
1331
+ continue;
1332
+ }
1333
+ generating = state.generating;
1334
+ const runtimeBlocker = chatGptBlockerFromAnswerState(state);
1335
+ if (runtimeBlocker)
1336
+ throw new ChatGptBrowserBlockerError(runtimeBlocker);
1337
+ // Require a REAL assistant message, not answerExpression's page-chrome
1338
+ // fallback (empty assistant returns sidebar/nav text): the thread's
1339
+ // conversation loads asynchronously after navigation, so keep polling.
1340
+ if (state.assistantMessageCount > 0 && isUsableChatGptAnswer(state.answer) && !state.generating) {
1341
+ // Two identical settled reads: a just-finished streaming caret artifact
1342
+ // must not sneak into the recovered text.
1343
+ stableRuns = state.answer === lastAnswer ? stableRuns + 1 : 0;
1344
+ lastAnswer = state.answer;
1345
+ if (stableRuns >= 1)
1346
+ break;
1347
+ }
1348
+ else {
1349
+ stableRuns = 0;
1350
+ lastAnswer = "";
1351
+ }
1352
+ }
1353
+ }
1354
+ finally {
1355
+ cdp.close();
1356
+ }
1357
+ if (!state || state.assistantMessageCount < 1 || !isUsableChatGptAnswer(state.answer)) {
1358
+ throw new ChatGptBrowserBlockerError({
1359
+ code: generating ? "still_generating" : "no_recoverable_answer",
1360
+ message: generating
1361
+ ? "That thread is still generating - the answer is not complete yet."
1362
+ : "No finished assistant answer loaded from that thread (the conversation may not have rendered, or the URL is not the consult thread).",
1363
+ retryable: true,
1364
+ next_step: generating
1365
+ ? "Wait for ChatGPT to finish, then rerun `prodex pro browser recover --target-url <url>`."
1366
+ : "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."
1367
+ });
1368
+ }
1369
+ return {
1370
+ url: state.url,
1371
+ title: state.title,
1372
+ answer: state.answer.trim(),
1373
+ modelHints: state.modelHints,
1374
+ warnings: []
1375
+ };
1376
+ }
1282
1377
  export async function sendChatGptPrompt(options) {
1283
1378
  const port = resolveCdpPort(options.port);
1284
1379
  const timeoutMs = options.timeoutMs ?? 90_000;
@@ -1335,16 +1430,20 @@ export async function sendChatGptPrompt(options) {
1335
1430
  if (blocker) {
1336
1431
  throw new ChatGptBrowserBlockerError(blocker);
1337
1432
  }
1338
- assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
1339
- if (normalizedTargetUrl)
1340
- assertChatGptTargetUrlMatches(status.url, normalizedTargetUrl);
1341
- assertVisibleChatGptTab(status.visibilityState, status.url, normalizedTargetUrl);
1433
+ // Busy handling must run BEFORE the composer readiness assert: a thread
1434
+ // still generating locks the composer, and asserting composer presence
1435
+ // first misreports that as "missing a visible prompt composer" (see
1436
+ // assertChatGptIdleAndReadyForPrompt). Default the queue budget to the send
1437
+ // timeout: consults continue threads by default, so landing on a thread
1438
+ // whose previous (often timed-out Pro) answer is still streaming is a when,
1439
+ // not an if - queueing behind it beats failing.
1342
1440
  let busyBlocker = chatGptBusyBlocker(status.generating);
1343
- if (busyBlocker && (options.busyWaitMs ?? 0) > 0) {
1441
+ const busyWaitBudgetMs = options.busyWaitMs ?? timeoutMs;
1442
+ if (busyBlocker && busyWaitBudgetMs > 0) {
1344
1443
  // Queue behind the in-flight response instead of failing: shared-tab
1345
1444
  // contention (another agent or the user mid-generation) is a when, not an
1346
1445
  // if. Bounded, and a mid-wait page blocker (usage limit etc.) still throws.
1347
- const busyDeadline = Date.now() + (options.busyWaitMs ?? 0);
1446
+ const busyDeadline = Date.now() + busyWaitBudgetMs;
1348
1447
  emitProgress("waiting", "tab busy with another response; waiting");
1349
1448
  while (busyBlocker && Date.now() < busyDeadline) {
1350
1449
  await sleep(3_000);
@@ -1356,10 +1455,16 @@ export async function sendChatGptPrompt(options) {
1356
1455
  if (busyBlocker)
1357
1456
  emitProgress("waiting", "tab busy with another response; waiting");
1358
1457
  }
1458
+ if (!busyBlocker) {
1459
+ // The composer takes a moment to unlock after generation ends; settle
1460
+ // again so the readiness assert below sees the reopened composer.
1461
+ status = await readSettledChatGptPageStatus(page);
1462
+ }
1359
1463
  }
1360
- if (busyBlocker) {
1361
- throw new ChatGptBrowserBlockerError(busyBlocker);
1362
- }
1464
+ assertChatGptIdleAndReadyForPrompt(status);
1465
+ if (normalizedTargetUrl)
1466
+ assertChatGptTargetUrlMatches(status.url, normalizedTargetUrl);
1467
+ assertVisibleChatGptTab(status.visibilityState, status.url, normalizedTargetUrl);
1363
1468
  emitProgress("tab_ready");
1364
1469
  // Progress details deliberately avoid project names (receipts redact them too).
1365
1470
  const selectionSummary = [
@@ -2124,37 +2229,23 @@ function win32ChromePaths(env) {
2124
2229
  "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
2125
2230
  ];
2126
2231
  }
2127
- const WSL_WINDOWS_CHROME_PATHS = [
2128
- "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
2129
- "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
2130
- "/mnt/c/Program Files/Microsoft/Edge/Application/msedge.exe",
2131
- "/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"
2132
- ];
2133
- function kernelLooksLikeWsl() {
2134
- try {
2135
- return /microsoft/i.test(readFileSync("/proc/version", "utf8"));
2136
- }
2137
- catch {
2138
- return false;
2139
- }
2140
- }
2141
2232
  /**
2142
2233
  * Ordered browser candidates for the current platform: PATH binary names
2143
2234
  * first, then well-known absolute install locations (macOS app bundles,
2144
- * Windows Program Files/LOCALAPPDATA, and Windows-host browsers under WSL).
2145
- * WSL detection cannot rely on WSL_DISTRO_NAME alone: non-login shells
2146
- * (measured live) may not carry it, so WSL_INTEROP and the kernel string are
2147
- * probed too.
2235
+ * Windows Program Files/LOCALAPPDATA). Windows-host browsers are deliberately
2236
+ * NOT candidates under WSL: auto-selecting a /mnt/c chrome.exe/msedge.exe
2237
+ * either opened a blank window (the old --version probe - Windows browsers
2238
+ * treat --version as a launch) or launched the user's Windows browser with a
2239
+ * Linux profile path, both measured live as the recurring transient
2240
+ * Edge+Chrome window spam. Under WSL the dedicated browser is a Linux chrome;
2241
+ * a Windows browser is opt-in via PRODEX_CHROME only.
2148
2242
  */
2149
- export function chromeCommandCandidates(platform = process.platform, env = process.env, isWsl = kernelLooksLikeWsl) {
2243
+ export function chromeCommandCandidates(platform = process.platform, env = process.env) {
2150
2244
  const candidates = [...CHROME_PATH_BINARY_NAMES];
2151
2245
  if (platform === "darwin")
2152
2246
  candidates.push(...DARWIN_CHROME_PATHS);
2153
2247
  if (platform === "win32")
2154
2248
  candidates.push(...win32ChromePaths(env));
2155
- if (platform === "linux" && (env.WSL_DISTRO_NAME || env.WSL_INTEROP || isWsl())) {
2156
- candidates.push(...WSL_WINDOWS_CHROME_PATHS);
2157
- }
2158
2249
  return candidates;
2159
2250
  }
2160
2251
  function resolveChromeCommand() {
@@ -2211,10 +2302,26 @@ function assertChromeLikeVersion(command, label) {
2211
2302
  throw new Error(`${label} must point to a Chrome/Chromium-compatible browser executable: ${command}`);
2212
2303
  }
2213
2304
  }
2305
+ // Windows chrome.exe/msedge.exe do not implement a console --version: they
2306
+ // treat it as a normal launch and open a visible blank window. Execing them to
2307
+ // probe was the source of the recurring transient Edge+Chrome window pairs on
2308
+ // WSL (trap-logged live: `cmd=msedge.exe --version parent=wslhost.exe`): under
2309
+ // system load the `google-chrome --version` probe exceeded its old 3s timeout,
2310
+ // the candidate walk fell through to the /mnt/c .exe paths, and each probe
2311
+ // spawned a blank window. The .exe candidates are fixed known install paths,
2312
+ // so file existence (checked by every caller) is the validation - never exec.
2313
+ export function isWindowsBrowserExecutablePath(command) {
2314
+ return /\.exe$/i.test(command);
2315
+ }
2214
2316
  function hasChromeLikeVersion(command) {
2317
+ if (isWindowsBrowserExecutablePath(command))
2318
+ return true;
2319
+ // 10s, not 3s: a loaded machine (e.g. a parallel test suite) can stall a
2320
+ // cold `google-chrome --version` past 3s, and a false negative here used to
2321
+ // cascade into the Windows .exe candidates above.
2215
2322
  const result = spawnSync(command, ["--version"], {
2216
2323
  encoding: "utf8",
2217
- timeout: 3000,
2324
+ timeout: 10_000,
2218
2325
  maxBuffer: 1024 * 1024
2219
2326
  });
2220
2327
  const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
package/dist/cli-args.js CHANGED
@@ -136,6 +136,16 @@ export function readPositiveIntegerFlag(args, flag) {
136
136
  throw new Error(`${flag} must be a positive integer`);
137
137
  return value;
138
138
  }
139
+ export function readNonNegativeIntegerFlag(args, flag) {
140
+ // Like readPositiveIntegerFlag, but 0 is a meaningful opt-out (e.g.
141
+ // --busy-wait-ms 0 = fail fast instead of queueing behind a busy thread).
142
+ const value = readNumberFlag(args, flag);
143
+ if (value === undefined)
144
+ return undefined;
145
+ if (!Number.isInteger(value) || value < 0)
146
+ throw new Error(`${flag} must be a non-negative integer`);
147
+ return value;
148
+ }
139
149
  export function readPortFlag(args, flag) {
140
150
  const value = readNumberFlag(args, flag);
141
151
  if (value === undefined)
package/dist/cli-help.js CHANGED
@@ -25,6 +25,7 @@ Ask / consult commands:
25
25
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]
26
26
  prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of model menu options
27
27
  prodex pro browser projects [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of sidebar project names (for --project)
28
+ prodex pro browser recover [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--timeout-ms 60000] # recover a finished answer from a thread whose send timed out
28
29
  prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
29
30
  prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
30
31
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
package/dist/cli-pro.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { buildDryRunBundle } from "./bundle.js";
4
- import { DEFAULT_CDP_PORT, resolveCdpPort, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, parseProMode, parseReasoningEffort, readLastBrowserLoginLaunch, recordBrowserLoginLaunch, sendChatGptPrompt } from "./chatgpt-browser.js";
5
- import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
4
+ import { DEFAULT_CDP_PORT, resolveCdpPort, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, parseProMode, parseReasoningEffort, readLastBrowserLoginLaunch, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
5
+ import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
6
6
  import { printProBrowserHelp, printProHelp } from "./cli-help.js";
7
7
  import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
8
8
  import { formatBrowserDefaults, redactServerUrl } from "./cli-server.js";
@@ -366,7 +366,64 @@ export async function runProCommand(rest, io, runCliFn) {
366
366
  io.stdout("Use with `pro browser ask --project \"<name>\"` or pin one with `prodex setup --project \"<name>\"`.");
367
367
  return 0;
368
368
  }
369
- throw unknownSubcommandError("pro browser", browserSubcommand, ["login", "ask", "smoke", "check", "models", "projects"]);
369
+ if (browserSubcommand === "recover") {
370
+ if (printProBrowserHelpIfRequested(browserArgs, "pro browser recover", io, { valueFlags: ["--cwd", "--port", "--timeout-ms", "--target-url", "--source-cli"] }))
371
+ return 0;
372
+ assertOnlyOptions(browserArgs, "pro browser recover", ["--cwd", "--port", "--timeout-ms", "--target-url", "--source-cli"]);
373
+ const recoverCwd = resolveCwdFlag(io.cwd, browserArgs);
374
+ const recoverSourceCli = resolveOptionalFileFlag(io.cwd, browserArgs, "--source-cli");
375
+ const targetUrl = readFlag(browserArgs, "--target-url");
376
+ if (!targetUrl) {
377
+ throw new Error("pro browser recover requires --target-url <thread-url> - the ChatGPT conversation URL whose finished answer to recover (e.g. the thread from a send_timeout blocker).");
378
+ }
379
+ const recoverPort = readPortFlag(browserArgs, "--port");
380
+ const recoverTimeoutMs = readPositiveIntegerFlag(browserArgs, "--timeout-ms");
381
+ const recoverResolvedPort = resolveCdpPort(recoverPort);
382
+ let consult;
383
+ try {
384
+ consult = await recoverChatGptAnswerFromThread({ port: recoverPort, targetUrl, timeoutMs: recoverTimeoutMs });
385
+ }
386
+ catch (error) {
387
+ const blocker = sourceAwareBrowserBlocker(browserSendBlockerFromError(error), recoverSourceCli, {
388
+ ...(recoverResolvedPort !== DEFAULT_CDP_PORT ? { port: recoverResolvedPort } : {})
389
+ });
390
+ throw new Error(blocker.next_step ? `${blocker.message} Next: ${blocker.next_step}` : errorMessage(error));
391
+ }
392
+ // Record the recovered answer as a done consult so `pro latest` re-prints it.
393
+ const recoverStore = new BridgeStore(recoverCwd);
394
+ const recoveredTask = await recoverStore.createTask({
395
+ source: "codex",
396
+ title: "GPT Pro consult (recovered)",
397
+ prompt: `Recovered answer from ${consult.url}`,
398
+ repo_id: "default",
399
+ files: [],
400
+ provenance: { adapter: "chatgpt-control", thread: consult.url, warnings: [] }
401
+ });
402
+ const recoveredArtifactText = formatProConsultArtifact(consult);
403
+ let recoveredArtifactPath;
404
+ try {
405
+ recoveredArtifactPath = await recoverStore.writeArtifactText(`.bridge/artifacts/pro-consults/${recoveredTask.id}.md`, recoveredArtifactText);
406
+ }
407
+ catch (error) {
408
+ io.stderr(`answer_artifact_warning: ${errorMessage(error)}`);
409
+ }
410
+ await recoverStore.completeTask(recoveredTask.id, {
411
+ status: "done",
412
+ summary: consult.answer,
413
+ artifacts: recoveredArtifactPath
414
+ ? [{ path: recoveredArtifactPath, role: "result", bytes: Buffer.byteLength(recoveredArtifactText, "utf8") }]
415
+ : [],
416
+ commands: ["recovered ChatGPT answer from thread"],
417
+ warnings: [],
418
+ provenance: { thread: consult.url, warnings: [] }
419
+ });
420
+ io.stdout(`${recoveredTask.id}\tdone\t${consult.url}`);
421
+ io.stdout("");
422
+ io.stdout(consult.answer);
423
+ io.stderr(`recovered: answer saved to .bridge; re-print with \`prodex pro latest --cwd ${recoverCwd}\``);
424
+ return 0;
425
+ }
426
+ throw unknownSubcommandError("pro browser", browserSubcommand, ["login", "ask", "smoke", "check", "models", "projects", "recover"]);
370
427
  }
371
428
  if (subcommand === "open" || subcommand === "status" || subcommand === "smoke" || subcommand === "check" || subcommand === "doctor") {
372
429
  throw new Error(`Use \`prodex pro browser ${subcommand === "doctor" ? "check" : subcommand}\` for explicit browser automation.`);
@@ -635,7 +692,7 @@ export async function runAskProCommand(rest, io) {
635
692
  ...(selectionEffort ? { effort: selectionEffort } : {})
636
693
  };
637
694
  const browserPort = hasSendMode ? resolveCdpPort(readPortFlag(parsedAskPro.optionArgs, "--port")) : undefined;
638
- const busyWaitMs = readPositiveIntegerFlag(parsedAskPro.optionArgs, "--busy-wait-ms");
695
+ const busyWaitMs = readNonNegativeIntegerFlag(parsedAskPro.optionArgs, "--busy-wait-ms");
639
696
  // Pro extended can legitimately think for minutes, so its default timeout is
640
697
  // higher; an explicit --timeout-ms always wins.
641
698
  // Pro reasoning routinely runs for many minutes (a real consult measured
package/dist/mcp.js CHANGED
@@ -138,7 +138,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
138
138
  const browserConsult = options.browserConsult;
139
139
  if (browserConsult) {
140
140
  server.registerTool("pro_consult", {
141
- description: "Ask the user's logged-in ChatGPT (Pro) in the visible browser and wait for the full answer. This drives a real browser send: it can take minutes (Pro extended reasoning), is human-paced, and records a durable receipt under .bridge/. Requires a running `prodex pro browser login` session. By DEFAULT the consult continues in the currently-open thread, so consecutive follow-ups on the same topic stay in one conversation (keeps context, avoids sidebar clutter). Pass new_chat:true ONLY to start a fresh thread for a genuinely new topic. `project` and `model` come from saved defaults (per-repo config, or PRODEX_DEFAULT_PROJECT / PRODEX_DEFAULT_MODEL env vars) when omitted - do NOT pass them per-call unless deliberately overriding. Returns task_id, thread URL, and the answer text.",
141
+ description: "Ask the user's logged-in ChatGPT (Pro) in the visible browser and wait for the full answer. This drives a real browser send: it can take minutes (Pro extended reasoning), is human-paced, and records a durable receipt under .bridge/. Requires a running `prodex pro browser login` session. By DEFAULT the consult continues in the currently-open thread, so consecutive follow-ups on the same topic stay in one conversation (keeps context, avoids sidebar clutter). Pass new_chat:true ONLY to start a fresh thread for a genuinely new topic. If the thread is still generating a previous answer, the send automatically queues behind it (up to the timeout budget) - long 'tab busy' progress is normal, not stuck. `project` and `model` come from saved defaults (per-repo config, or PRODEX_DEFAULT_PROJECT / PRODEX_DEFAULT_MODEL env vars) when omitted - do NOT pass them per-call unless deliberately overriding. Returns task_id, thread URL, and the answer text.",
142
142
  inputSchema: {
143
143
  prompt: McpBridgeTextSchema.min(1),
144
144
  model: McpShortTextSchema.optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.16.30",
3
+ "version": "0.16.32",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",