@youdie006/prodex 0.40.19 → 0.40.20

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.
@@ -31,7 +31,7 @@ export function pageShapeExpression() {
31
31
  return `(() => {
32
32
  const vis = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
33
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"]');
34
+ const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
35
35
  const describe = (el) => ({
36
36
  role: el.getAttribute("role"),
37
37
  testid: el.getAttribute("data-testid"),
@@ -160,7 +160,7 @@ async function verifyIdle(page) {
160
160
  draft: composer.ok !== false || composer.reason !== 'Composer stayed empty after text insertion',
161
161
  dialog: [...document.querySelectorAll('dialog[open],[role="dialog"],[aria-modal="true"]')].some(visible),
162
162
  attachments: attachmentState.removed > 0 || [...document.querySelectorAll('input[type="file"]')].some(e => e.files?.length > 0) || [...document.querySelectorAll('[role="progressbar"]')].some(visible),
163
- unpersisted: !location.pathname.includes('/c/') && !!document.querySelector('[data-message-author-role]')
163
+ unpersisted: !location.pathname.includes('/c/') && !!document.querySelector('[data-message-author-role],[data-chatgpt-search-unit-key]')
164
164
  };
165
165
  })()`;
166
166
  const reply = await request(page.webSocketDebuggerUrl, "Runtime.evaluate", { expression, returnByValue: true });
@@ -190,7 +190,7 @@ async function verifyVisibleAuthRecovery(page) {
190
190
  return { status, filledTextControl,
191
191
  dialog: [...document.querySelectorAll('dialog[open],[role="dialog"],[aria-modal="true"]')].some(visible),
192
192
  attachments: attachmentState.removed > 0 || [...document.querySelectorAll('input[type="file"]')].some(e => e.files?.length > 0) || [...document.querySelectorAll('[role="progressbar"]')].some(visible),
193
- unpersisted: !location.pathname.includes('/c/') && !!document.querySelector('[data-message-author-role]')
193
+ unpersisted: !location.pathname.includes('/c/') && !!document.querySelector('[data-message-author-role],[data-chatgpt-search-unit-key]')
194
194
  };
195
195
  })()`;
196
196
  const reply = await request(page.webSocketDebuggerUrl, "Runtime.evaluate", { expression, returnByValue: true });
@@ -174,12 +174,44 @@ export function resolveCdpPort(explicit) {
174
174
  // Text/buttons for login and status detection: exclude message bodies and the
175
175
  // composer, but KEEP the sidebar/nav - the logged-in signals ("New chat",
176
176
  // "Projects", the profile button, the plan hint) live there.
177
- export const CHATGPT_RUNTIME_BLOCKER_TEXT_EXCLUDED_ANCESTORS = '[data-message-author-role],script,style,noscript,[aria-hidden="true"],div[role="textbox"],textarea,[contenteditable="true"]';
177
+ export const CHATGPT_RUNTIME_BLOCKER_TEXT_EXCLUDED_ANCESTORS = '[data-message-author-role],[data-chatgpt-search-unit-key],script,style,noscript,[aria-hidden="true"],div[role="textbox"],textarea,[contenteditable="true"]';
178
178
  // Text scanned for PAGE BLOCKERS (captcha/usage-limit/cloudflare/...) also
179
179
  // excludes the sidebar/nav: a past-chat title like "usage limit reset" or
180
180
  // "verify human" in the history list must not be matched as a live blocker.
181
- export const CHATGPT_BLOCKER_SCAN_EXCLUDED_ANCESTORS = `${'[data-message-author-role],script,style,noscript,[aria-hidden="true"],div[role="textbox"],textarea,[contenteditable="true"]'},nav,aside,[role="navigation"]`;
182
- export const CHATGPT_COMPOSER_CANDIDATE_EXCLUDED_ANCESTORS = '[data-message-author-role],script,style,noscript,[aria-hidden="true"]';
181
+ export const CHATGPT_BLOCKER_SCAN_EXCLUDED_ANCESTORS = `${'[data-message-author-role],[data-chatgpt-search-unit-key],script,style,noscript,[aria-hidden="true"],div[role="textbox"],textarea,[contenteditable="true"]'},nav,aside,[role="navigation"]`;
182
+ /**
183
+ * The conversation's messages, whichever markup ChatGPT is rendering.
184
+ *
185
+ * Measured 2026-09-29: messages no longer carry data-message-author-role. A
186
+ * turn is a [data-chatgpt-search-unit-key] whose value ends in ":user" or
187
+ * ":assistant", the answer text sits in [data-chatgpt-selection-message-id],
188
+ * and the user's text in [data-user-message-bubble]. prodex counted zero
189
+ * messages on a page holding a prompt and its answer, so a send that had
190
+ * posted and been answered timed out as "acceptance unconfirmed" - a Pro reply
191
+ * sat in the thread for ten minutes while prodex waited for it. The model slug
192
+ * attribute went with it; where it is absent the model is simply not known.
193
+ */
194
+ const CHATGPT_MESSAGE_NODES_JS = `
195
+ const chatMessageNodes = () => {
196
+ const legacy = [...document.querySelectorAll('[data-message-author-role]')];
197
+ if (legacy.length > 0) {
198
+ return legacy.map((node) => ({ role: node.getAttribute('data-message-author-role'), node, textNode: node }));
199
+ }
200
+ const units = [...document.querySelectorAll('[data-chatgpt-search-unit-key]')];
201
+ return units
202
+ .filter((unit) => !units.some((other) => other !== unit && unit.contains(other)))
203
+ .map((unit) => {
204
+ const key = unit.getAttribute('data-chatgpt-search-unit-key') || '';
205
+ const role = /:assistant$/.test(key) ? 'assistant' : /:user$/.test(key) ? 'user' : null;
206
+ const textNode =
207
+ role === 'assistant'
208
+ ? unit.querySelector('[data-chatgpt-selection-message-id]') || unit
209
+ : unit.querySelector('[data-user-message-bubble]') || unit;
210
+ return { role, node: unit, textNode };
211
+ })
212
+ .filter((message) => message.role);
213
+ };`;
214
+ export const CHATGPT_COMPOSER_CANDIDATE_EXCLUDED_ANCESTORS = '[data-message-author-role],[data-chatgpt-search-unit-key],script,style,noscript,[aria-hidden="true"]';
183
215
  export const PRODEX_ACTIVE_COMPOSER_ATTRIBUTE = "data-prodex-active-composer";
184
216
  const MAX_PAGE_DISCOVERY_TIMEOUT_MS = 5_000;
185
217
  const PAGE_VISIBILITY_PROBE_TIMEOUT_MS = 1_000;
@@ -1655,10 +1687,10 @@ const MENU_OPEN_TIMEOUT_MS = 5_000;
1655
1687
  const MENU_SETTLE_TIMEOUT_MS = 5_000;
1656
1688
  const PROJECT_NAVIGATION_TIMEOUT_MS = 8_000;
1657
1689
  export function menuOpenExpression() {
1658
- return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"]'))`;
1690
+ return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])'))`;
1659
1691
  }
1660
1692
  export function menuClosedExpression() {
1661
- return `!document.querySelector('[data-testid="composer-intelligence-picker-content"]')`;
1693
+ return `!document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])')`;
1662
1694
  }
1663
1695
  function toLabelCandidates(label) {
1664
1696
  return typeof label === "string" ? [label] : [...label];
@@ -1786,7 +1818,7 @@ export function powerSliderStateExpression() {
1786
1818
  // Scoped to the picker, with a document-wide fallback for pickers that
1787
1819
  // predate the testid. Asking the whole document first meant any other
1788
1820
  // slider on the page became the one that got read and driven.
1789
- const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
1821
+ const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
1790
1822
  const slider = (menu && menu.querySelector('[role="slider"]')) || (menu ? null : document.querySelector('[role="slider"]'));
1791
1823
  const lines = menu ? (menu.innerText || "").split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean) : [];
1792
1824
  if (!slider) return { ok: false, reason: "power slider not found", lines };
@@ -1815,7 +1847,7 @@ export function activeMenuItemExpression() {
1815
1847
  return `(() => {
1816
1848
  const a = document.activeElement;
1817
1849
  if (!a) return null;
1818
- const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
1850
+ const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
1819
1851
  if (menu && !menu.contains(a)) return null;
1820
1852
  const label = ((a.innerText || a.textContent || "").trim().split(String.fromCharCode(10))[0] || "").trim();
1821
1853
  return { role: a.getAttribute("role"), label };
@@ -2055,15 +2087,21 @@ export function composerProjectBinding(input) {
2055
2087
  return "elsewhere";
2056
2088
  return "unknown";
2057
2089
  }
2090
+ // The picker menu used to carry data-testid="composer-intelligence-picker-content".
2091
+ // Measured 2026-09-29: ChatGPT dropped that testid; the picker is now a plain
2092
+ // Radix role="menu" still holding the effort slider and the model radios, so
2093
+ // every lookup below found nothing and a send died on "model menu did not open"
2094
+ // with the menu open on screen. Each lookup accepts the old testid or a menu
2095
+ // that contains the slider - the one thing that makes it this picker.
2058
2096
  export function powerSliderPresentExpression() {
2059
- return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"]'))`;
2097
+ return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"],[role="menu"] [role="slider"]'))`;
2060
2098
  }
2061
2099
  export function pickerClosedExpression() {
2062
- return `!document.querySelector('[data-testid="composer-intelligence-picker-content"]')`;
2100
+ return `!document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])')`;
2063
2101
  }
2064
2102
  export function focusPickerMenuExpression() {
2065
2103
  return `(() => {
2066
- const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
2104
+ const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
2067
2105
  if (!menu) return { ok: false, reason: "picker menu not open" };
2068
2106
  // Leave the focus the menu gave itself. It opens on the checked model row,
2069
2107
  // and ArrowDown from there walks the model list. Focusing the first item
@@ -2082,7 +2120,7 @@ export function focusPowerSliderExpression() {
2082
2120
  // Scoped to the picker, with a document-wide fallback for pickers that
2083
2121
  // predate the testid. Asking the whole document first meant any other
2084
2122
  // slider on the page became the one that got read and driven.
2085
- const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
2123
+ const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
2086
2124
  const slider = (menu && menu.querySelector('[role="slider"]')) || (menu ? null : document.querySelector('[role="slider"]'));
2087
2125
  if (!slider) return { ok: false, reason: "power slider not found" };
2088
2126
  slider.focus();
@@ -2107,7 +2145,7 @@ export function modelButtonRectExpression() {
2107
2145
  }
2108
2146
  export function menuItemRectExpression(label) {
2109
2147
  return `(() => {${CLICK_POINT_SNIPPET}
2110
- const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
2148
+ const m = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
2111
2149
  if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
2112
2150
  const items = [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
2113
2151
  const it = items.find(${menuLabelMatchPredicate(label)});
@@ -2135,7 +2173,7 @@ const PRO_RADIO_FINDER_SNIPPET = `
2135
2173
  [...scope.querySelectorAll('[role="menuitemradio"]')].find((r) => /^Pro( |$)/.test(((r.innerText || r.textContent || "").trim().split(String.fromCharCode(10))[0] || "").trim()));`;
2136
2174
  export function proRadioRectExpression() {
2137
2175
  return `(() => {${CLICK_POINT_SNIPPET}${PRO_RADIO_FINDER_SNIPPET}
2138
- const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
2176
+ const m = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
2139
2177
  if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
2140
2178
  const proRadio = findProRadio(m);
2141
2179
  if (!proRadio) return { ok: false, reason: "Pro option not found in the model menu" };
@@ -2187,54 +2225,123 @@ export function matchProjectOptionName(ariaLabels, wanted) {
2187
2225
  return "ambiguous";
2188
2226
  return -1;
2189
2227
  }
2228
+ /**
2229
+ * The sidebar's project rows, read the way the current ChatGPT renders them.
2230
+ *
2231
+ * Measured 2026-09-29: ChatGPT replaced the per-row "Open project options for
2232
+ * <name>" button, which every project lookup in prodex keyed on, with
2233
+ * "Project actions for <name>", and made the row itself a disclosure that
2234
+ * expands in place instead of navigating. The row now carries its name and id
2235
+ * as attributes (`data-app-action-sidebar-project-label`, `-id`), which is a
2236
+ * sturdier key than parsing a button label. Every --project send reported
2237
+ * "project not found in sidebar (0 projects visible)" with the sidebar open
2238
+ * and nine projects on screen.
2239
+ *
2240
+ * Falls back to the older option-button labels, so the previous UI still
2241
+ * resolves.
2242
+ */
2243
+ const SIDEBAR_PROJECT_ROWS_SNIPPET = `
2244
+ const legacyProjectName = (b) => (b.getAttribute("aria-label") || "").replace(/^open project options for /i, "").replace(/\\s*프로젝트 옵션 열기$/, "").trim();
2245
+ const sidebarProjects = () => {
2246
+ const rows = [...document.querySelectorAll("[data-app-action-sidebar-project-row]")]
2247
+ .map((row) => ({
2248
+ name: (row.getAttribute("data-app-action-sidebar-project-label") || "").trim(),
2249
+ id: (row.getAttribute("data-app-action-sidebar-project-id") || "").trim(),
2250
+ row,
2251
+ current: true
2252
+ }))
2253
+ .filter((entry) => entry.name);
2254
+ if (rows.length > 0) return rows;
2255
+ return [...document.querySelectorAll('[aria-label*="프로젝트 옵션"],[aria-label*="project options" i]')]
2256
+ .map((button) => ({
2257
+ name: legacyProjectName(button),
2258
+ id: "",
2259
+ row: button.closest('a,[role="link"],li') || button.parentElement,
2260
+ current: false
2261
+ }))
2262
+ .filter((entry) => entry.name);
2263
+ };`;
2264
+ /**
2265
+ * Where to rest the pointer so a sidebar project row shows its controls.
2266
+ *
2267
+ * The row's "New chat in <name>" button is only revealed while the row is
2268
+ * hovered: measured, the button itself reports opacity 1 while its container
2269
+ * sits at opacity 0, and the point prodex clicked resolved to the section
2270
+ * behind it. A manual test passed only because the pointer was already on the
2271
+ * row; a real send, arriving with the pointer elsewhere, clicked the section
2272
+ * and reported "did not navigate the visible tab".
2273
+ */
2274
+ export function sidebarProjectRowPointExpression(name) {
2275
+ return `(() => {${SIDEBAR_PROJECT_ROWS_SNIPPET}
2276
+ const wanted = ${JSON.stringify(name)};
2277
+ const projects = sidebarProjects().filter((p) => p.current);
2278
+ const exact = projects.filter((p) => p.name === wanted);
2279
+ const matches = exact.length > 0 ? exact : projects.filter((p) => p.name.toLowerCase() === wanted.toLowerCase());
2280
+ if (matches.length !== 1) return { ok: false };
2281
+ const r = matches[0].row.getBoundingClientRect();
2282
+ if (r.width < 1 || r.height < 1) return { ok: false };
2283
+ return { ok: true, x: Math.round(r.x + Math.min(r.width / 3, 60)), y: Math.round(r.y + r.height / 2) };
2284
+ })()`;
2285
+ }
2286
+ /** Hover a project row so its hover-only controls can be clicked. A no-op on the older sidebar. */
2287
+ async function revealSidebarProjectControls(cdp, name) {
2288
+ const point = await cdp.evaluate(sidebarProjectRowPointExpression(name));
2289
+ if (!point?.ok || point.x === undefined || point.y === undefined)
2290
+ return;
2291
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y });
2292
+ await sleep(400);
2293
+ }
2190
2294
  export function projectItemRectExpression(name) {
2191
- return `(() => {${CLICK_POINT_SNIPPET}
2192
- // Korean: "<name> 프로젝트 옵션 열기"; English: "Open project options for <name>".
2295
+ return `(() => {${CLICK_POINT_SNIPPET}${SIDEBAR_PROJECT_ROWS_SNIPPET}
2193
2296
  const wanted = ${JSON.stringify(name)};
2194
- // Extract the project name from the aria-label wrapper, then match by
2195
- // EQUALITY (mirror of matchProjectOptionName). A bare .includes() let
2196
- // "Codex" select "Codex Review" (substring) and silently sent the prompt
2197
- // into the wrong project.
2198
- const projName = (b) => (b.getAttribute("aria-label") || "").replace(/^open project options for /i, "").replace(/\\s*프로젝트 옵션 열기$/, "").trim();
2199
- const optionButtons = [...document.querySelectorAll('[aria-label*="프로젝트 옵션"],[aria-label*="project options" i]')];
2200
- let opt = null;
2201
- const exact = optionButtons.filter((b) => projName(b) === wanted);
2202
- if (exact.length === 1) opt = exact[0];
2297
+ // Match by EQUALITY, exact first and then case-insensitive, and refuse on
2298
+ // ambiguity: a substring match once let "Codex" select "Codex Review" and
2299
+ // post into a project nobody named.
2300
+ const projects = sidebarProjects();
2301
+ const exact = projects.filter((p) => p.name === wanted);
2302
+ let hit = null;
2303
+ if (exact.length === 1) hit = exact[0];
2203
2304
  else if (exact.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects; rename one to disambiguate" };
2204
2305
  else {
2205
- // Case-insensitive fallback: sidebar names are user-typed ("Codex") and
2206
- // an agent asking for "codex" should still resolve when unambiguous.
2207
- // Ambiguity fails loudly rather than guessing.
2208
- const ci = optionButtons.filter((b) => projName(b).toLowerCase() === wanted.toLowerCase());
2209
- if (ci.length === 1) opt = ci[0];
2306
+ const ci = projects.filter((p) => p.name.toLowerCase() === wanted.toLowerCase());
2307
+ if (ci.length === 1) hit = ci[0];
2210
2308
  else if (ci.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects case-insensitively; use the exact name" };
2211
2309
  }
2212
- let target = opt ? (opt.closest('a,[role="link"],li') || opt.parentElement) : null;
2213
- if (!target) {
2214
- // The fallback for a sidebar whose option buttons this cannot read. It
2215
- // used to take the first row CONTAINING the name, which is the substring
2216
- // match the exact comparison above exists to prevent: asking for "Codex"
2217
- // took "Codex Review" and sent the prompt into a project nobody named.
2218
- // A row's first line is its name; anything else here is a guess, and a
2219
- // guess about which project to post into is the failure being fixed.
2310
+ let visible = projects.length;
2311
+ if (!hit && projects.length === 0) {
2312
+ // The legacy fallback for a sidebar whose option buttons cannot be read:
2313
+ // a row's first line is its name, compared by equality.
2220
2314
  const icons = [...document.querySelectorAll('[data-testid="project-folder-icon"]')];
2221
2315
  const rowName = (row) => ((row.innerText || row.textContent || "").split("\\n").map((line) => line.trim()).find((line) => line.length > 0) || "");
2222
2316
  const rows = icons.map((ic) => ic.closest('a,li,[role="link"]') || ic.parentElement?.parentElement).filter(Boolean);
2317
+ visible = rows.length;
2223
2318
  const named = rows.filter((row) => rowName(row).toLowerCase() === wanted.toLowerCase());
2224
2319
  if (named.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects; rename one to disambiguate" };
2225
- if (named.length === 1) target = named[0];
2226
- }
2227
- if (!target) {
2228
- return { ok: false, reason: "project not found in sidebar (" + optionButtons.length + " projects visible; names are matched exactly first, then case-insensitively - check the exact sidebar spelling)" };
2320
+ if (named.length === 1) hit = { name: wanted, id: "", row: named[0], current: false };
2229
2321
  }
2230
- // 2026-07 ChatGPT update: the project row (li) is no longer a link - the
2231
- // navigation affordance is a dedicated "Open project home" button inside
2232
- // the row (verified live: clicking the row does nothing, clicking the home
2233
- // button navigates to /g/g-p-...). Prefer it; fall back to the row click
2234
- // for the old UI.
2235
- const home = target.querySelector('[aria-label*="project home" i],[aria-label*="프로젝트 홈"]');
2322
+ if (!hit) {
2323
+ if (visible === 0) {
2324
+ return { ok: false, reason: "no sidebar projects could be read (0 projects visible) - the sidebar is not rendered or ChatGPT changed how it lists projects" };
2325
+ }
2326
+ return { ok: false, reason: "project not found in sidebar (" + visible + " projects visible; names are matched exactly first, then case-insensitively - check the exact sidebar spelling)" };
2327
+ }
2328
+ if (hit.current) {
2329
+ // Clicking the row now only expands it. Its "New chat in <name>" button
2330
+ // is what opens the project, on a fresh chat inside it - measured: it
2331
+ // lands on /g/<id>/project with the composer labelled for that project.
2332
+ const label = "New chat in " + hit.name;
2333
+ const koLabel = hit.name + "에서 새 채팅";
2334
+ const buttons = [...document.querySelectorAll("button[aria-label]")];
2335
+ const enter =
2336
+ buttons.find((b) => hit.row.contains(b) && (b.getAttribute("aria-label") === label || b.getAttribute("aria-label") === koLabel)) ||
2337
+ buttons.find((b) => b.getAttribute("aria-label") === label || b.getAttribute("aria-label") === koLabel);
2338
+ if (!enter) return { ok: false, reason: "project row found but it has no new-chat control to open the project" };
2339
+ return clickPoint(enter);
2340
+ }
2341
+ // Older UI: a dedicated "Open project home" button inside the row, else the row.
2342
+ const home = hit.row.querySelector('[aria-label*="project home" i],[aria-label*="프로젝트 홈"]');
2236
2343
  if (home) return clickPoint(home);
2237
- return clickPoint(target, 18);
2344
+ return clickPoint(hit.row, 18);
2238
2345
  })()`;
2239
2346
  }
2240
2347
  // Clicking a menuitemradio commits the choice and closes the picker; a menu
@@ -2854,7 +2961,7 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
2854
2961
  export function newProjectButtonRectExpression() {
2855
2962
  return `(() => {${CLICK_POINT_SNIPPET}
2856
2963
  const el =
2857
- document.querySelector('button[aria-label="새 프로젝트"],button[aria-label="New project"]') ||
2964
+ document.querySelector('button[aria-label="새 프로젝트"],button[aria-label="New project"],button[aria-label="Add new project"]') ||
2858
2965
  [...document.querySelectorAll('button,[role="button"]')].find((b) =>
2859
2966
  /새 프로젝트|new project/i.test(((b.textContent || "") + (b.getAttribute("aria-label") || "")).trim())
2860
2967
  );
@@ -3031,6 +3138,7 @@ async function navigateToExistingProject(cdp, project) {
3031
3138
  const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
3032
3139
  throw new Error(`ChatGPT project not found in sidebar: ${project}${detail} List the visible names with \`prodex pro browser projects\`.`);
3033
3140
  }
3141
+ await revealSidebarProjectControls(cdp, project);
3034
3142
  await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(project)), `project ${project}`);
3035
3143
  const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
3036
3144
  if (!navigated) {
@@ -3147,6 +3255,7 @@ async function selectProject(cdp, options) {
3147
3255
  // project home - and then the same sidebar navigation over again.
3148
3256
  try {
3149
3257
  await openFreshChatGptHome(cdp);
3258
+ await revealSidebarProjectControls(cdp, wanted);
3150
3259
  await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(wanted)), `project ${wanted}`);
3151
3260
  // The click's navigation has to land before the placeholder means
3152
3261
  // anything; read on the page we came from, it answers about the wrong
@@ -3703,9 +3812,9 @@ export async function sendChatGptPrompt(options) {
3703
3812
  // inserts a newline) fall back to clicking the send button, re-reading
3704
3813
  // FRESH coordinates each attempt. Safe against double-submit: once the
3705
3814
  // prompt posts the composer clears and no send button is found.
3706
- const promptPostedExpression = `(() => {
3707
- const last = [...document.querySelectorAll('[data-message-author-role="user"]')].at(-1);
3708
- return Boolean(last && (last.innerText || "").includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
3815
+ const promptPostedExpression = `(() => {${CHATGPT_MESSAGE_NODES_JS}
3816
+ const last = chatMessageNodes().filter((message) => message.role === "user").at(-1);
3817
+ return Boolean(last && (last.textNode.innerText || "").includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
3709
3818
  })()`;
3710
3819
  // A dispatch can reach Chrome even if its acknowledgement is lost.
3711
3820
  submissionAttempted = true;
@@ -3933,7 +4042,7 @@ export function formatModelMenuOption(option) {
3933
4042
  }
3934
4043
  export function modelMenuOptionsExpression() {
3935
4044
  return `(() => {
3936
- const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
4045
+ const m = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
3937
4046
  if (!m) return [];
3938
4047
  // Radios are models, and so are the submenu rows an earlier picker kept them
3939
4048
  // behind. What is NOT a model is the power slider's own rows - its track,
@@ -3967,11 +4076,8 @@ export function modelMenuOptionsExpression() {
3967
4076
  // Sidebar project names, extracted from the per-row options-button aria-labels
3968
4077
  // (English "Open project options for <name>", Korean "<name> 프로젝트 옵션 열기").
3969
4078
  export function sidebarProjectNamesExpression() {
3970
- return `(() => {
3971
- const names = [...document.querySelectorAll('[aria-label*="프로젝트 옵션"],[aria-label*="project options" i]')]
3972
- .map((b) => (b.getAttribute("aria-label") || "").replace(/^open project options for /i, "").replace(/\\s*프로젝트 옵션 열기$/, "").trim())
3973
- .filter(Boolean);
3974
- return [...new Set(names)];
4079
+ return `(() => {${SIDEBAR_PROJECT_ROWS_SNIPPET}
4080
+ return [...new Set(sidebarProjects().map((p) => p.name))];
3975
4081
  })()`;
3976
4082
  }
3977
4083
  // Read-only discovery for --project/setup --project: list the sidebar project
@@ -4782,9 +4888,10 @@ export function statusExpression() {
4782
4888
  .filter((el) => !el.closest(runtimeExcludedTextSelector))
4783
4889
  .map((el) => (el.innerText || el.getAttribute("aria-label") || el.getAttribute("data-testid") || "").trim())
4784
4890
  .filter(Boolean);
4785
- const messages = [...document.querySelectorAll('[data-message-author-role]')].map((node) => ({
4786
- role: node.getAttribute('data-message-author-role'),
4787
- text: node.innerText || ""
4891
+ ${CHATGPT_MESSAGE_NODES_JS}
4892
+ const messages = chatMessageNodes().map((message) => ({
4893
+ role: message.role,
4894
+ text: message.textNode.innerText || ""
4788
4895
  }));
4789
4896
  const assistant = messages.filter((message) => message.role === "assistant").at(-1);
4790
4897
  const answer = assistant?.text || "";
@@ -5662,16 +5769,17 @@ export function answerExpression() {
5662
5769
  return parts.join(String.fromCharCode(10));
5663
5770
  };
5664
5771
  const lines = text.split(String.fromCharCode(10)).map((line) => line.trim()).filter(Boolean);
5665
- const messages = [...document.querySelectorAll('[data-message-author-role]')].map((node) => {
5666
- // ChatGPT tags each message with the model that produced it, on the
5667
- // message node or an ancestor depending on the build. This is the only
5668
- // ground truth for "did the Pro selection actually take".
5772
+ ${CHATGPT_MESSAGE_NODES_JS}
5773
+ const messages = chatMessageNodes().map(({ role, node, textNode }) => {
5774
+ // ChatGPT tagged each message with the model that produced it, on the
5775
+ // message node or an ancestor depending on the build. The current markup
5776
+ // carries no such tag, so the model is unknown there, not guessed.
5669
5777
  let modelSlug = node.getAttribute('data-message-model-slug') || undefined;
5670
5778
  if (!modelSlug && typeof node.closest === "function") {
5671
5779
  const tagged = node.closest('[data-message-model-slug]');
5672
5780
  if (tagged) modelSlug = tagged.getAttribute('data-message-model-slug') || undefined;
5673
5781
  }
5674
- return { role: node.getAttribute('data-message-author-role'), text: node.innerText || "", modelSlug };
5782
+ return { role, text: textNode.innerText || "", modelSlug };
5675
5783
  });
5676
5784
  const assistantMessages = messages.filter((message) => message.role === "assistant");
5677
5785
  const userMessages = messages.filter((message) => message.role === "user");
package/dist/cli-pro.js CHANGED
@@ -2724,6 +2724,21 @@ export function browserSendBlockerFromError(error) {
2724
2724
  "same thing in the prompt."
2725
2725
  };
2726
2726
  }
2727
+ // Zero projects on screen is not a missing name; it is a sidebar prodex could
2728
+ // not read. Measured 2026-09-29: ChatGPT renamed the row controls, every
2729
+ // project send said "0 projects visible" with nine projects on screen, and
2730
+ // calling that "not found, do not retry" sent the caller away from the one
2731
+ // thing worth doing - checking the browser and retrying once it reads again.
2732
+ if (/ChatGPT project not found in sidebar/.test(message) && /no sidebar projects could be read|\(0 projects visible/.test(message)) {
2733
+ return {
2734
+ code: "sidebar_projects_unreadable",
2735
+ message,
2736
+ retryable: true,
2737
+ next_step: "Nothing was sent. prodex could not read any project in the sidebar - it may not have rendered yet, or ChatGPT " +
2738
+ "changed how it lists projects. Run `prodex pro browser projects`: if it lists none while the sidebar shows " +
2739
+ "them, update prodex; otherwise retry."
2740
+ };
2741
+ }
2727
2742
  if (/ChatGPT project not found in sidebar/.test(message)) {
2728
2743
  return {
2729
2744
  code: "project_not_found",
package/dist/tui.js CHANGED
@@ -82,6 +82,17 @@ export function projectsWithIdsExpression() {
82
82
  return `(() => {
83
83
  const out = [];
84
84
  const seen = new Set();
85
+ // Current sidebar rows carry the project's name and id as attributes; only
86
+ // the open project still renders a /project link, so reading links alone
87
+ // listed one project of nine (measured 2026-09-29).
88
+ for (const row of document.querySelectorAll("[data-app-action-sidebar-project-row]")) {
89
+ if (out.length >= 100) break;
90
+ const rawId = (row.getAttribute("data-app-action-sidebar-project-id") || "").trim().toLowerCase();
91
+ const name = (row.getAttribute("data-app-action-sidebar-project-label") || "").replace(/\\s+/g, " ").trim().slice(0, 200);
92
+ if (!/^g-p-[0-9a-f]{1,128}$/.test(rawId) || !name || seen.has(rawId)) continue;
93
+ seen.add(rawId);
94
+ out.push({ id: rawId, name });
95
+ }
85
96
  for (const anchor of document.querySelectorAll("a[href]")) {
86
97
  if (out.length >= 100) break;
87
98
  if (typeof anchor.getClientRects !== "function" || anchor.getClientRects().length === 0) continue;
package/docs/releasing.md CHANGED
@@ -16,6 +16,16 @@ Release target: `v0.40.13` on `main`. The GitHub Release records the resolved co
16
16
 
17
17
  The release adds a guarded, opt-in transition and more accurate blocker reporting. It does not claim that headless Pro consultation is verified on either deployment target or that authentication can be retained indefinitely.
18
18
 
19
+ ## 0.40.19 Release Record
20
+
21
+ Release commit `8650136` on `main` (cut after PR #8, merge `b5a223f`); tag `v0.40.19`. Published by `publish.yml` run 36510352704: all six native verification jobs passed (Ubuntu Node 20/22/24, macOS 15 arm64 and Intel, Windows Server 2025 / Node 22), then npm publish with SLSA v1 provenance, then the GitHub Release (2026-09-29 02:17 UTC). Before tagging, PR #8 passed the same six jobs and `npm run release:verify` passed locally on the release commit after a build (a fresh checkout without `dist` fails two package-bin cases, because `release:verify` runs tests before building).
22
+
23
+ Installation on 2026-09-29, global npm, each verified with `prodex --version` = 0.40.19:
24
+
25
+ - WSL, M3, Mac mini: installed. Running services were not restarted: 3 stdio MCP servers on WSL and 4 on M3 keep the code they started with until their clients reconnect; the Mac mini had none.
26
+ - The browser container `prodex-browser-browser-1`, which this machine's Claude Code MCP entry now goes through, runs its own build and still reports 0.40.18; an npm install does not update it. It had exited on 2026-09-28 after Chromium's GPU process kept failing ("GPU process isn't usable") and was restarted with the documented `docker compose ... up -d --no-build browser` before this release.
27
+ - M4: not reachable (hostname does not resolve); not updated.
28
+
19
29
  ## 0.40.2 - 0.40.7 Release Record
20
30
 
21
31
  Recorded 2026-09-29, after the fact. These versions went around the tag-and-CI path, so none of them has a git tag, a GitHub Release or npm provenance; this section is their durable record. The commit for each published version was matched to the npm publish time and cross-checked by downloading the published package and finding that version's fixes in its `dist`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.19",
3
+ "version": "0.40.20",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",