@youdie006/prodex 0.36.5 → 0.37.0
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-diagnostics.js +104 -0
- package/dist/chatgpt-browser.js +113 -3
- package/dist/cli-args.js +1 -0
- package/dist/cli-help.js +4 -4
- package/dist/cli-pro.js +60 -0
- package/dist/cli-server.js +18 -0
- package/dist/issue-report.js +111 -0
- package/package.json +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture what the page looked like when a send failed.
|
|
3
|
+
*
|
|
4
|
+
* Every UI break in this project has cost a live debugging session: opening the
|
|
5
|
+
* picker by hand, reading rects, asking elementFromPoint what was on top. A
|
|
6
|
+
* report from another machine cannot carry any of that, so the same archaeology
|
|
7
|
+
* gets repeated by whoever can reproduce it.
|
|
8
|
+
*
|
|
9
|
+
* These stay LOCAL and are never attached to anything. A screenshot of ChatGPT
|
|
10
|
+
* shows the conversation, so the capture is a debugging aid for the person who
|
|
11
|
+
* hit the failure, not something to hand out - the same line `pro report-issue`
|
|
12
|
+
* already draws.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
/** Whether the caller asked for captures. Off unless explicitly turned on. */
|
|
17
|
+
export function diagnosticsEnabled(env = process.env) {
|
|
18
|
+
const raw = env.PRODEX_BROWSER_DIAGNOSTICS;
|
|
19
|
+
return raw !== undefined && raw !== "" && raw !== "0" && raw.toLowerCase() !== "false";
|
|
20
|
+
}
|
|
21
|
+
/** Where a capture for this failure belongs. */
|
|
22
|
+
export function diagnosticsDir(cwd, label) {
|
|
23
|
+
const safe = label.replace(/[^A-Za-z0-9_.-]+/g, "-").slice(0, 80) || "capture";
|
|
24
|
+
return path.join(cwd, ".bridge", "diagnostics", safe);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* What the picker and composer looked like: the shape prodex needs in order to
|
|
28
|
+
* drive them, read the way the failing code reads it.
|
|
29
|
+
*/
|
|
30
|
+
export function pageShapeExpression() {
|
|
31
|
+
return `(() => {
|
|
32
|
+
const vis = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
|
33
|
+
const box = (el) => { const r = el.getBoundingClientRect(); return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }; };
|
|
34
|
+
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
35
|
+
const describe = (el) => ({
|
|
36
|
+
role: el.getAttribute("role"),
|
|
37
|
+
testid: el.getAttribute("data-testid"),
|
|
38
|
+
label: (el.innerText || "").replace(/\\n+/g, " / ").trim().slice(0, 60),
|
|
39
|
+
checked: el.getAttribute("aria-checked"),
|
|
40
|
+
haspopup: el.getAttribute("aria-haspopup"),
|
|
41
|
+
valuenow: el.getAttribute("aria-valuenow"),
|
|
42
|
+
valuemax: el.getAttribute("aria-valuemax"),
|
|
43
|
+
valuetext: el.getAttribute("aria-valuetext"),
|
|
44
|
+
// The reason a click can be refused at every coordinate on a row.
|
|
45
|
+
pointerEvents: getComputedStyle(el).pointerEvents,
|
|
46
|
+
...box(el)
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
url: location.href,
|
|
50
|
+
title: document.title,
|
|
51
|
+
viewport: { w: window.innerWidth, h: window.innerHeight },
|
|
52
|
+
pickerOpen: Boolean(menu),
|
|
53
|
+
picker: menu ? { ...box(menu), items: [...menu.querySelectorAll('[role],button')].filter(vis).map(describe) } : null,
|
|
54
|
+
composerButtons: [...document.querySelectorAll('form button,form [role="button"]')].filter(vis).map(describe)
|
|
55
|
+
};
|
|
56
|
+
})()`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Write a screenshot and a shape snapshot next to each other. Failing to
|
|
60
|
+
* capture must never replace the failure being captured, so every step here is
|
|
61
|
+
* best effort and the caller gets back only what actually landed.
|
|
62
|
+
*/
|
|
63
|
+
export async function captureBrowserDiagnostics(target, input) {
|
|
64
|
+
const dir = diagnosticsDir(input.cwd, input.label);
|
|
65
|
+
const files = [];
|
|
66
|
+
try {
|
|
67
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const shape = await target.evaluate(pageShapeExpression());
|
|
74
|
+
const file = path.join(dir, "page-shape.json");
|
|
75
|
+
await writeFile(file, `${JSON.stringify(shape, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
76
|
+
files.push(file);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// A page that cannot answer is itself worth knowing, but not worth failing over.
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
// Some targets refuse captureScreenshot until the Page domain is on, and the
|
|
83
|
+
// failure is silent - the first capture written here came back with the
|
|
84
|
+
// shape and no image.
|
|
85
|
+
await target.send("Page.enable").catch(() => undefined);
|
|
86
|
+
const shot = (await target.send("Page.captureScreenshot", { format: "png" }));
|
|
87
|
+
if (typeof shot?.data === "string" && shot.data.length > 0) {
|
|
88
|
+
const file = path.join(dir, "screen.png");
|
|
89
|
+
await writeFile(file, Buffer.from(shot.data, "base64"), { mode: 0o600 });
|
|
90
|
+
files.push(file);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Screenshots are unavailable on some targets; the shape snapshot still helps.
|
|
95
|
+
}
|
|
96
|
+
return files.length > 0 ? { dir, files } : undefined;
|
|
97
|
+
}
|
|
98
|
+
/** One line telling the user where the capture went, and that it stays put. */
|
|
99
|
+
export function diagnosticsNote(capture) {
|
|
100
|
+
if (!capture)
|
|
101
|
+
return undefined;
|
|
102
|
+
return (`browser_diagnostics: wrote ${capture.files.length} file(s) to ${capture.dir}. ` +
|
|
103
|
+
"They stay on this machine - a screenshot of ChatGPT shows the conversation, so nothing attaches them to a report.");
|
|
104
|
+
}
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -4,6 +4,7 @@ import { accessSync, constants, statSync } from "node:fs";
|
|
|
4
4
|
import net from "node:net";
|
|
5
5
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { captureBrowserDiagnostics, diagnosticsEnabled, diagnosticsNote } from "./browser-diagnostics.js";
|
|
7
8
|
import os from "node:os";
|
|
8
9
|
import { readPowerSliderSelection } from "./picker-interaction.js";
|
|
9
10
|
export class ChatGptBrowserBlockerError extends Error {
|
|
@@ -1443,7 +1444,7 @@ export function menuItemRectExpression(label) {
|
|
|
1443
1444
|
if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
|
|
1444
1445
|
const items = [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
|
|
1445
1446
|
const it = items.find(${menuLabelMatchPredicate(label)});
|
|
1446
|
-
if (!it) return { ok: false, reason: "menu item not found", available: items.map((r) => ((r.innerText || r.textContent || "").trim().split(String.fromCharCode(10))[0] || "").trim()).slice(0, 12) };
|
|
1447
|
+
if (!it) return { ok: false, reason: "menu item not found", available: items.map((r) => ((r.innerText || r.textContent || "").trim().split(String.fromCharCode(10))[0] || "").trim()).filter((label) => label.length > 0).slice(0, 12) };
|
|
1447
1448
|
const point = clickPoint(it);
|
|
1448
1449
|
if (!point.ok) return point;
|
|
1449
1450
|
return { ...point, role: it.getAttribute("role"), haspopup: it.getAttribute("aria-haspopup") };
|
|
@@ -1597,6 +1598,66 @@ export function modelSelectionUnavailableWarning(requested, reason, available) {
|
|
|
1597
1598
|
`so the send used whatever the composer already had.${offers} ` +
|
|
1598
1599
|
'Pick an effort with --effort instead, or clear a saved default with `prodex setup --model ""`.');
|
|
1599
1600
|
}
|
|
1601
|
+
/**
|
|
1602
|
+
* A step the slider does not have.
|
|
1603
|
+
*
|
|
1604
|
+
* Reported from another machine: that account's picker has no "Pro" step at all
|
|
1605
|
+
* - five attempts, the same list every time - so a pinned default of model=Pro
|
|
1606
|
+
* became a step request the slider could not satisfy and every plain send died.
|
|
1607
|
+
* The picker declining to offer something is not prodex breaking, so it reads
|
|
1608
|
+
* the same way the model case does: warn, and let the send go.
|
|
1609
|
+
*/
|
|
1610
|
+
/**
|
|
1611
|
+
* A pinned default the picker does not offer.
|
|
1612
|
+
*
|
|
1613
|
+
* This is how prodex broke for someone else: model=Pro was pinned back when Pro
|
|
1614
|
+
* was a model, ChatGPT turned it into an effort step and dropped it from some
|
|
1615
|
+
* accounts entirely, and every plain send there failed afterwards. The picker
|
|
1616
|
+
* knows what it offers, so asking it once - when the default is set - beats
|
|
1617
|
+
* finding out on every send for weeks.
|
|
1618
|
+
*/
|
|
1619
|
+
/**
|
|
1620
|
+
* What a temporary chat costs.
|
|
1621
|
+
*
|
|
1622
|
+
* Measured: the chat list is byte-identical before and after, which is the
|
|
1623
|
+
* point - but the answer arrives without the "(transcript ...)" a normal send
|
|
1624
|
+
* reports, because the transcript API does not hold a chat that was never
|
|
1625
|
+
* saved. So the answer is read off the page, which is where prodex loses
|
|
1626
|
+
* markdown tables and citation urls. Trading fidelity for privacy is a fine
|
|
1627
|
+
* trade to offer and a bad one to make silently.
|
|
1628
|
+
*/
|
|
1629
|
+
export function temporaryChatWarning(temporary) {
|
|
1630
|
+
if (!temporary)
|
|
1631
|
+
return undefined;
|
|
1632
|
+
return ("temporary_chat: this chat is not saved, so the answer was read from the page rather than the transcript - " +
|
|
1633
|
+
"tables and citation links can be lost - and neither `pro browser recover` nor `--target-url` can reach it later.");
|
|
1634
|
+
}
|
|
1635
|
+
export function pinnedSelectionWarning(pinned, offered) {
|
|
1636
|
+
// An unreadable picker is not evidence that the pin is wrong.
|
|
1637
|
+
if (offered.length === 0)
|
|
1638
|
+
return undefined;
|
|
1639
|
+
const has = (wanted) => offered.some((label) => label.trim().toLowerCase() === wanted.trim().toLowerCase());
|
|
1640
|
+
const missing = [pinned.model, pinned.effort].find((wanted) => wanted !== undefined && !has(wanted));
|
|
1641
|
+
if (missing === undefined)
|
|
1642
|
+
return undefined;
|
|
1643
|
+
// Say only what was seen. The picker lists its models, but the effort slider
|
|
1644
|
+
// shows one step at a time - reading the others means moving it, which would
|
|
1645
|
+
// change the user's setting just to look. So this reports that the pin was not
|
|
1646
|
+
// among what the picker LISTED, not that the account cannot provide it.
|
|
1647
|
+
return (`pinned_selection_check: "${missing}" was not among what the picker listed (${offered.join(", ")}). ` +
|
|
1648
|
+
"The effort slider only shows its current step, so a step by that name may still exist. " +
|
|
1649
|
+
"If sends start warning that it was not applied, pin one of the listed names or clear it with `prodex setup --clear-model`.");
|
|
1650
|
+
}
|
|
1651
|
+
export function stepSelectionUnavailableWarning(requested, offered) {
|
|
1652
|
+
if (!requested)
|
|
1653
|
+
return undefined;
|
|
1654
|
+
// Quote what the page said. The browser that hit this runs in Korean, so the
|
|
1655
|
+
// steps come back translated and an English label prodex expected would be
|
|
1656
|
+
// useless to the person reading the warning.
|
|
1657
|
+
const list = offered.length ? ` It offers: ${offered.join(", ")}.` : "";
|
|
1658
|
+
return (`step_not_applied: this ChatGPT picker has no "${requested}" step, so the send used the slider's current setting.${list} ` +
|
|
1659
|
+
'Pick one of those with --effort, or clear a saved default with `prodex setup --clear-model`.');
|
|
1660
|
+
}
|
|
1600
1661
|
async function selectPickerModel(cdp, requested, warnings = []) {
|
|
1601
1662
|
const hit = await cdp.evaluate(menuItemRectExpression(requested));
|
|
1602
1663
|
if (!hit.ok || hit.x === undefined || hit.y === undefined) {
|
|
@@ -1725,7 +1786,19 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
1725
1786
|
if (plan.warning)
|
|
1726
1787
|
selectionWarnings.push(plan.warning);
|
|
1727
1788
|
if (plan.sliderLabel) {
|
|
1728
|
-
|
|
1789
|
+
try {
|
|
1790
|
+
await selectPowerStep(cdp, plan.sliderLabel);
|
|
1791
|
+
}
|
|
1792
|
+
catch (error) {
|
|
1793
|
+
// Only "this slider has no such step" is the picker declining. A
|
|
1794
|
+
// slider that will not open, or will not move, is a real failure.
|
|
1795
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1796
|
+
const noSuchStep = /has no "[^"]*" step/.test(message);
|
|
1797
|
+
if (!noSuchStep)
|
|
1798
|
+
throw error;
|
|
1799
|
+
const offered = /It showed: (.*)$/.exec(message)?.[1]?.split(" / ").map((part) => part.trim()) ?? [];
|
|
1800
|
+
selectionWarnings.push(stepSelectionUnavailableWarning(plan.sliderLabel, offered));
|
|
1801
|
+
}
|
|
1729
1802
|
}
|
|
1730
1803
|
if (plan.modelLabel) {
|
|
1731
1804
|
await selectPickerModel(cdp, plan.modelLabel, selectionWarnings);
|
|
@@ -2195,7 +2268,11 @@ export async function sendChatGptPrompt(options) {
|
|
|
2195
2268
|
// thread silently lands outside the project (measured live: --new-chat
|
|
2196
2269
|
// --project threads appeared in the root chat list, --project-only
|
|
2197
2270
|
// threads appeared inside the project).
|
|
2198
|
-
|
|
2271
|
+
// A temporary chat is reached by url rather than by clicking the control:
|
|
2272
|
+
// the same navigation this already does, one query parameter different, and
|
|
2273
|
+
// nothing to find on a page whose buttons keep moving.
|
|
2274
|
+
const freshUrl = options.temporary ? "https://chatgpt.com/?temporary-chat=true" : "https://chatgpt.com/";
|
|
2275
|
+
await evaluateOnPage(page, `location.assign(${JSON.stringify(freshUrl)})`);
|
|
2199
2276
|
await waitForFreshChatGptPage(page, 8_000);
|
|
2200
2277
|
}
|
|
2201
2278
|
let status = await readSettledChatGptPageStatus(page);
|
|
@@ -2277,7 +2354,33 @@ export async function sendChatGptPrompt(options) {
|
|
|
2277
2354
|
let submitButtonFound = false;
|
|
2278
2355
|
let wantsDeepResearch = false;
|
|
2279
2356
|
const sendWarnings = [];
|
|
2357
|
+
const temporaryNote = temporaryChatWarning(options.temporary);
|
|
2358
|
+
if (temporaryNote)
|
|
2359
|
+
sendWarnings.push(temporaryNote);
|
|
2280
2360
|
const cdp = await connectCdp(page.webSocketDebuggerUrl);
|
|
2361
|
+
// With diagnostics on, a send that fails leaves behind what the page looked
|
|
2362
|
+
// like: the shape prodex reads plus a screenshot. Every UI break in this
|
|
2363
|
+
// project has otherwise cost a live debugging session, and a report from
|
|
2364
|
+
// another machine can carry none of that. Local only - see browser-diagnostics.
|
|
2365
|
+
const captureOnFailure = async (label) => {
|
|
2366
|
+
if (!diagnosticsEnabled())
|
|
2367
|
+
return;
|
|
2368
|
+
const capture = await captureBrowserDiagnostics({
|
|
2369
|
+
evaluate: (expression) => evaluateOnPage(page, expression),
|
|
2370
|
+
// cdp.send resolves with the whole CDP message; the command's own result
|
|
2371
|
+
// is one level in. Passing the envelope through meant the screenshot
|
|
2372
|
+
// bytes were never found and the capture silently held only the shape.
|
|
2373
|
+
send: async (method, params) => (await cdp.send(method, params ?? {})).result
|
|
2374
|
+
}, { cwd: options.diagnosticsCwd ?? process.cwd(), label }).catch(() => undefined);
|
|
2375
|
+
const note = diagnosticsNote(capture);
|
|
2376
|
+
// Warnings only reach the caller on success, and this runs when the send is
|
|
2377
|
+
// about to throw - so the note goes out through progress, which the CLI
|
|
2378
|
+
// prints as it happens.
|
|
2379
|
+
if (note) {
|
|
2380
|
+
sendWarnings.push(note);
|
|
2381
|
+
emitProgress("waiting", note);
|
|
2382
|
+
}
|
|
2383
|
+
};
|
|
2281
2384
|
try {
|
|
2282
2385
|
await cdp.send("Runtime.enable");
|
|
2283
2386
|
await selectProject(cdp, options);
|
|
@@ -2375,6 +2478,13 @@ export async function sendChatGptPrompt(options) {
|
|
|
2375
2478
|
}
|
|
2376
2479
|
}
|
|
2377
2480
|
}
|
|
2481
|
+
catch (error) {
|
|
2482
|
+
// Capture before the connection closes: this is the moment the page still
|
|
2483
|
+
// looks the way it looked when it refused, and the selection failures this
|
|
2484
|
+
// project keeps hitting are invisible in the error text alone.
|
|
2485
|
+
await captureOnFailure(`send-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
2486
|
+
throw error;
|
|
2487
|
+
}
|
|
2378
2488
|
finally {
|
|
2379
2489
|
cdp.close();
|
|
2380
2490
|
}
|
package/dist/cli-args.js
CHANGED
package/dist/cli-help.js
CHANGED
|
@@ -26,7 +26,7 @@ Ask / consult commands:
|
|
|
26
26
|
prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of model menu options
|
|
27
27
|
prodex pro browser projects [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of sidebar project names (for --project)
|
|
28
28
|
prodex pro browser recover [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--timeout-ms 60000] # recover a finished answer from a thread whose send timed out
|
|
29
|
-
prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
|
|
29
|
+
prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--temporary] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
|
|
30
30
|
prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
|
|
31
31
|
prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
|
|
32
32
|
prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
|
|
@@ -167,7 +167,7 @@ Commands:
|
|
|
167
167
|
prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
|
|
168
168
|
prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
|
|
169
169
|
prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js]
|
|
170
|
-
prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt"
|
|
170
|
+
prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--target-url url --confirm-target] [--new-chat] [--temporary] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt"
|
|
171
171
|
prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
|
|
172
172
|
prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
|
|
173
173
|
prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
|
|
@@ -253,8 +253,8 @@ export function printProBrowserHelp(stdout, sourceCli) {
|
|
|
253
253
|
: "prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]";
|
|
254
254
|
const selectionUsage = '[--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"]';
|
|
255
255
|
const askUsage = sourceCli
|
|
256
|
-
? `${cli} pro browser ask${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`
|
|
257
|
-
: `prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`;
|
|
256
|
+
? `${cli} pro browser ask${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--temporary] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`
|
|
257
|
+
: `prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--temporary] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`;
|
|
258
258
|
const modelsUsage = sourceCli
|
|
259
259
|
? `${cli} pro browser models${sourceCliOption} [--port 9333] [--timeout-ms 15000]`
|
|
260
260
|
: "prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000]";
|
package/dist/cli-pro.js
CHANGED
|
@@ -11,6 +11,8 @@ import { errorMessage, firstLine, formatBlockedConsultRecordedMessage, formatPro
|
|
|
11
11
|
import { getTokenExpiryStatus, loadBrowserDefaults, loadLocalConfig } from "./config.js";
|
|
12
12
|
import { withBrowserSendLock } from "./browser-send-lock.js";
|
|
13
13
|
import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
|
|
14
|
+
import { CLI_VERSION } from "./cli-help.js";
|
|
15
|
+
import { PRODEX_ISSUE_REPO, buildIssueReport, fileGitHubIssue } from "./issue-report.js";
|
|
14
16
|
export async function runChatgptCommand(rest, io) {
|
|
15
17
|
const [subcommand, ...chatgptArgs] = rest;
|
|
16
18
|
if (!subcommand || isHelpSubcommand(subcommand)) {
|
|
@@ -157,6 +159,7 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
157
159
|
"--pro-mode",
|
|
158
160
|
"--effort",
|
|
159
161
|
"--new-chat",
|
|
162
|
+
"--temporary",
|
|
160
163
|
"--auto-login",
|
|
161
164
|
"--no-auto-login"
|
|
162
165
|
].find((flag) => proArgs.includes(flag));
|
|
@@ -739,6 +742,43 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
739
742
|
}
|
|
740
743
|
return 0;
|
|
741
744
|
}
|
|
745
|
+
if (subcommand === "report-issue") {
|
|
746
|
+
if (printHelpIfRequested(proArgs, "pro report-issue", io.stdout, printProHelp, { valueFlags: ["--cwd", "--task", "--repo"] }))
|
|
747
|
+
return 0;
|
|
748
|
+
assertOnlyOptions(proArgs, "pro report-issue", ["--cwd", "--task", "--repo"], ["--confirm"]);
|
|
749
|
+
const targetCwd = resolveCwdFlag(io.cwd, proArgs);
|
|
750
|
+
const targetStore = new BridgeStore(targetCwd);
|
|
751
|
+
const wantedTask = readFlag(proArgs, "--task");
|
|
752
|
+
const record = wantedTask
|
|
753
|
+
? await (async () => {
|
|
754
|
+
const result = await targetStore.getResultReadOnly(wantedTask);
|
|
755
|
+
const task = await targetStore.getTaskReadOnly(wantedTask);
|
|
756
|
+
return { task, result };
|
|
757
|
+
})()
|
|
758
|
+
: await latestBlockedConsult(targetStore);
|
|
759
|
+
if (!record)
|
|
760
|
+
throw new Error("No blocked consult found to report. Nothing failed, so there is nothing to file.");
|
|
761
|
+
const report = buildIssueReport({
|
|
762
|
+
task_id: record.result.task_id,
|
|
763
|
+
status: record.result.status,
|
|
764
|
+
...(record.result.blocker ? { blocker: record.result.blocker } : {})
|
|
765
|
+
}, { version: CLI_VERSION, platform: process.platform, nodeVersion: process.version });
|
|
766
|
+
io.stdout(`title: ${report.title}`);
|
|
767
|
+
io.stdout(`labels: ${report.labels.join(", ")}`);
|
|
768
|
+
io.stdout("");
|
|
769
|
+
io.stdout(report.body);
|
|
770
|
+
io.stdout("");
|
|
771
|
+
// Filing is outward-facing and public, so it never happens as a side
|
|
772
|
+
// effect of looking. The preview above is the whole report.
|
|
773
|
+
if (!proArgs.includes("--confirm")) {
|
|
774
|
+
io.stdout("Nothing was filed. Re-run with --confirm to open this as a GitHub issue.");
|
|
775
|
+
return 0;
|
|
776
|
+
}
|
|
777
|
+
const repo = readFlag(proArgs, "--repo") ?? PRODEX_ISSUE_REPO;
|
|
778
|
+
const filed = await fileGitHubIssue(repo, report);
|
|
779
|
+
io.stdout(`filed: ${filed}`);
|
|
780
|
+
return 0;
|
|
781
|
+
}
|
|
742
782
|
if (subcommand === "latest") {
|
|
743
783
|
if (printHelpIfRequested(proArgs, "pro latest", io.stdout, printProHelp, { valueFlags: ["--cwd", "--source-cli"] }))
|
|
744
784
|
return 0;
|
|
@@ -990,6 +1030,14 @@ export async function runAskProCommand(rest, io) {
|
|
|
990
1030
|
throw new Error("ask-pro cannot combine --target-url with --project/--project-new: --target-url pins the confirmed tab while the project step navigates the sidebar away from it. Open the project thread in the browser and pass its URL as --target-url instead.");
|
|
991
1031
|
}
|
|
992
1032
|
const newChat = parsedAskPro.optionArgs.includes("--new-chat");
|
|
1033
|
+
// A temporary chat is not saved, so there is nothing to come back to: the
|
|
1034
|
+
// recovery path every timeout message points at cannot fetch it later.
|
|
1035
|
+
// Requiring --new-chat keeps that explicit rather than quietly turning a
|
|
1036
|
+
// continuation into a throwaway.
|
|
1037
|
+
const temporary = parsedAskPro.optionArgs.includes("--temporary");
|
|
1038
|
+
if (temporary && !newChat) {
|
|
1039
|
+
throw new Error("--temporary starts a throwaway chat, so it needs --new-chat. A temporary chat cannot be continued or recovered later.");
|
|
1040
|
+
}
|
|
993
1041
|
if (newChat && normalizedTargetUrl) {
|
|
994
1042
|
throw new Error("ask-pro cannot combine --new-chat with --target-url: --new-chat navigates to a fresh chat while --target-url pins the confirmed tab.");
|
|
995
1043
|
}
|
|
@@ -1117,6 +1165,8 @@ export async function runAskProCommand(rest, io) {
|
|
|
1117
1165
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
1118
1166
|
...(tools.length > 0 ? { tools } : {}),
|
|
1119
1167
|
...(newChat ? { newChat: true } : {}),
|
|
1168
|
+
...(temporary ? { temporary: true } : {}),
|
|
1169
|
+
diagnosticsCwd: targetCwd,
|
|
1120
1170
|
...(busyWaitMs !== undefined ? { busyWaitMs } : {}),
|
|
1121
1171
|
project: selectionProject,
|
|
1122
1172
|
projectNew: selectionProjectNew,
|
|
@@ -1814,6 +1864,16 @@ export async function latestTrustedConsult(store, options = { readOnly: true })
|
|
|
1814
1864
|
throw firstUntrusted;
|
|
1815
1865
|
return undefined;
|
|
1816
1866
|
}
|
|
1867
|
+
/**
|
|
1868
|
+
* The newest consult that FAILED, which is what a bug report is about.
|
|
1869
|
+
*
|
|
1870
|
+
* latestTrustedConsult deliberately walks past blocked records looking for an
|
|
1871
|
+
* answer; a report wants the opposite.
|
|
1872
|
+
*/
|
|
1873
|
+
export async function latestBlockedConsult(store) {
|
|
1874
|
+
const records = await listConsultRecordsNewestFirst(store);
|
|
1875
|
+
return records.find((record) => record.result.status === "blocked");
|
|
1876
|
+
}
|
|
1817
1877
|
export function legacyChatGptNamespaceError(subcommand) {
|
|
1818
1878
|
const prefix = subcommand ? `Unknown legacy chatgpt subcommand: ${subcommand}.` : "The legacy `chatgpt` namespace is hidden.";
|
|
1819
1879
|
return new Error(`${prefix} Use \`prodex pro browser help\` for visible-browser commands.`);
|
package/dist/cli-server.js
CHANGED
|
@@ -52,9 +52,27 @@ export async function runSetupCommand(rest, io) {
|
|
|
52
52
|
io.stdout("The token is stored (once) in .bridge/config.local.json; print the full URL with `prodex status --show-token --url-only`.");
|
|
53
53
|
if (config.browser_defaults) {
|
|
54
54
|
io.stdout(`Browser send defaults: ${formatBrowserDefaults(config.browser_defaults)}`);
|
|
55
|
+
// Ask the picker whether it can actually provide what was just pinned.
|
|
56
|
+
// Pinning something it does not offer is how prodex broke for someone
|
|
57
|
+
// else: model=Pro was pinned when Pro was a model, ChatGPT made it an
|
|
58
|
+
// effort step and dropped it from some accounts, and every plain send
|
|
59
|
+
// failed there afterwards. Best effort - no browser is not a verdict.
|
|
60
|
+
const warning = await pinnedSelectionCheck(config.browser_defaults).catch(() => undefined);
|
|
61
|
+
if (warning)
|
|
62
|
+
io.stdout(warning);
|
|
55
63
|
}
|
|
56
64
|
return 0;
|
|
57
65
|
}
|
|
66
|
+
/** What the picker currently offers, compared against what was just pinned. */
|
|
67
|
+
async function pinnedSelectionCheck(defaults) {
|
|
68
|
+
const { listChatGptModelOptions, pinnedSelectionWarning } = await import("./chatgpt-browser.js");
|
|
69
|
+
const listed = await listChatGptModelOptions({ timeoutMs: 8_000 });
|
|
70
|
+
const offered = listed.options.flatMap((option) => [option.label, ...(option.value ? [option.value] : [])]);
|
|
71
|
+
return pinnedSelectionWarning({
|
|
72
|
+
...(defaults.model !== undefined ? { model: defaults.model } : {}),
|
|
73
|
+
...(defaults.effort !== undefined ? { effort: defaults.effort } : {})
|
|
74
|
+
}, offered);
|
|
75
|
+
}
|
|
58
76
|
export async function runStartCommand(rest, io) {
|
|
59
77
|
if (printHelpIfRequested(rest, "start", io.stdout, printStartHelp, { valueFlags: ["--cwd", "--source-cli"] }))
|
|
60
78
|
return 0;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a blocked consult into a bug report.
|
|
3
|
+
*
|
|
4
|
+
* Reports from this project have been arriving as chat messages: a session on
|
|
5
|
+
* another machine hit a broken picker three different ways, and the only record
|
|
6
|
+
* was the conversation it was typed into. A blocked receipt already holds what a
|
|
7
|
+
* report needs, so this reads that rather than asking anyone to retype it.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Which part of prodex a blocker belongs to.
|
|
11
|
+
*
|
|
12
|
+
* Guessing from the code prefix keeps this honest about what it does not know:
|
|
13
|
+
* an unfamiliar code gets no area label rather than a wrong one.
|
|
14
|
+
*/
|
|
15
|
+
export function issueAreaLabel(code) {
|
|
16
|
+
if (/^(browser|chatgpt|send|model|response|tab|composer|login|captcha|deep_research|pro_mode)/.test(code))
|
|
17
|
+
return "area:browser";
|
|
18
|
+
if (/^(bridge|store|receipt|task|result|session|artifact)/.test(code))
|
|
19
|
+
return "area:bridge";
|
|
20
|
+
if (/^(config|setup|token|mcp|server)/.test(code))
|
|
21
|
+
return "area:config";
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the report. Only the failure travels: never the prompt, the answer, or
|
|
26
|
+
* the summary, because a public issue must not become where a private consult
|
|
27
|
+
* leaks. Everything included here is either environment or blocker metadata.
|
|
28
|
+
*/
|
|
29
|
+
export function buildIssueReport(consult, environment) {
|
|
30
|
+
if (consult.status !== "blocked" || !consult.blocker) {
|
|
31
|
+
throw new Error(`${consult.task_id} is not a failure (status ${consult.status}), so there is nothing to report.`);
|
|
32
|
+
}
|
|
33
|
+
const code = (consult.blocker.code ?? "unknown").trim();
|
|
34
|
+
const message = (consult.blocker.message ?? "").trim();
|
|
35
|
+
const area = issueAreaLabel(code);
|
|
36
|
+
const body = [
|
|
37
|
+
"A consult was blocked. Filed from its receipt, so the prompt and the answer are not included.",
|
|
38
|
+
"",
|
|
39
|
+
"| | |",
|
|
40
|
+
"| --- | --- |",
|
|
41
|
+
`| blocker | \`${code}\` |`,
|
|
42
|
+
`| message | ${message || "(none)"} |`,
|
|
43
|
+
`| retryable | ${consult.blocker.retryable === true ? "yes" : "no"} |`,
|
|
44
|
+
`| prodex | ${environment.version} |`,
|
|
45
|
+
`| platform | ${environment.platform} |`,
|
|
46
|
+
`| node | ${environment.nodeVersion} |`,
|
|
47
|
+
"",
|
|
48
|
+
...(consult.blocker.next_step ? ["What it told the caller to do:", "", `> ${consult.blocker.next_step}`, ""] : []),
|
|
49
|
+
"Receipt (local, not attached): " + consult.task_id
|
|
50
|
+
].join("\n");
|
|
51
|
+
return {
|
|
52
|
+
title: `${code}: ${message || "blocked consult"}`.slice(0, 120),
|
|
53
|
+
body,
|
|
54
|
+
labels: ["bug", ...(area ? [area] : [])]
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether this report is news.
|
|
59
|
+
*
|
|
60
|
+
* A watchdog that files on every run turns one broken thing into a daily pile of
|
|
61
|
+
* identical issues, and the pile is what makes people stop reading them. Same
|
|
62
|
+
* blocker code, still open: add to it instead of opening another.
|
|
63
|
+
*/
|
|
64
|
+
export function chooseIssueAction(openIssues, report) {
|
|
65
|
+
const code = report.title.split(":")[0]?.trim();
|
|
66
|
+
if (!code)
|
|
67
|
+
return { action: "create" };
|
|
68
|
+
const existing = openIssues.find((issue) => issue.title.split(":")[0]?.trim() === code);
|
|
69
|
+
return existing ? { action: "comment", number: existing.number } : { action: "create" };
|
|
70
|
+
}
|
|
71
|
+
/** The repository a report goes to when the caller does not name one. */
|
|
72
|
+
export const PRODEX_ISSUE_REPO = "youdie006/prodex";
|
|
73
|
+
/**
|
|
74
|
+
* File the report through the `gh` CLI, which already holds the user's GitHub
|
|
75
|
+
* auth. Shelling out beats storing a token: prodex never asks for one, and
|
|
76
|
+
* whatever `gh` is allowed to do is exactly what this is allowed to do.
|
|
77
|
+
*/
|
|
78
|
+
export async function fileGitHubIssue(repo, report) {
|
|
79
|
+
const { execFile } = await import("node:child_process");
|
|
80
|
+
const { promisify } = await import("node:util");
|
|
81
|
+
const run = promisify(execFile);
|
|
82
|
+
try {
|
|
83
|
+
// Only ask for labels the repository has. A label it lacks makes `gh` refuse
|
|
84
|
+
// the whole issue ("could not add label: 'area:browser' not found"), so a
|
|
85
|
+
// missing label would cost the report rather than cost the label.
|
|
86
|
+
const known = await run("gh", ["label", "list", "--repo", repo, "--limit", "100", "--json", "name"], { timeout: 60_000 }).then(({ stdout }) => new Set(JSON.parse(stdout || "[]").map((label) => label.name)), () => undefined);
|
|
87
|
+
const labels = known ? report.labels.filter((label) => known.has(label)) : report.labels;
|
|
88
|
+
const open = await run("gh", ["issue", "list", "--repo", repo, "--state", "open", "--limit", "100", "--json", "number,title"], {
|
|
89
|
+
timeout: 60_000
|
|
90
|
+
}).then(({ stdout }) => JSON.parse(stdout || "[]"), () => []);
|
|
91
|
+
const decision = chooseIssueAction(open, report);
|
|
92
|
+
if (decision.action === "comment") {
|
|
93
|
+
await run("gh", ["issue", "comment", String(decision.number), "--repo", repo, "--body", report.body], { timeout: 60_000 });
|
|
94
|
+
return `commented on existing #${decision.number}`;
|
|
95
|
+
}
|
|
96
|
+
const args = ["issue", "create", "--repo", repo, "--title", report.title, "--body", report.body];
|
|
97
|
+
for (const label of labels)
|
|
98
|
+
args.push("--label", label);
|
|
99
|
+
const { stdout } = await run("gh", args, { timeout: 60_000 });
|
|
100
|
+
return stdout.trim().split(/\r?\n/).filter(Boolean).pop() ?? "(no url returned)";
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
// `gh` puts the reason on stderr and Node puts the command line in
|
|
104
|
+
// error.message, so reporting message first hid every real cause behind the
|
|
105
|
+
// command that produced it.
|
|
106
|
+
const stderr = error.stderr;
|
|
107
|
+
const detail = (typeof stderr === "string" ? stderr.split(/\r?\n/).map((line) => line.trim()).find(Boolean) : undefined) ??
|
|
108
|
+
(error instanceof Error ? error.message.split(/\r?\n/)[0] : String(error));
|
|
109
|
+
throw new Error(`Could not file the issue with \`gh\`: ${detail}. Check \`gh auth status\`, or copy the report above into a new issue by hand.`);
|
|
110
|
+
}
|
|
111
|
+
}
|