@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.
- package/dist/browser-process.js +32 -1
- package/dist/chatgpt-browser.js +340 -49
- package/dist/cli-pro.js +19 -12
- package/dist/cli-server.js +5 -1
- package/dist/store.js +35 -0
- package/docs/releasing.md +78 -0
- package/package.json +1 -1
package/dist/browser-process.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -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
|
-
*
|
|
195
|
+
* An assistant message as markdown, read from its rendered DOM.
|
|
196
196
|
*
|
|
197
|
-
* Measured 2026-10-06:
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() +
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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() +
|
|
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
|
|
5299
|
-
if ((
|
|
5300
|
-
// A
|
|
5301
|
-
//
|
|
5302
|
-
//
|
|
5303
|
-
|
|
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
|
-
|
|
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() +
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2896
|
-
|
|
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
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
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
|
|
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
|
|
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;
|
package/dist/cli-server.js
CHANGED
|
@@ -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
|
-
|
|
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`.
|