@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.
- package/dist/browser-process.js +32 -1
- package/dist/chatgpt-browser.js +326 -43
- package/dist/cli-pro.js +19 -12
- package/dist/cli-server.js +5 -1
- package/dist/store.js +35 -0
- package/docs/releasing.md +26 -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
5307
|
-
if ((
|
|
5308
|
-
// A
|
|
5309
|
-
//
|
|
5310
|
-
//
|
|
5311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,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**
|