@youdie006/prodex 0.40.25 → 0.40.26

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.
@@ -192,12 +192,18 @@ export const CHATGPT_BLOCKER_SCAN_EXCLUDED_ANCESTORS = `${'[data-message-author-
192
192
  * attribute went with it; where it is absent the model is simply not known.
193
193
  */
194
194
  const CHATGPT_MESSAGE_NODES_JS = `
195
+ // ChatGPT keeps a previously opened conversation mounted but hidden
196
+ // (measured 2026-10-06: two turns without a box on a project home), and
197
+ // counting them made a fresh chat look like an existing conversation.
198
+ // A display:contents node has no box of its own, so its children decide.
199
+ const isRenderedMessageNode = (node) =>
200
+ node.getClientRects().length > 0 || [...(node.children || [])].some((child) => child.getClientRects().length > 0);
195
201
  const chatMessageNodes = () => {
196
- const legacy = [...document.querySelectorAll('[data-message-author-role]')];
202
+ const legacy = [...document.querySelectorAll('[data-message-author-role]')].filter(isRenderedMessageNode);
197
203
  if (legacy.length > 0) {
198
204
  return legacy.map((node) => ({ role: node.getAttribute('data-message-author-role'), node, textNode: node }));
199
205
  }
200
- const units = [...document.querySelectorAll('[data-chatgpt-search-unit-key]')];
206
+ const units = [...document.querySelectorAll('[data-chatgpt-search-unit-key]')].filter(isRenderedMessageNode);
201
207
  return units
202
208
  .filter((unit) => !units.some((other) => other !== unit && unit.contains(other)))
203
209
  .map((unit) => {
@@ -5109,6 +5115,29 @@ export function insertComposerTextInPageExpression(text) {
5109
5115
  export function composerFileInputSelector() {
5110
5116
  return 'input[type="file"]:not([accept*="image"])';
5111
5117
  }
5118
+ const COMPOSER_FILE_INPUT_TIMEOUT_MS = 15_000;
5119
+ const COMPOSER_TOOLS_MENU_ATTEMPTS = 3;
5120
+ export const PRODEX_ATTACH_INPUT_ATTRIBUTE = "data-prodex-attach-input";
5121
+ /**
5122
+ * Tags the general file input of the form that holds the VISIBLE composer, so
5123
+ * DOM.querySelector can resolve exactly that node. A project home also keeps
5124
+ * a hidden copy of an earlier page mounted with its own complete composer
5125
+ * form (measured 2026-10-06: two editor forms, the hidden one first), and a
5126
+ * structural selector cannot tell the two apart. Returns false when no
5127
+ * visible composer form carries a general file input.
5128
+ */
5129
+ export function markComposerFileInputExpression() {
5130
+ const attribute = JSON.stringify(PRODEX_ATTACH_INPUT_ATTRIBUTE);
5131
+ return `(() => {${composerExpressionHelpers()}
5132
+ document.querySelectorAll('[' + ${attribute} + ']').forEach((node) => node.removeAttribute(${attribute}));
5133
+ const editor = findChatGptComposerCandidate();
5134
+ const form = editor && editor.closest('form');
5135
+ const input = form && form.querySelector(${JSON.stringify(composerFileInputSelector())});
5136
+ if (!input) return false;
5137
+ input.setAttribute(${attribute}, "1");
5138
+ return true;
5139
+ })()`;
5140
+ }
5112
5141
  /**
5113
5142
  * Lookup order for the attachment input. A conversation page also renders a
5114
5143
  * second form without the prompt editor, earlier in the document and with its
@@ -5140,7 +5169,8 @@ export function attachmentStateExpression(fileNames) {
5140
5169
  .join(String.fromCharCode(10));
5141
5170
  const haystack = text + String.fromCharCode(10) + labels;
5142
5171
  // Uploading a name that was used before makes ChatGPT show it as
5143
- // "stem(3).ext", so that spelling counts as the same file.
5172
+ // "stem(3).ext", and since 2026-10 as "stem(20261006-012426).ext", so
5173
+ // either spelling counts as the same file.
5144
5174
  const shownAsRenamed = (name) => {
5145
5175
  const dot = name.lastIndexOf(".");
5146
5176
  const stem = dot > 0 ? name.slice(0, dot) : name;
@@ -5149,7 +5179,7 @@ export function attachmentStateExpression(fileNames) {
5149
5179
  while ((from = haystack.indexOf(stem + "(", from)) !== -1) {
5150
5180
  let i = from + stem.length + 1;
5151
5181
  const digitsStart = i;
5152
- while (i < haystack.length && haystack[i] >= "0" && haystack[i] <= "9") i += 1;
5182
+ while (i < haystack.length && ((haystack[i] >= "0" && haystack[i] <= "9") || (i > digitsStart && haystack[i] === "-"))) i += 1;
5153
5183
  if (i > digitsStart && haystack.startsWith(")" + ext, i)) return true;
5154
5184
  from += 1;
5155
5185
  }
@@ -5167,9 +5197,14 @@ export function attachmentStateExpression(fileNames) {
5167
5197
  /** How many attachments are already sitting in the composer (read-only). */
5168
5198
  export function attachmentPresenceExpression() {
5169
5199
  return `(() => {
5170
- const buttons = [...document.querySelectorAll('button[aria-label]')].filter((b) =>
5171
- /remove file|파일 제거|첨부 제거/i.test(b.getAttribute("aria-label") || "")
5172
- );
5200
+ // The chip's remove button was "Remove file 1: name" and became
5201
+ // "Remove name" (measured 2026-10-06); a tool pill's "Remove Search" is
5202
+ // not a file. Chips on a hidden, still-mounted page have no box.
5203
+ const buttons = [...document.querySelectorAll('button[aria-label]')].filter((b) => {
5204
+ const label = b.getAttribute("aria-label") || "";
5205
+ if (!(/remove file|파일 제거|첨부 제거/i.test(label) || /^remove\\s+\\S.*\\.[A-Za-z0-9]{1,8}$/i.test(label))) return false;
5206
+ return b.getClientRects().length > 0;
5207
+ });
5173
5208
  return { ok: true, removed: buttons.length };
5174
5209
  })()`;
5175
5210
  }
@@ -5197,24 +5232,43 @@ export async function attachFilesToComposer(cdp, absolutePaths, options = {}) {
5197
5232
  // every later attach in that tab silently does nothing. A reload is the only
5198
5233
  // reliable reset, and it costs a few seconds only when there is something to
5199
5234
  // clear.
5200
- const stale = await cdp.evaluate(attachmentPresenceExpression());
5201
- if ((stale?.removed ?? 0) > 0) {
5202
- await cdp.evaluate("location.reload()");
5203
- await sleep(6_000);
5204
- const settleDeadline = Date.now() + 15_000;
5235
+ // "complete" is not "usable": the server-rendered editor is there before
5236
+ // the app attaches its file inputs (measured: complete at 3.3 s, inputs at
5237
+ // 5.1 s after a new-chat navigation), and every navigation path reaches
5238
+ // this point in that window sometimes. Wait for the input itself, before
5239
+ // the chip check (chips render with it) and before reading the DOM tree.
5240
+ const waitForComposerFileInput = async () => {
5241
+ const inputDeadline = Date.now() + COMPOSER_FILE_INPUT_TIMEOUT_MS;
5205
5242
  for (;;) {
5206
- const ready = await cdp.evaluate(composerTextStateExpression());
5207
- if (ready?.ok || Date.now() >= settleDeadline)
5208
- break;
5243
+ try {
5244
+ if (await cdp.evaluate(markComposerFileInputExpression()) === true)
5245
+ return true;
5246
+ }
5247
+ catch (error) {
5248
+ if (cdpCommandTimedOut(error))
5249
+ throw error;
5250
+ }
5251
+ if (Date.now() >= inputDeadline)
5252
+ return false;
5209
5253
  await sleep(500);
5210
5254
  }
5255
+ };
5256
+ let marked = await waitForComposerFileInput();
5257
+ const stale = await cdp.evaluate(attachmentPresenceExpression());
5258
+ if ((stale?.removed ?? 0) > 0) {
5259
+ // A fixed sleep and a composer check could both pass on the document being
5260
+ // left (a reload takes 5-8 s here), and the file then went into a page that
5261
+ // was replaced under it. The marked reload waits for the new document.
5262
+ await reloadAndAwaitComposer(cdp, 20_000);
5263
+ marked = await waitForComposerFileInput();
5211
5264
  }
5212
5265
  const document = await cdp.send("DOM.getDocument", { depth: -1, pierce: true });
5213
5266
  const rootNodeId = document.result?.root?.nodeId;
5214
5267
  if (rootNodeId === undefined)
5215
5268
  throw new Error("Could not read the ChatGPT page DOM to attach files.");
5269
+ const selectors = marked ? [`[${PRODEX_ATTACH_INPUT_ATTRIBUTE}]`, ...composerFileInputSelectors()] : composerFileInputSelectors();
5216
5270
  let inputNodeId;
5217
- for (const selector of composerFileInputSelectors()) {
5271
+ for (const selector of selectors) {
5218
5272
  const input = await cdp.send("DOM.querySelector", { nodeId: rootNodeId, selector });
5219
5273
  inputNodeId = input.result?.nodeId || undefined;
5220
5274
  if (inputNodeId)
@@ -5406,20 +5460,41 @@ export async function enableComposerTools(cdp, labels) {
5406
5460
  enabled.push(label);
5407
5461
  continue;
5408
5462
  }
5409
- const button = await cdp.evaluate(composerToolsButtonRectExpression());
5463
+ // The server-rendered editor shows before the app attaches the composer's
5464
+ // controls (measured 2026-10-06), so a first miss is usually just early.
5465
+ let button = await cdp.evaluate(composerToolsButtonRectExpression());
5466
+ const buttonDeadline = Date.now() + COMPOSER_FILE_INPUT_TIMEOUT_MS;
5467
+ while ((!button.ok || button.x === undefined || button.y === undefined) && Date.now() < buttonDeadline) {
5468
+ await sleep(500);
5469
+ button = await cdp.evaluate(composerToolsButtonRectExpression());
5470
+ }
5410
5471
  if (!button.ok || button.x === undefined || button.y === undefined) {
5411
5472
  throw new Error(button.reason ?? "Could not open the ChatGPT composer tools menu");
5412
5473
  }
5413
- await dispatchMouseClickAt(cdp, button.x, button.y);
5474
+ // A click that lands before the app has wired the button opens nothing
5475
+ // (measured 2026-10-06), which looked exactly like a menu without the
5476
+ // tool. The button is clicked again, freshly located, before concluding
5477
+ // the tool is missing.
5414
5478
  let entry = { ok: false };
5415
- const menuDeadline = Date.now() + 6_000;
5416
- for (;;) {
5417
- entry = await cdp.evaluate(composerToolEntryRectExpression(label));
5479
+ for (let attempt = 0; attempt < COMPOSER_TOOLS_MENU_ATTEMPTS; attempt += 1) {
5480
+ if (attempt > 0) {
5481
+ await dispatchEscapeKey(cdp);
5482
+ const again = await cdp.evaluate(composerToolsButtonRectExpression());
5483
+ if (again.ok && again.x !== undefined && again.y !== undefined)
5484
+ button = again;
5485
+ }
5486
+ await dispatchMouseClickAt(cdp, button.x, button.y);
5487
+ const menuDeadline = Date.now() + 4_000;
5488
+ for (;;) {
5489
+ entry = await cdp.evaluate(composerToolEntryRectExpression(label));
5490
+ if (entry.ok && entry.x !== undefined && entry.y !== undefined)
5491
+ break;
5492
+ if (Date.now() >= menuDeadline)
5493
+ break;
5494
+ await sleep(250);
5495
+ }
5418
5496
  if (entry.ok && entry.x !== undefined && entry.y !== undefined)
5419
5497
  break;
5420
- if (Date.now() >= menuDeadline)
5421
- break;
5422
- await sleep(250);
5423
5498
  }
5424
5499
  if (!entry.ok || entry.x === undefined || entry.y === undefined) {
5425
5500
  await dispatchEscapeKey(cdp);
package/docs/releasing.md CHANGED
@@ -16,6 +16,33 @@ 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.25 Release Record
20
+
21
+ Release commit `3c66da3` on `main` (cut after PR #15, merge `9bd908c`); tag `v0.40.25`.
22
+
23
+ **Publishing**
24
+ - `publish.yml` run 37394019059 passed all six platforms on the first attempt, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-06 00:48 UTC).
25
+ - `npm run release:verify` passed on the release commit after a build.
26
+
27
+ **What it fixes**
28
+ - A send into an existing conversation from a tab on another one now waits for that page to finish loading.
29
+
30
+ **Installation on 2026-10-06**
31
+ - Global npm, each verified with `prodex --version` = 0.40.25.
32
+ - WSL, M3, Mac mini: installed. Running services were not restarted: 19 stdio MCP servers on WSL and 7 on M3 keep older code until their clients reconnect. The Mac mini had none running.
33
+ - M4: not reachable (hostname does not resolve); not updated.
34
+ - The browser container already carries the same fix (2026-10-02 rebuild, see 0.40.24). An MCP consult through it answered on 2026-10-06 after the client reconnected.
35
+
36
+ **Live check after install (installed 0.40.25, shared WSL browser)**
37
+ - The tab was first moved to a new chat.
38
+ - `--continue --attach` answered, with a baseline of 5 earlier user messages.
39
+ - `--new-chat --attach` answered.
40
+ - A plain `--continue` recalled the word set at the start of the thread.
41
+
42
+ **Real use since 0.40.24 (2026-10-02 to 2026-10-06)**
43
+ - `prodex pro blockers`: 3 of 18 host consults were blocked, all three the probes that reproduced the bugs fixed in 0.40.24 and 0.40.25.
44
+ - 0 of 5 container consults were blocked.
45
+
19
46
  ## 0.40.24 Release Record
20
47
 
21
48
  Release commit `e927eee` on `main` (cut after PR #14, merge `393f720`); tag `v0.40.24`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.25",
3
+ "version": "0.40.26",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",