@youdie006/prodex 0.40.38 → 0.40.39

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.
@@ -15,9 +15,10 @@ function lockPath() {
15
15
  /**
16
16
  * Serialize visible-browser sends across processes. Waits up to waitMs for a
17
17
  * live holder to finish (0 = fail fast); a lock whose holder process is dead
18
- * is reaped immediately.
18
+ * is reaped immediately. budgetFlag is the option that sets waitMs for the
19
+ * command that is waiting, named when the wait runs out.
19
20
  */
20
- export async function withBrowserSendLock(waitMs, onWait, fn) {
21
+ export async function withBrowserSendLock(waitMs, onWait, fn, budgetFlag = "--timeout-ms") {
21
22
  const file = lockPath();
22
23
  const reapClaim = path.join(path.dirname(file), `.${path.basename(file)}.reap`);
23
24
  return withCrossProcessFileLock(file, {
@@ -25,7 +26,7 @@ export async function withBrowserSendLock(waitMs, onWait, fn) {
25
26
  retryMs: 2_000,
26
27
  privateParent: true,
27
28
  onWait: (holder) => onWait(`another prodex send holds the browser (pid ${holder.pid ?? "unknown"}); waiting`),
28
- busyError: (holder) => new Error(`Another prodex browser send is in progress (pid ${holder.pid ?? "unknown"}) and did not finish within the wait budget. Retry once it finishes, or raise --timeout-ms (which is also the queue budget).`),
29
+ busyError: (holder) => new Error(`Another prodex browser send is in progress (pid ${holder.pid ?? "unknown"}) and did not finish within the wait budget. Retry once it finishes, or raise ${budgetFlag} (which is also the queue budget).`),
29
30
  unavailableError: () => new Error(`A prodex browser send lock at ${file} is held by nothing and could not be removed, so no send can start. Stop all prodex senders and verify no request is active before deleting that file and the abandoned reaper claim at ${reapClaim}, then retry.`)
30
31
  }, fn);
31
32
  }
@@ -252,9 +252,31 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
252
252
  // Inline math carries data-math-display="false" (measured), so the value counts, not its presence.
253
253
  const isDisplayMath = (node) => attr(node, "data-math-display") === "true" || hasClass(node, "katex-display");
254
254
  const isCodeBlock = (node) => attr(node, "data-markdown-copy") === "code-block" || tagOf(node) === "pre";
255
+ // A mermaid block is drawn as an SVG preview whose only text is the
256
+ // toolbar and node labels (measured 2026-10-21); its source is in the
257
+ // React props of the preview's fiber ancestors, as CodeMirror's is in
258
+ // its editor state.
259
+ const isMermaidPreview = (node) => attr(node, "data-chatgpt-mermaid-preview") !== null;
260
+ const mermaidBlock = (node) => {
261
+ let source = null;
262
+ try {
263
+ const fiberKey = Object.keys(node).find((key) => key.startsWith("__reactFiber"));
264
+ let fiber = fiberKey ? node[fiberKey] : null;
265
+ for (let depth = 0; fiber && depth < 12 && source === null; depth += 1, fiber = fiber.return) {
266
+ const content = fiber.memoizedProps && fiber.memoizedProps.content;
267
+ if (typeof content === "string" && content.trim()) source = trimEnd(content);
268
+ }
269
+ } catch (error) {
270
+ source = null;
271
+ }
272
+ if (source === null) return "[mermaid diagram - open the thread to see its source]";
273
+ const longestRun = (source.match(new RegExp(BT + "+", "g")) || []).reduce((most, run) => Math.max(most, run.length), 0);
274
+ const fence = BT.repeat(Math.max(3, longestRun + 1));
275
+ return fence + "mermaid" + NL + source + NL + fence;
276
+ };
255
277
  const BLOCK_TAGS = ["p", "div", "section", "article", "figure", "ul", "ol", "li", "table", "blockquote", "pre", "hr", "h1", "h2", "h3", "h4", "h5", "h6"];
256
278
  const isBlock = (node) => node.nodeType === 1 && !skipped(node) &&
257
- (BLOCK_TAGS.includes(tagOf(node)) || isCodeBlock(node) || (isMath(node) && isDisplayMath(node)) ||
279
+ (BLOCK_TAGS.includes(tagOf(node)) || isCodeBlock(node) || isMermaidPreview(node) || (isMath(node) && isDisplayMath(node)) ||
258
280
  (tagOf(node) === "span" && findAll(node, (n) => BLOCK_TAGS.includes(tagOf(n))).length > 0));
259
281
  // The renderer web-search answers use since 2026-10-10 marks a source as a
260
282
  // badge inside a popover trigger; its urls only exist once it is opened.
@@ -362,6 +384,7 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
362
384
  const tag = tagOf(node);
363
385
  if (isMath(node)) return isDisplayMath(node) ? "$$" + NL + mathSource(node) + NL + "$$" : "$" + mathSource(node) + "$";
364
386
  if (isCodeBlock(node)) return codeBlock(node);
387
+ if (isMermaidPreview(node)) return mermaidBlock(node);
365
388
  if (tag === "table") return table(node);
366
389
  if (/^h[1-6]$/.test(tag)) return "#".repeat(Number(tag[1])) + " " + inline(node).trim();
367
390
  if (tag === "ul" || tag === "ol") return list(node, depth);
package/dist/cli-args.js CHANGED
@@ -72,6 +72,12 @@ export function unknownTopLevelCommandError(command) {
72
72
  return new Error(`Unknown command: ${command}.${suggestionText} Run \`prodex help\`.`);
73
73
  }
74
74
  export function unknownOptionError(option, command, candidates) {
75
+ // A prompt that starts with a dash ("-5 + 3, what is it?") lands here.
76
+ // Anything that cannot be a flag gets the way to send it instead of a guess.
77
+ if (!/^--?[A-Za-z][\w-]*(=.*)?$/.test(option)) {
78
+ const context = command ? ` for ${command}` : "";
79
+ return new Error(`Unknown option${context}: ${option}. If this is the prompt, put \`--\` before it: prodex ask -- ${JSON.stringify(option)}`);
80
+ }
75
81
  const suggestion = closestSuggestion(option, candidates);
76
82
  const suggestionText = suggestion ? `. Did you mean \`${suggestion}\`?` : "";
77
83
  const context = command ? ` for ${command}` : "";
package/dist/cli-pro.js CHANGED
@@ -2305,7 +2305,7 @@ async function recoverBrowserUnderSendLock(stderr, options) {
2305
2305
  }
2306
2306
  }
2307
2307
  async function completeVisibleAuthRecovery(io, options) {
2308
- return withBrowserSendLock(5_000, (detail) => io.stderr(`recovery: ${detail}`), async () => {
2308
+ return withBrowserSendLock(options.timeoutMs, (detail) => io.stderr(`recovery: ${detail}`), async () => {
2309
2309
  const { closeBlockedHeadlessBrowserForVisibleAuth, getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
2310
2310
  io.stderr("recovery: verifying the blocked headless browser before graceful visible handoff...");
2311
2311
  let url;
@@ -2342,10 +2342,26 @@ async function completeVisibleAuthRecovery(io, options) {
2342
2342
  headedLoginCommand: formatHeadedBrowserLoginCommand(options.sourceCli, { ...options.commandOptions, profileDir: opened.profileDir, port: opened.port })
2343
2343
  });
2344
2344
  return ready ? 0 : 1;
2345
- });
2345
+ }, "--wait-timeout-ms");
2346
+ }
2347
+ /**
2348
+ * Why a headless handoff sat on Cloudflare's check, and the way out. Measured
2349
+ * 2026-10-20 on M3: headless Chrome reported "HeadlessChrome/155.0.0.0" and an
2350
+ * 800x600 screen and stayed on "Just a moment", right after a visible sign-in.
2351
+ * WSL works because its browser is ordinary Chrome on a virtual display.
2352
+ */
2353
+ export function headlessCloudflareHint(code, platform) {
2354
+ if (code !== "cloudflare_check")
2355
+ return undefined;
2356
+ const cause = "Cloudflare keeps headless Chrome on its check (it identifies itself as HeadlessChrome), so another login or handoff will not get past it.";
2357
+ return platform === "darwin"
2358
+ ? `${cause} macOS has no virtual display for Chrome; run the dedicated browser in the browser container instead (docs/container-browser.md), or keep a visible window open behind your other windows.`
2359
+ : `${cause} Run it on a virtual display instead, with no window on screen: \`prodex pro browser login --virtual-display\`.`;
2346
2360
  }
2347
2361
  async function completeBackgroundBrowserLogin(io, options) {
2348
- return withBrowserSendLock(5_000, (detail) => io.stderr(`background: ${detail}`), async () => {
2362
+ // An agent sending consults back to back left no 5 s gap (measured
2363
+ // 2026-10-20 on M3), so the handoff queues for its turn like a send does.
2364
+ return withBrowserSendLock(options.timeoutMs, (detail) => io.stderr(`background: ${detail}`), async () => {
2349
2365
  const { closeIdleChatGptBrowserForHandoff, getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
2350
2366
  io.stderr("background: checking the dedicated browser before graceful handoff...");
2351
2367
  const { url } = await closeIdleChatGptBrowserForHandoff({ port: options.port, profileDir: options.profileDir });
@@ -2367,13 +2383,16 @@ async function completeBackgroundBrowserLogin(io, options) {
2367
2383
  if (!ready) {
2368
2384
  const status = await getChatGptBrowserStatus({ port: opened.port, timeoutMs: 5_000 }).catch(() => undefined);
2369
2385
  io.stderr(`background: NOT READY (${status?.blocker?.code ?? "not_ready"}). The same profile was kept; this is not proof that your saved login was lost. No prompt was sent.`);
2386
+ const hint = headlessCloudflareHint(status?.blocker?.code, process.platform);
2387
+ if (hint)
2388
+ io.stderr(`background: ${hint}`);
2370
2389
  return 1;
2371
2390
  }
2372
2391
  if (!getDedicatedBrowserHeadlessMode({ port: opened.port, profileDir: opened.profileDir }))
2373
2392
  throw new Error("The replacement browser is not actually headless; background readiness was not confirmed.");
2374
2393
  io.stdout("background: READY - the saved login works headless, with no visible window. You may close this terminal; later consults reuse this mode.");
2375
2394
  return 0;
2376
- });
2395
+ }, "--wait-timeout-ms");
2377
2396
  }
2378
2397
  function needsVisibleAuthRecovery(code) {
2379
2398
  return code !== undefined && ["login_required", "cloudflare_check", "captcha_required", "permission_required"].includes(code);
package/dist/repo.js CHANGED
@@ -70,7 +70,8 @@ export async function readRepoFile(root, repoPath, options = {}) {
70
70
  const resolved = resolveRepoPath(root, repoPath);
71
71
  await assertResolvedRepoPathAllowed(root, resolved, repoPath);
72
72
  const text = await readVerifiedUtf8File(resolved, () => assertResolvedRepoPathAllowed(root, resolved, repoPath), {
73
- maxBytes: MAX_REPO_READ_BYTES
73
+ maxBytes: MAX_REPO_READ_BYTES,
74
+ decodeUtf16Bom: true
74
75
  }).catch((error) => {
75
76
  if (error instanceof Error && /too large/.test(error.message)) {
76
77
  throw new Error(`Path ${repoPath} is too large to read through repo tools`);
package/dist/safe-file.js CHANGED
@@ -71,7 +71,7 @@ export async function readVerifiedUtf8File(filePath, validate, options = {}) {
71
71
  if (options.maxBytes !== undefined && stat.size > options.maxBytes) {
72
72
  throw new Error(`Target file is too large (${stat.size} bytes)`);
73
73
  }
74
- const content = await readHandleUtf8(handle, filePath, options.maxBytes);
74
+ const content = await readHandleUtf8(handle, filePath, options.maxBytes, options.decodeUtf16Bom === true);
75
75
  if (options.mode !== undefined) {
76
76
  await testHooks.beforeChmod?.(filePath, "read");
77
77
  await assertSafeOpenFile(handle, filePath, "read");
@@ -185,7 +185,7 @@ async function replaceByVerifiedTempFile(filePath, content, validate, options =
185
185
  throw error;
186
186
  }
187
187
  }
188
- async function readHandleUtf8(handle, filePath, maxBytes) {
188
+ async function readHandleUtf8(handle, filePath, maxBytes, decodeUtf16Bom = false) {
189
189
  await assertOpenFileMatchesPath(handle, filePath, "read");
190
190
  const stat = await handle.stat();
191
191
  if (!stat.isFile()) {
@@ -206,7 +206,24 @@ async function readHandleUtf8(handle, filePath, maxBytes) {
206
206
  throw new Error(`Target file is too large (${offset} bytes)`);
207
207
  }
208
208
  }
209
- return buffer.subarray(0, offset).toString("utf8");
209
+ return decodeFileText(buffer.subarray(0, offset), decodeUtf16Bom);
210
+ }
211
+ /**
212
+ * UTF-8, or UTF-16 when asked and the file starts with its BOM. Windows
213
+ * Notepad's "Unicode" saves UTF-16 LE with a BOM, and reading that as UTF-8
214
+ * turned every other byte into a NUL (measured 2026-10-20: --file refused it
215
+ * as "not a text file"). Only readers opt in, so nothing is re-encoded on write.
216
+ */
217
+ export function decodeFileText(bytes, decodeUtf16Bom = false) {
218
+ if (decodeUtf16Bom && bytes.length >= 2) {
219
+ if (bytes[0] === 0xff && bytes[1] === 0xfe)
220
+ return bytes.subarray(2).toString("utf16le");
221
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) {
222
+ const body = Buffer.from(bytes.subarray(2, 2 + ((bytes.length - 2) & ~1)));
223
+ return body.swap16().toString("utf16le");
224
+ }
225
+ }
226
+ return bytes.toString("utf8");
210
227
  }
211
228
  async function writeHandleUtf8(handle, filePath, content) {
212
229
  const replacement = Buffer.from(content, "utf8");
package/docs/releasing.md CHANGED
@@ -16,6 +16,44 @@ 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
+ ## Browser Incident 2026-10-20: M3 headless browser behind Cloudflare
20
+
21
+ **What happened**
22
+ - A read-only `prodex pro browser check` on M3 (0.40.38) reported `cloudflare_check`: the dedicated browser had run headless since 2026-09-16 and its ChatGPT tab sat on Cloudflare's human-verification page. Consults from M3's stdio MCP servers could not send.
23
+ - `login --headed --recover-visible` could not take it over (`Chrome DevTools command timed out: Runtime.enable` on the stuck tab), and `reset` declined because the browser itself still answered.
24
+
25
+ **Recovery**
26
+ - The dedicated browser was closed with CDP `Browser.close` (send lock idle), then started with a window. Cloudflare passed. `--minimized` was not kept: this macOS desktop reports a minimized window as hidden, so prodex restored it.
27
+ - The profile's ChatGPT login had expired; the maintainer signed in on the Mac. `pro browser check` then reported `ok logged_in=true composer=true`, and `pro browser smoke` answered `PRODEX_PRO_SMOKE_OK`.
28
+
29
+ **Headless handoff, then the cause (same day)**
30
+ - `login --background` handed the signed-in profile to headless (after PR #35's lock-wait problem was worked around by retrying), and the headless tab was immediately back on Cloudflare's "Just a moment" page (`NOT READY - cloudflare_check ... not ready headlessly`).
31
+ - Read over CDP: the headless browser reports `HeadlessChrome/155.0.0.0` in its user agent and an 800x600 screen. WSL never runs headless - its dedicated browser is ordinary Chrome on the Xvfb display - which is why the same account works there. macOS has no Xvfb. Faking the user agent was not done: the container design excludes stealth settings.
32
+ - M3's Claude and Codex MCP configs already use the browser container (`container-client.mjs --context colima-prodex-check mcp`), which runs ordinary Chromium on Xvfb and was logged in and ready. Three long-running sessions (two Claude, started via cmux with `--resume`, and a Codex running since 2026-09-15) still held host `prodex mcp` connections; they reach the container after a `/mcp` reconnect.
33
+ - M3's container was rebuilt from `container/redesign-patches` `a870b0c` (image `feb5a47aae06`, restart `unless-stopped`) under the container's send-lock check. Its login was kept, the ChatGPT tab opened by itself, `pro browser check` was ok and the smoke answered `PRODEX_PRO_SMOKE_OK`. The container's `prodex --version` still prints 0.40.18 because that branch never bumped its version.
34
+
35
+ **Open**
36
+ - A long-running headless browser ends up behind Cloudflare's check (also noted on WSL, where the Xvfb display is used instead). On macOS there is no virtual display, and a minimized window counts as hidden, so the remaining no-headless option there is a window kept behind others. Changing the headless preference is a design decision that was not made here.
37
+
38
+ ## 0.40.38 Release Record
39
+
40
+ Release commit `9541727` on `main` (cut after PR #32, merge `2c049b5`); tag `v0.40.38`.
41
+
42
+ **Publishing**
43
+ - `publish.yml` run 39362699716 passed all six platforms on the first attempt, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-20 03:08 UTC).
44
+ - `npm run release:verify` passed on the release commit after a build.
45
+
46
+ **Installed**
47
+ - WSL, M3, Mac mini: `prodex --version` reports 0.40.38. Running services were not restarted: 9 stdio MCP servers on WSL and 3 on M3 keep older code until their clients reconnect. The Mac mini had none running.
48
+ - The maintainer watchdog checkout was pulled to `9541727` and rebuilt.
49
+
50
+ **Verified live**
51
+ - Over a fresh stdio MCP server on installed 0.40.38: a `pro_consult` with a 15 s timeout returned `status: "blocked"` (`send_timeout`) with `recover`, and `pro_recover` with `recover.arguments` returned `status: "done"`. On 0.40.37 the same consult returned only error text naming a CLI command.
52
+ - Earlier the same day over MCP: `files`, `continue_thread`, and refusals for a path outside the repo, `.env`, a missing attachment, `new_chat` with `continue_thread`, and `deep-research` all behaved as documented.
53
+
54
+ **Browser container**
55
+ - `container/redesign-patches` `9c8a3d9` ports PR #32. Image `7486d2054a82`; branch suite 2049 passed, 3 skipped. Swapped under the send lock check. Live over MCP in the container: the same timeout returned `recover`, and `pro_recover` returned `done`.
56
+
19
57
  ## 0.40.37 Release Record
20
58
 
21
59
  Release commit `437590d` on `main` (cut after PR #31, merge `fd0ef2e`); tag `v0.40.37`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.38",
3
+ "version": "0.40.39",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",