@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.
- package/dist/chatgpt-browser.js +204 -36
- package/dist/cli-pro.js +5 -0
- package/dist/continue-thread.js +5 -3
- package/docs/releasing.md +59 -0
- package/package.json +1 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
431
|
+
return [line(rows[0]), line(cellNodes[0].map(rule)), ...rows.slice(1).map(line)].join(NL);
|
|
413
432
|
};
|
|
414
|
-
const list = (node,
|
|
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,
|
|
431
|
-
if (isBlock(child)) { if (buffer.trim()) words.push(buffer.trim()); buffer = ""; const b = block(child,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
449
|
-
|
|
450
|
-
|
|
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,
|
|
481
|
+
return blocks(node, keep);
|
|
453
482
|
};
|
|
454
|
-
const blocks = (node,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
}
|
package/dist/continue-thread.js
CHANGED
|
@@ -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
|
|
98
|
-
// "notes"
|
|
99
|
-
|
|
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).
|