@youdie006/prodex 0.40.19 → 0.40.21
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/dist/browser-diagnostics.js +1 -1
- package/dist/browser-handoff.js +2 -2
- package/dist/chatgpt-browser.js +303 -76
- package/dist/cli-pro.js +15 -0
- package/dist/tui.js +11 -0
- package/docs/releasing.md +23 -0
- package/package.json +1 -1
|
@@ -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"),
|
package/dist/browser-handoff.js
CHANGED
|
@@ -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 });
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -190,7 +222,15 @@ const CHATGPT_GENERATING_CONTROL_PATTERN = /\bstop\s+(?:generating|responding|re
|
|
|
190
222
|
// are more reliable than the button label text, which the pattern above can
|
|
191
223
|
// miss on an icon-only/relabeled control - a miss would let a mid-stream pause
|
|
192
224
|
// be accepted as the final answer, silently truncating it.
|
|
193
|
-
|
|
225
|
+
//
|
|
226
|
+
// Measured 2026-09-29: the stop control lost its test id and is now a composer
|
|
227
|
+
// button labelled just "Stop". Sampled every 1.5 s through a streamed answer,
|
|
228
|
+
// prodex reported generating=false on all 23 samples while that button was on
|
|
229
|
+
// screen - so a pause mid-answer (a Pro reasoning gap, a network stall) could
|
|
230
|
+
// be taken for the end and a truncated answer returned as complete. The label
|
|
231
|
+
// is matched only inside the composer's form, where the send control lives,
|
|
232
|
+
// so a "Stop" elsewhere on the page cannot answer for it.
|
|
233
|
+
export const CHATGPT_STREAMING_SELECTOR = '[data-testid="stop-button"],form button[aria-label="Stop"],form button[aria-label="중지"],[data-message-author-role="assistant"][aria-busy="true"],[data-message-author-role="assistant"] [aria-busy="true"]';
|
|
194
234
|
export function defaultChatGptProfileDir() {
|
|
195
235
|
return path.join(os.homedir(), ".local", "share", "prodex", "chrome-chatgpt-pro");
|
|
196
236
|
}
|
|
@@ -1655,10 +1695,10 @@ const MENU_OPEN_TIMEOUT_MS = 5_000;
|
|
|
1655
1695
|
const MENU_SETTLE_TIMEOUT_MS = 5_000;
|
|
1656
1696
|
const PROJECT_NAVIGATION_TIMEOUT_MS = 8_000;
|
|
1657
1697
|
export function menuOpenExpression() {
|
|
1658
|
-
return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"]'))`;
|
|
1698
|
+
return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])'))`;
|
|
1659
1699
|
}
|
|
1660
1700
|
export function menuClosedExpression() {
|
|
1661
|
-
return `!document.querySelector('[data-testid="composer-intelligence-picker-content"]')`;
|
|
1701
|
+
return `!document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])')`;
|
|
1662
1702
|
}
|
|
1663
1703
|
function toLabelCandidates(label) {
|
|
1664
1704
|
return typeof label === "string" ? [label] : [...label];
|
|
@@ -1786,7 +1826,7 @@ export function powerSliderStateExpression() {
|
|
|
1786
1826
|
// Scoped to the picker, with a document-wide fallback for pickers that
|
|
1787
1827
|
// predate the testid. Asking the whole document first meant any other
|
|
1788
1828
|
// slider on the page became the one that got read and driven.
|
|
1789
|
-
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
1829
|
+
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
|
|
1790
1830
|
const slider = (menu && menu.querySelector('[role="slider"]')) || (menu ? null : document.querySelector('[role="slider"]'));
|
|
1791
1831
|
const lines = menu ? (menu.innerText || "").split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean) : [];
|
|
1792
1832
|
if (!slider) return { ok: false, reason: "power slider not found", lines };
|
|
@@ -1815,7 +1855,7 @@ export function activeMenuItemExpression() {
|
|
|
1815
1855
|
return `(() => {
|
|
1816
1856
|
const a = document.activeElement;
|
|
1817
1857
|
if (!a) return null;
|
|
1818
|
-
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
1858
|
+
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
|
|
1819
1859
|
if (menu && !menu.contains(a)) return null;
|
|
1820
1860
|
const label = ((a.innerText || a.textContent || "").trim().split(String.fromCharCode(10))[0] || "").trim();
|
|
1821
1861
|
return { role: a.getAttribute("role"), label };
|
|
@@ -2055,15 +2095,21 @@ export function composerProjectBinding(input) {
|
|
|
2055
2095
|
return "elsewhere";
|
|
2056
2096
|
return "unknown";
|
|
2057
2097
|
}
|
|
2098
|
+
// The picker menu used to carry data-testid="composer-intelligence-picker-content".
|
|
2099
|
+
// Measured 2026-09-29: ChatGPT dropped that testid; the picker is now a plain
|
|
2100
|
+
// Radix role="menu" still holding the effort slider and the model radios, so
|
|
2101
|
+
// every lookup below found nothing and a send died on "model menu did not open"
|
|
2102
|
+
// with the menu open on screen. Each lookup accepts the old testid or a menu
|
|
2103
|
+
// that contains the slider - the one thing that makes it this picker.
|
|
2058
2104
|
export function powerSliderPresentExpression() {
|
|
2059
|
-
return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"]'))`;
|
|
2105
|
+
return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"],[role="menu"] [role="slider"]'))`;
|
|
2060
2106
|
}
|
|
2061
2107
|
export function pickerClosedExpression() {
|
|
2062
|
-
return `!document.querySelector('[data-testid="composer-intelligence-picker-content"]')`;
|
|
2108
|
+
return `!document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])')`;
|
|
2063
2109
|
}
|
|
2064
2110
|
export function focusPickerMenuExpression() {
|
|
2065
2111
|
return `(() => {
|
|
2066
|
-
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
2112
|
+
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
|
|
2067
2113
|
if (!menu) return { ok: false, reason: "picker menu not open" };
|
|
2068
2114
|
// Leave the focus the menu gave itself. It opens on the checked model row,
|
|
2069
2115
|
// and ArrowDown from there walks the model list. Focusing the first item
|
|
@@ -2082,22 +2128,43 @@ export function focusPowerSliderExpression() {
|
|
|
2082
2128
|
// Scoped to the picker, with a document-wide fallback for pickers that
|
|
2083
2129
|
// predate the testid. Asking the whole document first meant any other
|
|
2084
2130
|
// slider on the page became the one that got read and driven.
|
|
2085
|
-
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
2131
|
+
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
|
|
2086
2132
|
const slider = (menu && menu.querySelector('[role="slider"]')) || (menu ? null : document.querySelector('[role="slider"]'));
|
|
2087
2133
|
if (!slider) return { ok: false, reason: "power slider not found" };
|
|
2088
2134
|
slider.focus();
|
|
2089
2135
|
return { ok: document.activeElement === slider };
|
|
2090
2136
|
})()`;
|
|
2091
2137
|
}
|
|
2138
|
+
/**
|
|
2139
|
+
* The composer's model trigger - and nothing outside the composer.
|
|
2140
|
+
*
|
|
2141
|
+
* With no composer rendered yet the lookup used to fall back to the whole
|
|
2142
|
+
* document and take the first menu button with text in it. On the current
|
|
2143
|
+
* sidebar that is "Pinned" or "Explore": measured 2026-09-29, every send that
|
|
2144
|
+
* started on a fresh chat - a new chat, an attachment, a tool, a temporary chat
|
|
2145
|
+
* - clicked a sidebar menu, then failed with "model menu did not open". A
|
|
2146
|
+
* project send passed only because the project page had already rendered its
|
|
2147
|
+
* composer. Now the trigger is looked for inside the rendered composer's form
|
|
2148
|
+
* only (the exact "Select ChatGPT model" control first, the older heuristic
|
|
2149
|
+
* second), and an unrendered composer is reported as not found, which the
|
|
2150
|
+
* caller's poll waits out.
|
|
2151
|
+
*/
|
|
2092
2152
|
export function modelButtonRectExpression() {
|
|
2093
2153
|
return `(() => {${CLICK_POINT_SNIPPET}
|
|
2094
|
-
const
|
|
2095
|
-
|
|
2096
|
-
|
|
2154
|
+
const composer = [...document.querySelectorAll('#prompt-textarea,[contenteditable="true"],textarea')].find((el) => {
|
|
2155
|
+
const r = el.getBoundingClientRect();
|
|
2156
|
+
return r.width > 0 && r.height > 0;
|
|
2157
|
+
});
|
|
2158
|
+
if (!composer) return { ok: false, reason: "model selector button not found: the composer has not rendered" };
|
|
2159
|
+
const scope = composer.closest('form');
|
|
2160
|
+
if (!scope) return { ok: false, reason: "model selector button not found: the composer has no form around it" };
|
|
2161
|
+
const exact = [...scope.querySelectorAll('button[aria-label="Select ChatGPT model"][aria-haspopup="menu"],[data-codex-intelligence-trigger="true"][aria-haspopup="menu"]')];
|
|
2162
|
+
const b = exact.length === 1 ? exact[0] : exact.length > 1 ? null : [...scope.querySelectorAll('[aria-haspopup="menu"]')].find((el) => {
|
|
2097
2163
|
const t = (el.textContent || "").trim();
|
|
2098
2164
|
const aria = el.getAttribute("aria-label") || "";
|
|
2099
2165
|
return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach|file|voice|record|search|mic/i.test(t + aria);
|
|
2100
2166
|
});
|
|
2167
|
+
if (exact.length > 1) return { ok: false, reason: "model selector button not found: more than one model trigger in the composer" };
|
|
2101
2168
|
if (!b) return { ok: false, reason: "model selector button not found" };
|
|
2102
2169
|
// Return the label too: the caller compares it against the requested model
|
|
2103
2170
|
// to skip opening the menu when it is already selected.
|
|
@@ -2107,7 +2174,7 @@ export function modelButtonRectExpression() {
|
|
|
2107
2174
|
}
|
|
2108
2175
|
export function menuItemRectExpression(label) {
|
|
2109
2176
|
return `(() => {${CLICK_POINT_SNIPPET}
|
|
2110
|
-
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
2177
|
+
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
|
|
2111
2178
|
if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
|
|
2112
2179
|
const items = [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
|
|
2113
2180
|
const it = items.find(${menuLabelMatchPredicate(label)});
|
|
@@ -2135,7 +2202,7 @@ const PRO_RADIO_FINDER_SNIPPET = `
|
|
|
2135
2202
|
[...scope.querySelectorAll('[role="menuitemradio"]')].find((r) => /^Pro( |$)/.test(((r.innerText || r.textContent || "").trim().split(String.fromCharCode(10))[0] || "").trim()));`;
|
|
2136
2203
|
export function proRadioRectExpression() {
|
|
2137
2204
|
return `(() => {${CLICK_POINT_SNIPPET}${PRO_RADIO_FINDER_SNIPPET}
|
|
2138
|
-
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
2205
|
+
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
|
|
2139
2206
|
if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
|
|
2140
2207
|
const proRadio = findProRadio(m);
|
|
2141
2208
|
if (!proRadio) return { ok: false, reason: "Pro option not found in the model menu" };
|
|
@@ -2187,54 +2254,123 @@ export function matchProjectOptionName(ariaLabels, wanted) {
|
|
|
2187
2254
|
return "ambiguous";
|
|
2188
2255
|
return -1;
|
|
2189
2256
|
}
|
|
2257
|
+
/**
|
|
2258
|
+
* The sidebar's project rows, read the way the current ChatGPT renders them.
|
|
2259
|
+
*
|
|
2260
|
+
* Measured 2026-09-29: ChatGPT replaced the per-row "Open project options for
|
|
2261
|
+
* <name>" button, which every project lookup in prodex keyed on, with
|
|
2262
|
+
* "Project actions for <name>", and made the row itself a disclosure that
|
|
2263
|
+
* expands in place instead of navigating. The row now carries its name and id
|
|
2264
|
+
* as attributes (`data-app-action-sidebar-project-label`, `-id`), which is a
|
|
2265
|
+
* sturdier key than parsing a button label. Every --project send reported
|
|
2266
|
+
* "project not found in sidebar (0 projects visible)" with the sidebar open
|
|
2267
|
+
* and nine projects on screen.
|
|
2268
|
+
*
|
|
2269
|
+
* Falls back to the older option-button labels, so the previous UI still
|
|
2270
|
+
* resolves.
|
|
2271
|
+
*/
|
|
2272
|
+
const SIDEBAR_PROJECT_ROWS_SNIPPET = `
|
|
2273
|
+
const legacyProjectName = (b) => (b.getAttribute("aria-label") || "").replace(/^open project options for /i, "").replace(/\\s*프로젝트 옵션 열기$/, "").trim();
|
|
2274
|
+
const sidebarProjects = () => {
|
|
2275
|
+
const rows = [...document.querySelectorAll("[data-app-action-sidebar-project-row]")]
|
|
2276
|
+
.map((row) => ({
|
|
2277
|
+
name: (row.getAttribute("data-app-action-sidebar-project-label") || "").trim(),
|
|
2278
|
+
id: (row.getAttribute("data-app-action-sidebar-project-id") || "").trim(),
|
|
2279
|
+
row,
|
|
2280
|
+
current: true
|
|
2281
|
+
}))
|
|
2282
|
+
.filter((entry) => entry.name);
|
|
2283
|
+
if (rows.length > 0) return rows;
|
|
2284
|
+
return [...document.querySelectorAll('[aria-label*="프로젝트 옵션"],[aria-label*="project options" i]')]
|
|
2285
|
+
.map((button) => ({
|
|
2286
|
+
name: legacyProjectName(button),
|
|
2287
|
+
id: "",
|
|
2288
|
+
row: button.closest('a,[role="link"],li') || button.parentElement,
|
|
2289
|
+
current: false
|
|
2290
|
+
}))
|
|
2291
|
+
.filter((entry) => entry.name);
|
|
2292
|
+
};`;
|
|
2293
|
+
/**
|
|
2294
|
+
* Where to rest the pointer so a sidebar project row shows its controls.
|
|
2295
|
+
*
|
|
2296
|
+
* The row's "New chat in <name>" button is only revealed while the row is
|
|
2297
|
+
* hovered: measured, the button itself reports opacity 1 while its container
|
|
2298
|
+
* sits at opacity 0, and the point prodex clicked resolved to the section
|
|
2299
|
+
* behind it. A manual test passed only because the pointer was already on the
|
|
2300
|
+
* row; a real send, arriving with the pointer elsewhere, clicked the section
|
|
2301
|
+
* and reported "did not navigate the visible tab".
|
|
2302
|
+
*/
|
|
2303
|
+
export function sidebarProjectRowPointExpression(name) {
|
|
2304
|
+
return `(() => {${SIDEBAR_PROJECT_ROWS_SNIPPET}
|
|
2305
|
+
const wanted = ${JSON.stringify(name)};
|
|
2306
|
+
const projects = sidebarProjects().filter((p) => p.current);
|
|
2307
|
+
const exact = projects.filter((p) => p.name === wanted);
|
|
2308
|
+
const matches = exact.length > 0 ? exact : projects.filter((p) => p.name.toLowerCase() === wanted.toLowerCase());
|
|
2309
|
+
if (matches.length !== 1) return { ok: false };
|
|
2310
|
+
const r = matches[0].row.getBoundingClientRect();
|
|
2311
|
+
if (r.width < 1 || r.height < 1) return { ok: false };
|
|
2312
|
+
return { ok: true, x: Math.round(r.x + Math.min(r.width / 3, 60)), y: Math.round(r.y + r.height / 2) };
|
|
2313
|
+
})()`;
|
|
2314
|
+
}
|
|
2315
|
+
/** Hover a project row so its hover-only controls can be clicked. A no-op on the older sidebar. */
|
|
2316
|
+
async function revealSidebarProjectControls(cdp, name) {
|
|
2317
|
+
const point = await cdp.evaluate(sidebarProjectRowPointExpression(name));
|
|
2318
|
+
if (!point?.ok || point.x === undefined || point.y === undefined)
|
|
2319
|
+
return;
|
|
2320
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y });
|
|
2321
|
+
await sleep(400);
|
|
2322
|
+
}
|
|
2190
2323
|
export function projectItemRectExpression(name) {
|
|
2191
|
-
return `(() => {${CLICK_POINT_SNIPPET}
|
|
2192
|
-
// Korean: "<name> 프로젝트 옵션 열기"; English: "Open project options for <name>".
|
|
2324
|
+
return `(() => {${CLICK_POINT_SNIPPET}${SIDEBAR_PROJECT_ROWS_SNIPPET}
|
|
2193
2325
|
const wanted = ${JSON.stringify(name)};
|
|
2194
|
-
//
|
|
2195
|
-
//
|
|
2196
|
-
//
|
|
2197
|
-
|
|
2198
|
-
const
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
const exact = optionButtons.filter((b) => projName(b) === wanted);
|
|
2202
|
-
if (exact.length === 1) opt = exact[0];
|
|
2326
|
+
// Match by EQUALITY, exact first and then case-insensitive, and refuse on
|
|
2327
|
+
// ambiguity: a substring match once let "Codex" select "Codex Review" and
|
|
2328
|
+
// post into a project nobody named.
|
|
2329
|
+
const projects = sidebarProjects();
|
|
2330
|
+
const exact = projects.filter((p) => p.name === wanted);
|
|
2331
|
+
let hit = null;
|
|
2332
|
+
if (exact.length === 1) hit = exact[0];
|
|
2203
2333
|
else if (exact.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects; rename one to disambiguate" };
|
|
2204
2334
|
else {
|
|
2205
|
-
|
|
2206
|
-
|
|
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];
|
|
2335
|
+
const ci = projects.filter((p) => p.name.toLowerCase() === wanted.toLowerCase());
|
|
2336
|
+
if (ci.length === 1) hit = ci[0];
|
|
2210
2337
|
else if (ci.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects case-insensitively; use the exact name" };
|
|
2211
2338
|
}
|
|
2212
|
-
let
|
|
2213
|
-
if (!
|
|
2214
|
-
// The fallback for a sidebar whose option buttons
|
|
2215
|
-
//
|
|
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.
|
|
2339
|
+
let visible = projects.length;
|
|
2340
|
+
if (!hit && projects.length === 0) {
|
|
2341
|
+
// The legacy fallback for a sidebar whose option buttons cannot be read:
|
|
2342
|
+
// a row's first line is its name, compared by equality.
|
|
2220
2343
|
const icons = [...document.querySelectorAll('[data-testid="project-folder-icon"]')];
|
|
2221
2344
|
const rowName = (row) => ((row.innerText || row.textContent || "").split("\\n").map((line) => line.trim()).find((line) => line.length > 0) || "");
|
|
2222
2345
|
const rows = icons.map((ic) => ic.closest('a,li,[role="link"]') || ic.parentElement?.parentElement).filter(Boolean);
|
|
2346
|
+
visible = rows.length;
|
|
2223
2347
|
const named = rows.filter((row) => rowName(row).toLowerCase() === wanted.toLowerCase());
|
|
2224
2348
|
if (named.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects; rename one to disambiguate" };
|
|
2225
|
-
if (named.length === 1)
|
|
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)" };
|
|
2349
|
+
if (named.length === 1) hit = { name: wanted, id: "", row: named[0], current: false };
|
|
2229
2350
|
}
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2351
|
+
if (!hit) {
|
|
2352
|
+
if (visible === 0) {
|
|
2353
|
+
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" };
|
|
2354
|
+
}
|
|
2355
|
+
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)" };
|
|
2356
|
+
}
|
|
2357
|
+
if (hit.current) {
|
|
2358
|
+
// Clicking the row now only expands it. Its "New chat in <name>" button
|
|
2359
|
+
// is what opens the project, on a fresh chat inside it - measured: it
|
|
2360
|
+
// lands on /g/<id>/project with the composer labelled for that project.
|
|
2361
|
+
const label = "New chat in " + hit.name;
|
|
2362
|
+
const koLabel = hit.name + "에서 새 채팅";
|
|
2363
|
+
const buttons = [...document.querySelectorAll("button[aria-label]")];
|
|
2364
|
+
const enter =
|
|
2365
|
+
buttons.find((b) => hit.row.contains(b) && (b.getAttribute("aria-label") === label || b.getAttribute("aria-label") === koLabel)) ||
|
|
2366
|
+
buttons.find((b) => b.getAttribute("aria-label") === label || b.getAttribute("aria-label") === koLabel);
|
|
2367
|
+
if (!enter) return { ok: false, reason: "project row found but it has no new-chat control to open the project" };
|
|
2368
|
+
return clickPoint(enter);
|
|
2369
|
+
}
|
|
2370
|
+
// Older UI: a dedicated "Open project home" button inside the row, else the row.
|
|
2371
|
+
const home = hit.row.querySelector('[aria-label*="project home" i],[aria-label*="프로젝트 홈"]');
|
|
2236
2372
|
if (home) return clickPoint(home);
|
|
2237
|
-
return clickPoint(
|
|
2373
|
+
return clickPoint(hit.row, 18);
|
|
2238
2374
|
})()`;
|
|
2239
2375
|
}
|
|
2240
2376
|
// Clicking a menuitemradio commits the choice and closes the picker; a menu
|
|
@@ -2854,7 +2990,7 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
2854
2990
|
export function newProjectButtonRectExpression() {
|
|
2855
2991
|
return `(() => {${CLICK_POINT_SNIPPET}
|
|
2856
2992
|
const el =
|
|
2857
|
-
document.querySelector('button[aria-label="새 프로젝트"],button[aria-label="New project"]') ||
|
|
2993
|
+
document.querySelector('button[aria-label="새 프로젝트"],button[aria-label="New project"],button[aria-label="Add new project"]') ||
|
|
2858
2994
|
[...document.querySelectorAll('button,[role="button"]')].find((b) =>
|
|
2859
2995
|
/새 프로젝트|new project/i.test(((b.textContent || "") + (b.getAttribute("aria-label") || "")).trim())
|
|
2860
2996
|
);
|
|
@@ -2895,14 +3031,42 @@ async function dispatchEnterKey(cdp) {
|
|
|
2895
3031
|
// Creates a project in the user's real ChatGPT account: sidebar 새 프로젝트 →
|
|
2896
3032
|
// type the name into the popover input → Enter → wait for navigation into the
|
|
2897
3033
|
// new project. Backs out with Escape on any failure.
|
|
3034
|
+
/**
|
|
3035
|
+
* Open the create-project dialog on the current sidebar by keyboard.
|
|
3036
|
+
*
|
|
3037
|
+
* Measured 2026-09-29: the sidebar's "Add new project" button sits at the
|
|
3038
|
+
* sidebar's edge underneath the composer's overlay (z-20), so the point prodex
|
|
3039
|
+
* clicked resolved to the overlay and nothing opened - `--project-new` failed
|
|
3040
|
+
* with "new-project name input did not appear". The button carries a stable
|
|
3041
|
+
* data-app-action-sidebar-project-create attribute; focusing it and pressing
|
|
3042
|
+
* Enter opens the same "Create project" dialog without depending on what is
|
|
3043
|
+
* drawn over it. Returns false on a sidebar without that control, where the
|
|
3044
|
+
* older click path still applies.
|
|
3045
|
+
*/
|
|
3046
|
+
async function openNewProjectDialogByKeyboard(cdp) {
|
|
3047
|
+
const focused = await cdp.evaluate(`(() => {
|
|
3048
|
+
const button = document.querySelector('[data-app-action-sidebar-project-create]');
|
|
3049
|
+
if (!button || button.disabled) return false;
|
|
3050
|
+
button.focus();
|
|
3051
|
+
return document.activeElement === button;
|
|
3052
|
+
})()`);
|
|
3053
|
+
if (!focused)
|
|
3054
|
+
return false;
|
|
3055
|
+
// A button is activated by the key's text, not by the keydown alone:
|
|
3056
|
+
// without text the event reaches the page and nothing opens (measured).
|
|
3057
|
+
await dispatchEnterKey(cdp);
|
|
3058
|
+
return true;
|
|
3059
|
+
}
|
|
2898
3060
|
async function createChatGptProject(cdp, name) {
|
|
2899
3061
|
const hrefBefore = await cdp.evaluate("location.href");
|
|
2900
|
-
const button = await cdp.evaluate(newProjectButtonRectExpression());
|
|
2901
|
-
if (!button.ok || button.x === undefined || button.y === undefined) {
|
|
2902
|
-
throw new Error(button.reason ?? "new-project button not found in the sidebar");
|
|
2903
|
-
}
|
|
2904
3062
|
try {
|
|
2905
|
-
await
|
|
3063
|
+
if (!(await openNewProjectDialogByKeyboard(cdp))) {
|
|
3064
|
+
const button = await cdp.evaluate(newProjectButtonRectExpression());
|
|
3065
|
+
if (!button.ok || button.x === undefined || button.y === undefined) {
|
|
3066
|
+
throw new Error(button.reason ?? "new-project button not found in the sidebar");
|
|
3067
|
+
}
|
|
3068
|
+
await verifiedClickAt(cdp, button.x, button.y, "새 프로젝트");
|
|
3069
|
+
}
|
|
2906
3070
|
const inputVisible = await waitForExpressionTrue(cdp, newProjectNameInputVisibleExpression(), MENU_OPEN_TIMEOUT_MS);
|
|
2907
3071
|
if (!inputVisible)
|
|
2908
3072
|
throw new Error("new-project name input did not appear");
|
|
@@ -3031,6 +3195,7 @@ async function navigateToExistingProject(cdp, project) {
|
|
|
3031
3195
|
const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
|
|
3032
3196
|
throw new Error(`ChatGPT project not found in sidebar: ${project}${detail} List the visible names with \`prodex pro browser projects\`.`);
|
|
3033
3197
|
}
|
|
3198
|
+
await revealSidebarProjectControls(cdp, project);
|
|
3034
3199
|
await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(project)), `project ${project}`);
|
|
3035
3200
|
const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
3036
3201
|
if (!navigated) {
|
|
@@ -3147,6 +3312,7 @@ async function selectProject(cdp, options) {
|
|
|
3147
3312
|
// project home - and then the same sidebar navigation over again.
|
|
3148
3313
|
try {
|
|
3149
3314
|
await openFreshChatGptHome(cdp);
|
|
3315
|
+
await revealSidebarProjectControls(cdp, wanted);
|
|
3150
3316
|
await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(wanted)), `project ${wanted}`);
|
|
3151
3317
|
// The click's navigation has to land before the placeholder means
|
|
3152
3318
|
// anything; read on the page we came from, it answers about the wrong
|
|
@@ -3703,9 +3869,9 @@ export async function sendChatGptPrompt(options) {
|
|
|
3703
3869
|
// inserts a newline) fall back to clicking the send button, re-reading
|
|
3704
3870
|
// FRESH coordinates each attempt. Safe against double-submit: once the
|
|
3705
3871
|
// prompt posts the composer clears and no send button is found.
|
|
3706
|
-
const promptPostedExpression = `(() => {
|
|
3707
|
-
const last =
|
|
3708
|
-
return Boolean(last && (last.innerText || "").includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
|
|
3872
|
+
const promptPostedExpression = `(() => {${CHATGPT_MESSAGE_NODES_JS}
|
|
3873
|
+
const last = chatMessageNodes().filter((message) => message.role === "user").at(-1);
|
|
3874
|
+
return Boolean(last && (last.textNode.innerText || "").includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
|
|
3709
3875
|
})()`;
|
|
3710
3876
|
// A dispatch can reach Chrome even if its acknowledgement is lost.
|
|
3711
3877
|
submissionAttempted = true;
|
|
@@ -3812,6 +3978,8 @@ export async function sendChatGptPrompt(options) {
|
|
|
3812
3978
|
: undefined;
|
|
3813
3979
|
let consecutiveReadFailures = 0;
|
|
3814
3980
|
let answerSettled = false;
|
|
3981
|
+
let nonTextPolls = 0;
|
|
3982
|
+
let nonTextSettled = false;
|
|
3815
3983
|
const answerIsStable = createChatGptAnswerStabilityTracker();
|
|
3816
3984
|
while (Date.now() - started < timeoutMs) {
|
|
3817
3985
|
await sleep(1000);
|
|
@@ -3864,6 +4032,19 @@ export async function sendChatGptPrompt(options) {
|
|
|
3864
4032
|
if (runtimeBlocker)
|
|
3865
4033
|
throw new ChatGptBrowserBlockerError(submittedRequestBlocker(runtimeBlocker, requestId, pinnedThreadUrl));
|
|
3866
4034
|
emitProgress("waiting", finalState.generating ? "generating" : "stabilizing");
|
|
4035
|
+
// A turn that answered with an image leaves no assistant message, so the
|
|
4036
|
+
// count below never moves and the send used to wait out its whole budget
|
|
4037
|
+
// and report a timeout - measured again on 2026-09-29 after the page-only
|
|
4038
|
+
// reader replaced the transcript. Two quiet polls in a row settle it.
|
|
4039
|
+
if (finalState.nonTextAnswer && !finalState.generating && finalState.userMessageCount > beforeSubmit.userMessageCount) {
|
|
4040
|
+
nonTextPolls += 1;
|
|
4041
|
+
if (nonTextPolls >= 2) {
|
|
4042
|
+
nonTextSettled = true;
|
|
4043
|
+
break;
|
|
4044
|
+
}
|
|
4045
|
+
continue;
|
|
4046
|
+
}
|
|
4047
|
+
nonTextPolls = 0;
|
|
3867
4048
|
if (!hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, finalState))
|
|
3868
4049
|
continue;
|
|
3869
4050
|
// A "fresh" answer must also be stable: ChatGPT can momentarily look done
|
|
@@ -3876,6 +4057,19 @@ export async function sendChatGptPrompt(options) {
|
|
|
3876
4057
|
}
|
|
3877
4058
|
}
|
|
3878
4059
|
const completed = finalState;
|
|
4060
|
+
if (nonTextSettled && completed) {
|
|
4061
|
+
emitProgress("answered", "non-text");
|
|
4062
|
+
return {
|
|
4063
|
+
url: pinnedThreadUrl ?? completed.url,
|
|
4064
|
+
title: completed.title,
|
|
4065
|
+
answer: CHATGPT_NON_TEXT_ANSWER_NOTE,
|
|
4066
|
+
modelHints: completed.modelHints,
|
|
4067
|
+
...(boundProjectId ? { boundProjectId } : {}),
|
|
4068
|
+
requestId,
|
|
4069
|
+
requestVerified: true,
|
|
4070
|
+
warnings: withDialogNote([...sendWarnings]).filter((warning) => Boolean(warning))
|
|
4071
|
+
};
|
|
4072
|
+
}
|
|
3879
4073
|
if (answerSettled && completed && hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
|
|
3880
4074
|
emitProgress("answered");
|
|
3881
4075
|
return {
|
|
@@ -3933,7 +4127,7 @@ export function formatModelMenuOption(option) {
|
|
|
3933
4127
|
}
|
|
3934
4128
|
export function modelMenuOptionsExpression() {
|
|
3935
4129
|
return `(() => {
|
|
3936
|
-
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
4130
|
+
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]:has([role="slider"])');
|
|
3937
4131
|
if (!m) return [];
|
|
3938
4132
|
// Radios are models, and so are the submenu rows an earlier picker kept them
|
|
3939
4133
|
// behind. What is NOT a model is the power slider's own rows - its track,
|
|
@@ -3967,11 +4161,8 @@ export function modelMenuOptionsExpression() {
|
|
|
3967
4161
|
// Sidebar project names, extracted from the per-row options-button aria-labels
|
|
3968
4162
|
// (English "Open project options for <name>", Korean "<name> 프로젝트 옵션 열기").
|
|
3969
4163
|
export function sidebarProjectNamesExpression() {
|
|
3970
|
-
return `(() => {
|
|
3971
|
-
|
|
3972
|
-
.map((b) => (b.getAttribute("aria-label") || "").replace(/^open project options for /i, "").replace(/\\s*프로젝트 옵션 열기$/, "").trim())
|
|
3973
|
-
.filter(Boolean);
|
|
3974
|
-
return [...new Set(names)];
|
|
4164
|
+
return `(() => {${SIDEBAR_PROJECT_ROWS_SNIPPET}
|
|
4165
|
+
return [...new Set(sidebarProjects().map((p) => p.name))];
|
|
3975
4166
|
})()`;
|
|
3976
4167
|
}
|
|
3977
4168
|
// Read-only discovery for --project/setup --project: list the sidebar project
|
|
@@ -4782,9 +4973,10 @@ export function statusExpression() {
|
|
|
4782
4973
|
.filter((el) => !el.closest(runtimeExcludedTextSelector))
|
|
4783
4974
|
.map((el) => (el.innerText || el.getAttribute("aria-label") || el.getAttribute("data-testid") || "").trim())
|
|
4784
4975
|
.filter(Boolean);
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4976
|
+
${CHATGPT_MESSAGE_NODES_JS}
|
|
4977
|
+
const messages = chatMessageNodes().map((message) => ({
|
|
4978
|
+
role: message.role,
|
|
4979
|
+
text: message.textNode.innerText || ""
|
|
4788
4980
|
}));
|
|
4789
4981
|
const assistant = messages.filter((message) => message.role === "assistant").at(-1);
|
|
4790
4982
|
const answer = assistant?.text || "";
|
|
@@ -5035,9 +5227,27 @@ export function defaultTimeoutForTools(tools, fallbackMs) {
|
|
|
5035
5227
|
const wantsDeepResearch = tools.some((tool) => resolveComposerToolLabel(tool) === DEEP_RESEARCH_TOOL_LABEL);
|
|
5036
5228
|
return wantsDeepResearch ? Math.max(fallbackMs, DEEP_RESEARCH_MIN_TIMEOUT_MS) : fallbackMs;
|
|
5037
5229
|
}
|
|
5230
|
+
/**
|
|
5231
|
+
* The composer's "+" control that opens its tools.
|
|
5232
|
+
*
|
|
5233
|
+
* Measured 2026-09-29: ChatGPT dropped data-testid="composer-plus-btn"; the
|
|
5234
|
+
* control is now `Add files and more` with
|
|
5235
|
+
* data-composer-navigation-target="add-context", and every `--tool` send
|
|
5236
|
+
* failed with "composer tools button not found". Looked for inside the
|
|
5237
|
+
* rendered composer's form, so a similarly labelled control elsewhere cannot
|
|
5238
|
+
* answer for it; the old test id still wins where it exists.
|
|
5239
|
+
*/
|
|
5038
5240
|
export function composerToolsButtonRectExpression() {
|
|
5039
5241
|
return `(() => {${CLICK_POINT_SNIPPET}
|
|
5040
|
-
const
|
|
5242
|
+
const legacy = document.querySelector('[data-testid="composer-plus-btn"]');
|
|
5243
|
+
if (legacy) return clickPoint(legacy);
|
|
5244
|
+
const composer = [...document.querySelectorAll('#prompt-textarea,[contenteditable="true"],textarea')].find((el) => {
|
|
5245
|
+
const r = el.getBoundingClientRect();
|
|
5246
|
+
return r.width > 0 && r.height > 0;
|
|
5247
|
+
});
|
|
5248
|
+
const form = composer && composer.closest('form');
|
|
5249
|
+
if (!form) return { ok: false, reason: "composer tools button not found: the composer has not rendered" };
|
|
5250
|
+
const b = form.querySelector('button[data-composer-navigation-target="add-context"],button[aria-label="Add files and more"]');
|
|
5041
5251
|
if (!b) return { ok: false, reason: "composer tools button not found" };
|
|
5042
5252
|
return clickPoint(b);
|
|
5043
5253
|
})()`;
|
|
@@ -5662,16 +5872,17 @@ export function answerExpression() {
|
|
|
5662
5872
|
return parts.join(String.fromCharCode(10));
|
|
5663
5873
|
};
|
|
5664
5874
|
const lines = text.split(String.fromCharCode(10)).map((line) => line.trim()).filter(Boolean);
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
//
|
|
5668
|
-
//
|
|
5875
|
+
${CHATGPT_MESSAGE_NODES_JS}
|
|
5876
|
+
const messages = chatMessageNodes().map(({ role, node, textNode }) => {
|
|
5877
|
+
// ChatGPT tagged each message with the model that produced it, on the
|
|
5878
|
+
// message node or an ancestor depending on the build. The current markup
|
|
5879
|
+
// carries no such tag, so the model is unknown there, not guessed.
|
|
5669
5880
|
let modelSlug = node.getAttribute('data-message-model-slug') || undefined;
|
|
5670
5881
|
if (!modelSlug && typeof node.closest === "function") {
|
|
5671
5882
|
const tagged = node.closest('[data-message-model-slug]');
|
|
5672
5883
|
if (tagged) modelSlug = tagged.getAttribute('data-message-model-slug') || undefined;
|
|
5673
5884
|
}
|
|
5674
|
-
return { role
|
|
5885
|
+
return { role, node, text: textNode.innerText || "", modelSlug };
|
|
5675
5886
|
});
|
|
5676
5887
|
const assistantMessages = messages.filter((message) => message.role === "assistant");
|
|
5677
5888
|
const userMessages = messages.filter((message) => message.role === "user");
|
|
@@ -5708,6 +5919,22 @@ export function answerExpression() {
|
|
|
5708
5919
|
assistantMessageCount: assistantMessages.length,
|
|
5709
5920
|
userMessageCount: userMessages.length,
|
|
5710
5921
|
lastUserText: userMessages.at(-1)?.text || "",
|
|
5922
|
+
// An image result is rendered outside every message unit and leaves no
|
|
5923
|
+
// assistant message at all (measured 2026-09-29), so it is reported here
|
|
5924
|
+
// for the waiter to recognize: a generated image that comes after the
|
|
5925
|
+
// last user turn, with no assistant turn after that user turn.
|
|
5926
|
+
nonTextAnswer: (() => {
|
|
5927
|
+
const lastUser = userMessages.at(-1);
|
|
5928
|
+
if (!lastUser || !lastUser.node) return false;
|
|
5929
|
+
const lastRole = messages.at(-1) ? messages.at(-1).role : null;
|
|
5930
|
+
if (lastRole !== "user") return false;
|
|
5931
|
+
return [...document.querySelectorAll('main img')].some((img) => {
|
|
5932
|
+
const r = img.getBoundingClientRect();
|
|
5933
|
+
if (r.width < 60 || r.height < 60) return false;
|
|
5934
|
+
if (!/^generated image/i.test(img.getAttribute("alt") || "")) return false;
|
|
5935
|
+
return Boolean(lastUser.node.compareDocumentPosition(img) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
5936
|
+
});
|
|
5937
|
+
})(),
|
|
5711
5938
|
// ChatGPT tags each assistant message with the model that produced it -
|
|
5712
5939
|
// the only ground truth for "did the Pro selection actually take".
|
|
5713
5940
|
modelSlug: assistant ? assistant.modelSlug : undefined,
|
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,29 @@ 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.20 Release Record
|
|
20
|
+
|
|
21
|
+
Release commit `b8f8004` on `main` (cut after PR #9, merge `b134eae`); tag `v0.40.20`. `publish.yml` run 36522237267: the first attempt failed on macOS 15 Intel, where `tests/cli.test.ts` "uses an explicit --cwd target for doctor checks" exceeded its 30 s timeout on the slowest runner (full suite 9.5 min there; the same code had passed that runner in PR #9's CI, and no earlier failed CI run showed this test). Nothing was published by that attempt. Re-running the failed job passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release (2026-09-29 05:18 UTC).
|
|
22
|
+
|
|
23
|
+
What it restores: project consults, and every consult on a machine with a pinned default project, after the ChatGPT redesign measured on 2026-09-29 (sidebar project rows, the hover-only "New chat in" control, the model picker, message markup). Verified live on the shared browser before release: a named-project send and a pinned-default Codex + Pro send both answered inside the requested project.
|
|
24
|
+
|
|
25
|
+
Installation on 2026-09-29, global npm, each verified with `prodex --version` = 0.40.20:
|
|
26
|
+
|
|
27
|
+
- WSL, M3, Mac mini: installed. Running services were not restarted: 3 stdio MCP servers on WSL and 4 on M3 keep older code until their clients reconnect; the Mac mini had none.
|
|
28
|
+
- M4: not reachable (hostname does not resolve); not updated.
|
|
29
|
+
|
|
30
|
+
Browser container, same day. The Claude Code MCP entry on WSL goes through `prodex-browser-browser-1`, which builds from `feat/headless-browser-compatibility`, not from npm. That branch had already adapted to the same redesign on its own (not yet merged to `main`), so the image was rebuilt from its CI-passing tip `b5eac7d` as-is - not merged with `main`, because the two parallel implementations conflict in 17 places in `src/chatgpt-browser.ts` and a hand-made hybrid would be unverified. New image `sha256:7b3e53b2fb7f`; it reports 0.40.18, the version in that branch's `package.json`. Verified: no consult in flight before the swap; container healthy with the profile volume kept; MCP handshake through `scripts/container-client.mjs`; `pro browser login --wait` found the session READY; a project consult inside the container answered in the requested project with the destination verified. The two implementations still have to be reconciled when that branch is merged.
|
|
31
|
+
|
|
32
|
+
## 0.40.19 Release Record
|
|
33
|
+
|
|
34
|
+
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).
|
|
35
|
+
|
|
36
|
+
Installation on 2026-09-29, global npm, each verified with `prodex --version` = 0.40.19:
|
|
37
|
+
|
|
38
|
+
- 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.
|
|
39
|
+
- 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.
|
|
40
|
+
- M4: not reachable (hostname does not resolve); not updated.
|
|
41
|
+
|
|
19
42
|
## 0.40.2 - 0.40.7 Release Record
|
|
20
43
|
|
|
21
44
|
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`.
|