@youdie006/prodex 0.40.31 → 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}.`);
@@ -432,7 +432,21 @@ export const CHATGPT_MODEL_HINTS_JS = `
432
432
  }
433
433
  return seen.slice(0, 30);
434
434
  };`;
435
- 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}
436
450
  // ChatGPT keeps a previously opened conversation mounted but hidden
437
451
  // (measured 2026-10-06: two turns without a box on a project home), and
438
452
  // counting them made a fresh chat look like an existing conversation.
@@ -3679,6 +3693,7 @@ export async function recoverChatGptAnswerFromThread(options) {
3679
3693
  let state;
3680
3694
  let generating = false;
3681
3695
  let stableRuns = 0;
3696
+ let expandingPolls = 0;
3682
3697
  let lastAnswer = "";
3683
3698
  let lastObservedUrl = "";
3684
3699
  let completed = false;
@@ -3719,6 +3734,12 @@ export async function recoverChatGptAnswerFromThread(options) {
3719
3734
  lastAnswer = "";
3720
3735
  continue;
3721
3736
  }
3737
+ if (chatGptUserTurnStillExpanding(state, expandingPolls)) {
3738
+ expandingPolls += 1;
3739
+ stableRuns = 0;
3740
+ lastAnswer = "";
3741
+ continue;
3742
+ }
3722
3743
  if (options.requestId && !chatGptRequestMarkerMatches(state.lastUserText ?? "", options.requestId)) {
3723
3744
  throw new ChatGptBrowserBlockerError({
3724
3745
  code: "request_mismatch",
@@ -4188,6 +4209,7 @@ export async function sendChatGptPrompt(options) {
4188
4209
  // prompt posts the composer clears and no send button is found.
4189
4210
  const promptPostedExpression = `(() => {${CHATGPT_MESSAGE_NODES_JS}
4190
4211
  const last = chatMessageNodes().filter((message) => message.role === "user").at(-1);
4212
+ if (last && expandCollapsedUserTurn(last.textNode)) return false;
4191
4213
  return Boolean(last && userMessageText(last.textNode).includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
4192
4214
  })()`;
4193
4215
  // A dispatch can reach Chrome even if its acknowledgement is lost.
@@ -4232,6 +4254,7 @@ export async function sendChatGptPrompt(options) {
4232
4254
  const acceptDeadline = computePromptAcceptanceDeadline(timeoutMs, started);
4233
4255
  let accepted = false;
4234
4256
  let finalState;
4257
+ let expandingPolls = 0;
4235
4258
  while (Date.now() < acceptDeadline) {
4236
4259
  await sleep(500);
4237
4260
  try {
@@ -4254,6 +4277,10 @@ export async function sendChatGptPrompt(options) {
4254
4277
  accepted = true;
4255
4278
  break;
4256
4279
  }
4280
+ if (chatGptUserTurnStillExpanding(finalState, expandingPolls)) {
4281
+ expandingPolls += 1;
4282
+ continue;
4283
+ }
4257
4284
  if (hasChatGptPromptAcceptance(beforeSubmit, finalState))
4258
4285
  throw requestMismatch(normalizedTargetUrl);
4259
4286
  emitProgress("waiting", "prompt posting");
@@ -4295,6 +4322,7 @@ export async function sendChatGptPrompt(options) {
4295
4322
  : undefined;
4296
4323
  let consecutiveReadFailures = 0;
4297
4324
  let answerSettled = false;
4325
+ expandingPolls = 0;
4298
4326
  let nonTextPolls = 0;
4299
4327
  let nonTextSettled = false;
4300
4328
  const answerIsStable = createChatGptAnswerStabilityTracker();
@@ -4303,6 +4331,10 @@ export async function sendChatGptPrompt(options) {
4303
4331
  try {
4304
4332
  const observedState = await evaluateOnPage(page, answerExpression());
4305
4333
  consecutiveReadFailures = 0;
4334
+ if (chatGptUserTurnStillExpanding(observedState, expandingPolls)) {
4335
+ expandingPolls += 1;
4336
+ continue;
4337
+ }
4306
4338
  // Freeze the first conversation identity the accepted page exposes. A
4307
4339
  // later tab move must never rewrite result metadata to another thread.
4308
4340
  if (!pinnedConversationId) {
@@ -5905,16 +5937,15 @@ async function insertComposerTextViaCdp(cdp, text, page, toolLabels = []) {
5905
5937
  // Both failures come from crossing the CDP boundary mid-edit, so large text
5906
5938
  // is inserted by a single in-page execCommand instead: the editor applies it
5907
5939
  // as one input event and nothing can interleave. Measured: 67 KB inserted in
5908
- // ~20s, verified byte-for-byte.
5940
+ // ~20s, verified byte-for-byte; 57 KB took 63-126 s on 2026-10-13.
5909
5941
  if (text.length <= COMPOSER_INSERT_CHUNK_CHARS) {
5910
5942
  await cdp.send("Input.insertText", { text });
5911
5943
  }
5912
5944
  else {
5913
- // A 67 KB insert measured ~20s in the page, which the default 20s CDP
5914
- // command budget would cut off, so this one call gets its own connection
5915
- // with a size-scaled budget instead of loosening the budget for every
5916
- // command on the shared connection.
5917
- 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);
5918
5949
  const slowCdp = page ? await connectCdp(page.webSocketDebuggerUrl, budgetMs) : undefined;
5919
5950
  try {
5920
5951
  const target = slowCdp ?? cdp;
@@ -5934,6 +5965,16 @@ async function insertComposerTextViaCdp(cdp, text, page, toolLabels = []) {
5934
5965
  throw new Error(state.reason ?? "Composer stayed empty after text insertion");
5935
5966
  }
5936
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
+ }
5937
5978
  /**
5938
5979
  * Split composer text into insertText-sized pieces. Splits on code points (not
5939
5980
  * UTF-16 units) so a surrogate pair - an emoji, or any astral character - can
@@ -6061,6 +6102,15 @@ function chatGptRequestMarkerMatches(userText, requestId) {
6061
6102
  const markers = [...normalizeChatGptPromptText(userText).matchAll(/\[prodex-request:([a-f0-9]{32})\]/g)];
6062
6103
  return markers.at(-1)?.[1] === requestId && markers.filter((match) => match[1] === requestId).length === 1;
6063
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;
6064
6114
  /** Full prompt and per-send identity; wrappers may contain tool/file labels. */
6065
6115
  export function chatGptRequestMatchesUserTurn(userText, sentPrompt, requestId) {
6066
6116
  if (!chatGptRequestMarkerMatches(userText, requestId))
@@ -6370,10 +6420,11 @@ export function answerExpression() {
6370
6420
  if (tagged) modelSlug = tagged.getAttribute('data-message-model-slug') || undefined;
6371
6421
  }
6372
6422
  // User turns stay as typed: the request marker is matched against them.
6373
- return { role, node, text: role === "assistant" ? renderedMessageText(textNode) : userMessageText(textNode), modelSlug };
6423
+ return { role, node, textNode, text: role === "assistant" ? renderedMessageText(textNode) : userMessageText(textNode), modelSlug };
6374
6424
  });
6375
6425
  const assistantMessages = messages.filter((message) => message.role === "assistant");
6376
6426
  const userMessages = messages.filter((message) => message.role === "user");
6427
+ const lastUserCollapsed = expandCollapsedUserTurn(userMessages.at(-1)?.textNode);
6377
6428
  // A turn without an assistant message is NOT an answer. Treating one as an
6378
6429
  // answer (a 0.21.3 fallback for deep research, which is read from the
6379
6430
  // transcript now) turned a tool's progress panel into a 28-character
@@ -6407,6 +6458,7 @@ export function answerExpression() {
6407
6458
  assistantMessageCount: assistantMessages.length,
6408
6459
  userMessageCount: userMessages.length,
6409
6460
  lastUserText: userMessages.at(-1)?.text || "",
6461
+ lastUserCollapsed,
6410
6462
  // An image result is rendered outside every message unit and leaves no
6411
6463
  // assistant message at all (measured 2026-09-29), so it is reported here
6412
6464
  // for the waiter to recognize: a generated image that comes after the
package/docs/releasing.md CHANGED
@@ -16,6 +16,40 @@ 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
+
19
53
  ## Browser Container Rebuild 2026-10-10
20
54
 
21
55
  **Patches**
@@ -60,7 +94,7 @@ Release commit `e566efc` on `main` (cut after PR #22, merge `c0964f2`, plus the
60
94
  On the maintainer's WSL machine, cron runs the UI watchdog every hour (since 2026-10-08):
61
95
 
62
96
  ```
63
- 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
64
98
  ```
65
99
 
66
100
  **What each run does**
@@ -68,8 +102,10 @@ On the maintainer's WSL machine, cron runs the UI watchdog every hour (since 202
68
102
  - It records ChatGPT's build id and whether each control prodex relies on is present.
69
103
  - It compares with `~/.local/share/prodex/page-canary.json`.
70
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.
71
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.
72
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.
73
109
 
74
110
  **Where it runs from**
75
111
  - `~/.local/share/prodex-maint/repo` is a clone of `main`, built with `npm ci && npm run build`.
@@ -84,6 +120,7 @@ On the maintainer's WSL machine, cron runs the UI watchdog every hour (since 202
84
120
  - It timed out at 90 s on high efforts (issue #4).
85
121
  - It filed a busy browser as broken (issue #20, closed as a false positive).
86
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`.
87
124
 
88
125
  ## 0.40.29 Release Record
89
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.31",
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",