@youdie006/prodex 0.40.8 → 0.40.9
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 +8 -5
- package/dist/chatgpt-browser.js +155 -57
- package/dist/cli-args.js +2 -0
- package/dist/cli-help.js +12 -9
- package/dist/cli-pro.js +112 -19
- package/dist/continue-thread.js +9 -2
- package/dist/mcp.js +10 -6
- package/dist/schema.js +8 -0
- package/dist/store.js +3 -0
- package/docs/claude.md +4 -2
- package/docs/cli-reference.md +2 -2
- package/docs/clients.md +15 -1
- package/package.json +1 -1
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.
|
|
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` |
|
|
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
|
|
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
|
-
**
|
|
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
|
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -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.
|
|
721
|
-
*
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 = `
|
|
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 (
|
|
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
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
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.
|
|
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.
|
|
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,18 @@ 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
|
+
return value.replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1").replace(/\s+/g, " ").trim();
|
|
5227
|
+
}
|
|
5228
|
+
function chatGptRequestMarkerMatches(userText, requestId) {
|
|
5229
|
+
const markers = [...normalizeChatGptPromptText(userText).matchAll(/\[prodex-request:([a-f0-9]{32})\]/g)];
|
|
5230
|
+
return markers.at(-1)?.[1] === requestId && markers.filter((match) => match[1] === requestId).length === 1;
|
|
5231
|
+
}
|
|
5232
|
+
/** Full prompt and per-send identity; wrappers may contain tool/file labels. */
|
|
5233
|
+
export function chatGptRequestMatchesUserTurn(userText, sentPrompt, requestId) {
|
|
5234
|
+
return chatGptRequestMarkerMatches(userText, requestId) &&
|
|
5235
|
+
normalizeChatGptPromptText(userText).includes(normalizeChatGptPromptText(sentPrompt));
|
|
5236
|
+
}
|
|
5139
5237
|
/**
|
|
5140
5238
|
* Does this transcript belong to the consult that is waiting on it?
|
|
5141
5239
|
*
|
|
@@ -5151,12 +5249,8 @@ export function transcriptMatchesSentPrompt(userText, sentPrompt) {
|
|
|
5151
5249
|
// kept as "\\## File", fences as escaped backticks), so undo that before
|
|
5152
5250
|
// comparing - otherwise every prompt carrying markdown, which is every
|
|
5153
5251
|
// --file send, looks like a different conversation.
|
|
5154
|
-
const
|
|
5155
|
-
|
|
5156
|
-
.replace(/\s+/g, " ")
|
|
5157
|
-
.trim();
|
|
5158
|
-
const seen = normalize(userText);
|
|
5159
|
-
const sent = normalize(sentPrompt);
|
|
5252
|
+
const seen = normalizeChatGptPromptText(userText);
|
|
5253
|
+
const sent = normalizeChatGptPromptText(sentPrompt);
|
|
5160
5254
|
if (seen.length === 0 || sent.length === 0)
|
|
5161
5255
|
return false;
|
|
5162
5256
|
const expected = sent.slice(0, NORMALIZED_PROMPT_MATCH_CHARS);
|
|
@@ -5435,7 +5529,10 @@ export function answerExpression() {
|
|
|
5435
5529
|
// answer (a 0.21.3 fallback for deep research, which is read from the
|
|
5436
5530
|
// transcript now) turned a tool's progress panel into a 28-character
|
|
5437
5531
|
// "answer" that a consult returned as its result.
|
|
5438
|
-
const
|
|
5532
|
+
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
|
5533
|
+
// Pair only within the latest user turn; a previous reply is not the
|
|
5534
|
+
// answer to a new question whose assistant node has not rendered yet.
|
|
5535
|
+
const assistant = lastUserIndex < 0 ? undefined : messages.slice(lastUserIndex + 1).filter((message) => message.role === "assistant").at(-1);
|
|
5439
5536
|
const buttons = [...document.querySelectorAll('button,[role="button"]')]
|
|
5440
5537
|
.filter((node) => !!(node.offsetWidth || node.offsetHeight || node.getClientRects().length))
|
|
5441
5538
|
.filter((node) => !node.closest(excludedTextSelector))
|
|
@@ -5460,6 +5557,7 @@ export function answerExpression() {
|
|
|
5460
5557
|
awaitingResponseChoice: Boolean(document.querySelector(${responseChoiceSelector})),
|
|
5461
5558
|
assistantMessageCount: assistantMessages.length,
|
|
5462
5559
|
userMessageCount: userMessages.length,
|
|
5560
|
+
lastUserText: userMessages.at(-1)?.text || "",
|
|
5463
5561
|
// ChatGPT tags each assistant message with the model that produced it -
|
|
5464
5562
|
// the only ground truth for "did the Pro selection actually take".
|
|
5465
5563
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
315
|
-
--
|
|
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({
|
|
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
|
|
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 (
|
|
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({
|
|
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
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2228
|
-
//
|
|
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:
|
|
2237
|
-
|
|
2238
|
-
|
|
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
|
package/dist/continue-thread.js
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
package/docs/cli-reference.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|