@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.
@@ -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,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
- 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
+ }
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
- 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)})`);
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
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.36.5",
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",