@youdie006/prodex 0.40.47 → 0.40.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -331,6 +331,17 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
331
331
  const link = findAll(node, (n) => tagOf(n) === "a" && (attr(n, "href") || "").startsWith("http"))[0];
332
332
  return link ? "[" + label + "](" + untracked(attr(link, "href")) + ")" : "[" + label + "]";
333
333
  };
334
+ // Inline code goes in a backtick run longer than any inside it, padded
335
+ // with a space when it starts or ends with a backtick, or has a space at
336
+ // both ends that markdown would strip. One backtick each side turned the
337
+ // code a\`b into a one-letter span and a stray backtick (measured
338
+ // 2026-10-29).
339
+ const inlineCode = (value) => {
340
+ const longestRun = (value.match(new RegExp(BT + "+", "g")) || []).reduce((most, run) => Math.max(most, run.length), 0);
341
+ const ticks = BT.repeat(longestRun + 1);
342
+ const pad = value.startsWith(BT) || value.endsWith(BT) || (value.startsWith(" ") && value.endsWith(" ") && value.trim() !== "") ? " " : "";
343
+ return ticks + pad + value + pad + ticks;
344
+ };
334
345
  const inline = (node, keep) => {
335
346
  const keepLines = keepsLineBreaks(node, keep === true);
336
347
  let out = "";
@@ -351,7 +362,7 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
351
362
  if (isCheckbox(node)) return (attr(node, "aria-checked") === "true" || attr(node, "checked") !== null ? "[x]" : "[ ]") + " ";
352
363
  if (isMath(node)) return isDisplayMath(node) ? "$$" + mathSource(node) + "$$" : "$" + mathSource(node) + "$";
353
364
  if (attr(node, "data-chatgpt-copy-reference") !== null || isSourceBadge(node)) return pill(node);
354
- if (attr(node, "data-markdown-copy") === "inline-code" || tag === "code") return BT + (node.textContent || "") + BT;
365
+ if (attr(node, "data-markdown-copy") === "inline-code" || tag === "code") return inlineCode(node.textContent || "");
355
366
  if (tag === "br") return NL;
356
367
  const inner = inline(node, keep);
357
368
  // The newer renderer marks bold as span[data-d-default-strong], italic
@@ -406,16 +417,23 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
406
417
  };
407
418
  const table = (node) => {
408
419
  const cell = (value) => collapse(value).trim().split("|").join(String.fromCharCode(92) + "|");
409
- const rows = findAll(node, (n) => tagOf(n) === "tr").map((row) => children(row).filter((c) => c.nodeType === 1).map((c) => cell(inline(c))));
420
+ const cellNodes = findAll(node, (n) => tagOf(n) === "tr").map((row) => children(row).filter((c) => c.nodeType === 1));
421
+ const rows = cellNodes.map((cells) => cells.map((c) => cell(inline(c))));
410
422
  if (rows.length === 0) return "";
423
+ // The newer renderer marks a column written :---, :---: or ---: with
424
+ // data-d-align start, center or end on its cells, and a plain column
425
+ // with none (measured 2026-10-29); every column came back as ---.
426
+ const rule = (c) => {
427
+ const align = attr(c, "data-d-align");
428
+ return align === "start" ? ":---" : align === "center" ? ":---:" : align === "end" ? "---:" : "---";
429
+ };
411
430
  const line = (cells) => "| " + cells.join(" | ") + " |";
412
- return [line(rows[0]), line(rows[0].map(() => "---")), ...rows.slice(1).map(line)].join(NL);
431
+ return [line(rows[0]), line(cellNodes[0].map(rule)), ...rows.slice(1).map(line)].join(NL);
413
432
  };
414
- const list = (node, depth, keep) => {
433
+ const list = (node, keep) => {
415
434
  const ordered = tagOf(node) === "ol";
416
435
  let number = parseInt(attr(node, "start") || "1", 10);
417
436
  if (!Number.isFinite(number)) number = 1;
418
- const indent = " ".repeat(depth);
419
437
  const items = [];
420
438
  for (const item of children(node)) {
421
439
  if (item.nodeType !== 1 || tagOf(item) !== "li") continue;
@@ -427,16 +445,25 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
427
445
  const keepItemLines = keepsLineBreaks(item, keep === true);
428
446
  for (const child of children(item)) {
429
447
  const tag = child.nodeType === 1 ? tagOf(child) : "";
430
- if (tag === "ul" || tag === "ol") { nested.push(list(child, depth + 1, keepItemLines)); continue; }
431
- if (isBlock(child)) { if (buffer.trim()) words.push(buffer.trim()); buffer = ""; const b = block(child, depth + 1, keepItemLines); if (b) words.push(b); continue; }
448
+ if (tag === "ul" || tag === "ol") { nested.push(list(child, keepItemLines)); continue; }
449
+ if (isBlock(child)) { if (buffer.trim()) words.push(buffer.trim()); buffer = ""; const b = block(child, keepItemLines); if (b) words.push(b); continue; }
432
450
  buffer += inlineNode(child, keepItemLines);
433
451
  }
434
452
  if (buffer.trim()) words.push(buffer.trim());
435
- items.push(indent + marker + " " + words.join(" ") + (nested.length ? NL + nested.join(NL) : ""));
453
+ // A block spanning lines (a code block, a table) is a paragraph of its own.
454
+ const body = words.reduce((text, word) => (!text ? word : text + (text.includes(NL) || word.includes(NL) ? NL + NL : " ") + word), "");
455
+ // An item's later lines sit at its content column, after the marker
456
+ // and its space. They came back at column 0, or three spaces in for a
457
+ // nested list, so a code block, table, quote or second paragraph
458
+ // ended the item and a list under item 10 was not nested (measured
459
+ // 2026-10-29). Nested content is laid out from column 0 and shifted.
460
+ const pad = " ".repeat(marker.length + 1);
461
+ const lines = (body + (nested.length ? NL + nested.join(NL) : "")).split(NL);
462
+ items.push(marker + " " + lines.map((line, at) => (at === 0 || !line ? line : pad + line)).join(NL));
436
463
  }
437
464
  return items.join(NL);
438
465
  };
439
- const block = (node, depth, keep) => {
466
+ const block = (node, keep) => {
440
467
  if (node.nodeType === 3) return (keep === true ? collapseKeepingLines(node.textContent || "") : collapse(node.textContent || "")).trim();
441
468
  if (node.nodeType !== 1 || skipped(node)) return "";
442
469
  const tag = tagOf(node);
@@ -445,25 +472,27 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
445
472
  if (isMermaidPreview(node)) return mermaidBlock(node);
446
473
  if (tag === "table") return table(node);
447
474
  if (/^h[1-6]$/.test(tag)) return "#".repeat(Number(tag[1])) + " " + inline(node).trim();
448
- if (tag === "ul" || tag === "ol") return list(node, depth, keepsLineBreaks(node, keep === true));
449
- if (tag === "hr") return "---";
450
- if (tag === "blockquote") return blocks(node, depth, keep).split(NL).map((line) => "> " + line).join(NL);
475
+ if (tag === "ul" || tag === "ol") return list(node, keepsLineBreaks(node, keep === true));
476
+ // The newer renderer draws a rule as an empty
477
+ // div[data-d-component=divider] (measured 2026-10-29).
478
+ if (tag === "hr" || attr(node, "data-d-component") === "divider") return "---";
479
+ if (tag === "blockquote") return blocks(node, keep).split(NL).map((line) => "> " + line).join(NL);
451
480
  if (tag === "p") return inline(node, keep).trim();
452
- return blocks(node, depth, keep);
481
+ return blocks(node, keep);
453
482
  };
454
- const blocks = (node, depth, keep) => {
483
+ const blocks = (node, keep) => {
455
484
  const keepLines = keepsLineBreaks(node, keep === true);
456
485
  const parts = [];
457
486
  let buffer = "";
458
487
  const flush = () => { if (buffer.trim()) parts.push(buffer.trim()); buffer = ""; };
459
488
  for (const child of children(node)) {
460
- if (isBlock(child)) { flush(); const b = block(child, depth, keepLines); if (b) parts.push(b); continue; }
489
+ if (isBlock(child)) { flush(); const b = block(child, keepLines); if (b) parts.push(b); continue; }
461
490
  buffer += inlineNode(child, keepLines);
462
491
  }
463
492
  flush();
464
493
  return parts.join(NL + NL);
465
494
  };
466
- const markdown = blocks(root, 0).trim();
495
+ const markdown = blocks(root).trim();
467
496
  return markdown || root.innerText || "";
468
497
  };
469
498
  // A sent user turn renders inline code as <code>, dropping its backticks
@@ -2186,6 +2215,13 @@ export function menuOpenExpression() {
2186
2215
  export function menuClosedExpression() {
2187
2216
  return `!document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])')`;
2188
2217
  }
2218
+ /** One visible model selector, marked closed, with no picker on the page. */
2219
+ export function modelSelectorReadsClosedExpression() {
2220
+ return `(() => {
2221
+ 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);
2222
+ return triggers.length === 1 && triggers[0].getAttribute("aria-expanded") === "false" && !${menuOpenExpression()};
2223
+ })()`;
2224
+ }
2189
2225
  function toLabelCandidates(label) {
2190
2226
  return typeof label === "string" ? [label] : [...label];
2191
2227
  }
@@ -2258,6 +2294,28 @@ async function verifiedClickAt(cdp, x, y, label) {
2258
2294
  await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
2259
2295
  await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
2260
2296
  }
2297
+ const MODEL_MENU_FIRST_PRESS_MS = 1_500;
2298
+ /**
2299
+ * Waits for the model picker after the selector was pressed, pressing it once
2300
+ * more if the page ignored that press.
2301
+ *
2302
+ * Measured 2026-10-29 with the host under load: in two of every four sends
2303
+ * that started from a conversation, a trusted pointerdown and pointerup reached
2304
+ * the selector while it read closed and nothing happened - aria-expanded stayed
2305
+ * false and no menu was added - so the send failed as "model menu did not
2306
+ * open". A selector that still reads closed is pressed again; one that reads
2307
+ * open is left alone, since a press would shut it.
2308
+ */
2309
+ export async function waitForModelMenu(cdp) {
2310
+ if (await waitForExpressionTrue(cdp, menuOpenExpression(), MODEL_MENU_FIRST_PRESS_MS))
2311
+ return true;
2312
+ if (await cdp.evaluate(modelSelectorReadsClosedExpression())) {
2313
+ const again = await cdp.evaluate(modelButtonRectExpression());
2314
+ if (again.ok && again.x !== undefined && again.y !== undefined)
2315
+ await verifiedClickAt(cdp, again.x, again.y, "model selector");
2316
+ }
2317
+ return waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
2318
+ }
2261
2319
  /**
2262
2320
  * Whether the model picker BUTTON already advertises the requested model, so
2263
2321
  * the menu never has to be opened. ChatGPT moved the models behind a "Model"
@@ -3371,7 +3429,7 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
3371
3429
  button = fresh;
3372
3430
  }
3373
3431
  }
3374
- const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
3432
+ const opened = await waitForModelMenu(cdp);
3375
3433
  if (!opened)
3376
3434
  throw new Error("ChatGPT model menu did not open after clicking the selector");
3377
3435
  // Current ChatGPT: one power slider (Instant/Medium/High/Extra High/Pro on
@@ -5138,7 +5196,7 @@ export async function listChatGptModelOptions(input = {}) {
5138
5196
  }
5139
5197
  try {
5140
5198
  await verifiedClickAt(cdp, button.x, button.y, "model selector");
5141
- const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
5199
+ const opened = await waitForModelMenu(cdp);
5142
5200
  if (!opened)
5143
5201
  throw new Error("ChatGPT model menu did not open after clicking the selector");
5144
5202
  const options = await cdp.evaluate(modelMenuOptionsExpression());
package/docs/releasing.md CHANGED
@@ -35,6 +35,27 @@ The release adds a guarded, opt-in transition and more accurate blocker reportin
35
35
  **Open**
36
36
  - 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
37
 
38
+ ## 0.40.47 Release Record
39
+
40
+ 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.
41
+
42
+ **Publishing**
43
+ - `npm run release:verify` passed on the release commit after a build, with nothing else running on the machine.
44
+ - `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`.
45
+
46
+ **Installed**
47
+ - 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.
48
+ - The maintainer watchdog checkout was pulled to `609b1c9` and rebuilt.
49
+
50
+ **Container**
51
+ - `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`).
52
+ - 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.
53
+ - 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.
54
+
55
+ **Verified live on installed 0.40.47 (WSL, scratch repo, Instant)**
56
+ - 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.
57
+ - 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`.
58
+
38
59
  ## 0.40.46 Release Record
39
60
 
40
61
  Release commit `807f245` on `main` (cut after PRs #69 and #70, last merge `9253ee9`); tag `v0.40.46`. A batch released on the user's go-ahead: terminal-column widths in the interactive picker (#69) and line breaks kept inside answers from ChatGPT's newer `white-space: pre-line` renderer (#70).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.47",
3
+ "version": "0.40.48",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",