@youdie006/prodex 0.40.49 → 0.40.51

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.
@@ -24,9 +24,10 @@ export function sendPacingMarkerPath() {
24
24
  * Serialize visible-browser sends across processes. Waits up to waitMs for a
25
25
  * live holder to finish (0 = fail fast); a lock whose holder process is dead
26
26
  * is reaped immediately. budgetFlag is the option that sets waitMs for the
27
- * command that is waiting, named when the wait runs out.
27
+ * command that is waiting, named when the wait runs out. A waiter whose signal
28
+ * aborts leaves the queue with cancelledError.
28
29
  */
29
- export async function withBrowserSendLock(waitMs, onWait, fn, budgetFlag = "--timeout-ms") {
30
+ export async function withBrowserSendLock(waitMs, onWait, fn, budgetFlag = "--timeout-ms", cancel = {}) {
30
31
  const file = lockPath();
31
32
  const reapClaim = path.join(path.dirname(file), `.${path.basename(file)}.reap`);
32
33
  return withCrossProcessFileLock(file, {
@@ -35,6 +36,8 @@ export async function withBrowserSendLock(waitMs, onWait, fn, budgetFlag = "--ti
35
36
  privateParent: true,
36
37
  onWait: (holder) => onWait(`another prodex send holds the browser (pid ${holder.pid ?? "unknown"}); waiting`),
37
38
  busyError: (holder) => new Error(`Another prodex browser send is in progress (pid ${holder.pid ?? "unknown"}) and did not finish within the wait budget. Retry once it finishes, or raise ${budgetFlag} (which is also the queue budget).`),
38
- unavailableError: () => new Error(`A prodex browser send lock at ${file} is held by nothing and could not be removed, so no send can start. Stop all prodex senders and verify no request is active before deleting that file and the abandoned reaper claim at ${reapClaim}, then retry.`)
39
+ unavailableError: () => new Error(`A prodex browser send lock at ${file} is held by nothing and could not be removed, so no send can start. Stop all prodex senders and verify no request is active before deleting that file and the abandoned reaper claim at ${reapClaim}, then retry.`),
40
+ ...(cancel.signal ? { signal: cancel.signal } : {}),
41
+ ...(cancel.cancelledError ? { abortedError: cancel.cancelledError } : {})
39
42
  }, fn);
40
43
  }
package/dist/bundle.js CHANGED
@@ -66,7 +66,9 @@ export async function buildDryRunBundle(root, input) {
66
66
  }
67
67
  return {
68
68
  schema_version: SCHEMA_VERSION,
69
- id: makeBridgeId("sess", `${randomUUID().slice(0, 8)}-${input.prompt}`),
69
+ // The id is listed wherever sessions are (MCP bridge_list_sessions among
70
+ // them), so it carries no words from the prompt.
71
+ id: makeBridgeId("sess", randomUUID().slice(0, 8)),
70
72
  mode: "manual_copy",
71
73
  prompt: input.prompt,
72
74
  files,
@@ -568,15 +568,26 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
568
568
  * and the request marker (measured 2026-10-07: 179 saved answers in one test
569
569
  * repo carried a project name). They now come only from the model selector
570
570
  * and the visible composer form, matched on word boundaries.
571
+ *
572
+ * Measured 2026-11-03, a page can hold more than the composer: a new chat keeps
573
+ * the home page's composer, with its own trigger, mounted but hidden ahead of
574
+ * the conversation's, and an "Edit message" editor is a visible form inside
575
+ * the user turn. Taking the first trigger and every visible form reported the
576
+ * hidden composer's model and the prompt being edited, so only the visible
577
+ * composer outside the messages counts, and not the text typed into it.
571
578
  */
572
579
  export const CHATGPT_MODEL_HINTS_JS = `
573
580
  const modelHintLines = () => {
574
581
  const sources = [];
575
- const trigger = document.querySelector('button[aria-label="Select ChatGPT model"],[data-testid="model-switcher-dropdown-button"]');
582
+ const shown = (el) => el.getClientRects().length > 0 && !(el.parentElement && el.parentElement.closest('[data-message-author-role],[data-chatgpt-search-unit-key]'));
583
+ const trigger = [...document.querySelectorAll('button[aria-label="Select ChatGPT model"],[data-testid="model-switcher-dropdown-button"]')].find(shown);
576
584
  // The trigger's text names the model; its aria-label only names the control.
577
585
  if (trigger) sources.push(trigger.innerText || "");
578
- for (const form of [...document.querySelectorAll("form")]) {
579
- if (form.getClientRects().length > 0) sources.push(form.innerText || "");
586
+ const editor = [...document.querySelectorAll('#prompt-textarea,[contenteditable="true"],textarea')].find(shown);
587
+ const form = editor && editor.closest("form");
588
+ if (form) {
589
+ const typed = (editor.innerText || editor.value || "").split(String.fromCharCode(10)).map((line) => line.trim());
590
+ sources.push((form.innerText || "").split(String.fromCharCode(10)).filter((line) => !typed.includes(line.trim())).join(String.fromCharCode(10)));
580
591
  }
581
592
  const modelish = /\\b(?:ChatGPT|GPT(?:-[\\w.]+)?|Pro|Plus|Thinking|Instant|Extra High|High|Medium|Auto|Latest)\\b/i;
582
593
  const seen = [];
@@ -2713,12 +2724,19 @@ export function focusPowerSliderExpression() {
2713
2724
  * only (the exact "Select ChatGPT model" control first, the older heuristic
2714
2725
  * second), and an unrendered composer is reported as not found, which the
2715
2726
  * caller's poll waits out.
2727
+ *
2728
+ * An open "Edit message" editor is a form inside the transcript, ahead of the
2729
+ * composer in the document, and has no trigger. Measured 2026-11-03: taking it
2730
+ * refused every follow-up that selected a model until the page was reloaded,
2731
+ * so editors inside a message are skipped. Only message ancestors exclude one
2732
+ * here, not aria-hidden as in the typing lookup: an open menu aria-hides the
2733
+ * composer itself.
2716
2734
  */
2717
2735
  export function modelButtonRectExpression() {
2718
2736
  return `(() => {${CLICK_POINT_SNIPPET}
2719
2737
  const composer = [...document.querySelectorAll('#prompt-textarea,[contenteditable="true"],textarea')].find((el) => {
2720
2738
  const r = el.getBoundingClientRect();
2721
- return r.width > 0 && r.height > 0;
2739
+ return r.width > 0 && r.height > 0 && !(el.parentElement && el.parentElement.closest('[data-message-author-role],[data-chatgpt-search-unit-key]'));
2722
2740
  });
2723
2741
  if (!composer) return { ok: false, reason: "model selector button not found: the composer has not rendered" };
2724
2742
  const scope = composer.closest('form');
@@ -4581,6 +4599,7 @@ export async function sendChatGptPrompt(options) {
4581
4599
  if (last && expandCollapsedUserTurn(last.textNode)) return false;
4582
4600
  return Boolean(last && userMessageText(last.textNode).includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
4583
4601
  })()`;
4602
+ await options.beforePromptSubmit?.();
4584
4603
  // A dispatch can reach Chrome even if its acknowledgement is lost.
4585
4604
  submissionAttempted = true;
4586
4605
  await cdp.send("Input.dispatchKeyEvent", enterKeyEvent("keyDown"));
@@ -6103,9 +6122,11 @@ export function composerToolsButtonRectExpression() {
6103
6122
  return `(() => {${CLICK_POINT_SNIPPET}
6104
6123
  const legacy = document.querySelector('[data-testid="composer-plus-btn"]');
6105
6124
  if (legacy) return clickPoint(legacy);
6125
+ // An open "Edit message" editor sits in the transcript ahead of the
6126
+ // composer, in a form without this button; see modelButtonRectExpression.
6106
6127
  const composer = [...document.querySelectorAll('#prompt-textarea,[contenteditable="true"],textarea')].find((el) => {
6107
6128
  const r = el.getBoundingClientRect();
6108
- return r.width > 0 && r.height > 0;
6129
+ return r.width > 0 && r.height > 0 && !(el.parentElement && el.parentElement.closest('[data-message-author-role],[data-chatgpt-search-unit-key]'));
6109
6130
  });
6110
6131
  const form = composer && composer.closest('form');
6111
6132
  if (!form) return { ok: false, reason: "composer tools button not found: the composer has not rendered" };
@@ -6154,11 +6175,15 @@ export function composerToolEntryRectExpression(label) {
6154
6175
  /** Tool tokens currently sitting in the composer. */
6155
6176
  export function activeComposerToolsExpression(labels) {
6156
6177
  const labelsJson = JSON.stringify(labels);
6157
- return `(() => {
6178
+ return `(() => {${composerExpressionHelpers()}
6158
6179
  // The token normally lands inside the editor, but some builds render it as
6159
6180
  // a pill in the surrounding composer form - check both before deciding a
6160
6181
  // selection did not take.
6161
- const el = document.querySelector('#prompt-textarea,[contenteditable="true"]');
6182
+ // Read the composer a send types into. Measured 2026-11-03: a new chat
6183
+ // leaves the home page's composer mounted in a hidden workspace ahead of
6184
+ // the conversation's, and the first editor in the document was that one,
6185
+ // so a follow-up's tool was on but read as off.
6186
+ const el = findChatGptComposerCandidate();
6162
6187
  const form = el ? (el.closest("form") || el.parentElement) : null;
6163
6188
  const text = ((el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "")).toLowerCase();
6164
6189
  // Case-insensitively: prodex carries the label as the menu spells it
package/dist/cli-args.js CHANGED
@@ -31,6 +31,33 @@ export function findHelpFlagIndexBeforePromptDelimiter(args) {
31
31
  const limit = delimiterIndex === -1 ? args.length : delimiterIndex;
32
32
  return args.findIndex((arg, index) => index < limit && isHelpSubcommand(arg));
33
33
  }
34
+ /**
35
+ * Where a help request is, or -1. In a command whose unquoted words are its
36
+ * prompt, the word "help" asks for help only on its own: found 2026-11-09,
37
+ * `prodex ask can you help me fix this` was taken for a help request and
38
+ * failed with "Unexpected argument for ask: can". --help and -h always ask.
39
+ */
40
+ export function findHelpRequestIndex(args, options = {}) {
41
+ const delimiterIndex = args.indexOf("--");
42
+ const limit = delimiterIndex === -1 ? args.length : delimiterIndex;
43
+ const valueFlagSet = new Set(options.valueFlags ?? []);
44
+ const words = [];
45
+ for (let index = 0; index < limit; index += 1) {
46
+ const arg = args[index];
47
+ if (arg === "--help" || arg === "-h")
48
+ return index;
49
+ if (valueFlagSet.has(arg)) {
50
+ index += 1;
51
+ continue;
52
+ }
53
+ if (!arg.startsWith("-"))
54
+ words.push(index);
55
+ }
56
+ const helpWord = words.find((index) => args[index] === "help");
57
+ if (helpWord === undefined)
58
+ return -1;
59
+ return !options.promptWords || words.length === 1 ? helpWord : -1;
60
+ }
34
61
  export function assertHelpRequestArgs(args, command, options) {
35
62
  const delimiterIndex = args.indexOf("--");
36
63
  const commandArgs = delimiterIndex === -1 ? args : args.slice(0, delimiterIndex);
@@ -397,7 +424,7 @@ export function readNumberFlag(args, flag) {
397
424
  }
398
425
  export const RECEIPT_KINDS = ReceiptKindSchema.options;
399
426
  export function printHelpIfRequested(args, command, stdout, printHelp, options = {}) {
400
- const helpIndex = findHelpFlagIndexBeforePromptDelimiter(args);
427
+ const helpIndex = options.promptWords ? findHelpRequestIndex(args, options) : findHelpFlagIndexBeforePromptDelimiter(args);
401
428
  if (helpIndex === -1)
402
429
  return false;
403
430
  assertHelpRequestArgs(args, command, options);
@@ -92,7 +92,7 @@ export async function runTasksCommand(rest, io) {
92
92
  status: "done",
93
93
  summary,
94
94
  commands: readRepeatedFlag(taskArgs, "--command"),
95
- artifacts: await writeTaskCompleteArtifacts(targetStore, readRepeatedFlag(taskArgs, "--artifact"))
95
+ artifacts: await writeTaskCompleteArtifacts(targetStore, taskId, readRepeatedFlag(taskArgs, "--artifact"))
96
96
  });
97
97
  io.stdout(`${result.task_id}\t${result.status}\t${result.summary}`);
98
98
  return 0;
@@ -355,7 +355,7 @@ export function receiptInspectionListSuffix(receipt) {
355
355
  }
356
356
  return "";
357
357
  }
358
- export async function writeTaskCompleteArtifacts(store, values) {
358
+ export async function writeTaskCompleteArtifacts(store, taskId, values) {
359
359
  const parsed = [];
360
360
  const seen = new Set();
361
361
  for (const value of values) {
@@ -377,12 +377,7 @@ export async function writeTaskCompleteArtifacts(store, values) {
377
377
  seen.add(key);
378
378
  parsed.push({ artifactPath, content });
379
379
  }
380
- const artifacts = [];
381
- for (const { artifactPath, content } of parsed) {
382
- const storedPath = await store.writeArtifactText(artifactPath, content);
383
- artifacts.push({ path: storedPath, role: "result" });
384
- }
385
- return artifacts;
380
+ return store.writeResultArtifactTexts(taskId, parsed.map(({ artifactPath, content }) => ({ path: artifactPath, content })));
386
381
  }
387
382
  export async function listTasksForInspection(store, status) {
388
383
  return store.listTasksReadOnly(status);
package/dist/cli-pro.js CHANGED
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { BrowserProcessInspectionError } from "./browser-process.js";
6
6
  import { buildDryRunBundle } from "./bundle.js";
7
7
  import { ChatGptBrowserBlockerError, 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, resolveBrowserWindowMode, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl, chatStepNamedAsModel, normalizeChatGptThreadUrl } from "./chatgpt-browser.js";
8
- 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, assertProjectName } from "./cli-args.js";
8
+ import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, findHelpRequestIndex, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError, assertProjectName } from "./cli-args.js";
9
9
  import { printProBrowserHelp, printProHelp } from "./cli-help.js";
10
10
  import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
11
11
  import { formatBrowserDefaults, redactServerUrl } from "./cli-server.js";
@@ -14,7 +14,7 @@ import { getTokenExpiryStatus, loadBrowserDefaults, loadLocalConfig } from "./co
14
14
  import { sendPacingMarkerPath, withBrowserSendLock } from "./browser-send-lock.js";
15
15
  import { blockerCause, buildBlockerReport, CATCH_ALL_CODES } from "./blocker-report.js";
16
16
  import { isChatGptConversationUrl, projectIdFromSidebar, resolveContinuationThread } from "./continue-thread.js";
17
- import { FollowupApprovalRequired, reserveFollowup, resolveMaxAutoFollowups } from "./followup-budget.js";
17
+ import { assertFollowupAvailable, FollowupApprovalRequired, reserveFollowup, resolveMaxAutoFollowups } from "./followup-budget.js";
18
18
  import { readBridgeRoots } from "./registry.js";
19
19
  import { ProdexRequestIdSchema, SessionKeySchema } from "./schema.js";
20
20
  import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
@@ -158,7 +158,8 @@ export async function runProCommand(rest, io, runCliFn) {
158
158
  if (subcommand === "ask") {
159
159
  if (printHelpIfRequested(proArgs, "pro ask", io.stdout, printProHelp, {
160
160
  valueFlags: [...ASK_PRO_PREVIEW_VALUE_FLAGS],
161
- booleanFlags: [...ASK_PRO_BOOLEAN_FLAGS]
161
+ booleanFlags: [...ASK_PRO_BOOLEAN_FLAGS],
162
+ promptWords: true
162
163
  })) {
163
164
  return 0;
164
165
  }
@@ -187,7 +188,9 @@ export async function runProCommand(rest, io, runCliFn) {
187
188
  "--continue-task",
188
189
  "--auto-login",
189
190
  "--no-auto-login"
190
- ].find((flag) => proArgs.includes(flag));
191
+ // Only before --: the words after it are the prompt, which may well
192
+ // mention an option (found 2026-11-09: "what does --project do").
193
+ ].find((flag) => (proArgs.includes("--") ? proArgs.slice(0, proArgs.indexOf("--")) : proArgs).includes(flag));
191
194
  if (straySendFlag) {
192
195
  throw new Error(`${straySendFlag} only applies when sending; \`prodex pro ask\` is a dry-run preview. Use \`prodex pro browser ask\` (or \`prodex ask\`) to send.`);
193
196
  }
@@ -497,7 +500,8 @@ export async function runProCommand(rest, io, runCliFn) {
497
500
  if (browserSubcommand === "ask") {
498
501
  if (printProBrowserHelpIfRequested(browserArgs, "pro browser ask", io, {
499
502
  valueFlags: [...ASK_PRO_VALUE_FLAGS],
500
- booleanFlags: [...ASK_PRO_BOOLEAN_FLAGS]
503
+ booleanFlags: [...ASK_PRO_BOOLEAN_FLAGS],
504
+ promptWords: true
501
505
  })) {
502
506
  return 0;
503
507
  }
@@ -1170,7 +1174,10 @@ export function createBrowserSendProgressPrinter(write, heartbeatMs = 10_000) {
1170
1174
  write(`progress: ${PROGRESS_PHASE_LABELS[event.phase]}${event.detail ? ` (${event.detail})` : ""}`);
1171
1175
  };
1172
1176
  }
1173
- export async function runAskProCommand(rest, io, beforeSend) {
1177
+ export async function runAskProCommand(rest, io,
1178
+ // Runs before any task is created. The step it returns runs once the prompt
1179
+ // is typed, right before it is submitted.
1180
+ beforeSend) {
1174
1181
  const parsedAskPro = parseAskProArgs(rest);
1175
1182
  const hasDryRunMode = parsedAskPro.optionArgs.includes("--dry-run");
1176
1183
  const hasSendMode = parsedAskPro.optionArgs.includes("--send");
@@ -1472,11 +1479,23 @@ export async function runAskProCommand(rest, io, beforeSend) {
1472
1479
  if (hasSendMode) {
1473
1480
  // MCP approval checkpoints run after target validation but before any
1474
1481
  // task creation, pacing, browser recovery, or prompt send.
1475
- await beforeSend?.({
1482
+ const beforeSubmitStep = (await beforeSend?.({
1476
1483
  store: targetStore,
1477
1484
  ...(normalizedTargetUrl ? { thread: normalizedTargetUrl } : {}),
1478
1485
  ...(continuedFromTaskId ? { continuedFrom: continuedFromTaskId } : {})
1479
- });
1486
+ })) || undefined;
1487
+ // A cancelled call is stopped before its prompt is submitted: here, in
1488
+ // the send queue, instead of a browser recovery, and right before the
1489
+ // submit. After the submit it is not: an answer already on its way is
1490
+ // waited for and recorded as usual. The last check runs ahead of the
1491
+ // step, so a cancelled follow-up does not spend the budget it reserves.
1492
+ throwIfConsultCancelled(io.signal);
1493
+ const beforePromptSubmit = io.signal || beforeSubmitStep
1494
+ ? async () => {
1495
+ throwIfConsultCancelled(io.signal);
1496
+ await beforeSubmitStep?.();
1497
+ }
1498
+ : undefined;
1480
1499
  const browserCommandOptions = {
1481
1500
  cwd: targetCwd,
1482
1501
  port: parsedAskPro.optionArgs.includes("--port") ? browserPort : undefined
@@ -1555,9 +1574,10 @@ export async function runAskProCommand(rest, io, beforeSend) {
1555
1574
  proMode: selectionProMode,
1556
1575
  effort: selectionEffort,
1557
1576
  ...(allowSelectionFallback ? { allowSelectionFallback: true } : {}),
1577
+ ...(beforePromptSubmit ? { beforePromptSubmit } : {}),
1558
1578
  onProgress: createBrowserSendProgressPrinter(io.stderr)
1559
1579
  });
1560
- });
1580
+ }, "--timeout-ms", { ...(io.signal ? { signal: io.signal } : {}), cancelledError: consultCancelledError });
1561
1581
  // One-command recovery: interactive terminals (or explicit --auto-login)
1562
1582
  // launch the dedicated browser and retry once when no browser runs.
1563
1583
  // Scripts and agents keep the plain blocker unless they opt in.
@@ -1576,8 +1596,9 @@ export async function runAskProCommand(rest, io, beforeSend) {
1576
1596
  catch (error) {
1577
1597
  const firstBlocker = browserSendBlockerFromError(error);
1578
1598
  // Loss while reading an already-submitted prompt must not send it again.
1599
+ // Nor is a browser launched for a call that has been cancelled.
1579
1600
  if (!["browser_unreachable", "chatgpt_page_missing"].includes(firstBlocker.code) ||
1580
- !firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed)
1601
+ !firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed || io.signal?.aborted)
1581
1602
  throw error;
1582
1603
  const recover = firstBlocker.code === "chatgpt_page_missing" ? attemptMissingChatGptTabRecovery : attemptBrowserAutoRecovery;
1583
1604
  const recovered = await recover(io.stderr, {
@@ -1592,7 +1613,11 @@ export async function runAskProCommand(rest, io, beforeSend) {
1592
1613
  }
1593
1614
  }
1594
1615
  catch (error) {
1595
- const blocker = await withManualStepForWindowMode(sourceAwareBrowserBlocker(browserSendBlockerFromError(error), sourceCli, browserCommandOptions), resolveCdpPort(browserPort), sourceCli, browserCommandOptions);
1616
+ // Another follow-up spent the budget while this one waited for the
1617
+ // browser. Nothing was submitted; the caller asks the user instead.
1618
+ const blocker = error instanceof FollowupApprovalRequired
1619
+ ? { code: "followup_approval_required", message: error.message, retryable: false }
1620
+ : await withManualStepForWindowMode(sourceAwareBrowserBlocker(browserSendBlockerFromError(error), sourceCli, browserCommandOptions), resolveCdpPort(browserPort), sourceCli, browserCommandOptions);
1596
1621
  const message = blocker.next_step ? `${blocker.message} Next: ${blocker.next_step}` : errorMessage(error);
1597
1622
  // The blocker text can quote the requested/default project name (e.g. a
1598
1623
  // "project not found" error). Local stdout/stderr keep it (useful to the
@@ -1659,6 +1684,8 @@ export async function runAskProCommand(rest, io, beforeSend) {
1659
1684
  blocker
1660
1685
  }, null, 2));
1661
1686
  }
1687
+ if (error instanceof FollowupApprovalRequired)
1688
+ throw error;
1662
1689
  throw new Error(formatBlockedConsultRecordedMessage(message, task.id, sourceCli, { cwd: targetCwd }));
1663
1690
  }
1664
1691
  const answerArtifactText = formatProConsultArtifact(consult);
@@ -1930,6 +1957,18 @@ export async function performBrowserRecoverForMcp(cwd, input) {
1930
1957
  notes: stderrLines
1931
1958
  };
1932
1959
  }
1960
+ function consultCancelledError() {
1961
+ return new ChatGptBrowserBlockerError({
1962
+ code: "consult_cancelled",
1963
+ message: "The client cancelled this consult before its prompt was submitted, so nothing was sent to ChatGPT.",
1964
+ retryable: true,
1965
+ next_step: "Nothing to recover. Send the consult again if it is still wanted."
1966
+ });
1967
+ }
1968
+ function throwIfConsultCancelled(signal) {
1969
+ if (signal?.aborted)
1970
+ throw consultCancelledError();
1971
+ }
1933
1972
  /** The marker the send prints before throwing, when the answer outlived its record. */
1934
1973
  const ANSWER_NOT_SAVED_MARKER = "consult_answer_received_but_not_saved:";
1935
1974
  /**
@@ -1983,7 +2022,7 @@ export function mcpBlockedConsultOutcome(stdoutLines, error) {
1983
2022
  ...(thread && requestId ? { recover: { tool: "pro_recover", arguments: { thread, request_id: requestId } } } : {})
1984
2023
  };
1985
2024
  }
1986
- export async function performBrowserConsultForMcp(cwd, input, onProgress) {
2025
+ export async function performBrowserConsultForMcp(cwd, input, onProgress, signal) {
1987
2026
  const stdoutLines = [];
1988
2027
  const stderrLines = [];
1989
2028
  const sessionKey = resolveProdexSessionKey(input.session_key);
@@ -2031,17 +2070,23 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
2031
2070
  if (onProgress && line.startsWith("progress:"))
2032
2071
  onProgress(line);
2033
2072
  },
2034
- allowAskProBrowserSend: true
2073
+ allowAskProBrowserSend: true,
2074
+ ...(signal ? { signal } : {})
2035
2075
  }, async ({ store, thread, continuedFrom }) => {
2036
2076
  if (!thread || !continuedFrom)
2037
2077
  return;
2038
2078
  continuationTarget = { taskId: continuedFrom, thread };
2039
- followupBudget = await reserveFollowup(store, {
2040
- thread,
2041
- taskId: continuedFrom,
2042
- userApproved: input.user_approved === true,
2043
- limit
2044
- });
2079
+ const reservation = { thread, taskId: continuedFrom, userApproved: input.user_approved === true, limit };
2080
+ await assertFollowupAvailable(store, reservation);
2081
+ // Spent only when the prompt is about to be submitted, and once: a retry
2082
+ // after browser recovery is the same follow-up.
2083
+ let reserved = false;
2084
+ return async () => {
2085
+ if (reserved)
2086
+ return;
2087
+ followupBudget = await reserveFollowup(store, reservation);
2088
+ reserved = true;
2089
+ };
2045
2090
  });
2046
2091
  }
2047
2092
  catch (error) {
@@ -3400,7 +3445,7 @@ export function printBrowserLoginGuide(stdout, input) {
3400
3445
  }
3401
3446
  }
3402
3447
  export function printProBrowserHelpIfRequested(args, command, io, options) {
3403
- const helpIndex = findHelpFlagIndexBeforePromptDelimiter(args);
3448
+ const helpIndex = options.promptWords ? findHelpRequestIndex(args, options) : findHelpFlagIndexBeforePromptDelimiter(args);
3404
3449
  if (helpIndex === -1)
3405
3450
  return false;
3406
3451
  assertHelpRequestArgs(args, command, options);
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile } from "node:child_process";
3
3
  import { realpathSync } from "node:fs";
4
+ import http from "node:http";
4
5
  import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
6
  import { tmpdir } from "node:os";
6
7
  import path from "node:path";
@@ -280,7 +281,7 @@ export async function runCli(args, io = defaultIo()) {
280
281
  // pro_consult is stdio-only: the HTTP MCP surface is exposed to ChatGPT
281
282
  // itself (and possibly a tunnel) and must never drive the user's browser.
282
283
  await runMcpServer(mcpCwd, {
283
- browserConsult: (input, onProgress) => performBrowserConsultForMcp(mcpCwd, input, onProgress),
284
+ browserConsult: (input, onProgress, signal) => performBrowserConsultForMcp(mcpCwd, input, onProgress, signal),
284
285
  browserRecover: (input) => performBrowserRecoverForMcp(mcpCwd, input)
285
286
  });
286
287
  return 0;
@@ -288,31 +289,75 @@ export async function runCli(args, io = defaultIo()) {
288
289
  throw unknownTopLevelCommandError(command);
289
290
  }
290
291
  /**
291
- * Whether something other than prodex is sitting on the configured port.
292
+ * Whether something other than this repo's prodex server is sitting on the
293
+ * configured port, which `prodex start` then cannot bind.
292
294
  *
293
- * A prodex HTTP server answers /mcp - with 401 when the token is missing,
294
- * which is still an answer. Anything else holding the port means `prodex
295
- * start` cannot bind it, and the configured URL points at a stranger.
296
- * Silence (nothing listening) is the healthy case.
295
+ * A prodex server names itself on /health, without a token. Found
296
+ * 2026-11-09: the check used to take any server answering /mcp with 200, 400
297
+ * or 401 for prodex, and a program that does not speak HTTP, or accepts the
298
+ * connection and says nothing, for a free port. Another repo's prodex server
299
+ * is the likeliest holder, since every repo's setup defaults to port 8787; it
300
+ * refuses this repo's token. A refused connection is the healthy case.
297
301
  */
298
- export async function describeConfiguredPortHolder(host, port) {
299
- let response;
300
- try {
301
- response = await fetch(`http://${host}:${port}/mcp`, {
302
- method: "POST",
303
- headers: { "Content-Type": "application/json" },
304
- body: "{}",
305
- signal: AbortSignal.timeout(1_500)
302
+ export async function describeConfiguredPortHolder(host, port, token) {
303
+ const cannotBind = "so `prodex start` cannot bind it";
304
+ const health = await probeHttpEndpoint(host, port, "/health", { method: "GET" });
305
+ if (health.kind === "free")
306
+ return undefined;
307
+ if (health.kind === "not-http")
308
+ return `port ${port} on ${host} is held by a program that does not answer HTTP, ${cannotBind}.`;
309
+ if (!isProdexHealth(health.body)) {
310
+ return `port ${port} on ${host} is held by something that is not a prodex server (it answered /health with HTTP ${health.status}), ${cannotBind}.`;
311
+ }
312
+ if (token === undefined)
313
+ return undefined;
314
+ const mcp = await probeHttpEndpoint(host, port, "/mcp", {
315
+ method: "POST",
316
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
317
+ body: "{}"
318
+ });
319
+ if (mcp.kind === "http" && mcp.status === 401) {
320
+ return `port ${port} on ${host} is held by another prodex server, one that refuses this repo's token (another repo's \`prodex start\`, or one started before this token was set), ${cannotBind} here.`;
321
+ }
322
+ return undefined;
323
+ }
324
+ function probeHttpEndpoint(host, port, requestPath, options) {
325
+ return new Promise((resolve) => {
326
+ let connected = false;
327
+ const request = http.request({ host, port, path: requestPath, method: options.method, headers: options.headers, timeout: 1_500, agent: false }, (response) => {
328
+ let body = "";
329
+ response.setEncoding("utf8");
330
+ response.on("data", (chunk) => {
331
+ if (body.length < 4_096)
332
+ body += chunk;
333
+ });
334
+ response.on("end", () => resolve({ kind: "http", status: response.statusCode ?? 0, body }));
335
+ response.on("error", () => resolve({ kind: "http", status: response.statusCode ?? 0, body }));
306
336
  });
337
+ request.on("socket", (socket) => socket.once("connect", () => (connected = true)));
338
+ // A connection accepted and then left silent is a program holding the
339
+ // port. One never accepted is not: on WSL a free loopback port does not
340
+ // refuse a connection, it lets the attempt time out.
341
+ request.on("timeout", () => {
342
+ request.destroy();
343
+ resolve({ kind: connected ? "not-http" : "free" });
344
+ });
345
+ request.on("error", (error) => {
346
+ request.destroy();
347
+ const nothingListening = !connected || ["ECONNREFUSED", "EADDRNOTAVAIL", "EHOSTUNREACH", "ENETUNREACH"].includes(error.code ?? "");
348
+ resolve({ kind: nothingListening ? "free" : "not-http" });
349
+ });
350
+ request.end(options.body);
351
+ });
352
+ }
353
+ function isProdexHealth(body) {
354
+ try {
355
+ const parsed = JSON.parse(body);
356
+ return parsed.name === "prodex";
307
357
  }
308
358
  catch {
309
- // Nothing is listening, or it refused the connection: the port is free for
310
- // prodex to take, which is what this check is about.
311
- return undefined;
359
+ return false;
312
360
  }
313
- if (response.status === 401 || response.status === 200 || response.status === 400)
314
- return undefined;
315
- return `port ${port} on ${host} is held by something that is not a prodex server (it answered /mcp with HTTP ${response.status}), so \`prodex start\` cannot bind it.`;
316
361
  }
317
362
  function defaultIo() {
318
363
  return {
@@ -1046,7 +1091,7 @@ async function runDoctor(store, io, sourceCli, setupHintCwd) {
1046
1091
  // Measured here: the configured port was held by an unrelated program,
1047
1092
  // so `prodex start` could never bind it - and doctor called the config
1048
1093
  // ok anyway, which is the one line someone reads before believing it.
1049
- const portHolder = await describeConfiguredPortHolder(config.host, config.port);
1094
+ const portHolder = await describeConfiguredPortHolder(config.host, config.port, config.token);
1050
1095
  if (portHolder) {
1051
1096
  io.stdout(`config_warning: ${portHolder} \`${formatSetupCommand(sourceCli, { cwd: setupHintCwd })} --port <free port>\` moves prodex off it.`);
1052
1097
  }
@@ -33,7 +33,24 @@ export function resolveMaxAutoFollowups(envValue = process.env.PRODEX_MAX_AUTO_F
33
33
  }
34
34
  return value;
35
35
  }
36
+ /**
37
+ * Refuse a follow-up the budget no longer covers, without spending any of it.
38
+ *
39
+ * The follow-up is reserved only once its prompt is about to be submitted.
40
+ * Measured 2026-11-02 in the browser container: two follow-ups in one
41
+ * conversation, refused as composer_not_ready before anything was typed, were
42
+ * recorded as its follow-ups 3 and 4 of 5. This check keeps an exhausted budget
43
+ * from creating a task or touching the browser at all.
44
+ */
45
+ export async function assertFollowupAvailable(store, input) {
46
+ await withFollowupBudgetLock(store, input, async (conversationKey) => {
47
+ await readConversationLedger(store, { conversationKey, ...input });
48
+ });
49
+ }
36
50
  export async function reserveFollowup(store, input) {
51
+ return withFollowupBudgetLock(store, input, async (conversationKey) => reserveFollowupUnderLock(store, { ...input, conversationKey }));
52
+ }
53
+ async function withFollowupBudgetLock(store, input, run) {
37
54
  assertFollowupLimit(input.limit);
38
55
  const conversationKey = conversationKeyFromThread(input.thread);
39
56
  await store.ensure();
@@ -44,9 +61,10 @@ export async function reserveFollowup(store, input) {
44
61
  privateParent: true,
45
62
  busyError: (holder) => new Error(`Another follow-up reservation is in progress (pid ${holder.pid ?? "unknown"})`),
46
63
  unavailableError: () => new Error("The follow-up budget lock could not be recovered. Stop all prodex processes before removing the lock and its matching .reap claim, then retry.")
47
- }, async () => reserveFollowupUnderLock(store, { ...input, conversationKey }));
64
+ }, async () => run(conversationKey));
48
65
  }
49
- async function reserveFollowupUnderLock(store, input) {
66
+ /** The conversation's ledger so far; throws when the budget does not cover another follow-up. */
67
+ async function readConversationLedger(store, input) {
50
68
  const reservations = await readTrustedReservations(store);
51
69
  const conversationReservations = reservations
52
70
  .filter((reservation) => reservation.metadata.conversation_key === input.conversationKey)
@@ -59,6 +77,10 @@ async function reserveFollowupUnderLock(store, input) {
59
77
  remaining: 0
60
78
  });
61
79
  }
80
+ return previous;
81
+ }
82
+ async function reserveFollowupUnderLock(store, input) {
83
+ const previous = await readConversationLedger(store, input);
62
84
  const budget = input.userApproved === true
63
85
  ? { limit: input.limit, used: 0, remaining: input.limit }
64
86
  : {