@youdie006/prodex 0.40.47 → 0.40.49

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.
@@ -331,6 +331,17 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
331
331
  const link = findAll(node, (n) => tagOf(n) === "a" && (attr(n, "href") || "").startsWith("http"))[0];
332
332
  return link ? "[" + label + "](" + untracked(attr(link, "href")) + ")" : "[" + label + "]";
333
333
  };
334
+ // Inline code goes in a backtick run longer than any inside it, padded
335
+ // with a space when it starts or ends with a backtick, or has a space at
336
+ // both ends that markdown would strip. One backtick each side turned the
337
+ // code a\`b into a one-letter span and a stray backtick (measured
338
+ // 2026-10-29).
339
+ const inlineCode = (value) => {
340
+ const longestRun = (value.match(new RegExp(BT + "+", "g")) || []).reduce((most, run) => Math.max(most, run.length), 0);
341
+ const ticks = BT.repeat(longestRun + 1);
342
+ const pad = value.startsWith(BT) || value.endsWith(BT) || (value.startsWith(" ") && value.endsWith(" ") && value.trim() !== "") ? " " : "";
343
+ return ticks + pad + value + pad + ticks;
344
+ };
334
345
  const inline = (node, keep) => {
335
346
  const keepLines = keepsLineBreaks(node, keep === true);
336
347
  let out = "";
@@ -351,7 +362,7 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
351
362
  if (isCheckbox(node)) return (attr(node, "aria-checked") === "true" || attr(node, "checked") !== null ? "[x]" : "[ ]") + " ";
352
363
  if (isMath(node)) return isDisplayMath(node) ? "$$" + mathSource(node) + "$$" : "$" + mathSource(node) + "$";
353
364
  if (attr(node, "data-chatgpt-copy-reference") !== null || isSourceBadge(node)) return pill(node);
354
- if (attr(node, "data-markdown-copy") === "inline-code" || tag === "code") return BT + (node.textContent || "") + BT;
365
+ if (attr(node, "data-markdown-copy") === "inline-code" || tag === "code") return inlineCode(node.textContent || "");
355
366
  if (tag === "br") return NL;
356
367
  const inner = inline(node, keep);
357
368
  // The newer renderer marks bold as span[data-d-default-strong], italic
@@ -406,16 +417,23 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
406
417
  };
407
418
  const table = (node) => {
408
419
  const cell = (value) => collapse(value).trim().split("|").join(String.fromCharCode(92) + "|");
409
- const rows = findAll(node, (n) => tagOf(n) === "tr").map((row) => children(row).filter((c) => c.nodeType === 1).map((c) => cell(inline(c))));
420
+ const cellNodes = findAll(node, (n) => tagOf(n) === "tr").map((row) => children(row).filter((c) => c.nodeType === 1));
421
+ const rows = cellNodes.map((cells) => cells.map((c) => cell(inline(c))));
410
422
  if (rows.length === 0) return "";
423
+ // The newer renderer marks a column written :---, :---: or ---: with
424
+ // data-d-align start, center or end on its cells, and a plain column
425
+ // with none (measured 2026-10-29); every column came back as ---.
426
+ const rule = (c) => {
427
+ const align = attr(c, "data-d-align");
428
+ return align === "start" ? ":---" : align === "center" ? ":---:" : align === "end" ? "---:" : "---";
429
+ };
411
430
  const line = (cells) => "| " + cells.join(" | ") + " |";
412
- return [line(rows[0]), line(rows[0].map(() => "---")), ...rows.slice(1).map(line)].join(NL);
431
+ return [line(rows[0]), line(cellNodes[0].map(rule)), ...rows.slice(1).map(line)].join(NL);
413
432
  };
414
- const list = (node, depth, keep) => {
433
+ const list = (node, keep) => {
415
434
  const ordered = tagOf(node) === "ol";
416
435
  let number = parseInt(attr(node, "start") || "1", 10);
417
436
  if (!Number.isFinite(number)) number = 1;
418
- const indent = " ".repeat(depth);
419
437
  const items = [];
420
438
  for (const item of children(node)) {
421
439
  if (item.nodeType !== 1 || tagOf(item) !== "li") continue;
@@ -427,16 +445,25 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
427
445
  const keepItemLines = keepsLineBreaks(item, keep === true);
428
446
  for (const child of children(item)) {
429
447
  const tag = child.nodeType === 1 ? tagOf(child) : "";
430
- if (tag === "ul" || tag === "ol") { nested.push(list(child, depth + 1, keepItemLines)); continue; }
431
- if (isBlock(child)) { if (buffer.trim()) words.push(buffer.trim()); buffer = ""; const b = block(child, depth + 1, keepItemLines); if (b) words.push(b); continue; }
448
+ if (tag === "ul" || tag === "ol") { nested.push(list(child, keepItemLines)); continue; }
449
+ if (isBlock(child)) { if (buffer.trim()) words.push(buffer.trim()); buffer = ""; const b = block(child, keepItemLines); if (b) words.push(b); continue; }
432
450
  buffer += inlineNode(child, keepItemLines);
433
451
  }
434
452
  if (buffer.trim()) words.push(buffer.trim());
435
- items.push(indent + marker + " " + words.join(" ") + (nested.length ? NL + nested.join(NL) : ""));
453
+ // A block spanning lines (a code block, a table) is a paragraph of its own.
454
+ const body = words.reduce((text, word) => (!text ? word : text + (text.includes(NL) || word.includes(NL) ? NL + NL : " ") + word), "");
455
+ // An item's later lines sit at its content column, after the marker
456
+ // and its space. They came back at column 0, or three spaces in for a
457
+ // nested list, so a code block, table, quote or second paragraph
458
+ // ended the item and a list under item 10 was not nested (measured
459
+ // 2026-10-29). Nested content is laid out from column 0 and shifted.
460
+ const pad = " ".repeat(marker.length + 1);
461
+ const lines = (body + (nested.length ? NL + nested.join(NL) : "")).split(NL);
462
+ items.push(marker + " " + lines.map((line, at) => (at === 0 || !line ? line : pad + line)).join(NL));
436
463
  }
437
464
  return items.join(NL);
438
465
  };
439
- const block = (node, depth, keep) => {
466
+ const block = (node, keep) => {
440
467
  if (node.nodeType === 3) return (keep === true ? collapseKeepingLines(node.textContent || "") : collapse(node.textContent || "")).trim();
441
468
  if (node.nodeType !== 1 || skipped(node)) return "";
442
469
  const tag = tagOf(node);
@@ -445,25 +472,27 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
445
472
  if (isMermaidPreview(node)) return mermaidBlock(node);
446
473
  if (tag === "table") return table(node);
447
474
  if (/^h[1-6]$/.test(tag)) return "#".repeat(Number(tag[1])) + " " + inline(node).trim();
448
- if (tag === "ul" || tag === "ol") return list(node, depth, keepsLineBreaks(node, keep === true));
449
- if (tag === "hr") return "---";
450
- if (tag === "blockquote") return blocks(node, depth, keep).split(NL).map((line) => "> " + line).join(NL);
475
+ if (tag === "ul" || tag === "ol") return list(node, keepsLineBreaks(node, keep === true));
476
+ // The newer renderer draws a rule as an empty
477
+ // div[data-d-component=divider] (measured 2026-10-29).
478
+ if (tag === "hr" || attr(node, "data-d-component") === "divider") return "---";
479
+ if (tag === "blockquote") return blocks(node, keep).split(NL).map((line) => "> " + line).join(NL);
451
480
  if (tag === "p") return inline(node, keep).trim();
452
- return blocks(node, depth, keep);
481
+ return blocks(node, keep);
453
482
  };
454
- const blocks = (node, depth, keep) => {
483
+ const blocks = (node, keep) => {
455
484
  const keepLines = keepsLineBreaks(node, keep === true);
456
485
  const parts = [];
457
486
  let buffer = "";
458
487
  const flush = () => { if (buffer.trim()) parts.push(buffer.trim()); buffer = ""; };
459
488
  for (const child of children(node)) {
460
- if (isBlock(child)) { flush(); const b = block(child, depth, keepLines); if (b) parts.push(b); continue; }
489
+ if (isBlock(child)) { flush(); const b = block(child, keepLines); if (b) parts.push(b); continue; }
461
490
  buffer += inlineNode(child, keepLines);
462
491
  }
463
492
  flush();
464
493
  return parts.join(NL + NL);
465
494
  };
466
- const markdown = blocks(root, 0).trim();
495
+ const markdown = blocks(root).trim();
467
496
  return markdown || root.innerText || "";
468
497
  };
469
498
  // A sent user turn renders inline code as <code>, dropping its backticks
@@ -1121,7 +1150,8 @@ export function shouldRecoverThreadNavigation(args) {
1121
1150
  return !chatGptUrlsReferToSameTarget(currentUrl, pinnedThreadUrl);
1122
1151
  }
1123
1152
  export function hasFreshChatGptAnswer(previousAssistantMessageCount, state) {
1124
- return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer) && !state.generating;
1153
+ return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer) && !state.generating &&
1154
+ state.answerRenderFailed !== true;
1125
1155
  }
1126
1156
  // The acceptance phase waits for the prompt to POST (a new user/assistant
1127
1157
  // message), not for the answer. So a timeout here almost never means "slow
@@ -1164,7 +1194,8 @@ export function acceptanceTimeoutError(ctx) {
1164
1194
  });
1165
1195
  }
1166
1196
  export function hasPartialChatGptAnswer(previousAssistantMessageCount, state) {
1167
- return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer);
1197
+ return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer) &&
1198
+ state.answerRenderFailed !== true;
1168
1199
  }
1169
1200
  /**
1170
1201
  * True when the tab is on a fresh, empty ChatGPT chat: the root URL (not a
@@ -1561,6 +1592,21 @@ export function chatGptBlockerErrorFromAnswerState(state) {
1561
1592
  export function chatGptBlockerFromAnswerState(state) {
1562
1593
  return detectChatGptPageBlocker(state);
1563
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
+ }
1564
1610
  function submittedRequestBlocker(blocker, requestId, thread) {
1565
1611
  return {
1566
1612
  ...blocker,
@@ -2186,6 +2232,13 @@ export function menuOpenExpression() {
2186
2232
  export function menuClosedExpression() {
2187
2233
  return `!document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])')`;
2188
2234
  }
2235
+ /** One visible model selector, marked closed, with no picker on the page. */
2236
+ export function modelSelectorReadsClosedExpression() {
2237
+ return `(() => {
2238
+ const triggers = [...document.querySelectorAll('button[aria-label="Select ChatGPT model"][aria-haspopup="menu"],[data-codex-intelligence-trigger="true"][aria-haspopup="menu"]')].filter((el) => el.getClientRects().length > 0);
2239
+ return triggers.length === 1 && triggers[0].getAttribute("aria-expanded") === "false" && !${menuOpenExpression()};
2240
+ })()`;
2241
+ }
2189
2242
  function toLabelCandidates(label) {
2190
2243
  return typeof label === "string" ? [label] : [...label];
2191
2244
  }
@@ -2258,6 +2311,28 @@ async function verifiedClickAt(cdp, x, y, label) {
2258
2311
  await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
2259
2312
  await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
2260
2313
  }
2314
+ const MODEL_MENU_FIRST_PRESS_MS = 1_500;
2315
+ /**
2316
+ * Waits for the model picker after the selector was pressed, pressing it once
2317
+ * more if the page ignored that press.
2318
+ *
2319
+ * Measured 2026-10-29 with the host under load: in two of every four sends
2320
+ * that started from a conversation, a trusted pointerdown and pointerup reached
2321
+ * the selector while it read closed and nothing happened - aria-expanded stayed
2322
+ * false and no menu was added - so the send failed as "model menu did not
2323
+ * open". A selector that still reads closed is pressed again; one that reads
2324
+ * open is left alone, since a press would shut it.
2325
+ */
2326
+ export async function waitForModelMenu(cdp) {
2327
+ if (await waitForExpressionTrue(cdp, menuOpenExpression(), MODEL_MENU_FIRST_PRESS_MS))
2328
+ return true;
2329
+ if (await cdp.evaluate(modelSelectorReadsClosedExpression())) {
2330
+ const again = await cdp.evaluate(modelButtonRectExpression());
2331
+ if (again.ok && again.x !== undefined && again.y !== undefined)
2332
+ await verifiedClickAt(cdp, again.x, again.y, "model selector");
2333
+ }
2334
+ return waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
2335
+ }
2261
2336
  /**
2262
2337
  * Whether the model picker BUTTON already advertises the requested model, so
2263
2338
  * the menu never has to be opened. ChatGPT moved the models behind a "Model"
@@ -2826,6 +2901,64 @@ export function sidebarProjectRowPointExpression(name) {
2826
2901
  return { ok: true, x: Math.round(r.x + Math.min(r.width / 3, 60)), y: Math.round(r.y + r.height / 2) };
2827
2902
  })()`;
2828
2903
  }
2904
+ /**
2905
+ * Show the projects the sidebar keeps behind "Show more".
2906
+ *
2907
+ * Measured 2026-10-30 on WSL: the sidebar listed 9 project rows (4 pinned, 5
2908
+ * under Projects) and a "Show more" button under the Projects section;
2909
+ * pressing it added 16 more rows that were not in the page at all before. A
2910
+ * --project send for any of them failed as "project not found in sidebar (9
2911
+ * projects visible)". The button has no label of its own, so it is found as
2912
+ * the ghost button of a section that lists projects, outside every row, and
2913
+ * not the section's toggle; its English text is the fallback. The sidebar
2914
+ * offers nothing to fold the list again, so it stays shown.
2915
+ */
2916
+ export function expandSidebarProjectsExpression() {
2917
+ return `(async () => {
2918
+ const projectRows = (root) => root.querySelectorAll("[data-app-action-sidebar-project-row]").length;
2919
+ const before = projectRows(document);
2920
+ const showMore = (button) =>
2921
+ !button.closest("[data-app-action-sidebar-project-row],[data-sidebar-project-container-id^='project:']") &&
2922
+ !button.hasAttribute("data-app-action-sidebar-section-toggle") &&
2923
+ !button.getAttribute("aria-label") &&
2924
+ (button.getAttribute("data-variant") === "ghost" || /^show more$/i.test((button.innerText || "").trim())) &&
2925
+ button.getClientRects().length > 0;
2926
+ const sections = [...document.querySelectorAll("section[data-app-action-sidebar-section]")].filter((section) => projectRows(section) > 0);
2927
+ const more = sections.flatMap((section) => [...section.querySelectorAll("button")]).find(showMore);
2928
+ if (!more) return { clicked: false, before, after: before };
2929
+ more.click();
2930
+ for (let waited = 0; waited < 3000 && projectRows(document) <= before; waited += 100) {
2931
+ await new Promise((resolve) => setTimeout(resolve, 100));
2932
+ }
2933
+ return { clicked: true, before, after: projectRows(document) };
2934
+ })()`;
2935
+ }
2936
+ /**
2937
+ * The sidebar row of an existing project, shown first if it is behind "Show
2938
+ * more". Polls, because right after a --new-chat navigation the sidebar's
2939
+ * Projects section has not hydrated yet (measured live: "0 projects visible"
2940
+ * on the first read, rows appear moments later) - the same class of race as
2941
+ * the model-selector button after new-chat (0.15.7).
2942
+ */
2943
+ export async function findSidebarProjectRow(cdp, project, timeoutMs = 6_000) {
2944
+ let hit = { ok: false };
2945
+ let expanded = false;
2946
+ const deadline = Date.now() + timeoutMs;
2947
+ for (;;) {
2948
+ hit = await cdp.evaluate(projectItemRectExpression(project));
2949
+ if (hit.ok && hit.x !== undefined && hit.y !== undefined)
2950
+ return hit;
2951
+ if (!expanded && /project not found in sidebar/.test(hit.reason ?? "")) {
2952
+ const shown = await cdp.evaluate(expandSidebarProjectsExpression());
2953
+ expanded = shown?.clicked === true;
2954
+ if (expanded)
2955
+ continue;
2956
+ }
2957
+ if (Date.now() >= deadline)
2958
+ return hit;
2959
+ await sleep(300);
2960
+ }
2961
+ }
2829
2962
  /** Hover a project row so its hover-only controls can be clicked. A no-op on the older sidebar. */
2830
2963
  async function revealSidebarProjectControls(cdp, name) {
2831
2964
  const point = await cdp.evaluate(sidebarProjectRowPointExpression(name));
@@ -3371,7 +3504,7 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
3371
3504
  button = fresh;
3372
3505
  }
3373
3506
  }
3374
- const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
3507
+ const opened = await waitForModelMenu(cdp);
3375
3508
  if (!opened)
3376
3509
  throw new Error("ChatGPT model menu did not open after clicking the selector");
3377
3510
  // Current ChatGPT: one power slider (Instant/Medium/High/Extra High/Pro on
@@ -3717,21 +3850,7 @@ async function waitForComposerProjectBinding(cdp, project, timeoutMs) {
3717
3850
  // question, not this one's.
3718
3851
  async function navigateToExistingProject(cdp, project) {
3719
3852
  const hrefBefore = await cdp.evaluate("location.href");
3720
- // Poll for the project row instead of a single check: right after a
3721
- // --new-chat navigation the sidebar's Projects section has not hydrated yet
3722
- // (measured live: "0 projects visible" on the first read, rows appear
3723
- // moments later) - the same class of race as the model-selector button
3724
- // after new-chat (0.15.7).
3725
- let hit = { ok: false };
3726
- const projectDeadline = Date.now() + 6_000;
3727
- for (;;) {
3728
- hit = await cdp.evaluate(projectItemRectExpression(project));
3729
- if (hit.ok && hit.x !== undefined && hit.y !== undefined)
3730
- break;
3731
- if (Date.now() >= projectDeadline)
3732
- break;
3733
- await sleep(300);
3734
- }
3853
+ const hit = await findSidebarProjectRow(cdp, project);
3735
3854
  if (!hit.ok || hit.x === undefined || hit.y === undefined) {
3736
3855
  const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
3737
3856
  throw new Error(`ChatGPT project not found in sidebar: ${project}${detail} List the visible names with \`prodex pro browser projects\`.`);
@@ -3923,6 +4042,8 @@ export async function recoverChatGptAnswerFromThread(options) {
3923
4042
  let lastAnswer = "";
3924
4043
  let lastObservedUrl = "";
3925
4044
  let completed = false;
4045
+ let renderFailed = false;
4046
+ let reloadedForRenderFailure = false;
3926
4047
  try {
3927
4048
  await cdp.send("Runtime.enable");
3928
4049
  // In-tab navigation (location.assign, not Page.navigate which has crashed the
@@ -3975,6 +4096,21 @@ export async function recoverChatGptAnswerFromThread(options) {
3975
4096
  thread: url
3976
4097
  });
3977
4098
  }
4099
+ // The tab may already be on the thread with the page's error in place
4100
+ // of the answer, so recovery loads the thread once more to draw it.
4101
+ if (state.answerRenderFailed && !state.generating && state.assistantMessageCount > 0) {
4102
+ renderFailed = true;
4103
+ stableRuns = 0;
4104
+ lastAnswer = "";
4105
+ if (!reloadedForRenderFailure) {
4106
+ reloadedForRenderFailure = true;
4107
+ await evaluateOnPage(page.page, markDocumentForReloadExpression());
4108
+ await navigateIdleChatGptPage(page.page, url);
4109
+ await waitForReloadedDocument(page.page, deadline);
4110
+ }
4111
+ continue;
4112
+ }
4113
+ renderFailed = false;
3978
4114
  // Require a REAL assistant message, not answerExpression's page-chrome
3979
4115
  // fallback (empty assistant returns sidebar/nav text): the thread's
3980
4116
  // conversation loads asynchronously after navigation, so keep polling.
@@ -3999,6 +4135,13 @@ export async function recoverChatGptAnswerFromThread(options) {
3999
4135
  }
4000
4136
  if (!completed) {
4001
4137
  const targetMatched = Boolean(state && chatGptUrlsReferToSameTarget(state.url, url));
4138
+ if (targetMatched && renderFailed) {
4139
+ throw new ChatGptBrowserBlockerError({
4140
+ ...answerRenderFailedBlocker(url),
4141
+ retryable: true,
4142
+ 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."
4143
+ });
4144
+ }
4002
4145
  const hasUsableAnswer = Boolean(state && state.assistantMessageCount > 0 && isUsableChatGptAnswer(state.answer));
4003
4146
  const code = !targetMatched
4004
4147
  ? "thread_target_mismatch"
@@ -4551,6 +4694,7 @@ export async function sendChatGptPrompt(options) {
4551
4694
  expandingPolls = 0;
4552
4695
  let nonTextPolls = 0;
4553
4696
  let nonTextSettled = false;
4697
+ let renderFailedPolls = 0;
4554
4698
  const answerIsStable = createChatGptAnswerStabilityTracker();
4555
4699
  while (Date.now() - started < timeoutMs) {
4556
4700
  await sleep(1000);
@@ -4622,6 +4766,16 @@ export async function sendChatGptPrompt(options) {
4622
4766
  continue;
4623
4767
  }
4624
4768
  nonTextPolls = 0;
4769
+ // Two reads in a row, so a failure the renderer is about to replace does
4770
+ // not end the send.
4771
+ if (finalState.answerRenderFailed && !finalState.generating && finalState.assistantMessageCount > beforeSubmit.assistantMessageCount) {
4772
+ renderFailedPolls += 1;
4773
+ if (renderFailedPolls >= 2) {
4774
+ throw attachSendWarnings(new ChatGptBrowserBlockerError(submittedRequestBlocker(answerRenderFailedBlocker(pinnedThreadUrl), requestId, pinnedThreadUrl)), sendWarnings);
4775
+ }
4776
+ continue;
4777
+ }
4778
+ renderFailedPolls = 0;
4625
4779
  if (!hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, finalState))
4626
4780
  continue;
4627
4781
  // A "fresh" answer must also be stable: ChatGPT can momentarily look done
@@ -4954,7 +5108,10 @@ export function resolveProjectToDelete(projects, request) {
4954
5108
  return { ok: true, id: matches[0].id, name: matches[0].name };
4955
5109
  }
4956
5110
  export async function listChatGptProjectsWithIds(input = {}) {
4957
- return listVisibleChatGptNavigation(input, projectsWithIdsExpression());
5111
+ return listVisibleChatGptNavigation(input, `(async () => {
5112
+ await ${expandSidebarProjectsExpression()};
5113
+ return ${projectsWithIdsExpression()};
5114
+ })()`);
4958
5115
  }
4959
5116
  /** Project deletion has no bounded visible-DOM implementation. */
4960
5117
  export async function deleteChatGptProject(_input) {
@@ -5006,6 +5163,11 @@ export async function listChatGptSidebarProjects(input = {}) {
5006
5163
  break;
5007
5164
  await sleep(300);
5008
5165
  }
5166
+ if (projects.length > 0) {
5167
+ const shown = await evaluateOnPage(page, expandSidebarProjectsExpression());
5168
+ if (shown?.clicked)
5169
+ projects = await evaluateOnPage(page, sidebarProjectNamesExpression());
5170
+ }
5009
5171
  return { url: status.url, projects };
5010
5172
  }
5011
5173
  /**
@@ -5138,7 +5300,7 @@ export async function listChatGptModelOptions(input = {}) {
5138
5300
  }
5139
5301
  try {
5140
5302
  await verifiedClickAt(cdp, button.x, button.y, "model selector");
5141
- const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
5303
+ const opened = await waitForModelMenu(cdp);
5142
5304
  if (!opened)
5143
5305
  throw new Error("ChatGPT model menu did not open after clicking the selector");
5144
5306
  const options = await cdp.evaluate(modelMenuOptionsExpression());
@@ -6722,6 +6884,12 @@ export function answerExpression() {
6722
6884
  return Boolean(lastUser.node.compareDocumentPosition(img) & Node.DOCUMENT_POSITION_FOLLOWING);
6723
6885
  });
6724
6886
  })(),
6887
+ // The newer answer renderer, when it cannot draw an answer, puts
6888
+ // div[role=status][data-dil-render-failed] reading "This response
6889
+ // couldn’t load" in its place (measured 2026-10-30, while ChatGPT
6890
+ // held the finished answer), and that sentence was saved as the answer.
6891
+ answerRenderFailed: Boolean(assistant && [assistant.textNode, assistant.node].some((node) =>
6892
+ node && typeof node.querySelector === "function" && node.querySelector("[data-dil-render-failed]"))),
6725
6893
  // ChatGPT tags each assistant message with the model that produced it -
6726
6894
  // the only ground truth for "did the Pro selection actually take".
6727
6895
  modelSlug: assistant ? assistant.modelSlug : undefined,
package/dist/cli-pro.js CHANGED
@@ -1236,6 +1236,11 @@ export async function runAskProCommand(rest, io, beforeSend) {
1236
1236
  // --tool turns on a composer tool (deep-research, web-search,
1237
1237
  // create-image, or any label the menu shows) for this send.
1238
1238
  const tools = readRepeatedFlag(parsedAskPro.optionArgs, "--tool");
1239
+ // A tool is opened by its label, so a value that is only spaces names none:
1240
+ // the prompt went out with no tool and nothing said so.
1241
+ if (tools.some((tool) => !tool.trim())) {
1242
+ throw new Error("--tool is blank; name a composer tool (web-search, create-image), or leave the option out.");
1243
+ }
1239
1244
  if (tools.length > 0 && !hasSendMode) {
1240
1245
  throw new Error("--tool only applies when sending (`prodex pro browser ask`); the dry-run preview cannot open ChatGPT's tools menu.");
1241
1246
  }
@@ -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 -
package/docs/releasing.md CHANGED
@@ -16,6 +16,24 @@ 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
+ ## Browser Incident 2026-10-30: WSL browser container out of threads
20
+
21
+ **What happened**
22
+ - 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]`).
23
+ - 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.
24
+ - 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.
25
+ - M3's container was checked the same day: 151 of 256 in use, with 1 MCP server and no thread errors in 72 hours.
26
+
27
+ **Recovery (maintainer approved each step)**
28
+ - `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.
29
+ - `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.
30
+ - The restart dropped every client's MCP connection to the container. Each WSL Claude Code session needs `/mcp` to reconnect.
31
+
32
+ **Fixes**
33
+ - `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`.
34
+ - `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.
35
+ - Neither container image has been rebuilt with these commits yet.
36
+
19
37
  ## Browser Incident 2026-10-20: M3 headless browser behind Cloudflare
20
38
 
21
39
  **What happened**
@@ -35,6 +53,47 @@ The release adds a guarded, opt-in transition and more accurate blocker reportin
35
53
  **Open**
36
54
  - 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
55
 
56
+ ## 0.40.48 Release Record
57
+
58
+ 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.
59
+
60
+ **Publishing**
61
+ - `npm run release:verify` passed on the release commit after a build, with nothing else running on the machine.
62
+ - `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`.
63
+
64
+ **Installed**
65
+ - 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.
66
+ - The maintainer watchdog checkout was pulled to `406ae25` and rebuilt.
67
+
68
+ **Container**
69
+ - `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`.
70
+ - The images were not rebuilt: the WSL container still runs the `facbd9d` image (`c03be48c060d`) and the M3 container `cfaf881d2fd6`.
71
+
72
+ **Verified live on installed 0.40.48 (WSL, scratch repo, Instant)**
73
+ - 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 `| :--- | ---: |`.
74
+ - 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.
75
+
76
+ ## 0.40.47 Release Record
77
+
78
+ 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.
79
+
80
+ **Publishing**
81
+ - `npm run release:verify` passed on the release commit after a build, with nothing else running on the machine.
82
+ - `publish.yml` run 40656237042 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release, which lists the three entries from `CHANGELOG.md`.
83
+
84
+ **Installed**
85
+ - WSL, M3, Mac mini: `prodex --version` reports 0.40.47. Running services were not restarted: no host stdio MCP servers on WSL, 1 on M3, none on the Mac mini.
86
+ - The maintainer watchdog checkout was pulled to `609b1c9` and rebuilt.
87
+
88
+ **Container**
89
+ - `container/redesign-patches` `63b060c` ports #71, `62376fa` #72 and `facbd9d` #73 (#71 and #73 did not cherry-pick cleanly and their hunks were applied by exact replacement; tsc, build, the full suite, 2128 tests, and `release:verify` pass on `facbd9d`).
90
+ - WSL, on the user's request after the release: the image was built from `facbd9d` (`c03be48c060d`) and the container swapped while its send lock was idle. The old container had been `unhealthy` (its health check's fetch failed) and its last consult, from another session at 03:41 UTC, was blocked as `browser_unreachable`; that consult was left for its owner to recover. The new container came up healthy, `pro browser check` reported logged in with a composer, and an Instant smoke whose prompt linked `https://example.com/s?q=a&page=2` returned `PRODEX_PRO_SMOKE_OK`, status done. Its `prodex --version` still prints 0.40.18 because the branch never bumps its version.
91
+ - M3, on 2026-10-29: the build over ssh was refused by the agent's permission classifier, so the user ran the build-and-swap script. The image was built from `facbd9d` in the M3 container checkout (`cfaf881d2fd6`, replacing the 0.40.42 port `59747258b693`) and the container swapped while its send lock was idle. It came up healthy, `pro browser check` reported logged in with a composer, and the same Instant smoke (prompt linking `https://example.com/s?q=a&page=2`) returned `PRODEX_PRO_SMOKE_OK`, status done.
92
+
93
+ **Verified live on installed 0.40.47 (WSL, scratch repo, Instant)**
94
+ - A send whose prompt linked `https://example.com/s?q=a&page=2` returned status done. The user turn was stored with `\&` in the autolink's href half, the shape 0.40.46 refused.
95
+ - Its answer came back as "The ~~old~~ *new* [search](https://example.com/s?q=a&page=2) page." while the page's anchor href was `https://example.com/s?q=a&page=2&utm_source=chatgpt.com`.
96
+
38
97
  ## 0.40.46 Release Record
39
98
 
40
99
  Release commit `807f245` on `main` (cut after PRs #69 and #70, last merge `9253ee9`); tag `v0.40.46`. A batch released on the user's go-ahead: terminal-column widths in the interactive picker (#69) and line breaks kept inside answers from ChatGPT's newer `white-space: pre-line` renderer (#70).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.47",
3
+ "version": "0.40.49",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",