@youdie006/prodex 0.40.48 → 0.40.50

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/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,
@@ -1150,7 +1150,8 @@ export function shouldRecoverThreadNavigation(args) {
1150
1150
  return !chatGptUrlsReferToSameTarget(currentUrl, pinnedThreadUrl);
1151
1151
  }
1152
1152
  export function hasFreshChatGptAnswer(previousAssistantMessageCount, state) {
1153
- return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer) && !state.generating;
1153
+ return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer) && !state.generating &&
1154
+ state.answerRenderFailed !== true;
1154
1155
  }
1155
1156
  // The acceptance phase waits for the prompt to POST (a new user/assistant
1156
1157
  // message), not for the answer. So a timeout here almost never means "slow
@@ -1193,7 +1194,8 @@ export function acceptanceTimeoutError(ctx) {
1193
1194
  });
1194
1195
  }
1195
1196
  export function hasPartialChatGptAnswer(previousAssistantMessageCount, state) {
1196
- return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer);
1197
+ return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer) &&
1198
+ state.answerRenderFailed !== true;
1197
1199
  }
1198
1200
  /**
1199
1201
  * True when the tab is on a fresh, empty ChatGPT chat: the root URL (not a
@@ -1590,6 +1592,21 @@ export function chatGptBlockerErrorFromAnswerState(state) {
1590
1592
  export function chatGptBlockerFromAnswerState(state) {
1591
1593
  return detectChatGptPageBlocker(state);
1592
1594
  }
1595
+ /**
1596
+ * The answer exists, but the page shows "This response couldn’t load" in
1597
+ * its place. Measured 2026-10-30 in the browser container, where Chromium could
1598
+ * not start threads: the conversation held the finished answer, and loading
1599
+ * the thread again drew it.
1600
+ */
1601
+ export function answerRenderFailedBlocker(thread) {
1602
+ return {
1603
+ code: "answer_render_failed",
1604
+ message: "ChatGPT answered, but the page could not display the answer (it shows \"This response couldn’t load\"). No answer was returned.",
1605
+ retryable: false,
1606
+ next_step: "Recovery loads the conversation again and reads the answer.",
1607
+ ...(thread ? { thread } : {})
1608
+ };
1609
+ }
1593
1610
  function submittedRequestBlocker(blocker, requestId, thread) {
1594
1611
  return {
1595
1612
  ...blocker,
@@ -2696,12 +2713,19 @@ export function focusPowerSliderExpression() {
2696
2713
  * only (the exact "Select ChatGPT model" control first, the older heuristic
2697
2714
  * second), and an unrendered composer is reported as not found, which the
2698
2715
  * caller's poll waits out.
2716
+ *
2717
+ * An open "Edit message" editor is a form inside the transcript, ahead of the
2718
+ * composer in the document, and has no trigger. Measured 2026-11-03: taking it
2719
+ * refused every follow-up that selected a model until the page was reloaded,
2720
+ * so editors inside a message are skipped. Only message ancestors exclude one
2721
+ * here, not aria-hidden as in the typing lookup: an open menu aria-hides the
2722
+ * composer itself.
2699
2723
  */
2700
2724
  export function modelButtonRectExpression() {
2701
2725
  return `(() => {${CLICK_POINT_SNIPPET}
2702
2726
  const composer = [...document.querySelectorAll('#prompt-textarea,[contenteditable="true"],textarea')].find((el) => {
2703
2727
  const r = el.getBoundingClientRect();
2704
- return r.width > 0 && r.height > 0;
2728
+ return r.width > 0 && r.height > 0 && !(el.parentElement && el.parentElement.closest('[data-message-author-role],[data-chatgpt-search-unit-key]'));
2705
2729
  });
2706
2730
  if (!composer) return { ok: false, reason: "model selector button not found: the composer has not rendered" };
2707
2731
  const scope = composer.closest('form');
@@ -2884,6 +2908,64 @@ export function sidebarProjectRowPointExpression(name) {
2884
2908
  return { ok: true, x: Math.round(r.x + Math.min(r.width / 3, 60)), y: Math.round(r.y + r.height / 2) };
2885
2909
  })()`;
2886
2910
  }
2911
+ /**
2912
+ * Show the projects the sidebar keeps behind "Show more".
2913
+ *
2914
+ * Measured 2026-10-30 on WSL: the sidebar listed 9 project rows (4 pinned, 5
2915
+ * under Projects) and a "Show more" button under the Projects section;
2916
+ * pressing it added 16 more rows that were not in the page at all before. A
2917
+ * --project send for any of them failed as "project not found in sidebar (9
2918
+ * projects visible)". The button has no label of its own, so it is found as
2919
+ * the ghost button of a section that lists projects, outside every row, and
2920
+ * not the section's toggle; its English text is the fallback. The sidebar
2921
+ * offers nothing to fold the list again, so it stays shown.
2922
+ */
2923
+ export function expandSidebarProjectsExpression() {
2924
+ return `(async () => {
2925
+ const projectRows = (root) => root.querySelectorAll("[data-app-action-sidebar-project-row]").length;
2926
+ const before = projectRows(document);
2927
+ const showMore = (button) =>
2928
+ !button.closest("[data-app-action-sidebar-project-row],[data-sidebar-project-container-id^='project:']") &&
2929
+ !button.hasAttribute("data-app-action-sidebar-section-toggle") &&
2930
+ !button.getAttribute("aria-label") &&
2931
+ (button.getAttribute("data-variant") === "ghost" || /^show more$/i.test((button.innerText || "").trim())) &&
2932
+ button.getClientRects().length > 0;
2933
+ const sections = [...document.querySelectorAll("section[data-app-action-sidebar-section]")].filter((section) => projectRows(section) > 0);
2934
+ const more = sections.flatMap((section) => [...section.querySelectorAll("button")]).find(showMore);
2935
+ if (!more) return { clicked: false, before, after: before };
2936
+ more.click();
2937
+ for (let waited = 0; waited < 3000 && projectRows(document) <= before; waited += 100) {
2938
+ await new Promise((resolve) => setTimeout(resolve, 100));
2939
+ }
2940
+ return { clicked: true, before, after: projectRows(document) };
2941
+ })()`;
2942
+ }
2943
+ /**
2944
+ * The sidebar row of an existing project, shown first if it is behind "Show
2945
+ * more". Polls, because right after a --new-chat navigation the sidebar's
2946
+ * Projects section has not hydrated yet (measured live: "0 projects visible"
2947
+ * on the first read, rows appear moments later) - the same class of race as
2948
+ * the model-selector button after new-chat (0.15.7).
2949
+ */
2950
+ export async function findSidebarProjectRow(cdp, project, timeoutMs = 6_000) {
2951
+ let hit = { ok: false };
2952
+ let expanded = false;
2953
+ const deadline = Date.now() + timeoutMs;
2954
+ for (;;) {
2955
+ hit = await cdp.evaluate(projectItemRectExpression(project));
2956
+ if (hit.ok && hit.x !== undefined && hit.y !== undefined)
2957
+ return hit;
2958
+ if (!expanded && /project not found in sidebar/.test(hit.reason ?? "")) {
2959
+ const shown = await cdp.evaluate(expandSidebarProjectsExpression());
2960
+ expanded = shown?.clicked === true;
2961
+ if (expanded)
2962
+ continue;
2963
+ }
2964
+ if (Date.now() >= deadline)
2965
+ return hit;
2966
+ await sleep(300);
2967
+ }
2968
+ }
2887
2969
  /** Hover a project row so its hover-only controls can be clicked. A no-op on the older sidebar. */
2888
2970
  async function revealSidebarProjectControls(cdp, name) {
2889
2971
  const point = await cdp.evaluate(sidebarProjectRowPointExpression(name));
@@ -3775,21 +3857,7 @@ async function waitForComposerProjectBinding(cdp, project, timeoutMs) {
3775
3857
  // question, not this one's.
3776
3858
  async function navigateToExistingProject(cdp, project) {
3777
3859
  const hrefBefore = await cdp.evaluate("location.href");
3778
- // Poll for the project row instead of a single check: right after a
3779
- // --new-chat navigation the sidebar's Projects section has not hydrated yet
3780
- // (measured live: "0 projects visible" on the first read, rows appear
3781
- // moments later) - the same class of race as the model-selector button
3782
- // after new-chat (0.15.7).
3783
- let hit = { ok: false };
3784
- const projectDeadline = Date.now() + 6_000;
3785
- for (;;) {
3786
- hit = await cdp.evaluate(projectItemRectExpression(project));
3787
- if (hit.ok && hit.x !== undefined && hit.y !== undefined)
3788
- break;
3789
- if (Date.now() >= projectDeadline)
3790
- break;
3791
- await sleep(300);
3792
- }
3860
+ const hit = await findSidebarProjectRow(cdp, project);
3793
3861
  if (!hit.ok || hit.x === undefined || hit.y === undefined) {
3794
3862
  const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
3795
3863
  throw new Error(`ChatGPT project not found in sidebar: ${project}${detail} List the visible names with \`prodex pro browser projects\`.`);
@@ -3981,6 +4049,8 @@ export async function recoverChatGptAnswerFromThread(options) {
3981
4049
  let lastAnswer = "";
3982
4050
  let lastObservedUrl = "";
3983
4051
  let completed = false;
4052
+ let renderFailed = false;
4053
+ let reloadedForRenderFailure = false;
3984
4054
  try {
3985
4055
  await cdp.send("Runtime.enable");
3986
4056
  // In-tab navigation (location.assign, not Page.navigate which has crashed the
@@ -4033,6 +4103,21 @@ export async function recoverChatGptAnswerFromThread(options) {
4033
4103
  thread: url
4034
4104
  });
4035
4105
  }
4106
+ // The tab may already be on the thread with the page's error in place
4107
+ // of the answer, so recovery loads the thread once more to draw it.
4108
+ if (state.answerRenderFailed && !state.generating && state.assistantMessageCount > 0) {
4109
+ renderFailed = true;
4110
+ stableRuns = 0;
4111
+ lastAnswer = "";
4112
+ if (!reloadedForRenderFailure) {
4113
+ reloadedForRenderFailure = true;
4114
+ await evaluateOnPage(page.page, markDocumentForReloadExpression());
4115
+ await navigateIdleChatGptPage(page.page, url);
4116
+ await waitForReloadedDocument(page.page, deadline);
4117
+ }
4118
+ continue;
4119
+ }
4120
+ renderFailed = false;
4036
4121
  // Require a REAL assistant message, not answerExpression's page-chrome
4037
4122
  // fallback (empty assistant returns sidebar/nav text): the thread's
4038
4123
  // conversation loads asynchronously after navigation, so keep polling.
@@ -4057,6 +4142,13 @@ export async function recoverChatGptAnswerFromThread(options) {
4057
4142
  }
4058
4143
  if (!completed) {
4059
4144
  const targetMatched = Boolean(state && chatGptUrlsReferToSameTarget(state.url, url));
4145
+ if (targetMatched && renderFailed) {
4146
+ throw new ChatGptBrowserBlockerError({
4147
+ ...answerRenderFailedBlocker(url),
4148
+ retryable: true,
4149
+ next_step: "The page still could not display the answer after loading the conversation again. Check that the browser is not short of memory or processes, then rerun recovery."
4150
+ });
4151
+ }
4060
4152
  const hasUsableAnswer = Boolean(state && state.assistantMessageCount > 0 && isUsableChatGptAnswer(state.answer));
4061
4153
  const code = !targetMatched
4062
4154
  ? "thread_target_mismatch"
@@ -4496,6 +4588,7 @@ export async function sendChatGptPrompt(options) {
4496
4588
  if (last && expandCollapsedUserTurn(last.textNode)) return false;
4497
4589
  return Boolean(last && userMessageText(last.textNode).includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
4498
4590
  })()`;
4591
+ await options.beforePromptSubmit?.();
4499
4592
  // A dispatch can reach Chrome even if its acknowledgement is lost.
4500
4593
  submissionAttempted = true;
4501
4594
  await cdp.send("Input.dispatchKeyEvent", enterKeyEvent("keyDown"));
@@ -4609,6 +4702,7 @@ export async function sendChatGptPrompt(options) {
4609
4702
  expandingPolls = 0;
4610
4703
  let nonTextPolls = 0;
4611
4704
  let nonTextSettled = false;
4705
+ let renderFailedPolls = 0;
4612
4706
  const answerIsStable = createChatGptAnswerStabilityTracker();
4613
4707
  while (Date.now() - started < timeoutMs) {
4614
4708
  await sleep(1000);
@@ -4680,6 +4774,16 @@ export async function sendChatGptPrompt(options) {
4680
4774
  continue;
4681
4775
  }
4682
4776
  nonTextPolls = 0;
4777
+ // Two reads in a row, so a failure the renderer is about to replace does
4778
+ // not end the send.
4779
+ if (finalState.answerRenderFailed && !finalState.generating && finalState.assistantMessageCount > beforeSubmit.assistantMessageCount) {
4780
+ renderFailedPolls += 1;
4781
+ if (renderFailedPolls >= 2) {
4782
+ throw attachSendWarnings(new ChatGptBrowserBlockerError(submittedRequestBlocker(answerRenderFailedBlocker(pinnedThreadUrl), requestId, pinnedThreadUrl)), sendWarnings);
4783
+ }
4784
+ continue;
4785
+ }
4786
+ renderFailedPolls = 0;
4683
4787
  if (!hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, finalState))
4684
4788
  continue;
4685
4789
  // A "fresh" answer must also be stable: ChatGPT can momentarily look done
@@ -5012,7 +5116,10 @@ export function resolveProjectToDelete(projects, request) {
5012
5116
  return { ok: true, id: matches[0].id, name: matches[0].name };
5013
5117
  }
5014
5118
  export async function listChatGptProjectsWithIds(input = {}) {
5015
- return listVisibleChatGptNavigation(input, projectsWithIdsExpression());
5119
+ return listVisibleChatGptNavigation(input, `(async () => {
5120
+ await ${expandSidebarProjectsExpression()};
5121
+ return ${projectsWithIdsExpression()};
5122
+ })()`);
5016
5123
  }
5017
5124
  /** Project deletion has no bounded visible-DOM implementation. */
5018
5125
  export async function deleteChatGptProject(_input) {
@@ -5064,6 +5171,11 @@ export async function listChatGptSidebarProjects(input = {}) {
5064
5171
  break;
5065
5172
  await sleep(300);
5066
5173
  }
5174
+ if (projects.length > 0) {
5175
+ const shown = await evaluateOnPage(page, expandSidebarProjectsExpression());
5176
+ if (shown?.clicked)
5177
+ projects = await evaluateOnPage(page, sidebarProjectNamesExpression());
5178
+ }
5067
5179
  return { url: status.url, projects };
5068
5180
  }
5069
5181
  /**
@@ -5999,9 +6111,11 @@ export function composerToolsButtonRectExpression() {
5999
6111
  return `(() => {${CLICK_POINT_SNIPPET}
6000
6112
  const legacy = document.querySelector('[data-testid="composer-plus-btn"]');
6001
6113
  if (legacy) return clickPoint(legacy);
6114
+ // An open "Edit message" editor sits in the transcript ahead of the
6115
+ // composer, in a form without this button; see modelButtonRectExpression.
6002
6116
  const composer = [...document.querySelectorAll('#prompt-textarea,[contenteditable="true"],textarea')].find((el) => {
6003
6117
  const r = el.getBoundingClientRect();
6004
- return r.width > 0 && r.height > 0;
6118
+ return r.width > 0 && r.height > 0 && !(el.parentElement && el.parentElement.closest('[data-message-author-role],[data-chatgpt-search-unit-key]'));
6005
6119
  });
6006
6120
  const form = composer && composer.closest('form');
6007
6121
  if (!form) return { ok: false, reason: "composer tools button not found: the composer has not rendered" };
@@ -6050,11 +6164,15 @@ export function composerToolEntryRectExpression(label) {
6050
6164
  /** Tool tokens currently sitting in the composer. */
6051
6165
  export function activeComposerToolsExpression(labels) {
6052
6166
  const labelsJson = JSON.stringify(labels);
6053
- return `(() => {
6167
+ return `(() => {${composerExpressionHelpers()}
6054
6168
  // The token normally lands inside the editor, but some builds render it as
6055
6169
  // a pill in the surrounding composer form - check both before deciding a
6056
6170
  // selection did not take.
6057
- const el = document.querySelector('#prompt-textarea,[contenteditable="true"]');
6171
+ // Read the composer a send types into. Measured 2026-11-03: a new chat
6172
+ // leaves the home page's composer mounted in a hidden workspace ahead of
6173
+ // the conversation's, and the first editor in the document was that one,
6174
+ // so a follow-up's tool was on but read as off.
6175
+ const el = findChatGptComposerCandidate();
6058
6176
  const form = el ? (el.closest("form") || el.parentElement) : null;
6059
6177
  const text = ((el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "")).toLowerCase();
6060
6178
  // Case-insensitively: prodex carries the label as the menu spells it
@@ -6780,6 +6898,12 @@ export function answerExpression() {
6780
6898
  return Boolean(lastUser.node.compareDocumentPosition(img) & Node.DOCUMENT_POSITION_FOLLOWING);
6781
6899
  });
6782
6900
  })(),
6901
+ // The newer answer renderer, when it cannot draw an answer, puts
6902
+ // div[role=status][data-dil-render-failed] reading "This response
6903
+ // couldn’t load" in its place (measured 2026-10-30, while ChatGPT
6904
+ // held the finished answer), and that sentence was saved as the answer.
6905
+ answerRenderFailed: Boolean(assistant && [assistant.textNode, assistant.node].some((node) =>
6906
+ node && typeof node.querySelector === "function" && node.querySelector("[data-dil-render-failed]"))),
6783
6907
  // ChatGPT tags each assistant message with the model that produced it -
6784
6908
  // the only ground truth for "did the Pro selection actually take".
6785
6909
  modelSlug: assistant ? assistant.modelSlug : undefined,
package/dist/cli-pro.js CHANGED
@@ -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";
@@ -1170,7 +1170,10 @@ export function createBrowserSendProgressPrinter(write, heartbeatMs = 10_000) {
1170
1170
  write(`progress: ${PROGRESS_PHASE_LABELS[event.phase]}${event.detail ? ` (${event.detail})` : ""}`);
1171
1171
  };
1172
1172
  }
1173
- export async function runAskProCommand(rest, io, beforeSend) {
1173
+ export async function runAskProCommand(rest, io,
1174
+ // Runs before any task is created. The step it returns runs once the prompt
1175
+ // is typed, right before it is submitted.
1176
+ beforeSend) {
1174
1177
  const parsedAskPro = parseAskProArgs(rest);
1175
1178
  const hasDryRunMode = parsedAskPro.optionArgs.includes("--dry-run");
1176
1179
  const hasSendMode = parsedAskPro.optionArgs.includes("--send");
@@ -1236,6 +1239,11 @@ export async function runAskProCommand(rest, io, beforeSend) {
1236
1239
  // --tool turns on a composer tool (deep-research, web-search,
1237
1240
  // create-image, or any label the menu shows) for this send.
1238
1241
  const tools = readRepeatedFlag(parsedAskPro.optionArgs, "--tool");
1242
+ // A tool is opened by its label, so a value that is only spaces names none:
1243
+ // the prompt went out with no tool and nothing said so.
1244
+ if (tools.some((tool) => !tool.trim())) {
1245
+ throw new Error("--tool is blank; name a composer tool (web-search, create-image), or leave the option out.");
1246
+ }
1239
1247
  if (tools.length > 0 && !hasSendMode) {
1240
1248
  throw new Error("--tool only applies when sending (`prodex pro browser ask`); the dry-run preview cannot open ChatGPT's tools menu.");
1241
1249
  }
@@ -1467,11 +1475,11 @@ export async function runAskProCommand(rest, io, beforeSend) {
1467
1475
  if (hasSendMode) {
1468
1476
  // MCP approval checkpoints run after target validation but before any
1469
1477
  // task creation, pacing, browser recovery, or prompt send.
1470
- await beforeSend?.({
1478
+ const beforePromptSubmit = (await beforeSend?.({
1471
1479
  store: targetStore,
1472
1480
  ...(normalizedTargetUrl ? { thread: normalizedTargetUrl } : {}),
1473
1481
  ...(continuedFromTaskId ? { continuedFrom: continuedFromTaskId } : {})
1474
- });
1482
+ })) || undefined;
1475
1483
  const browserCommandOptions = {
1476
1484
  cwd: targetCwd,
1477
1485
  port: parsedAskPro.optionArgs.includes("--port") ? browserPort : undefined
@@ -1550,6 +1558,7 @@ export async function runAskProCommand(rest, io, beforeSend) {
1550
1558
  proMode: selectionProMode,
1551
1559
  effort: selectionEffort,
1552
1560
  ...(allowSelectionFallback ? { allowSelectionFallback: true } : {}),
1561
+ ...(beforePromptSubmit ? { beforePromptSubmit } : {}),
1553
1562
  onProgress: createBrowserSendProgressPrinter(io.stderr)
1554
1563
  });
1555
1564
  });
@@ -1587,7 +1596,11 @@ export async function runAskProCommand(rest, io, beforeSend) {
1587
1596
  }
1588
1597
  }
1589
1598
  catch (error) {
1590
- const blocker = await withManualStepForWindowMode(sourceAwareBrowserBlocker(browserSendBlockerFromError(error), sourceCli, browserCommandOptions), resolveCdpPort(browserPort), sourceCli, browserCommandOptions);
1599
+ // Another follow-up spent the budget while this one waited for the
1600
+ // browser. Nothing was submitted; the caller asks the user instead.
1601
+ const blocker = error instanceof FollowupApprovalRequired
1602
+ ? { code: "followup_approval_required", message: error.message, retryable: false }
1603
+ : await withManualStepForWindowMode(sourceAwareBrowserBlocker(browserSendBlockerFromError(error), sourceCli, browserCommandOptions), resolveCdpPort(browserPort), sourceCli, browserCommandOptions);
1591
1604
  const message = blocker.next_step ? `${blocker.message} Next: ${blocker.next_step}` : errorMessage(error);
1592
1605
  // The blocker text can quote the requested/default project name (e.g. a
1593
1606
  // "project not found" error). Local stdout/stderr keep it (useful to the
@@ -1654,6 +1667,8 @@ export async function runAskProCommand(rest, io, beforeSend) {
1654
1667
  blocker
1655
1668
  }, null, 2));
1656
1669
  }
1670
+ if (error instanceof FollowupApprovalRequired)
1671
+ throw error;
1657
1672
  throw new Error(formatBlockedConsultRecordedMessage(message, task.id, sourceCli, { cwd: targetCwd }));
1658
1673
  }
1659
1674
  const answerArtifactText = formatProConsultArtifact(consult);
@@ -2031,12 +2046,17 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
2031
2046
  if (!thread || !continuedFrom)
2032
2047
  return;
2033
2048
  continuationTarget = { taskId: continuedFrom, thread };
2034
- followupBudget = await reserveFollowup(store, {
2035
- thread,
2036
- taskId: continuedFrom,
2037
- userApproved: input.user_approved === true,
2038
- limit
2039
- });
2049
+ const reservation = { thread, taskId: continuedFrom, userApproved: input.user_approved === true, limit };
2050
+ await assertFollowupAvailable(store, reservation);
2051
+ // Spent only when the prompt is about to be submitted, and once: a retry
2052
+ // after browser recovery is the same follow-up.
2053
+ let reserved = false;
2054
+ return async () => {
2055
+ if (reserved)
2056
+ return;
2057
+ followupBudget = await reserveFollowup(store, reservation);
2058
+ reserved = true;
2059
+ };
2040
2060
  });
2041
2061
  }
2042
2062
  catch (error) {
@@ -94,9 +94,11 @@ export function threadMatchesProject(threadUrl, project, knownProjectIds) {
94
94
  // of them slug to "". The id is the only thing that identifies those, and
95
95
  // the caller reads it off the live sidebar.
96
96
  const slug = chatGptProjectSlug(project);
97
- // The id comes first and the name follows it, so an exact suffix match keeps
98
- // "notes" from answering for "notes-archive".
99
- if (slug && projectSegment.toLowerCase().endsWith(`-${slug}`))
97
+ // The segment is "g-p-", the id, a dash and the name, so the name has to be
98
+ // all that follows the id. A suffix test let "notes" answer for "my-notes";
99
+ // a prefix test would let it answer for "notes-archive".
100
+ const named = /^g-p-[0-9a-f]+-(.+)$/i.exec(projectSegment)?.[1]?.toLowerCase();
101
+ if (slug && named === slug)
100
102
  return true;
101
103
  // No name in the URL: fall back to the id this project has been seen using.
102
104
  // Nothing is guessed - the id came from another record of the same project -
@@ -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
  : {
package/docs/clients.md CHANGED
@@ -52,12 +52,17 @@ follow-up checkpoint (integer 0-1000; default 5). For example:
52
52
  PRODEX_MAX_AUTO_FOLLOWUPS = "8"
53
53
  ```
54
54
 
55
- The initial fresh consult is not a follow-up. Reservations are counted before sends
56
- and persist across reconnects, older task references, and different session keys in
57
- the same bridge and conversation. Failed/uncertain attempts count too. At the limit,
55
+ The initial fresh consult is not a follow-up. A follow-up is reserved once its prompt
56
+ is typed, right before it is submitted, and reservations persist across reconnects,
57
+ older task references, and different session keys in the same bridge and
58
+ conversation. A send refused before that point, such as `composer_not_ready`, does
59
+ not count; an attempt that fails or is uncertain after submitting does. At the limit,
58
60
  the tool returns `status: "awaiting_user"`, `task_id: null`, the intended continuation
59
61
  arguments, and `followup_budget: {limit, used, remaining}` without creating a consult
60
- task or sending a prompt. This response-only status is not a ledger task status.
62
+ task or sending a prompt. This response-only status is not a ledger task status. If
63
+ another follow-up in the same conversation uses the last of the budget while this one
64
+ waits for the browser, this one stops before submitting with the same response, and
65
+ its consult task is recorded as blocked with `followup_approval_required`.
61
66
 
62
67
  Ask the user whether to continue. Only after an explicit user request/approval may
63
68
  the caller send that continuation with `user_approved: true`. This human-directed
package/docs/releasing.md CHANGED
@@ -16,6 +16,125 @@ Release target: `v0.40.13` on `main`. The GitHub Release records the resolved co
16
16
 
17
17
  The release adds a guarded, opt-in transition and more accurate blocker reporting. It does not claim that headless Pro consultation is verified on either deployment target or that authentication can be retained indefinitely.
18
18
 
19
+ ## 0.40.49 Release Record
20
+
21
+ Release commit `74b9c4d` on `main` (cut after PRs #79-#83, last merge `be947df`, docs `7b6a3bd`); tag `v0.40.49`, published 2026-11-01. Released on the user's go-ahead as a batch: #79 (the page's render error saved as the answer), #80 (blank `--tool`), #81 (`--continue` matching a project by the end of its name) and #83 (projects behind the sidebar's "Show more"). #82 changed a test only and has no changelog entry.
22
+
23
+ **Before the cut**
24
+ - `main` CI on `7b6a3bd` (docs only) failed on macOS Intel: `npm test` ran past the release check's 15-minute budget on a slow runner, with no failing test. On the four runs before it that step took 10.5 to 11.8 minutes. The failed job was rerun and passed.
25
+
26
+ **Publishing**
27
+ - `npm run release:verify` passed on the release commit after a build.
28
+ - `publish.yml` run 41283791768 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release, which lists the four entries from `CHANGELOG.md`.
29
+
30
+ **Installed**
31
+ - WSL, M3, Mac mini: `prodex --version` reports 0.40.49.
32
+ - The maintainer watchdog checkout was pulled to `74b9c4d` and rebuilt.
33
+ - MacBook M4 (`m4`), added to the install targets this release. It had two copies:
34
+ - `~/.local/bin/prodex`, which its Codex MCP entry runs directly, links into `~/.gemini/harness-antigravity/runtime-prodex` (an npm project, 0.40.32). It was updated there with `npm i @youdie006/prodex@0.40.49`.
35
+ - A global copy under `/opt/homebrew` (0.40.0) was updated with `npm i -g --prefix /opt/homebrew`.
36
+ - The link pointed at `dist/cli.js`, which is not executable in the new install, because the package's executable has been `prodex.mjs` since 0.40.18. Run directly, it failed with "permission denied", so Codex could not have started the MCP server again. The link now points at the runtime's `node_modules/.bin/prodex`. Through it, `--version` reports 0.40.49 and `prodex mcp` answers `initialize` as 0.40.49.
37
+ - Running services were not restarted. They keep the old code until their client starts them again:
38
+ - M4: Codex's `prodex mcp`, started before the install.
39
+ - M3: a `prodex mcp` running since 2026-09-15.
40
+ - Mac mini and WSL: none on the host. WSL's MCP entry goes through the browser container.
41
+
42
+ **Verified live on installed 0.40.49 (WSL host browser, scratch repo)**
43
+ - `pro browser projects` listed 25 projects; 0.40.48 listed 9 of the same 25.
44
+ - `pro browser smoke` returned `requestVerified: true`.
45
+
46
+ **After the release**
47
+ - PR #84 (merge `b7b5d61`, unreleased): session ids no longer carry the start of the prompt.
48
+
49
+ **Container**
50
+ - `container/redesign-patches` `f2d13e6` ports #84 on top of the ports of #79-#83 (`f93b63f`, `661b2c6`, `106bd0a`, `facd4fd`) and the 1024 process limit (`55f2f3e`). tsc, the full suite (2161 tests) and `release:verify` pass on it.
51
+ - The agent's permission classifier refused the WSL `docker compose build`, so the user ran the build-and-swap scripts. Both images were built from `f2d13e6`. Both swaps waited for the container's send lock to be free.
52
+ - M3 (colima `prodex-check`): image `4bc8fac19752`, started 07:57 UTC.
53
+ - It came up healthy with the 1024 process limit, using 146.
54
+ - `pro browser check` reported logged in with a composer.
55
+ - `pro browser smoke` answered `PRODEX_PRO_SMOKE_OK` with its request verified.
56
+ - A dry-run `pro ask "Draft the payroll memo"` (nothing sent) got the id `sess_20261101_075935_93598ff4`, with no prompt words in it.
57
+ - WSL: image `e2e3523b640a`.
58
+ - The first run built the image but did not swap, because a Pro consult from another session had held the send lock since 07:54 UTC.
59
+ - The swap waited for that consult, which finished `done`. The lock cleared at 08:06:32, and the container was recreated at 08:06:35.
60
+ - It came up healthy with the 1024 process limit, using 177, and logged no thread errors.
61
+ - `pro browser check`, `smoke` and the dry-run id check gave the same results as on M3 (id `sess_20261101_080804_33e445d8`).
62
+ - The swap dropped every MCP client of the WSL container, so each WSL Claude Code session needs `/mcp`.
63
+ - Both containers' `prodex --version` still prints 0.40.18, because the container branch never bumps its version.
64
+
65
+ **MCP clients kept connected across container swaps (2026-11-01, after the swaps)**
66
+ - Both swaps had dropped every MCP client of their container: `scripts/container-client.mjs mcp` piped the client into `docker exec` and ended with it, so each Claude Code session needed `/mcp` typed by hand.
67
+ - `container/redesign-patches` `964b847` turns that command into a relay that reconnects:
68
+ - it answers requests in flight with an error that names `pro_recover`;
69
+ - it runs `docker exec` again until the container is back, holding new requests for up to 60 seconds;
70
+ - it replays the client's handshake, tells the client its tools may have changed, and then passes the held requests on.
71
+ - Verified:
72
+ - 6 new tests; suite 2167 passed; `release:verify` ok.
73
+ - On a throwaway container from the same image, one client connection listed 20 tools, survived a restart and then a delete-and-recreate, and listed 20 tools again each time.
74
+ - WSL deployment:
75
+ - The prodex MCP entries used to run the script from `/mnt/d/myproject/gptprouse`, a checkout another task keeps on its own branch.
76
+ - Three Claude configs now run it from a checkout pinned for this purpose, `~/.local/share/prodex-maint/container-src` at `964b847`: `~/.claude.json` and the two swapdex slot configs that had the entry.
77
+ - The Codex config (`~/.codex/config.toml`) was changed the same way.
78
+ - `claude mcp get prodex` reports Connected. A handshake through it reached the WSL container (server 0.40.18, 20 tools).
79
+ - M3 deployment:
80
+ - Its Claude and Codex configs run an installed copy, `~/.local/share/prodex/container-installed-8d7a094-wP3RK2/container-client.mjs`.
81
+ - That file was replaced in place with `964b847`'s; the old one is kept beside it as `container-client.mjs.8d7a094.bak`. The configs are unchanged.
82
+ - A handshake through it reached the M3 container.
83
+ - Sessions that were open during the swaps still need one `/mcp`. After that, a container restart or swap no longer disconnects them.
84
+ - When `container-client.mjs` changes again, both copies must be updated:
85
+ - WSL: move the pinned checkout to the new commit;
86
+ - M3: replace the installed copy.
87
+
88
+ ## Container consults ran on the last model selected (2026-11-01)
89
+
90
+ **What happened**
91
+ - The user reported that some sessions' consults did not use Pro but whatever model the browser last had.
92
+ - In the WSL container's results, 20 consults since 2026-10-10 carry `model_selection_warning: no model/effort was selected`. Today's 07:54 UTC Pro consult from another session is one of them.
93
+ - Cause: a consult that names no model, with no saved or environment default, keeps the picker's current selection. That is the designed fallback, but in a shared browser the current selection is whatever another session (or an Instant smoke test) picked last.
94
+ - The container's MCP server had neither default. `container-client.mjs mcp` passed no variables into the container, so an MCP entry's env block could not reach it, and the bridge in the container had no saved config.
95
+ - The host entries on WSL that run `prodex mcp` directly set `PRODEX_DEFAULT_MODEL=Pro` and were not affected.
96
+
97
+ **Fixes**
98
+ - `container/redesign-patches` `cd3b671`: `mcp` passes the client's `PRODEX_DEFAULT_MODEL`, `_EFFORT`, `_PRO_MODE` and `_PROJECT` into the container, and adds `PRODEX_DEFAULT_MODEL=Pro` when none of them names a model or a reasoning step. A consult's own `model` or `effort` still wins.
99
+ - Verified: 3 new tests, suite 2170 passed, `release:verify` ok.
100
+ - In the container, the resolved defaults went from `null` to `{"model":"Pro"}`.
101
+ - A consult that named no model, sent through the new client, came back done.
102
+ - Deployed to the WSL pinned checkout (`~/.local/share/prodex-maint/container-src` at `cd3b671`) and to the M3 installed copy.
103
+ - The 16 WSL sessions had reconnected (17:35-17:37 KST) before `cd3b671` was in place, so their relays still run the version without the default.
104
+ - So the bridge inside each container now saves Pro: `setup --cwd /home/node/bridge --model Pro` on WSL and M3. That writes `.bridge/config.local.json` with a loopback HTTP profile; no HTTP server runs there.
105
+ - Each consult reads this default, so it applies to connected clients at once. A consult through one of the old relays, naming no model, came back done from Pro.
106
+ - Each such consult carries the warning `model_is_now_an_effort`, because the current picker has Pro as an effort step. The model default is kept because a model or effort the consult names itself overrides it. A saved effort default would be applied on top of a model the consult names.
107
+
108
+ **M4 and Mac mini (host `prodex mcp`, 2026-11-03)**
109
+ - Neither machine's Claude or Codex entry set `PRODEX_DEFAULT_MODEL`. On M4, `.bridge/results` held one consult of five with the no-model warning (`~/.gemini/harness-antigravity/verification`).
110
+ - M4:
111
+ - `~/.claude.json` now has `env: {PRODEX_DEFAULT_MODEL: Pro}`, set with `claude mcp remove` and `add-json`.
112
+ - `~/.codex/config.toml` `[mcp_servers.prodex]` now has `env = { PRODEX_DEFAULT_MODEL = "Pro" }`. A backup is kept as `config.toml.bak-prodex-default-20261103`, and `codex mcp get prodex` parses it.
113
+ - The three running `prodex mcp` processes keep their old environment until their clients restart them.
114
+ - Mac mini: the same change, made on the user's explicit request after the first attempt was refused:
115
+ - Claude `~/.claude.json` now carries `env: {PRODEX_DEFAULT_MODEL: Pro}`.
116
+ - Codex `~/.codex/config.toml` has the same `env` line, with the backup `config.toml.bak-prodex-default-20261103`. `codex mcp get prodex` shows the variable set.
117
+ - No `prodex mcp` was running there, so the next client start uses it.
118
+ - A WSL Codex session started on 2026-10-28 still runs the client from the old path until it restarts. Its config, `~/.codex/config.toml` (linked from every swapdex slot), already points at the pinned checkout.
119
+
120
+ ## Browser Incident 2026-10-30: WSL browser container out of threads
121
+
122
+ **What happened**
123
+ - A probe consult through the WSL container (`prodex-browser-browser-1`, branch build reporting 0.40.18) came back `done`, request verified, with the answer "This response couldn’t load". The conversation held the real answer ("OK"). The page had drawn its own error in its place (`div[role=status][data-dil-render-failed]`).
124
+ - Cause: the container allowed 256 processes and threads. Chromium and the service used about 171 with one ChatGPT tab, and 17 stdio MCP servers (16 Claude Code sessions on the host, about 7 threads each) took the rest. Chromium logged `pthread_create: Resource temporarily unavailable` during the send.
125
+ - A reload of that thread while diagnosing it failed the same way, and then `Zygote could not fork`. After that Chromium could start no renderer at all. The tab stayed `browser_tab_crashed`, and prodex's one same-address reload before a send crashed again within 300 ms, so every consult through the container failed.
126
+ - M3's container was checked the same day: 151 of 256 in use, with 1 MCP server and no thread errors in 72 hours.
127
+
128
+ **Recovery (maintainer approved each step)**
129
+ - `docker update --pids-limit 1024 prodex-browser-browser-1` raised the limit in place. The thread errors stopped, but the browser still could not start renderers.
130
+ - `docker restart prodex-browser-browser-1`, after the container's send lock was confirmed free. The limit stays 1024 across restarts. Health was `healthy` within seconds, `container-client.mjs status` reported `ready: true`, and a fresh-chat consult with markdown in its prompt answered "OK" with its request verified. No thread errors were logged after the restart.
131
+ - The restart dropped every client's MCP connection to the container. Each WSL Claude Code session needs `/mcp` to reconnect.
132
+
133
+ **Fixes**
134
+ - `main` PR #79 (merge `eab9340`, not yet released): a send no longer saves the page's render error as the answer, and recovery loads the thread again to draw it. It was ported to `container/redesign-patches` as `f93b63f`.
135
+ - `container/redesign-patches` `55f2f3e`: `compose.json` sets `pids_limit` to 1024. The running WSL container already has it through `docker update`. M3's container gets it on its next rebuild.
136
+ - Neither container image has been rebuilt with these commits yet.
137
+
19
138
  ## Browser Incident 2026-10-20: M3 headless browser behind Cloudflare
20
139
 
21
140
  **What happened**
@@ -35,6 +154,26 @@ The release adds a guarded, opt-in transition and more accurate blocker reportin
35
154
  **Open**
36
155
  - A long-running headless browser ends up behind Cloudflare's check (also noted on WSL, where the Xvfb display is used instead). On macOS there is no virtual display, and a minimized window counts as hidden, so the remaining no-headless option there is a window kept behind others. Changing the headless preference is a design decision that was not made here.
37
156
 
157
+ ## 0.40.48 Release Record
158
+
159
+ Release commit `406ae25` on `main` (cut after PRs #74-#78, last merge `7a88f85`); tag `v0.40.48`. Released on the user's go-ahead, promptly because of #76: on 0.40.47, with the machine under load, the page often ignored the press on the model selector and the send was blocked as "ChatGPT model menu did not open after clicking the selector" (9 of 23 sends across five runs, every one starting from a conversation; also twice the same day without added load). #74 (horizontal rules), #75 (block content inside list items), #77 (inline code holding backticks) and #78 (table column alignment), found comparing live answers with the page, went out with it.
160
+
161
+ **Publishing**
162
+ - `npm run release:verify` passed on the release commit after a build, with nothing else running on the machine.
163
+ - `publish.yml` run 40819980786 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release, which lists the five entries from `CHANGELOG.md`.
164
+
165
+ **Installed**
166
+ - WSL, M3, Mac mini: `prodex --version` reports 0.40.48. Running services were not restarted: no host stdio MCP servers on WSL, 1 on M3, none on the Mac mini.
167
+ - The maintainer watchdog checkout was pulled to `406ae25` and rebuilt.
168
+
169
+ **Container**
170
+ - `container/redesign-patches` `7f1a7b9` ports #74, `e8b970d` #75, `4f1670e` #76, `3b947c7` #77 and `e30e649` #78. That branch recognises the picker only through its trigger, so #76's test case for a stray picker beside a closed trigger is left out there. tsc, build, the full suite (2144 tests) and `release:verify` pass on `e30e649`.
171
+ - The images were not rebuilt: the WSL container still runs the `facbd9d` image (`c03be48c060d`) and the M3 container `cfaf881d2fd6`.
172
+
173
+ **Verified live on installed 0.40.48 (WSL, scratch repo, Instant)**
174
+ - One answer holding a numbered step with a code block, the inline code ``a`b``, a rule and a table aligned left and right came back as written: the code block indented under its step, ``a`b`` intact, `---`, and `| :--- | ---: |`.
175
+ - Six sends with two test suites running on the machine (load average up to 9.4) all went through; a read-only page watcher showed one of them pressing the model selector twice before its menu opened.
176
+
38
177
  ## 0.40.47 Release Record
39
178
 
40
179
  Release commit `609b1c9` on `main` (cut after PRs #71-#73, last merge `40d7eee`); tag `v0.40.47`. Released promptly because of #72: on 0.40.46 any prompt carrying a url with "&" (a query string, bare or in a markdown link) was answered and then refused as `request_mismatch`, so the answer was not returned. #71 (italic and strikethrough in the newer renderer) and #73 (the `utm_source=chatgpt.com` parameter ChatGPT appends to rendered links) went out with it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.48",
3
+ "version": "0.40.50",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",