@youdie006/prodex 0.40.48 → 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.
@@ -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,
@@ -2884,6 +2901,64 @@ export function sidebarProjectRowPointExpression(name) {
2884
2901
  return { ok: true, x: Math.round(r.x + Math.min(r.width / 3, 60)), y: Math.round(r.y + r.height / 2) };
2885
2902
  })()`;
2886
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
+ }
2887
2962
  /** Hover a project row so its hover-only controls can be clicked. A no-op on the older sidebar. */
2888
2963
  async function revealSidebarProjectControls(cdp, name) {
2889
2964
  const point = await cdp.evaluate(sidebarProjectRowPointExpression(name));
@@ -3775,21 +3850,7 @@ async function waitForComposerProjectBinding(cdp, project, timeoutMs) {
3775
3850
  // question, not this one's.
3776
3851
  async function navigateToExistingProject(cdp, project) {
3777
3852
  const hrefBefore = await cdp.evaluate("location.href");
3778
- // Poll for the project row instead of a single check: right after a
3779
- // --new-chat navigation the sidebar's Projects section has not hydrated yet
3780
- // (measured live: "0 projects visible" on the first read, rows appear
3781
- // moments later) - the same class of race as the model-selector button
3782
- // after new-chat (0.15.7).
3783
- let hit = { ok: false };
3784
- const projectDeadline = Date.now() + 6_000;
3785
- for (;;) {
3786
- hit = await cdp.evaluate(projectItemRectExpression(project));
3787
- if (hit.ok && hit.x !== undefined && hit.y !== undefined)
3788
- break;
3789
- if (Date.now() >= projectDeadline)
3790
- break;
3791
- await sleep(300);
3792
- }
3853
+ const hit = await findSidebarProjectRow(cdp, project);
3793
3854
  if (!hit.ok || hit.x === undefined || hit.y === undefined) {
3794
3855
  const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
3795
3856
  throw new Error(`ChatGPT project not found in sidebar: ${project}${detail} List the visible names with \`prodex pro browser projects\`.`);
@@ -3981,6 +4042,8 @@ export async function recoverChatGptAnswerFromThread(options) {
3981
4042
  let lastAnswer = "";
3982
4043
  let lastObservedUrl = "";
3983
4044
  let completed = false;
4045
+ let renderFailed = false;
4046
+ let reloadedForRenderFailure = false;
3984
4047
  try {
3985
4048
  await cdp.send("Runtime.enable");
3986
4049
  // In-tab navigation (location.assign, not Page.navigate which has crashed the
@@ -4033,6 +4096,21 @@ export async function recoverChatGptAnswerFromThread(options) {
4033
4096
  thread: url
4034
4097
  });
4035
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;
4036
4114
  // Require a REAL assistant message, not answerExpression's page-chrome
4037
4115
  // fallback (empty assistant returns sidebar/nav text): the thread's
4038
4116
  // conversation loads asynchronously after navigation, so keep polling.
@@ -4057,6 +4135,13 @@ export async function recoverChatGptAnswerFromThread(options) {
4057
4135
  }
4058
4136
  if (!completed) {
4059
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
+ }
4060
4145
  const hasUsableAnswer = Boolean(state && state.assistantMessageCount > 0 && isUsableChatGptAnswer(state.answer));
4061
4146
  const code = !targetMatched
4062
4147
  ? "thread_target_mismatch"
@@ -4609,6 +4694,7 @@ export async function sendChatGptPrompt(options) {
4609
4694
  expandingPolls = 0;
4610
4695
  let nonTextPolls = 0;
4611
4696
  let nonTextSettled = false;
4697
+ let renderFailedPolls = 0;
4612
4698
  const answerIsStable = createChatGptAnswerStabilityTracker();
4613
4699
  while (Date.now() - started < timeoutMs) {
4614
4700
  await sleep(1000);
@@ -4680,6 +4766,16 @@ export async function sendChatGptPrompt(options) {
4680
4766
  continue;
4681
4767
  }
4682
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;
4683
4779
  if (!hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, finalState))
4684
4780
  continue;
4685
4781
  // A "fresh" answer must also be stable: ChatGPT can momentarily look done
@@ -5012,7 +5108,10 @@ export function resolveProjectToDelete(projects, request) {
5012
5108
  return { ok: true, id: matches[0].id, name: matches[0].name };
5013
5109
  }
5014
5110
  export async function listChatGptProjectsWithIds(input = {}) {
5015
- return listVisibleChatGptNavigation(input, projectsWithIdsExpression());
5111
+ return listVisibleChatGptNavigation(input, `(async () => {
5112
+ await ${expandSidebarProjectsExpression()};
5113
+ return ${projectsWithIdsExpression()};
5114
+ })()`);
5016
5115
  }
5017
5116
  /** Project deletion has no bounded visible-DOM implementation. */
5018
5117
  export async function deleteChatGptProject(_input) {
@@ -5064,6 +5163,11 @@ export async function listChatGptSidebarProjects(input = {}) {
5064
5163
  break;
5065
5164
  await sleep(300);
5066
5165
  }
5166
+ if (projects.length > 0) {
5167
+ const shown = await evaluateOnPage(page, expandSidebarProjectsExpression());
5168
+ if (shown?.clicked)
5169
+ projects = await evaluateOnPage(page, sidebarProjectNamesExpression());
5170
+ }
5067
5171
  return { url: status.url, projects };
5068
5172
  }
5069
5173
  /**
@@ -6780,6 +6884,12 @@ export function answerExpression() {
6780
6884
  return Boolean(lastUser.node.compareDocumentPosition(img) & Node.DOCUMENT_POSITION_FOLLOWING);
6781
6885
  });
6782
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]"))),
6783
6893
  // ChatGPT tags each assistant message with the model that produced it -
6784
6894
  // the only ground truth for "did the Pro selection actually take".
6785
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,26 @@ 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
+
38
76
  ## 0.40.47 Release Record
39
77
 
40
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.48",
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",