@youdie006/prodex 0.36.4 → 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.
@@ -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
+ }
@@ -4,7 +4,9 @@ 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";
9
+ import { readPowerSliderSelection } from "./picker-interaction.js";
8
10
  export class ChatGptBrowserBlockerError extends Error {
9
11
  blocker;
10
12
  constructor(blocker) {
@@ -1300,6 +1302,14 @@ const CLICK_POINT_SNIPPET = `
1300
1302
  el.scrollIntoView({ block: "center", inline: "nearest" });
1301
1303
  const r = el.getBoundingClientRect();
1302
1304
  if (r.width < 1 || r.height < 1) return { ok: false, reason: "target element has no visible area" };
1305
+ // An inert row cannot be clicked at ANY point, so say that instead of
1306
+ // blaming whatever hit-testing returns instead. Measured on the current
1307
+ // picker: the model rows carry pointer-events:none and sit outside the
1308
+ // menu's own box, so every coordinate on them resolves to the effort
1309
+ // control and the refusal read as "another element covers it".
1310
+ if (getComputedStyle(el).pointerEvents === "none") {
1311
+ return { ok: false, reason: "target element is not clickable (pointer-events: none)" };
1312
+ }
1303
1313
  el.setAttribute('data-prodex-click', '1');
1304
1314
  const x = Math.round(r.x + r.width / 2);
1305
1315
  const y = Math.round(r.y + (yCap ? Math.min(r.height / 2, yCap) : r.height / 2));
@@ -1382,15 +1392,24 @@ export function powerSliderStateExpression() {
1382
1392
  const slider = document.querySelector('[role="slider"]');
1383
1393
  const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
1384
1394
  const lines = menu ? (menu.innerText || "").split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean) : [];
1385
- const after = (label) => { const i = lines.indexOf(label); return i >= 0 ? lines[i + 1] : null; };
1386
1395
  if (!slider) return { ok: false, reason: "power slider not found", lines };
1396
+ const readPowerSliderSelection = ${readPowerSliderSelection.toString()};
1397
+ const selection = readPowerSliderSelection({
1398
+ sliderValueText: slider.getAttribute("aria-valuetext"),
1399
+ items: menu ? [...menu.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')].map((item) => ({
1400
+ role: item.getAttribute("role"),
1401
+ text: item.innerText || item.textContent || "",
1402
+ checked: item.getAttribute("aria-checked") === "true",
1403
+ containsSlider: item.contains(slider)
1404
+ })) : []
1405
+ });
1387
1406
  return {
1388
1407
  ok: true,
1389
1408
  position: Number(slider.getAttribute("aria-valuenow")),
1390
1409
  min: Number(slider.getAttribute("aria-valuemin")),
1391
1410
  max: Number(slider.getAttribute("aria-valuemax")),
1392
- model: after("Model"),
1393
- effort: after("Effort"),
1411
+ model: selection.model,
1412
+ effort: selection.effort,
1394
1413
  lines
1395
1414
  };
1396
1415
  })()`;
@@ -1425,7 +1444,7 @@ export function menuItemRectExpression(label) {
1425
1444
  if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
1426
1445
  const items = [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
1427
1446
  const it = items.find(${menuLabelMatchPredicate(label)});
1428
- 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) };
1429
1448
  const point = clickPoint(it);
1430
1449
  if (!point.ok) return point;
1431
1450
  return { ...point, role: it.getAttribute("role"), haspopup: it.getAttribute("aria-haspopup") };
@@ -1562,11 +1581,92 @@ const POWER_SLIDER_APPEAR_ATTEMPTS = 5;
1562
1581
  // Click a model row. They are ordinary `menuitemradio` entries in the same
1563
1582
  // menu as the effort slider, so the existing by-label lookup reaches them; what
1564
1583
  // was missing is anything routing a model here instead of into the slider.
1565
- async function selectPickerModel(cdp, requested) {
1584
+ /**
1585
+ * Whether a model selection failure is the picker not offering it at all.
1586
+ *
1587
+ * Those two cases - the row is absent, or it is inert - are the UI declining to
1588
+ * provide the choice, and they must not kill a send that is otherwise fine. It
1589
+ * is the same answer prodex already gives for a Pro sub-mode it cannot apply.
1590
+ * Anything else (a menu that would not open, a click that would not land) is a
1591
+ * real failure and stays one, so a genuine break is not buried under a warning.
1592
+ */
1593
+ export function modelSelectionUnavailableWarning(requested, reason, available) {
1594
+ if (!/menu item not found|not clickable/i.test(reason))
1595
+ return undefined;
1596
+ const offers = available?.length ? ` It offers: ${available.join(", ")}.` : "";
1597
+ return (`model_not_applied: this ChatGPT picker does not offer "${requested}" as a selectable model, ` +
1598
+ `so the send used whatever the composer already had.${offers} ` +
1599
+ 'Pick an effort with --effort instead, or clear a saved default with `prodex setup --model ""`.');
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
+ }
1661
+ async function selectPickerModel(cdp, requested, warnings = []) {
1566
1662
  const hit = await cdp.evaluate(menuItemRectExpression(requested));
1567
1663
  if (!hit.ok || hit.x === undefined || hit.y === undefined) {
1568
- const available = hit.available?.length ? ` The picker offers: ${hit.available.join(", ")}.` : "";
1569
- throw new Error(`ChatGPT's model picker has no "${requested}" model.${available}`);
1664
+ const unavailable = modelSelectionUnavailableWarning(requested, hit.reason ?? "", hit.available);
1665
+ if (unavailable) {
1666
+ warnings.push(unavailable);
1667
+ return;
1668
+ }
1669
+ throw new Error(hit.reason ?? `ChatGPT's model picker has no "${requested}" model.`);
1570
1670
  }
1571
1671
  await verifiedClickWithRetry(cdp, () => cdp.evaluate(menuItemRectExpression(requested)), requested);
1572
1672
  }
@@ -1686,10 +1786,22 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1686
1786
  if (plan.warning)
1687
1787
  selectionWarnings.push(plan.warning);
1688
1788
  if (plan.sliderLabel) {
1689
- await selectPowerStep(cdp, plan.sliderLabel);
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
+ }
1690
1802
  }
1691
1803
  if (plan.modelLabel) {
1692
- await selectPickerModel(cdp, plan.modelLabel);
1804
+ await selectPickerModel(cdp, plan.modelLabel, selectionWarnings);
1693
1805
  }
1694
1806
  // This branch cannot honour a sub-mode, and used to return without
1695
1807
  // saying so - the caller got a clean receipt for a 확장 it never got.
@@ -2156,7 +2268,11 @@ export async function sendChatGptPrompt(options) {
2156
2268
  // thread silently lands outside the project (measured live: --new-chat
2157
2269
  // --project threads appeared in the root chat list, --project-only
2158
2270
  // threads appeared inside the project).
2159
- await evaluateOnPage(page, `location.assign("https://chatgpt.com/")`);
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)})`);
2160
2276
  await waitForFreshChatGptPage(page, 8_000);
2161
2277
  }
2162
2278
  let status = await readSettledChatGptPageStatus(page);
@@ -2238,7 +2354,33 @@ export async function sendChatGptPrompt(options) {
2238
2354
  let submitButtonFound = false;
2239
2355
  let wantsDeepResearch = false;
2240
2356
  const sendWarnings = [];
2357
+ const temporaryNote = temporaryChatWarning(options.temporary);
2358
+ if (temporaryNote)
2359
+ sendWarnings.push(temporaryNote);
2241
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
+ };
2242
2384
  try {
2243
2385
  await cdp.send("Runtime.enable");
2244
2386
  await selectProject(cdp, options);
@@ -2336,6 +2478,13 @@ export async function sendChatGptPrompt(options) {
2336
2478
  }
2337
2479
  }
2338
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
+ }
2339
2488
  finally {
2340
2489
  cdp.close();
2341
2490
  }
package/dist/cli-args.js CHANGED
@@ -249,6 +249,7 @@ export function assertNoExtraArgs(args, command, maxPositionals) {
249
249
  }
250
250
  }
251
251
  export const ASK_PRO_BOOLEAN_FLAGS = new Set([
252
+ "--temporary",
252
253
  "--dry-run",
253
254
  "--send",
254
255
  "--confirm-target",
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.`);
@@ -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
+ }
@@ -0,0 +1,40 @@
1
+ /** Pure decision helpers for ChatGPT's composer picker, kept out of the page. */
2
+ /** Read model and effort from both the current mixed-control picker and its prior labeled-row form. */
3
+ export function readPowerSliderSelection(snapshot) {
4
+ const items = snapshot.items.map((item) => ({
5
+ ...item,
6
+ lines: item.text
7
+ .split("\n")
8
+ .map((line) => line.trim())
9
+ .filter(Boolean)
10
+ }));
11
+ const powerLabels = new Set([
12
+ "instant",
13
+ "즉시",
14
+ "빠름",
15
+ "fast",
16
+ "medium",
17
+ "중간",
18
+ "보통",
19
+ "high",
20
+ "높음",
21
+ "extra high",
22
+ "extrahigh",
23
+ "very high",
24
+ "매우 높음",
25
+ "매우높음",
26
+ "pro",
27
+ "프로"
28
+ ]);
29
+ const legacyModel = items.find((item) => item.lines[0] === "Model")?.lines[1] ?? null;
30
+ const legacyEffort = items.find((item) => item.lines[0] === "Effort")?.lines[1] ?? null;
31
+ const checkedModel = items.find((item) => item.role === "menuitemradio" && item.checked)?.lines[0] ?? null;
32
+ const sliderOwner = items.find((item) => item.role === "menuitem" && item.containsSlider)?.lines[0] ?? null;
33
+ const visiblePowerLabel = items.find((item) => item.role === "menuitem" && powerLabels.has((item.lines[0] ?? "").toLowerCase()))?.lines[0] ?? null;
34
+ const accessibleCandidate = snapshot.sliderValueText?.trim() || null;
35
+ const accessibleValue = accessibleCandidate && powerLabels.has(accessibleCandidate.toLowerCase()) ? accessibleCandidate : null;
36
+ return {
37
+ model: legacyModel ?? checkedModel,
38
+ effort: accessibleValue ?? legacyEffort ?? sliderOwner ?? visiblePowerLabel
39
+ };
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.36.4",
3
+ "version": "0.37.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",