@youdie006/prodex 0.40.8 → 0.40.10

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
@@ -71,13 +71,14 @@ prodex pro latest # re-print the last answer
71
71
 
72
72
  While Pro thinks, progress goes to stderr: connecting, prompt sent, elapsed time while generating. A Pro selection raises the send budget to twenty minutes on its own; `--timeout-ms` overrides it. Answers are read from the rendered page, so formatting can differ from the original message. If the dedicated browser is not running, an interactive `ask` starts it, waits for your saved session, and retries once (`--no-auto-login` turns that off; scripts opt in with `--auto-login`).
73
73
 
74
- If the browser stops responding after your question was sent, prodex stops without sending it again. Reopen it with `prodex pro browser login`, then use `prodex pro browser recover --target-url <thread-url>` with the conversation URL in the error (MCP: `pro_recover`). If no URL was captured, inspect the original chat before asking again.
74
+ If the browser stops responding after your question was sent, prodex stops without sending it again. Use the `thread` and `request_id` from the error with `prodex pro browser recover --target-url <thread-url> --request-id <32hex>` (MCP: `pro_recover`). The request ID verifies that the recovered assistant answer follows that exact marked user turn. Legacy recovery without it remains available but returns `request_verified: false` and a warning.
75
75
 
76
76
  Useful flags on every send:
77
77
 
78
78
  | Flag | What it does |
79
79
  |---|---|
80
- | `--new-chat` | Send into a fresh chat. Recommended for repeated consults; very long threads eventually confuse send detection. |
80
+ | `--new-chat` | Explicitly request the default: ordinary consults start in a fresh chat. The shared current tab is never an implicit destination. |
81
+ | `--session-key id` | Identify one caller for scoped `--continue`. Falls back to `PRODEX_SESSION_KEY`, then `CODEX_THREAD_ID`. |
81
82
  | `--file path` | Inline a text file's contents into the prompt. Repeatable. |
82
83
  | `--attach path` | Upload the file itself: the only way to hand ChatGPT a pdf, pptx, xlsx or image. Paths must live inside the repo. |
83
84
  | `--tool web-search` | Select a rendered composer tool. Automatic deep-research report retrieval is currently unsupported and is blocked before sending. |
@@ -104,7 +105,9 @@ prints a token-free config that points Claude at `prodex mcp --cwd /absolute/pat
104
105
  { "mcpServers": { "prodex": { "command": "prodex", "args": ["mcp", "--cwd", "/absolute/path/to/your/repo"] } } }
105
106
  ```
106
107
 
107
- The server exposes `pro_consult` (a visible-browser send, with the same model, effort, project and tool choices as the CLI), `pro_recover` (fetch an answer that finished after a timeout), the bridge ledger tools (`bridge_create_task`, `bridge_list_tasks`, `bridge_fetch_result`, receipts, sessions), bounded `repo_read_file` and `repo_search`, and a receipt-gated write path: `repo_write_file_dry_run` first, `repo_write_file_apply` only while git HEAD and the file's preimage hash still match, `repo_stage_reviewed_paths` for applied receipts only. No shell tool, no ungated write. `prodex claude prompt` prints a paste-ready prompt that verifies the wiring. [docs/claude.md](docs/claude.md) covers Claude Desktop and Claude Code; [docs/clients.md](docs/clients.md) covers the others, including the per-call approval and `tool_timeout_sec` Codex needs.
108
+ The server exposes `pro_consult` (a visible-browser send, with the same model, effort, project and tool choices as the CLI), `pro_recover` (fetch an answer that finished after a timeout), the bridge ledger tools (`bridge_create_task`, `bridge_list_tasks`, `bridge_fetch_result`, receipts, sessions), bounded `repo_read_file` and `repo_search`, and a receipt-gated write path: `repo_write_file_dry_run` first, `repo_write_file_apply` only while git HEAD and the file's preimage hash still match, `repo_stage_reviewed_paths` for applied receipts only. Each stdio MCP connection receives one default session key, ordinary consults start fresh, and `continue_thread` only searches that key and project. Logical agents sharing one MCP connection should pass distinct explicit `session_key` values and preserve them for follow-ups; an explicit key also preserves continuity across an MCP restart. No shell tool, no ungated write. `prodex claude prompt` prints a paste-ready prompt that verifies the wiring. [docs/claude.md](docs/claude.md) covers Claude Desktop and Claude Code; [docs/clients.md](docs/clients.md) covers the others, including the per-call approval and `tool_timeout_sec` Codex needs.
109
+
110
+ 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.
108
111
 
109
112
  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.
110
113
 
@@ -240,9 +243,9 @@ Reports are deduplicated by blocker code, so something that stays broken adds to
240
243
 
241
244
  **Why the pause before sending?** Pacing: `send_pacing: waiting Ns` on stderr. `PRODEX_MIN_SEND_INTERVAL_MS=0` disables it.
242
245
 
243
- **The answer timed out.** Pro can take many minutes; incomplete send results are marked rather than presented as finished. If the thread finished after the timeout, `prodex pro browser recover --target-url <thread>` reads its rendered answer without sending again. Deep-research widget reports are not supported by this recovery path.
246
+ **The answer timed out.** Pro can take many minutes. Do not resend automatically. Recover the original marked turn with `prodex pro browser recover --target-url <thread> --request-id <request_id>` after it finishes.
244
247
 
245
- **Sends started failing after many consults in one chat.** Long threads confuse prompt-acceptance detection. Use `--new-chat` (`new_chat: true` on the MCP tool).
248
+ **A consult returned an unrelated answer.** Current sends append a unique visible request marker and accept only the assistant turn following that marker. Ordinary calls also start fresh. A `request_mismatch` blocker is not retryable; inspect the named request instead of treating the returned page's last answer as the consult.
246
249
 
247
250
  **Every send says "still generating" and nothing is being written.** ChatGPT parked the thread on "which response do you prefer?". prodex reports `response_choice_pending` and names the buttons; pick one, or send with `--new-chat`.
248
251
 
@@ -714,19 +714,43 @@ export function isFreshChatGptPage(state) {
714
714
  const onRoot = /^https:\/\/chatgpt\.com\/?(?:[?#].*)?$/.test(state.url);
715
715
  return onRoot && state.assistantMessageCount === 0 && state.userMessageCount === 0;
716
716
  }
717
+ /** Check and navigate in one renderer task, so a late stop control prevents the move. */
718
+ export function idleChatGptNavigationExpression(url) {
719
+ return `(() => {
720
+ const status = ${statusExpression()};
721
+ if (status.generating && !status.awaitingResponseChoice) return false;
722
+ location.assign(${JSON.stringify(url)});
723
+ return true;
724
+ })()`;
725
+ }
726
+ async function navigateIdleChatGptPage(page, url) {
727
+ if (await evaluateOnPage(page, idleChatGptNavigationExpression(url)) !== true) {
728
+ throw new ChatGptBrowserBlockerError({
729
+ code: "response_in_progress",
730
+ message: "The shared ChatGPT tab became busy before navigation. Nothing was sent or moved.",
731
+ retryable: true,
732
+ next_step: "Wait for the current response to finish before retrying this operation."
733
+ });
734
+ }
735
+ }
717
736
  /**
718
737
  * Poll until the tab settles on a fresh empty chat (or the timeout elapses).
719
738
  * Deterministically replaces a fixed post-navigation sleep so a slow SPA
720
- * navigation cannot leave the old thread's state in place. Best-effort: on
721
- * timeout it returns and the caller proceeds (the acceptance logic still
722
- * guards), but the poll removes the common race.
739
+ * navigation cannot leave the old thread's state in place. A false result
740
+ * must not be used as permission to send into stale content.
723
741
  */
724
742
  async function waitForFreshChatGptPage(page, timeoutMs) {
725
743
  const deadline = Date.now() + timeoutMs;
726
744
  while (Date.now() < deadline) {
727
- const state = await evaluateOnPage(page, answerExpression());
728
- if (isFreshChatGptPage(state))
729
- return true;
745
+ try {
746
+ const state = await evaluateOnPage(page, answerExpression());
747
+ if (isFreshChatGptPage(state))
748
+ return true;
749
+ }
750
+ catch (error) {
751
+ if (!/execution context|cannot find context|Runtime\.evaluate failed/i.test(String(error)))
752
+ throw error;
753
+ }
730
754
  await sleep(300);
731
755
  }
732
756
  return false;
@@ -2558,7 +2582,12 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
2558
2582
  // GPT-5.6 Sol) instead of a model radio list, so "Pro" is the top EFFORT.
2559
2583
  // Drive it when it is there and fall through to the legacy radio path when
2560
2584
  // it is not, so both UI generations work.
2561
- const sliderState = await cdp.evaluate(powerSliderStateExpression());
2585
+ let sliderState = await cdp.evaluate(powerSliderStateExpression());
2586
+ if (!sliderState?.ok && await waitForExpressionTrue(cdp, powerSliderPresentExpression(), MENU_OPEN_TIMEOUT_MS)) {
2587
+ // The container and model rows can paint before the effort control.
2588
+ // Do not toggle an already-open menu or guess that this is the old UI.
2589
+ sliderState = await cdp.evaluate(powerSliderStateExpression());
2590
+ }
2562
2591
  if (sliderState?.ok) {
2563
2592
  // The slider is the EFFORT control and the models are radios beside it.
2564
2593
  // Sending a model name into the slider made it walk every step looking
@@ -3037,6 +3066,9 @@ async function selectProject(cdp, options) {
3037
3066
  // sits in the thread the operator can see. Navigates the visible tab to the
3038
3067
  // thread and waits for a stable, non-generating answer.
3039
3068
  export async function recoverChatGptAnswerFromThread(options) {
3069
+ if (options.requestId !== undefined && !/^[a-f0-9]{32}$/.test(options.requestId)) {
3070
+ throw new Error("requestId must be the 32-character prodex request identifier.");
3071
+ }
3040
3072
  const port = resolveCdpPort(options.port);
3041
3073
  const timeoutMs = Math.max(1_000, options.timeoutMs ?? 60_000);
3042
3074
  const url = normalizeChatGptTargetUrl(options.targetUrl);
@@ -3049,6 +3081,14 @@ export async function recoverChatGptAnswerFromThread(options) {
3049
3081
  next_step: "Run `prodex pro browser login` to reopen the dedicated window - it reuses the saved session (no manual login unless it expired) and returns immediately when run non-interactively - then retry."
3050
3082
  });
3051
3083
  }
3084
+ const currentStatus = await readSettledChatGptPageStatus(page.page);
3085
+ const currentBlocker = detectChatGptPageBlocker(currentStatus);
3086
+ if (currentBlocker)
3087
+ throw new ChatGptBrowserBlockerError(currentBlocker);
3088
+ const alreadyOnTarget = chatGptUrlsReferToSameTarget(currentStatus.url, url);
3089
+ const currentBusy = chatGptBusyBlocker(currentStatus);
3090
+ if (!alreadyOnTarget && currentBusy)
3091
+ throw new ChatGptBrowserBlockerError(currentBusy);
3052
3092
  const cdp = await connectCdp(page.page.webSocketDebuggerUrl);
3053
3093
  let state;
3054
3094
  let generating = false;
@@ -3060,7 +3100,8 @@ export async function recoverChatGptAnswerFromThread(options) {
3060
3100
  await cdp.send("Runtime.enable");
3061
3101
  // In-tab navigation (location.assign, not Page.navigate which has crashed the
3062
3102
  // instance) so we read the requested thread, not whatever was open.
3063
- await cdp.evaluate(`location.assign(${JSON.stringify(url)})`);
3103
+ if (!alreadyOnTarget)
3104
+ await navigateIdleChatGptPage(page.page, url);
3064
3105
  const deadline = Date.now() + timeoutMs;
3065
3106
  while (Date.now() < deadline) {
3066
3107
  await sleep(500);
@@ -3084,6 +3125,15 @@ export async function recoverChatGptAnswerFromThread(options) {
3084
3125
  lastAnswer = "";
3085
3126
  continue;
3086
3127
  }
3128
+ if (options.requestId && !chatGptRequestMarkerMatches(state.lastUserText ?? "", options.requestId)) {
3129
+ throw new ChatGptBrowserBlockerError({
3130
+ code: "request_mismatch",
3131
+ message: "The recovered conversation's latest user turn does not match the requested prodex request. No answer was returned.",
3132
+ retryable: false,
3133
+ next_step: "Inspect the original request in the browser. Do not treat a later turn in the same conversation as its answer.",
3134
+ thread: url
3135
+ });
3136
+ }
3087
3137
  // Require a REAL assistant message, not answerExpression's page-chrome
3088
3138
  // fallback (empty assistant returns sidebar/nav text): the thread's
3089
3139
  // conversation loads asynchronously after navigation, so keep polling.
@@ -3139,7 +3189,9 @@ export async function recoverChatGptAnswerFromThread(options) {
3139
3189
  answer: state.answer.trim(),
3140
3190
  modelHints: state.modelHints,
3141
3191
  ...(state.modelSlug ? { modelSlug: state.modelSlug } : {}),
3142
- warnings: []
3192
+ ...(options.requestId ? { requestId: options.requestId } : {}),
3193
+ requestVerified: options.requestId !== undefined,
3194
+ warnings: options.requestId ? [] : ["request_unverified: recovered the latest answer in the named conversation without a request ID. Verify the preceding question before using this as a review."]
3143
3195
  };
3144
3196
  }
3145
3197
  export async function sendChatGptPrompt(options) {
@@ -3149,6 +3201,16 @@ export async function sendChatGptPrompt(options) {
3149
3201
  }
3150
3202
  const port = resolveCdpPort(options.port);
3151
3203
  const timeoutMs = options.timeoutMs ?? 90_000;
3204
+ const requestId = randomBytes(16).toString("hex");
3205
+ const sentPrompt = `${options.prompt}\n\n[prodex-request:${requestId}]`;
3206
+ const requestMatches = (state) => chatGptRequestMatchesUserTurn(state.lastUserText ?? "", sentPrompt, requestId);
3207
+ const requestMismatch = (thread) => new ChatGptBrowserBlockerError({
3208
+ code: "request_mismatch",
3209
+ message: "The visible user turn does not match this prodex request. No answer was returned because it may belong to another session.",
3210
+ retryable: false,
3211
+ next_step: `Do not resend automatically. Inspect the original chat for [prodex-request:${requestId}] before recovering its answer.`,
3212
+ ...(thread ? { thread } : {})
3213
+ });
3152
3214
  /** Dialogs answered on the reload connection, which comes and goes before the send's own, so the receipt still says so. */
3153
3215
  const earlyDialogsAnswered = [];
3154
3216
  const sendStartedAt = Date.now();
@@ -3184,27 +3246,6 @@ export async function sendChatGptPrompt(options) {
3184
3246
  assertChatGptPageAvailable();
3185
3247
  }
3186
3248
  const page = pageResult.page;
3187
- if (options.newChat && !options.project && !options.projectNew) {
3188
- // Long accumulated threads eventually break acceptance detection, so
3189
- // start from a clean chat. Wait for the tab to actually reach the fresh
3190
- // empty chat (root URL, zero messages) rather than a fixed sleep: a slow
3191
- // SPA navigation could otherwise leave the old thread rendered, poisoning
3192
- // the answer-count baseline captured below and causing a false timeout.
3193
- //
3194
- // Skipped when a project is requested: the project home the selection step
3195
- // navigates to IS the fresh composer for "a new chat in this project".
3196
- // Navigating to the root new chat first leaves the SPA composer bound to
3197
- // the ROOT conversation target even after entering the project, so the
3198
- // thread silently lands outside the project (measured live: --new-chat
3199
- // --project threads appeared in the root chat list, --project-only
3200
- // threads appeared inside the project).
3201
- // A temporary chat is reached by url rather than by clicking the control:
3202
- // the same navigation this already does, one query parameter different, and
3203
- // nothing to find on a page whose buttons keep moving.
3204
- const freshUrl = options.temporary ? "https://chatgpt.com/?temporary-chat=true" : "https://chatgpt.com/";
3205
- await evaluateOnPage(page, `location.assign(${JSON.stringify(freshUrl)})`);
3206
- await waitForFreshChatGptPage(page, 8_000);
3207
- }
3208
3249
  let status = await readSettledChatGptPageStatus(page);
3209
3250
  status = await ensureVisibleChatGptPage(port, page, status);
3210
3251
  const blocker = detectChatGptPageBlocker(status);
@@ -3245,6 +3286,8 @@ export async function sendChatGptPrompt(options) {
3245
3286
  busyBlocker = chatGptBusyBlocker(status);
3246
3287
  }
3247
3288
  }
3289
+ if (busyBlocker)
3290
+ throw new ChatGptBrowserBlockerError(busyBlocker);
3248
3291
  // ChatGPT's error page does not come back on a reload - measured: a project
3249
3292
  // home that failed reloaded straight back into it - and the tab then stays
3250
3293
  // there for every later send, including the retry its own blocker asks for.
@@ -3334,6 +3377,26 @@ export async function sendChatGptPrompt(options) {
3334
3377
  throw new ChatGptBrowserBlockerError(chatGptResponseChoiceBlocker(true));
3335
3378
  }
3336
3379
  assertChatGptIdleAndReadyForPrompt(status, busyBlocker, true);
3380
+ if (options.newChat && !options.project && !options.projectNew) {
3381
+ // Never navigate away from a prior in-flight request, including one whose
3382
+ // caller timed out and released the process lock. Project homes supply
3383
+ // their own fresh composer and must not pass through the root first.
3384
+ const freshUrl = options.temporary ? "https://chatgpt.com/?temporary-chat=true" : "https://chatgpt.com/";
3385
+ await navigateIdleChatGptPage(page, freshUrl);
3386
+ if (!await waitForFreshChatGptPage(page, 8_000)) {
3387
+ throw new ChatGptBrowserBlockerError({
3388
+ code: "fresh_chat_not_ready",
3389
+ message: "The new-chat page did not become an empty conversation. Nothing was sent.",
3390
+ retryable: true,
3391
+ next_step: "Wait for the dedicated browser to finish loading a new chat, then retry."
3392
+ });
3393
+ }
3394
+ status = await readSettledChatGptPageStatus(page);
3395
+ const freshBlocker = detectChatGptPageBlocker(status);
3396
+ if (freshBlocker)
3397
+ throw new ChatGptBrowserBlockerError(freshBlocker);
3398
+ assertChatGptIdleAndReadyForPrompt(status);
3399
+ }
3337
3400
  // Only a PINNED target has to be under the tab already; a resolved thread is
3338
3401
  // navigated to below, and asserting the match here would refuse the send for
3339
3402
  // the tab merely being somewhere else - which is the whole reason a
@@ -3439,6 +3502,22 @@ export async function sendChatGptPrompt(options) {
3439
3502
  // so assistant-message counts compare within the thread we actually send
3440
3503
  // into; a --project/--project-new hop lands on a page with its own counts.
3441
3504
  beforeSubmit = await evaluateOnPage(page, answerExpression());
3505
+ const beforeTyping = await readSettledChatGptPageStatus(page);
3506
+ const beforeTypingBlocker = detectChatGptPageBlocker(beforeTyping);
3507
+ if (beforeTypingBlocker)
3508
+ throw new ChatGptBrowserBlockerError(beforeTypingBlocker);
3509
+ assertChatGptIdleAndReadyForPrompt(beforeTyping);
3510
+ if (normalizedTargetUrl)
3511
+ assertChatGptTargetUrlMatches(beforeSubmit.url, normalizedTargetUrl);
3512
+ if ((options.newChat || options.project || options.projectNew) &&
3513
+ (beforeSubmit.userMessageCount !== 0 || beforeSubmit.assistantMessageCount !== 0 || conversationIdFromThreadUrl(beforeSubmit.url))) {
3514
+ throw new ChatGptBrowserBlockerError({
3515
+ code: "fresh_chat_not_ready",
3516
+ message: "The fresh-chat destination changed to an existing conversation before typing. Nothing was sent.",
3517
+ retryable: true,
3518
+ next_step: "Wait for other browser activity to finish before starting a new consult."
3519
+ });
3520
+ }
3442
3521
  dbgSend(`baseline url=${beforeSubmit.url} user=${beforeSubmit.userMessageCount} assistant=${beforeSubmit.assistantMessageCount}`);
3443
3522
  // Read the binding once more, on the composer this send is about to type
3444
3523
  // into. Everything between selectProject and here - the model picker, the
@@ -3464,7 +3543,7 @@ export async function sendChatGptPrompt(options) {
3464
3543
  }
3465
3544
  if (toolLabels.length > 0)
3466
3545
  emitProgress("selecting", `tools=${toolLabels.join(", ")}`);
3467
- await insertComposerTextViaCdp(cdp, options.prompt, page, toolLabels);
3546
+ await insertComposerTextViaCdp(cdp, sentPrompt, page, toolLabels);
3468
3547
  // The send button renders asynchronously after the prompt lands. Poll for it
3469
3548
  // BEFORE submitting so (a) submitButtonFound reflects whether the control
3470
3549
  // actually EXISTS - otherwise a successful Enter-key submit skips the fallback
@@ -3489,7 +3568,10 @@ export async function sendChatGptPrompt(options) {
3489
3568
  // inserts a newline) fall back to clicking the send button, re-reading
3490
3569
  // FRESH coordinates each attempt. Safe against double-submit: once the
3491
3570
  // prompt posts the composer clears and no send button is found.
3492
- const promptPostedExpression = `document.querySelectorAll('[data-message-author-role="user"]').length > ${beforeSubmit.userMessageCount}`;
3571
+ const promptPostedExpression = `(() => {
3572
+ const last = [...document.querySelectorAll('[data-message-author-role="user"]')].at(-1);
3573
+ return Boolean(last && (last.innerText || "").includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
3574
+ })()`;
3493
3575
  await cdp.send("Input.dispatchKeyEvent", enterKeyEvent("keyDown"));
3494
3576
  await cdp.send("Input.dispatchKeyEvent", enterKeyEvent("keyUp"));
3495
3577
  let promptPosted = await waitForExpressionTrue(cdp, promptPostedExpression, 1_500);
@@ -3535,10 +3617,14 @@ export async function sendChatGptPrompt(options) {
3535
3617
  if (runtimeBlocker)
3536
3618
  throw new ChatGptBrowserBlockerError(runtimeBlocker);
3537
3619
  dbgSend(`accept-poll url=${finalState.url} user=${finalState.userMessageCount} assistant=${finalState.assistantMessageCount} generating=${finalState.generating}`);
3538
- if (hasChatGptPromptAcceptance(beforeSubmit, finalState)) {
3620
+ if (requestMatches(finalState)) {
3621
+ if (normalizedTargetUrl)
3622
+ assertChatGptTargetUrlMatches(finalState.url, normalizedTargetUrl);
3539
3623
  accepted = true;
3540
3624
  break;
3541
3625
  }
3626
+ if (hasChatGptPromptAcceptance(beforeSubmit, finalState))
3627
+ throw requestMismatch(normalizedTargetUrl);
3542
3628
  emitProgress("waiting", "prompt posting");
3543
3629
  }
3544
3630
  if (!accepted) {
@@ -3583,7 +3669,6 @@ export async function sendChatGptPrompt(options) {
3583
3669
  let pinnedThreadUrl = pinnedConversationId
3584
3670
  ? canonicalChatGptThreadUrl(pinnedConversationId, normalizedTargetUrl ?? finalState?.url)
3585
3671
  : undefined;
3586
- let recoveredNavigations = 0;
3587
3672
  let consecutiveReadFailures = 0;
3588
3673
  let answerSettled = false;
3589
3674
  const answerIsStable = createChatGptAnswerStabilityTracker();
@@ -3595,6 +3680,8 @@ export async function sendChatGptPrompt(options) {
3595
3680
  // Freeze the first conversation identity the accepted page exposes. A
3596
3681
  // later tab move must never rewrite result metadata to another thread.
3597
3682
  if (!pinnedConversationId) {
3683
+ if (!requestMatches(observedState))
3684
+ throw requestMismatch();
3598
3685
  const observedConversationId = conversationIdFromThreadUrl(observedState.url);
3599
3686
  if (observedConversationId) {
3600
3687
  pinnedConversationId = observedConversationId;
@@ -3602,21 +3689,16 @@ export async function sendChatGptPrompt(options) {
3602
3689
  }
3603
3690
  }
3604
3691
  if (shouldRecoverThreadNavigation({ pinnedThreadUrl, currentUrl: observedState.url })) {
3605
- if (recoveredNavigations >= 2) {
3606
- throw new ChatGptBrowserBlockerError({
3607
- code: "thread_navigated_away",
3608
- message: "The browser tab was moved to a different ChatGPT conversation while this consult was waiting for its answer.",
3609
- retryable: true,
3610
- next_step: `Keep the dedicated browser on the consult thread, then fetch the answer with \`prodex pro browser recover --target-url ${pinnedThreadUrl}\`.`,
3611
- thread: pinnedThreadUrl
3612
- });
3613
- }
3614
- recoveredNavigations += 1;
3615
- sendWarnings.push(`thread_navigated_away_recovered: something moved the tab to another conversation mid-wait; prodex navigated back to ${pinnedThreadUrl}.`);
3616
- await evaluateOnPage(page, `location.assign(${JSON.stringify(pinnedThreadUrl)})`);
3617
- await sleep(3_000);
3618
- continue;
3692
+ throw new ChatGptBrowserBlockerError({
3693
+ code: "thread_navigated_away",
3694
+ message: "The browser tab was moved to a different ChatGPT conversation while this consult was waiting for its answer.",
3695
+ retryable: false,
3696
+ next_step: `Do not resend automatically. After other sessions finish, inspect the original request [prodex-request:${requestId}] in ${pinnedThreadUrl}.`,
3697
+ thread: pinnedThreadUrl
3698
+ });
3619
3699
  }
3700
+ if (!requestMatches(observedState))
3701
+ throw requestMismatch(pinnedThreadUrl);
3620
3702
  // Only a state from the pinned conversation may become eligible for
3621
3703
  // completed or partial result salvage after the polling deadline.
3622
3704
  finalState = observedState;
@@ -3660,6 +3742,8 @@ export async function sendChatGptPrompt(options) {
3660
3742
  modelHints: completed.modelHints,
3661
3743
  ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
3662
3744
  ...(boundProjectId ? { boundProjectId } : {}),
3745
+ requestId,
3746
+ requestVerified: true,
3663
3747
  warnings: withDialogNote([...sendWarnings, selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })]).filter((warning) => Boolean(warning))
3664
3748
  };
3665
3749
  }
@@ -3675,10 +3759,12 @@ export async function sendChatGptPrompt(options) {
3675
3759
  modelHints: completed.modelHints,
3676
3760
  ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
3677
3761
  ...(boundProjectId ? { boundProjectId } : {}),
3762
+ requestId,
3763
+ requestVerified: true,
3678
3764
  warnings: withDialogNote([
3679
3765
  ...sendWarnings,
3680
3766
  ...(selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) }) ? [selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })] : []),
3681
- `answer_incomplete: ChatGPT's answer did not reach a stable completed state after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms), so the answer below may be truncated. Raise --timeout-ms and retry for the full response.`
3767
+ `answer_incomplete: ChatGPT's answer did not reach a stable completed state after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms), so the answer below may be truncated. Do not resend the question. Recover the original answer with --target-url ${pinnedThreadUrl ?? completed.url} --request-id ${requestId} once it finishes.`
3682
3768
  ])
3683
3769
  };
3684
3770
  }
@@ -3686,7 +3772,7 @@ export async function sendChatGptPrompt(options) {
3686
3772
  // after prodex gives up, and `pro browser recover --target-url` exists to
3687
3773
  // fetch it - but only if the caller knows which thread to point at.
3688
3774
  throw Object.assign(new Error(`Timed out after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms) waiting for ChatGPT to respond. ` +
3689
- "Pro reasoning can run many minutes. Raise --timeout-ms and retry."), pinnedThreadUrl ?? completed?.url ? { thread: pinnedThreadUrl ?? completed?.url } : {});
3775
+ "Pro reasoning can run many minutes. Do not resend the question; recover the original answer once it finishes."), { requestId }, pinnedThreadUrl ?? completed?.url ? { thread: pinnedThreadUrl ?? completed?.url } : {});
3690
3776
  }
3691
3777
  /**
3692
3778
  * One line of `pro browser models`.
@@ -5136,6 +5222,20 @@ export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
5136
5222
  return seen.includes(sent);
5137
5223
  }
5138
5224
  const NORMALIZED_PROMPT_MATCH_CHARS = 120;
5225
+ function normalizeChatGptPromptText(value) {
5226
+ const unescaped = value.replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1");
5227
+ const renderedFences = unescaped.replace(/(^|\r?\n)```text[ \t]*\r?\n([\s\S]*?)\r?\n```(?=\r?\n|$)/g, "$1text\n$2");
5228
+ return renderedFences.replace(/\s+/g, " ").trim();
5229
+ }
5230
+ function chatGptRequestMarkerMatches(userText, requestId) {
5231
+ const markers = [...normalizeChatGptPromptText(userText).matchAll(/\[prodex-request:([a-f0-9]{32})\]/g)];
5232
+ return markers.at(-1)?.[1] === requestId && markers.filter((match) => match[1] === requestId).length === 1;
5233
+ }
5234
+ /** Full prompt and per-send identity; wrappers may contain tool/file labels. */
5235
+ export function chatGptRequestMatchesUserTurn(userText, sentPrompt, requestId) {
5236
+ return chatGptRequestMarkerMatches(userText, requestId) &&
5237
+ normalizeChatGptPromptText(userText).includes(normalizeChatGptPromptText(sentPrompt));
5238
+ }
5139
5239
  /**
5140
5240
  * Does this transcript belong to the consult that is waiting on it?
5141
5241
  *
@@ -5151,12 +5251,8 @@ export function transcriptMatchesSentPrompt(userText, sentPrompt) {
5151
5251
  // kept as "\\## File", fences as escaped backticks), so undo that before
5152
5252
  // comparing - otherwise every prompt carrying markdown, which is every
5153
5253
  // --file send, looks like a different conversation.
5154
- const normalize = (value) => value
5155
- .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
5156
- .replace(/\s+/g, " ")
5157
- .trim();
5158
- const seen = normalize(userText);
5159
- const sent = normalize(sentPrompt);
5254
+ const seen = normalizeChatGptPromptText(userText);
5255
+ const sent = normalizeChatGptPromptText(sentPrompt);
5160
5256
  if (seen.length === 0 || sent.length === 0)
5161
5257
  return false;
5162
5258
  const expected = sent.slice(0, NORMALIZED_PROMPT_MATCH_CHARS);
@@ -5435,7 +5531,10 @@ export function answerExpression() {
5435
5531
  // answer (a 0.21.3 fallback for deep research, which is read from the
5436
5532
  // transcript now) turned a tool's progress panel into a 28-character
5437
5533
  // "answer" that a consult returned as its result.
5438
- const assistant = assistantMessages.at(-1);
5534
+ const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
5535
+ // Pair only within the latest user turn; a previous reply is not the
5536
+ // answer to a new question whose assistant node has not rendered yet.
5537
+ const assistant = lastUserIndex < 0 ? undefined : messages.slice(lastUserIndex + 1).filter((message) => message.role === "assistant").at(-1);
5439
5538
  const buttons = [...document.querySelectorAll('button,[role="button"]')]
5440
5539
  .filter((node) => !!(node.offsetWidth || node.offsetHeight || node.getClientRects().length))
5441
5540
  .filter((node) => !node.closest(excludedTextSelector))
@@ -5460,6 +5559,7 @@ export function answerExpression() {
5460
5559
  awaitingResponseChoice: Boolean(document.querySelector(${responseChoiceSelector})),
5461
5560
  assistantMessageCount: assistantMessages.length,
5462
5561
  userMessageCount: userMessages.length,
5562
+ lastUserText: userMessages.at(-1)?.text || "",
5463
5563
  // ChatGPT tags each assistant message with the model that produced it -
5464
5564
  // the only ground truth for "did the Pro selection actually take".
5465
5565
  modelSlug: assistant ? assistant.modelSlug : undefined,
package/dist/cli-args.js CHANGED
@@ -271,6 +271,7 @@ export const ASK_PRO_BOOLEAN_FLAGS = new Set([
271
271
  export const ASK_PRO_SELECTION_VALUE_FLAGS = ["--project", "--project-new", "--model", "--pro-mode", "--effort"];
272
272
  export const ASK_PRO_VALUE_FLAGS = new Set([
273
273
  "--cwd",
274
+ "--session-key",
274
275
  // Continue one NAMED past consult, when "the last one" is not the one meant.
275
276
  "--continue-task",
276
277
  "--file",
@@ -287,6 +288,7 @@ export const ASK_PRO_VALUE_FLAGS = new Set([
287
288
  ]);
288
289
  export const ASK_PRO_PREVIEW_VALUE_FLAGS = new Set([
289
290
  "--cwd",
291
+ "--session-key",
290
292
  "--file",
291
293
  "--port",
292
294
  "--timeout-ms",
package/dist/cli-help.js CHANGED
@@ -25,8 +25,8 @@ Ask / consult commands:
25
25
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]
26
26
  prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of model menu options
27
27
  prodex pro browser projects [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of sidebar project names (for --project)
28
- prodex pro browser recover [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--timeout-ms 60000] # recover a finished answer from a thread whose send timed out
29
- prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
28
+ prodex pro browser recover [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--request-id 32hex] [--timeout-ms 60000] # recover a finished answer from a timed-out request
29
+ prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--session-key id] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
30
30
  prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
31
31
  prodex pro blockers [--cwd /absolute/path/to/repo] [--since 7d] [--limit 10] [--json] # what actually blocks consults, ranked, across every bridge root on this machine
32
32
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
@@ -170,7 +170,7 @@ Commands:
170
170
  prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
171
171
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
172
172
  prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js]
173
- prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt"
173
+ prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--session-key id] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt"
174
174
  prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
175
175
  prodex pro blockers [--cwd /absolute/path/to/repo] [--since 7d] [--limit 10] [--json] # what actually blocks consults, ranked, across every bridge root on this machine
176
176
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
@@ -261,8 +261,8 @@ export function printProBrowserHelp(stdout, sourceCli) {
261
261
  : "prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]";
262
262
  const selectionUsage = '[--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"]';
263
263
  const askUsage = sourceCli
264
- ? `${cli} pro browser ask${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`
265
- : `prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`;
264
+ ? `${cli} pro browser ask${sourceCliOption} [--cwd /absolute/path/to/repo] [--session-key id] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`
265
+ : `prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--session-key id] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`;
266
266
  const modelsUsage = sourceCli
267
267
  ? `${cli} pro browser models${sourceCliOption} [--port 9333] [--timeout-ms 15000]`
268
268
  : "prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000]";
@@ -282,8 +282,8 @@ export function printProBrowserHelp(stdout, sourceCli) {
282
282
  ? `${cli} pro browser reset${sourceCliOption} [--port 9333] [--confirm] # end a browser that runs but stopped answering; previews unless --confirm`
283
283
  : "prodex pro browser reset [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--confirm] # end a browser that runs but stopped answering; previews unless --confirm";
284
284
  const recoverUsage = sourceCli
285
- ? `${cli} pro browser recover${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--timeout-ms 60000] # read a stable, finished answer rendered in the requested thread`
286
- : "prodex pro browser recover [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--timeout-ms 60000] # read a stable, finished answer rendered in the requested thread";
285
+ ? `${cli} pro browser recover${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--request-id 32hex] [--timeout-ms 60000] # read the stable answer for one marked request`
286
+ : "prodex pro browser recover [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--request-id 32hex] [--timeout-ms 60000] # read the stable answer for one marked request";
287
287
  stdout(`${cli} pro browser
288
288
 
289
289
  Commands:
@@ -311,9 +311,12 @@ Model/project selection (ask):
311
311
  --project Enter an existing sidebar project before sending. Cannot be combined with --target-url.
312
312
 
313
313
  Continuing a conversation (ask):
314
- --continue Send into the conversation a previous consult is already in: the newest FINISHED consult of the same project, read from this repo's own .bridge records. The browser tab is shared and a pinned project starts a new chat on every send, so the tab is not what decides this. A --project here scopes the search rather than navigating. Refuses instead of guessing when this project has no finished consult yet.
315
- --continue-task Continue one named past consult by its task_id, when the newest is not the conversation meant. List them with \`${cli} pro list\`.
314
+ Ordinary sends start a fresh chat by default, including inside a saved project. new_chat:false never opts into the shared current tab.
315
+ --session-key Stable caller id for --continue. Falls back to PRODEX_SESSION_KEY, then CODEX_THREAD_ID.
316
+ --continue Continue this session key's newest FINISHED consult in the same project. Refuses when no caller key or matching consult exists.
317
+ --continue-task Continue one named past consult by its task_id, deliberately crossing session boundaries. List them with \`${cli} pro list\`.
316
318
  Cannot be combined with --new-chat, --target-url, --project-new or --temporary.
319
+ Recovery should use the request_id returned by the original send: --request-id verifies the answer follows that exact marked user turn. Omitting it keeps legacy recovery but reports request_verified=false.
317
320
  --pro-mode and --effort cannot be combined. Labels are matched in both the Korean and English (US) ChatGPT UI (e.g. 높음/High, Pro 확장/Pro Extended).
318
321
  Run \`${cli} pro browser models${sourceCliOption}\` to list the labels your account currently shows.
319
322
  Persist defaults with \`${cli} setup${sourceCliOption}\`; per-ask flags override them.
package/dist/cli-pro.js CHANGED
@@ -13,6 +13,7 @@ import { withBrowserSendLock } from "./browser-send-lock.js";
13
13
  import { blockerCause, buildBlockerReport, CATCH_ALL_CODES } from "./blocker-report.js";
14
14
  import { projectIdFromSidebar, resolveContinuationThread } from "./continue-thread.js";
15
15
  import { readBridgeRoots } from "./registry.js";
16
+ import { ProdexRequestIdSchema, SessionKeySchema } from "./schema.js";
16
17
  import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
17
18
  import { CLI_VERSION } from "./cli-help.js";
18
19
  import { PRODEX_ISSUE_REPO, buildIssueReport, fileGitHubIssue } from "./issue-report.js";
@@ -571,9 +572,9 @@ export async function runProCommand(rest, io, runCliFn) {
571
572
  return 0;
572
573
  }
573
574
  if (browserSubcommand === "recover") {
574
- if (printProBrowserHelpIfRequested(browserArgs, "pro browser recover", io, { valueFlags: ["--cwd", "--port", "--timeout-ms", "--target-url", "--source-cli"] }))
575
+ if (printProBrowserHelpIfRequested(browserArgs, "pro browser recover", io, { valueFlags: ["--cwd", "--port", "--timeout-ms", "--target-url", "--request-id", "--source-cli"] }))
575
576
  return 0;
576
- assertOnlyOptions(browserArgs, "pro browser recover", ["--cwd", "--port", "--timeout-ms", "--target-url", "--source-cli"]);
577
+ assertOnlyOptions(browserArgs, "pro browser recover", ["--cwd", "--port", "--timeout-ms", "--target-url", "--request-id", "--source-cli"]);
577
578
  const recoverCwd = resolveCwdFlag(io.cwd, browserArgs);
578
579
  const recoverSourceCli = resolveOptionalFileFlag(io.cwd, browserArgs, "--source-cli");
579
580
  const targetUrl = readFlag(browserArgs, "--target-url");
@@ -582,6 +583,10 @@ export async function runProCommand(rest, io, runCliFn) {
582
583
  }
583
584
  const recoverPort = readPortFlag(browserArgs, "--port");
584
585
  const recoverTimeoutMs = readPositiveIntegerFlag(browserArgs, "--timeout-ms");
586
+ const recoverRequestIdRaw = readFlag(browserArgs, "--request-id");
587
+ const recoverRequestId = recoverRequestIdRaw === undefined
588
+ ? undefined
589
+ : validatedProdexRequestId(recoverRequestIdRaw, "--request-id");
585
590
  const recoverResolvedPort = resolveCdpPort(recoverPort);
586
591
  let consult;
587
592
  try {
@@ -590,7 +595,12 @@ export async function runProCommand(rest, io, runCliFn) {
590
595
  // send is streaming into, which is the consult it would destroy - and
591
596
  // recovery is exactly what gets run after a send appears to hang, so
592
597
  // the two meeting is a when rather than an if.
593
- consult = await withBrowserSendLock(recoverTimeoutMs ?? 60_000, (detail) => io.stderr(`progress: ${detail}`), () => recoverChatGptAnswerFromThread({ port: recoverPort, targetUrl, timeoutMs: recoverTimeoutMs }));
598
+ consult = await withBrowserSendLock(recoverTimeoutMs ?? 60_000, (detail) => io.stderr(`progress: ${detail}`), () => recoverChatGptAnswerFromThread({
599
+ port: recoverPort,
600
+ targetUrl,
601
+ timeoutMs: recoverTimeoutMs,
602
+ ...(recoverRequestId ? { requestId: recoverRequestId } : {})
603
+ }));
594
604
  }
595
605
  catch (error) {
596
606
  const blocker = sourceAwareBrowserBlocker(browserSendBlockerFromError(error), recoverSourceCli, {
@@ -616,6 +626,12 @@ export async function runProCommand(rest, io, runCliFn) {
616
626
  catch (error) {
617
627
  io.stderr(`answer_artifact_warning: ${errorMessage(error)}`);
618
628
  }
629
+ for (const warning of consult.warnings)
630
+ io.stderr(warning);
631
+ if (consult.requestId)
632
+ io.stderr(`request_id: ${consult.requestId}`);
633
+ if (consult.requestVerified !== undefined)
634
+ io.stderr(`request_verified: ${consult.requestVerified ? "yes" : "no"}`);
619
635
  await recoverStore.completeTask(recoveredTask.id, {
620
636
  status: "done",
621
637
  summary: consult.answer,
@@ -623,8 +639,8 @@ export async function runProCommand(rest, io, runCliFn) {
623
639
  ? [{ path: recoveredArtifactPath, role: "result", bytes: Buffer.byteLength(recoveredArtifactText, "utf8") }]
624
640
  : [],
625
641
  commands: ["recovered ChatGPT answer from thread"],
626
- warnings: [],
627
- provenance: { thread: consult.url, warnings: [] }
642
+ warnings: consult.warnings,
643
+ provenance: { thread: consult.url, warnings: consult.warnings }
628
644
  });
629
645
  io.stdout(`${recoveredTask.id}\tdone\t${consult.url}`);
630
646
  io.stdout("");
@@ -985,6 +1001,27 @@ function autoLoginDisabledByEnv(env = process.env) {
985
1001
  const raw = (env.PRODEX_NO_AUTO_LOGIN ?? "").trim().toLowerCase();
986
1002
  return raw === "1" || raw === "true" || raw === "yes";
987
1003
  }
1004
+ function validatedSessionKey(value, source) {
1005
+ const parsed = SessionKeySchema.safeParse(value);
1006
+ if (parsed.success)
1007
+ return parsed.data;
1008
+ throw new Error(`${source} must be a non-empty identifier of at most 128 characters using letters, digits, '.', '_', ':', '@', '/', or '-'.`);
1009
+ }
1010
+ export function resolveProdexSessionKey(explicit, env = process.env) {
1011
+ if (explicit !== undefined)
1012
+ return validatedSessionKey(explicit, "--session-key");
1013
+ if (env.PRODEX_SESSION_KEY !== undefined)
1014
+ return validatedSessionKey(env.PRODEX_SESSION_KEY, "PRODEX_SESSION_KEY");
1015
+ if (env.CODEX_THREAD_ID !== undefined)
1016
+ return validatedSessionKey(env.CODEX_THREAD_ID, "CODEX_THREAD_ID");
1017
+ return undefined;
1018
+ }
1019
+ function validatedProdexRequestId(value, source) {
1020
+ const parsed = ProdexRequestIdSchema.safeParse(value);
1021
+ if (parsed.success)
1022
+ return parsed.data;
1023
+ throw new Error(`${source} must be the 32-character lowercase hex request identifier returned by prodex.`);
1024
+ }
988
1025
  // Retired browser subcommands map to the one that replaced them. Every value
989
1026
  // here must be a subcommand that actually exists, so the error is a single hop
990
1027
  // to a runnable command.
@@ -1136,6 +1173,7 @@ export async function runAskProCommand(rest, io) {
1136
1173
  promptText = prompt ? `${prompt}\n\n--- piped input (stdin) ---\n${piped}` : piped;
1137
1174
  }
1138
1175
  const browserDefaults = await loadBrowserDefaults(targetCwd);
1176
+ const sessionKey = resolveProdexSessionKey(readFlag(parsedAskPro.optionArgs, "--session-key"));
1139
1177
  const explicitProject = readFlag(parsedAskPro.optionArgs, "--project");
1140
1178
  const explicitProjectNew = readFlag(parsedAskPro.optionArgs, "--project-new");
1141
1179
  // Opt out of a pinned default project for one send. Without this a repo
@@ -1159,6 +1197,9 @@ export async function runAskProCommand(rest, io) {
1159
1197
  const continueRequested = parsedAskPro.optionArgs.includes("--continue");
1160
1198
  const continueTaskId = readFlag(parsedAskPro.optionArgs, "--continue-task");
1161
1199
  if (continueRequested || continueTaskId !== undefined) {
1200
+ if (continueTaskId === undefined && sessionKey === undefined) {
1201
+ throw new Error("--continue needs --session-key <id> (or PRODEX_SESSION_KEY/CODEX_THREAD_ID) so it cannot use another client's conversation. Use --continue-task <task_id> to name one explicitly.");
1202
+ }
1162
1203
  const conflict = [
1163
1204
  parsedAskPro.optionArgs.includes("--new-chat") ? "--new-chat" : undefined,
1164
1205
  targetUrl !== undefined ? "--target-url" : undefined,
@@ -1188,12 +1229,14 @@ export async function runAskProCommand(rest, io) {
1188
1229
  const resolved = resolveContinuationThread({
1189
1230
  consults: (await targetStore.listSessionsReadOnly()).map((session) => ({
1190
1231
  taskId: session.task_id ?? "",
1232
+ ...(session.session_key ? { sessionKey: session.session_key } : {}),
1191
1233
  ...(session.thread ? { thread: session.thread } : {}),
1192
1234
  status: session.status,
1193
1235
  ...(session.created_at ? { createdAt: session.created_at } : {})
1194
1236
  })),
1195
1237
  ...(continuationProject ? { project: continuationProject } : {}),
1196
1238
  ...(continuationProjectId ? { projectId: continuationProjectId } : {}),
1239
+ ...(sessionKey ? { sessionKey } : {}),
1197
1240
  ...(continueTaskId !== undefined ? { taskId: continueTaskId } : {})
1198
1241
  });
1199
1242
  if ("error" in resolved)
@@ -1205,7 +1248,8 @@ export async function runAskProCommand(rest, io) {
1205
1248
  normalizedTargetUrl = normalizeChatGptTargetUrl(resolved.target.thread);
1206
1249
  io.stderr(`progress: continuing ${resolved.target.taskId}`);
1207
1250
  }
1208
- const newChat = parsedAskPro.optionArgs.includes("--new-chat");
1251
+ const explicitlyRequestedNewChat = parsedAskPro.optionArgs.includes("--new-chat");
1252
+ const newChat = explicitlyRequestedNewChat || normalizedTargetUrl === undefined;
1209
1253
  // A temporary chat is not saved, so there is nothing to come back to: the
1210
1254
  // recovery path every timeout message points at cannot fetch it later.
1211
1255
  // Requiring --new-chat keeps that explicit rather than quietly turning a
@@ -1220,7 +1264,7 @@ export async function runAskProCommand(rest, io) {
1220
1264
  if (temporary && !newChat) {
1221
1265
  throw new Error("--temporary starts a throwaway chat, so it needs --new-chat. A temporary chat cannot be continued or recovered later.");
1222
1266
  }
1223
- if (newChat && normalizedTargetUrl) {
1267
+ if (explicitlyRequestedNewChat && normalizedTargetUrl) {
1224
1268
  throw new Error("ask-pro cannot combine --new-chat with --target-url: --new-chat navigates to a fresh chat while --target-url pins the confirmed tab.");
1225
1269
  }
1226
1270
  const explicitModel = readFlag(parsedAskPro.optionArgs, "--model");
@@ -1335,6 +1379,7 @@ export async function runAskProCommand(rest, io) {
1335
1379
  id: bundle.id,
1336
1380
  direction: "codex_to_chatgpt",
1337
1381
  backend: "chatgpt-control",
1382
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1338
1383
  task_id: task.id,
1339
1384
  thread: normalizedTargetUrl,
1340
1385
  status: "running",
@@ -1464,6 +1509,7 @@ export async function runAskProCommand(rest, io) {
1464
1509
  id: bundle.id,
1465
1510
  direction: "codex_to_chatgpt",
1466
1511
  backend: "chatgpt-control",
1512
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1467
1513
  task_id: task.id,
1468
1514
  thread: blockedThread,
1469
1515
  status: "blocked",
@@ -1477,7 +1523,15 @@ export async function runAskProCommand(rest, io) {
1477
1523
  // Keep stdout machine-parseable for --json consumers on the blocked
1478
1524
  // path too; the human-readable error still goes to stderr via throw.
1479
1525
  if (jsonOutput) {
1480
- io.stdout(JSON.stringify({ task_id: task.id, status: "blocked", thread: blockedThread ?? null, answer: null, warnings: blockedWarnings, blocker }, null, 2));
1526
+ io.stdout(JSON.stringify({
1527
+ task_id: task.id,
1528
+ status: "blocked",
1529
+ thread: blockedThread ?? null,
1530
+ answer: null,
1531
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1532
+ warnings: blockedWarnings,
1533
+ blocker
1534
+ }, null, 2));
1481
1535
  }
1482
1536
  throw new Error(formatBlockedConsultRecordedMessage(message, task.id, sourceCli, { cwd: targetCwd }));
1483
1537
  }
@@ -1514,6 +1568,10 @@ export async function runAskProCommand(rest, io) {
1514
1568
  io.stderr(warning);
1515
1569
  if (consult.modelSlug)
1516
1570
  io.stderr(`model_used: ${consult.modelSlug}`);
1571
+ if (consult.requestId)
1572
+ io.stderr(`request_id: ${consult.requestId}`);
1573
+ if (consult.requestVerified !== undefined)
1574
+ io.stderr(`request_verified: ${consult.requestVerified ? "yes" : "no"}`);
1517
1575
  // Which conversation this followed, and where the answer actually landed.
1518
1576
  // The progress line that says it is filtered out of MCP notes, so an
1519
1577
  // agent asking for a follow-up had no way to know which thread it got -
@@ -1562,6 +1620,8 @@ export async function runAskProCommand(rest, io) {
1562
1620
  // receipt used to record only what prodex asked for.
1563
1621
  ...(consult.modelSlug ? { model_used: consult.modelSlug } : {}),
1564
1622
  ...(proVerified !== undefined ? { pro_verified: proVerified } : {}),
1623
+ ...(consult.requestId ? { request_id: consult.requestId } : {}),
1624
+ ...(consult.requestVerified !== undefined ? { request_verified: consult.requestVerified } : {}),
1565
1625
  // Intent and evidence, kept apart. `selection` is what was asked
1566
1626
  // for; this is where the answer turned out to be, and whether
1567
1627
  // anything actually confirmed it.
@@ -1606,6 +1666,7 @@ export async function runAskProCommand(rest, io) {
1606
1666
  id: bundle.id,
1607
1667
  direction: "codex_to_chatgpt",
1608
1668
  backend: "chatgpt-control",
1669
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1609
1670
  task_id: task.id,
1610
1671
  thread: consult.url,
1611
1672
  status: "done",
@@ -1617,6 +1678,9 @@ export async function runAskProCommand(rest, io) {
1617
1678
  status: result.status,
1618
1679
  thread: consult.url,
1619
1680
  answer: result.summary,
1681
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1682
+ ...(consult.requestId ? { request_id: consult.requestId } : {}),
1683
+ ...(consult.requestVerified !== undefined ? { request_verified: consult.requestVerified } : {}),
1620
1684
  ...(continuedFromTaskId ? { continued_from: continuedFromTaskId } : {}),
1621
1685
  destination: {
1622
1686
  observed: destination.destination,
@@ -1640,6 +1704,7 @@ export async function runAskProCommand(rest, io) {
1640
1704
  id: bundle.id,
1641
1705
  direction: "codex_to_chatgpt",
1642
1706
  backend: "manual",
1707
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1643
1708
  status: "preview",
1644
1709
  warnings: []
1645
1710
  }, io);
@@ -1692,6 +1757,14 @@ Rules:
1692
1757
 
1693
1758
  Write the debate in the language of the topic.`;
1694
1759
  }
1760
+ function browserMetadataFromNotes(notes) {
1761
+ const requestId = notes.find((line) => line.startsWith("request_id: "))?.slice("request_id: ".length).trim();
1762
+ const verified = notes.find((line) => line.startsWith("request_verified: "))?.slice("request_verified: ".length).trim();
1763
+ return {
1764
+ ...(requestId ? { request_id: requestId } : {}),
1765
+ ...(verified === "yes" ? { request_verified: true } : verified === "no" ? { request_verified: false } : {})
1766
+ };
1767
+ }
1695
1768
  /**
1696
1769
  * MCP-side counterpart to `pro browser recover`. A consult that outlives its
1697
1770
  * budget hands back the thread it landed in, but until now the only way to act
@@ -1706,6 +1779,7 @@ export async function performBrowserRecoverForMcp(cwd, input) {
1706
1779
  "recover",
1707
1780
  "--target-url",
1708
1781
  input.thread,
1782
+ ...(input.request_id !== undefined ? ["--request-id", input.request_id] : []),
1709
1783
  ...(input.timeout_ms !== undefined ? ["--timeout-ms", String(input.timeout_ms)] : [])
1710
1784
  ];
1711
1785
  await runProCommand(argv, {
@@ -1715,11 +1789,13 @@ export async function performBrowserRecoverForMcp(cwd, input) {
1715
1789
  }, async () => 0);
1716
1790
  const header = stdoutLines[0] ?? "";
1717
1791
  const [taskId = "", status = "", thread = ""] = header.split("\t");
1792
+ const metadata = browserMetadataFromNotes(stderrLines);
1718
1793
  return {
1719
1794
  task_id: taskId,
1720
1795
  status,
1721
1796
  thread,
1722
1797
  answer: stdoutLines.slice(2).join("\n"),
1798
+ ...metadata,
1723
1799
  notes: stderrLines
1724
1800
  };
1725
1801
  }
@@ -1750,6 +1826,7 @@ export function answerRescuedFromFailedPersistence(stdoutLines) {
1750
1826
  export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1751
1827
  const stdoutLines = [];
1752
1828
  const stderrLines = [];
1829
+ const sessionKey = resolveProdexSessionKey(input.session_key);
1753
1830
  const argv = [
1754
1831
  "--send",
1755
1832
  // MCP callers have no terminal, so the interactive auto-recovery gate
@@ -1762,6 +1839,7 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1762
1839
  ...(input.pro_mode !== undefined ? ["--pro-mode", input.pro_mode] : []),
1763
1840
  ...(input.effort !== undefined ? ["--effort", input.effort] : []),
1764
1841
  ...(input.project !== undefined ? ["--project", input.project] : []),
1842
+ ...(sessionKey !== undefined ? ["--session-key", sessionKey] : []),
1765
1843
  ...(input.timeout_ms !== undefined ? ["--timeout-ms", String(input.timeout_ms)] : []),
1766
1844
  ...(input.files ?? []).flatMap((file) => ["--file", file]),
1767
1845
  ...(input.attach ?? []).flatMap((file) => ["--attach", file]),
@@ -1794,25 +1872,31 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1794
1872
  const rescued = answerRescuedFromFailedPersistence(stdoutLines);
1795
1873
  if (!rescued)
1796
1874
  throw error;
1875
+ const notes = [
1876
+ ...stderrLines.filter((line) => !line.startsWith("progress:")),
1877
+ `answer_not_saved: ${errorMessage(error)}`
1878
+ ];
1797
1879
  return {
1798
1880
  task_id: rescued.taskId,
1799
1881
  status: "answered_not_saved",
1800
1882
  thread: rescued.thread,
1801
1883
  answer: rescued.answer,
1802
- notes: [
1803
- ...stderrLines.filter((line) => !line.startsWith("progress:")),
1804
- `answer_not_saved: ${errorMessage(error)}`
1805
- ]
1884
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1885
+ ...browserMetadataFromNotes(notes),
1886
+ notes
1806
1887
  };
1807
1888
  }
1808
1889
  const header = stdoutLines[0] ?? "";
1809
1890
  const [taskId = "", status = "", thread = ""] = header.split("\t");
1891
+ const notes = stderrLines.filter((line) => !line.startsWith("progress:"));
1810
1892
  return {
1811
1893
  task_id: taskId,
1812
1894
  status,
1813
1895
  thread,
1814
1896
  answer: stdoutLines.slice(2).join("\n"),
1815
- notes: stderrLines.filter((line) => !line.startsWith("progress:"))
1897
+ ...(sessionKey ? { session_key: sessionKey } : {}),
1898
+ ...browserMetadataFromNotes(notes),
1899
+ notes
1816
1900
  };
1817
1901
  }
1818
1902
  /**
@@ -2222,10 +2306,17 @@ export function browserSendBlockerFromError(error) {
2222
2306
  const thread = typeof error === "object" && error !== null && "thread" in error && typeof error.thread === "string"
2223
2307
  ? (error.thread)
2224
2308
  : undefined;
2309
+ const requestId = typeof error === "object" &&
2310
+ error !== null &&
2311
+ "requestId" in error &&
2312
+ typeof error.requestId === "string" &&
2313
+ ProdexRequestIdSchema.safeParse(error.requestId).success
2314
+ ? error.requestId
2315
+ : undefined;
2225
2316
  const timedOut = message.match(/Timed out after [\s\S]*?(\d+)\s*ms/);
2226
2317
  if (timedOut) {
2227
- // Suggest a concrete doubled budget so the user can paste a rerun command
2228
- // instead of guessing what "raise --timeout-ms" means in milliseconds.
2318
+ // Marked sends recover the exact request. Legacy timeout errors retain a
2319
+ // concrete doubled budget so the user need not guess one.
2229
2320
  const usedMs = Number(timedOut[1]);
2230
2321
  const suggestedMs = Number.isFinite(usedMs) && usedMs > 0 ? usedMs * 2 : 600_000;
2231
2322
  return {
@@ -2233,10 +2324,12 @@ export function browserSendBlockerFromError(error) {
2233
2324
  message,
2234
2325
  retryable: true,
2235
2326
  ...(thread ? { thread } : {}),
2236
- next_step: `Rerun with a bigger budget (${formatDurationMs(suggestedMs)}): \`prodex pro browser ask --timeout-ms ${suggestedMs} "<same prompt>"\`.` +
2237
- (thread
2238
- ? ` ChatGPT often finishes after prodex gives up - fetch that answer instead of re-asking: \`prodex pro browser recover --target-url ${thread}\`.`
2239
- : "")
2327
+ next_step: thread && requestId
2328
+ ? `Do not resend automatically. ChatGPT often finishes after prodex gives up; recover that exact request instead: \`prodex pro browser recover --target-url ${thread} --request-id ${requestId}\`.`
2329
+ : `Rerun with a bigger budget (${formatDurationMs(suggestedMs)}): \`prodex pro browser ask --timeout-ms ${suggestedMs} "<same prompt>"\`.` +
2330
+ (thread
2331
+ ? ` ChatGPT often finishes after prodex gives up - fetch that answer instead of re-asking: \`prodex pro browser recover --target-url ${thread}\`.`
2332
+ : "")
2240
2333
  };
2241
2334
  }
2242
2335
  // A CDP command timeout means the page's renderer stalled, which in the
@@ -125,8 +125,8 @@ export function projectIdFromSidebar(projects, name) {
125
125
  *
126
126
  * Naming a task wins over the search, because the caller who names one knows
127
127
  * which conversation they mean. Otherwise it is the most recent consult that
128
- * finished, in this project - fail-closed when there is none, since guessing
129
- * the conversation is the failure this exists to prevent.
128
+ * finished for this caller, in this project - fail-closed when there is none,
129
+ * since guessing the conversation is the failure this exists to prevent.
130
130
  */
131
131
  export function resolveContinuationThread(input) {
132
132
  const withThread = input.consults.filter((consult) => consult.thread && isChatGptConversationUrl(consult.thread));
@@ -147,10 +147,17 @@ export function resolveContinuationThread(input) {
147
147
  }
148
148
  return { target: { taskId: named.taskId, thread: named.thread } };
149
149
  }
150
+ if (!input.sessionKey) {
151
+ return {
152
+ error: "--continue needs a caller session key so it cannot select another client's conversation. " +
153
+ "Pass --session-key <id> (or PRODEX_SESSION_KEY/CODEX_THREAD_ID), or name the intended consult with --continue-task <task_id>."
154
+ };
155
+ }
150
156
  const knownProjectIds = new Set(input.project ? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project)) ?? [] : []);
151
157
  if (input.projectId)
152
158
  knownProjectIds.add(input.projectId.toLowerCase());
153
159
  const candidates = withThread
160
+ .filter((consult) => consult.sessionKey === input.sessionKey)
154
161
  .filter((consult) => consult.status === "done")
155
162
  .filter((consult) => threadMatchesProject(consult.thread, input.project, knownProjectIds))
156
163
  .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
package/dist/mcp.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
  import { createRequire } from "node:module";
4
5
  import process from "node:process";
@@ -6,7 +7,7 @@ import { serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
6
7
  import { JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js";
7
8
  import { z } from "zod";
8
9
  import { createMcpToolHandlers, MAX_MCP_BRIDGE_TEXT_BYTES, MAX_MCP_SHORT_TEXT_BYTES, staleServerWarning, withServerVersionNotice } from "./mcp-tools.js";
9
- import { ReceiptKindSchema } from "./schema.js";
10
+ import { ProdexRequestIdSchema, ReceiptKindSchema, SessionKeySchema } from "./schema.js";
10
11
  const McpBridgeTextSchema = z.string().max(MAX_MCP_BRIDGE_TEXT_BYTES);
11
12
  const McpShortTextSchema = z.string().max(MAX_MCP_SHORT_TEXT_BYTES);
12
13
  const BridgeFileInputSchema = z.object({
@@ -51,6 +52,7 @@ function serverVersionNotice() {
51
52
  }
52
53
  export function createServer(cwd = process.cwd(), options = {}) {
53
54
  const server = new McpServer({ name: "prodex", version: mcpPackageJson.version ?? "0.0.0" });
55
+ const mcpSessionKey = `mcp-${randomUUID()}`;
54
56
  const handlers = createMcpToolHandlers({
55
57
  cwd,
56
58
  source: options.source,
@@ -167,9 +169,10 @@ export function createServer(cwd = process.cwd(), options = {}) {
167
169
  const browserConsult = options.browserConsult;
168
170
  if (browserConsult) {
169
171
  server.registerTool("pro_consult", {
170
- description: "Ask the user's logged-in ChatGPT (Pro) in the visible browser and wait for the full answer. This drives a real browser send: it can take minutes (Pro extended reasoning), is human-paced, and records a durable receipt under .bridge/. Requires a running `prodex pro browser login` session. By default the consult continues in whatever thread the browser tab is showing - EXCEPT when a project applies (passed here, or pinned as a saved default), because entering a project starts a new chat in it. To follow up on a previous consult, pass continue_thread:true: it resolves the thread from prodex's own records - the newest finished consult of the same project - instead of trusting the shared tab, and continue_task with a task_id names one exactly. Pass new_chat:true to start a fresh thread for a genuinely new topic. If the thread is still generating a previous answer, the send automatically queues behind it (up to the timeout budget) - long 'tab busy' progress is normal, not stuck. `project` and `model` come from saved defaults (per-repo config, or PRODEX_DEFAULT_PROJECT / PRODEX_DEFAULT_MODEL env vars) when omitted - do NOT pass them per-call unless deliberately overriding. Returns task_id, thread URL, and the answer text.",
172
+ description: "Ask the user's logged-in ChatGPT (Pro) in the visible browser and wait for the full answer. This drives a real browser send: it can take minutes (Pro extended reasoning), is human-paced, and records a durable receipt under .bridge/. Requires a running `prodex pro browser login` session. Every ordinary consult starts a fresh chat, including inside a passed or saved default project; new_chat:false never opts into the shared current tab. To follow up, pass continue_thread:true: it resolves only the newest finished consult with this caller's session_key and project. Each MCP connection gets one default session_key. Logical agents sharing one connection must pass distinct explicit keys and preserve them for follow-ups; an explicit key also preserves identity across MCP restarts. continue_task deliberately names one task across session boundaries. If the thread is still generating a previous answer, the send queues behind it up to the timeout budget. `project` and `model` come from saved defaults when omitted. Returns task_id, thread URL, session_key, request correlation evidence, and the answer text.",
171
173
  inputSchema: {
172
174
  prompt: McpBridgeTextSchema.min(1),
175
+ session_key: SessionKeySchema.optional().describe("Stable logical-caller identifier for scoped continue_thread lookup. Omit to share this MCP connection's default key; logical agents sharing one connection should pass distinct keys and preserve them for follow-ups."),
173
176
  model: McpShortTextSchema.optional(),
174
177
  // pro_mode is deliberately NOT advertised: ChatGPT's 2026-07 update
175
178
  // removed Pro sub-modes, and agents that saw the field passed
@@ -192,11 +195,11 @@ export function createServer(cwd = process.cwd(), options = {}) {
192
195
  new_chat: z
193
196
  .boolean()
194
197
  .optional()
195
- .describe("Start a fresh thread for a new topic."),
198
+ .describe("Start a fresh thread. Ordinary consults already do this by default; false does not reuse the shared tab."),
196
199
  continue_thread: z
197
200
  .boolean()
198
201
  .optional()
199
- .describe("Follow up inside the conversation a previous consult is already in, resolved from prodex's records: the newest finished consult of the same project. This is the reliable way to keep a follow-up in one conversation - the tab is shared, and a project default starts a new chat on every send. Fails rather than guessing when this project has no finished consult yet."),
202
+ .describe("Follow up inside this caller session_key's newest finished consult of the same project. Fails rather than using another MCP/Codex session or the shared browser tab."),
200
203
  continue_task: z
201
204
  .string()
202
205
  .min(1)
@@ -227,15 +230,16 @@ export function createServer(cwd = process.cwd(), options = {}) {
227
230
  // Progress delivery must never break the consult.
228
231
  });
229
232
  };
230
- return asText(withServerVersionNotice(await browserConsult(input, onProgress), serverVersionNotice()));
233
+ return asText(withServerVersionNotice(await browserConsult({ ...input, session_key: input.session_key ?? mcpSessionKey }, onProgress), serverVersionNotice()));
231
234
  });
232
235
  }
233
236
  const browserRecover = options.browserRecover;
234
237
  if (browserRecover) {
235
238
  server.registerTool("pro_recover", {
236
- description: "Read a stable, finished assistant answer rendered in the requested ChatGPT thread after a consult stopped waiting, and record a receipt. It sends no prompt and acquires the shared send lock before navigating. Wrong-thread, generating, missing, or changing answers are refused. Hidden transcripts and deep-research report retrieval are unsupported; inspect those manually in ChatGPT.",
239
+ description: "Read a stable, finished assistant answer rendered in the requested ChatGPT thread after a consult stopped waiting, and record a receipt. Pass the request_id returned by pro_consult to verify the answer follows that exact marked user turn. Without it, legacy recovery returns request_verified:false and an explicit warning. It sends no prompt and acquires the shared send lock before navigating. Wrong-thread, wrong-request, generating, missing, or changing answers are refused.",
237
240
  inputSchema: {
238
241
  thread: McpShortTextSchema.min(1).describe("The ChatGPT conversation URL from the blocker (its `thread` field)."),
242
+ request_id: ProdexRequestIdSchema.optional().describe("The 32-character request_id returned by the original pro_consult."),
239
243
  timeout_ms: z.number().int().positive().max(600_000).optional()
240
244
  }
241
245
  }, async (input) => asText(withServerVersionNotice(await browserRecover(input), serverVersionNotice())));
package/dist/schema.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { z } from "zod";
2
2
  export const SCHEMA_VERSION = 1;
3
+ export const SessionKeySchema = z
4
+ .string()
5
+ .trim()
6
+ .min(1)
7
+ .max(128)
8
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/, "Session key must be an identifier, not prompt text");
9
+ export const ProdexRequestIdSchema = z.string().regex(/^[a-f0-9]{32}$/);
3
10
  export const AdapterSchema = z.enum(["cli", "mcp", "manual", "oracle", "chatgpt-control"]);
4
11
  export const TaskStatusSchema = z.enum(["new", "claimed", "done", "blocked"]);
5
12
  export const ResultStatusSchema = z.enum(["done", "blocked"]);
@@ -83,6 +90,7 @@ export const SessionSchema = z.object({
83
90
  id: z.string().regex(/^sess_\d{8}_\d{6}_[a-z0-9-]+$/),
84
91
  direction: z.enum(["codex_to_chatgpt", "chatgpt_to_codex", "claude_to_codex"]),
85
92
  backend: AdapterSchema,
93
+ session_key: SessionKeySchema.optional(),
86
94
  project: z.string().optional(),
87
95
  thread: z.string().optional(),
88
96
  task_id: z.string().optional(),
package/dist/store.js CHANGED
@@ -442,6 +442,7 @@ export class BridgeStore {
442
442
  id,
443
443
  direction: input.direction,
444
444
  backend: input.backend,
445
+ session_key: input.session_key,
445
446
  project: input.project,
446
447
  thread: input.thread,
447
448
  task_id: input.task_id,
@@ -458,6 +459,7 @@ export class BridgeStore {
458
459
  id: input.id,
459
460
  direction: input.direction,
460
461
  backend: input.backend,
462
+ session_key: input.session_key ?? existing?.session_key,
461
463
  project: input.project,
462
464
  thread: input.thread,
463
465
  task_id: input.task_id,
@@ -482,6 +484,7 @@ export class BridgeStore {
482
484
  id: existing.id,
483
485
  direction: existing.direction,
484
486
  backend: existing.backend,
487
+ session_key: existing.session_key,
485
488
  project: existing.project,
486
489
  thread: existing.thread,
487
490
  task_id: existing.task_id,
package/docs/claude.md CHANGED
@@ -105,9 +105,11 @@ The server currently exposes ledger-first tools:
105
105
 
106
106
  Write tools are narrow and receipt-gated, and they require a git worktree with a committed HEAD. Claude must first call `repo_write_file_dry_run` with an existing repo-relative text file, replacement content, and the expected git HEAD. The file is not changed; the receipt stores hashes/diff and points at a replacement-text artifact under `.bridge/artifacts/repo-writes/`. To apply it, Claude must call `repo_write_file_apply` with the dry-run receipt id, the same expected HEAD, and the reported preimage hash. If git HEAD, file content, or artifact content changed, apply fails. To stage the result, Claude must call `repo_stage_reviewed_paths` with applied write receipt ids and the same expected HEAD; staging fails if any file changed after apply.
107
107
 
108
- `pro_consult` lets Claude ask your logged-in ChatGPT (Pro) directly: it drives the same explicit visible-browser consult as `prodex pro browser ask` (human-paced, blocker-gated, receipt-recorded, answer saved under `.bridge/artifacts/pro-consults/`) and can take minutes for Pro extended reasoning. It requires a prior `prodex pro browser login` session and is registered only on the local stdio MCP server the HTTP MCP surface never exposes it, so nothing reachable through a tunnel or ChatGPT itself can drive your browser.
108
+ `pro_consult` lets Claude ask your logged-in ChatGPT (Pro) directly: it drives the same explicit visible-browser consult as `prodex pro browser ask` (human-paced, blocker-gated, receipt-recorded, answer saved under `.bridge/artifacts/pro-consults/`) and can take minutes for Pro extended reasoning. Ordinary calls start fresh. Each stdio MCP connection gets one default `session_key`, and `continue_thread` only resolves that key's latest finished consult in the same project. Logical agents sharing one connection should pass distinct explicit keys and preserve them for follow-ups. An explicit key also keeps continuity across an MCP process restart. It requires a prior `prodex pro browser login` session and is registered only on the local stdio MCP server; the HTTP MCP surface never exposes it, so nothing reachable through a tunnel or ChatGPT itself can drive your browser.
109
109
 
110
- `pro_recover` reads a finished, rendered answer after a consult stopped waiting. It verifies the requested conversation and refuses still-generating or unstable content. It sends nothing. Deep-research widget reports are not supported; current builds block automated deep-research sends before posting. Like `pro_consult`, recovery is registered only on the local stdio MCP server.
110
+ `pro_recover` reads a finished, rendered answer after a consult stopped waiting. Pass the original `request_id` with its `thread` to verify the answer follows that exact marked user turn. Omitting it supports old records but returns `request_verified: false` and a warning. It sends nothing and refuses wrong-request, wrong-thread, still-generating, or unstable content. Deep-research widget reports remain unsupported, and this tool is also registered only on the local stdio MCP server.
111
+
112
+ Updating the installed package does not reload an already-running MCP process. Reconnect the MCP server or restart the Codex/Claude agent client to load the new build. The dedicated browser profile remains signed in, so no ChatGPT reauthentication is needed.
111
113
 
112
114
  Generic bridge result/session/task tools redact ChatGPT thread metadata, including nested blockers; local `pro_consult` recovery information remains available. A legacy result artifact with no saved hash returns `legacy_artifact_unverified` rather than implying its bytes were verified. `sessions cancel` only clears stale bookkeeping after a send was interrupted; it does not stop an active consult.
113
115
 
@@ -131,9 +131,9 @@ prodex results artifact latest
131
131
  prodex sessions show latest
132
132
  ```
133
133
 
134
- This uses the currently available ChatGPT web session and model selection. It is not a hidden API client, and it does not read cookies, tokens, localStorage, or sessionStorage.
134
+ This uses the currently available ChatGPT web session and model selection. Each ordinary ask starts a fresh chat, including inside a configured project. Use `--session-key <id> --continue` for a follow-up scoped to one caller, or `--continue-task <task_id>` to deliberately name a consult across sessions. `PRODEX_SESSION_KEY` and then `CODEX_THREAD_ID` are the CLI fallbacks. It is not a hidden API client, and it does not read cookies, tokens, localStorage, or sessionStorage.
135
135
 
136
- Current builds read rendered page content only. Project/conversation listings are limited to entries exposed by the UI. Automatic chat/project deletion and deep-research report retrieval are unsupported; those commands stop rather than call internal endpoints. A recovered answer must belong to the requested thread and be stable and finished. Formatting may differ from ChatGPT's rendered message.
136
+ Current builds read rendered page content only. Project/conversation listings are limited to entries exposed by the UI. Automatic chat/project deletion and deep-research report retrieval are unsupported; those commands stop rather than call internal endpoints. Every send carries a visible request marker; success requires the returned assistant turn to follow that marker. Recover a timeout with `--target-url <thread> --request-id <request_id>` so recovery verifies the same turn. Omitting the request ID is legacy, unverified recovery. Formatting may differ from ChatGPT's rendered message.
137
137
 
138
138
  Locks fail closed if a process is killed while reclaiming an abandoned lock. A leftover `.reap` claim then needs manual cleanup: first stop every prodex process using that resource and confirm no request/write/startup is active; only then remove the affected lock and its matching `.reap` file. Browser locks live beside the recorded send lock, repo-write locks under `.bridge`, and virtual-display allocation locks under `~/.local/share/prodex/xvfb`. Do not remove a live request's lock to shorten a wait.
139
139
 
package/docs/clients.md CHANGED
@@ -20,6 +20,18 @@ Install the `prodex` binary with `npm install -g @youdie006/prodex` (note the sc
20
20
  The same operating rules apply to every client: manual-first, explicit `pro browser ...`
21
21
  sends only, stop on blockers, no bypass, low volume, local only (see the README).
22
22
 
23
+ Each stdio MCP connection gets one default `session_key`. An ordinary `pro_consult`
24
+ starts a fresh chat; `new_chat: false` does not reuse whichever conversation another
25
+ session left in the shared tab. A `continue_thread: true` call searches only the same
26
+ session key and project. Logical agents sharing one connection should use distinct
27
+ explicit keys and preserve them for follow-ups. An explicit key also keeps continuity
28
+ across an MCP process restart; `continue_task` deliberately names a recorded consult.
29
+
30
+ After updating the installed package, reconnect the MCP server or restart the agent
31
+ client. A running stdio process keeps the old code until it exits. The dedicated
32
+ browser profile is unchanged, so restarting Codex/Claude does not require signing in
33
+ to ChatGPT again.
34
+
23
35
  ## Claude Code
24
36
 
25
37
  See [claude.md](claude.md), or:
@@ -51,7 +63,9 @@ or a per-server `"timeout"`.
51
63
  Keep the client budget longer than the ordinary consult budget. Current
52
64
  browser-only builds block `tools: ["deep-research"]` before sending because
53
65
  automatic report retrieval depended on an internal API. Run research manually
54
- in ChatGPT; `pro_recover` only accepts a finished, rendered conversation answer.
66
+ in ChatGPT. Recover a timed-out ordinary answer with both the returned `thread` and
67
+ `request_id`; recovery without a request ID is retained for old records but is marked
68
+ unverified.
55
69
 
56
70
  Approval gate (verified on Codex 0.142.5): Codex asks for per-call approval
57
71
  before invoking prodex MCP tools. In interactive `codex` sessions you simply
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.8",
3
+ "version": "0.40.10",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",