@youdie006/prodex 0.40.30 → 0.40.32

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 CHANGED
@@ -93,7 +93,7 @@ Useful flags on every send:
93
93
  |---|---|
94
94
  | `--new-chat` | Explicitly request the default: ordinary consults start in a fresh chat. The shared current tab is never an implicit destination. |
95
95
  | `--session-key id` | Identify one caller for scoped `--continue`. Falls back to `PRODEX_SESSION_KEY`, then `CODEX_THREAD_ID`. |
96
- | `--file path` | Inline a text file's contents into the prompt. Repeatable. |
96
+ | `--file path` | Inline a text file's contents into the prompt, up to 500 lines; a longer file is refused with a pointer to `--attach`. Repeatable. |
97
97
  | `--attach path` | Upload the file itself: the only way to hand ChatGPT a pdf, pptx, xlsx or image. Paths must live inside the repo. |
98
98
  | `--tool web-search` | Select a rendered composer tool. Automatic deep-research report retrieval is currently unsupported and is blocked before sending. |
99
99
  | `--project "name"` | Send inside an existing sidebar project. `--project-new` creates one first. `prodex pro browser projects` lists exact names. |
package/dist/bundle.js CHANGED
@@ -32,6 +32,8 @@ export function looksLikeBinaryFileContent(content) {
32
32
  }
33
33
  return control / sample.length > 0.1;
34
34
  }
35
+ /** The most lines `--file` puts into a prompt; longer files go through --attach. */
36
+ export const INLINE_FILE_MAX_LINES = 500;
35
37
  export async function buildDryRunBundle(root, input) {
36
38
  const sections = [
37
39
  "# prodex consult dry run",
@@ -46,7 +48,13 @@ export async function buildDryRunBundle(root, input) {
46
48
  // The prompt leads so the instruction is never buried under file dumps.
47
49
  const sendSections = [input.prompt.trim()];
48
50
  for (const file of input.files) {
49
- const content = await readRepoFile(root, file, { maxLines: 500 });
51
+ const content = await readRepoFile(root, file, { maxLines: INLINE_FILE_MAX_LINES });
52
+ // Sending only the first lines without saying so made ChatGPT answer
53
+ // confidently about a file it never saw whole (measured 2026-10-13).
54
+ if (content.total_lines > content.end_line) {
55
+ throw new Error(`--file ${file} has ${content.total_lines} lines; --file inlines at most ${INLINE_FILE_MAX_LINES}. ` +
56
+ `Upload the whole file instead: --attach ${file}.`);
57
+ }
50
58
  if (looksLikeBinaryFileContent(content.content)) {
51
59
  throw new Error(`--file ${file} is not a text file, and --file inlines a file's TEXT into the prompt. ` +
52
60
  `Upload it instead: --attach ${file}.`);
@@ -256,6 +256,9 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
256
256
  const isBlock = (node) => node.nodeType === 1 && !skipped(node) &&
257
257
  (BLOCK_TAGS.includes(tagOf(node)) || isCodeBlock(node) || (isMath(node) && isDisplayMath(node)) ||
258
258
  (tagOf(node) === "span" && findAll(node, (n) => BLOCK_TAGS.includes(tagOf(n))).length > 0));
259
+ // The renderer web-search answers use since 2026-10-10 marks a source as a
260
+ // badge inside a popover trigger; its urls only exist once it is opened.
261
+ const isSourceBadge = (node) => attr(node, "data-d-component") === "popover-trigger" && findAll(node, (n) => attr(n, "data-pill") !== null).length > 0;
259
262
  const pill = (node) => {
260
263
  const label = collapse(node.textContent || "").trim();
261
264
  if (!label) return "";
@@ -268,7 +271,7 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
268
271
  const piece = inlineNode(child);
269
272
  if (!piece) continue;
270
273
  // A source pill sits right after its sentence; keep one space before it.
271
- if (child.nodeType === 1 && attr(child, "data-chatgpt-copy-reference") !== null && out && !isSpaceChar(out[out.length - 1])) out += " ";
274
+ if (child.nodeType === 1 && (attr(child, "data-chatgpt-copy-reference") !== null || isSourceBadge(child)) && out && !isSpaceChar(out[out.length - 1])) out += " ";
272
275
  out += piece;
273
276
  }
274
277
  return out;
@@ -280,11 +283,12 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
280
283
  // A task list item's box (measured: button[role=checkbox][aria-checked]).
281
284
  if (isCheckbox(node)) return (attr(node, "aria-checked") === "true" || attr(node, "checked") !== null ? "[x]" : "[ ]") + " ";
282
285
  if (isMath(node)) return isDisplayMath(node) ? "$$" + mathSource(node) + "$$" : "$" + mathSource(node) + "$";
283
- if (attr(node, "data-chatgpt-copy-reference") !== null) return pill(node);
286
+ if (attr(node, "data-chatgpt-copy-reference") !== null || isSourceBadge(node)) return pill(node);
284
287
  if (attr(node, "data-markdown-copy") === "inline-code" || tag === "code") return BT + (node.textContent || "") + BT;
285
288
  if (tag === "br") return NL;
286
289
  const inner = inline(node);
287
- if (tag === "strong" || tag === "b") return inner.trim() ? "**" + inner.trim() + "**" : "";
290
+ // The newer renderer marks bold as span[data-d-default-strong].
291
+ if (tag === "strong" || tag === "b" || attr(node, "data-d-default-strong") !== null) return inner.trim() ? "**" + inner.trim() + "**" : "";
288
292
  if (tag === "em" || tag === "i") return inner.trim() ? "*" + inner.trim() + "*" : "";
289
293
  if (tag === "del" || tag === "s") return inner.trim() ? "~~" + inner.trim() + "~~" : "";
290
294
  if (tag === "a") {
@@ -428,7 +432,21 @@ export const CHATGPT_MODEL_HINTS_JS = `
428
432
  }
429
433
  return seen.slice(0, 30);
430
434
  };`;
431
- const CHATGPT_MESSAGE_NODES_JS = `${CHATGPT_RENDERED_MESSAGE_TEXT_JS}
435
+ /**
436
+ * ChatGPT collapses a long user turn behind "Show more" and leaves the rest
437
+ * out of the DOM (measured 2026-10-13: 10,250 of 12,095 characters, the request
438
+ * marker among the missing). Clicking it only changes what is displayed, and
439
+ * the full text renders after the click returns, so the caller reads again.
440
+ */
441
+ export const CHATGPT_EXPAND_USER_TURN_JS = `
442
+ const expandCollapsedUserTurn = (node) => {
443
+ if (!node || typeof node.querySelector !== 'function') return false;
444
+ const more = node.querySelector('button[aria-expanded="false"]');
445
+ if (!more) return false;
446
+ more.click();
447
+ return true;
448
+ };`;
449
+ const CHATGPT_MESSAGE_NODES_JS = `${CHATGPT_RENDERED_MESSAGE_TEXT_JS}${CHATGPT_EXPAND_USER_TURN_JS}
432
450
  // ChatGPT keeps a previously opened conversation mounted but hidden
433
451
  // (measured 2026-10-06: two turns without a box on a project home), and
434
452
  // counting them made a fresh chat look like an existing conversation.
@@ -3675,6 +3693,7 @@ export async function recoverChatGptAnswerFromThread(options) {
3675
3693
  let state;
3676
3694
  let generating = false;
3677
3695
  let stableRuns = 0;
3696
+ let expandingPolls = 0;
3678
3697
  let lastAnswer = "";
3679
3698
  let lastObservedUrl = "";
3680
3699
  let completed = false;
@@ -3715,6 +3734,12 @@ export async function recoverChatGptAnswerFromThread(options) {
3715
3734
  lastAnswer = "";
3716
3735
  continue;
3717
3736
  }
3737
+ if (chatGptUserTurnStillExpanding(state, expandingPolls)) {
3738
+ expandingPolls += 1;
3739
+ stableRuns = 0;
3740
+ lastAnswer = "";
3741
+ continue;
3742
+ }
3718
3743
  if (options.requestId && !chatGptRequestMarkerMatches(state.lastUserText ?? "", options.requestId)) {
3719
3744
  throw new ChatGptBrowserBlockerError({
3720
3745
  code: "request_mismatch",
@@ -4184,6 +4209,7 @@ export async function sendChatGptPrompt(options) {
4184
4209
  // prompt posts the composer clears and no send button is found.
4185
4210
  const promptPostedExpression = `(() => {${CHATGPT_MESSAGE_NODES_JS}
4186
4211
  const last = chatMessageNodes().filter((message) => message.role === "user").at(-1);
4212
+ if (last && expandCollapsedUserTurn(last.textNode)) return false;
4187
4213
  return Boolean(last && userMessageText(last.textNode).includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
4188
4214
  })()`;
4189
4215
  // A dispatch can reach Chrome even if its acknowledgement is lost.
@@ -4228,6 +4254,7 @@ export async function sendChatGptPrompt(options) {
4228
4254
  const acceptDeadline = computePromptAcceptanceDeadline(timeoutMs, started);
4229
4255
  let accepted = false;
4230
4256
  let finalState;
4257
+ let expandingPolls = 0;
4231
4258
  while (Date.now() < acceptDeadline) {
4232
4259
  await sleep(500);
4233
4260
  try {
@@ -4250,6 +4277,10 @@ export async function sendChatGptPrompt(options) {
4250
4277
  accepted = true;
4251
4278
  break;
4252
4279
  }
4280
+ if (chatGptUserTurnStillExpanding(finalState, expandingPolls)) {
4281
+ expandingPolls += 1;
4282
+ continue;
4283
+ }
4253
4284
  if (hasChatGptPromptAcceptance(beforeSubmit, finalState))
4254
4285
  throw requestMismatch(normalizedTargetUrl);
4255
4286
  emitProgress("waiting", "prompt posting");
@@ -4291,6 +4322,7 @@ export async function sendChatGptPrompt(options) {
4291
4322
  : undefined;
4292
4323
  let consecutiveReadFailures = 0;
4293
4324
  let answerSettled = false;
4325
+ expandingPolls = 0;
4294
4326
  let nonTextPolls = 0;
4295
4327
  let nonTextSettled = false;
4296
4328
  const answerIsStable = createChatGptAnswerStabilityTracker();
@@ -4299,6 +4331,10 @@ export async function sendChatGptPrompt(options) {
4299
4331
  try {
4300
4332
  const observedState = await evaluateOnPage(page, answerExpression());
4301
4333
  consecutiveReadFailures = 0;
4334
+ if (chatGptUserTurnStillExpanding(observedState, expandingPolls)) {
4335
+ expandingPolls += 1;
4336
+ continue;
4337
+ }
4302
4338
  // Freeze the first conversation identity the accepted page exposes. A
4303
4339
  // later tab move must never rewrite result metadata to another thread.
4304
4340
  if (!pinnedConversationId) {
@@ -5901,16 +5937,15 @@ async function insertComposerTextViaCdp(cdp, text, page, toolLabels = []) {
5901
5937
  // Both failures come from crossing the CDP boundary mid-edit, so large text
5902
5938
  // is inserted by a single in-page execCommand instead: the editor applies it
5903
5939
  // as one input event and nothing can interleave. Measured: 67 KB inserted in
5904
- // ~20s, verified byte-for-byte.
5940
+ // ~20s, verified byte-for-byte; 57 KB took 63-126 s on 2026-10-13.
5905
5941
  if (text.length <= COMPOSER_INSERT_CHUNK_CHARS) {
5906
5942
  await cdp.send("Input.insertText", { text });
5907
5943
  }
5908
5944
  else {
5909
- // A 67 KB insert measured ~20s in the page, which the default 20s CDP
5910
- // command budget would cut off, so this one call gets its own connection
5911
- // with a size-scaled budget instead of loosening the budget for every
5912
- // command on the shared connection.
5913
- const budgetMs = Math.max(60_000, Math.ceil(text.length / 1_000) * 1_000);
5945
+ // A large insert outlasts the default 20s CDP command budget, so this one
5946
+ // call gets its own connection with a size-scaled budget instead of
5947
+ // loosening the budget for every command on the shared connection.
5948
+ const budgetMs = composerInsertBudgetMs(text.length);
5914
5949
  const slowCdp = page ? await connectCdp(page.webSocketDebuggerUrl, budgetMs) : undefined;
5915
5950
  try {
5916
5951
  const target = slowCdp ?? cdp;
@@ -5930,6 +5965,16 @@ async function insertComposerTextViaCdp(cdp, text, page, toolLabels = []) {
5930
5965
  throw new Error(state.reason ?? "Composer stayed empty after text insertion");
5931
5966
  }
5932
5967
  export const COMPOSER_INSERT_CHUNK_CHARS = 4_000;
5968
+ /**
5969
+ * How long one in-page insert of `chars` characters may take. Measured
5970
+ * 2026-10-13: 10 s for 15 KB, 34 s for 30 KB, and 63 s then 126 s for the same
5971
+ * 57 KB - far slower than when this path was built (67 KB in ~20 s), and the
5972
+ * old one-second-per-KB budget cut a 57 KB prompt off. 5 ms per character
5973
+ * covers the slowest measured run with room to spare.
5974
+ */
5975
+ export function composerInsertBudgetMs(chars) {
5976
+ return Math.max(60_000, chars * 5);
5977
+ }
5933
5978
  /**
5934
5979
  * Split composer text into insertText-sized pieces. Splits on code points (not
5935
5980
  * UTF-16 units) so a surrogate pair - an emoji, or any astral character - can
@@ -6057,10 +6102,34 @@ function chatGptRequestMarkerMatches(userText, requestId) {
6057
6102
  const markers = [...normalizeChatGptPromptText(userText).matchAll(/\[prodex-request:([a-f0-9]{32})\]/g)];
6058
6103
  return markers.at(-1)?.[1] === requestId && markers.filter((match) => match[1] === requestId).length === 1;
6059
6104
  }
6105
+ /**
6106
+ * A collapsed user turn is not judged until its expanded text has rendered.
6107
+ * Bounded, so a turn that collapses again on every render is judged in the end
6108
+ * instead of holding the send until its timeout.
6109
+ */
6110
+ export function chatGptUserTurnStillExpanding(state, skippedPolls = 0) {
6111
+ return state.lastUserCollapsed === true && skippedPolls < USER_TURN_EXPAND_MAX_POLLS;
6112
+ }
6113
+ export const USER_TURN_EXPAND_MAX_POLLS = 10;
6060
6114
  /** Full prompt and per-send identity; wrappers may contain tool/file labels. */
6061
6115
  export function chatGptRequestMatchesUserTurn(userText, sentPrompt, requestId) {
6062
- return chatGptRequestMarkerMatches(userText, requestId) &&
6063
- normalizeChatGptPromptText(userText).includes(normalizeChatGptPromptText(sentPrompt));
6116
+ if (!chatGptRequestMarkerMatches(userText, requestId))
6117
+ return false;
6118
+ if (normalizeChatGptPromptText(userText).includes(normalizeChatGptPromptText(sentPrompt)))
6119
+ return true;
6120
+ // The container's ChatGPT renders a sent turn as markdown since 2026-10-10:
6121
+ // a --file prompt's fence lines disappear and the inner fences shift, so
6122
+ // every such send posted and was then refused. Fence lines carry no request
6123
+ // text; everything else, punctuation and inline backticks included, must
6124
+ // still match in order.
6125
+ return withoutFenceLines(userText).includes(withoutFenceLines(sentPrompt));
6126
+ }
6127
+ function withoutFenceLines(value) {
6128
+ return value
6129
+ .replace(AUTOLINKED_URL_PATTERN, "$1")
6130
+ .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
6131
+ .replace(/^[ \t]*```[\w-]*[ \t]*$/gm, "")
6132
+ .replace(/\s+/g, "");
6064
6133
  }
6065
6134
  /**
6066
6135
  * Does this transcript belong to the consult that is waiting on it?
@@ -6351,10 +6420,11 @@ export function answerExpression() {
6351
6420
  if (tagged) modelSlug = tagged.getAttribute('data-message-model-slug') || undefined;
6352
6421
  }
6353
6422
  // User turns stay as typed: the request marker is matched against them.
6354
- return { role, node, text: role === "assistant" ? renderedMessageText(textNode) : userMessageText(textNode), modelSlug };
6423
+ return { role, node, textNode, text: role === "assistant" ? renderedMessageText(textNode) : userMessageText(textNode), modelSlug };
6355
6424
  });
6356
6425
  const assistantMessages = messages.filter((message) => message.role === "assistant");
6357
6426
  const userMessages = messages.filter((message) => message.role === "user");
6427
+ const lastUserCollapsed = expandCollapsedUserTurn(userMessages.at(-1)?.textNode);
6358
6428
  // A turn without an assistant message is NOT an answer. Treating one as an
6359
6429
  // answer (a 0.21.3 fallback for deep research, which is read from the
6360
6430
  // transcript now) turned a tool's progress panel into a 28-character
@@ -6388,6 +6458,7 @@ export function answerExpression() {
6388
6458
  assistantMessageCount: assistantMessages.length,
6389
6459
  userMessageCount: userMessages.length,
6390
6460
  lastUserText: userMessages.at(-1)?.text || "",
6461
+ lastUserCollapsed,
6391
6462
  // An image result is rendered outside every message unit and leaves no
6392
6463
  // assistant message at all (measured 2026-09-29), so it is reported here
6393
6464
  // for the waiter to recognize: a generated image that comes after the
package/docs/releasing.md CHANGED
@@ -16,12 +16,85 @@ 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-10: earlyoom
20
+
21
+ **What happened**
22
+ - The host's dedicated browser (port 9333) stopped answering twice: around 11:49 and at 17:45 KST. The browser container exited at 11:49 KST (exit 1, `chromium exited (SIGTRAP)`, restart policy `no`) and stayed down.
23
+ - The hourly canary logged `skipped - the browser is not available` from 18:17 KST on, and filed nothing.
24
+
25
+ **Cause**
26
+ - The machine's `earlyoom` (override from 2026-08-14: `-m 15 -s 40`) sent SIGTERM to Chrome and Chromium processes: 25 between 11:45 and 11:49, 17 at 17:45. It acts when available memory is under 15% and free swap under 40%. Swap was 88% used (1 GiB free of 8), so every dip under 15% memory picked a process, and Chrome's processes carried the highest badness (800-876).
27
+ - Earlier days show the same: 248 Chrome kills on 2026-10-08 (09:00-11:59 KST), which likely explains that day's browser losses.
28
+ - At 19:03 earlyoom also killed `chrome-headless`, `swapdex`, `ssh` and `dbus-daemon` processes, so the pressure is machine-wide, not prodex's.
29
+
30
+ **Recovery**
31
+ - Host browser restarted once on the Xvfb display (`login --virtual-display`, logged in, 4 s); the container started from the same image `547e852632b5` and its tab reopened (2 s). The canary from `12aab1e` (PR #24, re-read before reporting broken) then reported `ok` on build `314720d0`.
32
+
33
+ **Not changed**
34
+ - The earlyoom configuration and the container restart policy. Both are the maintainer's machine-level choices.
35
+
36
+ ## 0.40.31 Release Record
37
+
38
+ Release commit `0b10dd1` on `main` (cut after PR #23, merge `d5296e0`); tag `v0.40.31`.
39
+
40
+ **Publishing**
41
+ - `publish.yml` run 38019182789 passed all six platforms on the first attempt, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-10 03:03 UTC).
42
+ - `npm run release:verify` passed on the release commit after a build.
43
+
44
+ **Installed**
45
+ - WSL, M3, Mac mini: `prodex --version` reports 0.40.31. Running services were not restarted: 4 stdio MCP servers on WSL and 4 on M3 keep older code until their clients reconnect. The Mac mini had none running.
46
+ - The maintainer watchdog checkout (`~/.local/share/prodex-maint/repo`) was pulled to `0b10dd1` and rebuilt, so the hourly canary now uses the live build check and the renderer markers.
47
+
48
+ **Verified live**
49
+ - Host 0.40.31: a Markdown `--file` new-chat send answered (`opal-5`).
50
+ - The host's dedicated browser had stopped answering on port 9333 after about 11:18 KST; it was restarted once on the Xvfb display (`login --virtual-display`, logged in, 5 s). Cause not established.
51
+ - The canary's first run right after that restart reported `broken: model selector`, because the composer had not rendered yet; the next run was `ok` on build `314720d0`. The model picker lists GPT-6, GPT-5.6 Sol and GPT-5.5.
52
+
53
+ ## Browser Container Rebuild 2026-10-10
54
+
55
+ **Patches**
56
+ - `container/redesign-patches` `5047ef9` ports 0.40.28-0.40.30 and PR #23 (newer answer renderer, sent turn drawn as markdown) onto the container branch. It is based on `b5eac7d`; `feat/headless-browser-compatibility` itself is unchanged.
57
+
58
+ **Image**
59
+ - `547e852632b5`, built from `5047ef9`; the branch suite passed (2027 passed, 3 skipped).
60
+ - Both swaps were gated on the send lock being free. After the swap the browser had no ChatGPT tab, so `prodex pro browser login --wait` reopened it (logged in, 3 s). MCP clients of the container need `/mcp` reconnects.
61
+
62
+ **Verified live**
63
+ - Image `9b51e0639a37` (the port without the fence fix): web search with bold and `[GitHub+1]` badges, continued and temporary attach, recover, models (the picker now lists GPT-6), smoke in a new chat, `pro list` in 5 s. A Markdown `--file` send posted but was refused as unverified: the container's ChatGPT drew the sent turn as markdown and dropped the ```` ```text ```` fence line.
64
+ - Image `547e852632b5`: the same `--file` send answered 2/2, and the smoke answered `PRODEX_PRO_SMOKE_OK`.
65
+
66
+ **Not a bug**
67
+ - A host 0.40.30 `--file` run failed during this check only because another send held the browser lock past its wait budget; the next run answered.
68
+
69
+ ## 0.40.30 Release Record
70
+
71
+ Release commit `e566efc` on `main` (cut after PR #22, merge `c0964f2`, plus the docs commit `09e80bc`); tag `v0.40.30`.
72
+
73
+ **Publishing**
74
+ - `publish.yml` run 37798469373 passed all six platforms on the first attempt, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-08 15:32 UTC).
75
+ - `npm run release:verify` passed on the release commit after a build.
76
+
77
+ **What it fixes**
78
+ - `pro browser smoke` always uses a fresh chat instead of whatever conversation was open.
79
+ - Its default budget is 5 minutes instead of 90 s.
80
+ - The page canary and the watchdog changes from the same PR are maintainer tooling and are not in the package. See Maintainer Watchdog.
81
+
82
+ **Installation on 2026-10-09**
83
+ - Global npm with `--prefer-online`, each verified with `prodex --version` = 0.40.30.
84
+ - WSL, M3, Mac mini: installed. Running services were not restarted: 21 stdio MCP servers on WSL and 10 on M3 keep older code until their clients reconnect. The Mac mini had none running.
85
+ - M4: not reachable (hostname does not resolve); not updated.
86
+
87
+ **Live check after install (installed 0.40.30, shared WSL browser)**
88
+ - With the tab on an existing conversation, `pro browser smoke` answered `PRODEX_PRO_SMOKE_OK` in a new conversation, not the open one.
89
+ - A new-chat attachment answered.
90
+ - The hourly watchdog had already logged `canary: ok` runs for build `4c511f80`.
91
+
19
92
  ## Maintainer Watchdog
20
93
 
21
94
  On the maintainer's WSL machine, cron runs the UI watchdog every hour (since 2026-10-08):
22
95
 
23
96
  ```
24
- 17 * * * * PATH=<node bin>:/usr/bin:/bin PRODEX_CLI=~/.local/share/prodex-maint/prodex PRODEX_WATCHDOG_CWD=/mnt/d/MyProject/gptprouse <node> ~/.local/share/prodex-maint/repo/scripts/ui-watchdog.mjs --canary --file-issue >> ~/.local/state/prodex-watchdog.log 2>&1
97
+ 17 * * * * PATH=<node bin>:/usr/bin:/bin PRODEX_CLI=~/.local/share/prodex-maint/prodex PRODEX_WATCHDOG_CWD=/mnt/d/MyProject/gptprouse <node> ~/.local/share/prodex-maint/repo/scripts/ui-watchdog.mjs --canary --revive --file-issue >> ~/.local/state/prodex-watchdog.log 2>&1
25
98
  ```
26
99
 
27
100
  **What each run does**
@@ -29,8 +102,10 @@ On the maintainer's WSL machine, cron runs the UI watchdog every hour (since 202
29
102
  - It records ChatGPT's build id and whether each control prodex relies on is present.
30
103
  - It compares with `~/.local/share/prodex/page-canary.json`.
31
104
  - It skips while a consult holds the send lock.
105
+ - A broken read is read again up to twice, 10 s apart, and counts only if it stays broken. Right after a browser restart on 2026-10-10 the first read found no model selector because the composer had not rendered yet.
32
106
  - Only when the canary reports changed (3) or broken (2) does the watchdog run `pro browser smoke` (a fresh chat, 5-minute budget). If that fails, it files or extends a GitHub issue by blocker code.
33
107
  - A busy browser is logged as skipped and never filed.
108
+ - With `--revive` (since 2026-10-10), a canary that finds the dedicated browser gone, or without a ChatGPT tab, makes the watchdog run `pro browser login --virtual-display --wait` once and read the page again. It does this at most once every 6 hours (`~/.local/state/prodex-maint/revive.json`), so a machine short of memory does not get a new Chrome every hour. A consult holding the lock, or a tab that stopped answering, is never touched. This came from the earlyoom incident below, where the browser stayed down for hours.
34
109
 
35
110
  **Where it runs from**
36
111
  - `~/.local/share/prodex-maint/repo` is a clone of `main`, built with `npm ci && npm run build`.
@@ -45,6 +120,7 @@ On the maintainer's WSL machine, cron runs the UI watchdog every hour (since 202
45
120
  - It timed out at 90 s on high efforts (issue #4).
46
121
  - It filed a busy browser as broken (issue #20, closed as a false positive).
47
122
  - The crontab before the change is saved at `~/.local/state/prodex-maint/crontab.backup-20261008`.
123
+ - `--revive` was added to the cron line on 2026-10-11 after PR #25 (merge `9c9a1b4`); the checkout was pulled to that merge and rebuilt, and one manual run was `ok`. The crontab before that edit is saved at `~/.local/state/prodex-maint/crontab.backup-20261011`.
48
124
 
49
125
  ## 0.40.29 Release Record
50
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.30",
3
+ "version": "0.40.32",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",