@youdie006/prodex 0.40.28 → 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
@@ -1671,7 +1875,9 @@ async function openChatGptThread(cdp, url) {
1671
1875
  if (await cdp.evaluate(chatGptThreadReadyExpression(conversationId)))
1672
1876
  return;
1673
1877
  await cdp.evaluate(`location.assign(${JSON.stringify(url)})`);
1674
- 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;
1675
1881
  while (Date.now() < deadline) {
1676
1882
  await sleep(250);
1677
1883
  try {
@@ -2473,6 +2679,20 @@ const POWER_SLIDER_SWALLOWED_PRESS_RETRIES = 6;
2473
2679
  * Anything else (a menu that would not open, a click that would not land) is a
2474
2680
  * real failure and stays one, so a genuine break is not buried under a warning.
2475
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
+ }
2476
2696
  export function modelSelectionUnavailableWarning(requested, reason, available) {
2477
2697
  if (!/menu item not found|not clickable/i.test(reason))
2478
2698
  return undefined;
@@ -2607,7 +2827,15 @@ async function selectPickerModel(cdp, requested, warnings = []) {
2607
2827
  return;
2608
2828
  const hit = await cdp.evaluate(menuItemRectExpression(requested));
2609
2829
  if (!hit.ok || hit.x === undefined || hit.y === undefined) {
2610
- 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));
2611
2839
  if (unavailable) {
2612
2840
  warnings.push(unavailable);
2613
2841
  return;
@@ -3956,7 +4184,7 @@ export async function sendChatGptPrompt(options) {
3956
4184
  // prompt posts the composer clears and no send button is found.
3957
4185
  const promptPostedExpression = `(() => {${CHATGPT_MESSAGE_NODES_JS}
3958
4186
  const last = chatMessageNodes().filter((message) => message.role === "user").at(-1);
3959
- 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}]`)}));
3960
4188
  })()`;
3961
4189
  // A dispatch can reach Chrome even if its acknowledgement is lost.
3962
4190
  submissionAttempted = true;
@@ -4087,7 +4315,9 @@ export async function sendChatGptPrompt(options) {
4087
4315
  code: "thread_navigated_away",
4088
4316
  message: "The browser tab was moved to a different ChatGPT conversation while this consult was waiting for its answer.",
4089
4317
  retryable: false,
4090
- 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}\`.`,
4091
4321
  thread: pinnedThreadUrl
4092
4322
  });
4093
4323
  }
@@ -4332,7 +4562,7 @@ export function resolveConversationToDelete(conversations, request) {
4332
4562
  * The port cannot tell a dead browser from an absent one; the process list can.
4333
4563
  */
4334
4564
  export function findLaunchedBrowserProcesses(psOutput, input) {
4335
- return findBrowserProcessesByPort(parsePosixProcessList(psOutput), {
4565
+ return findBrowserProcessesByPort(inOwnNetworkNamespace(parsePosixProcessList(psOutput)), {
4336
4566
  platform: "linux",
4337
4567
  port: input.port,
4338
4568
  fallbackProfileDir: input.profileDir
@@ -4633,7 +4863,14 @@ export async function listChatGptModelOptions(input = {}) {
4633
4863
  input.signal?.addEventListener("abort", abandon, { once: true });
4634
4864
  try {
4635
4865
  await cdp.send("Runtime.enable");
4636
- 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
+ }
4637
4874
  if (!button.ok || button.x === undefined || button.y === undefined) {
4638
4875
  throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
4639
4876
  }
@@ -5027,7 +5264,7 @@ export function statusExpression() {
5027
5264
  const responseChoiceSelector = JSON.stringify(CHATGPT_RESPONSE_CHOICE_SELECTOR);
5028
5265
  const generatingControlPattern = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.source);
5029
5266
  const generatingControlFlags = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.flags);
5030
- return `(() => {
5267
+ return `(() => {${CHATGPT_MODEL_HINTS_JS}
5031
5268
  ${composerExpressionHelpers()}
5032
5269
  const text = document.body?.innerText || "";
5033
5270
  const runtimeExcludedTextSelector = ${excludedTextSelector};
@@ -5061,7 +5298,7 @@ export function statusExpression() {
5061
5298
  ${CHATGPT_MESSAGE_NODES_JS}
5062
5299
  const messages = chatMessageNodes().map((message) => ({
5063
5300
  role: message.role,
5064
- text: message.textNode.innerText || ""
5301
+ text: message.role === "assistant" ? renderedMessageText(message.textNode) : userMessageText(message.textNode)
5065
5302
  }));
5066
5303
  const assistant = messages.filter((message) => message.role === "assistant").at(-1);
5067
5304
  const answer = assistant?.text || "";
@@ -5080,7 +5317,7 @@ export function statusExpression() {
5080
5317
  hasComposer,
5081
5318
  generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || visibleButtonLabels.some((label) => generatingControlPattern.test(label)),
5082
5319
  awaitingResponseChoice: Boolean(document.querySelector(${responseChoiceSelector})),
5083
- modelHints: lines.filter((line) => /GPT|Pro|Thinking|ChatGPT|Extra High|Auto/i.test(line)).slice(0, 30),
5320
+ modelHints: modelHintLines(),
5084
5321
  openDialogText: (([...document.querySelectorAll('[role="dialog"]')].find((d) => d.offsetWidth || d.offsetHeight || d.getClientRects().length)?.innerText) || "").trim().slice(0, 200)
5085
5322
  };
5086
5323
  })()`;
@@ -5166,6 +5403,7 @@ export function composerFileInputSelector() {
5166
5403
  return 'input[type="file"]:not([accept*="image"])';
5167
5404
  }
5168
5405
  const COMPOSER_TOOLS_MENU_ATTEMPTS = 3;
5406
+ const LEFTOVER_ATTACHMENT_REMOVE_ATTEMPTS = 5;
5169
5407
  export const PRODEX_ATTACH_INPUT_ATTRIBUTE = "data-prodex-attach-input";
5170
5408
  /**
5171
5409
  * Tags the general file input of the form that holds the VISIBLE composer, so
@@ -5257,6 +5495,22 @@ export function attachmentPresenceExpression() {
5257
5495
  return { ok: true, removed: buttons.length };
5258
5496
  })()`;
5259
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
+ }
5260
5514
  /**
5261
5515
  * Attach files to the composer and wait until ChatGPT has taken them. The
5262
5516
  * files must be readable by the BROWSER process, so a browser on another
@@ -5303,12 +5557,32 @@ export async function attachFilesToComposer(cdp, absolutePaths, options = {}) {
5303
5557
  }
5304
5558
  };
5305
5559
  let marked = await waitForComposerFileInput();
5306
- const stale = await cdp.evaluate(attachmentPresenceExpression());
5307
- if ((stale?.removed ?? 0) > 0) {
5308
- // A fixed sleep and a composer check could both pass on the document being
5309
- // left (a reload takes 5-8 s here), and the file then went into a page that
5310
- // was replaced under it. The marked reload waits for the new document.
5311
- 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
+ }
5312
5586
  marked = await waitForComposerFileInput();
5313
5587
  }
5314
5588
  const document = await cdp.send("DOM.getDocument", { depth: -1, pierce: true });
@@ -5485,7 +5759,11 @@ export function composerTextStateExpression(expectedText, toolLabels = []) {
5485
5759
  if (!raw) return { ok: false, reason: "Composer stayed empty after text insertion" };
5486
5760
  const expected = ${expectedJson};
5487
5761
  if (expected === null) return { ok: true, actualText: raw.slice(0, 120) };
5488
- 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, "");
5489
5767
  if (norm(raw) !== norm(expected)) {
5490
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) };
5491
5769
  }
@@ -5756,6 +6034,7 @@ export function pickLandedConversation(candidates, sentPrompt) {
5756
6034
  */
5757
6035
  export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
5758
6036
  const normalize = (value) => value
6037
+ .replace(AUTOLINKED_URL_PATTERN, "$1")
5759
6038
  .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
5760
6039
  .replace(/\s+/g, " ")
5761
6040
  .trim();
@@ -5766,8 +6045,11 @@ export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
5766
6045
  return seen.includes(sent);
5767
6046
  }
5768
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;
5769
6051
  function normalizeChatGptPromptText(value) {
5770
- const unescaped = value.replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1");
6052
+ const unescaped = value.replace(AUTOLINKED_URL_PATTERN, "$1").replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1");
5771
6053
  const renderedFences = unescaped.replace(/(^|\r?\n)```text[ \t]*\r?\n([\s\S]*?)\r?\n```(?=\r?\n|$)/g, "$1text\n$2");
5772
6054
  return renderedFences.replace(/\s+/g, " ").trim();
5773
6055
  }
@@ -6035,7 +6317,7 @@ export function answerExpression() {
6035
6317
  const responseChoiceSelector = JSON.stringify(CHATGPT_RESPONSE_CHOICE_SELECTOR);
6036
6318
  const generatingControlPattern = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.source);
6037
6319
  const generatingControlFlags = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.flags);
6038
- return `(() => {
6320
+ return `(() => {${CHATGPT_MODEL_HINTS_JS}
6039
6321
  const text = document.body?.innerText || "";
6040
6322
  const excludedTextSelector = ${excludedTextSelector};
6041
6323
  const blockerScanExcludedSelector = ${blockerScanExcludedSelector};
@@ -6068,7 +6350,8 @@ export function answerExpression() {
6068
6350
  const tagged = node.closest('[data-message-model-slug]');
6069
6351
  if (tagged) modelSlug = tagged.getAttribute('data-message-model-slug') || undefined;
6070
6352
  }
6071
- 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 };
6072
6355
  });
6073
6356
  const assistantMessages = messages.filter((message) => message.role === "assistant");
6074
6357
  const userMessages = messages.filter((message) => message.role === "user");
@@ -6124,7 +6407,7 @@ export function answerExpression() {
6124
6407
  // ChatGPT tags each assistant message with the model that produced it -
6125
6408
  // the only ground truth for "did the Pro selection actually take".
6126
6409
  modelSlug: assistant ? assistant.modelSlug : undefined,
6127
- modelHints: lines.filter((line) => /GPT|Pro|Thinking|ChatGPT|Extra High|Auto/i.test(line)).slice(0, 30)
6410
+ modelHints: modelHintLines()
6128
6411
  };
6129
6412
  })()`;
6130
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,32 @@ 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
+
19
45
  ## Browser Container Rebuild 2026-10-06 (afternoon)
20
46
 
21
47
  **Patches now committed**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.28",
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",