@youdie006/prodex 0.39.4 → 0.40.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,81 @@
1
+ /**
2
+ * Rank what actually blocks consults, across every bridge root on the machine.
3
+ *
4
+ * Half of every consult here ends in a blocker, and until now asking WHICH
5
+ * failure dominates meant writing a throwaway script: `pro list` reads a single
6
+ * repo, and the registry that knows where the others are had no reader. Pure
7
+ * on purpose - the reading lives in the command, so the ranking can be tested
8
+ * without a filesystem.
9
+ */
10
+ /**
11
+ * Codes that describe how a send died rather than why. Counting these whole
12
+ * reports "the catch-all is biggest" and names nothing to fix - measured, one
13
+ * of them held 165 of 267 blockers - so their message decides the group.
14
+ */
15
+ const CATCH_ALL_CODES = new Set(["browser_send_failed", "consult_failed", "unknown_error"]);
16
+ /**
17
+ * The part of a message that identifies the failure, with the varying parts
18
+ * removed: the same picker failure is written once with "Pro" and once with a
19
+ * model name, and those are one cause, not two.
20
+ */
21
+ export function blockerCause(message) {
22
+ const firstSentence = /^(.*?)(?:\.\s|\.$|$)/.exec(message.trim())?.[1] ?? message.trim();
23
+ return firstSentence
24
+ .replace(/"[^"]*"/g, '"..."')
25
+ .replace(/\d+/g, "N")
26
+ .replace(/\s+/g, " ")
27
+ .trim();
28
+ }
29
+ function groupKey(code, message) {
30
+ if (!CATCH_ALL_CODES.has(code))
31
+ return code;
32
+ const cause = blockerCause(message);
33
+ return cause ? `${code}: ${cause}` : code;
34
+ }
35
+ export function buildBlockerReport(input) {
36
+ const cutoff = input.since ? Date.parse(input.since) : undefined;
37
+ const inWindow = input.consults.filter((c) => {
38
+ if (cutoff === undefined)
39
+ return true;
40
+ if (!c.createdAt)
41
+ return false;
42
+ const at = Date.parse(c.createdAt);
43
+ return Number.isFinite(at) && at >= cutoff;
44
+ });
45
+ const groups = new Map();
46
+ let blocked = 0;
47
+ for (const consult of inWindow) {
48
+ if (!consult.blocker)
49
+ continue;
50
+ blocked += 1;
51
+ const key = groupKey(consult.blocker.code, consult.blocker.message);
52
+ const at = consult.createdAt ?? "";
53
+ const existing = groups.get(key);
54
+ if (existing) {
55
+ existing.count += 1;
56
+ if (at > existing.lastSeen)
57
+ existing.lastSeen = at;
58
+ existing.repos.set(consult.repo, (existing.repos.get(consult.repo) ?? 0) + 1);
59
+ }
60
+ else {
61
+ groups.set(key, {
62
+ count: 1,
63
+ lastSeen: at,
64
+ example: consult.blocker.message,
65
+ repos: new Map([[consult.repo, 1]])
66
+ });
67
+ }
68
+ }
69
+ const ranked = [...groups.entries()]
70
+ .sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]))
71
+ .slice(0, input.limit ?? Number.POSITIVE_INFINITY)
72
+ .map(([code, g]) => ({
73
+ code,
74
+ count: g.count,
75
+ share: blocked > 0 ? g.count / blocked : 0,
76
+ lastSeen: g.lastSeen,
77
+ example: g.example,
78
+ repos: [...g.repos.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([repo]) => repo)
79
+ }));
80
+ return { totalConsults: inWindow.length, blocked, roots: input.roots, groups: ranked };
81
+ }
@@ -613,14 +613,32 @@ export function hasChatGptPromptAcceptance(previous, state) {
613
613
  * they were getting Pro reasoning when they were not.
614
614
  */
615
615
  export function modelSelectionWarning(requestedModel, modelSlug) {
616
- if (!requestedModel || !modelSlug)
617
- return undefined;
618
- const wantsPro = /\bpro\b/i.test(requestedModel);
619
- if (!wantsPro)
616
+ return selectionMismatchWarning({
617
+ ...(requestedModel !== undefined ? { model: requestedModel } : {}),
618
+ ...(modelSlug !== undefined ? { modelSlug } : {})
619
+ });
620
+ }
621
+ /** Whether a requested label names ChatGPT's Pro step, in either locale. */
622
+ export function namesPro(label) {
623
+ if (!label)
624
+ return false;
625
+ return /\bpro\b/i.test(label) || label.includes("프로");
626
+ }
627
+ /**
628
+ * Compare what was asked for against the model that actually answered.
629
+ *
630
+ * Pro can be asked for on either axis - `--model Pro` or `--effort Pro` - and
631
+ * this only ever looked at the model, so an effort-shaped Pro request answered
632
+ * by a lesser model passed without a word. Measured: a consult asking for Pro
633
+ * came back from gpt-5-6-thinking and was recorded as a clean success.
634
+ */
635
+ export function selectionMismatchWarning(input) {
636
+ const asked = namesPro(input.model) ? input.model : namesPro(input.effort) ? input.effort : undefined;
637
+ if (!asked || !input.modelSlug)
620
638
  return undefined;
621
- if (/pro/i.test(modelSlug))
639
+ if (/pro/i.test(input.modelSlug))
622
640
  return undefined;
623
- return `model_mismatch: you asked for ${requestedModel}, but the answer came from "${modelSlug}". Check the model picker in the browser; the selection did not take.`;
641
+ return `model_mismatch: you asked for ${asked}, but the answer came from "${input.modelSlug}". Check the model picker in the browser; the selection did not take.`;
624
642
  }
625
643
  /**
626
644
  * The panel ChatGPT raises to ask which of two answers you prefer.
@@ -2248,6 +2266,14 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
2248
2266
  const noSuchStep = /has no "[^"]*" step/.test(message);
2249
2267
  if (!noSuchStep)
2250
2268
  throw error;
2269
+ // Asking for a step is a contract. Sending at whatever the slider
2270
+ // happened to be on is not a lesser version of that contract, it is
2271
+ // a different answer that the caller cannot use: measured, a Pro
2272
+ // request came back from gpt-5-6-thinking after minutes of waiting
2273
+ // and was thrown away. A caller that would rather have any answer
2274
+ // says so with --allow-model-fallback.
2275
+ if (!options.allowSelectionFallback)
2276
+ throw error;
2251
2277
  const offered = /It showed: (.*)$/.exec(message)?.[1]?.split(" / ").map((part) => part.trim()) ?? [];
2252
2278
  selectionWarnings.push(stepSelectionUnavailableWarning(plan.sliderLabel, offered));
2253
2279
  }
@@ -3108,7 +3134,7 @@ export async function sendChatGptPrompt(options) {
3108
3134
  answer: transcript.answer,
3109
3135
  modelHints: finalState?.modelHints ?? [],
3110
3136
  ...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
3111
- warnings: withDialogNote([...sendWarnings, modelSelectionWarning(options.model, transcript.modelSlug || finalState?.modelSlug)]).filter((warning) => Boolean(warning))
3137
+ warnings: withDialogNote([...sendWarnings, selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...((transcript.modelSlug || finalState?.modelSlug) ? { modelSlug: (transcript.modelSlug || finalState?.modelSlug) } : {}) })]).filter((warning) => Boolean(warning))
3112
3138
  };
3113
3139
  };
3114
3140
  // Deep research never reaches the DOM answer wait below: the report is
@@ -3256,7 +3282,7 @@ export async function sendChatGptPrompt(options) {
3256
3282
  answer: completed.answer.trim(),
3257
3283
  modelHints: completed.modelHints,
3258
3284
  ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
3259
- warnings: withDialogNote([...sendWarnings, modelSelectionWarning(options.model, completed.modelSlug)]).filter((warning) => Boolean(warning))
3285
+ warnings: withDialogNote([...sendWarnings, selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })]).filter((warning) => Boolean(warning))
3260
3286
  };
3261
3287
  }
3262
3288
  // Timed out while the answer was still streaming: salvage the partial text
@@ -3272,7 +3298,7 @@ export async function sendChatGptPrompt(options) {
3272
3298
  ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
3273
3299
  warnings: withDialogNote([
3274
3300
  ...sendWarnings,
3275
- ...(modelSelectionWarning(options.model, completed.modelSlug) ? [modelSelectionWarning(options.model, completed.modelSlug)] : []),
3301
+ ...(selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) }) ? [selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })] : []),
3276
3302
  `answer_incomplete: ChatGPT was still generating after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms), so the answer below may be truncated. Raise --timeout-ms and retry for the full response.`
3277
3303
  ])
3278
3304
  };
package/dist/cli-args.js CHANGED
@@ -259,7 +259,11 @@ export const ASK_PRO_BOOLEAN_FLAGS = new Set([
259
259
  "--auto-login",
260
260
  "--no-auto-login",
261
261
  // Send outside any project for once, overriding a pinned default.
262
- "--no-project"
262
+ "--no-project",
263
+ // Send even when the requested model/effort could not be applied, rather
264
+ // than stopping. Off by default: an answer from a step nobody asked for is
265
+ // usually thrown away.
266
+ "--allow-model-fallback"
263
267
  ]);
264
268
  export const ASK_PRO_SELECTION_VALUE_FLAGS = ["--project", "--project-new", "--model", "--pro-mode", "--effort"];
265
269
  export const ASK_PRO_VALUE_FLAGS = new Set([
package/dist/cli-help.js CHANGED
@@ -26,8 +26,9 @@ 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] [--temporary] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--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] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|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
+ prodex pro blockers [--cwd /absolute/path/to/repo] [--since 7d] [--limit 10] [--json] # what actually blocks consults, ranked, across every bridge root on this machine
31
32
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
32
33
  prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
33
34
  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
@@ -88,6 +89,7 @@ Optional visible-browser send defaults (applied by \`pro browser ask\` when the
88
89
  --model Composer model by its exact menu label. Only Pro applies on the current picker: it is a slider step, and the model rows cannot be clicked
89
90
  --pro-mode Pro sub-mode: 기본 (standard) or 확장 (extended)
90
91
  --effort Reasoning effort: 즉시 / 중간 / 높음 / 매우 높음 / Max / Ultra / Pro. Max and Ultra are rungs of ChatGPT's Work surface and only apply when the browser is already on Work (prodex does not switch to Work for them); every other value is sent on Chat, whose top step is Pro
92
+ --allow-model-fallback Send even when the requested model/effort could not be applied. Off by default: a send that could not reach what was asked for stops instead, because an answer from a step nobody asked for is usually unusable
91
93
  --project Sidebar project to enter before sending
92
94
  Clear a saved default with --clear-model / --clear-pro-mode / --clear-effort / --clear-project.
93
95
  --pro-mode and --effort are different model axes and cannot be combined. View saved defaults with \`prodex status\`.`);
@@ -168,8 +170,9 @@ Commands:
168
170
  prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
169
171
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
170
172
  prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js]
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 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt"
173
+ 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] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt"
172
174
  prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
175
+ prodex pro blockers [--cwd /absolute/path/to/repo] [--since 7d] [--limit 10] [--json] # what actually blocks consults, ranked, across every bridge root on this machine
173
176
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
174
177
  prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
175
178
  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
@@ -255,8 +258,8 @@ export function printProBrowserHelp(stdout, sourceCli) {
255
258
  : "prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]";
256
259
  const selectionUsage = '[--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"]';
257
260
  const askUsage = sourceCli
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"`;
261
+ ? `${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] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`
262
+ : `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] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`;
260
263
  const modelsUsage = sourceCli
261
264
  ? `${cli} pro browser models${sourceCliOption} [--port 9333] [--timeout-ms 15000]`
262
265
  : "prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000]";
package/dist/cli-pro.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, statSync } from "node:fs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { buildDryRunBundle } from "./bundle.js";
5
- import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead } from "./chatgpt-browser.js";
5
+ import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro } from "./chatgpt-browser.js";
6
6
  import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
7
7
  import { printProBrowserHelp, printProHelp } from "./cli-help.js";
8
8
  import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
@@ -10,6 +10,8 @@ import { formatBrowserDefaults, redactServerUrl } from "./cli-server.js";
10
10
  import { errorMessage, firstLine, formatBlockedConsultRecordedMessage, formatProLatestCommand, formatBrowserCheckCommand, formatBrowserLoginCommand, formatBrowserSmokeCommand, formatBrowserTargetAskCommand, formatInitCommand, formatSetupCommand, isMissingFileError, computeSendPacingWaitMs, isUntrustedResultError, resolveMinSendIntervalMs, sourceAwareBrowserBlocker, sourceAwareBrowserNextStep, sourceAwareResultError, sourceAwareResultMessage, sourceAwareSetupMessage } from "./cli-shared.js";
11
11
  import { getTokenExpiryStatus, loadBrowserDefaults, loadLocalConfig } from "./config.js";
12
12
  import { withBrowserSendLock } from "./browser-send-lock.js";
13
+ import { blockerCause, buildBlockerReport } from "./blocker-report.js";
14
+ import { readBridgeRoots } from "./registry.js";
13
15
  import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
14
16
  import { CLI_VERSION } from "./cli-help.js";
15
17
  import { PRODEX_ISSUE_REPO, buildIssueReport, fileGitHubIssue } from "./issue-report.js";
@@ -732,6 +734,50 @@ export async function runProCommand(rest, io, runCliFn) {
732
734
  // a two-hop dead end that made an agent abandon the diagnosis.
733
735
  throw new Error(`Use \`prodex pro browser ${legacyBrowserSubcommandReplacement(subcommand)}\` for explicit browser automation.`);
734
736
  }
737
+ if (subcommand === "blockers") {
738
+ if (printHelpIfRequested(proArgs, "pro blockers", io.stdout, printProHelp, { valueFlags: ["--cwd", "--since", "--limit"] }))
739
+ return 0;
740
+ assertOnlyOptions(proArgs, "pro blockers", ["--cwd", "--since", "--limit"], ["--json"]);
741
+ const scopedToOneRepo = readFlag(proArgs, "--cwd") !== undefined;
742
+ // Default to every bridge root: the failures are spread across the repos
743
+ // consults were run from, and `pro list` already covers just this one.
744
+ const roots = scopedToOneRepo ? [resolveCwdFlag(io.cwd, proArgs)] : await readBridgeRoots();
745
+ const since = readSinceFlag(proArgs);
746
+ const limit = readPositiveIntegerFlag(proArgs, "--limit") ?? 10;
747
+ const consults = [];
748
+ let readable = 0;
749
+ for (const root of roots) {
750
+ try {
751
+ const results = await new BridgeStore(root).listResultsReadOnly();
752
+ readable += 1;
753
+ for (const result of results) {
754
+ if (!result.task_id.includes("gpt-pro-consult"))
755
+ continue;
756
+ consults.push({
757
+ repo: path.basename(root),
758
+ createdAt: result.created_at,
759
+ ...(result.blocker ? { blocker: { code: result.blocker.code, message: result.blocker.message } } : {})
760
+ });
761
+ }
762
+ }
763
+ catch {
764
+ // A root that has been deleted or is unreadable is not a failure of
765
+ // the report; it just has nothing to contribute.
766
+ }
767
+ }
768
+ const report = buildBlockerReport({
769
+ consults,
770
+ roots: readable,
771
+ ...(since ? { since } : {}),
772
+ limit
773
+ });
774
+ if (proArgs.includes("--json")) {
775
+ io.stdout(JSON.stringify(report, null, 2));
776
+ return 0;
777
+ }
778
+ io.stdout(formatBlockerReport(report, { since, scopedToOneRepo }));
779
+ return 0;
780
+ }
735
781
  if (subcommand === "list") {
736
782
  if (printHelpIfRequested(proArgs, "pro list", io.stdout, printProHelp, { valueFlags: ["--cwd", "--source-cli"] }))
737
783
  return 0;
@@ -879,7 +925,7 @@ export async function runProCommand(rest, io, runCliFn) {
879
925
  io.stdout(formatDebatePrompt({ topic: readFlag(proArgs, "--topic"), rounds, sourceCli }));
880
926
  return 0;
881
927
  }
882
- throw unknownSubcommandError("pro", subcommand, ["ask", "browser", "debate-prompt", "list", "latest", "show"]);
928
+ throw unknownSubcommandError("pro", subcommand, ["ask", "blockers", "browser", "debate-prompt", "list", "latest", "show"]);
883
929
  }
884
930
  export async function runConsultsCommand(rest, io) {
885
931
  throw new Error("The legacy `consults` alias is retired. Use `prodex pro list`, `prodex pro latest`, or `prodex pro show <task-id|latest>`.");
@@ -1110,13 +1156,22 @@ export async function runAskProCommand(rest, io) {
1110
1156
  };
1111
1157
  const browserPort = hasSendMode ? resolveCdpPort(readPortFlag(parsedAskPro.optionArgs, "--port")) : undefined;
1112
1158
  const busyWaitMs = readNonNegativeIntegerFlag(parsedAskPro.optionArgs, "--busy-wait-ms");
1159
+ const allowSelectionFallback = parsedAskPro.optionArgs.includes("--allow-model-fallback");
1113
1160
  // Pro extended can legitimately think for minutes, so its default timeout is
1114
1161
  // higher; an explicit --timeout-ms always wins.
1115
1162
  // Pro reasoning routinely runs for many minutes (a real consult measured
1116
1163
  // ~13 minutes). The elevated default used to be keyed to the removed
1117
1164
  // --pro-mode, so --model Pro sends fell back to 90s and chronically timed
1118
1165
  // out. Any effective Pro selection now defaults to 15 minutes.
1119
- const effectiveProSelection = selectionProMode !== undefined || (selectionModel !== undefined && /pro/i.test(selectionModel));
1166
+ // Pro can be asked for on either axis. Keying the raised budget to the
1167
+ // model alone gave `--effort Pro` five minutes for reasoning this file's
1168
+ // own comment calls 6-20 minutes, which manufactured send_timeout
1169
+ // blockers - 55 of the 267 blockers measured on one machine.
1170
+ const effectiveProSelection = isProSelection({
1171
+ ...(selectionModel !== undefined ? { model: selectionModel } : {}),
1172
+ ...(selectionProMode !== undefined ? { proMode: selectionProMode } : {}),
1173
+ ...(selectionEffort !== undefined ? { effort: selectionEffort } : {})
1174
+ });
1120
1175
  // Pro reasoning routinely runs 6-20 minutes; 15 min was still cutting long
1121
1176
  // answers off (field report), so a Pro selection defaults to 20 minutes.
1122
1177
  // With NO model selection at all (no flag, no saved default) the UI's
@@ -1202,6 +1257,7 @@ export async function runAskProCommand(rest, io) {
1202
1257
  model: selectionModel,
1203
1258
  proMode: selectionProMode,
1204
1259
  effort: selectionEffort,
1260
+ ...(allowSelectionFallback ? { allowSelectionFallback: true } : {}),
1205
1261
  onProgress: createBrowserSendProgressPrinter(io.stderr)
1206
1262
  }));
1207
1263
  // One-command recovery: interactive terminals (or explicit --auto-login)
@@ -1303,6 +1359,16 @@ export async function runAskProCommand(rest, io) {
1303
1359
  io.stderr(warning);
1304
1360
  if (consult.modelSlug)
1305
1361
  io.stderr(`model_used: ${consult.modelSlug}`);
1362
+ // "Can I count this as a Pro review?" - answered here rather than left
1363
+ // for whoever reads the receipt to work out from two other fields.
1364
+ const proVerified = proSelectionVerified({
1365
+ ...(selectionModel !== undefined ? { model: selectionModel } : {}),
1366
+ ...(selectionProMode !== undefined ? { proMode: selectionProMode } : {}),
1367
+ ...(selectionEffort !== undefined ? { effort: selectionEffort } : {}),
1368
+ ...(consult.modelSlug !== undefined ? { modelSlug: consult.modelSlug } : {})
1369
+ });
1370
+ if (proVerified !== undefined)
1371
+ io.stderr(`pro_verified: ${proVerified ? "yes" : "no"}`);
1306
1372
  let answerArtifactPath;
1307
1373
  const answerArtifactBytes = Buffer.byteLength(answerArtifactText, "utf8");
1308
1374
  if (answerArtifactBytes > MAX_FETCHABLE_RESULT_ARTIFACT_BYTES) {
@@ -1333,6 +1399,7 @@ export async function runAskProCommand(rest, io) {
1333
1399
  // What actually answered, straight from ChatGPT's own tag - the
1334
1400
  // receipt used to record only what prodex asked for.
1335
1401
  ...(consult.modelSlug ? { model_used: consult.modelSlug } : {}),
1402
+ ...(proVerified !== undefined ? { pro_verified: proVerified } : {}),
1336
1403
  warnings: persistenceWarnings
1337
1404
  }
1338
1405
  });
@@ -1498,6 +1565,7 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1498
1565
  ...(input.attach ?? []).flatMap((file) => ["--attach", file]),
1499
1566
  ...(input.tools ?? []).flatMap((tool) => ["--tool", tool]),
1500
1567
  ...(input.new_chat ? ["--new-chat"] : []),
1568
+ ...(input.allow_model_fallback ? ["--allow-model-fallback"] : []),
1501
1569
  "--",
1502
1570
  input.prompt
1503
1571
  ];
@@ -1705,6 +1773,29 @@ export async function assertBrowserLaunchStayedAlive(opened, timeoutMs) {
1705
1773
  }
1706
1774
  throw new Error(`Chrome/Chromium did not expose a reachable DevTools endpoint after launch. Check the visible browser environment, profile lock, display access, or PRODEX_CHROME, then retry.`);
1707
1775
  }
1776
+ /** Whether a send asked for ChatGPT's Pro step, on either axis that can name it. */
1777
+ export function isProSelection(selection) {
1778
+ if (selection.proMode !== undefined)
1779
+ return true;
1780
+ return namesPro(selection.model) || namesPro(selection.effort);
1781
+ }
1782
+ /**
1783
+ * Whether a Pro request was actually answered by Pro, per ChatGPT's own tag on
1784
+ * the message. Undefined when Pro was not asked for, or when no tag came back
1785
+ * to check against - there is nothing to certify in either case.
1786
+ *
1787
+ * The receipt already carried the request and the answering model separately,
1788
+ * leaving the comparison to whoever read it later. The question a caller has
1789
+ * is "can I count this as a Pro review?", and a consult that asked for Pro and
1790
+ * was answered by gpt-5-6-thinking was recorded as a clean success.
1791
+ */
1792
+ export function proSelectionVerified(selection) {
1793
+ if (!isProSelection(selection))
1794
+ return undefined;
1795
+ if (!selection.modelSlug)
1796
+ return undefined;
1797
+ return /pro/i.test(selection.modelSlug);
1798
+ }
1708
1799
  export function browserSendBlockerFromError(error) {
1709
1800
  const blocker = typeof error === "object" && error !== null && "blocker" in error ? error.blocker : undefined;
1710
1801
  if (typeof blocker === "object" &&
@@ -1770,6 +1861,17 @@ export function browserSendBlockerFromError(error) {
1770
1861
  next_step: "Another prodex send holds the browser. Wait for it to finish and retry, or pass a longer --timeout-ms, which is also the queue budget."
1771
1862
  };
1772
1863
  }
1864
+ // The picker could not provide the step that was asked for. Retrying asks
1865
+ // the same picker the same question, so this is not retryable; the caller
1866
+ // either picks a step it offers or opts into whatever the slider is on.
1867
+ if (/has no "[^"]*" step/.test(message)) {
1868
+ return {
1869
+ code: "selection_not_applied",
1870
+ message,
1871
+ retryable: false,
1872
+ next_step: "ChatGPT's picker could not provide the model or effort that was asked for, so nothing was sent. Run `prodex pro browser models` to see the steps this account offers and ask for one of those, or pass --allow-model-fallback to send at whatever the picker is currently set to."
1873
+ };
1874
+ }
1773
1875
  // The picker's slider took focus before its key handler was attached and
1774
1876
  // ignored every press. Retrying is the cure; nothing in the browser is
1775
1877
  // broken, so the generic "resolve it manually" pointed at nothing.
@@ -1878,6 +1980,63 @@ export function formatProConsultArtifact(consult) {
1878
1980
  lines.push("## Answer", "", consult.answer.trim(), "");
1879
1981
  return lines.join("\n");
1880
1982
  }
1983
+ /**
1984
+ * `--since 7d`, `--since 24h`, or a plain date. A window is how you ask whether
1985
+ * a fix landed, so it has to be quick to type.
1986
+ */
1987
+ export function readSinceFlag(args) {
1988
+ const raw = readFlag(args, "--since");
1989
+ if (raw === undefined)
1990
+ return undefined;
1991
+ const relative = /^(\d+)\s*([dh])$/i.exec(raw.trim());
1992
+ if (relative) {
1993
+ const amount = Number(relative[1]);
1994
+ const hours = relative[2]?.toLowerCase() === "d" ? amount * 24 : amount;
1995
+ return new Date(Date.now() - hours * 3_600_000).toISOString();
1996
+ }
1997
+ const parsed = Date.parse(raw);
1998
+ if (!Number.isFinite(parsed)) {
1999
+ throw new Error(`--since expects a date or an age like 7d or 24h, not "${raw}".`);
2000
+ }
2001
+ return new Date(parsed).toISOString();
2002
+ }
2003
+ /** The ranked report as a table, widest column first so the counts line up. */
2004
+ export function formatBlockerReport(report, options = {}) {
2005
+ const scope = options.scopedToOneRepo ? "this repo" : `${report.roots} bridge root${report.roots === 1 ? "" : "s"}`;
2006
+ const window = options.since ? ` since ${options.since.slice(0, 10)}` : "";
2007
+ if (report.totalConsults === 0) {
2008
+ return `No consults recorded in ${scope}${window}.`;
2009
+ }
2010
+ const share = report.totalConsults > 0 ? Math.round((report.blocked / report.totalConsults) * 100) : 0;
2011
+ const head = `Consult blockers: ${report.blocked} of ${report.totalConsults} consults (${share}%) across ${scope}${window}`;
2012
+ if (report.groups.length === 0)
2013
+ return `${head}\n\nNothing was blocked.`;
2014
+ // A split catch-all carries the first sentence in its own name, so the
2015
+ // example would repeat it; only a bare code needs one.
2016
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}\u2026` : text);
2017
+ const rows = report.groups.map((group) => {
2018
+ const example = group.example.replace(/\s+/g, " ").trim();
2019
+ // Compare through the same normalisation the cause went through: the code
2020
+ // says `has no "..." step` where the message says `has no "Pro" step`.
2021
+ const causePart = group.code.includes(": ") ? group.code.split(": ").slice(1).join(": ") : undefined;
2022
+ const redundant = causePart !== undefined && blockerCause(example) === causePart;
2023
+ return {
2024
+ count: String(group.count),
2025
+ share: `${Math.round(group.share * 100)}%`,
2026
+ code: clip(group.code, 58),
2027
+ lastSeen: group.lastSeen ? group.lastSeen.slice(0, 10) : "-",
2028
+ example: redundant ? "" : clip(example, 58)
2029
+ };
2030
+ });
2031
+ const width = (pick, header) => Math.max(header.length, ...rows.map((row) => pick(row).length));
2032
+ const wCount = width((r) => r.count, "COUNT");
2033
+ const wShare = width((r) => r.share, "SHARE");
2034
+ const wCode = width((r) => r.code, "CAUSE");
2035
+ const wLast = width((r) => r.lastSeen, "LAST SEEN");
2036
+ const line = (count, sharePct, code, lastSeen, example) => ` ${count.padStart(wCount)} ${sharePct.padStart(wShare)} ${code.padEnd(wCode)} ${lastSeen.padEnd(wLast)} ${example}`;
2037
+ const body = rows.map((row) => line(row.count, row.share, row.code, row.lastSeen, row.example).trimEnd());
2038
+ return [head, "", line("COUNT", "SHARE", "CAUSE", "LAST SEEN", "EXAMPLE"), ...body].join("\n");
2039
+ }
1881
2040
  export function formatProListSummary(consult, sourceCli, options = {}) {
1882
2041
  const blocker = sourceAwareProAnswerBlocker(consult, sourceCli, options);
1883
2042
  return firstLine(sourceAwareProAnswerSummary(consult.result.summary, consult.result.blocker, blocker));
package/dist/mcp.js CHANGED
@@ -187,7 +187,11 @@ export function createServer(cwd = process.cwd(), options = {}) {
187
187
  new_chat: z
188
188
  .boolean()
189
189
  .optional()
190
- .describe("Start a fresh thread. Omit to continue the current thread (preferred for follow-ups).")
190
+ .describe("Start a fresh thread. Omit to continue the current thread (preferred for follow-ups)."),
191
+ allow_model_fallback: z
192
+ .boolean()
193
+ .optional()
194
+ .describe("Send even when the requested model or effort could not be applied. Off by default, because an answer from a step nobody asked for is usually unusable: a consult asking for Pro that comes back from a lesser model cannot be cited as a Pro review. Pass true only when any answer beats no answer.")
191
195
  }
192
196
  }, async (input, extra) => {
193
197
  // Bridge send progress to MCP progress notifications, but only when
package/dist/registry.js CHANGED
@@ -44,6 +44,38 @@ async function directoryExists(dir) {
44
44
  return false;
45
45
  }
46
46
  }
47
+ /**
48
+ * Every bridge root this machine knows about. The registry has only ever been
49
+ * written; reading it is what lets a report span the repos where the failures
50
+ * actually are, instead of the one the command happens to run in.
51
+ *
52
+ * Advisory like the rest of the registry: a missing or corrupt file is an
53
+ * empty list, not an error, and roots that have since been deleted are
54
+ * dropped rather than reported.
55
+ */
56
+ export async function readBridgeRoots() {
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(await fs.readFile(bridgesRegistryPath(), "utf8"));
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ const raw = parsed?.roots;
65
+ if (!Array.isArray(raw))
66
+ return [];
67
+ const roots = [];
68
+ for (const entry of raw.slice(0, MAX_REGISTRY_ROOTS)) {
69
+ const value = typeof entry === "string" ? entry : entry?.path;
70
+ if (typeof value !== "string" || value.length === 0)
71
+ continue;
72
+ if (!(await directoryExists(path.join(value, ".bridge"))))
73
+ continue;
74
+ if (!roots.includes(value))
75
+ roots.push(value);
76
+ }
77
+ return roots;
78
+ }
47
79
  export function registerBridgeRoot(root) {
48
80
  const next = registryQueue.then(() => registerBridgeRootInner(root));
49
81
  // Keep the chain alive even if an inner registration rejects.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.39.4",
3
+ "version": "0.40.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",