@youdie006/prodex 0.23.1 → 0.25.0

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/dist/bundle.js CHANGED
@@ -12,10 +12,14 @@ export async function buildDryRunBundle(root, input) {
12
12
  input.prompt.trim()
13
13
  ];
14
14
  const files = [];
15
+ // The prompt leads so the instruction is never buried under file dumps.
16
+ const sendSections = [input.prompt.trim()];
15
17
  for (const file of input.files) {
16
18
  const content = await readRepoFile(root, file, { maxLines: 500 });
17
19
  files.push({ path: file, role: "context", bytes: Buffer.byteLength(content.content, "utf8") });
18
- sections.push("", `## File: ${file}`, "", "```text", content.content, "```");
20
+ const fileSection = ["", `## File: ${file}`, "", "```text", content.content, "```"];
21
+ sections.push(...fileSection);
22
+ sendSections.push(...fileSection);
19
23
  }
20
24
  return {
21
25
  schema_version: SCHEMA_VERSION,
@@ -24,6 +28,7 @@ export async function buildDryRunBundle(root, input) {
24
28
  prompt: input.prompt,
25
29
  files,
26
30
  text: sections.join("\n"),
31
+ sendText: sendSections.join("\n"),
27
32
  created_at: nowIso()
28
33
  };
29
34
  }
@@ -402,8 +402,43 @@ export function isUsableChatGptAnswer(answer) {
402
402
  if (headerOnly)
403
403
  return false;
404
404
  }
405
+ // A composer tool renders its own progress panel into the assistant turn
406
+ // before any text exists. Measured on a --tool web-search send: "Searching
407
+ // the web" over an "Answer now" button, which the page reports as a settled
408
+ // two-line answer. Only treat it as a placeholder when it is the WHOLE
409
+ // content, so an answer that discusses searching still counts.
410
+ if (lineCount <= 2) {
411
+ const toolPanelOnly = normalized
412
+ .split(/\r?\n/)
413
+ .map((line) => line.trim())
414
+ .filter(Boolean)
415
+ .every((line) => /^(searching the web|웹\s*검색\s*중|answer now|지금\s*답변|검색\s*중)$/i.test(line));
416
+ if (toolPanelOnly)
417
+ return false;
418
+ }
405
419
  return true;
406
420
  }
421
+ /**
422
+ * Who decides the answer is finished: the transcript, when it can be read.
423
+ *
424
+ * The page only guesses - a tool's progress panel looks exactly like a settled
425
+ * two-line answer, and a consult once returned "Searching the web / Answer now"
426
+ * as its result. When the transcript is reachable AND belongs to this consult,
427
+ * its state is authoritative: "pending" keeps the wait alive even if the page
428
+ * looks done. "unavailable" (unreachable, or a different conversation) is the
429
+ * only case that falls back to reading the page.
430
+ */
431
+ export function classifyTranscriptRead(state, sentPrompt) {
432
+ if (!state)
433
+ return "unavailable";
434
+ if (!transcriptMatchesSentPrompt(state.userText, sentPrompt))
435
+ return "unavailable";
436
+ if (state.ok)
437
+ return "answer";
438
+ return state.reason === "answer_not_finished" || state.reason === "no_assistant_message" || state.reason === "answer_empty"
439
+ ? "pending"
440
+ : "unavailable";
441
+ }
407
442
  export function hasFreshChatGptAnswer(previousAssistantMessageCount, state) {
408
443
  return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer) && !state.generating;
409
444
  }
@@ -1750,25 +1785,23 @@ export async function recoverChatGptAnswerFromThread(options) {
1750
1785
  warnings: []
1751
1786
  };
1752
1787
  }
1753
- /**
1754
- * Read the answer from the conversation transcript, or undefined when it is not
1755
- * there yet. The transcript trails the rendered stream by a beat, so callers
1756
- * either poll it or fall back to the DOM text.
1757
- */
1758
- async function readTranscriptAnswer(page, conversationId) {
1788
+ async function readTranscriptAnswer(page, conversationId, sentPrompt) {
1759
1789
  let transcript;
1760
1790
  try {
1761
1791
  transcript = await evaluateOnPage(page, transcriptAnswerExpression(conversationId), { timeoutMs: 30_000 });
1762
1792
  }
1763
1793
  catch {
1764
- // Transcript unavailable (endpoint changed, transient failure): the DOM
1794
+ // Transcript unreachable (endpoint changed, transient failure): the DOM
1765
1795
  // reader still runs, so this never blocks a send.
1766
- return undefined;
1796
+ return { classification: "unavailable" };
1767
1797
  }
1768
- if (!transcript.ok || transcript.text.trim().length === 0)
1769
- return undefined;
1798
+ const classification = classifyTranscriptRead(transcript, sentPrompt);
1799
+ if (classification !== "answer")
1800
+ return { classification };
1770
1801
  const answer = resolveTranscriptCitations(transcript.text, transcript.references).trim();
1771
- return answer.length > 0 ? { answer, modelSlug: transcript.modelSlug } : undefined;
1802
+ return answer.length > 0
1803
+ ? { classification: "answer", answer: { answer, modelSlug: transcript.modelSlug } }
1804
+ : { classification: "pending" };
1772
1805
  }
1773
1806
  export async function sendChatGptPrompt(options) {
1774
1807
  const port = resolveCdpPort(options.port);
@@ -2084,7 +2117,7 @@ export async function sendChatGptPrompt(options) {
2084
2117
  await sleep(5_000);
2085
2118
  continue;
2086
2119
  }
2087
- if (lastState.ok && lastState.report.trim().length > 0) {
2120
+ if (lastState.ok && lastState.report.trim().length > 0 && transcriptMatchesSentPrompt(lastState.userText, options.prompt)) {
2088
2121
  const report = resolveTranscriptCitations(lastState.report, lastState.references).trim();
2089
2122
  emitProgress("answered", `deep research report (${report.length} chars)`);
2090
2123
  return {
@@ -2115,15 +2148,19 @@ export async function sendChatGptPrompt(options) {
2115
2148
  await sleep(1000);
2116
2149
  try {
2117
2150
  finalState = await evaluateOnPage(page, answerExpression());
2151
+ // First conversation id wins. Re-deriving it every poll would let a tab
2152
+ // that wandered to another thread redirect the read to a stranger's
2153
+ // conversation - and the prompt check below is the second line of defence,
2154
+ // not the first.
2118
2155
  if (!transcriptConversationId && finalState?.url)
2119
2156
  transcriptConversationId = conversationIdFromThreadUrl(finalState.url);
2120
2157
  // The transcript is the same data the page renders, minus the rendering:
2121
2158
  // markdown instead of flattened innerText, an explicit finish state
2122
2159
  // instead of caret heuristics, and the model that actually answered.
2123
2160
  if (transcriptConversationId && !finalState.generating) {
2124
- const transcript = await readTranscriptAnswer(page, transcriptConversationId);
2125
- if (transcript)
2126
- return transcriptResult(transcript);
2161
+ const transcript = await readTranscriptAnswer(page, transcriptConversationId, options.prompt);
2162
+ if (transcript.answer)
2163
+ return transcriptResult(transcript.answer);
2127
2164
  }
2128
2165
  // Only the DOM reader depends on which thread the tab is showing; once the
2129
2166
  // conversation id is known, a wandering tab is harmless.
@@ -2167,12 +2204,15 @@ export async function sendChatGptPrompt(options) {
2167
2204
  // Give the transcript that beat: it carries markdown (tables and fenced
2168
2205
  // code that innerText flattens) and the model that actually answered.
2169
2206
  if (transcriptConversationId) {
2170
- for (let attempt = 0; attempt < 6; attempt += 1) {
2171
- const transcript = await readTranscriptAnswer(page, transcriptConversationId);
2172
- if (transcript)
2173
- return transcriptResult(transcript);
2174
- await sleep(1_500);
2175
- }
2207
+ const transcript = await readTranscriptAnswer(page, transcriptConversationId, options.prompt);
2208
+ if (transcript.answer)
2209
+ return transcriptResult(transcript.answer);
2210
+ // The transcript can read this conversation and says it is not done:
2211
+ // believe it over a page that merely looks settled. A tool's progress
2212
+ // panel renders exactly like a two-line answer, and that is how a
2213
+ // consult once returned "Searching the web / Answer now" as its result.
2214
+ if (transcript.classification === "pending")
2215
+ continue;
2176
2216
  }
2177
2217
  break;
2178
2218
  }
@@ -3030,7 +3070,7 @@ export function deepResearchUnreadableBlocker(threadUrl) {
3030
3070
  */
3031
3071
  export function transcriptAnswerExpression(conversationId) {
3032
3072
  return `(async () => {
3033
- const fail = (reason, extra) => Object.assign({ ok: false, reason, status: "", endTurn: false, isComplete: false, text: "", modelSlug: "", references: [] }, extra || {});
3073
+ const fail = (reason, extra) => Object.assign({ ok: false, reason, status: "", endTurn: false, isComplete: false, text: "", modelSlug: "", references: [], userText: "" }, extra || {});
3034
3074
  let token = "";
3035
3075
  try {
3036
3076
  const session = await fetch("/api/auth/session", { credentials: "include" });
@@ -3063,7 +3103,9 @@ export function transcriptAnswerExpression(conversationId) {
3063
3103
  const message = chain.find(
3064
3104
  (entry) => entry && entry.author && entry.author.role === "assistant" && entry.content && entry.content.content_type === "text"
3065
3105
  );
3066
- if (!message) return fail("no_assistant_message");
3106
+ const userMessage = chain.find((entry) => entry && entry.author && entry.author.role === "user" && entry.content);
3107
+ const userText = userMessage ? (userMessage.content.parts || []).filter((part) => typeof part === "string").join("") : "";
3108
+ if (!message) return fail("no_assistant_message", { userText });
3067
3109
  const parts = (message.content.parts || []).filter((part) => typeof part === "string");
3068
3110
  const text = parts.join("");
3069
3111
  const metadata = message.metadata || {};
@@ -3073,15 +3115,37 @@ export function transcriptAnswerExpression(conversationId) {
3073
3115
  isComplete: metadata.is_complete === true,
3074
3116
  text,
3075
3117
  modelSlug: metadata.model_slug || "",
3076
- references: Array.isArray(metadata.content_references) ? metadata.content_references : []
3118
+ references: Array.isArray(metadata.content_references) ? metadata.content_references : [],
3119
+ userText
3077
3120
  };
3078
3121
  if (state.status !== "finished_successfully" || !state.endTurn) return fail("answer_not_finished", state);
3079
3122
  if (!text) return fail("answer_empty", state);
3080
3123
  return Object.assign({ ok: true, reason: "" }, state);
3081
3124
  })()`;
3082
3125
  }
3126
+ const NORMALIZED_PROMPT_MATCH_CHARS = 120;
3127
+ /**
3128
+ * Does this transcript belong to the consult that is waiting on it?
3129
+ *
3130
+ * The browser is shared with the user and other agents. Reading "whatever
3131
+ * conversation the tab shows" once cost a consult its answer - prodex saved a
3132
+ * different conversation as the result. Identity is checked against the prompt
3133
+ * that was actually sent, normalized because ChatGPT re-wraps whitespace, and
3134
+ * as a containment test because the transcript wraps the prompt: a composer
3135
+ * tool prefixes it ("@Deep research ...") and attachments append to it.
3136
+ */
3137
+ export function transcriptMatchesSentPrompt(userText, sentPrompt) {
3138
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
3139
+ const seen = normalize(userText);
3140
+ const sent = normalize(sentPrompt);
3141
+ if (seen.length === 0 || sent.length === 0)
3142
+ return false;
3143
+ const expected = sent.slice(0, NORMALIZED_PROMPT_MATCH_CHARS);
3144
+ return seen.includes(expected);
3145
+ }
3083
3146
  // ChatGPT marks citations with private-use delimiters (U+E200 opens, U+E202
3084
3147
  // separates, U+E201 closes) and keeps the real sources in content_references.
3148
+ const CITATION_DELIMITER_PATTERN = /[\uE200-\uE206]/;
3085
3149
  const CITATION_MARKER_PATTERN = /\uE200[^\uE200-\uE206]*(?:[\uE202\uE204-\uE206][^\uE200-\uE206]*)*[\uE201\uE203]/g;
3086
3150
  /**
3087
3151
  * Turn those markers into ordinary markdown links, so a saved answer keeps the
@@ -3091,7 +3155,11 @@ const CITATION_MARKER_PATTERN = /\uE200[^\uE200-\uE206]*(?:[\uE202\uE204-\uE206]
3091
3155
  export function resolveTranscriptCitations(text, references = []) {
3092
3156
  const byMarker = new Map();
3093
3157
  for (const reference of references) {
3094
- if (reference && typeof reference.matched_text === "string" && reference.matched_text.length > 0) {
3158
+ // Only substitute on text that IS a marker. A `sources_footnote` reference
3159
+ // carries matched_text " " - a single space - and substituting on that
3160
+ // replaced every space in the document, fusing a 47k-character report into
3161
+ // one run-on word.
3162
+ if (reference && typeof reference.matched_text === "string" && CITATION_DELIMITER_PATTERN.test(reference.matched_text)) {
3095
3163
  byMarker.set(reference.matched_text, reference);
3096
3164
  }
3097
3165
  }
@@ -3117,7 +3185,7 @@ export function resolveTranscriptCitations(text, references = []) {
3117
3185
  }
3118
3186
  export function deepResearchReportExpression(conversationId) {
3119
3187
  return `(async () => {
3120
- const fail = (reason, status) => ({ ok: false, reason, status: status || "", report: "", chars: 0, references: [] });
3188
+ const fail = (reason, status, userText) => ({ ok: false, reason, status: status || "", report: "", chars: 0, references: [], userText: userText || "" });
3121
3189
  let token = "";
3122
3190
  try {
3123
3191
  const session = await fetch("/api/auth/session", { credentials: "include" });
@@ -3138,7 +3206,8 @@ export function deepResearchReportExpression(conversationId) {
3138
3206
  } catch (error) {
3139
3207
  return fail("conversation_error");
3140
3208
  }
3141
- const nodes = Object.keys((conversation && conversation.mapping) || {}).map((key) => conversation.mapping[key]);
3209
+ const mapping = (conversation && conversation.mapping) || {};
3210
+ const nodes = Object.keys(mapping).map((key) => mapping[key]);
3142
3211
  const widgetNode = nodes.find(
3143
3212
  (node) => node && node.message && node.message.metadata && node.message.metadata.chatgpt_sdk && node.message.metadata.chatgpt_sdk.widget_state
3144
3213
  );
@@ -3150,12 +3219,22 @@ export function deepResearchReportExpression(conversationId) {
3150
3219
  return fail("widget_state_unparsable");
3151
3220
  }
3152
3221
  const status = (state && state.status) || "";
3222
+ const chain = [];
3223
+ let walkId = conversation && conversation.current_node;
3224
+ let walkGuard = 0;
3225
+ while (walkId && mapping[walkId] && walkGuard < 2000) {
3226
+ walkGuard += 1;
3227
+ if (mapping[walkId].message) chain.push(mapping[walkId].message);
3228
+ walkId = mapping[walkId].parent;
3229
+ }
3230
+ const userNode = chain.find((entry) => entry && entry.author && entry.author.role === "user" && entry.content);
3231
+ const userText = userNode ? (userNode.content.parts || []).filter((part) => typeof part === "string").join("") : "";
3153
3232
  const message = (state && state.report_message) || null;
3154
3233
  const parts = message && message.content && message.content.parts;
3155
3234
  const report = Array.isArray(parts) ? parts.filter((part) => typeof part === "string").join("") : "";
3156
3235
  const references = message && message.metadata && Array.isArray(message.metadata.content_references) ? message.metadata.content_references : [];
3157
- if (!report) return fail("report_not_ready", status);
3158
- return { ok: true, reason: "", status, report, chars: report.length, references };
3236
+ if (!report) return fail("report_not_ready", status, userText);
3237
+ return { ok: true, reason: "", status, report, chars: report.length, references, userText };
3159
3238
  })()`;
3160
3239
  }
3161
3240
  /**
package/dist/cli-pro.js CHANGED
@@ -52,7 +52,7 @@ export async function runChatgptCommand(rest, io) {
52
52
  const task = await targetStore.createTask({
53
53
  source: "codex",
54
54
  title: "GPT Pro smoke",
55
- prompt: bundle.text,
55
+ prompt: bundle.sendText,
56
56
  repo_id: "default",
57
57
  provenance: {
58
58
  adapter: "chatgpt-control",
@@ -852,7 +852,8 @@ export async function runAskProCommand(rest, io) {
852
852
  const task = await targetStore.createTask({
853
853
  source: "codex",
854
854
  title: "GPT Pro consult",
855
- prompt: bundle.text,
855
+ // Record what ChatGPT actually received, not the preview rendering.
856
+ prompt: bundle.sendText,
856
857
  repo_id: "default",
857
858
  files: files.map((file) => ({ path: file, role: "context" })),
858
859
  provenance: {
@@ -901,7 +902,7 @@ export async function runAskProCommand(rest, io) {
901
902
  // fast. A dead holder is still reaped immediately.
902
903
  const sendOnce = () => withBrowserSendLock(busyWaitMs ?? browserTimeoutMs ?? 0, (detail) => io.stderr(`progress: ${detail}`), () => sendChatGptPrompt({
903
904
  port: browserPort,
904
- prompt: bundle.text,
905
+ prompt: bundle.sendText,
905
906
  targetUrl: normalizedTargetUrl,
906
907
  timeoutMs: browserTimeoutMs,
907
908
  ...(attachments.length > 0 ? { attachments } : {}),
@@ -1155,12 +1156,36 @@ Rules:
1155
1156
  Write the debate in the language of the topic.`;
1156
1157
  }
1157
1158
  /**
1158
- * MCP-facing wrapper around the visible-browser ask flow. Reuses the full CLI
1159
- * path (send pacing, task/session/receipt persistence, artifact save) by
1160
- * invoking runAskProCommand with captured io, then returns structured fields
1161
- * instead of printed lines. Registered only on the local stdio MCP server -
1162
- * never on the HTTP MCP surface, which is exposed to ChatGPT itself.
1159
+ * MCP-side counterpart to `pro browser recover`. A consult that outlives its
1160
+ * budget hands back the thread it landed in, but until now the only way to act
1161
+ * on that was a shell command - which an agent reaching prodex over MCP may not
1162
+ * be able to run. Recovery has to be reachable the same way the consult was.
1163
1163
  */
1164
+ export async function performBrowserRecoverForMcp(cwd, input) {
1165
+ const stdoutLines = [];
1166
+ const stderrLines = [];
1167
+ const argv = [
1168
+ "browser",
1169
+ "recover",
1170
+ "--target-url",
1171
+ input.thread,
1172
+ ...(input.timeout_ms !== undefined ? ["--timeout-ms", String(input.timeout_ms)] : [])
1173
+ ];
1174
+ await runProCommand(argv, {
1175
+ cwd,
1176
+ stdout: (line) => stdoutLines.push(line),
1177
+ stderr: (line) => stderrLines.push(line)
1178
+ }, async () => 0);
1179
+ const header = stdoutLines[0] ?? "";
1180
+ const [taskId = "", status = "", thread = ""] = header.split("\t");
1181
+ return {
1182
+ task_id: taskId,
1183
+ status,
1184
+ thread,
1185
+ answer: stdoutLines.slice(2).join("\n"),
1186
+ notes: stderrLines
1187
+ };
1188
+ }
1164
1189
  export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1165
1190
  const stdoutLines = [];
1166
1191
  const stderrLines = [];
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ import { printHelpIfRequested, assertNoExtraArgs, assertOnlyOptions, formatCliCo
19
19
  import { listRawResultsForInspection, listTasksForInspection, runReceiptsCommand, runResultsCommand, runSessionsCommand, runTasksCommand } from "./cli-ledger.js";
20
20
  import { isMissingFileError, errorMessage, formatBrowserCheckCommand, formatBrowserLoginCommand, formatInitCommand, formatReleaseStatusCommand, formatSetupCommand, sourceAwareReleaseMessage, sourceAwareSetupMessage } from "./cli-shared.js";
21
21
  import { TOKEN_BEARING_MCP_URL_AUTHORITY_WARNING, redactServerUrl, runInitCommand, runSetupCommand, runStartCommand, runStatusCommand, runTunnelCommand } from "./cli-server.js";
22
- import { assertNoMissingTerminalConsultResults, assertNoOrphanConsultResults, formatConfigWarningLine, isConsultRecord, performBrowserConsultForMcp, runAskProCommand, runChatgptCommand, runConsultsCommand, runProCommand } from "./cli-pro.js";
22
+ import { assertNoMissingTerminalConsultResults, assertNoOrphanConsultResults, formatConfigWarningLine, isConsultRecord, performBrowserConsultForMcp, performBrowserRecoverForMcp, runAskProCommand, runChatgptCommand, runConsultsCommand, runProCommand } from "./cli-pro.js";
23
23
  import { CLI_VERSION, printClaudeHelp, printDoctorHelp, printHelp, printMcpHelp, printOnboardHelp, printProjectHelp, printReleaseHelp } from "./cli-help.js";
24
24
  const execFileAsync = promisify(execFile);
25
25
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -208,7 +208,8 @@ export async function runCli(args, io = defaultIo()) {
208
208
  // pro_consult is stdio-only: the HTTP MCP surface is exposed to ChatGPT
209
209
  // itself (and possibly a tunnel) and must never drive the user's browser.
210
210
  await runMcpServer(mcpCwd, {
211
- browserConsult: (input, onProgress) => performBrowserConsultForMcp(mcpCwd, input, onProgress)
211
+ browserConsult: (input, onProgress) => performBrowserConsultForMcp(mcpCwd, input, onProgress),
212
+ browserRecover: (input) => performBrowserRecoverForMcp(mcpCwd, input)
212
213
  });
213
214
  return 0;
214
215
  }
@@ -336,7 +337,7 @@ repo: ${cwd}
336
337
  2. Let coding agents consult ChatGPT (stdio MCP: Claude, Codex, Cursor, ...):
337
338
  ${cli} claude config --cwd ${quotedCwd}${sourceCliOption}
338
339
  ${cli} claude prompt --cwd ${quotedCwd}${sourceCliOption}
339
- Agents get the bridge/ledger tools plus pro_consult (ask ChatGPT Pro directly; see docs/clients.md for Codex timeout and approval notes).
340
+ Agents get the bridge/ledger tools plus pro_consult (ask ChatGPT Pro directly; see docs/clients.md for Codex timeout and approval notes) and pro_recover (collect an answer that finished after a consult stopped waiting - including a deep research report).
340
341
  Saved setup defaults (--model/--project) apply to agent consults too - pin them once per repo so consults stop landing in the general chat list.
341
342
  Agents often run the MCP as \`prodex mcp\` with no --cwd, which misses a per-repo default. For a default that applies from ANY directory, set PRODEX_DEFAULT_PROJECT and PRODEX_DEFAULT_MODEL (in the agent's MCP env block, or your shell) to YOUR project/model. No project? Consults just go to the general chat. List your exact project names with \`${cli} pro browser projects\`.
342
343
  ${cli} pro debate-prompt --topic "your question"${sourceCliOption} # structured GPT Pro debate prompt for your agent
package/dist/mcp.js CHANGED
@@ -154,7 +154,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
154
154
  .array(McpShortTextSchema)
155
155
  .max(4)
156
156
  .optional()
157
- .describe("ChatGPT composer tools to enable for this consult: \"deep-research\" (a multi-minute browsed report - the timeout rises to 30 minutes automatically), \"web-search\" (current facts), \"create-image\". Deep research often replies with a CLARIFYING QUESTION first; answer it with a normal follow-up consult in the same thread."),
157
+ .describe("ChatGPT composer tools to enable for this consult: \"deep-research\" (a browsed report; prodex presses start and waits out the run, which takes about ten minutes, so the timeout rises to 30 minutes automatically and the FULL report comes back as the answer - if the budget still runs out, the blocker carries the thread and pro_recover collects the report later), \"web-search\" (current facts, with the sources kept as links), \"create-image\". Deep research sometimes replies with a CLARIFYING QUESTION instead; answer it with a normal follow-up consult in the same thread."),
158
158
  attach: z
159
159
  .array(McpShortTextSchema)
160
160
  .max(10)
@@ -187,6 +187,16 @@ export function createServer(cwd = process.cwd(), options = {}) {
187
187
  return asText(await browserConsult(input, onProgress));
188
188
  });
189
189
  }
190
+ const browserRecover = options.browserRecover;
191
+ if (browserRecover) {
192
+ server.registerTool("pro_recover", {
193
+ description: "Fetch a ChatGPT answer that finished AFTER a consult stopped waiting, and record it as a normal consult receipt. Use this whenever pro_consult came back with a timeout or a still-running blocker: those carry the thread URL, and the answer is almost always sitting in that thread. This is also how a deep research report is collected - a research run takes about ten minutes and keeps going even when the consult that started it has already returned. Reading is cheap and does not send anything, so it is safe to retry.",
194
+ inputSchema: {
195
+ thread: McpShortTextSchema.min(1).describe("The ChatGPT conversation URL from the blocker (its `thread` field)."),
196
+ timeout_ms: z.number().int().positive().max(600_000).optional()
197
+ }
198
+ }, async (input) => asText(await browserRecover(input)));
199
+ }
190
200
  return server;
191
201
  }
192
202
  export async function runMcpServer(cwd = process.cwd(), options = {}) {
package/docs/claude.md CHANGED
@@ -99,6 +99,7 @@ The server currently exposes ledger-first tools:
99
99
  - `repo_write_file_apply`
100
100
  - `repo_stage_reviewed_paths`
101
101
  - `pro_consult`
102
+ - `pro_recover`
102
103
 
103
104
  `bridge_complete_task` and `bridge_block_task` close tasks by writing durable `.bridge/results` records; they do not modify repo files. `bridge_fetch_result_artifact` only returns text artifacts that are listed on a result record and stored under `.bridge/artifacts/pro-consults/` or `.bridge/artifacts/results/`; it does not expose arbitrary `.bridge/artifacts` files. Newly finalized result artifacts record a sha256, and fetch rejects the artifact if its content changed afterward. The bridge rejects oversized result artifacts before task finalization; if a Pro browser answer is too large for `bridge_fetch_result_artifact`, it stays in the result summary with `answer_artifact_warning` instead of listing an unfetchable artifact.
104
105
 
@@ -106,7 +107,9 @@ Write tools are narrow and receipt-gated, and they require a git worktree with a
106
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
109
 
109
- No shell, public tunnel, direct ungated write, or direct ungated staging tools are exposed through the Claude stdio MCP server; the only browser-facing tool is the explicit `pro_consult` consult described above.
110
+ `pro_recover` collects an answer that finished after a consult stopped waiting. A timed-out or still-running consult returns the thread it landed in, and the answer is almost always sitting there; this is also how a deep research report is collected, since a research run keeps going after the consult that started it has returned. It reads a thread and sends nothing, so retrying it is safe. Like `pro_consult`, it is registered only on the local stdio MCP server.
111
+
112
+ No shell, public tunnel, direct ungated write, or direct ungated staging tools are exposed through the Claude stdio MCP server; the only browser-facing tools are the explicit `pro_consult` consult and the read-only `pro_recover` described above.
110
113
 
111
114
  ## First Prompt
112
115
 
package/docs/clients.md CHANGED
@@ -48,6 +48,13 @@ consult you expect. Claude Code needs no change: its default stdio tool
48
48
  timeout is effectively unlimited (~28h) unless you tightened `MCP_TOOL_TIMEOUT`
49
49
  or a per-server `"timeout"`.
50
50
 
51
+ A deep research consult (`tools: ["deep-research"]`) is the longest of these:
52
+ the run takes about ten minutes and prodex raises its own budget to 30, so a
53
+ client timeout below that aborts the call while the research keeps going. That
54
+ is recoverable rather than lost - `pro_recover` with the thread from the
55
+ blocker collects the report afterwards - but a client budget that covers the
56
+ run avoids the round trip.
57
+
51
58
  Approval gate (verified on Codex 0.142.5): Codex asks for per-call approval
52
59
  before invoking prodex MCP tools. In interactive `codex` sessions you simply
53
60
  approve the prompt. In non-interactive `codex exec`, the approval cannot be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.23.1",
3
+ "version": "0.25.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",