@youdie006/prodex 0.36.5 → 0.37.1

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/README.md CHANGED
@@ -424,6 +424,47 @@ During local development, you can run the TypeScript source directly:
424
424
  npm run dev -- tasks list
425
425
  ```
426
426
 
427
+ ## When a send breaks
428
+
429
+ prodex drives a web UI that changes underneath it, so the interesting question is what you have when that happens.
430
+
431
+ **Report it from the receipt, not from memory.** A blocked consult already records the blocker, the version and the platform, so `prodex pro report-issue` builds the report out of that:
432
+
433
+ ```bash
434
+ prodex pro report-issue # prints it, files nothing
435
+ prodex pro report-issue --confirm # opens it as a GitHub issue, through `gh`
436
+ ```
437
+
438
+ The prompt, the answer and the summary never travel - a public issue is not where a private consult should leak - and filing is deduplicated by blocker code, so something that stays broken adds to one issue instead of opening a new one every time.
439
+
440
+ **Capture what the page looked like.** With diagnostics on, a failing send leaves a screenshot and a snapshot of the picker and composer next to each other:
441
+
442
+ ```bash
443
+ PRODEX_BROWSER_DIAGNOSTICS=1 prodex ask "..."
444
+ # .bridge/diagnostics/<when>/{screen.png,page-shape.json}
445
+ ```
446
+
447
+ The snapshot carries roles, testids, rects, `aria-value*` and `pointer-events`. Those last two are what identified a model row that no coordinate could click and a slider step prodex could not read. Captures stay on your machine and are never attached to a report; a screenshot of ChatGPT shows the conversation.
448
+
449
+ **Notice before a person does.** `scripts/ui-watchdog.mjs` sends a token and checks the reply - a real round trip rather than a guess from the page's shape - and can file what it finds:
450
+
451
+ ```bash
452
+ node scripts/ui-watchdog.mjs # says ok or broken
453
+ node scripts/ui-watchdog.mjs --file-issue # and reports it
454
+ ```
455
+
456
+ It needs the logged-in browser, so it belongs on a machine that has one rather than in CI. A daily cron entry is enough.
457
+
458
+ ## Sending without leaving a trace
459
+
460
+ `--temporary` sends into a ChatGPT Temporary Chat, which leaves your chat list untouched:
461
+
462
+ ```bash
463
+ prodex ask --new-chat --temporary "..."
464
+ ```
465
+
466
+ It costs something, and prodex says so on every such send: an unsaved chat is not in the transcript API, so the answer is read off the page, where tables and citation links can be lost - and neither `pro browser recover` nor `--target-url` can reach it afterwards. It requires `--new-chat`, because a throwaway chat cannot be continued.
467
+
427
468
  ## FAQ
428
469
 
429
470
  **A send failed with `send_ui_changed` / "the ChatGPT web UI may have changed".** prodex drives the visible ChatGPT web UI, so an OpenAI redesign of the composer or send control can break sends. When a send times out without the prompt ever posting (the composer still holds the text, or no send button was found), prodex reports this as a likely UI change instead of a misleading "slow model" timeout. Fix: update prodex (`npm i -g @youdie006/prodex@latest`); if it persists, open an issue at https://github.com/youdie006/prodex/issues, and paste the prompt manually in the visible browser in the meantime.
@@ -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 {
@@ -14,7 +15,12 @@ export class ChatGptBrowserBlockerError extends Error {
14
15
  this.blocker = blocker;
15
16
  }
16
17
  }
17
- const REASONING_EFFORTS = ["즉시", "중간", "높음", "매우 높음"];
18
+ // Five steps, not four. Measured at both ends of the live slider: position 0 of
19
+ // 4 reads "Instant, 1 of 5" and position 4 reads "Pro, 5 of 5", so Pro is a step
20
+ // of this control rather than a model. Leaving it out meant --effort could not
21
+ // reach the top of the slider at all, and the only route there - --model Pro -
22
+ // answered with advice to "say --effort Pro", which then failed.
23
+ const REASONING_EFFORTS = ["즉시", "중간", "높음", "매우 높음", "Pro"];
18
24
  const PRO_MODES = ["기본", "확장"];
19
25
  // Aliases map friendly CLI input onto the exact Korean menu labels the picker
20
26
  // clicks by text. Keys are lowercased and space-stripped before lookup.
@@ -24,7 +30,9 @@ const REASONING_EFFORT_ALIASES = {
24
30
  medium: "중간",
25
31
  high: "높음",
26
32
  max: "매우 높음",
27
- extrahigh: "매우 높음"
33
+ extrahigh: "매우 높음",
34
+ pro: "Pro",
35
+ "프로": "Pro"
28
36
  };
29
37
  // Menu labels per canonical value, verified live in both the Korean and the
30
38
  // English (US) ChatGPT UI. Matching tries every candidate so either UI works.
@@ -32,7 +40,8 @@ const EFFORT_MENU_LABELS = {
32
40
  "즉시": ["즉시", "Instant"],
33
41
  "중간": ["중간", "Medium"],
34
42
  "높음": ["높음", "High"],
35
- "매우 높음": ["매우 높음", "Extra High"]
43
+ "매우 높음": ["매우 높음", "Extra High"],
44
+ Pro: ["Pro", "프로"]
36
45
  };
37
46
  const PRO_MODE_SUBMENU_LABELS = {
38
47
  "기본": ["Pro 기본", "Pro Standard", "Standard", "기본"],
@@ -1443,7 +1452,7 @@ export function menuItemRectExpression(label) {
1443
1452
  if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
1444
1453
  const items = [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
1445
1454
  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) };
1455
+ 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
1456
  const point = clickPoint(it);
1448
1457
  if (!point.ok) return point;
1449
1458
  return { ...point, role: it.getAttribute("role"), haspopup: it.getAttribute("aria-haspopup") };
@@ -1597,6 +1606,80 @@ export function modelSelectionUnavailableWarning(requested, reason, available) {
1597
1606
  `so the send used whatever the composer already had.${offers} ` +
1598
1607
  'Pick an effort with --effort instead, or clear a saved default with `prodex setup --model ""`.');
1599
1608
  }
1609
+ /**
1610
+ * A step the slider does not have.
1611
+ *
1612
+ * Reported from another machine: that account's picker has no "Pro" step at all
1613
+ * - five attempts, the same list every time - so a pinned default of model=Pro
1614
+ * became a step request the slider could not satisfy and every plain send died.
1615
+ * The picker declining to offer something is not prodex breaking, so it reads
1616
+ * the same way the model case does: warn, and let the send go.
1617
+ */
1618
+ /**
1619
+ * A pinned default the picker does not offer.
1620
+ *
1621
+ * This is how prodex broke for someone else: model=Pro was pinned back when Pro
1622
+ * was a model, ChatGPT turned it into an effort step and dropped it from some
1623
+ * accounts entirely, and every plain send there failed afterwards. The picker
1624
+ * knows what it offers, so asking it once - when the default is set - beats
1625
+ * finding out on every send for weeks.
1626
+ */
1627
+ /**
1628
+ * What a temporary chat costs.
1629
+ *
1630
+ * Measured: the chat list is byte-identical before and after, which is the
1631
+ * point - but the answer arrives without the "(transcript ...)" a normal send
1632
+ * reports, because the transcript API does not hold a chat that was never
1633
+ * saved. So the answer is read off the page, which is where prodex loses
1634
+ * markdown tables and citation urls. Trading fidelity for privacy is a fine
1635
+ * trade to offer and a bad one to make silently.
1636
+ */
1637
+ export function temporaryChatWarning(temporary) {
1638
+ if (!temporary)
1639
+ return undefined;
1640
+ return ("temporary_chat: this chat is not saved, so the answer was read from the page rather than the transcript - " +
1641
+ "tables and citation links can be lost - and neither `pro browser recover` nor `--target-url` can reach it later.");
1642
+ }
1643
+ export function pinnedSelectionWarning(pinned, offered) {
1644
+ // An unreadable picker is not evidence that the pin is wrong.
1645
+ if (offered.length === 0)
1646
+ return undefined;
1647
+ const has = (wanted) => offered.some((label) => label.trim().toLowerCase() === wanted.trim().toLowerCase());
1648
+ // A value prodex already knows to be a slider step needs no listing to
1649
+ // vouch for it: the slider shows one step at a time, so a listing that
1650
+ // happens to read "Extra High" says nothing about Pro. Warning there was a
1651
+ // false alarm about a setting the very next send used successfully, and an
1652
+ // alarm that cries about known-good settings is one people learn to skip.
1653
+ const knownStep = (wanted) => {
1654
+ try {
1655
+ parseReasoningEffort(wanted);
1656
+ return true;
1657
+ }
1658
+ catch {
1659
+ return false;
1660
+ }
1661
+ };
1662
+ const missing = [pinned.model, pinned.effort].find((wanted) => wanted !== undefined && !has(wanted) && !knownStep(wanted));
1663
+ if (missing === undefined)
1664
+ return undefined;
1665
+ // Say only what was seen. The picker lists its models, but the effort slider
1666
+ // shows one step at a time - reading the others means moving it, which would
1667
+ // change the user's setting just to look. So this reports that the pin was not
1668
+ // among what the picker LISTED, not that the account cannot provide it.
1669
+ return (`pinned_selection_check: "${missing}" was not among what the picker listed (${offered.join(", ")}). ` +
1670
+ "The effort slider only shows its current step, so a step by that name may still exist. " +
1671
+ "If sends start warning that it was not applied, pin one of the listed names or clear it with `prodex setup --clear-model`.");
1672
+ }
1673
+ export function stepSelectionUnavailableWarning(requested, offered) {
1674
+ if (!requested)
1675
+ return undefined;
1676
+ // Quote what the page said. The browser that hit this runs in Korean, so the
1677
+ // steps come back translated and an English label prodex expected would be
1678
+ // useless to the person reading the warning.
1679
+ const list = offered.length ? ` It offers: ${offered.join(", ")}.` : "";
1680
+ return (`step_not_applied: this ChatGPT picker has no "${requested}" step, so the send used the slider's current setting.${list} ` +
1681
+ 'Pick one of those with --effort, or clear a saved default with `prodex setup --clear-model`.');
1682
+ }
1600
1683
  async function selectPickerModel(cdp, requested, warnings = []) {
1601
1684
  const hit = await cdp.evaluate(menuItemRectExpression(requested));
1602
1685
  if (!hit.ok || hit.x === undefined || hit.y === undefined) {
@@ -1725,7 +1808,19 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1725
1808
  if (plan.warning)
1726
1809
  selectionWarnings.push(plan.warning);
1727
1810
  if (plan.sliderLabel) {
1728
- await selectPowerStep(cdp, plan.sliderLabel);
1811
+ try {
1812
+ await selectPowerStep(cdp, plan.sliderLabel);
1813
+ }
1814
+ catch (error) {
1815
+ // Only "this slider has no such step" is the picker declining. A
1816
+ // slider that will not open, or will not move, is a real failure.
1817
+ const message = error instanceof Error ? error.message : String(error);
1818
+ const noSuchStep = /has no "[^"]*" step/.test(message);
1819
+ if (!noSuchStep)
1820
+ throw error;
1821
+ const offered = /It showed: (.*)$/.exec(message)?.[1]?.split(" / ").map((part) => part.trim()) ?? [];
1822
+ selectionWarnings.push(stepSelectionUnavailableWarning(plan.sliderLabel, offered));
1823
+ }
1729
1824
  }
1730
1825
  if (plan.modelLabel) {
1731
1826
  await selectPickerModel(cdp, plan.modelLabel, selectionWarnings);
@@ -2195,7 +2290,11 @@ export async function sendChatGptPrompt(options) {
2195
2290
  // thread silently lands outside the project (measured live: --new-chat
2196
2291
  // --project threads appeared in the root chat list, --project-only
2197
2292
  // threads appeared inside the project).
2198
- await evaluateOnPage(page, `location.assign("https://chatgpt.com/")`);
2293
+ // A temporary chat is reached by url rather than by clicking the control:
2294
+ // the same navigation this already does, one query parameter different, and
2295
+ // nothing to find on a page whose buttons keep moving.
2296
+ const freshUrl = options.temporary ? "https://chatgpt.com/?temporary-chat=true" : "https://chatgpt.com/";
2297
+ await evaluateOnPage(page, `location.assign(${JSON.stringify(freshUrl)})`);
2199
2298
  await waitForFreshChatGptPage(page, 8_000);
2200
2299
  }
2201
2300
  let status = await readSettledChatGptPageStatus(page);
@@ -2277,7 +2376,33 @@ export async function sendChatGptPrompt(options) {
2277
2376
  let submitButtonFound = false;
2278
2377
  let wantsDeepResearch = false;
2279
2378
  const sendWarnings = [];
2379
+ const temporaryNote = temporaryChatWarning(options.temporary);
2380
+ if (temporaryNote)
2381
+ sendWarnings.push(temporaryNote);
2280
2382
  const cdp = await connectCdp(page.webSocketDebuggerUrl);
2383
+ // With diagnostics on, a send that fails leaves behind what the page looked
2384
+ // like: the shape prodex reads plus a screenshot. Every UI break in this
2385
+ // project has otherwise cost a live debugging session, and a report from
2386
+ // another machine can carry none of that. Local only - see browser-diagnostics.
2387
+ const captureOnFailure = async (label) => {
2388
+ if (!diagnosticsEnabled())
2389
+ return;
2390
+ const capture = await captureBrowserDiagnostics({
2391
+ evaluate: (expression) => evaluateOnPage(page, expression),
2392
+ // cdp.send resolves with the whole CDP message; the command's own result
2393
+ // is one level in. Passing the envelope through meant the screenshot
2394
+ // bytes were never found and the capture silently held only the shape.
2395
+ send: async (method, params) => (await cdp.send(method, params ?? {})).result
2396
+ }, { cwd: options.diagnosticsCwd ?? process.cwd(), label }).catch(() => undefined);
2397
+ const note = diagnosticsNote(capture);
2398
+ // Warnings only reach the caller on success, and this runs when the send is
2399
+ // about to throw - so the note goes out through progress, which the CLI
2400
+ // prints as it happens.
2401
+ if (note) {
2402
+ sendWarnings.push(note);
2403
+ emitProgress("waiting", note);
2404
+ }
2405
+ };
2281
2406
  try {
2282
2407
  await cdp.send("Runtime.enable");
2283
2408
  await selectProject(cdp, options);
@@ -2375,6 +2500,13 @@ export async function sendChatGptPrompt(options) {
2375
2500
  }
2376
2501
  }
2377
2502
  }
2503
+ catch (error) {
2504
+ // Capture before the connection closes: this is the moment the page still
2505
+ // looks the way it looked when it refused, and the selection failures this
2506
+ // project keeps hitting are invisible in the error text alone.
2507
+ await captureOnFailure(`send-${new Date().toISOString().replace(/[:.]/g, "-")}`);
2508
+ throw error;
2509
+ }
2378
2510
  finally {
2379
2511
  cdp.close();
2380
2512
  }
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,10 +26,11 @@ 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 즉시|중간|높음|"매우 높음"|Pro] [--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]
33
+ prodex pro report-issue [--cwd /absolute/path/to/repo] [--task <task-id>] [--repo owner/name] [--confirm] # bug report from a failed consult's receipt; previews unless --confirm, never carries the prompt or the answer
33
34
 
34
35
  Bridge ledger (durable tasks/results/receipts/sessions under .bridge/):
35
36
  prodex init [--cwd /absolute/path/to/repo]
@@ -51,7 +52,7 @@ Bridge ledger (durable tasks/results/receipts/sessions under .bridge/):
51
52
 
52
53
  Agent / MCP integration:
53
54
  prodex mcp [--cwd /absolute/path/to/repo]
54
- prodex setup [--cwd /absolute/path/to/repo] [--host 127.0.0.1] [--port 8787] [--token-ttl-hours <hours>] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name"] [--clear-model|--clear-pro-mode|--clear-effort|--clear-project] [--interactive]
55
+ prodex setup [--cwd /absolute/path/to/repo] [--host 127.0.0.1] [--port 8787] [--token-ttl-hours <hours>] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Pro] [--project "name"] [--clear-model|--clear-pro-mode|--clear-effort|--clear-project] [--interactive]
55
56
  prodex start [--cwd /absolute/path/to/repo] [--source-cli /absolute/path/to/dist/cli.js]
56
57
  prodex status [--cwd /absolute/path/to/repo] [--source-cli /absolute/path/to/dist/cli.js] [--show-token] [--url-only] [--unsafe-show-non-expiring-token]
57
58
  prodex tunnel url [--cwd /absolute/path/to/repo] [--source-cli /absolute/path/to/dist/cli.js] --public-url https://... [--show-token] [--url-only]
@@ -79,7 +80,7 @@ export function printSetupHelp(stdout) {
79
80
  stdout(`prodex setup
80
81
 
81
82
  Commands:
82
- prodex setup [--cwd /absolute/path/to/repo] [--host 127.0.0.1] [--port 8787] [--token-ttl-hours <hours>] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name"] [--clear-model|--clear-pro-mode|--clear-effort|--clear-project] [--interactive]
83
+ prodex setup [--cwd /absolute/path/to/repo] [--host 127.0.0.1] [--port 8787] [--token-ttl-hours <hours>] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Pro] [--project "name"] [--clear-model|--clear-pro-mode|--clear-effort|--clear-project] [--interactive]
83
84
 
84
85
  Save a loopback-only HTTP MCP profile in .bridge/config.local.json. Use --token-ttl-hours before tunnels or ChatGPT Project use.
85
86
 
@@ -167,10 +168,11 @@ Commands:
167
168
  prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
168
169
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
169
170
  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"
171
+ 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 즉시|중간|높음|"매우 높음"|Pro] [--project "name" | --project-new "name"] "prompt"
171
172
  prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
172
173
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
173
174
  prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
175
+ prodex pro report-issue [--cwd /absolute/path/to/repo] [--task <task-id>] [--repo owner/name] [--confirm] # bug report from a failed consult's receipt; previews unless --confirm, never carries the prompt or the answer
174
176
 
175
177
  Use \`prodex pro ask\` for dry-run/manual previews.
176
178
  Use \`prodex pro browser ask\` only when you want an explicit visible-browser send.
@@ -251,10 +253,10 @@ export function printProBrowserHelp(stdout, sourceCli) {
251
253
  const smokeUsage = sourceCli
252
254
  ? `${cli} pro browser smoke${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]`
253
255
  : "prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]";
254
- const selectionUsage = '[--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"]';
256
+ const selectionUsage = '[--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Pro] [--project "name" | --project-new "name"]';
255
257
  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"`;
258
+ ? `${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"`
259
+ : `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
260
  const modelsUsage = sourceCli
259
261
  ? `${cli} pro browser models${sourceCliOption} [--port 9333] [--timeout-ms 15000]`
260
262
  : "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;
package/dist/cli.js CHANGED
@@ -436,7 +436,9 @@ repo: ${cwd}
436
436
 
437
437
  Safety notes:
438
438
  - This command only prints commands; it does not start servers, open browsers, or write files.
439
- - Visible-browser sends require a manual, visible browser session and stop on login, captcha, Cloudflare, permission, rate-limit, or usage-limit blockers, plus response_choice_pending when ChatGPT is waiting for you to pick which of two answers you prefer.`;
439
+ - Visible-browser sends require a manual, visible browser session and stop on login, captcha, Cloudflare, permission, rate-limit, or usage-limit blockers, plus response_choice_pending when ChatGPT is waiting for you to pick which of two answers you prefer.
440
+ - When a send is blocked, \`prodex pro report-issue\` turns that receipt into a bug report (prints it; --confirm files it). It carries the blocker, version and platform, never the prompt or the answer.
441
+ - PRODEX_BROWSER_DIAGNOSTICS=1 makes a failing send leave a screenshot and a page snapshot under .bridge/diagnostics for whoever debugs it. They stay local.`;
440
442
  }
441
443
  async function hasOnboardingReadme(cwd) {
442
444
  try {
package/dist/config.js CHANGED
@@ -10,7 +10,8 @@ const BRIDGE_DIRECTORY_MODE = 0o700;
10
10
  const BrowserDefaultsSchema = z.object({
11
11
  model: z.string().min(1).optional(),
12
12
  pro_mode: z.enum(["기본", "확장"]).optional(),
13
- effort: z.enum(["즉시", "중간", "높음", "매우 높음"]).optional(),
13
+ // Pro is the slider's fifth step, so a saved default may name it.
14
+ effort: z.enum(["즉시", "중간", "높음", "매우 높음", "Pro"]).optional(),
14
15
  project: z.string().min(1).optional()
15
16
  });
16
17
  const LocalConfigSchema = z.object({
@@ -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.1",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",