@youdie006/prodex 0.40.46 → 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.
@@ -315,11 +315,32 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
315
315
  // The renderer web-search answers use since 2026-10-10 marks a source as a
316
316
  // badge inside a popover trigger; its urls only exist once it is opened.
317
317
  const isSourceBadge = (node) => attr(node, "data-d-component") === "popover-trigger" && findAll(node, (n) => attr(n, "data-pill") !== null).length > 0;
318
+ // ChatGPT's link component appends utm_source=chatgpt.com to every link
319
+ // it renders (measured 2026-10-28), a parameter the answer never wrote.
320
+ const untracked = (href) => {
321
+ const hashAt = href.indexOf("#");
322
+ const base = hashAt === -1 ? href : href.slice(0, hashAt);
323
+ const queryAt = base.indexOf("?");
324
+ if (queryAt === -1) return href;
325
+ const params = base.slice(queryAt + 1).split("&").filter((param) => param !== "utm_source=chatgpt.com");
326
+ return base.slice(0, queryAt) + (params.length > 0 ? "?" + params.join("&") : "") + (hashAt === -1 ? "" : href.slice(hashAt));
327
+ };
318
328
  const pill = (node) => {
319
329
  const label = collapse(node.textContent || "").trim();
320
330
  if (!label) return "";
321
331
  const link = findAll(node, (n) => tagOf(n) === "a" && (attr(n, "href") || "").startsWith("http"))[0];
322
- return link ? "[" + label + "](" + attr(link, "href") + ")" : "[" + label + "]";
332
+ return link ? "[" + label + "](" + untracked(attr(link, "href")) + ")" : "[" + label + "]";
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;
323
344
  };
324
345
  const inline = (node, keep) => {
325
346
  const keepLines = keepsLineBreaks(node, keep === true);
@@ -341,16 +362,28 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
341
362
  if (isCheckbox(node)) return (attr(node, "aria-checked") === "true" || attr(node, "checked") !== null ? "[x]" : "[ ]") + " ";
342
363
  if (isMath(node)) return isDisplayMath(node) ? "$$" + mathSource(node) + "$$" : "$" + mathSource(node) + "$";
343
364
  if (attr(node, "data-chatgpt-copy-reference") !== null || isSourceBadge(node)) return pill(node);
344
- 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 || "");
345
366
  if (tag === "br") return NL;
346
367
  const inner = inline(node, keep);
347
- // The newer renderer marks bold as span[data-d-default-strong].
348
- if (tag === "strong" || tag === "b" || attr(node, "data-d-default-strong") !== null) return inner.trim() ? "**" + inner.trim() + "**" : "";
349
- if (tag === "em" || tag === "i") return inner.trim() ? "*" + inner.trim() + "*" : "";
350
- if (tag === "del" || tag === "s") return inner.trim() ? "~~" + inner.trim() + "~~" : "";
368
+ // The newer renderer marks bold as span[data-d-default-strong], italic
369
+ // as span[data-d-font-style=italic] and strikethrough as
370
+ // span[data-d-text-decoration=line-through] (measured 2026-10-28; only
371
+ // bold was read, so the other two came back as plain words). One
372
+ // element can carry several, so each applies in turn.
373
+ const strong = tag === "strong" || tag === "b" || attr(node, "data-d-default-strong") !== null;
374
+ const italic = tag === "em" || tag === "i" || attr(node, "data-d-font-style") === "italic";
375
+ const struck = tag === "del" || tag === "s" || (attr(node, "data-d-text-decoration") || "").includes("line-through");
376
+ if (strong || italic || struck) {
377
+ let marked = inner.trim();
378
+ if (!marked) return "";
379
+ if (strong) marked = "**" + marked + "**";
380
+ if (italic) marked = "*" + marked + "*";
381
+ if (struck) marked = "~~" + marked + "~~";
382
+ return marked;
383
+ }
351
384
  if (tag === "a") {
352
385
  const href = attr(node, "href") || "";
353
- return href.startsWith("http") && inner.trim() ? "[" + inner.trim() + "](" + href + ")" : inner;
386
+ return href.startsWith("http") && inner.trim() ? "[" + inner.trim() + "](" + untracked(href) + ")" : inner;
354
387
  }
355
388
  return inner;
356
389
  };
@@ -384,16 +417,23 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
384
417
  };
385
418
  const table = (node) => {
386
419
  const cell = (value) => collapse(value).trim().split("|").join(String.fromCharCode(92) + "|");
387
- 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))));
388
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
+ };
389
430
  const line = (cells) => "| " + cells.join(" | ") + " |";
390
- 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);
391
432
  };
392
- const list = (node, depth, keep) => {
433
+ const list = (node, keep) => {
393
434
  const ordered = tagOf(node) === "ol";
394
435
  let number = parseInt(attr(node, "start") || "1", 10);
395
436
  if (!Number.isFinite(number)) number = 1;
396
- const indent = " ".repeat(depth);
397
437
  const items = [];
398
438
  for (const item of children(node)) {
399
439
  if (item.nodeType !== 1 || tagOf(item) !== "li") continue;
@@ -405,16 +445,25 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
405
445
  const keepItemLines = keepsLineBreaks(item, keep === true);
406
446
  for (const child of children(item)) {
407
447
  const tag = child.nodeType === 1 ? tagOf(child) : "";
408
- if (tag === "ul" || tag === "ol") { nested.push(list(child, depth + 1, keepItemLines)); continue; }
409
- 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; }
410
450
  buffer += inlineNode(child, keepItemLines);
411
451
  }
412
452
  if (buffer.trim()) words.push(buffer.trim());
413
- 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));
414
463
  }
415
464
  return items.join(NL);
416
465
  };
417
- const block = (node, depth, keep) => {
466
+ const block = (node, keep) => {
418
467
  if (node.nodeType === 3) return (keep === true ? collapseKeepingLines(node.textContent || "") : collapse(node.textContent || "")).trim();
419
468
  if (node.nodeType !== 1 || skipped(node)) return "";
420
469
  const tag = tagOf(node);
@@ -423,25 +472,27 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
423
472
  if (isMermaidPreview(node)) return mermaidBlock(node);
424
473
  if (tag === "table") return table(node);
425
474
  if (/^h[1-6]$/.test(tag)) return "#".repeat(Number(tag[1])) + " " + inline(node).trim();
426
- if (tag === "ul" || tag === "ol") return list(node, depth, keepsLineBreaks(node, keep === true));
427
- if (tag === "hr") return "---";
428
- 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);
429
480
  if (tag === "p") return inline(node, keep).trim();
430
- return blocks(node, depth, keep);
481
+ return blocks(node, keep);
431
482
  };
432
- const blocks = (node, depth, keep) => {
483
+ const blocks = (node, keep) => {
433
484
  const keepLines = keepsLineBreaks(node, keep === true);
434
485
  const parts = [];
435
486
  let buffer = "";
436
487
  const flush = () => { if (buffer.trim()) parts.push(buffer.trim()); buffer = ""; };
437
488
  for (const child of children(node)) {
438
- 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; }
439
490
  buffer += inlineNode(child, keepLines);
440
491
  }
441
492
  flush();
442
493
  return parts.join(NL + NL);
443
494
  };
444
- const markdown = blocks(root, 0).trim();
495
+ const markdown = blocks(root).trim();
445
496
  return markdown || root.innerText || "";
446
497
  };
447
498
  // A sent user turn renders inline code as <code>, dropping its backticks
@@ -2164,6 +2215,13 @@ export function menuOpenExpression() {
2164
2215
  export function menuClosedExpression() {
2165
2216
  return `!document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])')`;
2166
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
+ }
2167
2225
  function toLabelCandidates(label) {
2168
2226
  return typeof label === "string" ? [label] : [...label];
2169
2227
  }
@@ -2236,6 +2294,28 @@ async function verifiedClickAt(cdp, x, y, label) {
2236
2294
  await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
2237
2295
  await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
2238
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
+ }
2239
2319
  /**
2240
2320
  * Whether the model picker BUTTON already advertises the requested model, so
2241
2321
  * the menu never has to be opened. ChatGPT moved the models behind a "Model"
@@ -3349,7 +3429,7 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
3349
3429
  button = fresh;
3350
3430
  }
3351
3431
  }
3352
- const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
3432
+ const opened = await waitForModelMenu(cdp);
3353
3433
  if (!opened)
3354
3434
  throw new Error("ChatGPT model menu did not open after clicking the selector");
3355
3435
  // Current ChatGPT: one power slider (Instant/Medium/High/Extra High/Pro on
@@ -5116,7 +5196,7 @@ export async function listChatGptModelOptions(input = {}) {
5116
5196
  }
5117
5197
  try {
5118
5198
  await verifiedClickAt(cdp, button.x, button.y, "model selector");
5119
- const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
5199
+ const opened = await waitForModelMenu(cdp);
5120
5200
  if (!opened)
5121
5201
  throw new Error("ChatGPT model menu did not open after clicking the selector");
5122
5202
  const options = await cdp.evaluate(modelMenuOptionsExpression());
@@ -6281,9 +6361,8 @@ export function pickLandedConversation(candidates, sentPrompt) {
6281
6361
  * nothing is the safe answer here.
6282
6362
  */
6283
6363
  export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
6284
- const normalize = (value) => value
6364
+ const normalize = (value) => undoMarkdownEscapes(value)
6285
6365
  .replace(AUTOLINKED_URL_PATTERN, "$1")
6286
- .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
6287
6366
  .replace(/\s+/g, " ")
6288
6367
  .trim();
6289
6368
  const seen = normalize(userText);
@@ -6298,6 +6377,14 @@ const NORMALIZED_PROMPT_MATCH_CHARS = 120;
6298
6377
  // halves differently ("Function\_(x)" vs "Function_\(x\)", measured
6299
6378
  // 2026-10-21), so the pattern is applied after markdown escapes are undone.
6300
6379
  const AUTOLINKED_URL_PATTERN = /\[(https?:\/\/[^\]\s]+)\]\(\1\)/g;
6380
+ // The composer escapes more than the markdown it would otherwise misread: an
6381
+ // autolinked url's href kept "\\&" where its text had "&" (measured
6382
+ // 2026-10-28). Any ASCII punctuation can carry a markdown escape, so every such
6383
+ // escape is undone, on both sides alike.
6384
+ const MARKDOWN_ESCAPE_PATTERN = /\\([!-\/:-@[-`{-~])/g;
6385
+ function undoMarkdownEscapes(value) {
6386
+ return value.replace(MARKDOWN_ESCAPE_PATTERN, "$1");
6387
+ }
6301
6388
  /**
6302
6389
  * ChatGPT displays a sent turn's HTML entities decoded (measured 2026-10-19:
6303
6390
  * "&amp; &lt;b&gt;" was shown as "& <b>"), so both sides are compared decoded.
@@ -6314,7 +6401,7 @@ function decodeHtmlEntities(value) {
6314
6401
  });
6315
6402
  }
6316
6403
  function normalizeChatGptPromptText(value) {
6317
- const unescaped = decodeHtmlEntities(value).replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1").replace(AUTOLINKED_URL_PATTERN, "$1");
6404
+ const unescaped = undoMarkdownEscapes(decodeHtmlEntities(value)).replace(AUTOLINKED_URL_PATTERN, "$1");
6318
6405
  const renderedFences = unescaped.replace(/(^|\r?\n)```text[ \t]*\r?\n([\s\S]*?)\r?\n```(?=\r?\n|$)/g, "$1text\n$2");
6319
6406
  return renderedFences.replace(/\s+/g, " ").trim();
6320
6407
  }
@@ -6345,8 +6432,7 @@ export function chatGptRequestMatchesUserTurn(userText, sentPrompt, requestId) {
6345
6432
  return withoutFenceLines(userText).includes(withoutFenceLines(sentPrompt));
6346
6433
  }
6347
6434
  function withoutFenceLines(value) {
6348
- return decodeHtmlEntities(value)
6349
- .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
6435
+ return undoMarkdownEscapes(decodeHtmlEntities(value))
6350
6436
  .replace(AUTOLINKED_URL_PATTERN, "$1")
6351
6437
  .replace(/^[ \t]*```[\w-]*[ \t]*$/gm, "")
6352
6438
  .replace(/\s+/g, "");
package/docs/releasing.md CHANGED
@@ -35,6 +35,47 @@ 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
+
59
+ ## 0.40.46 Release Record
60
+
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).
62
+
63
+ **Publishing**
64
+ - `npm run release:verify` passed on the release commit after a build, with nothing else running on the machine.
65
+ - `publish.yml` run 40645773352 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release, which lists the two entries from `CHANGELOG.md`.
66
+
67
+ **Installed**
68
+ - WSL, M3, Mac mini: `prodex --version` reports 0.40.46, and each installed `dist/chatgpt-browser.js` carries the line-break reader. Running services were not restarted: no host stdio MCP servers on WSL, 1 on M3, none on the Mac mini.
69
+ - The maintainer watchdog checkout was pulled to `807f245` and rebuilt.
70
+
71
+ **Container**
72
+ - `container/redesign-patches` `5d021d1` ports #69 and `ad24ce2` ports #70 (the #70 cherry-pick conflicted in `src/chatgpt-browser.ts` and the PR diff was applied instead; tsc, build, the suite and `release:verify` pass on it).
73
+ - The images were still not rebuilt: `docker compose build` is refused by the agent's permission classifier on WSL and over ssh on M3. The running WSL container is the #49-#58 port and the M3 container the 0.40.42 port.
74
+
75
+ **Verified**
76
+ - Before the release, on the live tab: the reader in 0.40.45 returned "alpha beta" for an answer showing "alpha" and "beta" on two lines; the #70 build returned "alpha\nbeta" from the same page, with the list after it unchanged.
77
+ - After installing, the tab no longer showed a multi-line answer, so the line-break fix was not re-read live on the installed build; the installed reader ran on the current answer without error.
78
+
38
79
  ## 0.40.45 Release Record
39
80
 
40
81
  Release commit `5e5f965` on `main` (cut after PRs #59 and #63-#68, last merge `52243f4`); tag `v0.40.45`. A batch released on the user's go-ahead: machine-wide send pacing (#59), accurate advice for a busy tab (#63), word-bounded project-name redaction (#64), the IPv6 loopback message (#65), no cancelling of finished sessions (#66), a blank saved default project stopped before the browser (#67), and the project-delete preview (#68).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.46",
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",