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