@youdie006/prodex 0.39.0 → 0.39.2

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/dist/cli-help.js CHANGED
@@ -26,7 +26,7 @@ Ask / consult commands:
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
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
29
- prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--temporary] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Pro] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
29
+ prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--temporary] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
30
30
  prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
31
31
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
32
32
  prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
@@ -52,7 +52,7 @@ Bridge ledger (durable tasks/results/receipts/sessions under .bridge/):
52
52
 
53
53
  Agent / MCP integration:
54
54
  prodex mcp [--cwd /absolute/path/to/repo]
55
- prodex setup [--cwd /absolute/path/to/repo] [--host 127.0.0.1] [--port 8787] [--token-ttl-hours <hours>] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Pro] [--project "name"] [--clear-model|--clear-pro-mode|--clear-effort|--clear-project] [--interactive]
55
+ prodex setup [--cwd /absolute/path/to/repo] [--host 127.0.0.1] [--port 8787] [--token-ttl-hours <hours>] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name"] [--clear-model|--clear-pro-mode|--clear-effort|--clear-project] [--interactive]
56
56
  prodex start [--cwd /absolute/path/to/repo] [--source-cli /absolute/path/to/dist/cli.js]
57
57
  prodex status [--cwd /absolute/path/to/repo] [--source-cli /absolute/path/to/dist/cli.js] [--show-token] [--url-only] [--unsafe-show-non-expiring-token]
58
58
  prodex tunnel url [--cwd /absolute/path/to/repo] [--source-cli /absolute/path/to/dist/cli.js] --public-url https://... [--show-token] [--url-only]
@@ -80,14 +80,14 @@ export function printSetupHelp(stdout) {
80
80
  stdout(`prodex setup
81
81
 
82
82
  Commands:
83
- prodex setup [--cwd /absolute/path/to/repo] [--host 127.0.0.1] [--port 8787] [--token-ttl-hours <hours>] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Pro] [--project "name"] [--clear-model|--clear-pro-mode|--clear-effort|--clear-project] [--interactive]
83
+ prodex setup [--cwd /absolute/path/to/repo] [--host 127.0.0.1] [--port 8787] [--token-ttl-hours <hours>] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name"] [--clear-model|--clear-pro-mode|--clear-effort|--clear-project] [--interactive]
84
84
 
85
85
  Save a loopback-only HTTP MCP profile in .bridge/config.local.json. Use --token-ttl-hours before tunnels or ChatGPT Project use.
86
86
 
87
87
  Optional visible-browser send defaults (applied by \`pro browser ask\` when the matching per-ask flag is omitted):
88
- --model Composer model to pick by its exact menu label (verified: Pro)
88
+ --model Composer model by its exact menu label. Only Pro applies on the current picker: it is a slider step, and the model rows cannot be clicked
89
89
  --pro-mode Pro sub-mode: 기본 (standard) or 확장 (extended)
90
- --effort Reasoning effort: 즉시 / 중간 / 높음 / 매우 높음 (English aliases: instant/medium/high/max); picking one deselects Pro
90
+ --effort Reasoning effort: 즉시 / 중간 / 높음 / 매우 높음 / Max / Ultra / Pro. Max and Ultra are rungs of ChatGPT's Work surface and only apply when the browser is already on Work (prodex does not switch to Work for them); every other value is sent on Chat, whose top step is Pro
91
91
  --project Sidebar project to enter before sending
92
92
  Clear a saved default with --clear-model / --clear-pro-mode / --clear-effort / --clear-project.
93
93
  --pro-mode and --effort are different model axes and cannot be combined. View saved defaults with \`prodex status\`.`);
@@ -168,7 +168,7 @@ Commands:
168
168
  prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
169
169
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
170
170
  prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js]
171
- prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--target-url url --confirm-target] [--new-chat] [--temporary] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Pro] [--project "name" | --project-new "name"] "prompt"
171
+ prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--target-url url --confirm-target] [--new-chat] [--temporary] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt"
172
172
  prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
173
173
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
174
174
  prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
@@ -179,7 +179,7 @@ Use \`prodex pro browser ask\` only when you want an explicit visible-browser se
179
179
  Model/project selection (visible-browser send):
180
180
  --model "label" Pick the composer model by its exact menu label (verified: Pro). Submenu models (e.g. the GPT-5.6 Sol variants) are rejected for now.
181
181
  --pro-mode 기본 | 확장 Pro sub-mode (only when the model is Pro); a Pro selection raises the default timeout to 1200000 ms
182
- --effort 즉시|중간|높음|매우 높음 Reasoning effort (aliases: instant/medium/high/max); picking one deselects Pro
182
+ --effort 즉시|중간|높음|매우 높음|Max|Ultra|Pro Reasoning effort (aliases: instant/light, medium, high, extrahigh/max, ultra); picking one moves the single power slider
183
183
  --project "name" Enter an existing sidebar project first (cannot combine with --target-url)
184
184
  Labels are matched in both the Korean and English (US) UI; run \`prodex pro browser models\` to list what your account shows.
185
185
  Persist defaults with \`prodex setup --model/--pro-mode/--effort/--project\`; clear them with setup --clear-model/--clear-pro-mode/--clear-effort/--clear-project.
@@ -253,7 +253,7 @@ export function printProBrowserHelp(stdout, sourceCli) {
253
253
  const smokeUsage = sourceCli
254
254
  ? `${cli} pro browser smoke${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]`
255
255
  : "prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]";
256
- const selectionUsage = '[--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Pro] [--project "name" | --project-new "name"]';
256
+ const selectionUsage = '[--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"]';
257
257
  const askUsage = sourceCli
258
258
  ? `${cli} pro browser ask${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--temporary] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`
259
259
  : `prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--temporary] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`;
@@ -299,9 +299,9 @@ Commands:
299
299
 
300
300
  Visible-browser sends require a manual browser session and stop on login, captcha, Cloudflare, permission, rate-limit, or usage-limit blockers, plus response_choice_pending when ChatGPT is waiting for you to pick which of two answers you prefer.
301
301
  Model/project selection (ask):
302
- --model Composer model to pick by its exact menu label (verified: Pro). Models whose menu entry opens a submenu of variants are rejected with a clear error for now.
302
+ --model Composer model by its exact menu label. On the current picker only Pro applies - it is a step of the power slider, while the model rows carry pointer-events: none and no coordinate reaches them, so any other name warns and the send goes out on whatever the composer already had. Models whose menu entry opens a submenu of variants are rejected with a clear error for now.
303
303
  --pro-mode Pro sub-mode: 기본 (standard) or 확장 (extended), used when the model is Pro. A Pro selection raises the default --timeout-ms to 1200000.
304
- --effort Reasoning effort: 즉시 / 중간 / 높음 / 매우 높음 (aliases: instant/medium/high/max). Picking an effort switches the composer to the standard reasoning model, deselecting Pro.
304
+ --effort Reasoning effort: 즉시 / 중간 / 높음 / 매우 높음 / Max / Ultra / Pro (aliases: instant/light, medium, high, extrahigh/max, ultra). One power slider sets the model and the effort together, so picking an effort moves off Pro. Max and Ultra are rungs of the Work surface and only apply when the browser is already on Work; everything else is sent on Chat.
305
305
  --project Enter an existing sidebar project before sending. Cannot be combined with --target-url.
306
306
  --pro-mode and --effort cannot be combined. Labels are matched in both the Korean and English (US) ChatGPT UI (e.g. 높음/High, Pro 확장/Pro Extended).
307
307
  Run \`${cli} pro browser models${sourceCliOption}\` to list the labels your account currently shows.
package/dist/cli-pro.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, statSync } from "node:fs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { buildDryRunBundle } from "./bundle.js";
5
- import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
5
+ import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead } from "./chatgpt-browser.js";
6
6
  import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
7
7
  import { printProBrowserHelp, printProHelp } from "./cli-help.js";
8
8
  import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
@@ -429,10 +429,14 @@ export async function runProCommand(rest, io, runCliFn) {
429
429
  const modelsResolvedPort = resolveCdpPort(modelsPort);
430
430
  let listed;
431
431
  try {
432
- listed = await listChatGptModelOptions({
432
+ // Reading the ladder WALKS the slider, so it must not interleave with
433
+ // a send: two processes driving one browser is how a listing ended up
434
+ // changing the model a consult was about to use. Waits for a send in
435
+ // flight rather than barging in.
436
+ listed = await withBrowserSendLock(modelsTimeoutMs ?? 60_000, (detail) => io.stderr(`progress: ${detail}`), () => listChatGptModelOptions({
433
437
  port: modelsPort,
434
438
  timeoutMs: modelsTimeoutMs
435
- });
439
+ }));
436
440
  }
437
441
  catch (error) {
438
442
  // Keep the next step actionable for a custom port: the raw blocker
@@ -443,6 +447,9 @@ export async function runProCommand(rest, io, runCliFn) {
443
447
  });
444
448
  throw new Error(blocker.next_step ? `${blocker.message} Next: ${blocker.next_step}` : errorMessage(error));
445
449
  }
450
+ if (listed.surface) {
451
+ io.stdout(`Read from ChatGPT's ${listed.surface} surface. Chat and Work have different pickers - only one of them offers Pro.`);
452
+ }
446
453
  io.stdout("Model menu options in the visible ChatGPT tab (read-only; nothing was selected):");
447
454
  for (const option of listed.options) {
448
455
  io.stdout(formatModelMenuOption(option));
@@ -525,7 +532,12 @@ export async function runProCommand(rest, io, runCliFn) {
525
532
  const recoverResolvedPort = resolveCdpPort(recoverPort);
526
533
  let consult;
527
534
  try {
528
- consult = await recoverChatGptAnswerFromThread({ port: recoverPort, targetUrl, timeoutMs: recoverTimeoutMs });
535
+ // Recovery NAVIGATES the tab to the thread it is reading. Doing that
536
+ // under a send in flight walks the browser off the conversation that
537
+ // send is streaming into, which is the consult it would destroy - and
538
+ // recovery is exactly what gets run after a send appears to hang, so
539
+ // the two meeting is a when rather than an if.
540
+ consult = await withBrowserSendLock(recoverTimeoutMs ?? 60_000, (detail) => io.stderr(`progress: ${detail}`), () => recoverChatGptAnswerFromThread({ port: recoverPort, targetUrl, timeoutMs: recoverTimeoutMs }));
529
541
  }
530
542
  catch (error) {
531
543
  const blocker = sourceAwareBrowserBlocker(browserSendBlockerFromError(error), recoverSourceCli, {
@@ -1206,12 +1218,16 @@ export async function runAskProCommand(rest, io) {
1206
1218
  const firstBlocker = browserSendBlockerFromError(error);
1207
1219
  if (firstBlocker.code !== "browser_unreachable" || !autoLoginAllowed)
1208
1220
  throw error;
1221
+ const recoveryNotes = [];
1209
1222
  const recovered = await attemptBrowserAutoRecovery(io.stderr, {
1210
- ...(browserPort !== undefined ? { port: browserPort } : {})
1223
+ ...(browserPort !== undefined ? { port: browserPort } : {}),
1224
+ notes: recoveryNotes
1211
1225
  });
1212
1226
  if (!recovered)
1213
1227
  throw error;
1214
1228
  consult = await sendOnce();
1229
+ if (recoveryNotes.length > 0)
1230
+ consult = { ...consult, warnings: [...recoveryNotes, ...consult.warnings] };
1215
1231
  }
1216
1232
  }
1217
1233
  catch (error) {
@@ -1524,7 +1540,9 @@ async function confirmBrowserSilence(port, probes = 3, gapMs = 2_000) {
1524
1540
  if (attempt > 0)
1525
1541
  await sleep(gapMs);
1526
1542
  const status = await getChatGptBrowserStatus({ ...(port !== undefined ? { port } : {}), timeoutMs: 2_000 });
1527
- if (status.reachable)
1543
+ // Slow is not silent. Three 2s timeouts on a loaded machine used to read as
1544
+ // a dead browser, and what followed was SIGTERM to a live one mid-answer.
1545
+ if (!statusMeansBrowserDead(status))
1528
1546
  return false;
1529
1547
  }
1530
1548
  return true;
@@ -1565,6 +1583,9 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1565
1583
  return false;
1566
1584
  }
1567
1585
  stderr(`recover: the browser stopped answering; ending it (pid ${wedged.join(", ")}) and starting a fresh one...`);
1586
+ // Ending someone's browser is not a progress line to scroll past: it goes on
1587
+ // the receipt, where an agent or a person reading `pro latest` will see it.
1588
+ options.notes?.push(`browser_recovered: the dedicated browser stopped answering its control port and prodex ended it (pid ${wedged.join(", ")}) and started a fresh one before sending. Anything it was doing at the time is gone; the profile and login were kept.`);
1568
1589
  await endWedgedBrowser(wedged);
1569
1590
  // Wait for the profile lock to actually clear rather than guessing at a
1570
1591
  // delay: the replacement launch fails outright if the old process still
@@ -1738,13 +1759,35 @@ export function browserSendBlockerFromError(error) {
1738
1759
  // A CDP command timeout means the page's renderer stalled, which in the
1739
1760
  // field means a very long thread (or a long prompt landing on one). Generic
1740
1761
  // "resolve the browser issue manually" advice gave the caller nothing to do.
1762
+ // The browser lock refusing is not a browser issue: another prodex send owns
1763
+ // the window. Sending people to "resolve the visible browser issue" for it
1764
+ // pointed at a problem that did not exist.
1765
+ if (/Another prodex browser send is in progress/.test(message)) {
1766
+ return {
1767
+ code: "browser_busy",
1768
+ message,
1769
+ retryable: true,
1770
+ next_step: "Another prodex send holds the browser. Wait for it to finish and retry, or pass a longer --timeout-ms, which is also the queue budget."
1771
+ };
1772
+ }
1773
+ // The tab's DevTools connection went away mid-send with no timeout behind
1774
+ // it: the tab was closed, navigated from outside, or the browser exited.
1775
+ if (/Chrome DevTools websocket (closed|is not open)/.test(message)) {
1776
+ return {
1777
+ code: "browser_connection_lost",
1778
+ message,
1779
+ retryable: true,
1780
+ next_step: "The connection to the ChatGPT tab closed while prodex was using it - the tab was closed or navigated by someone else, or the browser exited. Check the visible window and retry; `prodex pro browser login` reopens it."
1781
+ };
1782
+ }
1741
1783
  const cdpTimeout = message.match(/Chrome DevTools command timed out: (\S+)/);
1742
1784
  if (cdpTimeout) {
1743
1785
  return {
1744
1786
  code: "browser_cdp_timeout",
1745
1787
  message,
1746
1788
  retryable: true,
1747
- next_step: "The ChatGPT tab stopped responding (usually a very long thread). Retry with `--new-chat` for a fresh, light thread, or reload the tab in the browser first."
1789
+ next_step: "The ChatGPT tab stopped responding. A very long thread does it, and so does an open JavaScript dialog - that one halts the page outright, and no retry gets past it because the browser will not let a late client dismiss it. " +
1790
+ "Look at the visible window and close any dialog sitting on it, or reopen the window with `prodex pro browser login`. For a heavy thread, retry with `--new-chat` for a fresh, light one."
1748
1791
  };
1749
1792
  }
1750
1793
  return {
@@ -63,10 +63,42 @@ export async function runSetupCommand(rest, io) {
63
63
  }
64
64
  return 0;
65
65
  }
66
+ /** A best-effort warning must not outlast the command it decorates. */
67
+ const PINNED_SELECTION_CHECK_BUDGET_MS = 15_000;
66
68
  /** What the picker currently offers, compared against what was just pinned. */
67
69
  async function pinnedSelectionCheck(defaults) {
68
70
  const { listChatGptModelOptions, pinnedSelectionWarning } = await import("./chatgpt-browser.js");
69
- const listed = await listChatGptModelOptions({ timeoutMs: 8_000 });
71
+ // Only the model names are compared here, so the ladder is not walked: this
72
+ // is a config command and it has no business moving the live slider. The
73
+ // whole check is also raced against a deadline, because timeoutMs covers
74
+ // finding the page and not what happens inside the picker afterwards - a
75
+ // best-effort warning must never be what makes `setup` hang.
76
+ // Fail fast if a send holds the browser: this is a decoration on a config
77
+ // command, and opening a menu underneath a streaming consult is not worth a
78
+ // warning. The caller already swallows what this throws.
79
+ const { withBrowserSendLock } = await import("./browser-send-lock.js");
80
+ // The deadline timer is cleared once the listing wins and never holds the
81
+ // process open: the CLI exits through process.exitCode, so a live timer kept
82
+ // `setup` alive for the whole budget after its work was done. And when the
83
+ // deadline wins, the listing is abandoned - its connection closed, the send
84
+ // lock released - rather than left running unattended behind the prompt.
85
+ let deadline;
86
+ const abandon = new AbortController();
87
+ const listed = await Promise.race([
88
+ withBrowserSendLock(0, () => { }, () => listChatGptModelOptions({ timeoutMs: 8_000, walkPowerSlider: false, signal: abandon.signal })),
89
+ new Promise((resolve) => {
90
+ deadline = setTimeout(() => {
91
+ abandon.abort();
92
+ resolve(undefined);
93
+ }, PINNED_SELECTION_CHECK_BUDGET_MS);
94
+ deadline.unref?.();
95
+ })
96
+ ]).finally(() => {
97
+ if (deadline)
98
+ clearTimeout(deadline);
99
+ });
100
+ if (!listed)
101
+ return undefined;
70
102
  const offered = listed.options.flatMap((option) => [option.label, ...(option.value ? [option.value] : [])]);
71
103
  return pinnedSelectionWarning({
72
104
  ...(defaults.model !== undefined ? { model: defaults.model } : {}),
package/dist/mcp.js CHANGED
@@ -190,7 +190,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
190
190
  const browserRecover = options.browserRecover;
191
191
  if (browserRecover) {
192
192
  server.registerTool("pro_recover", {
193
- description: "Fetch a ChatGPT answer that finished AFTER a consult stopped waiting, and record it as a normal consult receipt. Use this whenever pro_consult came back with a timeout or a still-running blocker: those carry the thread URL, and the answer is almost always sitting in that thread. This is also how a deep research report is collected - a research run takes about ten minutes and keeps going even when the consult that started it has already returned. Reading is cheap and does not send anything, so it is safe to retry.",
193
+ description: "Fetch a ChatGPT answer that finished AFTER a consult stopped waiting, and record it as a normal consult receipt. Use this whenever pro_consult came back with a timeout or a still-running blocker: those carry the thread URL, and the answer is almost always sitting in that thread. This is also how a deep research report is collected - a research run takes about ten minutes and keeps going even when the consult that started it has already returned. It sends no prompt, but it does navigate the visible tab to that thread, so it waits for any consult still streaming rather than walking the browser off it.",
194
194
  inputSchema: {
195
195
  thread: McpShortTextSchema.min(1).describe("The ChatGPT conversation URL from the blocker (its `thread` field)."),
196
196
  timeout_ms: z.number().int().positive().max(600_000).optional()
@@ -70,3 +70,115 @@ export function menuKeyboardStep(input) {
70
70
  }
71
71
  return pressed < limit ? "down" : "exhausted";
72
72
  }
73
+ /**
74
+ * One move towards putting the power slider back where the walk found it.
75
+ *
76
+ * Driven by the position actually read rather than by a replayed count, so an
77
+ * interrupted walk still converges instead of abandoning the setting wherever
78
+ * it stopped.
79
+ */
80
+ export function sliderRestoreStep(input) {
81
+ const { current, target } = input;
82
+ if (!Number.isInteger(current) || !Number.isInteger(target))
83
+ return "done";
84
+ if (current === target)
85
+ return "done";
86
+ return current > target ? "left" : "right";
87
+ }
88
+ /**
89
+ * Decide whether the browser needs putting back on ChatGPT's Chat surface.
90
+ *
91
+ * Chat and Work have different model pickers - Work's has no Pro at all - so a
92
+ * browser that has drifted onto Work silently drives the wrong one.
93
+ */
94
+ export function chatSurfaceChoice(surfaces) {
95
+ const chat = surfaces.find((surface) => surface.label.trim().toLowerCase() === "chat");
96
+ if (!chat)
97
+ return "no-toggle";
98
+ return chat.checked ? "already-chat" : "switch-to-chat";
99
+ }
100
+ /**
101
+ * Which surface is live, using the visible toggle where there is one and the
102
+ * value ChatGPT persists where there is not.
103
+ *
104
+ * The toggle is only rendered on the home screen, but threads and project pages
105
+ * keep the surface that was chosen - so a send into a project can be driving
106
+ * Work's picker with nothing on the page to say so.
107
+ */
108
+ export function chatSurfaceState(input) {
109
+ const fromToggle = chatSurfaceChoice(input.surfaces);
110
+ if (fromToggle !== "no-toggle")
111
+ return fromToggle;
112
+ const stored = (input.storedMode ?? "").trim().replace(/^"|"$/g, "").toLowerCase();
113
+ if (stored === "chat")
114
+ return "already-chat";
115
+ // Only a value that names Work is a reason to move. Anything else - "null",
116
+ // a mode this code has never heard of - is not evidence, and acting on it
117
+ // would rewrite the preference and reload the page on every send.
118
+ if (stored === "work")
119
+ return "switch-to-chat";
120
+ return "unknown";
121
+ }
122
+ /** Steps that exist only on ChatGPT's Work surface, so asking for one means staying there. */
123
+ const WORK_ONLY_EFFORTS = new Set(["max", "ultra"]);
124
+ /**
125
+ * Whether the requested effort only exists on the Work surface.
126
+ *
127
+ * Sends otherwise put the browser back on Chat, which would make these two
128
+ * impossible to apply while still being offered by the help.
129
+ */
130
+ export function effortNeedsWorkSurface(effort) {
131
+ return effort ? WORK_ONLY_EFFORTS.has(effort.trim().toLowerCase()) : false;
132
+ }
133
+ /**
134
+ * How to answer a JavaScript dialog that appeared in the driven browser.
135
+ *
136
+ * A dialog halts the page's main thread, so every evaluate after it hangs -
137
+ * and a client that had not enabled the Page domain beforehand cannot dismiss
138
+ * it at all. Answering one is therefore about getting out of the way, not about
139
+ * agreeing to whatever it asked: only beforeunload is accepted, because that is
140
+ * the page questioning a navigation prodex itself requested.
141
+ */
142
+ export function javascriptDialogResponse(type) {
143
+ return { accept: type === "beforeunload" };
144
+ }
145
+ /**
146
+ * A note that prodex answered a dialog in the driven browser.
147
+ *
148
+ * Answering one is a button pressed in someone's real session, and it is also
149
+ * the likeliest explanation for a send that behaved strangely around it, so it
150
+ * belongs in the receipt rather than in nobody's notes.
151
+ */
152
+ export function answeredDialogWarning(types) {
153
+ if (types.length === 0)
154
+ return undefined;
155
+ const counts = new Map();
156
+ for (const type of types)
157
+ counts.set(type, (counts.get(type) ?? 0) + 1);
158
+ const listed = [...counts]
159
+ .map(([type, count]) => (count > 1 ? `${type} x${count}` : type))
160
+ .join(", ");
161
+ const accepted = types.some((type) => type === "beforeunload");
162
+ const action = accepted
163
+ ? "accepted the page's leave prompt and dismissed the rest"
164
+ : "dismissed without agreeing to it";
165
+ return (`dialog_answered: a JavaScript dialog appeared in the ChatGPT window during this send (${listed}) and prodex ${action}, ` +
166
+ "because a dialog left open halts the page and nothing after it would have run.");
167
+ }
168
+ /**
169
+ * The surface a picker listing was read from: the checked toggle where one is
170
+ * drawn (the home screen), else the choice ChatGPT persists - which is what the
171
+ * app itself reads on the pages that draw no toggle. Undefined when neither
172
+ * says anything.
173
+ */
174
+ export function surfaceFromProbe(probe) {
175
+ const checked = probe?.surfaces?.find((entry) => entry.checked)?.label;
176
+ if (checked)
177
+ return checked;
178
+ const stored = (probe?.storedMode ?? "").trim().replace(/^"|"$/g, "").toLowerCase();
179
+ if (stored === "chat")
180
+ return "Chat";
181
+ if (stored === "work")
182
+ return "Work";
183
+ return undefined;
184
+ }