@youdie006/prodex 0.40.27 → 0.40.29

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.
@@ -1,3 +1,4 @@
1
+ import { readlinkSync } from "node:fs";
1
2
  import { spawnSync } from "node:child_process";
2
3
  import path from "node:path";
3
4
  export class BrowserProcessInspectionError extends Error {
@@ -38,6 +39,33 @@ function defaultProcessListRunner(command, args, options) {
38
39
  return spawnSync(command, args, options);
39
40
  }
40
41
  /** Read process identity without passing a port, profile, or other user data to a shell. */
42
+ function defaultNetworkNamespaceReader(pid) {
43
+ try {
44
+ return readlinkSync(`/proc/${pid}/ns/net`);
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ /**
51
+ * Drop processes that live in another network namespace than this one.
52
+ *
53
+ * A browser container's Chromium shows up in the host's process list, runs as
54
+ * the same uid, and passes --remote-debugging-port=9333 too, but that port is
55
+ * the container's own. Counting it as ours made `pro browser reset` on the host
56
+ * offer to end the container's browser (measured 2026-10-08: 12 processes, all
57
+ * of them the container's), and a second claimant on "our" port fails a
58
+ * port-only search outright. A namespace that cannot be read keeps the process.
59
+ */
60
+ export function inOwnNetworkNamespace(processes, readNetworkNamespace = defaultNetworkNamespaceReader) {
61
+ const own = readNetworkNamespace("self");
62
+ if (!own)
63
+ return processes;
64
+ return processes.filter((processInfo) => {
65
+ const theirs = readNetworkNamespace(processInfo.processId);
66
+ return theirs === undefined || theirs === own;
67
+ });
68
+ }
41
69
  export function inspectBrowserProcesses(options = {}) {
42
70
  const platform = options.platform ?? process.platform;
43
71
  const run = options.run ?? defaultProcessListRunner;
@@ -53,7 +81,10 @@ export function inspectBrowserProcesses(options = {}) {
53
81
  if (listed.error || listed.status !== 0 || stdout === undefined) {
54
82
  throw new BrowserProcessInspectionError("Could not inspect browser processes.");
55
83
  }
56
- return platform === "win32" ? parseWindowsCimProcessJson(stdout) : parsePosixProcessList(stdout);
84
+ if (platform === "win32")
85
+ return parseWindowsCimProcessJson(stdout);
86
+ const processes = parsePosixProcessList(stdout);
87
+ return platform === "linux" ? inOwnNetworkNamespace(processes, options.readNetworkNamespace) : processes;
57
88
  }
58
89
  export function parseWindowsCimProcessJson(raw) {
59
90
  let parsed;
@@ -7,7 +7,7 @@ import path from "node:path";
7
7
  import WsWebSocket from "ws";
8
8
  import { captureBrowserDiagnostics, diagnosticsEnabled, diagnosticsNote } from "./browser-diagnostics.js";
9
9
  import { withCrossProcessFileLock, writeVerifiedUtf8File } from "./safe-file.js";
10
- import { findBrowserProcessesByPort, findMatchingBrowserProcesses, inspectBrowserProcesses, parsePosixProcessList } from "./browser-process.js";
10
+ import { findBrowserProcessesByPort, findMatchingBrowserProcesses, inOwnNetworkNamespace, inspectBrowserProcesses, parsePosixProcessList } from "./browser-process.js";
11
11
  import os from "node:os";
12
12
  import { answeredDialogWarning, chatSurfaceState, effortNeedsWorkSurface, javascriptDialogResponse, menuKeyboardStep, readPowerSliderSelection, sliderRestoreStep, surfaceFromProbe, sliderPressOutcome, sliderDidNotRespond } from "./picker-interaction.js";
13
13
  import { projectsWithIdsExpression, recentConversationTitlesExpression } from "./tui.js";
@@ -192,38 +192,242 @@ export const CHATGPT_BLOCKER_SCAN_EXCLUDED_ANCESTORS = `${'[data-message-author-
192
192
  * attribute went with it; where it is absent the model is simply not known.
193
193
  */
194
194
  /**
195
- * A message's text with its source pills spelled out.
195
+ * An assistant message as markdown, read from its rendered DOM.
196
196
  *
197
- * Measured 2026-10-06: ChatGPT renders each source as an inline pill
198
- * ([data-chatgpt-copy-reference]) and innerText puts its label on a line of
199
- * its own, so a page-read answer came back as "walnut-9" plus a line
200
- * "cont-att", or ended in a bare "blog.rust-lang.org" that read as part of
201
- * the answer. A web source becomes " [label](url)", as the transcript reader
202
- * writes it; a file source has no link and becomes " [label]". The label is
203
- * looked for on its own line first, so the same words earlier in the prose
204
- * are left alone.
197
+ * innerText drops what markdown carries. Measured 2026-10-06/07: headings lost
198
+ * their #, numbered lists their numbers, bullets their markers, links their
199
+ * urls, code blocks their fences (gaining the toolbar's "Python"/"Run"),
200
+ * tables their structure, KaTeX math came back one glyph per line and twice,
201
+ * and source pills became loose lines. The transcript API that held the
202
+ * markdown is not used (internal endpoints were removed on purpose), so the
203
+ * rendered tree is walked instead: block elements become markdown blocks,
204
+ * ChatGPT's own copy markers ([data-markdown-copy]) identify code blocks,
205
+ * inline code and the toolbar to leave out, math is written from
206
+ * [data-math-source], and a source pill becomes " [label](url)" or
207
+ * " [label]". When nothing could be read, the page text is returned as is.
205
208
  */
206
209
  export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
207
210
  const renderedMessageText = (root) => {
211
+ const NL = String.fromCharCode(10);
212
+ const BT = String.fromCharCode(96);
213
+ const isSpaceChar = (ch) => ch === " " || ch === NL || ch === String.fromCharCode(9) || ch === String.fromCharCode(13);
214
+ const tagOf = (node) => (node.tagName || "").toLowerCase();
215
+ const attr = (node, name) => (node.getAttribute ? node.getAttribute(name) : null);
216
+ const hasClass = (node, name) => (" " + (attr(node, "class") || "") + " ").includes(" " + name + " ");
217
+ const children = (node) => [...(node.childNodes || [])];
218
+ const findAll = (node, test, out = []) => {
219
+ for (const child of children(node)) {
220
+ if (child.nodeType !== 1) continue;
221
+ if (test(child)) out.push(child);
222
+ findAll(child, test, out);
223
+ }
224
+ return out;
225
+ };
226
+ const collapse = (value) => {
227
+ let out = "";
228
+ let space = false;
229
+ for (const ch of value) {
230
+ if (isSpaceChar(ch)) { space = true; continue; }
231
+ if (space) out += " ";
232
+ space = false;
233
+ out += ch;
234
+ }
235
+ return space ? out + " " : out;
236
+ };
237
+ const trimEnd = (value) => { let end = value.length; while (end > 0 && isSpaceChar(value[end - 1])) end -= 1; return value.slice(0, end); };
238
+ const isCheckbox = (node) => attr(node, "role") === "checkbox" || (tagOf(node) === "input" && attr(node, "type") === "checkbox");
239
+ const skipped = (node) => {
240
+ const tag = tagOf(node);
241
+ if (isCheckbox(node)) return false;
242
+ return tag === "button" || tag === "svg" || tag === "script" || tag === "style" || tag === "img" ||
243
+ attr(node, "hidden") !== null || attr(node, "data-markdown-copy") === "exclude";
244
+ };
245
+ const isMath = (node) => attr(node, "data-math-source") !== null || hasClass(node, "katex") || hasClass(node, "katex-display");
246
+ const mathSource = (node) => {
247
+ const source = attr(node, "data-math-source");
248
+ if (source !== null) return source;
249
+ const annotation = findAll(node, (n) => tagOf(n) === "annotation")[0];
250
+ return annotation ? annotation.textContent || "" : node.textContent || "";
251
+ };
252
+ // Inline math carries data-math-display="false" (measured), so the value counts, not its presence.
253
+ const isDisplayMath = (node) => attr(node, "data-math-display") === "true" || hasClass(node, "katex-display");
254
+ const isCodeBlock = (node) => attr(node, "data-markdown-copy") === "code-block" || tagOf(node) === "pre";
255
+ const BLOCK_TAGS = ["p", "div", "section", "article", "figure", "ul", "ol", "li", "table", "blockquote", "pre", "hr", "h1", "h2", "h3", "h4", "h5", "h6"];
256
+ const isBlock = (node) => node.nodeType === 1 && !skipped(node) &&
257
+ (BLOCK_TAGS.includes(tagOf(node)) || isCodeBlock(node) || (isMath(node) && isDisplayMath(node)) ||
258
+ (tagOf(node) === "span" && findAll(node, (n) => BLOCK_TAGS.includes(tagOf(n))).length > 0));
259
+ const pill = (node) => {
260
+ const label = collapse(node.textContent || "").trim();
261
+ if (!label) return "";
262
+ const link = findAll(node, (n) => tagOf(n) === "a" && (attr(n, "href") || "").startsWith("http"))[0];
263
+ return link ? "[" + label + "](" + attr(link, "href") + ")" : "[" + label + "]";
264
+ };
265
+ const inline = (node) => {
266
+ let out = "";
267
+ for (const child of children(node)) {
268
+ const piece = inlineNode(child);
269
+ if (!piece) continue;
270
+ // A source pill sits right after its sentence; keep one space before it.
271
+ if (child.nodeType === 1 && attr(child, "data-chatgpt-copy-reference") !== null && out && !isSpaceChar(out[out.length - 1])) out += " ";
272
+ out += piece;
273
+ }
274
+ return out;
275
+ };
276
+ const inlineNode = (node) => {
277
+ if (node.nodeType === 3) return collapse(node.textContent || "");
278
+ if (node.nodeType !== 1 || skipped(node)) return "";
279
+ const tag = tagOf(node);
280
+ // A task list item's box (measured: button[role=checkbox][aria-checked]).
281
+ if (isCheckbox(node)) return (attr(node, "aria-checked") === "true" || attr(node, "checked") !== null ? "[x]" : "[ ]") + " ";
282
+ if (isMath(node)) return isDisplayMath(node) ? "$$" + mathSource(node) + "$$" : "$" + mathSource(node) + "$";
283
+ if (attr(node, "data-chatgpt-copy-reference") !== null) return pill(node);
284
+ if (attr(node, "data-markdown-copy") === "inline-code" || tag === "code") return BT + (node.textContent || "") + BT;
285
+ if (tag === "br") return NL;
286
+ const inner = inline(node);
287
+ if (tag === "strong" || tag === "b") return inner.trim() ? "**" + inner.trim() + "**" : "";
288
+ if (tag === "em" || tag === "i") return inner.trim() ? "*" + inner.trim() + "*" : "";
289
+ if (tag === "del" || tag === "s") return inner.trim() ? "~~" + inner.trim() + "~~" : "";
290
+ if (tag === "a") {
291
+ const href = attr(node, "href") || "";
292
+ return href.startsWith("http") && inner.trim() ? "[" + inner.trim() + "](" + href + ")" : inner;
293
+ }
294
+ return inner;
295
+ };
296
+ const codeBlock = (node) => {
297
+ // CodeMirror renders only the lines in view: a 150-line block came back
298
+ // with 36 (measured 2026-10-07). Its editor holds the whole document.
299
+ const editorContent = findAll(node, (n) => hasClass(n, "cm-content"))[0];
300
+ let fullCode = null;
301
+ try {
302
+ const view = editorContent && ((editorContent.cmTile && editorContent.cmTile.view) || (editorContent.cmView && editorContent.cmView.view));
303
+ const doc = view && view.state && view.state.doc;
304
+ if (doc && typeof doc.toString === "function") fullCode = String(doc.toString());
305
+ } catch (error) {
306
+ fullCode = null;
307
+ }
308
+ const lines = findAll(node, (n) => hasClass(n, "cm-line")).map((line) => line.textContent || "");
309
+ const pre = tagOf(node) === "pre" ? node : findAll(node, (n) => tagOf(n) === "pre")[0];
310
+ const code = fullCode !== null ? trimEnd(fullCode) : lines.length > 0 ? lines.join(NL) : trimEnd(pre ? pre.textContent || "" : node.textContent || "");
311
+ const editor = findAll(node, (n) => attr(n, "data-language") !== null)[0];
312
+ const toolbar = findAll(node, (n) => attr(n, "data-markdown-copy") === "exclude")[0];
313
+ const label = toolbar ? collapse(findAll(toolbar, (n) => n.nodeType === 1 && tagOf(n) === "div" && children(n).every((c) => c.nodeType === 3)).map((n) => n.textContent || "")[0] || "").trim() : "";
314
+ const language = ((editor && attr(editor, "data-language")) || label).trim().toLowerCase();
315
+ return BT + BT + BT + language + NL + code + NL + BT + BT + BT;
316
+ };
317
+ const table = (node) => {
318
+ const cell = (value) => collapse(value).trim().split("|").join(String.fromCharCode(92) + "|");
319
+ const rows = findAll(node, (n) => tagOf(n) === "tr").map((row) => children(row).filter((c) => c.nodeType === 1).map((c) => cell(inline(c))));
320
+ if (rows.length === 0) return "";
321
+ const line = (cells) => "| " + cells.join(" | ") + " |";
322
+ return [line(rows[0]), line(rows[0].map(() => "---")), ...rows.slice(1).map(line)].join(NL);
323
+ };
324
+ const list = (node, depth) => {
325
+ const ordered = tagOf(node) === "ol";
326
+ let number = parseInt(attr(node, "start") || "1", 10);
327
+ if (!Number.isFinite(number)) number = 1;
328
+ const indent = " ".repeat(depth);
329
+ const items = [];
330
+ for (const item of children(node)) {
331
+ if (item.nodeType !== 1 || tagOf(item) !== "li") continue;
332
+ const marker = ordered ? number + "." : "-";
333
+ number += 1;
334
+ const words = [];
335
+ const nested = [];
336
+ let buffer = "";
337
+ for (const child of children(item)) {
338
+ const tag = child.nodeType === 1 ? tagOf(child) : "";
339
+ if (tag === "ul" || tag === "ol") { nested.push(list(child, depth + 1)); continue; }
340
+ if (isBlock(child)) { if (buffer.trim()) words.push(buffer.trim()); buffer = ""; const b = block(child, depth + 1); if (b) words.push(b); continue; }
341
+ buffer += inlineNode(child);
342
+ }
343
+ if (buffer.trim()) words.push(buffer.trim());
344
+ items.push(indent + marker + " " + words.join(" ") + (nested.length ? NL + nested.join(NL) : ""));
345
+ }
346
+ return items.join(NL);
347
+ };
348
+ const block = (node, depth) => {
349
+ if (node.nodeType === 3) return collapse(node.textContent || "").trim();
350
+ if (node.nodeType !== 1 || skipped(node)) return "";
351
+ const tag = tagOf(node);
352
+ if (isMath(node)) return isDisplayMath(node) ? "$$" + NL + mathSource(node) + NL + "$$" : "$" + mathSource(node) + "$";
353
+ if (isCodeBlock(node)) return codeBlock(node);
354
+ if (tag === "table") return table(node);
355
+ if (/^h[1-6]$/.test(tag)) return "#".repeat(Number(tag[1])) + " " + inline(node).trim();
356
+ if (tag === "ul" || tag === "ol") return list(node, depth);
357
+ if (tag === "hr") return "---";
358
+ if (tag === "blockquote") return blocks(node, depth).split(NL).map((line) => "> " + line).join(NL);
359
+ if (tag === "p") return inline(node).trim();
360
+ return blocks(node, depth);
361
+ };
362
+ const blocks = (node, depth) => {
363
+ const parts = [];
364
+ let buffer = "";
365
+ const flush = () => { if (buffer.trim()) parts.push(buffer.trim()); buffer = ""; };
366
+ for (const child of children(node)) {
367
+ if (isBlock(child)) { flush(); const b = block(child, depth); if (b) parts.push(b); continue; }
368
+ buffer += inlineNode(child);
369
+ }
370
+ flush();
371
+ return parts.join(NL + NL);
372
+ };
373
+ const markdown = blocks(root, 0).trim();
374
+ return markdown || root.innerText || "";
375
+ };
376
+ // A sent user turn renders inline code as <code>, dropping its backticks
377
+ // from innerText (measured 2026-10-07), and the request is matched against
378
+ // what was typed. Each inline code span gets its backticks back, in page order.
379
+ const userMessageText = (root) => {
208
380
  let text = root.innerText || "";
209
- const newline = String.fromCharCode(10);
210
- const isSpace = (ch) => ch === " " || ch === newline || ch === String.fromCharCode(9) || ch === String.fromCharCode(13);
381
+ const tick = String.fromCharCode(96);
211
382
  let cursor = 0;
212
- for (const pill of [...root.querySelectorAll('[data-chatgpt-copy-reference]')]) {
213
- const label = (pill.innerText || "").trim();
214
- if (!label) continue;
215
- const onOwnLine = text.indexOf(newline + label, cursor);
216
- const at = onOwnLine >= 0 ? onOwnLine + 1 : text.indexOf(label, cursor);
383
+ for (const code of [...root.querySelectorAll('code')]) {
384
+ if (code.closest('pre')) continue;
385
+ const value = code.textContent || "";
386
+ // A fenced block in a user turn is one <code> outside any <pre>
387
+ // (measured); inline code is a single line.
388
+ if (!value || value.includes(String.fromCharCode(10))) continue;
389
+ // The span's text can also occur inside a word earlier on ("x" in
390
+ // "exact"), so only an occurrence on word boundaries counts.
391
+ const wordChar = (ch) => !!ch && ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || (ch >= "0" && ch <= "9") || ch === "_");
392
+ let at = text.indexOf(value, cursor);
393
+ while (at >= 0 && ((wordChar(value[0]) && wordChar(text[at - 1])) || (wordChar(value[value.length - 1]) && wordChar(text[at + value.length])))) {
394
+ at = text.indexOf(value, at + 1);
395
+ }
217
396
  if (at < 0) continue;
218
- let start = at;
219
- while (start > cursor && isSpace(text[start - 1])) start -= 1;
220
- const link = pill.querySelector('a[href^="http"]');
221
- const rendered = link ? " [" + label + "](" + link.getAttribute("href") + ")" : " [" + label + "]";
222
- text = text.slice(0, start) + rendered + text.slice(at + label.length);
223
- cursor = start + rendered.length;
397
+ if (text[at - 1] === tick && text[at + value.length] === tick) { cursor = at + value.length + 1; continue; }
398
+ const rendered = tick + value + tick;
399
+ text = text.slice(0, at) + rendered + text.slice(at + value.length);
400
+ cursor = at + rendered.length;
224
401
  }
225
402
  return text;
226
403
  };`;
404
+ /**
405
+ * Lines that say which model or effort the composer is on, for the hints a
406
+ * consult reports and saves.
407
+ *
408
+ * They were every line of the whole page matching /GPT|Pro|.../i, so the saved
409
+ * answer of every consult listed sidebar project names, conversation titles
410
+ * and the request marker (measured 2026-10-07: 179 saved answers in one test
411
+ * repo carried a project name). They now come only from the model selector
412
+ * and the visible composer form, matched on word boundaries.
413
+ */
414
+ export const CHATGPT_MODEL_HINTS_JS = `
415
+ const modelHintLines = () => {
416
+ const sources = [];
417
+ const trigger = document.querySelector('button[aria-label="Select ChatGPT model"],[data-testid="model-switcher-dropdown-button"]');
418
+ // The trigger's text names the model; its aria-label only names the control.
419
+ if (trigger) sources.push(trigger.innerText || "");
420
+ for (const form of [...document.querySelectorAll("form")]) {
421
+ if (form.getClientRects().length > 0) sources.push(form.innerText || "");
422
+ }
423
+ const modelish = /\\b(?:ChatGPT|GPT(?:-[\\w.]+)?|Pro|Plus|Thinking|Instant|Extra High|High|Medium|Auto|Latest)\\b/i;
424
+ const seen = [];
425
+ for (const line of sources.join(String.fromCharCode(10)).split(String.fromCharCode(10))) {
426
+ const trimmed = line.trim();
427
+ if (trimmed && trimmed.length <= 80 && modelish.test(trimmed) && !seen.includes(trimmed)) seen.push(trimmed);
428
+ }
429
+ return seen.slice(0, 30);
430
+ };`;
227
431
  const CHATGPT_MESSAGE_NODES_JS = `${CHATGPT_RENDERED_MESSAGE_TEXT_JS}
228
432
  // ChatGPT keeps a previously opened conversation mounted but hidden
229
433
  // (measured 2026-10-06: two turns without a box on a project home), and
@@ -844,8 +1048,10 @@ async function navigateIdleChatGptPage(page, url) {
844
1048
  * navigation cannot leave the old thread's state in place. A false result
845
1049
  * must not be used as permission to send into stale content.
846
1050
  */
847
- // Measured 5.2-8.5 s for the new-chat page to settle (2026-09-30).
848
- const FRESH_CHAT_TIMEOUT_MS = 15_000;
1051
+ // Measured 5.2-8.5 s for the new-chat page to settle (2026-09-30), and over
1052
+ // 15 s for back-to-back sends with the host at load average 28-32 on 8 cores
1053
+ // (2026-10-06). It is only a ceiling: a ready page returns at once.
1054
+ const FRESH_CHAT_TIMEOUT_MS = 30_000;
849
1055
  /** Poll until the document marked before a navigation has been replaced and the new one has a composer. */
850
1056
  async function waitForReloadedDocument(page, deadline) {
851
1057
  while (Date.now() < deadline) {
@@ -1669,7 +1875,9 @@ async function openChatGptThread(cdp, url) {
1669
1875
  if (await cdp.evaluate(chatGptThreadReadyExpression(conversationId)))
1670
1876
  return;
1671
1877
  await cdp.evaluate(`location.assign(${JSON.stringify(url)})`);
1672
- const deadline = Date.now() + RELOAD_SETTLE_TIMEOUT_MS;
1878
+ // A thread with many turns took 13.9 s to finish loading (2026-10-06), and
1879
+ // readiness waits for a loaded document, so the reload ceiling was too short.
1880
+ const deadline = Date.now() + COMPOSER_RENDER_TIMEOUT_MS;
1673
1881
  while (Date.now() < deadline) {
1674
1882
  await sleep(250);
1675
1883
  try {
@@ -1750,6 +1958,13 @@ async function waitForExpressionTrue(cdp, expression, timeoutMs, intervalMs = 15
1750
1958
  const MENU_OPEN_TIMEOUT_MS = 5_000;
1751
1959
  const MENU_SETTLE_TIMEOUT_MS = 5_000;
1752
1960
  const PROJECT_NAVIGATION_TIMEOUT_MS = 8_000;
1961
+ /**
1962
+ * How long the composer's own controls (model selector, tools button, file
1963
+ * input) may take to appear after the editor. Measured over 8 s for the model
1964
+ * selector on back-to-back sends with the host at load average 24-44
1965
+ * (2026-10-06). It is only a ceiling: a rendered control returns at once.
1966
+ */
1967
+ const COMPOSER_RENDER_TIMEOUT_MS = 30_000;
1753
1968
  export function menuOpenExpression() {
1754
1969
  return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])'))`;
1755
1970
  }
@@ -2464,6 +2679,20 @@ const POWER_SLIDER_SWALLOWED_PRESS_RETRIES = 6;
2464
2679
  * Anything else (a menu that would not open, a click that would not land) is a
2465
2680
  * real failure and stays one, so a genuine break is not buried under a warning.
2466
2681
  */
2682
+ /**
2683
+ * The models to name in a model_not_applied warning: the model rows as
2684
+ * `pro browser models` reads them (a submenu row by its value), else whatever
2685
+ * the menu lookup saw. The lookup's list also carries the effort slider's row,
2686
+ * which put "Medium" among the models (measured 2026-10-07).
2687
+ */
2688
+ export function offeredModelLabels(options, fallback) {
2689
+ // A submenu row is named by its value; a radio row's second line is a note
2690
+ // ("Leaving on October 14"), so it is named by its label.
2691
+ const labels = (options ?? [])
2692
+ .map((option) => (option.kind === "submenu" ? option.value || option.label : option.label))
2693
+ .filter((label) => label.length > 0);
2694
+ return labels.length > 0 ? [...new Set(labels)] : fallback ? [...fallback] : undefined;
2695
+ }
2467
2696
  export function modelSelectionUnavailableWarning(requested, reason, available) {
2468
2697
  if (!/menu item not found|not clickable/i.test(reason))
2469
2698
  return undefined;
@@ -2598,7 +2827,15 @@ async function selectPickerModel(cdp, requested, warnings = []) {
2598
2827
  return;
2599
2828
  const hit = await cdp.evaluate(menuItemRectExpression(requested));
2600
2829
  if (!hit.ok || hit.x === undefined || hit.y === undefined) {
2601
- const unavailable = modelSelectionUnavailableWarning(requested, hit.reason ?? "", hit.available);
2830
+ let models;
2831
+ try {
2832
+ models = await cdp.evaluate(modelMenuOptionsExpression());
2833
+ }
2834
+ catch (error) {
2835
+ if (cdpCommandTimedOut(error))
2836
+ throw error;
2837
+ }
2838
+ const unavailable = modelSelectionUnavailableWarning(requested, hit.reason ?? "", offeredModelLabels(models, hit.available));
2602
2839
  if (unavailable) {
2603
2840
  warnings.push(unavailable);
2604
2841
  return;
@@ -2846,7 +3083,7 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
2846
3083
  // the same pattern succeeded either side of it. The neighbouring waits for
2847
3084
  // the same kind of render already allow six to eight.
2848
3085
  let button = { ok: false };
2849
- const buttonDeadline = Date.now() + PROJECT_NAVIGATION_TIMEOUT_MS;
3086
+ const buttonDeadline = Date.now() + COMPOSER_RENDER_TIMEOUT_MS;
2850
3087
  for (;;) {
2851
3088
  button = await cdp.evaluate(modelButtonRectExpression());
2852
3089
  if (button.ok && button.x !== undefined && button.y !== undefined)
@@ -3947,7 +4184,7 @@ export async function sendChatGptPrompt(options) {
3947
4184
  // prompt posts the composer clears and no send button is found.
3948
4185
  const promptPostedExpression = `(() => {${CHATGPT_MESSAGE_NODES_JS}
3949
4186
  const last = chatMessageNodes().filter((message) => message.role === "user").at(-1);
3950
- return Boolean(last && (last.textNode.innerText || "").includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
4187
+ return Boolean(last && userMessageText(last.textNode).includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
3951
4188
  })()`;
3952
4189
  // A dispatch can reach Chrome even if its acknowledgement is lost.
3953
4190
  submissionAttempted = true;
@@ -4078,7 +4315,9 @@ export async function sendChatGptPrompt(options) {
4078
4315
  code: "thread_navigated_away",
4079
4316
  message: "The browser tab was moved to a different ChatGPT conversation while this consult was waiting for its answer.",
4080
4317
  retryable: false,
4081
- next_step: `Do not resend automatically. After other sessions finish, inspect the original request [prodex-request:${requestId}] in ${pinnedThreadUrl}.`,
4318
+ // The answer keeps coming in that thread and recovers by its request id
4319
+ // once it finishes (verified 2026-10-07), so name the exact command.
4320
+ next_step: `Do not resend automatically. After other sessions finish with the tab, recover this request's answer: \`prodex pro browser recover --target-url ${pinnedThreadUrl} --request-id ${requestId}\`.`,
4082
4321
  thread: pinnedThreadUrl
4083
4322
  });
4084
4323
  }
@@ -4323,7 +4562,7 @@ export function resolveConversationToDelete(conversations, request) {
4323
4562
  * The port cannot tell a dead browser from an absent one; the process list can.
4324
4563
  */
4325
4564
  export function findLaunchedBrowserProcesses(psOutput, input) {
4326
- return findBrowserProcessesByPort(parsePosixProcessList(psOutput), {
4565
+ return findBrowserProcessesByPort(inOwnNetworkNamespace(parsePosixProcessList(psOutput)), {
4327
4566
  platform: "linux",
4328
4567
  port: input.port,
4329
4568
  fallbackProfileDir: input.profileDir
@@ -4624,7 +4863,14 @@ export async function listChatGptModelOptions(input = {}) {
4624
4863
  input.signal?.addEventListener("abort", abandon, { once: true });
4625
4864
  try {
4626
4865
  await cdp.send("Runtime.enable");
4627
- const button = await cdp.evaluate(modelButtonRectExpression());
4866
+ // Right after a send the composer is still rendering, and a single look
4867
+ // failed with "the composer has no form around it" (measured 2026-10-07).
4868
+ let button = await cdp.evaluate(modelButtonRectExpression());
4869
+ const buttonDeadline = Date.now() + COMPOSER_RENDER_TIMEOUT_MS;
4870
+ while ((!button.ok || button.x === undefined || button.y === undefined) && Date.now() < buttonDeadline && !input.signal?.aborted) {
4871
+ await sleep(300);
4872
+ button = await cdp.evaluate(modelButtonRectExpression());
4873
+ }
4628
4874
  if (!button.ok || button.x === undefined || button.y === undefined) {
4629
4875
  throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
4630
4876
  }
@@ -5018,7 +5264,7 @@ export function statusExpression() {
5018
5264
  const responseChoiceSelector = JSON.stringify(CHATGPT_RESPONSE_CHOICE_SELECTOR);
5019
5265
  const generatingControlPattern = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.source);
5020
5266
  const generatingControlFlags = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.flags);
5021
- return `(() => {
5267
+ return `(() => {${CHATGPT_MODEL_HINTS_JS}
5022
5268
  ${composerExpressionHelpers()}
5023
5269
  const text = document.body?.innerText || "";
5024
5270
  const runtimeExcludedTextSelector = ${excludedTextSelector};
@@ -5052,7 +5298,7 @@ export function statusExpression() {
5052
5298
  ${CHATGPT_MESSAGE_NODES_JS}
5053
5299
  const messages = chatMessageNodes().map((message) => ({
5054
5300
  role: message.role,
5055
- text: message.textNode.innerText || ""
5301
+ text: message.role === "assistant" ? renderedMessageText(message.textNode) : userMessageText(message.textNode)
5056
5302
  }));
5057
5303
  const assistant = messages.filter((message) => message.role === "assistant").at(-1);
5058
5304
  const answer = assistant?.text || "";
@@ -5071,7 +5317,7 @@ export function statusExpression() {
5071
5317
  hasComposer,
5072
5318
  generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || visibleButtonLabels.some((label) => generatingControlPattern.test(label)),
5073
5319
  awaitingResponseChoice: Boolean(document.querySelector(${responseChoiceSelector})),
5074
- modelHints: lines.filter((line) => /GPT|Pro|Thinking|ChatGPT|Extra High|Auto/i.test(line)).slice(0, 30),
5320
+ modelHints: modelHintLines(),
5075
5321
  openDialogText: (([...document.querySelectorAll('[role="dialog"]')].find((d) => d.offsetWidth || d.offsetHeight || d.getClientRects().length)?.innerText) || "").trim().slice(0, 200)
5076
5322
  };
5077
5323
  })()`;
@@ -5156,8 +5402,8 @@ export function insertComposerTextInPageExpression(text) {
5156
5402
  export function composerFileInputSelector() {
5157
5403
  return 'input[type="file"]:not([accept*="image"])';
5158
5404
  }
5159
- const COMPOSER_FILE_INPUT_TIMEOUT_MS = 15_000;
5160
5405
  const COMPOSER_TOOLS_MENU_ATTEMPTS = 3;
5406
+ const LEFTOVER_ATTACHMENT_REMOVE_ATTEMPTS = 5;
5161
5407
  export const PRODEX_ATTACH_INPUT_ATTRIBUTE = "data-prodex-attach-input";
5162
5408
  /**
5163
5409
  * Tags the general file input of the form that holds the VISIBLE composer, so
@@ -5249,6 +5495,22 @@ export function attachmentPresenceExpression() {
5249
5495
  return { ok: true, removed: buttons.length };
5250
5496
  })()`;
5251
5497
  }
5498
+ /**
5499
+ * Where to click to remove one leftover attachment chip: the first rendered
5500
+ * "Remove <file name>" button in a composer form, or { ok: false }.
5501
+ */
5502
+ export function leftoverAttachmentRemovePointExpression() {
5503
+ return `(() => {
5504
+ const button = [...document.querySelectorAll('form button[aria-label]')].find((b) => {
5505
+ const label = b.getAttribute("aria-label") || "";
5506
+ if (!(/remove file|파일 제거|첨부 제거/i.test(label) || /^remove\\s+\\S.*\\.[A-Za-z0-9]{1,8}$/i.test(label))) return false;
5507
+ return b.getClientRects().length > 0;
5508
+ });
5509
+ if (!button) return { ok: false };
5510
+ const r = button.getBoundingClientRect();
5511
+ return { ok: true, x: r.x + r.width / 2, y: r.y + r.height / 2 };
5512
+ })()`;
5513
+ }
5252
5514
  /**
5253
5515
  * Attach files to the composer and wait until ChatGPT has taken them. The
5254
5516
  * files must be readable by the BROWSER process, so a browser on another
@@ -5279,7 +5541,7 @@ export async function attachFilesToComposer(cdp, absolutePaths, options = {}) {
5279
5541
  // this point in that window sometimes. Wait for the input itself, before
5280
5542
  // the chip check (chips render with it) and before reading the DOM tree.
5281
5543
  const waitForComposerFileInput = async () => {
5282
- const inputDeadline = Date.now() + COMPOSER_FILE_INPUT_TIMEOUT_MS;
5544
+ const inputDeadline = Date.now() + COMPOSER_RENDER_TIMEOUT_MS;
5283
5545
  for (;;) {
5284
5546
  try {
5285
5547
  if (await cdp.evaluate(markComposerFileInputExpression()) === true)
@@ -5295,12 +5557,32 @@ export async function attachFilesToComposer(cdp, absolutePaths, options = {}) {
5295
5557
  }
5296
5558
  };
5297
5559
  let marked = await waitForComposerFileInput();
5298
- const stale = await cdp.evaluate(attachmentPresenceExpression());
5299
- if ((stale?.removed ?? 0) > 0) {
5300
- // A fixed sleep and a composer check could both pass on the document being
5301
- // left (a reload takes 5-8 s here), and the file then went into a page that
5302
- // was replaced under it. The marked reload waits for the new document.
5303
- await reloadAndAwaitComposer(cdp, 20_000);
5560
+ const leftoverCount = async () => (await cdp.evaluate(attachmentPresenceExpression()))?.removed ?? 0;
5561
+ if (await leftoverCount() > 0) {
5562
+ // A leftover chip now survives a reload (measured 2026-10-06), and a send
5563
+ // went out carrying it next to the file it was asked for. Its own remove
5564
+ // button works and later attaches still work, so it is clicked first; the
5565
+ // marked reload stays as a fallback, and a chip that survives both stops
5566
+ // the send rather than riding along.
5567
+ for (let attempt = 0; attempt < LEFTOVER_ATTACHMENT_REMOVE_ATTEMPTS; attempt += 1) {
5568
+ const point = await cdp.evaluate(leftoverAttachmentRemovePointExpression());
5569
+ if (!point?.ok || point.x === undefined || point.y === undefined)
5570
+ break;
5571
+ await dispatchMouseClickAt(cdp, point.x, point.y);
5572
+ await sleep(1_000);
5573
+ }
5574
+ if (await leftoverCount() > 0) {
5575
+ await reloadAndAwaitComposer(cdp, 20_000);
5576
+ await waitForComposerFileInput();
5577
+ }
5578
+ if (await leftoverCount() > 0) {
5579
+ throw new ChatGptBrowserBlockerError({
5580
+ code: "leftover_attachment",
5581
+ message: "The ChatGPT composer still holds an attachment from an earlier send, and it could not be removed. Nothing was sent.",
5582
+ retryable: false,
5583
+ next_step: "Remove the attachment from the composer in the dedicated browser, then retry."
5584
+ });
5585
+ }
5304
5586
  marked = await waitForComposerFileInput();
5305
5587
  }
5306
5588
  const document = await cdp.send("DOM.getDocument", { depth: -1, pierce: true });
@@ -5477,7 +5759,11 @@ export function composerTextStateExpression(expectedText, toolLabels = []) {
5477
5759
  if (!raw) return { ok: false, reason: "Composer stayed empty after text insertion" };
5478
5760
  const expected = ${expectedJson};
5479
5761
  if (expected === null) return { ok: true, actualText: raw.slice(0, 120) };
5480
- const norm = (s) => s.replace(/\\s+/g, " ").trim();
5762
+ // Whitespace is left out of the comparison altogether: the editor adds line
5763
+ // breaks of its own, around blank lines and inside an autolinked url
5764
+ // ("[link](" then a break then "https://..."), and leftover text still
5765
+ // differs in the characters that are not whitespace.
5766
+ const norm = (s) => s.replace(/\\s+/g, "");
5481
5767
  if (norm(raw) !== norm(expected)) {
5482
5768
  return { ok: false, reason: "Composer text did not match the prompt after insertion (possible leftover text in the composer)", actualText: raw.slice(0, 120) };
5483
5769
  }
@@ -5504,7 +5790,7 @@ export async function enableComposerTools(cdp, labels) {
5504
5790
  // The server-rendered editor shows before the app attaches the composer's
5505
5791
  // controls (measured 2026-10-06), so a first miss is usually just early.
5506
5792
  let button = await cdp.evaluate(composerToolsButtonRectExpression());
5507
- const buttonDeadline = Date.now() + COMPOSER_FILE_INPUT_TIMEOUT_MS;
5793
+ const buttonDeadline = Date.now() + COMPOSER_RENDER_TIMEOUT_MS;
5508
5794
  while ((!button.ok || button.x === undefined || button.y === undefined) && Date.now() < buttonDeadline) {
5509
5795
  await sleep(500);
5510
5796
  button = await cdp.evaluate(composerToolsButtonRectExpression());
@@ -5748,6 +6034,7 @@ export function pickLandedConversation(candidates, sentPrompt) {
5748
6034
  */
5749
6035
  export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
5750
6036
  const normalize = (value) => value
6037
+ .replace(AUTOLINKED_URL_PATTERN, "$1")
5751
6038
  .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
5752
6039
  .replace(/\s+/g, " ")
5753
6040
  .trim();
@@ -5758,8 +6045,11 @@ export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
5758
6045
  return seen.includes(sent);
5759
6046
  }
5760
6047
  const NORMALIZED_PROMPT_MATCH_CHARS = 120;
6048
+ // The composer can autolink a url before the send, storing "[url](url)" in
6049
+ // place of the bare url (measured 2026-10-07, under load).
6050
+ const AUTOLINKED_URL_PATTERN = /\[(https?:\/\/[^\]\s]+)\]\(\1\)/g;
5761
6051
  function normalizeChatGptPromptText(value) {
5762
- const unescaped = value.replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1");
6052
+ const unescaped = value.replace(AUTOLINKED_URL_PATTERN, "$1").replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1");
5763
6053
  const renderedFences = unescaped.replace(/(^|\r?\n)```text[ \t]*\r?\n([\s\S]*?)\r?\n```(?=\r?\n|$)/g, "$1text\n$2");
5764
6054
  return renderedFences.replace(/\s+/g, " ").trim();
5765
6055
  }
@@ -6027,7 +6317,7 @@ export function answerExpression() {
6027
6317
  const responseChoiceSelector = JSON.stringify(CHATGPT_RESPONSE_CHOICE_SELECTOR);
6028
6318
  const generatingControlPattern = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.source);
6029
6319
  const generatingControlFlags = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.flags);
6030
- return `(() => {
6320
+ return `(() => {${CHATGPT_MODEL_HINTS_JS}
6031
6321
  const text = document.body?.innerText || "";
6032
6322
  const excludedTextSelector = ${excludedTextSelector};
6033
6323
  const blockerScanExcludedSelector = ${blockerScanExcludedSelector};
@@ -6060,7 +6350,8 @@ export function answerExpression() {
6060
6350
  const tagged = node.closest('[data-message-model-slug]');
6061
6351
  if (tagged) modelSlug = tagged.getAttribute('data-message-model-slug') || undefined;
6062
6352
  }
6063
- return { role, node, text: renderedMessageText(textNode), modelSlug };
6353
+ // User turns stay as typed: the request marker is matched against them.
6354
+ return { role, node, text: role === "assistant" ? renderedMessageText(textNode) : userMessageText(textNode), modelSlug };
6064
6355
  });
6065
6356
  const assistantMessages = messages.filter((message) => message.role === "assistant");
6066
6357
  const userMessages = messages.filter((message) => message.role === "user");
@@ -6116,7 +6407,7 @@ export function answerExpression() {
6116
6407
  // ChatGPT tags each assistant message with the model that produced it -
6117
6408
  // the only ground truth for "did the Pro selection actually take".
6118
6409
  modelSlug: assistant ? assistant.modelSlug : undefined,
6119
- modelHints: lines.filter((line) => /GPT|Pro|Thinking|ChatGPT|Extra High|Auto/i.test(line)).slice(0, 30)
6410
+ modelHints: modelHintLines()
6120
6411
  };
6121
6412
  })()`;
6122
6413
  }
package/dist/cli-pro.js CHANGED
@@ -2623,7 +2623,10 @@ export function browserSendBlockerFromError(error) {
2623
2623
  return {
2624
2624
  code: "send_timeout",
2625
2625
  message,
2626
- retryable: true,
2626
+ // A marked request in a thread was posted: resending puts the question in
2627
+ // a second conversation, which next_step already says not to do. The
2628
+ // flag said the opposite (measured 2026-10-07).
2629
+ retryable: !(thread && requestId),
2627
2630
  ...(thread ? { thread } : {}),
2628
2631
  next_step: thread && requestId
2629
2632
  ? `Do not resend automatically. ChatGPT often finishes after prodex gives up; recover that exact request instead: \`prodex pro browser recover --target-url ${thread} --request-id ${requestId}\`.`
@@ -2892,8 +2895,11 @@ export function formatProAnswer(consult, sourceCli, options = {}) {
2892
2895
  }
2893
2896
  export function formatProConsultArtifact(consult) {
2894
2897
  const lines = [`# ChatGPT Pro Consult`, "", `Thread: ${consult.url}`, `Title: ${consult.title}`, ""];
2895
- if (consult.modelHints.length > 0) {
2896
- lines.push("Model hints:", ...consult.modelHints.map((hint) => `- ${hint}`), "");
2898
+ // Filtered again on the way to disk: a saved answer must not carry page text
2899
+ // such as sidebar project names or the request marker.
2900
+ const savedHints = consult.modelHints.filter((hint) => MODEL_HINT_PATTERN.test(hint) && hint.length <= 80 && !hint.includes("prodex-request:"));
2901
+ if (savedHints.length > 0) {
2902
+ lines.push("Model hints:", ...savedHints.map((hint) => `- ${hint}`), "");
2897
2903
  }
2898
2904
  if (consult.warnings.length > 0) {
2899
2905
  lines.push("Warnings:", ...consult.warnings.map((warning) => `- ${warning}`), "");
@@ -3048,18 +3054,19 @@ async function listConsultRecordsNewestFirst(store, options = { readOnly: true }
3048
3054
  }
3049
3055
  export async function listConsultListEntries(store, options = { readOnly: true }) {
3050
3056
  const records = await listConsultRecordsNewestFirst(store, options);
3057
+ // One pass over the receipts for the whole list (see getFinalizedResultsReadOnly).
3058
+ const finalized = await store.getFinalizedResultsReadOnly(records.map((record) => record.result.task_id));
3051
3059
  const entries = [];
3052
3060
  for (const record of records) {
3053
- try {
3054
- entries.push({ kind: "trusted", consult: { ...record, result: await store.getFinalizedResultReadOnly(record.result.task_id) } });
3055
- }
3056
- catch (error) {
3057
- if (isUntrustedResultError(error)) {
3058
- entries.push({ kind: "untrusted", task: record.task, result: record.result, error });
3061
+ const outcome = finalized.get(record.result.task_id);
3062
+ if (outcome instanceof Error || !outcome) {
3063
+ if (isUntrustedResultError(outcome)) {
3064
+ entries.push({ kind: "untrusted", task: record.task, result: record.result, error: outcome });
3059
3065
  continue;
3060
3066
  }
3061
- throw error;
3067
+ throw outcome ?? new Error(`No finalized result was read for ${record.result.task_id}`);
3062
3068
  }
3069
+ entries.push({ kind: "trusted", consult: { ...record, result: outcome } });
3063
3070
  }
3064
3071
  return entries;
3065
3072
  }
@@ -3442,9 +3449,9 @@ export function formatBrowserEarlyExit(exit) {
3442
3449
  return "no exit details";
3443
3450
  return exit.error ?? `exit code ${exit.code ?? "null"}${exit.signal ? ` signal ${exit.signal}` : ""}`;
3444
3451
  }
3452
+ const MODEL_HINT_PATTERN = /\b(?:ChatGPT|GPT(?:-[\w.]+)?|Pro|Plus|Team|Enterprise|Thinking|Instant|Extra High|High|Medium|Auto|Latest)\b/i;
3445
3453
  export function formatBrowserModelHints(modelHints) {
3446
- const modelish = /\b(?:ChatGPT|GPT(?:-[\w.]+)?|Pro|Plus|Team|Enterprise|Thinking|Extra High|Auto)\b/i;
3447
- const hints = [...new Set(modelHints.map((hint) => hint.trim()).filter((hint) => modelish.test(hint)))]
3454
+ const hints = [...new Set(modelHints.map((hint) => hint.trim()).filter((hint) => MODEL_HINT_PATTERN.test(hint)))]
3448
3455
  .map((hint) => (hint.length > 80 ? `${hint.slice(0, 77)}...` : hint))
3449
3456
  .slice(0, 6);
3450
3457
  return hints.length > 0 ? hints.join(" | ") : undefined;
@@ -49,7 +49,11 @@ export async function runSetupCommand(rest, io) {
49
49
  io.stdout("Saved local ChatGPT Developer Mode MCP profile.");
50
50
  io.stdout(`Server URL: ${redactServerUrl(config.server_url)}`);
51
51
  io.stdout(formatTokenExpiryLine(config));
52
- io.stdout("The token is stored (once) in .bridge/config.local.json; print the full URL with `prodex status --show-token --url-only`.");
52
+ // A token without expiry is refused by plain `status --show-token`, which
53
+ // is what setup creates by default; the hint has to be one that works.
54
+ io.stdout(getTokenExpiryStatus(config).status === "valid"
55
+ ? "The token is stored (once) in .bridge/config.local.json; print the full URL with `prodex status --show-token --url-only`."
56
+ : "The token is stored (once) in .bridge/config.local.json; it never expires, so keep it local. Print the full URL with `prodex status --show-token --url-only --unsafe-show-non-expiring-token`, or rerun setup with --token-ttl-hours <hours> before sharing it.");
53
57
  if (config.browser_defaults) {
54
58
  io.stdout(`Browser send defaults: ${formatBrowserDefaults(config.browser_defaults)}`);
55
59
  // Ask the picker whether it can actually provide what was just pinned.
package/dist/store.js CHANGED
@@ -346,6 +346,41 @@ export class BridgeStore {
346
346
  await this.assertTrustedTaskCompletionReceiptReadOnly(taskId, result);
347
347
  return result;
348
348
  }
349
+ /**
350
+ * getFinalizedResultReadOnly for many tasks, reading the receipts once.
351
+ *
352
+ * Each single call reads every receipt to find its task's completion
353
+ * receipt, so listing N consults read the receipt directory N times: 238 s
354
+ * for 260 consults and 969 receipts (measured 2026-10-07). The checks are
355
+ * the same; a task that fails them maps to its error instead of a result.
356
+ */
357
+ async getFinalizedResultsReadOnly(taskIds) {
358
+ const ready = await this.hasReadyStorageDirReadOnly("receipts");
359
+ const byTask = new Map();
360
+ if (ready) {
361
+ for (const receipt of await this.readAll("receipts", parseReceiptRecord, { cleanupTempHardLinks: false })) {
362
+ if (receipt.kind !== "task_completed" || !receipt.task_id)
363
+ continue;
364
+ const list = byTask.get(receipt.task_id) ?? [];
365
+ list.push(receipt);
366
+ byTask.set(receipt.task_id, list);
367
+ }
368
+ }
369
+ const results = new Map();
370
+ for (const taskId of taskIds) {
371
+ try {
372
+ const result = await this.getResultReadOnly(taskId);
373
+ if (!ready)
374
+ throw untrustedResultError(this.root, taskId, "has no trusted task_completed receipt");
375
+ await this.assertReceiptsTrustCompletion(taskId, result, byTask.get(taskId) ?? []);
376
+ results.set(taskId, result);
377
+ }
378
+ catch (error) {
379
+ results.set(taskId, error);
380
+ }
381
+ }
382
+ return results;
383
+ }
349
384
  async resealResult(taskId) {
350
385
  const task = await this.getTask(taskId);
351
386
  if (task.status !== "done" && task.status !== "blocked") {
package/docs/releasing.md CHANGED
@@ -16,6 +16,84 @@ 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.28 Release Record
20
+
21
+ Release commit `8100c37` on `main` (cut after PR #18, merge `8afda89`); tag `v0.40.28`.
22
+
23
+ **Publishing**
24
+ - `publish.yml` run 37432329959 passed all six platforms on the first attempt, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-06 08:11 UTC).
25
+ - PR #18's CI on its docs-only head commit passed macOS Intel only on the third attempt:
26
+ - The first attempt was an unrelated 30 s timeout in `tests/cli-pro-browser-send.test.ts`.
27
+ - The second ended with "npm test" failing without naming a test; the output was cut off mid-run.
28
+ - The code commit before it had passed all six platforms.
29
+ - `npm run release:verify` passed on the release commit after a build.
30
+
31
+ **What it fixes**
32
+ - On a heavily loaded host, the new-chat page and the composer's controls get 30 s ceilings. Before, back-to-back sends were refused as `composer_not_ready` or `fresh_chat_not_ready` before anything was sent.
33
+
34
+ **Installation on 2026-10-06**
35
+ - Global npm, each verified with `prodex --version` = 0.40.28.
36
+ - WSL, M3, Mac mini: installed. Running services were not restarted: 11 stdio MCP servers on WSL and 4 on M3 keep older code until their clients reconnect. The Mac mini had none running.
37
+ - M4: not reachable (hostname does not resolve); not updated.
38
+ - The browser container already runs the same ceilings (see the afternoon rebuild).
39
+
40
+ **Live check after install (installed 0.40.28, shared WSL browser, host load average 5.5)**
41
+ - Three back-to-back `--model Pro --pro-mode` new-chat sends answered.
42
+ - A project send with an attachment answered.
43
+ - A create-image send answered.
44
+
45
+ ## Browser Container Rebuild 2026-10-06 (afternoon)
46
+
47
+ **Patches now committed**
48
+ - The patches the container runs on top of `feat/headless-browser-compatibility` `b5eac7d` are now committed on their own branch, `container/redesign-patches`:
49
+ - `0c3a9cc`: every patch from 2026-09-30 to 2026-10-06.
50
+ - `1233603`: the 30 s composer-render ceiling from this change.
51
+ - They had lived only in a scratch worktree.
52
+ - `feat/headless-browser-compatibility` itself is unchanged.
53
+
54
+ **Image**
55
+ - `sha256:ce0240c0e8a4` was built from `1233603`, and the branch suite passed (2002).
56
+ - The swap was gated on the send lock being free.
57
+
58
+ **Verified**
59
+ - Three back-to-back `--model Pro --pro-mode` new-chat sends answered (42-50 s each).
60
+ - Before this, a real consult from another session had been refused as `composer_not_ready` (`task_20261006_050125`) under host load average 24-44.
61
+
62
+ **Host load**
63
+ - The load came from an unrelated container, `apo-neo4j`, which had restarted 57,058 times because it could not resolve its own hostname. The user is handling it.
64
+
65
+ ## 0.40.27 Release Record
66
+
67
+ Release commit `f84ce2b` on `main` (cut after PR #17, merge `9dad219`); tag `v0.40.27`.
68
+
69
+ **Publishing**
70
+ - `publish.yml` run 37419470087 passed all six platforms on the first attempt, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-06 05:57 UTC).
71
+ - `npm run release:verify` on the release commit passed on its second run. The first failed in package smoke, where the fake DevTools endpoint did not answer within 500 ms while the host load average was about 30 (8 cores).
72
+
73
+ **What it fixes**
74
+ - Source pills in page-read answers are spelled out as markdown links or `[label]`.
75
+ - Recovery waits for the thread's turns instead of refusing its own request right after navigating.
76
+
77
+ **Installation on 2026-10-06**
78
+ - Global npm, each verified with `prodex --version` = 0.40.27.
79
+ - WSL, M3, Mac mini: installed. Running services were not restarted: 5 stdio MCP servers on WSL and 4 on M3 keep older code until their clients reconnect. The Mac mini had none running.
80
+ - M4: not reachable (hostname does not resolve); not updated.
81
+
82
+ **Live check after install (installed 0.40.27, shared WSL browser)**
83
+ - All answered:
84
+ - a new-chat attachment
85
+ - a web-search answer, ending in a markdown source link
86
+ - a project send
87
+ - `pro browser recover` with the tab on another conversation, which returned the answer
88
+
89
+ **Browser container, same day**
90
+ - The pill and recovery fixes were adapted to the branch code.
91
+ - A consult from another session (task `task_20261006_050125`, 05:01 UTC) had been refused with `composer_not_ready`. Back-to-back `--model Pro` new-chat sends reproduced a related failure, `fresh_chat_not_ready`: the new-chat page took more than 15 s to settle with the host load average at 28-32. The branch's new-chat ceiling was raised to 30 s. It is only a ceiling.
92
+ - Image `sha256:2b76266ad524`. The earlier interrupted-looking consult `task_20261006_050200` had finished at 05:07, before the swap.
93
+ - The swap was gated on the send lock being free.
94
+ - Two branch tests failed in the pre-build run, and the build script did not stop on test failures, so the image was built and swapped anyway. One test advanced a fake clock 25 s against the new 30 s ceiling; it was fixed. The other was a load-sensitive project-sidebar test, which passed on rerun. After that, the full branch suite passed (2002). The image's runtime code is the code that passed.
95
+ - Verified: three back-to-back `--model Pro --pro-mode` new-chat sends answered (44-50 s each).
96
+
19
97
  ## 0.40.26 Release Record
20
98
 
21
99
  Release commit `b413c24` on `main` (cut after PR #16, merge `af5cf20`); tag `v0.40.26`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.27",
3
+ "version": "0.40.29",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",