@youdie006/prodex 0.40.3 → 0.40.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blocker-report.js +1 -1
- package/dist/bundle.js +35 -0
- package/dist/chatgpt-browser.js +205 -21
- package/dist/cli-help.js +1 -1
- package/dist/cli-pro.js +247 -16
- package/dist/cli.js +39 -2
- package/dist/config.js +6 -1
- package/dist/continue-thread.js +88 -6
- package/dist/http-mcp.js +23 -6
- package/dist/mcp-tools.js +3 -1
- package/dist/mcp.js +7 -2
- package/dist/schema.js +8 -0
- package/dist/store.js +11 -2
- package/package.json +1 -1
package/dist/blocker-report.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* reports "the catch-all is biggest" and names nothing to fix - measured, one
|
|
13
13
|
* of them held 165 of 267 blockers - so their message decides the group.
|
|
14
14
|
*/
|
|
15
|
-
const CATCH_ALL_CODES = new Set(["browser_send_failed", "consult_failed", "unknown_error"]);
|
|
15
|
+
export const CATCH_ALL_CODES = new Set(["browser_send_failed", "consult_failed", "unknown_error"]);
|
|
16
16
|
/**
|
|
17
17
|
* The part of a message that identifies the failure, with the varying parts
|
|
18
18
|
* removed: the same picker failure is written once with "Pro" and once with a
|
package/dist/bundle.js
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { makeBridgeId, nowIso, SCHEMA_VERSION } from "./schema.js";
|
|
3
3
|
import { readRepoFile } from "./repo.js";
|
|
4
|
+
/**
|
|
5
|
+
* Whether a file prodex was asked to INLINE is text at all.
|
|
6
|
+
*
|
|
7
|
+
* `--file` puts a file's text into the prompt and `--attach` uploads the file
|
|
8
|
+
* itself; pointing `--file` at a binary used to inline the bytes. Measured:
|
|
9
|
+
* `--file` on an executable produced a prompt carrying an ELF header and NUL
|
|
10
|
+
* bytes inside a text fence - 535 control bytes in a 798-byte preview - which
|
|
11
|
+
* costs tokens, answers nothing, and sends control characters through the
|
|
12
|
+
* composer and the prompt-identity check.
|
|
13
|
+
*
|
|
14
|
+
* A NUL byte is the classic test and the one that never false-positives on
|
|
15
|
+
* real source; a high share of other control bytes catches the rest without
|
|
16
|
+
* tripping on text that merely has tabs and newlines.
|
|
17
|
+
*/
|
|
18
|
+
export function looksLikeBinaryFileContent(content) {
|
|
19
|
+
if (content.includes("\u0000"))
|
|
20
|
+
return true;
|
|
21
|
+
const sample = content.slice(0, 4096);
|
|
22
|
+
if (sample.length === 0)
|
|
23
|
+
return false;
|
|
24
|
+
let control = 0;
|
|
25
|
+
for (const ch of sample) {
|
|
26
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
27
|
+
// Tab, newline and carriage return are ordinary text.
|
|
28
|
+
if (code === 9 || code === 10 || code === 13)
|
|
29
|
+
continue;
|
|
30
|
+
if (code < 32 || code === 0xfffd)
|
|
31
|
+
control += 1;
|
|
32
|
+
}
|
|
33
|
+
return control / sample.length > 0.1;
|
|
34
|
+
}
|
|
4
35
|
export async function buildDryRunBundle(root, input) {
|
|
5
36
|
const sections = [
|
|
6
37
|
"# prodex consult dry run",
|
|
@@ -16,6 +47,10 @@ export async function buildDryRunBundle(root, input) {
|
|
|
16
47
|
const sendSections = [input.prompt.trim()];
|
|
17
48
|
for (const file of input.files) {
|
|
18
49
|
const content = await readRepoFile(root, file, { maxLines: 500 });
|
|
50
|
+
if (looksLikeBinaryFileContent(content.content)) {
|
|
51
|
+
throw new Error(`--file ${file} is not a text file, and --file inlines a file's TEXT into the prompt. ` +
|
|
52
|
+
`Upload it instead: --attach ${file}.`);
|
|
53
|
+
}
|
|
19
54
|
files.push({ path: file, role: "context", bytes: Buffer.byteLength(content.content, "utf8") });
|
|
20
55
|
const fileSection = ["", `## File: ${file}`, "", "```text", content.content, "```"];
|
|
21
56
|
sections.push(...fileSection);
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -503,6 +503,17 @@ export function isUsableChatGptAnswer(answer) {
|
|
|
503
503
|
return false;
|
|
504
504
|
return true;
|
|
505
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* What comes back when the turn finished and wrote no text.
|
|
508
|
+
*
|
|
509
|
+
* Measured with `--tool create-image`: the image was generated and rendered,
|
|
510
|
+
* the assistant message in the transcript came back finished with an empty
|
|
511
|
+
* part (the image lives in a separate tool message), and prodex waited out the
|
|
512
|
+
* whole budget before reporting a timeout for a result that was already there.
|
|
513
|
+
* The thread URL travels with every answer, so saying so and pointing at it
|
|
514
|
+
* beats six minutes of silence.
|
|
515
|
+
*/
|
|
516
|
+
export const CHATGPT_NON_TEXT_ANSWER_NOTE = "[no text answer] ChatGPT finished this turn without writing any text - an image or another non-text result. Open the thread to see it.";
|
|
506
517
|
/**
|
|
507
518
|
* Who decides the answer is finished: the transcript, when it can be read.
|
|
508
519
|
*
|
|
@@ -520,9 +531,16 @@ export function classifyTranscriptRead(state, sentPrompt) {
|
|
|
520
531
|
return "unavailable";
|
|
521
532
|
if (state.ok)
|
|
522
533
|
return "answer";
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
534
|
+
// A turn that FINISHED with no text is not a turn still being written. The
|
|
535
|
+
// transcript only reports answer_empty after checking the turn ended, and
|
|
536
|
+
// calling it "pending" is what made an image request wait out its whole
|
|
537
|
+
// budget: measured, `--tool create-image` produced the image, its assistant
|
|
538
|
+
// message came back finished with an empty part, and the send spent six and
|
|
539
|
+
// a half minutes "stabilizing" before reporting a timeout for an answer that
|
|
540
|
+
// was sitting in the thread.
|
|
541
|
+
if (state.reason === "answer_empty")
|
|
542
|
+
return "no_text";
|
|
543
|
+
return state.reason === "answer_not_finished" || state.reason === "no_assistant_message" ? "pending" : "unavailable";
|
|
526
544
|
}
|
|
527
545
|
/**
|
|
528
546
|
* Should prodex drag the tab back to the thread it pinned?
|
|
@@ -540,8 +558,11 @@ export function shouldRecoverThreadNavigation(args) {
|
|
|
540
558
|
return false;
|
|
541
559
|
if (!conversationIdFromThreadUrl(pinnedThreadUrl))
|
|
542
560
|
return false;
|
|
543
|
-
if (lastTranscriptClassification === "pending" ||
|
|
561
|
+
if (lastTranscriptClassification === "pending" ||
|
|
562
|
+
lastTranscriptClassification === "answer" ||
|
|
563
|
+
lastTranscriptClassification === "no_text") {
|
|
544
564
|
return false;
|
|
565
|
+
}
|
|
545
566
|
return !chatGptUrlsReferToSameTarget(currentUrl, pinnedThreadUrl);
|
|
546
567
|
}
|
|
547
568
|
export function hasFreshChatGptAnswer(previousAssistantMessageCount, state) {
|
|
@@ -1360,6 +1381,66 @@ async function dispatchEscapeKey(cdp) {
|
|
|
1360
1381
|
* stamp is what proves the old document is gone; the URL alone can be read off
|
|
1361
1382
|
* the very page we are trying to leave.
|
|
1362
1383
|
*/
|
|
1384
|
+
/**
|
|
1385
|
+
* Put the tab on a specific conversation, for a thread prodex itself resolved.
|
|
1386
|
+
*/
|
|
1387
|
+
async function openChatGptThread(cdp, url) {
|
|
1388
|
+
const conversationId = conversationIdFromThreadUrl(url);
|
|
1389
|
+
if (!conversationId)
|
|
1390
|
+
throw new Error(`Not a ChatGPT conversation URL: ${url}`);
|
|
1391
|
+
await cdp.evaluate(`location.assign(${JSON.stringify(url)})`);
|
|
1392
|
+
const deadline = Date.now() + RELOAD_SETTLE_TIMEOUT_MS;
|
|
1393
|
+
while (Date.now() < deadline) {
|
|
1394
|
+
await sleep(250);
|
|
1395
|
+
try {
|
|
1396
|
+
if (await cdp.evaluate(chatGptThreadReadyExpression(conversationId)))
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
catch (error) {
|
|
1400
|
+
// Between documents the context is gone and the next poll lands on the
|
|
1401
|
+
// new one; a command timeout means the tab stopped answering.
|
|
1402
|
+
if (cdpCommandTimedOut(error))
|
|
1403
|
+
throw error;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
throw new ChatGptBrowserBlockerError(chatGptThreadUnavailableBlocker(url));
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* The conversation a follow-up names cannot be opened.
|
|
1410
|
+
*
|
|
1411
|
+
* Retrying cannot undelete a thread, and the generic "resolve the visible
|
|
1412
|
+
* browser issue manually" this used to fall back to describes a browser that
|
|
1413
|
+
* is working fine - measured on a thread whose project had been deleted: the
|
|
1414
|
+
* cause was named in the message and then thrown away by the catch-all next
|
|
1415
|
+
* step underneath it.
|
|
1416
|
+
*/
|
|
1417
|
+
/**
|
|
1418
|
+
* The composer's model selector never rendered.
|
|
1419
|
+
*
|
|
1420
|
+
* Always transient in the field: the composer is still being built, which is
|
|
1421
|
+
* why the poll above exists at all. The catch-all it used to fall into said
|
|
1422
|
+
* "resolve the visible browser issue manually" - there is no issue to resolve,
|
|
1423
|
+
* and the next send usually works.
|
|
1424
|
+
*/
|
|
1425
|
+
export function chatGptComposerNotReadyBlocker(reason) {
|
|
1426
|
+
return {
|
|
1427
|
+
code: "composer_not_ready",
|
|
1428
|
+
message: `ChatGPT's composer did not finish rendering its model selector${reason ? ` (${reason})` : ""}, so the model could not be chosen.`,
|
|
1429
|
+
retryable: true,
|
|
1430
|
+
next_step: "Nothing was sent. Retry - the composer is usually a moment behind a page that has just navigated, and a send right " +
|
|
1431
|
+
"after another one lands on it mid-render."
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
export function chatGptThreadUnavailableBlocker(url) {
|
|
1435
|
+
return {
|
|
1436
|
+
code: "thread_unavailable",
|
|
1437
|
+
message: `ChatGPT did not open the conversation to continue (${url}). It may have been deleted, or its project was.`,
|
|
1438
|
+
retryable: false,
|
|
1439
|
+
next_step: "That conversation cannot be reached, and retrying will not bring it back. Send without --continue to start a new one, " +
|
|
1440
|
+
"or name a different consult with --continue-task <task_id> (`prodex pro list` shows them).",
|
|
1441
|
+
thread: url
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1363
1444
|
async function openFreshChatGptHome(cdp) {
|
|
1364
1445
|
await cdp.evaluate(markDocumentForReloadExpression());
|
|
1365
1446
|
await cdp.evaluate(`location.assign("https://chatgpt.com/")`);
|
|
@@ -1641,6 +1722,20 @@ export function reloadedDocumentReadyExpression(extraCondition = "true") {
|
|
|
1641
1722
|
* home that fails to load, and the composer candidate is the real editor
|
|
1642
1723
|
* rather than the broad selector that also matches a hidden fallback.
|
|
1643
1724
|
*/
|
|
1725
|
+
/**
|
|
1726
|
+
* True once the tab is on this conversation and has rendered a real composer.
|
|
1727
|
+
*
|
|
1728
|
+
* The id is compared rather than the whole URL because ChatGPT rewrites the
|
|
1729
|
+
* project part of it (measured: the same project appears with and without its
|
|
1730
|
+
* name), and the composer has to be the real editor rather than the hidden 0x0
|
|
1731
|
+
* fallback that the broad selector also matches.
|
|
1732
|
+
*/
|
|
1733
|
+
export function chatGptThreadReadyExpression(conversationId) {
|
|
1734
|
+
return `(() => {${composerExpressionHelpers()}
|
|
1735
|
+
if (!location.href.includes(${JSON.stringify(conversationId)})) return false;
|
|
1736
|
+
return Boolean(findChatGptComposerCandidate());
|
|
1737
|
+
})()`;
|
|
1738
|
+
}
|
|
1644
1739
|
export function freshChatGptHomeReadyExpression() {
|
|
1645
1740
|
return reloadedDocumentReadyExpression(`/^https:\\/\\/chatgpt\\.com\\/?(?:[?#].*)?$/.test(location.href) && (() => {${composerExpressionHelpers()}
|
|
1646
1741
|
return Boolean(findChatGptComposerCandidate());
|
|
@@ -1757,9 +1852,13 @@ export function composerProjectBinding(input) {
|
|
|
1757
1852
|
const named = /^new\s+chat\s+in\s+(.+)$/i.exec(placeholder)?.[1] ?? /^(.+?)\uc5d0\uc11c\s*\uc0c8\s*\ucc44\ud305$/.exec(placeholder)?.[1];
|
|
1758
1853
|
if (named)
|
|
1759
1854
|
return named.trim().toLowerCase() === wanted ? "bound" : "elsewhere";
|
|
1760
|
-
// The
|
|
1761
|
-
//
|
|
1762
|
-
|
|
1855
|
+
// The label a plain new chat carries: recognised, and it names no project, so
|
|
1856
|
+
// the composer belongs to none. Two wordings measured on the same live root -
|
|
1857
|
+
// the hidden fallback textarea says "Ask ChatGPT" while the editor prodex
|
|
1858
|
+
// actually reads says "Chat with ChatGPT" - and reading only the first left
|
|
1859
|
+
// the clearest case of "this composer is not the project's" reported as a
|
|
1860
|
+
// label that could not be read.
|
|
1861
|
+
if (/^(?:ask|chat with)\s+chatgpt$/i.test(placeholder))
|
|
1763
1862
|
return "elsewhere";
|
|
1764
1863
|
return "unknown";
|
|
1765
1864
|
}
|
|
@@ -2379,8 +2478,15 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
2379
2478
|
// new-chat navigation the composer form (and the selector inside it) has not
|
|
2380
2479
|
// finished rendering yet, so a single check throws "model selector button not
|
|
2381
2480
|
// found" even though the button appears a moment later.
|
|
2481
|
+
//
|
|
2482
|
+
// Four seconds was not enough for the case this browser exists for: two
|
|
2483
|
+
// agents sending one after the other. Measured - a send that started two
|
|
2484
|
+
// seconds behind another, queued on the lock, and entered a page the first
|
|
2485
|
+
// one had only just finished with, failed here once in three attempts while
|
|
2486
|
+
// the same pattern succeeded either side of it. The neighbouring waits for
|
|
2487
|
+
// the same kind of render already allow six to eight.
|
|
2382
2488
|
let button = { ok: false };
|
|
2383
|
-
const buttonDeadline = Date.now() +
|
|
2489
|
+
const buttonDeadline = Date.now() + PROJECT_NAVIGATION_TIMEOUT_MS;
|
|
2384
2490
|
for (;;) {
|
|
2385
2491
|
button = await cdp.evaluate(modelButtonRectExpression());
|
|
2386
2492
|
if (button.ok && button.x !== undefined && button.y !== undefined)
|
|
@@ -2390,7 +2496,7 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
2390
2496
|
await sleep(200);
|
|
2391
2497
|
}
|
|
2392
2498
|
if (!button.ok || button.x === undefined || button.y === undefined) {
|
|
2393
|
-
throw new
|
|
2499
|
+
throw new ChatGptBrowserBlockerError(chatGptComposerNotReadyBlocker(button.reason));
|
|
2394
2500
|
}
|
|
2395
2501
|
// Skip the menu entirely when the picker already shows the requested model:
|
|
2396
2502
|
// it is the same end state, and it survives ChatGPT reshuffling the menu
|
|
@@ -3046,7 +3152,7 @@ async function readTranscriptAnswer(page, conversationId, sentPrompt) {
|
|
|
3046
3152
|
const answer = resolveTranscriptCitations(transcript.text, transcript.references).trim();
|
|
3047
3153
|
return answer.length > 0
|
|
3048
3154
|
? { classification: "answer", answer: { answer, modelSlug: transcript.modelSlug } }
|
|
3049
|
-
: { classification: "
|
|
3155
|
+
: { classification: "no_text" };
|
|
3050
3156
|
}
|
|
3051
3157
|
// A page that has not reported the prompt posting within this long is worth
|
|
3052
3158
|
// double-checking against the transcript; the probe is a couple of small fetches.
|
|
@@ -3086,7 +3192,10 @@ export async function sendChatGptPrompt(options) {
|
|
|
3086
3192
|
if (options.newChat && normalizedTargetUrl) {
|
|
3087
3193
|
throw new Error("newChat cannot be combined with targetUrl: a fresh chat navigates away from the pinned tab.");
|
|
3088
3194
|
}
|
|
3089
|
-
|
|
3195
|
+
// A resolved thread is reached by navigating, so page discovery must not
|
|
3196
|
+
// demand a tab already sitting on it.
|
|
3197
|
+
const requireTabAtTargetUrl = options.navigateToTargetUrl ? undefined : normalizedTargetUrl;
|
|
3198
|
+
const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), requireTabAtTargetUrl);
|
|
3090
3199
|
if (!pageResult.ok) {
|
|
3091
3200
|
throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
|
|
3092
3201
|
}
|
|
@@ -3094,8 +3203,8 @@ export async function sendChatGptPrompt(options) {
|
|
|
3094
3203
|
if (pageResult.blocker) {
|
|
3095
3204
|
throw new ChatGptBrowserBlockerError(pageResult.blocker);
|
|
3096
3205
|
}
|
|
3097
|
-
if (
|
|
3098
|
-
assertChatGptTargetTabAvailable(
|
|
3206
|
+
if (requireTabAtTargetUrl) {
|
|
3207
|
+
assertChatGptTargetTabAvailable(requireTabAtTargetUrl);
|
|
3099
3208
|
}
|
|
3100
3209
|
assertChatGptPageAvailable();
|
|
3101
3210
|
}
|
|
@@ -3242,14 +3351,21 @@ export async function sendChatGptPrompt(options) {
|
|
|
3242
3351
|
awaitingResponseChoice: status.awaitingResponseChoice === true,
|
|
3243
3352
|
...(options.newChat !== undefined ? { newChat: options.newChat } : {}),
|
|
3244
3353
|
...(options.project !== undefined ? { project: options.project } : {}),
|
|
3245
|
-
...(options.projectNew !== undefined ? { projectNew: options.projectNew } : {})
|
|
3354
|
+
...(options.projectNew !== undefined ? { projectNew: options.projectNew } : {}),
|
|
3355
|
+
// Navigating to another conversation leaves the parked one alone, the
|
|
3356
|
+
// same way a fresh chat or a project home does.
|
|
3357
|
+
...(options.navigateToTargetUrl ? { newChat: true } : {})
|
|
3246
3358
|
})) {
|
|
3247
3359
|
throw new ChatGptBrowserBlockerError(chatGptResponseChoiceBlocker(true));
|
|
3248
3360
|
}
|
|
3249
3361
|
assertChatGptIdleAndReadyForPrompt(status, busyBlocker, true);
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3362
|
+
// Only a PINNED target has to be under the tab already; a resolved thread is
|
|
3363
|
+
// navigated to below, and asserting the match here would refuse the send for
|
|
3364
|
+
// the tab merely being somewhere else - which is the whole reason a
|
|
3365
|
+
// continuation resolves from records rather than from the tab.
|
|
3366
|
+
if (requireTabAtTargetUrl)
|
|
3367
|
+
assertChatGptTargetUrlMatches(status.url, requireTabAtTargetUrl);
|
|
3368
|
+
assertVisibleChatGptTab(status.visibilityState, status.url, requireTabAtTargetUrl);
|
|
3253
3369
|
emitProgress("tab_ready");
|
|
3254
3370
|
// Progress details deliberately avoid project names (receipts redact them too).
|
|
3255
3371
|
const selectionSummary = [
|
|
@@ -3315,6 +3431,9 @@ export async function sendChatGptPrompt(options) {
|
|
|
3315
3431
|
// keeps Work's composer, and switching afterwards does not move it.
|
|
3316
3432
|
// Max and Ultra are rungs of Work's slider, so asking for one means staying
|
|
3317
3433
|
// there; anything else belongs on Chat, whose top step is Pro.
|
|
3434
|
+
if (normalizedTargetUrl && options.navigateToTargetUrl) {
|
|
3435
|
+
await openChatGptThread(cdp, normalizedTargetUrl);
|
|
3436
|
+
}
|
|
3318
3437
|
if (!effortNeedsWorkSurface(options.effort)) {
|
|
3319
3438
|
// Leaving the current page is safe only for a send that was going to
|
|
3320
3439
|
// navigate anyway; a continuation or a pinned tab has to be reloaded
|
|
@@ -3440,7 +3559,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3440
3559
|
// looks the way it looked when it refused, and the selection failures this
|
|
3441
3560
|
// project keeps hitting are invisible in the error text alone.
|
|
3442
3561
|
await captureOnFailure(`send-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
3443
|
-
throw error;
|
|
3562
|
+
throw attachSendWarnings(error, sendWarnings);
|
|
3444
3563
|
}
|
|
3445
3564
|
finally {
|
|
3446
3565
|
cdp.close();
|
|
@@ -3635,6 +3754,13 @@ export async function sendChatGptPrompt(options) {
|
|
|
3635
3754
|
lastTranscriptClassification = transcript.classification;
|
|
3636
3755
|
if (transcript.answer)
|
|
3637
3756
|
return transcriptResult(transcript.answer);
|
|
3757
|
+
// A finished turn with no text is an answer of a different shape - an
|
|
3758
|
+
// image, measured - and waiting for words it will never write spends
|
|
3759
|
+
// the whole budget and then calls the result a timeout. The page must
|
|
3760
|
+
// agree it has stopped generating before this counts.
|
|
3761
|
+
if (transcript.classification === "no_text") {
|
|
3762
|
+
return transcriptResult({ answer: CHATGPT_NON_TEXT_ANSWER_NOTE, modelSlug: "" });
|
|
3763
|
+
}
|
|
3638
3764
|
}
|
|
3639
3765
|
if (shouldRecoverThreadNavigation({ pinnedThreadUrl, currentUrl: finalState?.url, lastTranscriptClassification })) {
|
|
3640
3766
|
if (recoveredNavigations >= 2) {
|
|
@@ -4354,6 +4480,39 @@ export function portAccepts(port, timeoutMs = 250) {
|
|
|
4354
4480
|
}
|
|
4355
4481
|
/** True when a fetch failed because its AbortSignal.timeout fired, not because nothing was listening. */
|
|
4356
4482
|
/** Whether an error is a DevTools command that got no answer in time - the one failure that has closed the socket. */
|
|
4483
|
+
/**
|
|
4484
|
+
* Carry the warnings a failed send collected out with its error.
|
|
4485
|
+
*
|
|
4486
|
+
* They are attached to the RESULT, so a send that throws loses them - and the
|
|
4487
|
+
* one that explains the failure is exactly the kind that gets lost: a send
|
|
4488
|
+
* that had to move off ChatGPT's Work surface, or that recovered a browser,
|
|
4489
|
+
* and then died at the next step reported nothing about either. The error
|
|
4490
|
+
* already carries `thread` this way and the classifier already reads it off,
|
|
4491
|
+
* so this follows the same road.
|
|
4492
|
+
*/
|
|
4493
|
+
export function attachSendWarnings(error, warnings) {
|
|
4494
|
+
if (warnings.length === 0)
|
|
4495
|
+
return error;
|
|
4496
|
+
if (typeof error !== "object" || error === null)
|
|
4497
|
+
return error;
|
|
4498
|
+
const carrier = error;
|
|
4499
|
+
if (Array.isArray(carrier.warnings))
|
|
4500
|
+
return error;
|
|
4501
|
+
try {
|
|
4502
|
+
carrier.warnings = [...warnings];
|
|
4503
|
+
}
|
|
4504
|
+
catch {
|
|
4505
|
+
// A frozen error is still the error; the warnings are a bonus, not a duty.
|
|
4506
|
+
}
|
|
4507
|
+
return error;
|
|
4508
|
+
}
|
|
4509
|
+
/** The warnings a failed send carried out with it, if any survived. */
|
|
4510
|
+
export function sendWarningsFromError(error) {
|
|
4511
|
+
if (typeof error !== "object" || error === null)
|
|
4512
|
+
return [];
|
|
4513
|
+
const carried = error.warnings;
|
|
4514
|
+
return Array.isArray(carried) ? carried.filter((entry) => typeof entry === "string") : [];
|
|
4515
|
+
}
|
|
4357
4516
|
export function cdpCommandTimedOut(error) {
|
|
4358
4517
|
return error instanceof Error && /Chrome DevTools command timed out/.test(error.message);
|
|
4359
4518
|
}
|
|
@@ -4901,11 +5060,33 @@ export function composerToolsButtonRectExpression() {
|
|
|
4901
5060
|
})()`;
|
|
4902
5061
|
}
|
|
4903
5062
|
/** Click point for a tools-menu entry, matched by its visible label. */
|
|
5063
|
+
/**
|
|
5064
|
+
* Where a tool's name must NOT be taken from.
|
|
5065
|
+
*
|
|
5066
|
+
* The lookup searches the document for the tool's name, because the menu this
|
|
5067
|
+
* ChatGPT build renders is not reachable by any container selector that was
|
|
5068
|
+
* tried - it carries no menu role, no aria-controls, and no floating popover
|
|
5069
|
+
* node. That search is fine until the sidebar holds a CHAT titled like a tool:
|
|
5070
|
+
* measured, the first `--tool create-image` send of an account succeeds and
|
|
5071
|
+
* leaves a conversation called "Create Image", and every send after it matched
|
|
5072
|
+
* that chat row, clicked it, navigated to the old conversation, and then
|
|
5073
|
+
* reported that the composer never showed the tool as active.
|
|
5074
|
+
*
|
|
5075
|
+
* Scoping the search to an open menu instead looked right and broke every tool
|
|
5076
|
+
* - web-search included, which had just been measured working - because
|
|
5077
|
+
* nothing matched the container. Excluding the places a tool name can only be
|
|
5078
|
+
* a coincidence keeps the search that works and removes the match that lies.
|
|
5079
|
+
*/
|
|
5080
|
+
const COMPOSER_TOOL_LOOKUP_EXCLUDED_ANCESTORS = 'nav,aside,[role="navigation"],[data-sidebar-item],[data-testid="conversation-turn"],[data-message-author-role]';
|
|
4904
5081
|
export function composerToolEntryRectExpression(label) {
|
|
4905
5082
|
const candidatesJson = JSON.stringify(composerToolMenuTexts(label).map((text) => text.toLowerCase()));
|
|
5083
|
+
const excludedJson = JSON.stringify(COMPOSER_TOOL_LOOKUP_EXCLUDED_ANCESTORS);
|
|
4906
5084
|
return `(() => {${CLICK_POINT_SNIPPET}
|
|
4907
5085
|
const candidates = ${candidatesJson};
|
|
4908
|
-
const
|
|
5086
|
+
const excluded = ${excludedJson};
|
|
5087
|
+
const leaves = [...document.querySelectorAll("div,span,button,a")].filter(
|
|
5088
|
+
(el) => el.children.length === 0 && !el.closest(excluded)
|
|
5089
|
+
);
|
|
4909
5090
|
const leaf = leaves.find((el) => candidates.includes((el.textContent || "").trim().toLowerCase()));
|
|
4910
5091
|
if (!leaf) {
|
|
4911
5092
|
const available = [...new Set(leaves.map((el) => (el.textContent || "").trim()).filter((t) => t.length > 1 && t.length < 30))].slice(0, 20);
|
|
@@ -4924,8 +5105,11 @@ export function activeComposerToolsExpression(labels) {
|
|
|
4924
5105
|
// selection did not take.
|
|
4925
5106
|
const el = document.querySelector('#prompt-textarea,[contenteditable="true"]');
|
|
4926
5107
|
const form = el ? (el.closest("form") || el.parentElement) : null;
|
|
4927
|
-
const text = (el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "");
|
|
4928
|
-
|
|
5108
|
+
const text = ((el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "")).toLowerCase();
|
|
5109
|
+
// Case-insensitively: prodex carries the label as the menu spells it
|
|
5110
|
+
// ("Create image") while the page has been measured using "Create Image"
|
|
5111
|
+
// for the same tool.
|
|
5112
|
+
return { ok: true, active: ${labelsJson}.filter((label) => text.includes(String(label).toLowerCase())) };
|
|
4929
5113
|
})()`;
|
|
4930
5114
|
}
|
|
4931
5115
|
export function composerTextStateExpression(expectedText, toolLabels = []) {
|
package/dist/cli-help.js
CHANGED
|
@@ -89,7 +89,7 @@ Optional visible-browser send defaults (applied by \`pro browser ask\` when the
|
|
|
89
89
|
--model Composer model by its exact menu label. Only Pro applies on the current picker: it is a slider step, and the model rows cannot be clicked
|
|
90
90
|
--pro-mode Pro sub-mode: 기본 (standard) or 확장 (extended)
|
|
91
91
|
--effort Reasoning effort: 즉시 / 중간 / 높음 / 매우 높음 / Max / Ultra / Pro. Max and Ultra are rungs of ChatGPT's Work surface and only apply when the browser is already on Work (prodex does not switch to Work for them); every other value is sent on Chat, whose top step is Pro
|
|
92
|
-
--allow-model-fallback Send even when the requested
|
|
92
|
+
--allow-model-fallback Send even when the requested EFFORT step could not be applied. Off by default: a send that could not reach the step it asked for stops instead, because an answer from a step nobody asked for is usually unusable. It does not gate the model axis - this picker's model rows cannot be driven at all, so a model it will not provide is always a warning and the send goes out on whatever the composer had; model_used and pro_verified say what actually replied
|
|
93
93
|
--project Sidebar project to enter before sending
|
|
94
94
|
Clear a saved default with --clear-model / --clear-pro-mode / --clear-effort / --clear-project.
|
|
95
95
|
--pro-mode and --effort are different model axes and cannot be combined. View saved defaults with \`prodex status\`.`);
|
package/dist/cli-pro.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, statSync } from "node:fs";
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { buildDryRunBundle } from "./bundle.js";
|
|
5
|
-
import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
|
|
5
|
+
import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
|
|
6
6
|
import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
|
|
7
7
|
import { printProBrowserHelp, printProHelp } from "./cli-help.js";
|
|
8
8
|
import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
|
|
@@ -10,8 +10,8 @@ import { formatBrowserDefaults, redactServerUrl } from "./cli-server.js";
|
|
|
10
10
|
import { errorMessage, firstLine, formatBlockedConsultRecordedMessage, formatProLatestCommand, formatBrowserCheckCommand, formatBrowserLoginCommand, formatBrowserSmokeCommand, formatBrowserTargetAskCommand, formatInitCommand, formatSetupCommand, isMissingFileError, computeSendPacingWaitMs, isUntrustedResultError, resolveMinSendIntervalMs, sourceAwareBrowserBlocker, sourceAwareBrowserNextStep, sourceAwareResultError, sourceAwareResultMessage, sourceAwareSetupMessage } from "./cli-shared.js";
|
|
11
11
|
import { getTokenExpiryStatus, loadBrowserDefaults, loadLocalConfig } from "./config.js";
|
|
12
12
|
import { withBrowserSendLock } from "./browser-send-lock.js";
|
|
13
|
-
import { blockerCause, buildBlockerReport } from "./blocker-report.js";
|
|
14
|
-
import { resolveContinuationThread } from "./continue-thread.js";
|
|
13
|
+
import { blockerCause, buildBlockerReport, CATCH_ALL_CODES } from "./blocker-report.js";
|
|
14
|
+
import { projectIdFromSidebar, resolveContinuationThread } from "./continue-thread.js";
|
|
15
15
|
import { readBridgeRoots } from "./registry.js";
|
|
16
16
|
import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
|
|
17
17
|
import { CLI_VERSION } from "./cli-help.js";
|
|
@@ -759,7 +759,7 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
759
759
|
consults.push({
|
|
760
760
|
repo: path.basename(root),
|
|
761
761
|
createdAt: result.created_at,
|
|
762
|
-
...(result.blocker ? { blocker:
|
|
762
|
+
...(result.blocker ? { blocker: reclassifyRecordedBlocker(result.blocker) } : {})
|
|
763
763
|
});
|
|
764
764
|
}
|
|
765
765
|
}
|
|
@@ -1129,6 +1129,20 @@ export async function runAskProCommand(rest, io) {
|
|
|
1129
1129
|
// Scope by the project this send would have used, so a follow-up cannot
|
|
1130
1130
|
// land in another project's conversation.
|
|
1131
1131
|
const continuationProject = explicitProject ?? (suppressProject ? undefined : browserDefaults?.project);
|
|
1132
|
+
// The name alone cannot identify a project written in another script -
|
|
1133
|
+
// measured, two of this account's projects slug to nothing - so the id
|
|
1134
|
+
// is read off the live sidebar. A sidebar that cannot be read leaves
|
|
1135
|
+
// the name matching to do what it can; the send would fail on the same
|
|
1136
|
+
// browser anyway.
|
|
1137
|
+
let continuationProjectId;
|
|
1138
|
+
if (continuationProject) {
|
|
1139
|
+
try {
|
|
1140
|
+
continuationProjectId = projectIdFromSidebar(await listChatGptProjectsWithIds({ port: resolveCdpPort(readPortFlag(parsedAskPro.optionArgs, "--port")) }), continuationProject);
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
continuationProjectId = undefined;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1132
1146
|
const resolved = resolveContinuationThread({
|
|
1133
1147
|
consults: (await targetStore.listSessionsReadOnly()).map((session) => ({
|
|
1134
1148
|
taskId: session.task_id ?? "",
|
|
@@ -1137,6 +1151,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
1137
1151
|
...(session.created_at ? { createdAt: session.created_at } : {})
|
|
1138
1152
|
})),
|
|
1139
1153
|
...(continuationProject ? { project: continuationProject } : {}),
|
|
1154
|
+
...(continuationProjectId ? { projectId: continuationProjectId } : {}),
|
|
1140
1155
|
...(continueTaskId !== undefined ? { taskId: continueTaskId } : {})
|
|
1141
1156
|
});
|
|
1142
1157
|
if ("error" in resolved)
|
|
@@ -1313,6 +1328,9 @@ export async function runAskProCommand(rest, io) {
|
|
|
1313
1328
|
port: browserPort,
|
|
1314
1329
|
prompt: bundle.sendText,
|
|
1315
1330
|
targetUrl: normalizedTargetUrl,
|
|
1331
|
+
// A thread prodex resolved from its own records is reached by
|
|
1332
|
+
// navigating; a --target-url the person confirmed is not moved.
|
|
1333
|
+
...(continuedFromTaskId ? { navigateToTargetUrl: true } : {}),
|
|
1316
1334
|
timeoutMs: browserTimeoutMs,
|
|
1317
1335
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
1318
1336
|
...(tools.length > 0 ? { tools } : {}),
|
|
@@ -1366,6 +1384,12 @@ export async function runAskProCommand(rest, io) {
|
|
|
1366
1384
|
// --new-chat there is no target url, and a blocker that started a run
|
|
1367
1385
|
// still has a thread worth handing back.
|
|
1368
1386
|
const blockedThread = blocker.thread ?? normalizedTargetUrl;
|
|
1387
|
+
// What the send had already noticed before it died. These used to go
|
|
1388
|
+
// out with the result, so a failure dropped them - including the note
|
|
1389
|
+
// that would explain it, like having just moved off the Work surface.
|
|
1390
|
+
const blockedWarnings = sendWarningsFromError(error).map(redactProject);
|
|
1391
|
+
for (const warning of blockedWarnings)
|
|
1392
|
+
io.stderr(warning);
|
|
1369
1393
|
const persistedBlocker = {
|
|
1370
1394
|
...blocker,
|
|
1371
1395
|
message: redactProject(blocker.message),
|
|
@@ -1376,7 +1400,17 @@ export async function runAskProCommand(rest, io) {
|
|
|
1376
1400
|
status: "blocked",
|
|
1377
1401
|
summary: redactProject(message),
|
|
1378
1402
|
commands: ["visible ChatGPT browser consult"],
|
|
1379
|
-
|
|
1403
|
+
warnings: blockedWarnings,
|
|
1404
|
+
blocker: persistedBlocker,
|
|
1405
|
+
// What was asked for, on the record that failed. Without it a
|
|
1406
|
+
// timeout cannot be read back against the budget it was given.
|
|
1407
|
+
provenance: {
|
|
1408
|
+
...(blockedThread ? { thread: blockedThread } : {}),
|
|
1409
|
+
...(Object.keys(selectionMetadata).length > 0
|
|
1410
|
+
? { selection: redactSelectionForRecord(selectionMetadata, redactProject) }
|
|
1411
|
+
: {}),
|
|
1412
|
+
warnings: blockedWarnings
|
|
1413
|
+
}
|
|
1380
1414
|
});
|
|
1381
1415
|
await writeSessionBestEffort(targetStore, {
|
|
1382
1416
|
id: bundle.id,
|
|
@@ -1395,7 +1429,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
1395
1429
|
// Keep stdout machine-parseable for --json consumers on the blocked
|
|
1396
1430
|
// path too; the human-readable error still goes to stderr via throw.
|
|
1397
1431
|
if (jsonOutput) {
|
|
1398
|
-
io.stdout(JSON.stringify({ task_id: task.id, status: "blocked", thread: blockedThread ?? null, answer: null, warnings:
|
|
1432
|
+
io.stdout(JSON.stringify({ task_id: task.id, status: "blocked", thread: blockedThread ?? null, answer: null, warnings: blockedWarnings, blocker }, null, 2));
|
|
1399
1433
|
}
|
|
1400
1434
|
throw new Error(formatBlockedConsultRecordedMessage(message, task.id, sourceCli, { cwd: targetCwd }));
|
|
1401
1435
|
}
|
|
@@ -1432,6 +1466,13 @@ export async function runAskProCommand(rest, io) {
|
|
|
1432
1466
|
io.stderr(warning);
|
|
1433
1467
|
if (consult.modelSlug)
|
|
1434
1468
|
io.stderr(`model_used: ${consult.modelSlug}`);
|
|
1469
|
+
// Which conversation this followed, and where the answer actually landed.
|
|
1470
|
+
// The progress line that says it is filtered out of MCP notes, so an
|
|
1471
|
+
// agent asking for a follow-up had no way to know which thread it got -
|
|
1472
|
+
// or whether the answer is in the project it asked for.
|
|
1473
|
+
if (continuedFromTaskId)
|
|
1474
|
+
io.stderr(`continued_from: ${continuedFromTaskId}`);
|
|
1475
|
+
io.stderr(`destination: ${destination.destination}${destination.verified === undefined ? "" : ` verified=${destination.verified}`}`);
|
|
1435
1476
|
// "Can I count this as a Pro review?" - answered here rather than left
|
|
1436
1477
|
// for whoever reads the receipt to work out from two other fields.
|
|
1437
1478
|
const proVerified = proSelectionVerified({
|
|
@@ -1500,6 +1541,9 @@ export async function runAskProCommand(rest, io) {
|
|
|
1500
1541
|
warnings: persistenceWarnings,
|
|
1501
1542
|
provenance: {
|
|
1502
1543
|
thread: consult.url,
|
|
1544
|
+
...(Object.keys(selectionMetadata).length > 0
|
|
1545
|
+
? { selection: redactSelectionForRecord(selectionMetadata, redactProjectNamesForRecord(selectionMetadata)) }
|
|
1546
|
+
: {}),
|
|
1503
1547
|
warnings: persistenceWarnings
|
|
1504
1548
|
}
|
|
1505
1549
|
});
|
|
@@ -1525,6 +1569,11 @@ export async function runAskProCommand(rest, io) {
|
|
|
1525
1569
|
status: result.status,
|
|
1526
1570
|
thread: consult.url,
|
|
1527
1571
|
answer: result.summary,
|
|
1572
|
+
...(continuedFromTaskId ? { continued_from: continuedFromTaskId } : {}),
|
|
1573
|
+
destination: {
|
|
1574
|
+
observed: destination.destination,
|
|
1575
|
+
...(destination.verified !== undefined ? { verified: destination.verified } : {})
|
|
1576
|
+
},
|
|
1528
1577
|
warnings: persistenceWarnings
|
|
1529
1578
|
}, null, 2));
|
|
1530
1579
|
}
|
|
@@ -1626,6 +1675,30 @@ export async function performBrowserRecoverForMcp(cwd, input) {
|
|
|
1626
1675
|
notes: stderrLines
|
|
1627
1676
|
};
|
|
1628
1677
|
}
|
|
1678
|
+
/** The marker the send prints before throwing, when the answer outlived its record. */
|
|
1679
|
+
const ANSWER_NOT_SAVED_MARKER = "consult_answer_received_but_not_saved:";
|
|
1680
|
+
/**
|
|
1681
|
+
* Pull an answer out of a send that got one and then failed to record it.
|
|
1682
|
+
*
|
|
1683
|
+
* The CLI prints `consult_answer_received_but_not_saved: <task> <thread>`,
|
|
1684
|
+
* a blank line, and the answer, then throws - so a person still has the text.
|
|
1685
|
+
* An agent calling through MCP only saw the throw, which discarded exactly the
|
|
1686
|
+
* answer that was most expensive to get.
|
|
1687
|
+
*/
|
|
1688
|
+
export function answerRescuedFromFailedPersistence(stdoutLines) {
|
|
1689
|
+
const index = stdoutLines.findIndex((line) => line.startsWith(ANSWER_NOT_SAVED_MARKER));
|
|
1690
|
+
if (index === -1)
|
|
1691
|
+
return undefined;
|
|
1692
|
+
const [taskId = "", thread = ""] = stdoutLines[index].slice(ANSWER_NOT_SAVED_MARKER.length).trim().split(/\s+/);
|
|
1693
|
+
const answer = stdoutLines
|
|
1694
|
+
.slice(index + 1)
|
|
1695
|
+
.join("\n")
|
|
1696
|
+
.replace(/^\n+/, "")
|
|
1697
|
+
.trim();
|
|
1698
|
+
if (!answer)
|
|
1699
|
+
return undefined;
|
|
1700
|
+
return { taskId, thread, answer };
|
|
1701
|
+
}
|
|
1629
1702
|
export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
1630
1703
|
const stdoutLines = [];
|
|
1631
1704
|
const stderrLines = [];
|
|
@@ -1652,16 +1725,38 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
|
1652
1725
|
"--",
|
|
1653
1726
|
input.prompt
|
|
1654
1727
|
];
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
onProgress(
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1728
|
+
try {
|
|
1729
|
+
await runAskProCommand(argv, {
|
|
1730
|
+
cwd,
|
|
1731
|
+
stdout: (line) => stdoutLines.push(line),
|
|
1732
|
+
stderr: (line) => {
|
|
1733
|
+
stderrLines.push(line);
|
|
1734
|
+
if (onProgress && line.startsWith("progress:"))
|
|
1735
|
+
onProgress(line);
|
|
1736
|
+
},
|
|
1737
|
+
allowAskProBrowserSend: true
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
catch (error) {
|
|
1741
|
+
// A send whose ANSWER arrived and whose recording then failed prints the
|
|
1742
|
+
// answer and throws, so the CLI caller still has it. Rethrowing here threw
|
|
1743
|
+
// it away instead - the one case where that costs the most, since the
|
|
1744
|
+
// answer is usually a Pro run someone waited minutes for. Hand it back
|
|
1745
|
+
// with the failure attached rather than losing it.
|
|
1746
|
+
const rescued = answerRescuedFromFailedPersistence(stdoutLines);
|
|
1747
|
+
if (!rescued)
|
|
1748
|
+
throw error;
|
|
1749
|
+
return {
|
|
1750
|
+
task_id: rescued.taskId,
|
|
1751
|
+
status: "answered_not_saved",
|
|
1752
|
+
thread: rescued.thread,
|
|
1753
|
+
answer: rescued.answer,
|
|
1754
|
+
notes: [
|
|
1755
|
+
...stderrLines.filter((line) => !line.startsWith("progress:")),
|
|
1756
|
+
`answer_not_saved: ${errorMessage(error)}`
|
|
1757
|
+
]
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1665
1760
|
const header = stdoutLines[0] ?? "";
|
|
1666
1761
|
const [taskId = "", status = "", thread = ""] = header.split("\t");
|
|
1667
1762
|
return {
|
|
@@ -1940,6 +2035,35 @@ export function redactProjectNames(text, names) {
|
|
|
1940
2035
|
}
|
|
1941
2036
|
return redacted;
|
|
1942
2037
|
}
|
|
2038
|
+
/** The project name is scrubbed in records; the rest of a selection is not. */
|
|
2039
|
+
export function redactSelectionForRecord(selection, redact) {
|
|
2040
|
+
const recorded = {};
|
|
2041
|
+
for (const [key, value] of Object.entries(selection)) {
|
|
2042
|
+
recorded[key] = key === "project" || key === "project_new" ? redact(value) : value;
|
|
2043
|
+
}
|
|
2044
|
+
return recorded;
|
|
2045
|
+
}
|
|
2046
|
+
/** The redactor a record needs when it has only the selection to go on. */
|
|
2047
|
+
function redactProjectNamesForRecord(selection) {
|
|
2048
|
+
return (text) => redactProjectNames(text, [selection.project, selection.project_new]);
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* A recorded blocker, read with what the classifier knows NOW.
|
|
2052
|
+
*
|
|
2053
|
+
* Records keep the code they were written with. Most of the failures that
|
|
2054
|
+
* recur were the catch-all when they were recorded - 178 of 296 in this
|
|
2055
|
+
* machine's ledger - and only got their own codes afterwards, so a report
|
|
2056
|
+
* grouped by the recorded code kept showing "browser_send_failed: ..." rows
|
|
2057
|
+
* for causes that have names. Re-reading the message through the classifier
|
|
2058
|
+
* names them; a message it still cannot place keeps its recorded code, and a
|
|
2059
|
+
* record that was never the catch-all is left exactly as written.
|
|
2060
|
+
*/
|
|
2061
|
+
export function reclassifyRecordedBlocker(recorded) {
|
|
2062
|
+
if (!CATCH_ALL_CODES.has(recorded.code))
|
|
2063
|
+
return { code: recorded.code, message: recorded.message };
|
|
2064
|
+
const live = browserSendBlockerFromError(new Error(recorded.message));
|
|
2065
|
+
return { code: CATCH_ALL_CODES.has(live.code) ? recorded.code : live.code, message: recorded.message };
|
|
2066
|
+
}
|
|
1943
2067
|
export function browserSendBlockerFromError(error) {
|
|
1944
2068
|
const blocker = typeof error === "object" && error !== null && "blocker" in error ? error.blocker : undefined;
|
|
1945
2069
|
if (typeof blocker === "object" &&
|
|
@@ -2017,6 +2141,100 @@ export function browserSendBlockerFromError(error) {
|
|
|
2017
2141
|
next_step: "Nothing was sent, so nothing landed in the wrong project. Retry - the composer normally binds on the next navigation - or open the project once in the visible browser and send again."
|
|
2018
2142
|
};
|
|
2019
2143
|
}
|
|
2144
|
+
// Everything below came out of the ledger rather than out of the code: 178
|
|
2145
|
+
// of 296 recorded blockers were the catch-all, and replaying their messages
|
|
2146
|
+
// through this function showed 109 still landing there - each one told to
|
|
2147
|
+
// "resolve the visible browser issue manually", which is advice for a
|
|
2148
|
+
// problem nobody has. These are the recurring ones that are still reachable,
|
|
2149
|
+
// in the order they actually happen.
|
|
2150
|
+
//
|
|
2151
|
+
// All of them fail BEFORE the prompt is submitted: the selection steps and
|
|
2152
|
+
// the post-insertion check all run ahead of the send, so "nothing was sent"
|
|
2153
|
+
// is a fact here and not a hope.
|
|
2154
|
+
if (/Refusing to click/.test(message)) {
|
|
2155
|
+
return {
|
|
2156
|
+
code: "click_blocked",
|
|
2157
|
+
message,
|
|
2158
|
+
retryable: true,
|
|
2159
|
+
next_step: "Something on the page was sitting over the control prodex needed, so nothing was sent. Retry - these covers are " +
|
|
2160
|
+
"usually a banner or a menu that goes away on its own - or clear it in the visible browser."
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
if (/Composer text did not match the prompt after insertion|Composer stayed empty after text insertion/.test(message)) {
|
|
2164
|
+
return {
|
|
2165
|
+
code: "composer_text_mismatch",
|
|
2166
|
+
message,
|
|
2167
|
+
retryable: true,
|
|
2168
|
+
next_step: "The composer did not end up holding the prompt, so nothing was sent. Clear whatever is in it in the visible " +
|
|
2169
|
+
"browser and retry, or send into a fresh chat with --new-chat."
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
if (/did not finish accepting .* within the upload budget/.test(message)) {
|
|
2173
|
+
return {
|
|
2174
|
+
code: "attachment_upload_timeout",
|
|
2175
|
+
message,
|
|
2176
|
+
retryable: true,
|
|
2177
|
+
next_step: "ChatGPT was still ingesting the attachment when the budget ran out, so nothing was sent. Retry with a longer " +
|
|
2178
|
+
"--timeout-ms, or inline a text file with --file instead of uploading it."
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
if (/composer tools menu has no "/.test(message)) {
|
|
2182
|
+
return {
|
|
2183
|
+
code: "tool_not_offered",
|
|
2184
|
+
message,
|
|
2185
|
+
// The menu was read and the tool is not in it; asking again reads the
|
|
2186
|
+
// same menu.
|
|
2187
|
+
retryable: false,
|
|
2188
|
+
next_step: "Nothing was sent. This account's composer does not offer that tool - drop --tool and ask for the same thing in the prompt."
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
if (/model selector button not found|did not expose its power slider|Could not open the ChatGPT model selector|model menu is not open|power slider not found/.test(message)) {
|
|
2192
|
+
return {
|
|
2193
|
+
code: "composer_not_ready",
|
|
2194
|
+
message,
|
|
2195
|
+
retryable: true,
|
|
2196
|
+
next_step: "The composer had not finished rendering its picker, so nothing was sent. Retry - it is usually a moment behind " +
|
|
2197
|
+
"a page that has just navigated, and a send right after another one lands on it mid-render."
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
if (/but the composer never showed it as active/.test(message)) {
|
|
2201
|
+
return {
|
|
2202
|
+
code: "tool_not_applied",
|
|
2203
|
+
message,
|
|
2204
|
+
retryable: true,
|
|
2205
|
+
next_step: "The composer tool was chosen but never turned on, so nothing was sent. Retry, or drop --tool and ask for the " +
|
|
2206
|
+
"same thing in the prompt."
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
if (/ChatGPT project not found in sidebar/.test(message)) {
|
|
2210
|
+
return {
|
|
2211
|
+
code: "project_not_found",
|
|
2212
|
+
message,
|
|
2213
|
+
// prodex already waits for the sidebar to hydrate before saying this, so
|
|
2214
|
+
// the name is genuinely not there; asking again asks the same sidebar.
|
|
2215
|
+
retryable: false,
|
|
2216
|
+
next_step: "Nothing was sent. The sidebar has no project by that name - list the exact spellings with " +
|
|
2217
|
+
"`prodex pro browser projects`, then pass one of those, or send without --project."
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
if (/Clicking project "[^"]*" did not navigate/.test(message)) {
|
|
2221
|
+
return {
|
|
2222
|
+
code: "project_navigation_failed",
|
|
2223
|
+
message,
|
|
2224
|
+
retryable: true,
|
|
2225
|
+
next_step: "The sidebar click did not move the tab into the project, so nothing was sent. Retry, or open the project once " +
|
|
2226
|
+
"in the visible browser and send again."
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
if (/Pro option not found in the model menu/.test(message)) {
|
|
2230
|
+
return {
|
|
2231
|
+
code: "selection_not_applied",
|
|
2232
|
+
message,
|
|
2233
|
+
retryable: false,
|
|
2234
|
+
next_step: "Nothing was sent. This account's picker does not offer Pro as a menu option - ask for it on the effort axis " +
|
|
2235
|
+
"with `--effort Pro`, or send without a model and check `model_used` in the answer."
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2020
2238
|
// The picker could not provide the step that was asked for. Retrying asks
|
|
2021
2239
|
// the same picker the same question, so this is not retryable; the caller
|
|
2022
2240
|
// either picks a step it offers or opts into whatever the slider is on.
|
|
@@ -2102,6 +2320,15 @@ async function recordedModelUsed(store, taskId) {
|
|
|
2102
2320
|
return undefined;
|
|
2103
2321
|
}
|
|
2104
2322
|
}
|
|
2323
|
+
/** One line naming the model, effort and project a send asked for, or nothing. */
|
|
2324
|
+
export function formatAskedFor(selection) {
|
|
2325
|
+
if (!selection)
|
|
2326
|
+
return undefined;
|
|
2327
|
+
const parts = ["model", "pro_mode", "effort", "project", "project_new"]
|
|
2328
|
+
.filter((key) => selection[key])
|
|
2329
|
+
.map((key) => `${key}=${selection[key]}`);
|
|
2330
|
+
return parts.length > 0 ? `asked_for: ${parts.join(" ")}` : undefined;
|
|
2331
|
+
}
|
|
2105
2332
|
export function formatProAnswer(consult, sourceCli, options = {}) {
|
|
2106
2333
|
const blocker = sourceAwareProAnswerBlocker(consult, sourceCli, options);
|
|
2107
2334
|
const summary = sourceAwareProAnswerSummary(consult.result.summary, consult.result.blocker, blocker);
|
|
@@ -2109,6 +2336,10 @@ export function formatProAnswer(consult, sourceCli, options = {}) {
|
|
|
2109
2336
|
`task_id: ${consult.task.id}`,
|
|
2110
2337
|
`status: ${consult.result.status}`,
|
|
2111
2338
|
consult.task.provenance.thread ? `thread: ${consult.task.provenance.thread}` : undefined,
|
|
2339
|
+
// What the send asked for. Recorded on failures as well as answers now,
|
|
2340
|
+
// and a person reading a timeout needs it on the same screen as the
|
|
2341
|
+
// budget it used up.
|
|
2342
|
+
formatAskedFor(consult.task.provenance.selection),
|
|
2112
2343
|
`created_at: ${consult.result.created_at}`,
|
|
2113
2344
|
"",
|
|
2114
2345
|
summary
|
package/dist/cli.js
CHANGED
|
@@ -289,6 +289,33 @@ export async function runCli(args, io = defaultIo()) {
|
|
|
289
289
|
}
|
|
290
290
|
throw unknownTopLevelCommandError(command);
|
|
291
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Whether something other than prodex is sitting on the configured port.
|
|
294
|
+
*
|
|
295
|
+
* A prodex HTTP server answers /mcp - with 401 when the token is missing,
|
|
296
|
+
* which is still an answer. Anything else holding the port means `prodex
|
|
297
|
+
* start` cannot bind it, and the configured URL points at a stranger.
|
|
298
|
+
* Silence (nothing listening) is the healthy case.
|
|
299
|
+
*/
|
|
300
|
+
export async function describeConfiguredPortHolder(host, port) {
|
|
301
|
+
let response;
|
|
302
|
+
try {
|
|
303
|
+
response = await fetch(`http://${host}:${port}/mcp`, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: { "Content-Type": "application/json" },
|
|
306
|
+
body: "{}",
|
|
307
|
+
signal: AbortSignal.timeout(1_500)
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// Nothing is listening, or it refused the connection: the port is free for
|
|
312
|
+
// prodex to take, which is what this check is about.
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
if (response.status === 401 || response.status === 200 || response.status === 400)
|
|
316
|
+
return undefined;
|
|
317
|
+
return `port ${port} on ${host} is held by something that is not a prodex server (it answered /mcp with HTTP ${response.status}), so \`prodex start\` cannot bind it.`;
|
|
318
|
+
}
|
|
292
319
|
function defaultIo() {
|
|
293
320
|
return {
|
|
294
321
|
// PRODEX_CWD wins over a working directory prodex cannot use (a /dev/fd
|
|
@@ -1020,6 +1047,14 @@ async function runDoctor(store, io, sourceCli, setupHintCwd) {
|
|
|
1020
1047
|
const warningLine = formatConfigWarningLine(tokenStatus, sourceCli, setupHintCwd);
|
|
1021
1048
|
if (warningLine)
|
|
1022
1049
|
io.stdout(warningLine);
|
|
1050
|
+
// "ok" has to mean the configured endpoint can actually be served.
|
|
1051
|
+
// Measured here: the configured port was held by an unrelated program,
|
|
1052
|
+
// so `prodex start` could never bind it - and doctor called the config
|
|
1053
|
+
// ok anyway, which is the one line someone reads before believing it.
|
|
1054
|
+
const portHolder = await describeConfiguredPortHolder(config.host, config.port);
|
|
1055
|
+
if (portHolder) {
|
|
1056
|
+
io.stdout(`config_warning: ${portHolder} \`${formatSetupCommand(sourceCli, { cwd: setupHintCwd })} --port <free port>\` moves prodex off it.`);
|
|
1057
|
+
}
|
|
1023
1058
|
}
|
|
1024
1059
|
}
|
|
1025
1060
|
catch (error) {
|
|
@@ -1082,7 +1117,9 @@ async function runHttpMcpCatalogSmoke() {
|
|
|
1082
1117
|
cwd,
|
|
1083
1118
|
host: "127.0.0.1",
|
|
1084
1119
|
port: 0,
|
|
1085
|
-
token: "doctor-token"
|
|
1120
|
+
token: "doctor-token",
|
|
1121
|
+
// A smoke bridge in a temp directory is not a place anyone works.
|
|
1122
|
+
registerRoot: false
|
|
1086
1123
|
});
|
|
1087
1124
|
client = new Client({ name: "prodex-doctor", version: CLI_VERSION });
|
|
1088
1125
|
await withTimeout(client.connect(new StreamableHTTPClientTransport(new URL(running.mcp_url))), 20_000, "timed out connecting to HTTP MCP server");
|
|
@@ -1353,7 +1390,7 @@ async function runMcpWriteSmoke() {
|
|
|
1353
1390
|
await execFileAsync("git", ["commit", "-m", "initial"], { cwd });
|
|
1354
1391
|
const { stdout: headOut } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd });
|
|
1355
1392
|
const head = headOut.trim();
|
|
1356
|
-
const handlers = createMcpToolHandlers({ cwd });
|
|
1393
|
+
const handlers = createMcpToolHandlers({ cwd, registerRoot: false });
|
|
1357
1394
|
const dryRun = await handlers.repo_write_file_dry_run({
|
|
1358
1395
|
path: "notes.md",
|
|
1359
1396
|
content: "new\n",
|
package/dist/config.js
CHANGED
|
@@ -252,8 +252,13 @@ export async function loadBrowserDefaults(cwd) {
|
|
|
252
252
|
// stop applying with nothing said, so a consult that should have landed in
|
|
253
253
|
// a project lands in the general chat and looks like it worked.
|
|
254
254
|
if (!isMissingFileError(error)) {
|
|
255
|
+
// Say only what is true. Passing the flags explicitly does NOT get past
|
|
256
|
+
// this - the config is read before any of them are looked at - and
|
|
257
|
+
// advice that does not work is worse than none. Moving the file aside
|
|
258
|
+
// does work: a repo with no config has no defaults and sends fine.
|
|
255
259
|
throw new Error(`${error instanceof Error ? error.message : String(error)} Until then prodex will not apply the browser defaults ` +
|
|
256
|
-
`pinned there (project, model),
|
|
260
|
+
`pinned there (project, model), and every send from this repo stops here. Move .bridge/config.local.json aside ` +
|
|
261
|
+
`if this repo does not need the HTTP MCP surface.`, { cause: error });
|
|
257
262
|
}
|
|
258
263
|
repo = undefined;
|
|
259
264
|
}
|
package/dist/continue-thread.js
CHANGED
|
@@ -28,6 +28,50 @@ export function chatGptProjectSlug(name) {
|
|
|
28
28
|
.replace(/[^a-z0-9]+/g, "-")
|
|
29
29
|
.replace(/^-+|-+$/g, "");
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether a recorded URL is a conversation someone can go back to.
|
|
33
|
+
*
|
|
34
|
+
* Not everything prodex records as a "thread" is one. Measured across this
|
|
35
|
+
* machine's records: six of them are `https://chatgpt.com/?temporary-chat=true`
|
|
36
|
+
* - a temporary chat is never saved, so returning to that URL opens a fresh
|
|
37
|
+
* empty one. Continuing into it would look like a follow-up and be a new
|
|
38
|
+
* conversation with none of the context, which is the failure --continue
|
|
39
|
+
* exists to remove rather than reproduce.
|
|
40
|
+
*/
|
|
41
|
+
export function isChatGptConversationUrl(threadUrl) {
|
|
42
|
+
const id = /\/c\/([^/?#]+)/.exec(threadUrl)?.[1];
|
|
43
|
+
if (!id)
|
|
44
|
+
return false;
|
|
45
|
+
// ChatGPT shows a provisional id - measured as "/c/WEB:<uuid>" - between the
|
|
46
|
+
// prompt posting and the server naming the conversation. It is not a
|
|
47
|
+
// conversation anyone can return to, and the id parser elsewhere already
|
|
48
|
+
// refuses it; accepting it here would let a follow-up pick a thread that
|
|
49
|
+
// then fails to open with an error about the URL rather than about the
|
|
50
|
+
// conversation.
|
|
51
|
+
return !/^web:/i.test(id);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The project ids these recorded threads have been seen using, by project name.
|
|
55
|
+
*
|
|
56
|
+
* Some thread URLs carry the project name after its id and some carry only the
|
|
57
|
+
* id (measured: three of this machine's hundred-odd records). The named ones
|
|
58
|
+
* teach the mapping, so the bare ones can still be recognised instead of being
|
|
59
|
+
* invisible to every follow-up - which would quietly continue an OLDER
|
|
60
|
+
* conversation while the newest one sat unmatched.
|
|
61
|
+
*/
|
|
62
|
+
export function projectIdsByName(threadUrls) {
|
|
63
|
+
const byName = new Map();
|
|
64
|
+
for (const url of threadUrls) {
|
|
65
|
+
const match = /\/g\/g-p-([0-9a-f]+)-([^/?#]+)/i.exec(url);
|
|
66
|
+
if (!match)
|
|
67
|
+
continue;
|
|
68
|
+
const name = match[2].toLowerCase();
|
|
69
|
+
const ids = byName.get(name) ?? new Set();
|
|
70
|
+
ids.add(match[1].toLowerCase());
|
|
71
|
+
byName.set(name, ids);
|
|
72
|
+
}
|
|
73
|
+
return byName;
|
|
74
|
+
}
|
|
31
75
|
/**
|
|
32
76
|
* Whether a recorded thread belongs to the project this send is for.
|
|
33
77
|
*
|
|
@@ -35,18 +79,46 @@ export function chatGptProjectSlug(name) {
|
|
|
35
79
|
* so a follow-up meant for the general chat cannot walk into a project - and a
|
|
36
80
|
* project's follow-up cannot land in another project's conversation.
|
|
37
81
|
*/
|
|
38
|
-
export function threadMatchesProject(threadUrl, project) {
|
|
82
|
+
export function threadMatchesProject(threadUrl, project, knownProjectIds) {
|
|
39
83
|
const projectSegment = /\/g\/(g-p-[^/?#]+)/.exec(threadUrl)?.[1];
|
|
40
84
|
if (!project)
|
|
41
85
|
return projectSegment === undefined;
|
|
42
86
|
if (!projectSegment)
|
|
43
87
|
return false;
|
|
88
|
+
// The slug is the name with everything but ASCII letters and digits turned
|
|
89
|
+
// to dashes, which for a name written in another script is nothing at all:
|
|
90
|
+
// measured, four of this account's five projects have Korean names and two
|
|
91
|
+
// of them slug to "". The id is the only thing that identifies those, and
|
|
92
|
+
// the caller reads it off the live sidebar.
|
|
44
93
|
const slug = chatGptProjectSlug(project);
|
|
45
|
-
if (!slug)
|
|
46
|
-
return false;
|
|
47
94
|
// The id comes first and the name follows it, so an exact suffix match keeps
|
|
48
95
|
// "notes" from answering for "notes-archive".
|
|
49
|
-
|
|
96
|
+
if (slug && projectSegment.toLowerCase().endsWith(`-${slug}`))
|
|
97
|
+
return true;
|
|
98
|
+
// No name in the URL: fall back to the id this project has been seen using.
|
|
99
|
+
// Nothing is guessed - the id came from another record of the same project -
|
|
100
|
+
// and it survives a rename, which the name comparison cannot.
|
|
101
|
+
const id = /^g-p-([0-9a-f]+)/i.exec(projectSegment)?.[1]?.toLowerCase();
|
|
102
|
+
return Boolean(id && knownProjectIds?.has(id));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The id of the project a name refers to, from the sidebar as it is now.
|
|
106
|
+
*
|
|
107
|
+
* Same rules as the sidebar click: the exact name first, then a
|
|
108
|
+
* case-insensitive match when it is the only one - never a guess between two.
|
|
109
|
+
* The id comes back without its "g-p-" prefix, which is how thread URLs are
|
|
110
|
+
* read everywhere else here.
|
|
111
|
+
*/
|
|
112
|
+
export function projectIdFromSidebar(projects, name) {
|
|
113
|
+
const wanted = name.trim();
|
|
114
|
+
const strip = (id) => id.replace(/^g-p-/i, "").toLowerCase();
|
|
115
|
+
const exact = projects.filter((project) => project.name.trim() === wanted);
|
|
116
|
+
if (exact.length === 1)
|
|
117
|
+
return strip(exact[0].id);
|
|
118
|
+
if (exact.length > 1)
|
|
119
|
+
return undefined;
|
|
120
|
+
const loose = projects.filter((project) => project.name.trim().toLowerCase() === wanted.toLowerCase());
|
|
121
|
+
return loose.length === 1 ? strip(loose[0].id) : undefined;
|
|
50
122
|
}
|
|
51
123
|
/**
|
|
52
124
|
* The thread a follow-up should continue, or why it cannot be resolved.
|
|
@@ -57,9 +129,16 @@ export function threadMatchesProject(threadUrl, project) {
|
|
|
57
129
|
* the conversation is the failure this exists to prevent.
|
|
58
130
|
*/
|
|
59
131
|
export function resolveContinuationThread(input) {
|
|
60
|
-
const withThread = input.consults.filter((consult) =>
|
|
132
|
+
const withThread = input.consults.filter((consult) => consult.thread && isChatGptConversationUrl(consult.thread));
|
|
61
133
|
if (input.taskId) {
|
|
62
134
|
const named = withThread.find((consult) => consult.taskId === input.taskId);
|
|
135
|
+
const recordedButUnusable = !named && input.consults.some((consult) => consult.taskId === input.taskId);
|
|
136
|
+
if (recordedButUnusable) {
|
|
137
|
+
return {
|
|
138
|
+
error: `Consult "${input.taskId}" has no conversation to continue - it was a temporary chat, or it never reached one. ` +
|
|
139
|
+
`A temporary chat is never saved, so there is nothing to go back to.`
|
|
140
|
+
};
|
|
141
|
+
}
|
|
63
142
|
if (!named) {
|
|
64
143
|
return {
|
|
65
144
|
error: `No recorded consult thread for "${input.taskId}". List what is here with \`prodex pro list\`, ` +
|
|
@@ -68,9 +147,12 @@ export function resolveContinuationThread(input) {
|
|
|
68
147
|
}
|
|
69
148
|
return { target: { taskId: named.taskId, thread: named.thread } };
|
|
70
149
|
}
|
|
150
|
+
const knownProjectIds = new Set(input.project ? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project)) ?? [] : []);
|
|
151
|
+
if (input.projectId)
|
|
152
|
+
knownProjectIds.add(input.projectId.toLowerCase());
|
|
71
153
|
const candidates = withThread
|
|
72
154
|
.filter((consult) => consult.status === "done")
|
|
73
|
-
.filter((consult) => threadMatchesProject(consult.thread, input.project))
|
|
155
|
+
.filter((consult) => threadMatchesProject(consult.thread, input.project, knownProjectIds))
|
|
74
156
|
.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
|
75
157
|
const latest = candidates[0];
|
|
76
158
|
if (!latest) {
|
package/dist/http-mcp.js
CHANGED
|
@@ -32,7 +32,7 @@ export async function startHttpMcpServer(options) {
|
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
34
|
if (req.method === "POST") {
|
|
35
|
-
await handlePost(req, res, transports, options.cwd, requestBodyLimitBytes);
|
|
35
|
+
await handlePost(req, res, transports, options.cwd, requestBodyLimitBytes, options.registerRoot);
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
38
|
if (req.method === "GET" || req.method === "DELETE") {
|
|
@@ -51,10 +51,23 @@ export async function startHttpMcpServer(options) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
|
+
const requestedPort = options.port ?? 8787;
|
|
54
55
|
await new Promise((resolve, reject) => {
|
|
55
|
-
server.once("error",
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
server.once("error", (error) => {
|
|
57
|
+
// "address already in use" names neither who has it nor how to move.
|
|
58
|
+
// Measured here: prodex's default port was held by an unrelated tool of
|
|
59
|
+
// the user's, and the only thing said was the raw errno - on a machine
|
|
60
|
+
// where the fix is one flag.
|
|
61
|
+
if (error.code === "EADDRINUSE") {
|
|
62
|
+
reject(new Error(`Port ${requestedPort} on ${host} is already taken, so the HTTP MCP server did not start. ` +
|
|
63
|
+
`Another prodex may already be running here - check with \`prodex status\` - or another program holds it ` +
|
|
64
|
+
`(\`ss -ltnp | grep ${requestedPort}\` says which). Move prodex with \`prodex setup --port <free port>\`, then start again.`, { cause: error }));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
reject(error);
|
|
68
|
+
});
|
|
69
|
+
server.listen(requestedPort, host, () => {
|
|
70
|
+
server.removeAllListeners("error");
|
|
58
71
|
resolve();
|
|
59
72
|
});
|
|
60
73
|
});
|
|
@@ -118,7 +131,7 @@ async function withTimeout(promise, timeoutMs, message) {
|
|
|
118
131
|
clearTimeout(timeout);
|
|
119
132
|
}
|
|
120
133
|
}
|
|
121
|
-
async function handlePost(req, res, transports, cwd, requestBodyLimitBytes) {
|
|
134
|
+
async function handlePost(req, res, transports, cwd, requestBodyLimitBytes, registerRoot) {
|
|
122
135
|
const body = await readJsonBody(req, requestBodyLimitBytes);
|
|
123
136
|
const sessionId = headerValue(req.headers["mcp-session-id"]);
|
|
124
137
|
if (sessionId) {
|
|
@@ -145,7 +158,11 @@ async function handlePost(req, res, transports, cwd, requestBodyLimitBytes) {
|
|
|
145
158
|
if (id)
|
|
146
159
|
transports.delete(id);
|
|
147
160
|
};
|
|
148
|
-
const mcpServer = createMcpServer(cwd, {
|
|
161
|
+
const mcpServer = createMcpServer(cwd, {
|
|
162
|
+
source: "chatgpt_project",
|
|
163
|
+
claimedBy: "chatgpt",
|
|
164
|
+
...(registerRoot !== undefined ? { registerRoot } : {})
|
|
165
|
+
});
|
|
149
166
|
await mcpServer.connect(transport);
|
|
150
167
|
await transport.handleRequest(req, res, body);
|
|
151
168
|
return;
|
package/dist/mcp-tools.js
CHANGED
|
@@ -40,7 +40,9 @@ function redactTaskForMcp(task) {
|
|
|
40
40
|
return { ...task, provenance: redactedProvenance };
|
|
41
41
|
}
|
|
42
42
|
export function createMcpToolHandlers(context) {
|
|
43
|
-
const store = new BridgeStore(context.cwd
|
|
43
|
+
const store = new BridgeStore(context.cwd, {
|
|
44
|
+
...(context.registerRoot !== undefined ? { registerRoot: context.registerRoot } : {})
|
|
45
|
+
});
|
|
44
46
|
const source = context.source ?? "claude";
|
|
45
47
|
const claimedBy = context.claimedBy ?? source;
|
|
46
48
|
return {
|
package/dist/mcp.js
CHANGED
|
@@ -51,7 +51,12 @@ function serverVersionNotice() {
|
|
|
51
51
|
}
|
|
52
52
|
export function createServer(cwd = process.cwd(), options = {}) {
|
|
53
53
|
const server = new McpServer({ name: "prodex", version: mcpPackageJson.version ?? "0.0.0" });
|
|
54
|
-
const handlers = createMcpToolHandlers({
|
|
54
|
+
const handlers = createMcpToolHandlers({
|
|
55
|
+
cwd,
|
|
56
|
+
source: options.source,
|
|
57
|
+
claimedBy: options.claimedBy,
|
|
58
|
+
...(options.registerRoot !== undefined ? { registerRoot: options.registerRoot } : {})
|
|
59
|
+
});
|
|
55
60
|
server.registerTool("bridge_create_task", {
|
|
56
61
|
description: "Create a durable task for Codex/local execution in .bridge/tasks.",
|
|
57
62
|
inputSchema: {
|
|
@@ -201,7 +206,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
201
206
|
allow_model_fallback: z
|
|
202
207
|
.boolean()
|
|
203
208
|
.optional()
|
|
204
|
-
.describe("Send even when the requested
|
|
209
|
+
.describe("Send even when the requested EFFORT step could not be applied. Off by default, because an answer from a step nobody asked for is usually unusable: a consult asking for Pro that comes back from a lesser model cannot be cited as a Pro review. Pass true only when any answer beats no answer. It does not gate the model axis: this picker's model rows cannot be driven at all, so a model it will not provide is always a warning and the send goes out on whatever the composer had - check `model_used` and `pro_verified` in the answer to see what actually replied.")
|
|
205
210
|
}
|
|
206
211
|
}, async (input, extra) => {
|
|
207
212
|
// Bridge send progress to MCP progress notifications, but only when
|
package/dist/schema.js
CHANGED
|
@@ -23,6 +23,14 @@ export const ProvenanceSchema = z.object({
|
|
|
23
23
|
session_id: z.string().optional(),
|
|
24
24
|
thread: z.string().optional(),
|
|
25
25
|
project: z.string().optional(),
|
|
26
|
+
/**
|
|
27
|
+
* What the send asked for - model, effort, project. Recorded on failures as
|
|
28
|
+
* well as answers: 57 timeouts in this machine's ledger carry no selection
|
|
29
|
+
* at all, because it was only ever written into the ANSWER receipt, so the
|
|
30
|
+
* ledger cannot say whether a Pro run times out more often than a quick one
|
|
31
|
+
* - which is the first question anyone asks of a timeout.
|
|
32
|
+
*/
|
|
33
|
+
selection: z.record(z.string()).optional(),
|
|
26
34
|
warnings: z.array(z.string()).default([])
|
|
27
35
|
});
|
|
28
36
|
export const BridgeFileSchema = z.object({
|
package/dist/store.js
CHANGED
|
@@ -66,9 +66,17 @@ const READ_ALL_CONCURRENCY = 32;
|
|
|
66
66
|
export class BridgeStore {
|
|
67
67
|
root;
|
|
68
68
|
bridgeDir;
|
|
69
|
-
|
|
69
|
+
registerRoot;
|
|
70
|
+
/**
|
|
71
|
+
* `registerRoot: false` keeps this bridge out of the machine-wide registry.
|
|
72
|
+
* For throwaway roots prodex makes for itself - doctor's smoke checks build
|
|
73
|
+
* a bridge in a temp directory, delete it, and left a dead entry in the
|
|
74
|
+
* user's registry on every run.
|
|
75
|
+
*/
|
|
76
|
+
constructor(root = process.cwd(), options = {}) {
|
|
70
77
|
this.root = root;
|
|
71
78
|
this.bridgeDir = path.join(root, ".bridge");
|
|
79
|
+
this.registerRoot = options.registerRoot !== false;
|
|
72
80
|
}
|
|
73
81
|
async ensure() {
|
|
74
82
|
await assertUsableBridgeRoot(this.root);
|
|
@@ -85,7 +93,8 @@ export class BridgeStore {
|
|
|
85
93
|
await this.ensureReceiptIntegrityKey();
|
|
86
94
|
// Advisory: let local indexers (sessionwiki's prodex adapter) find this
|
|
87
95
|
// bridge. Best-effort inside - a registry failure never breaks the bridge.
|
|
88
|
-
|
|
96
|
+
if (this.registerRoot)
|
|
97
|
+
await registerBridgeRoot(this.root);
|
|
89
98
|
}
|
|
90
99
|
dir(kind) {
|
|
91
100
|
return path.join(this.bridgeDir, kind);
|