@youdie006/prodex 0.40.18 → 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.
- package/README.md +3 -1
- package/dist/browser-diagnostics.js +1 -1
- package/dist/browser-handoff.js +2 -2
- package/dist/chatgpt-browser.js +174 -66
- package/dist/cli-pro.js +89 -4
- package/dist/cli-shared.js +15 -2
- package/dist/tui.js +11 -0
- package/docs/releasing.md +32 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,7 +77,9 @@ Finish any native browser or OS confirmation before requesting the handoff: Chro
|
|
|
77
77
|
|
|
78
78
|
A Chrome account-connection chooser is separate from ChatGPT sign-in. If prodex confirms that chooser is displayed, choose for yourself whether to connect Chrome to the account; "Use Chrome without an account" declines that separate connection. A hidden or unreadable internal target still blocks the handoff but is not proof that a prompt is visible or that ChatGPT is logged out.
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
The login guide explains this separate Chrome choice before the authentication steps. Keep the ChatGPT tab open after making it. If a verified signed-in headed session reaches another authentication or protection blocker immediately after the headless handoff, prodex stops without prescribing another login cycle. Repeating login is not a demonstrated fix for that headless failure; `login: READY` before the handoff is not `background: READY` after it.
|
|
81
|
+
|
|
82
|
+
"Login readiness is not yet confirmed" does not mean the saved login was lost. Inspect the dedicated browser before signing in again. An explicit `login_required`, `cloudflare_check`, `captcha_required`, or `permission_required` during a headless/virtual-display wait stops promptly. Outside the just-verified handoff case above, `prodex pro browser login --headed --recover-visible` is available for explicit visible inspection of a running headless browser's same profile and page. This recovery refuses other tabs, active work, filled inputs, and uncertain browser identity; it never clicks a security check or sends a prompt. A usage limit or missing composer has its own next step instead of a blanket login instruction.
|
|
81
83
|
|
|
82
84
|
While Pro thinks, progress goes to stderr: connecting, prompt sent, elapsed time while generating. A Pro selection raises the send budget to twenty minutes on its own; `--timeout-ms` overrides it. Answers are read from the rendered page, so formatting can differ from the original message. If the dedicated browser is not running, an interactive `ask` starts it, waits for your saved session, and retries once (`--no-auto-login` turns that off; scripts opt in with `--auto-login`).
|
|
83
85
|
|
|
@@ -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;
|
|
@@ -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
|
-
//
|
|
2195
|
-
//
|
|
2196
|
-
//
|
|
2197
|
-
|
|
2198
|
-
const
|
|
2199
|
-
|
|
2200
|
-
|
|
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
|
-
|
|
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];
|
|
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
|
|
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.
|
|
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)
|
|
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
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
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
|
-
|
|
5666
|
-
|
|
5667
|
-
//
|
|
5668
|
-
//
|
|
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
|
|
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
|
@@ -392,9 +392,10 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
392
392
|
port: opened.port,
|
|
393
393
|
headless,
|
|
394
394
|
minimized,
|
|
395
|
+
// A blocked background handoff must not make a temporary auth window permanent.
|
|
395
396
|
...(alreadyRunning && savedLaunchForPort?.resume_headless === true &&
|
|
396
|
-
!Object.values(windowModeOptions.flags).some((value) => typeof value === "boolean") &&
|
|
397
|
-
|
|
397
|
+
(background || (!Object.values(windowModeOptions.flags).some((value) => typeof value === "boolean") &&
|
|
398
|
+
!["PRODEX_HEADLESS", "PRODEX_VIRTUAL_DISPLAY", "PRODEX_MINIMIZE_WINDOW"].some((key) => (process.env[key] ?? "").trim() !== "")))
|
|
398
399
|
? { resume_headless: true } : {}),
|
|
399
400
|
...(virtualDisplay
|
|
400
401
|
? { virtual_display: virtualDisplay.displayNumber }
|
|
@@ -1544,7 +1545,7 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1544
1545
|
}
|
|
1545
1546
|
}
|
|
1546
1547
|
catch (error) {
|
|
1547
|
-
const blocker = sourceAwareBrowserBlocker(browserSendBlockerFromError(error), sourceCli, browserCommandOptions);
|
|
1548
|
+
const blocker = await withManualStepForWindowMode(sourceAwareBrowserBlocker(browserSendBlockerFromError(error), sourceCli, browserCommandOptions), resolveCdpPort(browserPort), sourceCli, browserCommandOptions);
|
|
1548
1549
|
const message = blocker.next_step ? `${blocker.message} Next: ${blocker.next_step}` : errorMessage(error);
|
|
1549
1550
|
// The blocker text can quote the requested/default project name (e.g. a
|
|
1550
1551
|
// "project not found" error). Local stdout/stderr keep it (useful to the
|
|
@@ -2319,6 +2320,7 @@ async function completeBackgroundBrowserLogin(io, options) {
|
|
|
2319
2320
|
port: opened.port,
|
|
2320
2321
|
timeoutMs: options.timeoutMs,
|
|
2321
2322
|
windowMode: { headless: true, virtualDisplay: false, minimized: false },
|
|
2323
|
+
verifiedHeadedHandoff: true,
|
|
2322
2324
|
headedLoginCommand: options.headedLoginCommand,
|
|
2323
2325
|
headlessRecoveryCommand: options.headlessRecoveryCommand
|
|
2324
2326
|
});
|
|
@@ -2387,6 +2389,12 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
|
|
|
2387
2389
|
const blocker = status.blocker;
|
|
2388
2390
|
const blockerNextStep = blocker?.next_step ? ` Next: ${blocker.next_step}` : "";
|
|
2389
2391
|
if (!hasInteractiveWindow && blocker && needsVisibleAuthRecovery(blocker.code)) {
|
|
2392
|
+
if (options.verifiedHeadedHandoff) {
|
|
2393
|
+
stderr(`login: blocked - ${blocker.message}`);
|
|
2394
|
+
stderr(`login: NOT READY - ${blocker.code}. Sign-in was verified before the headless handoff, but the same profile is not ready headlessly.`);
|
|
2395
|
+
stderr("login: Another login is not a demonstrated fix. Stop automatic recovery and inspect this headless failure before another login or mode change. No further window will be opened automatically.");
|
|
2396
|
+
return false;
|
|
2397
|
+
}
|
|
2390
2398
|
const visibleStep = options.windowMode?.headless === true && options.headlessRecoveryCommand
|
|
2391
2399
|
? `run \`${options.headlessRecoveryCommand}\` to handle it visibly.`
|
|
2392
2400
|
: `${manualInspection} Handle the reported step visibly.`;
|
|
@@ -2716,6 +2724,21 @@ export function browserSendBlockerFromError(error) {
|
|
|
2716
2724
|
"same thing in the prompt."
|
|
2717
2725
|
};
|
|
2718
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
|
+
}
|
|
2719
2742
|
if (/ChatGPT project not found in sidebar/.test(message)) {
|
|
2720
2743
|
return {
|
|
2721
2744
|
code: "project_not_found",
|
|
@@ -3040,6 +3063,60 @@ export async function listConsultListEntries(store, options = { readOnly: true }
|
|
|
3040
3063
|
}
|
|
3041
3064
|
return entries;
|
|
3042
3065
|
}
|
|
3066
|
+
/** Blockers whose only remedy is a person acting inside the browser window. */
|
|
3067
|
+
const MANUAL_BROWSER_STEP_CODES = new Set(["login_required", "captcha_required", "cloudflare_check"]);
|
|
3068
|
+
/**
|
|
3069
|
+
* Rewrite a "do it in the visible browser" next step for a browser that has no
|
|
3070
|
+
* window to do it in.
|
|
3071
|
+
*
|
|
3072
|
+
* Measured with a signed-out profile on a virtual display: `pro browser check`
|
|
3073
|
+
* and every send reported `login_required` with "Log in manually in the
|
|
3074
|
+
* visible browser." - on a browser prodex had itself started with no visible
|
|
3075
|
+
* window. The login command already knew the way out (close that browser, log
|
|
3076
|
+
* in through a headed one), but the blocker a send or a check prints did not.
|
|
3077
|
+
* A session that expires under a no-window setup is exactly this case, and the
|
|
3078
|
+
* advice it got could not be followed.
|
|
3079
|
+
*
|
|
3080
|
+
* Only the manual-step codes are rewritten, and only when the saved launch
|
|
3081
|
+
* belongs to the port in use, so a stale record for another browser cannot
|
|
3082
|
+
* change the advice for this one.
|
|
3083
|
+
*/
|
|
3084
|
+
export function manualBrowserStepForWindowMode(blocker, launch, port, commands) {
|
|
3085
|
+
if (!MANUAL_BROWSER_STEP_CODES.has(blocker.code))
|
|
3086
|
+
return blocker;
|
|
3087
|
+
if (!launch || launch.port !== port)
|
|
3088
|
+
return blocker;
|
|
3089
|
+
if (launch.headless === true) {
|
|
3090
|
+
return {
|
|
3091
|
+
...blocker,
|
|
3092
|
+
next_step: `The dedicated browser runs headless, with no window to do this in. Run \`${commands.visibleRecovery}\` ` +
|
|
3093
|
+
"to switch it to a visible window through the guarded path, complete the step there, then retry."
|
|
3094
|
+
};
|
|
3095
|
+
}
|
|
3096
|
+
if (launch.virtual_display !== undefined) {
|
|
3097
|
+
return {
|
|
3098
|
+
...blocker,
|
|
3099
|
+
next_step: "The dedicated browser runs on a virtual display, with no window to do this in. Close it yourself when no " +
|
|
3100
|
+
`consult is running, then run \`${commands.headedLogin}\` to complete the step in a visible window, and retry.`
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
3103
|
+
return blocker;
|
|
3104
|
+
}
|
|
3105
|
+
/** The same rewrite, with the saved launch record and the commands resolved for this call. */
|
|
3106
|
+
async function withManualStepForWindowMode(blocker, port, sourceCli, commandOptions) {
|
|
3107
|
+
if (!MANUAL_BROWSER_STEP_CODES.has(blocker.code))
|
|
3108
|
+
return blocker;
|
|
3109
|
+
const launch = await readLastBrowserLoginLaunch().catch(() => undefined);
|
|
3110
|
+
const options = {
|
|
3111
|
+
...commandOptions,
|
|
3112
|
+
port,
|
|
3113
|
+
...(launch?.profile_dir ? { profileDir: launch.profile_dir } : {})
|
|
3114
|
+
};
|
|
3115
|
+
return manualBrowserStepForWindowMode(blocker, launch, port, {
|
|
3116
|
+
headedLogin: formatHeadedBrowserLoginCommand(sourceCli, options),
|
|
3117
|
+
visibleRecovery: formatVisibleAuthRecoveryCommand(sourceCli, options)
|
|
3118
|
+
});
|
|
3119
|
+
}
|
|
3043
3120
|
function formatHeadedBrowserLoginCommand(sourceCli, options = {}) {
|
|
3044
3121
|
return `${formatBrowserLoginCommand(sourceCli, options)} --headed`;
|
|
3045
3122
|
}
|
|
@@ -3100,6 +3177,9 @@ export function printBrowserLoginGuide(stdout, input) {
|
|
|
3100
3177
|
return;
|
|
3101
3178
|
}
|
|
3102
3179
|
stdout("");
|
|
3180
|
+
stdout("Chrome account connection is separate from ChatGPT sign-in. If Chrome asks, choose whether to connect the browser account yourself.");
|
|
3181
|
+
stdout("'Use Chrome without an account' declines that browser connection, not your ChatGPT login. Keep the ChatGPT tab open after making that choice.");
|
|
3182
|
+
stdout("");
|
|
3103
3183
|
stdout("Steps:");
|
|
3104
3184
|
if (windowAvailable) {
|
|
3105
3185
|
stdout(input.reusedProfile || input.reused === true
|
|
@@ -3261,7 +3341,12 @@ export async function printProductCheck(store, io, args, configCwd = io.cwd) {
|
|
|
3261
3341
|
else if (browserStatus.blocker) {
|
|
3262
3342
|
const visibilityText = browserStatus.blocker.code === "tab_not_visible" ? ` visibility=${browserStatus.visibilityState ?? "unknown"}` : "";
|
|
3263
3343
|
io.stdout(`chatgpt: blocked ${browserStatus.blocker.code}${visibilityText} - ${browserStatus.blocker.message}`);
|
|
3264
|
-
const
|
|
3344
|
+
const modeAware = await withManualStepForWindowMode(browserStatus.blocker, checkPort, sourceCli, browserCommandOptions);
|
|
3345
|
+
// A rewritten step already carries fully formed commands; running it back
|
|
3346
|
+
// through the product-check rewrite would only re-derive them.
|
|
3347
|
+
const nextStep = modeAware === browserStatus.blocker
|
|
3348
|
+
? productCheckBrowserNextStep(browserStatus.blocker.next_step, sourceCli, browserCommandOptions)
|
|
3349
|
+
: modeAware.next_step;
|
|
3265
3350
|
if (nextStep)
|
|
3266
3351
|
io.stdout(`next: ${nextStep}`);
|
|
3267
3352
|
}
|
package/dist/cli-shared.js
CHANGED
|
@@ -88,10 +88,10 @@ export function sourceAwareBrowserNextStep(nextStep, sourceCli, options = {}) {
|
|
|
88
88
|
return `\`cd ${cwdPrefix} && ${formatBrowserAskCommandBody(sourceCli)}${storedArgs ?? ""}\``;
|
|
89
89
|
})
|
|
90
90
|
.replace(/`prodex pro browser login([^`]*)?`/g, (_match, storedArgs) => {
|
|
91
|
-
return `\`${formatBrowserLoginCommand(sourceCli, browserOptionsWithStoredPort(options, storedArgs))}\``;
|
|
91
|
+
return `\`${formatBrowserLoginCommand(sourceCli, browserOptionsWithStoredPort(options, storedArgs))}${flagsBesidesPort(storedArgs)}\``;
|
|
92
92
|
})
|
|
93
93
|
.replace(/`prodex pro browser smoke([^`]*)?`/g, (_match, storedArgs) => {
|
|
94
|
-
return `\`${formatBrowserSmokeCommand(sourceCli, browserOptionsWithStoredPort(options, storedArgs))}\``;
|
|
94
|
+
return `\`${formatBrowserSmokeCommand(sourceCli, browserOptionsWithStoredPort(options, storedArgs))}${flagsBesidesPort(storedArgs)}\``;
|
|
95
95
|
})
|
|
96
96
|
.replace(/`prodex pro browser ask([^`]*)?`/g, (_match, storedArgs) => {
|
|
97
97
|
return `\`${formatBrowserAskCommandBody(sourceCli)}${storedArgs ?? ""}\``;
|
|
@@ -131,6 +131,19 @@ export function isUntrustedResultError(error) {
|
|
|
131
131
|
export function formatCommandInCwd(command, cwd) {
|
|
132
132
|
return cwd ? `cd ${shellQuote(cwd)} && ${command}` : command;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* The flags of a quoted command other than --port, which the rewrite rebuilds
|
|
136
|
+
* itself. They used to be dropped: a next step saying "run `prodex pro browser
|
|
137
|
+
* login --background`" came out as a plain `login`, and "--headed" vanished from
|
|
138
|
+
* the step that exists to get a window - so following the advice relaunched the
|
|
139
|
+
* browser in the very mode it was telling the caller to leave.
|
|
140
|
+
*/
|
|
141
|
+
export function flagsBesidesPort(storedArgs) {
|
|
142
|
+
if (!storedArgs)
|
|
143
|
+
return "";
|
|
144
|
+
const rest = storedArgs.replace(/(?:^|\s)--port\s+\d{1,5}(?=\s|$)/, " ").replace(/\s+/g, " ").trim();
|
|
145
|
+
return rest ? ` ${rest}` : "";
|
|
146
|
+
}
|
|
134
147
|
export function browserOptionsWithStoredPort(options, storedArgs) {
|
|
135
148
|
if (options.port || !storedArgs)
|
|
136
149
|
return options;
|
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,38 @@ 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
|
+
|
|
29
|
+
## 0.40.2 - 0.40.7 Release Record
|
|
30
|
+
|
|
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`.
|
|
32
|
+
|
|
33
|
+
**Channel.** Built with `prodex release pack` (normalized tarball) and published by hand with `npm publish <tarball>` from WSL, because a source-tree publish is refused on the WSL mount's file modes. That route bypasses `publish.yml`, so there is no provenance attestation (0.40.1 and 0.40.8 have one; 0.40.2 - 0.40.6 do not), no tag and no Release. Tags were not backfilled: pushing a `v*` tag runs `publish.yml`, which would re-verify old code and then fail at `npm publish` because the version already exists.
|
|
34
|
+
|
|
35
|
+
| Version | Commit | npm publish (UTC) | Verification before publish | Live check |
|
|
36
|
+
|---|---|---|---|---|
|
|
37
|
+
| 0.40.2 | `003d4d7` | 2026-09-09 17:39 | vitest 1126 pass; `release:verify` ok | Defective: installed from the registry on 2026-09-10 and sent into a real project - refused `project_not_bound`. Every project send fails in this version (see CHANGELOG). |
|
|
38
|
+
| 0.40.3 | `6f36b47` | 2026-09-10 07:27 | vitest 1184 pass; `release:verify` ok | Registry package sent into an existing project, across projects, and with `--project-new`; `destination.verified: true` on the receipt. |
|
|
39
|
+
| 0.40.4 | `2a84bc5` | 2026-09-11 00:21 | vitest 1198 pass; `release:verify` ok | `--continue` with the tab deliberately moved elsewhere, over CLI and over MCP `pro_consult` (`continue_thread`). |
|
|
40
|
+
| 0.40.5 | `a9c629d` | 2026-09-14 00:43 | vitest 1248 pass; `release:verify` ok; tarball `publish --dry-run` ok | create-image and web-search sends; back-to-back sends queued on the lock 3/3. |
|
|
41
|
+
| 0.40.6 | `4550cad` | 2026-09-14 01:52 | vitest 1259 pass; `release:verify` ok; tarball `publish --dry-run` ok | Dedicated browser killed twice; `--auto-login` relaunched it in about 3 s and the receipt carried `browser_recovered`. |
|
|
42
|
+
| 0.40.7 | `678a02e` (tag only) | not published | `publish.yml` run 34816390439 failed at Release verification; logs no longer retrievable | Superseded by 0.40.8. |
|
|
43
|
+
|
|
44
|
+
**Installation.** Global npm installs, verified with `prodex --version` on each host after install:
|
|
45
|
+
|
|
46
|
+
- 0.40.3, 0.40.4, 0.40.5, 0.40.6: WSL, M3 and the Mac mini, each upgraded on its publish day (the Mac mini came from 0.37.0 at 0.40.3). M4 was unreachable over SSH every time and was never updated.
|
|
47
|
+
- Running services are separate from the install. After 0.40.3 and 0.40.4 the long-lived stdio MCP servers (13 on WSL, 8 on M3) kept the old code. A freshly spawned server reported `serverInfo.version` 0.40.4 and completed a `continue_thread` consult. On 2026-09-14, before 0.40.5, every prodex MCP server was stopped with SIGTERM (20 on WSL, 7 on M3) with no consult in flight; the servers the clients respawned came up on 0.40.4, the install at that moment, and needed another reconnect after the 0.40.5 and 0.40.6 installs.
|
|
48
|
+
- State on 2026-09-29: WSL 0.40.18, M3 0.40.18, Mac mini still 0.40.6 (not upgraded since), M4 unreachable (its hostname no longer resolves).
|
|
49
|
+
- 2026-09-29: Mac mini upgraded 0.40.6 -> 0.40.18 with `npm i -g @youdie006/prodex@0.40.18`; `prodex --version` reports 0.40.18. No prodex MCP server was running there, so nothing needed a restart; the next one a client starts runs 0.40.18.
|
|
50
|
+
|
|
19
51
|
## Publishing
|
|
20
52
|
|
|
21
53
|
Publishing to npm runs entirely in CI with **no long-lived token** — auth is npm [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC), so nothing needs to store or paste an `NPM_TOKEN`, and every release carries a verifiable `--provenance` attestation.
|