@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 +3 -1
- package/dist/chatgpt-browser.js +146 -22
- package/dist/cli-pro.js +31 -11
- package/dist/continue-thread.js +5 -3
- package/dist/followup-budget.js +24 -2
- package/docs/clients.md +9 -4
- package/docs/releasing.md +139 -0
- package/package.json +1 -1
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
|
|
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,
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
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) {
|
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/dist/followup-budget.js
CHANGED
|
@@ -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 () =>
|
|
64
|
+
}, async () => run(conversationKey));
|
|
48
65
|
}
|
|
49
|
-
|
|
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.
|
|
56
|
-
|
|
57
|
-
|
|
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.
|