@youdie006/prodex 0.36.3 → 0.36.5

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.
@@ -5,6 +5,7 @@ import net from "node:net";
5
5
  import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import os from "node:os";
8
+ import { readPowerSliderSelection } from "./picker-interaction.js";
8
9
  export class ChatGptBrowserBlockerError extends Error {
9
10
  blocker;
10
11
  constructor(blocker) {
@@ -654,23 +655,38 @@ export function proModeNotAppliedWarning(proMode, sliderEffort) {
654
655
  "Drop --pro-mode here, or clear a saved default with `prodex setup --clear-pro-mode`.");
655
656
  }
656
657
  /**
657
- * Which of two flags the slider actually obeyed.
658
+ * Which control each request belongs to.
658
659
  *
659
- * On this picker one control carries both: "Pro" is the top EFFORT step, not a
660
- * model. Naming a model AND an effort therefore asks for two positions of the
661
- * same slider, and the send takes `effort ?? model` - so the model is dropped.
662
- * Measured: `--model Pro --effort 중간` answered from gpt-5-6-thinking with an
663
- * empty warnings list. Asking for Pro and quietly getting a cheaper model is
664
- * the kind of thing a receipt exists to catch.
660
+ * The picker holds two of them: a `role="slider"` for effort (Instant..Pro) and
661
+ * `menuitemradio` rows for the models. Selection used to send both through the
662
+ * slider (`effort ?? model`), so a model name walked the slider looking for a
663
+ * step it could never be - and the refusal quoted the slider's TEXT, which
664
+ * contains the model names, so the message read as "it is right there in the
665
+ * list". `--model Pro` only ever worked when the slider happened to sit on its
666
+ * top step; once it moved to Instant the same command failed.
667
+ *
668
+ * Pro stopped being a model and became that top step, but it is the pinned
669
+ * default in every repo configured before the change, so it is honoured where
670
+ * it now lives rather than failing every one of them - out loud.
665
671
  */
666
- export function modelIgnoredForEffortWarning(model, effort) {
667
- if (!model || !effort)
668
- return undefined;
669
- if (model.trim().toLowerCase() === effort.trim().toLowerCase())
670
- return undefined;
671
- return (`model_ignored: this ChatGPT picker sets the model and the effort with one slider, ` +
672
- `so --model ${model} and --effort ${effort} name two positions of it. The send used ${effort}. ` +
673
- `Pass only one of them to say which you meant.`);
672
+ export function pickerSelectionPlan(input) {
673
+ const plan = {};
674
+ const modelIsNowAnEffort = input.model !== undefined && /^\s*pro\s*$/i.test(input.model);
675
+ if (input.effort !== undefined)
676
+ plan.sliderLabel = input.effort;
677
+ else if (modelIsNowAnEffort)
678
+ plan.sliderLabel = input.model;
679
+ if (input.model !== undefined && !modelIsNowAnEffort)
680
+ plan.modelLabel = input.model;
681
+ if (modelIsNowAnEffort) {
682
+ plan.warning =
683
+ `model_is_now_an_effort: ChatGPT's picker lists Pro as an effort step, not a model, so --model ${input.model} was ` +
684
+ (input.effort === undefined
685
+ ? "applied to the effort slider instead. Say --effort Pro to be explicit"
686
+ : `ignored in favour of --effort ${input.effort}`) +
687
+ ", or clear a saved default with `prodex setup --model \"\"`.";
688
+ }
689
+ return plan;
674
690
  }
675
691
  export function chatGptBusyBlocker(state) {
676
692
  // Waiting for a choice is not generating. Conflating them turned a click
@@ -1285,6 +1301,14 @@ const CLICK_POINT_SNIPPET = `
1285
1301
  el.scrollIntoView({ block: "center", inline: "nearest" });
1286
1302
  const r = el.getBoundingClientRect();
1287
1303
  if (r.width < 1 || r.height < 1) return { ok: false, reason: "target element has no visible area" };
1304
+ // An inert row cannot be clicked at ANY point, so say that instead of
1305
+ // blaming whatever hit-testing returns instead. Measured on the current
1306
+ // picker: the model rows carry pointer-events:none and sit outside the
1307
+ // menu's own box, so every coordinate on them resolves to the effort
1308
+ // control and the refusal read as "another element covers it".
1309
+ if (getComputedStyle(el).pointerEvents === "none") {
1310
+ return { ok: false, reason: "target element is not clickable (pointer-events: none)" };
1311
+ }
1288
1312
  el.setAttribute('data-prodex-click', '1');
1289
1313
  const x = Math.round(r.x + r.width / 2);
1290
1314
  const y = Math.round(r.y + (yCap ? Math.min(r.height / 2, yCap) : r.height / 2));
@@ -1367,15 +1391,24 @@ export function powerSliderStateExpression() {
1367
1391
  const slider = document.querySelector('[role="slider"]');
1368
1392
  const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
1369
1393
  const lines = menu ? (menu.innerText || "").split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean) : [];
1370
- const after = (label) => { const i = lines.indexOf(label); return i >= 0 ? lines[i + 1] : null; };
1371
1394
  if (!slider) return { ok: false, reason: "power slider not found", lines };
1395
+ const readPowerSliderSelection = ${readPowerSliderSelection.toString()};
1396
+ const selection = readPowerSliderSelection({
1397
+ sliderValueText: slider.getAttribute("aria-valuetext"),
1398
+ items: menu ? [...menu.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')].map((item) => ({
1399
+ role: item.getAttribute("role"),
1400
+ text: item.innerText || item.textContent || "",
1401
+ checked: item.getAttribute("aria-checked") === "true",
1402
+ containsSlider: item.contains(slider)
1403
+ })) : []
1404
+ });
1372
1405
  return {
1373
1406
  ok: true,
1374
1407
  position: Number(slider.getAttribute("aria-valuenow")),
1375
1408
  min: Number(slider.getAttribute("aria-valuemin")),
1376
1409
  max: Number(slider.getAttribute("aria-valuemax")),
1377
- model: after("Model"),
1378
- effort: after("Effort"),
1410
+ model: selection.model,
1411
+ effort: selection.effort,
1379
1412
  lines
1380
1413
  };
1381
1414
  })()`;
@@ -1544,6 +1577,38 @@ const POWER_SLIDER_APPEAR_ATTEMPTS = 5;
1544
1577
  * menu must already be open. Returns the quota line so the caller can warn
1545
1578
  * when Pro runs are nearly spent.
1546
1579
  */
1580
+ // Click a model row. They are ordinary `menuitemradio` entries in the same
1581
+ // menu as the effort slider, so the existing by-label lookup reaches them; what
1582
+ // was missing is anything routing a model here instead of into the slider.
1583
+ /**
1584
+ * Whether a model selection failure is the picker not offering it at all.
1585
+ *
1586
+ * Those two cases - the row is absent, or it is inert - are the UI declining to
1587
+ * provide the choice, and they must not kill a send that is otherwise fine. It
1588
+ * is the same answer prodex already gives for a Pro sub-mode it cannot apply.
1589
+ * Anything else (a menu that would not open, a click that would not land) is a
1590
+ * real failure and stays one, so a genuine break is not buried under a warning.
1591
+ */
1592
+ export function modelSelectionUnavailableWarning(requested, reason, available) {
1593
+ if (!/menu item not found|not clickable/i.test(reason))
1594
+ return undefined;
1595
+ const offers = available?.length ? ` It offers: ${available.join(", ")}.` : "";
1596
+ return (`model_not_applied: this ChatGPT picker does not offer "${requested}" as a selectable model, ` +
1597
+ `so the send used whatever the composer already had.${offers} ` +
1598
+ 'Pick an effort with --effort instead, or clear a saved default with `prodex setup --model ""`.');
1599
+ }
1600
+ async function selectPickerModel(cdp, requested, warnings = []) {
1601
+ const hit = await cdp.evaluate(menuItemRectExpression(requested));
1602
+ if (!hit.ok || hit.x === undefined || hit.y === undefined) {
1603
+ const unavailable = modelSelectionUnavailableWarning(requested, hit.reason ?? "", hit.available);
1604
+ if (unavailable) {
1605
+ warnings.push(unavailable);
1606
+ return;
1607
+ }
1608
+ throw new Error(hit.reason ?? `ChatGPT's model picker has no "${requested}" model.`);
1609
+ }
1610
+ await verifiedClickWithRetry(cdp, () => cdp.evaluate(menuItemRectExpression(requested)), requested);
1611
+ }
1547
1612
  async function selectPowerStep(cdp, requested) {
1548
1613
  // The menu paints a moment after it opens, and on a page that has just been
1549
1614
  // built - a project created seconds ago - that moment is longer. Failing the
@@ -1650,18 +1715,26 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1650
1715
  // it is not, so both UI generations work.
1651
1716
  const sliderState = await cdp.evaluate(powerSliderStateExpression());
1652
1717
  if (sliderState?.ok) {
1653
- const wanted = options.effort ?? options.model;
1654
- if (wanted) {
1655
- await selectPowerStep(cdp, wanted);
1718
+ // The slider is the EFFORT control and the models are radios beside it.
1719
+ // Sending a model name into the slider made it walk every step looking
1720
+ // for one it could never be.
1721
+ const plan = pickerSelectionPlan({
1722
+ ...(options.model !== undefined ? { model: options.model } : {}),
1723
+ ...(options.effort !== undefined ? { effort: options.effort } : {})
1724
+ });
1725
+ if (plan.warning)
1726
+ selectionWarnings.push(plan.warning);
1727
+ if (plan.sliderLabel) {
1728
+ await selectPowerStep(cdp, plan.sliderLabel);
1729
+ }
1730
+ if (plan.modelLabel) {
1731
+ await selectPickerModel(cdp, plan.modelLabel, selectionWarnings);
1656
1732
  }
1657
1733
  // This branch cannot honour a sub-mode, and used to return without
1658
1734
  // saying so - the caller got a clean receipt for a 확장 it never got.
1659
1735
  const proModeWarning = proModeNotAppliedWarning(options.proMode, sliderState.effort ?? undefined);
1660
1736
  if (proModeWarning)
1661
1737
  selectionWarnings.push(proModeWarning);
1662
- const modelWarning = modelIgnoredForEffortWarning(options.model, options.effort);
1663
- if (modelWarning)
1664
- selectionWarnings.push(modelWarning);
1665
1738
  await dispatchEscapeKey(cdp);
1666
1739
  return;
1667
1740
  }
@@ -0,0 +1,40 @@
1
+ /** Pure decision helpers for ChatGPT's composer picker, kept out of the page. */
2
+ /** Read model and effort from both the current mixed-control picker and its prior labeled-row form. */
3
+ export function readPowerSliderSelection(snapshot) {
4
+ const items = snapshot.items.map((item) => ({
5
+ ...item,
6
+ lines: item.text
7
+ .split("\n")
8
+ .map((line) => line.trim())
9
+ .filter(Boolean)
10
+ }));
11
+ const powerLabels = new Set([
12
+ "instant",
13
+ "즉시",
14
+ "빠름",
15
+ "fast",
16
+ "medium",
17
+ "중간",
18
+ "보통",
19
+ "high",
20
+ "높음",
21
+ "extra high",
22
+ "extrahigh",
23
+ "very high",
24
+ "매우 높음",
25
+ "매우높음",
26
+ "pro",
27
+ "프로"
28
+ ]);
29
+ const legacyModel = items.find((item) => item.lines[0] === "Model")?.lines[1] ?? null;
30
+ const legacyEffort = items.find((item) => item.lines[0] === "Effort")?.lines[1] ?? null;
31
+ const checkedModel = items.find((item) => item.role === "menuitemradio" && item.checked)?.lines[0] ?? null;
32
+ const sliderOwner = items.find((item) => item.role === "menuitem" && item.containsSlider)?.lines[0] ?? null;
33
+ const visiblePowerLabel = items.find((item) => item.role === "menuitem" && powerLabels.has((item.lines[0] ?? "").toLowerCase()))?.lines[0] ?? null;
34
+ const accessibleCandidate = snapshot.sliderValueText?.trim() || null;
35
+ const accessibleValue = accessibleCandidate && powerLabels.has(accessibleCandidate.toLowerCase()) ? accessibleCandidate : null;
36
+ return {
37
+ model: legacyModel ?? checkedModel,
38
+ effort: accessibleValue ?? legacyEffort ?? sliderOwner ?? visiblePowerLabel
39
+ };
40
+ }
package/dist/store.js CHANGED
@@ -55,6 +55,10 @@ export async function assertUsableBridgeRoot(root) {
55
55
  return;
56
56
  throw new Error(`Bridge root is not a usable repo directory: ${root}${looksLikeDevicePath ? " (that is a file-descriptor/device path, not a repo)" : ""}. prodex uses the process working directory when no --cwd is given, so a server started from a pipe or a deleted directory lands here. Pass --cwd /absolute/path/to/repo, or set PRODEX_CWD=/absolute/path/to/repo (works for the MCP server, which takes no flags).`);
57
57
  }
58
+ // How many record files to read at once. High enough to hide per-file latency
59
+ // on a slow mount, low enough that a big store cannot run the process out of
60
+ // file descriptors.
61
+ const READ_ALL_CONCURRENCY = 32;
58
62
  export class BridgeStore {
59
63
  root;
60
64
  bridgeDir;
@@ -878,15 +882,40 @@ export class BridgeStore {
878
882
  async readAll(kind, parseRecord, options = {}) {
879
883
  const dir = this.dir(kind);
880
884
  const entries = await readdir(dir, { withFileTypes: true });
881
- const items = [];
882
- for (const entry of entries) {
883
- if (!entry.isFile() || !entry.name.endsWith(".json"))
884
- continue;
885
- const id = entry.name.replace(/\.json$/, "");
886
- if (!isBridgeRecordId(kind, id))
887
- continue;
888
- items.push(this.parseRecord(kind, id, await this.readRecordJson(kind, id, options), parseRecord));
889
- }
885
+ const ids = entries
886
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
887
+ .map((entry) => entry.name.replace(/\.json$/, ""))
888
+ .filter((id) => isBridgeRecordId(kind, id));
889
+ // Read them together. The records are independent, but this loop used to
890
+ // await each one, so the cost was the file count times the filesystem's
891
+ // latency: measured on a Windows mount at ~24ms a file, 505 receipts took
892
+ // 12s, and `pro latest` paid it on every call because verifying ONE result
893
+ // reads every receipt. Bounded so a large store cannot exhaust the fd table.
894
+ const items = new Array(ids.length);
895
+ // The sequential loop threw on the FIRST bad record and never reached the
896
+ // rest, so that is the error callers were told about. Keep reporting the
897
+ // earliest one rather than whichever read happens to lose the race.
898
+ let failure;
899
+ let next = 0;
900
+ const readOne = async () => {
901
+ for (;;) {
902
+ const index = next;
903
+ next += 1;
904
+ if (index >= ids.length)
905
+ return;
906
+ const id = ids[index];
907
+ try {
908
+ items[index] = this.parseRecord(kind, id, await this.readRecordJson(kind, id, options), parseRecord);
909
+ }
910
+ catch (error) {
911
+ if (!failure || index < failure.index)
912
+ failure = { index, error };
913
+ }
914
+ }
915
+ };
916
+ await Promise.all(Array.from({ length: Math.min(READ_ALL_CONCURRENCY, ids.length) }, readOne));
917
+ if (failure)
918
+ throw failure.error;
890
919
  return items;
891
920
  }
892
921
  parseRecord(kind, id, value, parseRecord) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.36.3",
3
+ "version": "0.36.5",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",
@@ -133,7 +133,23 @@ async function readRequiredPackageJson(packageJsonPath) {
133
133
  }
134
134
  }
135
135
 
136
+ // Budgets sized from what each check actually does, not from one number for all
137
+ // of them. Measured on a developer machine whose repo sits on a 9p mount:
138
+ // smoke:package takes 244s because it packs, installs and drives the package
139
+ // end to end, and the suite takes ~50s. A single 180s cap killed smoke:package
140
+ // mid-run and reported "release verification failed" with only npm's banner as
141
+ // evidence, because the process died before it printed anything - while running
142
+ // the same command directly passed every check. CI runs this same path before
143
+ // publishing, so the cap was a thin margin in the release path too.
136
144
  async function runFullReleaseVerification(rootDir) {
145
+ // Declared here, not at module scope: this file runs its entry point during
146
+ // module evaluation, so a top-level const is still in its temporal dead zone
147
+ // by the time this is called.
148
+ const CHECK_TIMEOUT_MS = {
149
+ default: 300_000,
150
+ "npm test": 900_000,
151
+ "npm run smoke:package": 1_800_000
152
+ };
137
153
  const checks = [
138
154
  ["npm", ["test"]],
139
155
  ["npm", ["run", "typecheck"]],
@@ -142,7 +158,8 @@ async function runFullReleaseVerification(rootDir) {
142
158
  ["node", ["dist/cli.js", "doctor"]]
143
159
  ];
144
160
  for (const [command, commandArgs] of checks) {
145
- await run(commandForPlatform(command), commandArgs, rootDir);
161
+ const key = [command, ...commandArgs].join(" ");
162
+ await run(commandForPlatform(command), commandArgs, rootDir, CHECK_TIMEOUT_MS[key] ?? CHECK_TIMEOUT_MS.default);
146
163
  }
147
164
  }
148
165
 
@@ -328,13 +345,13 @@ function formatPathList(paths) {
328
345
  return paths.length > 8 ? `${shown}, ... (${paths.length} files)` : shown;
329
346
  }
330
347
 
331
- async function run(command, commandArgs, cwd) {
348
+ async function run(command, commandArgs, cwd, timeoutMs = 300_000) {
332
349
  const commandLine = [command, ...commandArgs].join(" ");
333
350
  console.log(`release_check: ${commandLine}`);
334
351
  try {
335
352
  await execFileAsync(command, commandArgs, {
336
353
  cwd,
337
- timeout: 180_000,
354
+ timeout: timeoutMs,
338
355
  maxBuffer: 20 * 1024 * 1024
339
356
  });
340
357
  } catch (error) {