@youdie006/prodex 0.40.20 → 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.
@@ -222,7 +222,15 @@ const CHATGPT_GENERATING_CONTROL_PATTERN = /\bstop\s+(?:generating|responding|re
222
222
  // are more reliable than the button label text, which the pattern above can
223
223
  // miss on an icon-only/relabeled control - a miss would let a mid-stream pause
224
224
  // be accepted as the final answer, silently truncating it.
225
- export const CHATGPT_STREAMING_SELECTOR = '[data-testid="stop-button"],[data-message-author-role="assistant"][aria-busy="true"],[data-message-author-role="assistant"] [aria-busy="true"]';
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"]';
226
234
  export function defaultChatGptProfileDir() {
227
235
  return path.join(os.homedir(), ".local", "share", "prodex", "chrome-chatgpt-pro");
228
236
  }
@@ -2127,15 +2135,36 @@ export function focusPowerSliderExpression() {
2127
2135
  return { ok: document.activeElement === slider };
2128
2136
  })()`;
2129
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
+ */
2130
2152
  export function modelButtonRectExpression() {
2131
2153
  return `(() => {${CLICK_POINT_SNIPPET}
2132
- const c = document.querySelector('#prompt-textarea,[contenteditable="true"],textarea');
2133
- const scope = c ? (c.closest('form') || document) : document;
2134
- const b = [...scope.querySelectorAll('[aria-haspopup="menu"]')].find((el) => {
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) => {
2135
2163
  const t = (el.textContent || "").trim();
2136
2164
  const aria = el.getAttribute("aria-label") || "";
2137
2165
  return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach|file|voice|record|search|mic/i.test(t + aria);
2138
2166
  });
2167
+ if (exact.length > 1) return { ok: false, reason: "model selector button not found: more than one model trigger in the composer" };
2139
2168
  if (!b) return { ok: false, reason: "model selector button not found" };
2140
2169
  // Return the label too: the caller compares it against the requested model
2141
2170
  // to skip opening the menu when it is already selected.
@@ -3002,14 +3031,42 @@ async function dispatchEnterKey(cdp) {
3002
3031
  // Creates a project in the user's real ChatGPT account: sidebar 새 프로젝트 →
3003
3032
  // type the name into the popover input → Enter → wait for navigation into the
3004
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
+ }
3005
3060
  async function createChatGptProject(cdp, name) {
3006
3061
  const hrefBefore = await cdp.evaluate("location.href");
3007
- const button = await cdp.evaluate(newProjectButtonRectExpression());
3008
- if (!button.ok || button.x === undefined || button.y === undefined) {
3009
- throw new Error(button.reason ?? "new-project button not found in the sidebar");
3010
- }
3011
3062
  try {
3012
- await verifiedClickAt(cdp, button.x, button.y, "새 프로젝트");
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
+ }
3013
3070
  const inputVisible = await waitForExpressionTrue(cdp, newProjectNameInputVisibleExpression(), MENU_OPEN_TIMEOUT_MS);
3014
3071
  if (!inputVisible)
3015
3072
  throw new Error("new-project name input did not appear");
@@ -3921,6 +3978,8 @@ export async function sendChatGptPrompt(options) {
3921
3978
  : undefined;
3922
3979
  let consecutiveReadFailures = 0;
3923
3980
  let answerSettled = false;
3981
+ let nonTextPolls = 0;
3982
+ let nonTextSettled = false;
3924
3983
  const answerIsStable = createChatGptAnswerStabilityTracker();
3925
3984
  while (Date.now() - started < timeoutMs) {
3926
3985
  await sleep(1000);
@@ -3973,6 +4032,19 @@ export async function sendChatGptPrompt(options) {
3973
4032
  if (runtimeBlocker)
3974
4033
  throw new ChatGptBrowserBlockerError(submittedRequestBlocker(runtimeBlocker, requestId, pinnedThreadUrl));
3975
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;
3976
4048
  if (!hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, finalState))
3977
4049
  continue;
3978
4050
  // A "fresh" answer must also be stable: ChatGPT can momentarily look done
@@ -3985,6 +4057,19 @@ export async function sendChatGptPrompt(options) {
3985
4057
  }
3986
4058
  }
3987
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
+ }
3988
4073
  if (answerSettled && completed && hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
3989
4074
  emitProgress("answered");
3990
4075
  return {
@@ -5142,9 +5227,27 @@ export function defaultTimeoutForTools(tools, fallbackMs) {
5142
5227
  const wantsDeepResearch = tools.some((tool) => resolveComposerToolLabel(tool) === DEEP_RESEARCH_TOOL_LABEL);
5143
5228
  return wantsDeepResearch ? Math.max(fallbackMs, DEEP_RESEARCH_MIN_TIMEOUT_MS) : fallbackMs;
5144
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
+ */
5145
5240
  export function composerToolsButtonRectExpression() {
5146
5241
  return `(() => {${CLICK_POINT_SNIPPET}
5147
- const b = document.querySelector('[data-testid="composer-plus-btn"]');
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"]');
5148
5251
  if (!b) return { ok: false, reason: "composer tools button not found" };
5149
5252
  return clickPoint(b);
5150
5253
  })()`;
@@ -5779,7 +5882,7 @@ export function answerExpression() {
5779
5882
  const tagged = node.closest('[data-message-model-slug]');
5780
5883
  if (tagged) modelSlug = tagged.getAttribute('data-message-model-slug') || undefined;
5781
5884
  }
5782
- return { role, text: textNode.innerText || "", modelSlug };
5885
+ return { role, node, text: textNode.innerText || "", modelSlug };
5783
5886
  });
5784
5887
  const assistantMessages = messages.filter((message) => message.role === "assistant");
5785
5888
  const userMessages = messages.filter((message) => message.role === "user");
@@ -5816,6 +5919,22 @@ export function answerExpression() {
5816
5919
  assistantMessageCount: assistantMessages.length,
5817
5920
  userMessageCount: userMessages.length,
5818
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
+ })(),
5819
5938
  // ChatGPT tags each assistant message with the model that produced it -
5820
5939
  // the only ground truth for "did the Pro selection actually take".
5821
5940
  modelSlug: assistant ? assistant.modelSlug : undefined,
package/docs/releasing.md CHANGED
@@ -16,6 +16,19 @@ 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
+
19
32
  ## 0.40.19 Release Record
20
33
 
21
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.20",
3
+ "version": "0.40.21",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",