@youdie006/prodex 0.40.11 → 0.40.12

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
@@ -115,7 +115,7 @@ a configurable approval checkpoint (default 5, not a target round count); an
115
115
  See [same-task dialogue](docs/clients.md#same-task-dialogue) for the budget and
116
116
  `user_approved` contract. New topics still start fresh chats.
117
117
 
118
- Updating the installed npm package does not reload an MCP process that is already running. Reconnect the MCP server or restart the Codex/Claude client to load the new build. The dedicated browser profile is separate and remains signed in, so this does not require ChatGPT authentication again.
118
+ Updating the installed npm package does not reload an MCP process that is already running. Reconnect the MCP server or restart the Codex/Claude client to load the new build. This preserves the separate dedicated browser profile and does not itself require another login. ChatGPT can still expire the saved session; a `login_required` blocker means you need to sign in manually again.
119
119
 
120
120
  An MCP server usually starts without `--cwd`, so a per-repo default can be missed. For defaults that apply from any directory, set `PRODEX_DEFAULT_PROJECT`, `PRODEX_DEFAULT_MODEL`, `PRODEX_DEFAULT_EFFORT` or `PRODEX_DEFAULT_PRO_MODE` in the agent's MCP `env` block; a per-repo config still wins field by field.
121
121
 
@@ -245,6 +245,12 @@ Reports are deduplicated by blocker code, so something that stays broken adds to
245
245
 
246
246
  ## FAQ
247
247
 
248
+ **Do I need tmux?** No. Explicit CLI commands work in a normal terminal, and stdio MCP works through pipes without a terminal. Only the interactive picker and `setup --interactive` need keyboard input from a terminal. Keep the calling CLI or agent running while waiting for an answer: closing its terminal or disconnecting SSH can interrupt collection. tmux is an optional way to keep that foreground session alive, not a requirement. `prodex start` is also a foreground process, not an installed service.
249
+
250
+ **Can I close the terminal after login?** Yes, once login is READY: the dedicated Chrome is launched separately. Keep that browser running for consults. If a CLI or agent exits during a request, ChatGPT may still finish it; recover the original thread and request ID rather than automatically sending the question again.
251
+
252
+ **It stopped with `browser_tab_crashed` or `Runtime.enable`.** Chrome can leave an "Aw, Snap!" tab listed on its control port even though that tab's renderer has crashed. Before typing a new prompt, prodex can reload a confirmed crashed tab once at the same conversation address and records `browser_tab_recovered`. A timeout alone never authorizes a reload. A crash after a prompt was submitted stops without resending; inspect the original conversation, then recover with both `--target-url` and `--request-id` from the blocker. Other tabs, the browser profile, and saved login are left alone.
253
+
248
254
  **A send failed with `send_ui_changed`.** ChatGPT redesigned the composer or send control. Update (`npm i -g @youdie006/prodex@latest`); if it persists, `prodex pro report-issue`, and paste the prompt by hand meanwhile.
249
255
 
250
256
  **It stopped with `tab_not_visible`.** A tab counts as watchable only while its window is not minimized and it is the active tab. Leave the dedicated window behind your editor and it sends in the background; prodex never steals focus (`PRODEX_ACTIVATE_TAB=1` if you want the tab pulled forward on a stopped send).
@@ -4,6 +4,7 @@ import { accessSync, constants, statSync } from "node:fs";
4
4
  import net from "node:net";
5
5
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
+ import WsWebSocket from "ws";
7
8
  import { captureBrowserDiagnostics, diagnosticsEnabled, diagnosticsNote } from "./browser-diagnostics.js";
8
9
  import { withCrossProcessFileLock } from "./safe-file.js";
9
10
  import os from "node:os";
@@ -17,6 +18,22 @@ export class ChatGptBrowserBlockerError extends Error {
17
18
  this.blocker = blocker;
18
19
  }
19
20
  }
21
+ function crashedTabBlocker(thread, requestId) {
22
+ return {
23
+ code: "browser_tab_crashed",
24
+ message: "Chrome reported that this ChatGPT tab's renderer crashed. The browser itself may still be running.",
25
+ retryable: false,
26
+ next_step: requestId
27
+ ? `Do not resend automatically. Reload the crashed tab at the same address, then ${thread
28
+ ? `collect the original answer with \`prodex pro browser recover --target-url ${thread} --request-id ${requestId}\`.`
29
+ : `find [prodex-request:${requestId}] in the original conversation before recovering its answer; its thread URL was not captured.`}`
30
+ : "Before typing, prodex can reload a confirmed crashed tab once at the same address. If it crashes again, inspect Chrome's error screen; do not restart other tabs or resend an uncertain request.",
31
+ ...(thread ? { thread } : {})
32
+ };
33
+ }
34
+ function isCrashedTabError(error) {
35
+ return error instanceof ChatGptBrowserBlockerError && error.blocker.code === "browser_tab_crashed";
36
+ }
20
37
  function unsupportedChatGptOperationError(operation, nextStep) {
21
38
  return new ChatGptBrowserBlockerError({
22
39
  code: "unsupported_chatgpt_operation",
@@ -1410,9 +1427,18 @@ export async function getChatGptBrowserStatus(options = {}) {
1410
1427
  // `pro browser check --timeout-ms 5000` measured 65 seconds because every
1411
1428
  // evaluate silently used the default. Agents read that silence as a hung
1412
1429
  // bridge and start "recovering" a browser that is merely busy.
1413
- const state = await evaluateOnPage(page.page, statusExpression(), {
1414
- ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
1415
- });
1430
+ let state;
1431
+ try {
1432
+ state = await evaluateOnPage(page.page, statusExpression(), {
1433
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
1434
+ });
1435
+ }
1436
+ catch (error) {
1437
+ if (!isCrashedTabError(error))
1438
+ throw error;
1439
+ return { reachable: true, loggedInLikely: false, hasComposer: false, modelHints: [],
1440
+ url: page.page.url, blocker: crashedTabBlocker(page.page.url) };
1441
+ }
1416
1442
  const loggedInLikely = inferChatGptPageLoggedInLikely(state);
1417
1443
  // The busy verdict is checked against the transcript here too, so `check`
1418
1444
  // does not report a finished conversation as one still being written.
@@ -1862,6 +1888,47 @@ async function reloadPageAndAwaitComposer(page) {
1862
1888
  cdp.close();
1863
1889
  }
1864
1890
  }
1891
+ /** A crashed renderer cannot mark its document or enable Runtime before reload. */
1892
+ async function recoverCrashedPageBeforeSend(port, page) {
1893
+ const cdp = await connectCdp(page.webSocketDebuggerUrl, 2_000);
1894
+ try {
1895
+ if (!cdp.crashed())
1896
+ return { state: await readSettledChatGptPageStatus(page), reloaded: false };
1897
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(2_000) });
1898
+ if (!response.ok)
1899
+ throw new Error("Could not verify the crashed tab before reload");
1900
+ const pages = await response.json();
1901
+ const current = pages.find((candidate) => candidate.webSocketDebuggerUrl === page.webSocketDebuggerUrl && candidate.id === page.id);
1902
+ if (!current)
1903
+ throw new Error("The crashed ChatGPT tab closed before recovery; nothing was reloaded");
1904
+ assertChatGptTargetUrlMatches(current.url, page.url);
1905
+ if (!cdp.crashed())
1906
+ return { state: await readSettledChatGptPageStatus(page), reloaded: false };
1907
+ const reply = await cdp.send("Page.reload");
1908
+ if (reply.error?.message)
1909
+ throw new Error(`Page.reload failed: ${reply.error.message}`);
1910
+ const deadline = Date.now() + RELOAD_SETTLE_TIMEOUT_MS;
1911
+ while (Date.now() < deadline) {
1912
+ await sleep(250);
1913
+ try {
1914
+ const state = await cdp.evaluate(statusExpression());
1915
+ if (detectChatGptPageBlocker(state))
1916
+ return { state, reloaded: true };
1917
+ assertChatGptTargetUrlMatches(state.url, page.url);
1918
+ if (state.hasComposer)
1919
+ return { state, reloaded: true };
1920
+ }
1921
+ catch (error) {
1922
+ if (!/execution context|cannot find context|Runtime\.evaluate failed/i.test(error instanceof Error ? error.message : String(error)))
1923
+ throw error;
1924
+ }
1925
+ }
1926
+ throw new ChatGptBrowserBlockerError(crashedTabBlocker(page.url));
1927
+ }
1928
+ finally {
1929
+ cdp.close();
1930
+ }
1931
+ }
1865
1932
  // Every alternative is wording measured on the error page itself, anchored so
1866
1933
  // the whole body has to be made of them and nothing else. It may repeat one:
1867
1934
  // the heading and the button carry the same words.
@@ -3246,7 +3313,22 @@ export async function sendChatGptPrompt(options) {
3246
3313
  assertChatGptPageAvailable();
3247
3314
  }
3248
3315
  const page = pageResult.page;
3249
- let status = await readSettledChatGptPageStatus(page);
3316
+ const preflightWarnings = [];
3317
+ let status;
3318
+ try {
3319
+ status = await readSettledChatGptPageStatus(page);
3320
+ }
3321
+ catch (error) {
3322
+ if (!isCrashedTabError(error))
3323
+ throw error;
3324
+ emitProgress("waiting", "Chrome reported a crashed tab; checking same-tab recovery before typing");
3325
+ const recovered = await recoverCrashedPageBeforeSend(port, page);
3326
+ status = recovered.state;
3327
+ if (recovered.reloaded) {
3328
+ preflightWarnings.push("browser_tab_recovered: Chrome reported a crashed tab; reloaded the same conversation before typing. No previous prompt was resent.");
3329
+ emitProgress("waiting", "crashed tab reloaded at the same address; rechecking readiness");
3330
+ }
3331
+ }
3250
3332
  status = await ensureVisibleChatGptPage(port, page, status);
3251
3333
  const blocker = detectChatGptPageBlocker(status);
3252
3334
  if (blocker) {
@@ -3426,7 +3508,7 @@ export async function sendChatGptPrompt(options) {
3426
3508
  let beforeSubmit;
3427
3509
  let boundProjectId;
3428
3510
  let submitButtonFound = false;
3429
- const sendWarnings = [];
3511
+ const sendWarnings = [...preflightWarnings];
3430
3512
  // Anything the page put in front of prodex was answered on the caller's
3431
3513
  // behalf. The note is read at return time - there are several return paths -
3432
3514
  // so it is folded into the array rather than pushed from each of them.
@@ -3593,6 +3675,8 @@ export async function sendChatGptPrompt(options) {
3593
3675
  // looks the way it looked when it refused, and the selection failures this
3594
3676
  // project keeps hitting are invisible in the error text alone.
3595
3677
  await captureOnFailure(`send-${new Date().toISOString().replace(/[:.]/g, "-")}`);
3678
+ if (isCrashedTabError(error))
3679
+ throw attachSendWarnings(new ChatGptBrowserBlockerError(crashedTabBlocker(undefined, requestId)), sendWarnings);
3596
3680
  throw attachSendWarnings(error, sendWarnings);
3597
3681
  }
3598
3682
  finally {
@@ -3608,7 +3692,9 @@ export async function sendChatGptPrompt(options) {
3608
3692
  try {
3609
3693
  finalState = await evaluateOnPage(page, answerExpression());
3610
3694
  }
3611
- catch {
3695
+ catch (error) {
3696
+ if (isCrashedTabError(error))
3697
+ throw attachSendWarnings(new ChatGptBrowserBlockerError(crashedTabBlocker(undefined, requestId)), sendWarnings);
3612
3698
  // Transient CDP failure (command timeout, mid-poll navigation): retry the
3613
3699
  // poll rather than aborting the whole send.
3614
3700
  continue;
@@ -3704,6 +3790,8 @@ export async function sendChatGptPrompt(options) {
3704
3790
  finalState = observedState;
3705
3791
  }
3706
3792
  catch (error) {
3793
+ if (isCrashedTabError(error))
3794
+ throw attachSendWarnings(new ChatGptBrowserBlockerError(crashedTabBlocker(pinnedThreadUrl, requestId)), sendWarnings);
3707
3795
  if (error instanceof ChatGptBrowserBlockerError)
3708
3796
  throw error;
3709
3797
  // Transient CDP failure while the answer is streaming: retry. A throw here
@@ -3713,7 +3801,7 @@ export async function sendChatGptPrompt(options) {
3713
3801
  // sitting out the whole budget on it only delays the recovery.
3714
3802
  consecutiveReadFailures += 1;
3715
3803
  if (consecutiveReadFailures >= CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP) {
3716
- throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(pinnedThreadUrl ?? finalState?.url));
3804
+ throw new ChatGptBrowserBlockerError(browserLostMidWaitBlocker(pinnedThreadUrl ?? finalState?.url, requestId));
3717
3805
  }
3718
3806
  continue;
3719
3807
  }
@@ -4417,8 +4505,10 @@ async function getChatGptPageVisibility(pages) {
4417
4505
  try {
4418
4506
  visibilityByPage.set(page.webSocketDebuggerUrl, await evaluateOnPage(page, "document.visibilityState", { timeoutMs: PAGE_VISIBILITY_PROBE_TIMEOUT_MS }));
4419
4507
  }
4420
- catch {
4421
- // Leave visibility unknown; untargeted sends treat unknown ChatGPT pages conservatively.
4508
+ catch (error) {
4509
+ if (isCrashedTabError(error))
4510
+ visibilityByPage.set(page.webSocketDebuggerUrl, "crashed");
4511
+ // Other failures leave visibility unknown; untargeted sends treat them conservatively.
4422
4512
  }
4423
4513
  }));
4424
4514
  return visibilityByPage;
@@ -4463,9 +4553,11 @@ export function resolveCdpTimeoutMs(explicit) {
4463
4553
  }
4464
4554
  async function connectCdp(webSocketUrl, timeoutMs) {
4465
4555
  const effectiveTimeoutMs = resolveCdpTimeoutMs(timeoutMs);
4466
- const ws = new WebSocket(webSocketUrl);
4556
+ const WebSocketConstructor = globalThis.WebSocket ?? WsWebSocket;
4557
+ const ws = new WebSocketConstructor(webSocketUrl);
4467
4558
  let id = 0;
4468
4559
  const pending = new Map();
4560
+ let crashError;
4469
4561
  /** Types of JavaScript dialog answered on this connection. */
4470
4562
  const dialogsAnswered = [];
4471
4563
  ws.addEventListener("message", (event) => {
@@ -4480,6 +4572,23 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4480
4572
  // the per-command timeout fires.
4481
4573
  return;
4482
4574
  }
4575
+ const method = message.method;
4576
+ if (method === "Inspector.targetCrashed") {
4577
+ crashError = new ChatGptBrowserBlockerError(crashedTabBlocker());
4578
+ for (const [messageId, waiter] of pending) {
4579
+ if (waiter.method === "Inspector.enable" || waiter.method === "Page.reload")
4580
+ continue;
4581
+ if (waiter.timer)
4582
+ clearTimeout(waiter.timer);
4583
+ pending.delete(messageId);
4584
+ waiter.reject(crashError);
4585
+ }
4586
+ return;
4587
+ }
4588
+ if (method === "Inspector.targetReloadedAfterCrash") {
4589
+ crashError = undefined;
4590
+ return;
4591
+ }
4483
4592
  if (message.id && pending.has(message.id)) {
4484
4593
  const waiter = pending.get(message.id);
4485
4594
  if (waiter.timer)
@@ -4505,7 +4614,7 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4505
4614
  for (const [messageId, waiter] of pending) {
4506
4615
  if (waiter.timer)
4507
4616
  clearTimeout(waiter.timer);
4508
- waiter.reject(new Error("Chrome DevTools websocket closed"));
4617
+ waiter.reject(crashError ?? new Error("Chrome DevTools websocket closed"));
4509
4618
  pending.delete(messageId);
4510
4619
  }
4511
4620
  });
@@ -4544,13 +4653,15 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4544
4653
  /** The timeout that closed this socket, so later commands can name it rather than a bare closed socket. */
4545
4654
  let closedByTimeout;
4546
4655
  const send = (method, params = {}) => {
4656
+ if (crashError && method !== "Inspector.enable" && method !== "Page.reload")
4657
+ return Promise.reject(crashError);
4547
4658
  // Once a timeout has closed the socket, every later command would sit out
4548
4659
  // its own full timeout for an answer that cannot come. Measured cost of not
4549
4660
  // checking: a 14-probe confirmation loop turning into minutes of silence.
4550
4661
  // The rejection carries the timeout that did the closing: a caller that
4551
4662
  // swallowed that first error and moved on would otherwise report "socket
4552
4663
  // not open", which names neither the stalled tab nor the dialog cure.
4553
- if (ws.readyState !== WebSocket.OPEN) {
4664
+ if (ws.readyState !== WebSocketConstructor.OPEN) {
4554
4665
  return Promise.reject(new Error(closedByTimeout
4555
4666
  ? `${closedByTimeout} (the connection was closed by that timeout before ${method})`
4556
4667
  : `Chrome DevTools websocket is not open (${method})`));
@@ -4563,7 +4674,7 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4563
4674
  ws.close();
4564
4675
  reject(new Error(closedByTimeout));
4565
4676
  }, Math.max(1, effectiveTimeoutMs));
4566
- pending.set(messageId, { resolve, reject, timer });
4677
+ pending.set(messageId, { method, resolve, reject, timer });
4567
4678
  ws.send(JSON.stringify({ id: messageId, method, params }));
4568
4679
  });
4569
4680
  };
@@ -4576,6 +4687,15 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4576
4687
  // caller's diagnosis from "command timed out: Runtime.enable" - which at
4577
4688
  // least names what was being attempted - into a bare "websocket closed".
4578
4689
  // Nothing waits on the arming, so a build without the domain is no worse off.
4690
+ // Inspector is browser-side and replays an existing renderer crash. Runtime
4691
+ // and DOM commands cannot diagnose that state because the renderer is gone.
4692
+ try {
4693
+ await send("Inspector.enable");
4694
+ }
4695
+ catch (error) {
4696
+ ws.close();
4697
+ throw error;
4698
+ }
4579
4699
  ws.send(JSON.stringify({ id: ++id, method: "Page.enable", params: {} }));
4580
4700
  const evaluate = async (expression) => {
4581
4701
  const response = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
@@ -4585,7 +4705,7 @@ async function connectCdp(webSocketUrl, timeoutMs) {
4585
4705
  throw new Error("Runtime.evaluate failed");
4586
4706
  return response.result?.result?.value;
4587
4707
  };
4588
- return { send, evaluate, dialogsAnswered, close: () => ws.close() };
4708
+ return { send, evaluate, dialogsAnswered, crashed: () => crashError !== undefined, close: () => ws.close() };
4589
4709
  }
4590
4710
  // In-page reasoning-header placeholder test, shared by statusExpression and
4591
4711
  // answerExpression. MUST stay in sync with isUsableChatGptAnswer: a header
@@ -5154,7 +5274,7 @@ export function chunkComposerText(text, size = COMPOSER_INSERT_CHUNK_CHARS) {
5154
5274
  // Reading the page can fail for a moment (a navigation, a busy renderer). Five
5155
5275
  // failures in a row is not a moment - it is a browser that went away.
5156
5276
  const CONSECUTIVE_READ_FAILURES_BEFORE_GIVING_UP = 5;
5157
- export function browserLostMidWaitBlocker(threadUrl) {
5277
+ export function browserLostMidWaitBlocker(threadUrl, requestId) {
5158
5278
  // Killing the browser aborts a streaming answer too, so promise nothing
5159
5279
  // about the answer itself - only say where to look. Deep research is the one
5160
5280
  // case that genuinely keeps going without us.
@@ -5165,8 +5285,8 @@ export function browserLostMidWaitBlocker(threadUrl) {
5165
5285
  // Retrying the send would duplicate a prompt that has already posted.
5166
5286
  retryable: false,
5167
5287
  next_step: threadUrl
5168
- ? `Run \`prodex pro browser login\` to reopen the browser, then collect the answer with \`prodex pro browser recover --target-url ${threadUrl}\` (MCP: pro_recover with thread ${threadUrl}).`
5169
- : "Run `prodex pro browser login` to reopen the browser, then inspect the original chat before asking again. The prompt was already submitted; prodex did not capture its thread URL.",
5288
+ ? `Run \`prodex pro browser login\` to reopen the browser, then collect the answer with \`prodex pro browser recover --target-url ${threadUrl}${requestId ? ` --request-id ${requestId}` : ""}\` (MCP: pro_recover with thread ${threadUrl}${requestId ? ` and request_id ${requestId}` : ""}).`
5289
+ : `Run \`prodex pro browser login\` to reopen the browser, then inspect the original chat before asking again. The prompt was already submitted; prodex did not capture its thread URL.${requestId ? ` Find [prodex-request:${requestId}] in that conversation before recovering its answer; do not resend automatically.` : ""}`,
5170
5290
  ...(threadUrl ? { thread: threadUrl } : {})
5171
5291
  };
5172
5292
  }
package/dist/cli-pro.js CHANGED
@@ -1219,20 +1219,27 @@ export async function runAskProCommand(rest, io, beforeSend) {
1219
1219
  // the name matching to do what it can; the send would fail on the same
1220
1220
  // browser anyway.
1221
1221
  let continuationProjectId;
1222
+ let ambiguousProjectName = false;
1222
1223
  if (continuationProject) {
1223
1224
  try {
1224
- continuationProjectId = projectIdFromSidebar(await listChatGptProjectsWithIds({ port: resolveCdpPort(readPortFlag(parsedAskPro.optionArgs, "--port")) }), continuationProject);
1225
+ const projects = await listChatGptProjectsWithIds({ port: resolveCdpPort(readPortFlag(parsedAskPro.optionArgs, "--port")) });
1226
+ continuationProjectId = projectIdFromSidebar(projects, continuationProject);
1227
+ ambiguousProjectName = !continuationProjectId && projects.some((project) => project.name.trim().toLowerCase() === continuationProject.trim().toLowerCase());
1225
1228
  }
1226
1229
  catch {
1227
1230
  continuationProjectId = undefined;
1228
1231
  }
1229
1232
  }
1233
+ if (ambiguousProjectName && continueTaskId === undefined) {
1234
+ throw new Error(`Project name "${continuationProject}" is ambiguous in the sidebar. Name the intended conversation with --continue-task <task_id>.`);
1235
+ }
1230
1236
  const resolved = resolveContinuationThread({
1231
1237
  consults: (await targetStore.listSessionsReadOnly()).map((session) => ({
1232
1238
  taskId: session.task_id ?? "",
1233
1239
  ...(session.session_key ? { sessionKey: session.session_key } : {}),
1234
1240
  ...(session.thread ? { thread: session.thread } : {}),
1235
1241
  status: session.status,
1242
+ warnings: session.warnings,
1236
1243
  ...(session.created_at ? { createdAt: session.created_at } : {})
1237
1244
  })),
1238
1245
  ...(continuationProject ? { project: continuationProject } : {}),
@@ -1569,6 +1576,9 @@ export async function runAskProCommand(rest, io, beforeSend) {
1569
1576
  });
1570
1577
  if (destination.warning)
1571
1578
  persistenceWarnings.push(destination.warning);
1579
+ if (consult.requestVerified === false && !persistenceWarnings.some((warning) => warning.startsWith("request_unverified:"))) {
1580
+ persistenceWarnings.push("request_unverified: the saved answer was not verified against its requested user turn. Review the original conversation before continuing.");
1581
+ }
1572
1582
  // Truncation and other send warnings must be visible at runtime, not
1573
1583
  // only inside the persisted receipt: a caller who never opens .bridge
1574
1584
  // would otherwise treat a cut-off answer as complete.
@@ -2536,8 +2546,9 @@ export function browserSendBlockerFromError(error) {
2536
2546
  code: "browser_cdp_timeout",
2537
2547
  message,
2538
2548
  retryable: true,
2539
- next_step: "The ChatGPT tab stopped responding. A very long thread does it, and so does an open JavaScript dialog - that one halts the page outright, and no retry gets past it because the browser will not let a late client dismiss it. " +
2540
- "Look at the visible window and close any dialog sitting on it, or reopen the window with `prodex pro browser login`. For a heavy thread, retry with `--new-chat` for a fresh, light one."
2549
+ next_step: "The ChatGPT tab stopped responding; a timeout alone does not prove it crashed. A long thread or an open JavaScript dialog can also stall it. " +
2550
+ "Inspect the visible tab: if Chrome shows 'Aw, Snap!', reload only that tab at the same address. Close an ordinary dialog manually; login, verification, and permission prompts require your action. " +
2551
+ "If the browser is gone, reopen it with `prodex pro browser login`. Do not resend automatically when the prompt may already have posted; recover its original thread and request ID. Use `--new-chat` only for a new request after the original is accounted for."
2541
2552
  };
2542
2553
  }
2543
2554
  return {
@@ -72,6 +72,9 @@ export function projectIdsByName(threadUrls) {
72
72
  }
73
73
  return byName;
74
74
  }
75
+ function projectIdFromThreadUrl(threadUrl) {
76
+ return /\/g\/g-p-([0-9a-f]+)(?:-|\/)/i.exec(threadUrl)?.[1]?.toLowerCase();
77
+ }
75
78
  /**
76
79
  * Whether a recorded thread belongs to the project this send is for.
77
80
  *
@@ -153,13 +156,22 @@ export function resolveContinuationThread(input) {
153
156
  "Pass --session-key <id> (or PRODEX_SESSION_KEY/CODEX_THREAD_ID), or name the intended consult with --continue-task <task_id>."
154
157
  };
155
158
  }
156
- const knownProjectIds = new Set(input.project ? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project)) ?? [] : []);
157
- if (input.projectId)
158
- knownProjectIds.add(input.projectId.toLowerCase());
159
+ const recordedProjectIds = new Set(input.project ? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project)) ?? [] : []);
160
+ if (input.project && !input.projectId && recordedProjectIds.size > 1) {
161
+ return {
162
+ error: `Recorded project name "${input.project}" is ambiguous because its consult threads use multiple project ids. ` +
163
+ "Name the intended conversation with --continue-task <task_id>."
164
+ };
165
+ }
166
+ const selectedProjectId = input.project
167
+ ? input.projectId?.replace(/^g-p-/i, "").toLowerCase() ?? recordedProjectIds.values().next().value
168
+ : undefined;
159
169
  const candidates = withThread
160
170
  .filter((consult) => consult.sessionKey === input.sessionKey)
161
171
  .filter((consult) => consult.status === "done")
162
- .filter((consult) => threadMatchesProject(consult.thread, input.project, knownProjectIds))
172
+ .filter((consult) => selectedProjectId
173
+ ? projectIdFromThreadUrl(consult.thread) === selectedProjectId
174
+ : threadMatchesProject(consult.thread, input.project))
163
175
  .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
164
176
  const latest = candidates[0];
165
177
  if (!latest) {
@@ -169,5 +181,18 @@ export function resolveContinuationThread(input) {
169
181
  `Send once without --continue, or name a consult with --continue-task <task_id>.`
170
182
  };
171
183
  }
184
+ const unreliableWarningPrefixes = [
185
+ "answer_incomplete:",
186
+ "request_unverified:",
187
+ "receipt_record_warning:",
188
+ "session_record_warning:"
189
+ ];
190
+ const unreliableWarning = latest.warnings?.find((warning) => unreliableWarningPrefixes.some((prefix) => warning.startsWith(prefix)));
191
+ if (unreliableWarning) {
192
+ return {
193
+ error: `Cannot implicitly continue consult "${latest.taskId}" because its record is unreliable: ${unreliableWarning} ` +
194
+ `Recover and verify the original answer first. After explicit user review, name it with --continue-task ${latest.taskId}. Do not resend automatically.`
195
+ };
196
+ }
172
197
  return { target: { taskId: latest.taskId, thread: latest.thread } };
173
198
  }
@@ -49,9 +49,7 @@ export function buildIssueReport(consult, environment) {
49
49
  `| platform | ${environment.platform} |`,
50
50
  `| node | ${environment.nodeVersion} |`,
51
51
  "",
52
- "Private error details and recovery instructions are omitted. Review the local receipt before sharing more context.",
53
- "",
54
- "Receipt (local, not attached): " + consult.task_id
52
+ "Private error details and recovery instructions are omitted. Review the local receipt before sharing more context."
55
53
  ].join("\n");
56
54
  return {
57
55
  title: `${code}: ${message || "blocked consult"}`.slice(0, 120),
package/docs/clients.md CHANGED
@@ -74,9 +74,17 @@ already-resolved target). An answer that failed to save or is incomplete does no
74
74
  invite another automatic turn; report it and resolve the blocker first.
75
75
 
76
76
  After updating the installed package, reconnect the MCP server or restart the agent
77
- client. A running stdio process keeps the old code until it exits. The dedicated
78
- browser profile is unchanged, so restarting Codex/Claude does not require signing in
79
- to ChatGPT again.
77
+ client. A running stdio process keeps the old code until it exits. This preserves the
78
+ dedicated browser profile and does not itself require signing in again. A saved
79
+ ChatGPT session can still expire independently; stop on `login_required` and finish
80
+ the login manually.
81
+
82
+ Stdio MCP does not require tmux or a terminal. The client launches prodex and keeps
83
+ its stdin/stdout pipes open. Keep that client or its remote SSH session running
84
+ while a consult is pending; restarting it can interrupt answer collection even if
85
+ ChatGPT is still generating. Recover that marked request instead of resending it.
86
+ The separate `prodex start` HTTP server runs in the foreground and also needs its
87
+ own process kept alive; neither command installs a background service.
80
88
 
81
89
  ## Claude Code
82
90
 
package/docs/releasing.md CHANGED
@@ -15,7 +15,7 @@ git tag v0.8.2
15
15
  git push origin v0.8.2
16
16
  ```
17
17
 
18
- `.github/workflows/publish.yml` fires on a `v*.*.*` tag: it checks out, installs, verifies the tag equals `package.json`'s version, runs `release:verify`, and publishes with `npm publish --provenance --access public`. The tag/version guard prevents publishing a mismatched version.
18
+ `.github/workflows/publish.yml` fires on a `v*.*.*` tag: it checks out, installs, verifies the tag equals `package.json`'s version, builds, runs `release:check -- --metadata-only` and `release:verify`, then publishes with `npm publish --provenance --access public --ignore-scripts`. The explicit metadata check also runs for manual workflow dispatches. It is required because `--ignore-scripts` skips `prepublishOnly`; a separate main-branch CI run is not a substitute for checking the commit being published.
19
19
 
20
20
  One-time setup (owner, on npmjs.com): open the package → Settings → Trusted Publishing → add a GitHub Actions publisher for repo `youdie006/prodex` and workflow `publish.yml`. After that, no npm tokens are needed anywhere; revoke any previously issued automation tokens.
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.11",
3
+ "version": "0.40.12",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",
@@ -59,12 +59,14 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@modelcontextprotocol/sdk": "^1.13.3",
62
+ "ws": "^8.21.3",
62
63
  "zod": "^3.25.67"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@types/node": "^22.15.32",
67
+ "@types/ws": "^8.18.1",
66
68
  "tsx": "^4.20.3",
67
69
  "typescript": "^5.8.3",
68
- "vitest": "^4.1.9"
70
+ "vitest": "^4.1.11"
69
71
  }
70
72
  }