@youdie006/prodex 0.40.4 → 0.40.6
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 +128 -10
- package/dist/cli-help.js +1 -1
- package/dist/cli-pro.js +256 -18
- package/dist/cli.js +39 -2
- package/dist/continue-thread.js +37 -4
- 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
|
@@ -1414,6 +1414,23 @@ async function openChatGptThread(cdp, url) {
|
|
|
1414
1414
|
* cause was named in the message and then thrown away by the catch-all next
|
|
1415
1415
|
* step underneath it.
|
|
1416
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
|
+
}
|
|
1417
1434
|
export function chatGptThreadUnavailableBlocker(url) {
|
|
1418
1435
|
return {
|
|
1419
1436
|
code: "thread_unavailable",
|
|
@@ -2461,8 +2478,15 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
2461
2478
|
// new-chat navigation the composer form (and the selector inside it) has not
|
|
2462
2479
|
// finished rendering yet, so a single check throws "model selector button not
|
|
2463
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.
|
|
2464
2488
|
let button = { ok: false };
|
|
2465
|
-
const buttonDeadline = Date.now() +
|
|
2489
|
+
const buttonDeadline = Date.now() + PROJECT_NAVIGATION_TIMEOUT_MS;
|
|
2466
2490
|
for (;;) {
|
|
2467
2491
|
button = await cdp.evaluate(modelButtonRectExpression());
|
|
2468
2492
|
if (button.ok && button.x !== undefined && button.y !== undefined)
|
|
@@ -2472,7 +2496,7 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
2472
2496
|
await sleep(200);
|
|
2473
2497
|
}
|
|
2474
2498
|
if (!button.ok || button.x === undefined || button.y === undefined) {
|
|
2475
|
-
throw new
|
|
2499
|
+
throw new ChatGptBrowserBlockerError(chatGptComposerNotReadyBlocker(button.reason));
|
|
2476
2500
|
}
|
|
2477
2501
|
// Skip the menu entirely when the picker already shows the requested model:
|
|
2478
2502
|
// it is the same end state, and it survives ChatGPT reshuffling the menu
|
|
@@ -3535,7 +3559,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3535
3559
|
// looks the way it looked when it refused, and the selection failures this
|
|
3536
3560
|
// project keeps hitting are invisible in the error text alone.
|
|
3537
3561
|
await captureOnFailure(`send-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
3538
|
-
throw error;
|
|
3562
|
+
throw attachSendWarnings(error, sendWarnings);
|
|
3539
3563
|
}
|
|
3540
3564
|
finally {
|
|
3541
3565
|
cdp.close();
|
|
@@ -4456,6 +4480,39 @@ export function portAccepts(port, timeoutMs = 250) {
|
|
|
4456
4480
|
}
|
|
4457
4481
|
/** True when a fetch failed because its AbortSignal.timeout fired, not because nothing was listening. */
|
|
4458
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
|
+
}
|
|
4459
4516
|
export function cdpCommandTimedOut(error) {
|
|
4460
4517
|
return error instanceof Error && /Chrome DevTools command timed out/.test(error.message);
|
|
4461
4518
|
}
|
|
@@ -5003,11 +5060,33 @@ export function composerToolsButtonRectExpression() {
|
|
|
5003
5060
|
})()`;
|
|
5004
5061
|
}
|
|
5005
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]';
|
|
5006
5081
|
export function composerToolEntryRectExpression(label) {
|
|
5007
5082
|
const candidatesJson = JSON.stringify(composerToolMenuTexts(label).map((text) => text.toLowerCase()));
|
|
5083
|
+
const excludedJson = JSON.stringify(COMPOSER_TOOL_LOOKUP_EXCLUDED_ANCESTORS);
|
|
5008
5084
|
return `(() => {${CLICK_POINT_SNIPPET}
|
|
5009
5085
|
const candidates = ${candidatesJson};
|
|
5010
|
-
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
|
+
);
|
|
5011
5090
|
const leaf = leaves.find((el) => candidates.includes((el.textContent || "").trim().toLowerCase()));
|
|
5012
5091
|
if (!leaf) {
|
|
5013
5092
|
const available = [...new Set(leaves.map((el) => (el.textContent || "").trim()).filter((t) => t.length > 1 && t.length < 30))].slice(0, 20);
|
|
@@ -5029,9 +5108,7 @@ export function activeComposerToolsExpression(labels) {
|
|
|
5029
5108
|
const text = ((el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "")).toLowerCase();
|
|
5030
5109
|
// Case-insensitively: prodex carries the label as the menu spells it
|
|
5031
5110
|
// ("Create image") while the page has been measured using "Create Image"
|
|
5032
|
-
// for the same tool.
|
|
5033
|
-
// seen - the create-image activation failure measured on this account
|
|
5034
|
-
// survives it, and its cause is still open.
|
|
5111
|
+
// for the same tool.
|
|
5035
5112
|
return { ok: true, active: ${labelsJson}.filter((label) => text.includes(String(label).toLowerCase())) };
|
|
5036
5113
|
})()`;
|
|
5037
5114
|
}
|
|
@@ -5319,7 +5396,10 @@ export function recentConversationsExpression(limit = 4) {
|
|
|
5319
5396
|
}
|
|
5320
5397
|
const user = chain.find((entry) => entry && entry.author && entry.author.role === "user" && entry.content);
|
|
5321
5398
|
const text = user ? (user.content.parts || []).filter((part) => typeof part === "string").join("") : "";
|
|
5322
|
-
|
|
5399
|
+
// Long enough that two consults sharing an opening can still be told
|
|
5400
|
+
// apart by the rest of the prompt; bounded so a huge --file send does
|
|
5401
|
+
// not drag its whole payload back through the bridge.
|
|
5402
|
+
out.push({ id: item.id, userText: text.slice(0, 4000) });
|
|
5323
5403
|
} catch (error) {
|
|
5324
5404
|
// A conversation we cannot read is simply not a match.
|
|
5325
5405
|
}
|
|
@@ -5327,9 +5407,47 @@ export function recentConversationsExpression(limit = 4) {
|
|
|
5327
5407
|
return out;
|
|
5328
5408
|
})()`;
|
|
5329
5409
|
}
|
|
5330
|
-
/**
|
|
5410
|
+
/**
|
|
5411
|
+
* Which of those conversations is the one this send posted into, if any.
|
|
5412
|
+
*
|
|
5413
|
+
* Identity is a PREFIX test - the first 120 normalized characters - because a
|
|
5414
|
+
* composer tool prefixes the prompt and attachments append to it, so neither
|
|
5415
|
+
* end is reliable on its own. Two consults that open the same way therefore
|
|
5416
|
+
* look identical to it, which is not hypothetical: an agent working from a
|
|
5417
|
+
* template, or a debate loop, repeats its opening every round. Taking the
|
|
5418
|
+
* first match then reads an OLDER conversation and returns its answer as this
|
|
5419
|
+
* send's - measured on two prompts sharing a 125-character preamble and
|
|
5420
|
+
* differing at character 142, where sending the newer one picked the older.
|
|
5421
|
+
*
|
|
5422
|
+
* So an ambiguous prefix is resolved by the whole prompt, and if that cannot
|
|
5423
|
+
* single one out either, nothing is picked. The caller falls back to its other
|
|
5424
|
+
* evidence, which costs a wait; picking wrong costs the wrong answer.
|
|
5425
|
+
*/
|
|
5331
5426
|
export function pickLandedConversation(candidates, sentPrompt) {
|
|
5332
|
-
|
|
5427
|
+
const matches = candidates.filter((candidate) => transcriptMatchesSentPrompt(candidate.userText, sentPrompt));
|
|
5428
|
+
if (matches.length <= 1)
|
|
5429
|
+
return matches[0]?.id;
|
|
5430
|
+
const whole = matches.filter((candidate) => transcriptContainsWholeSentPrompt(candidate.userText, sentPrompt));
|
|
5431
|
+
return whole.length === 1 ? whole[0].id : undefined;
|
|
5432
|
+
}
|
|
5433
|
+
/**
|
|
5434
|
+
* The stricter test, for telling apart conversations the prefix cannot.
|
|
5435
|
+
*
|
|
5436
|
+
* Still a containment test - the transcript wraps the prompt - but of the
|
|
5437
|
+
* whole prompt rather than its opening. A prompt longer than the recorded
|
|
5438
|
+
* sample cannot match, which leaves the caller with nothing to pick, and
|
|
5439
|
+
* nothing is the safe answer here.
|
|
5440
|
+
*/
|
|
5441
|
+
export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
|
|
5442
|
+
const normalize = (value) => value
|
|
5443
|
+
.replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
|
|
5444
|
+
.replace(/\s+/g, " ")
|
|
5445
|
+
.trim();
|
|
5446
|
+
const seen = normalize(userText);
|
|
5447
|
+
const sent = normalize(sentPrompt);
|
|
5448
|
+
if (seen.length === 0 || sent.length === 0)
|
|
5449
|
+
return false;
|
|
5450
|
+
return seen.includes(sent);
|
|
5333
5451
|
}
|
|
5334
5452
|
export function transcriptAnswerExpression(conversationId) {
|
|
5335
5453
|
return `(async () => {
|
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)
|
|
@@ -1337,6 +1352,11 @@ export async function runAskProCommand(rest, io) {
|
|
|
1337
1352
|
const autoLoginAllowed = !parsedAskPro.optionArgs.includes("--no-auto-login") &&
|
|
1338
1353
|
(parsedAskPro.optionArgs.includes("--auto-login") || io.isInteractive === true);
|
|
1339
1354
|
let consult;
|
|
1355
|
+
// Declared out here so a retry that fails still reports what recovery
|
|
1356
|
+
// did: the notes used to live inside the inner catch and were attached
|
|
1357
|
+
// to the ANSWER, so a recovered browser whose retry then died recorded
|
|
1358
|
+
// nothing about the recovery at all.
|
|
1359
|
+
const recoveryNotes = [];
|
|
1340
1360
|
try {
|
|
1341
1361
|
try {
|
|
1342
1362
|
consult = await sendOnce();
|
|
@@ -1345,7 +1365,6 @@ export async function runAskProCommand(rest, io) {
|
|
|
1345
1365
|
const firstBlocker = browserSendBlockerFromError(error);
|
|
1346
1366
|
if (firstBlocker.code !== "browser_unreachable" || !autoLoginAllowed)
|
|
1347
1367
|
throw error;
|
|
1348
|
-
const recoveryNotes = [];
|
|
1349
1368
|
const recovered = await attemptBrowserAutoRecovery(io.stderr, {
|
|
1350
1369
|
...(browserPort !== undefined ? { port: browserPort } : {}),
|
|
1351
1370
|
notes: recoveryNotes
|
|
@@ -1369,6 +1388,12 @@ export async function runAskProCommand(rest, io) {
|
|
|
1369
1388
|
// --new-chat there is no target url, and a blocker that started a run
|
|
1370
1389
|
// still has a thread worth handing back.
|
|
1371
1390
|
const blockedThread = blocker.thread ?? normalizedTargetUrl;
|
|
1391
|
+
// What the send had already noticed before it died. These used to go
|
|
1392
|
+
// out with the result, so a failure dropped them - including the note
|
|
1393
|
+
// that would explain it, like having just moved off the Work surface.
|
|
1394
|
+
const blockedWarnings = [...recoveryNotes, ...sendWarningsFromError(error)].map(redactProject);
|
|
1395
|
+
for (const warning of blockedWarnings)
|
|
1396
|
+
io.stderr(warning);
|
|
1372
1397
|
const persistedBlocker = {
|
|
1373
1398
|
...blocker,
|
|
1374
1399
|
message: redactProject(blocker.message),
|
|
@@ -1379,7 +1404,17 @@ export async function runAskProCommand(rest, io) {
|
|
|
1379
1404
|
status: "blocked",
|
|
1380
1405
|
summary: redactProject(message),
|
|
1381
1406
|
commands: ["visible ChatGPT browser consult"],
|
|
1382
|
-
|
|
1407
|
+
warnings: blockedWarnings,
|
|
1408
|
+
blocker: persistedBlocker,
|
|
1409
|
+
// What was asked for, on the record that failed. Without it a
|
|
1410
|
+
// timeout cannot be read back against the budget it was given.
|
|
1411
|
+
provenance: {
|
|
1412
|
+
...(blockedThread ? { thread: blockedThread } : {}),
|
|
1413
|
+
...(Object.keys(selectionMetadata).length > 0
|
|
1414
|
+
? { selection: redactSelectionForRecord(selectionMetadata, redactProject) }
|
|
1415
|
+
: {}),
|
|
1416
|
+
warnings: blockedWarnings
|
|
1417
|
+
}
|
|
1383
1418
|
});
|
|
1384
1419
|
await writeSessionBestEffort(targetStore, {
|
|
1385
1420
|
id: bundle.id,
|
|
@@ -1398,7 +1433,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
1398
1433
|
// Keep stdout machine-parseable for --json consumers on the blocked
|
|
1399
1434
|
// path too; the human-readable error still goes to stderr via throw.
|
|
1400
1435
|
if (jsonOutput) {
|
|
1401
|
-
io.stdout(JSON.stringify({ task_id: task.id, status: "blocked", thread: blockedThread ?? null, answer: null, warnings:
|
|
1436
|
+
io.stdout(JSON.stringify({ task_id: task.id, status: "blocked", thread: blockedThread ?? null, answer: null, warnings: blockedWarnings, blocker }, null, 2));
|
|
1402
1437
|
}
|
|
1403
1438
|
throw new Error(formatBlockedConsultRecordedMessage(message, task.id, sourceCli, { cwd: targetCwd }));
|
|
1404
1439
|
}
|
|
@@ -1510,6 +1545,9 @@ export async function runAskProCommand(rest, io) {
|
|
|
1510
1545
|
warnings: persistenceWarnings,
|
|
1511
1546
|
provenance: {
|
|
1512
1547
|
thread: consult.url,
|
|
1548
|
+
...(Object.keys(selectionMetadata).length > 0
|
|
1549
|
+
? { selection: redactSelectionForRecord(selectionMetadata, redactProjectNamesForRecord(selectionMetadata)) }
|
|
1550
|
+
: {}),
|
|
1513
1551
|
warnings: persistenceWarnings
|
|
1514
1552
|
}
|
|
1515
1553
|
});
|
|
@@ -1641,6 +1679,30 @@ export async function performBrowserRecoverForMcp(cwd, input) {
|
|
|
1641
1679
|
notes: stderrLines
|
|
1642
1680
|
};
|
|
1643
1681
|
}
|
|
1682
|
+
/** The marker the send prints before throwing, when the answer outlived its record. */
|
|
1683
|
+
const ANSWER_NOT_SAVED_MARKER = "consult_answer_received_but_not_saved:";
|
|
1684
|
+
/**
|
|
1685
|
+
* Pull an answer out of a send that got one and then failed to record it.
|
|
1686
|
+
*
|
|
1687
|
+
* The CLI prints `consult_answer_received_but_not_saved: <task> <thread>`,
|
|
1688
|
+
* a blank line, and the answer, then throws - so a person still has the text.
|
|
1689
|
+
* An agent calling through MCP only saw the throw, which discarded exactly the
|
|
1690
|
+
* answer that was most expensive to get.
|
|
1691
|
+
*/
|
|
1692
|
+
export function answerRescuedFromFailedPersistence(stdoutLines) {
|
|
1693
|
+
const index = stdoutLines.findIndex((line) => line.startsWith(ANSWER_NOT_SAVED_MARKER));
|
|
1694
|
+
if (index === -1)
|
|
1695
|
+
return undefined;
|
|
1696
|
+
const [taskId = "", thread = ""] = stdoutLines[index].slice(ANSWER_NOT_SAVED_MARKER.length).trim().split(/\s+/);
|
|
1697
|
+
const answer = stdoutLines
|
|
1698
|
+
.slice(index + 1)
|
|
1699
|
+
.join("\n")
|
|
1700
|
+
.replace(/^\n+/, "")
|
|
1701
|
+
.trim();
|
|
1702
|
+
if (!answer)
|
|
1703
|
+
return undefined;
|
|
1704
|
+
return { taskId, thread, answer };
|
|
1705
|
+
}
|
|
1644
1706
|
export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
1645
1707
|
const stdoutLines = [];
|
|
1646
1708
|
const stderrLines = [];
|
|
@@ -1667,16 +1729,38 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
|
1667
1729
|
"--",
|
|
1668
1730
|
input.prompt
|
|
1669
1731
|
];
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
onProgress(
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1732
|
+
try {
|
|
1733
|
+
await runAskProCommand(argv, {
|
|
1734
|
+
cwd,
|
|
1735
|
+
stdout: (line) => stdoutLines.push(line),
|
|
1736
|
+
stderr: (line) => {
|
|
1737
|
+
stderrLines.push(line);
|
|
1738
|
+
if (onProgress && line.startsWith("progress:"))
|
|
1739
|
+
onProgress(line);
|
|
1740
|
+
},
|
|
1741
|
+
allowAskProBrowserSend: true
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
catch (error) {
|
|
1745
|
+
// A send whose ANSWER arrived and whose recording then failed prints the
|
|
1746
|
+
// answer and throws, so the CLI caller still has it. Rethrowing here threw
|
|
1747
|
+
// it away instead - the one case where that costs the most, since the
|
|
1748
|
+
// answer is usually a Pro run someone waited minutes for. Hand it back
|
|
1749
|
+
// with the failure attached rather than losing it.
|
|
1750
|
+
const rescued = answerRescuedFromFailedPersistence(stdoutLines);
|
|
1751
|
+
if (!rescued)
|
|
1752
|
+
throw error;
|
|
1753
|
+
return {
|
|
1754
|
+
task_id: rescued.taskId,
|
|
1755
|
+
status: "answered_not_saved",
|
|
1756
|
+
thread: rescued.thread,
|
|
1757
|
+
answer: rescued.answer,
|
|
1758
|
+
notes: [
|
|
1759
|
+
...stderrLines.filter((line) => !line.startsWith("progress:")),
|
|
1760
|
+
`answer_not_saved: ${errorMessage(error)}`
|
|
1761
|
+
]
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1680
1764
|
const header = stdoutLines[0] ?? "";
|
|
1681
1765
|
const [taskId = "", status = "", thread = ""] = header.split("\t");
|
|
1682
1766
|
return {
|
|
@@ -1717,6 +1801,20 @@ function autoClearDisabledByEnv(env = process.env) {
|
|
|
1717
1801
|
const raw = (env.PRODEX_NO_AUTO_CLEAR ?? "").trim().toLowerCase();
|
|
1718
1802
|
return raw === "1" || raw === "true" || raw === "yes";
|
|
1719
1803
|
}
|
|
1804
|
+
/**
|
|
1805
|
+
* What recovery did to the browser, in the words the receipt keeps.
|
|
1806
|
+
*
|
|
1807
|
+
* Both halves are recorded because both change what the answer came from. The
|
|
1808
|
+
* ending case takes someone's running browser with it; the launch case is the
|
|
1809
|
+
* quieter one and used to record nothing at all - measured, killing the
|
|
1810
|
+
* dedicated browser and sending with --auto-login recovered in three seconds
|
|
1811
|
+
* and left warnings: [] on the receipt, the result and the task.
|
|
1812
|
+
*/
|
|
1813
|
+
export function browserRecoveredNote(ended) {
|
|
1814
|
+
return ended.length > 0
|
|
1815
|
+
? `browser_recovered: the dedicated browser stopped answering its control port and prodex ended it (pid ${ended.join(", ")}) and started a fresh one before sending. Anything it was doing at the time is gone; the profile and login were kept.`
|
|
1816
|
+
: "browser_recovered: the dedicated browser was not running, so prodex started it with the saved profile before sending. The login was kept; anything the old browser had open is gone.";
|
|
1817
|
+
}
|
|
1720
1818
|
export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
1721
1819
|
// Launching is right when the browser is gone and wrong when it is only deaf:
|
|
1722
1820
|
// a second Chrome on the same profile joins the wedged one rather than
|
|
@@ -1751,7 +1849,7 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
|
1751
1849
|
stderr(`recover: the browser stopped answering; ending it (pid ${wedged.join(", ")}) and starting a fresh one...`);
|
|
1752
1850
|
// Ending someone's browser is not a progress line to scroll past: it goes on
|
|
1753
1851
|
// the receipt, where an agent or a person reading `pro latest` will see it.
|
|
1754
|
-
options.notes?.push(
|
|
1852
|
+
options.notes?.push(browserRecoveredNote(wedged));
|
|
1755
1853
|
await endWedgedBrowser(wedged);
|
|
1756
1854
|
// Wait for the profile lock to actually clear rather than guessing at a
|
|
1757
1855
|
// delay: the replacement launch fails outright if the old process still
|
|
@@ -1794,6 +1892,10 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
|
1794
1892
|
stderr("recover: window minimized again");
|
|
1795
1893
|
}
|
|
1796
1894
|
stderr("recover: browser READY - retrying the send...");
|
|
1895
|
+
// The wedged branch above records what it ended; this branch records that
|
|
1896
|
+
// the browser was gone and this answer came from one prodex started.
|
|
1897
|
+
if (wedged.length === 0)
|
|
1898
|
+
options.notes?.push(browserRecoveredNote([]));
|
|
1797
1899
|
return true;
|
|
1798
1900
|
}
|
|
1799
1901
|
catch (error) {
|
|
@@ -1955,6 +2057,35 @@ export function redactProjectNames(text, names) {
|
|
|
1955
2057
|
}
|
|
1956
2058
|
return redacted;
|
|
1957
2059
|
}
|
|
2060
|
+
/** The project name is scrubbed in records; the rest of a selection is not. */
|
|
2061
|
+
export function redactSelectionForRecord(selection, redact) {
|
|
2062
|
+
const recorded = {};
|
|
2063
|
+
for (const [key, value] of Object.entries(selection)) {
|
|
2064
|
+
recorded[key] = key === "project" || key === "project_new" ? redact(value) : value;
|
|
2065
|
+
}
|
|
2066
|
+
return recorded;
|
|
2067
|
+
}
|
|
2068
|
+
/** The redactor a record needs when it has only the selection to go on. */
|
|
2069
|
+
function redactProjectNamesForRecord(selection) {
|
|
2070
|
+
return (text) => redactProjectNames(text, [selection.project, selection.project_new]);
|
|
2071
|
+
}
|
|
2072
|
+
/**
|
|
2073
|
+
* A recorded blocker, read with what the classifier knows NOW.
|
|
2074
|
+
*
|
|
2075
|
+
* Records keep the code they were written with. Most of the failures that
|
|
2076
|
+
* recur were the catch-all when they were recorded - 178 of 296 in this
|
|
2077
|
+
* machine's ledger - and only got their own codes afterwards, so a report
|
|
2078
|
+
* grouped by the recorded code kept showing "browser_send_failed: ..." rows
|
|
2079
|
+
* for causes that have names. Re-reading the message through the classifier
|
|
2080
|
+
* names them; a message it still cannot place keeps its recorded code, and a
|
|
2081
|
+
* record that was never the catch-all is left exactly as written.
|
|
2082
|
+
*/
|
|
2083
|
+
export function reclassifyRecordedBlocker(recorded) {
|
|
2084
|
+
if (!CATCH_ALL_CODES.has(recorded.code))
|
|
2085
|
+
return { code: recorded.code, message: recorded.message };
|
|
2086
|
+
const live = browserSendBlockerFromError(new Error(recorded.message));
|
|
2087
|
+
return { code: CATCH_ALL_CODES.has(live.code) ? recorded.code : live.code, message: recorded.message };
|
|
2088
|
+
}
|
|
1958
2089
|
export function browserSendBlockerFromError(error) {
|
|
1959
2090
|
const blocker = typeof error === "object" && error !== null && "blocker" in error ? error.blocker : undefined;
|
|
1960
2091
|
if (typeof blocker === "object" &&
|
|
@@ -2032,6 +2163,100 @@ export function browserSendBlockerFromError(error) {
|
|
|
2032
2163
|
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."
|
|
2033
2164
|
};
|
|
2034
2165
|
}
|
|
2166
|
+
// Everything below came out of the ledger rather than out of the code: 178
|
|
2167
|
+
// of 296 recorded blockers were the catch-all, and replaying their messages
|
|
2168
|
+
// through this function showed 109 still landing there - each one told to
|
|
2169
|
+
// "resolve the visible browser issue manually", which is advice for a
|
|
2170
|
+
// problem nobody has. These are the recurring ones that are still reachable,
|
|
2171
|
+
// in the order they actually happen.
|
|
2172
|
+
//
|
|
2173
|
+
// All of them fail BEFORE the prompt is submitted: the selection steps and
|
|
2174
|
+
// the post-insertion check all run ahead of the send, so "nothing was sent"
|
|
2175
|
+
// is a fact here and not a hope.
|
|
2176
|
+
if (/Refusing to click/.test(message)) {
|
|
2177
|
+
return {
|
|
2178
|
+
code: "click_blocked",
|
|
2179
|
+
message,
|
|
2180
|
+
retryable: true,
|
|
2181
|
+
next_step: "Something on the page was sitting over the control prodex needed, so nothing was sent. Retry - these covers are " +
|
|
2182
|
+
"usually a banner or a menu that goes away on its own - or clear it in the visible browser."
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
if (/Composer text did not match the prompt after insertion|Composer stayed empty after text insertion/.test(message)) {
|
|
2186
|
+
return {
|
|
2187
|
+
code: "composer_text_mismatch",
|
|
2188
|
+
message,
|
|
2189
|
+
retryable: true,
|
|
2190
|
+
next_step: "The composer did not end up holding the prompt, so nothing was sent. Clear whatever is in it in the visible " +
|
|
2191
|
+
"browser and retry, or send into a fresh chat with --new-chat."
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
if (/did not finish accepting .* within the upload budget/.test(message)) {
|
|
2195
|
+
return {
|
|
2196
|
+
code: "attachment_upload_timeout",
|
|
2197
|
+
message,
|
|
2198
|
+
retryable: true,
|
|
2199
|
+
next_step: "ChatGPT was still ingesting the attachment when the budget ran out, so nothing was sent. Retry with a longer " +
|
|
2200
|
+
"--timeout-ms, or inline a text file with --file instead of uploading it."
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
if (/composer tools menu has no "/.test(message)) {
|
|
2204
|
+
return {
|
|
2205
|
+
code: "tool_not_offered",
|
|
2206
|
+
message,
|
|
2207
|
+
// The menu was read and the tool is not in it; asking again reads the
|
|
2208
|
+
// same menu.
|
|
2209
|
+
retryable: false,
|
|
2210
|
+
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."
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
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)) {
|
|
2214
|
+
return {
|
|
2215
|
+
code: "composer_not_ready",
|
|
2216
|
+
message,
|
|
2217
|
+
retryable: true,
|
|
2218
|
+
next_step: "The composer had not finished rendering its picker, so nothing was sent. Retry - it is usually a moment behind " +
|
|
2219
|
+
"a page that has just navigated, and a send right after another one lands on it mid-render."
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
if (/but the composer never showed it as active/.test(message)) {
|
|
2223
|
+
return {
|
|
2224
|
+
code: "tool_not_applied",
|
|
2225
|
+
message,
|
|
2226
|
+
retryable: true,
|
|
2227
|
+
next_step: "The composer tool was chosen but never turned on, so nothing was sent. Retry, or drop --tool and ask for the " +
|
|
2228
|
+
"same thing in the prompt."
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
if (/ChatGPT project not found in sidebar/.test(message)) {
|
|
2232
|
+
return {
|
|
2233
|
+
code: "project_not_found",
|
|
2234
|
+
message,
|
|
2235
|
+
// prodex already waits for the sidebar to hydrate before saying this, so
|
|
2236
|
+
// the name is genuinely not there; asking again asks the same sidebar.
|
|
2237
|
+
retryable: false,
|
|
2238
|
+
next_step: "Nothing was sent. The sidebar has no project by that name - list the exact spellings with " +
|
|
2239
|
+
"`prodex pro browser projects`, then pass one of those, or send without --project."
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
if (/Clicking project "[^"]*" did not navigate/.test(message)) {
|
|
2243
|
+
return {
|
|
2244
|
+
code: "project_navigation_failed",
|
|
2245
|
+
message,
|
|
2246
|
+
retryable: true,
|
|
2247
|
+
next_step: "The sidebar click did not move the tab into the project, so nothing was sent. Retry, or open the project once " +
|
|
2248
|
+
"in the visible browser and send again."
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
if (/Pro option not found in the model menu/.test(message)) {
|
|
2252
|
+
return {
|
|
2253
|
+
code: "selection_not_applied",
|
|
2254
|
+
message,
|
|
2255
|
+
retryable: false,
|
|
2256
|
+
next_step: "Nothing was sent. This account's picker does not offer Pro as a menu option - ask for it on the effort axis " +
|
|
2257
|
+
"with `--effort Pro`, or send without a model and check `model_used` in the answer."
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2035
2260
|
// The picker could not provide the step that was asked for. Retrying asks
|
|
2036
2261
|
// the same picker the same question, so this is not retryable; the caller
|
|
2037
2262
|
// either picks a step it offers or opts into whatever the slider is on.
|
|
@@ -2117,6 +2342,15 @@ async function recordedModelUsed(store, taskId) {
|
|
|
2117
2342
|
return undefined;
|
|
2118
2343
|
}
|
|
2119
2344
|
}
|
|
2345
|
+
/** One line naming the model, effort and project a send asked for, or nothing. */
|
|
2346
|
+
export function formatAskedFor(selection) {
|
|
2347
|
+
if (!selection)
|
|
2348
|
+
return undefined;
|
|
2349
|
+
const parts = ["model", "pro_mode", "effort", "project", "project_new"]
|
|
2350
|
+
.filter((key) => selection[key])
|
|
2351
|
+
.map((key) => `${key}=${selection[key]}`);
|
|
2352
|
+
return parts.length > 0 ? `asked_for: ${parts.join(" ")}` : undefined;
|
|
2353
|
+
}
|
|
2120
2354
|
export function formatProAnswer(consult, sourceCli, options = {}) {
|
|
2121
2355
|
const blocker = sourceAwareProAnswerBlocker(consult, sourceCli, options);
|
|
2122
2356
|
const summary = sourceAwareProAnswerSummary(consult.result.summary, consult.result.blocker, blocker);
|
|
@@ -2124,6 +2358,10 @@ export function formatProAnswer(consult, sourceCli, options = {}) {
|
|
|
2124
2358
|
`task_id: ${consult.task.id}`,
|
|
2125
2359
|
`status: ${consult.result.status}`,
|
|
2126
2360
|
consult.task.provenance.thread ? `thread: ${consult.task.provenance.thread}` : undefined,
|
|
2361
|
+
// What the send asked for. Recorded on failures as well as answers now,
|
|
2362
|
+
// and a person reading a timeout needs it on the same screen as the
|
|
2363
|
+
// budget it used up.
|
|
2364
|
+
formatAskedFor(consult.task.provenance.selection),
|
|
2127
2365
|
`created_at: ${consult.result.created_at}`,
|
|
2128
2366
|
"",
|
|
2129
2367
|
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/continue-thread.js
CHANGED
|
@@ -39,7 +39,16 @@ export function chatGptProjectSlug(name) {
|
|
|
39
39
|
* exists to remove rather than reproduce.
|
|
40
40
|
*/
|
|
41
41
|
export function isChatGptConversationUrl(threadUrl) {
|
|
42
|
-
|
|
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);
|
|
43
52
|
}
|
|
44
53
|
/**
|
|
45
54
|
* The project ids these recorded threads have been seen using, by project name.
|
|
@@ -76,6 +85,11 @@ export function threadMatchesProject(threadUrl, project, knownProjectIds) {
|
|
|
76
85
|
return projectSegment === undefined;
|
|
77
86
|
if (!projectSegment)
|
|
78
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.
|
|
79
93
|
const slug = chatGptProjectSlug(project);
|
|
80
94
|
// The id comes first and the name follows it, so an exact suffix match keeps
|
|
81
95
|
// "notes" from answering for "notes-archive".
|
|
@@ -87,6 +101,25 @@ export function threadMatchesProject(threadUrl, project, knownProjectIds) {
|
|
|
87
101
|
const id = /^g-p-([0-9a-f]+)/i.exec(projectSegment)?.[1]?.toLowerCase();
|
|
88
102
|
return Boolean(id && knownProjectIds?.has(id));
|
|
89
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;
|
|
122
|
+
}
|
|
90
123
|
/**
|
|
91
124
|
* The thread a follow-up should continue, or why it cannot be resolved.
|
|
92
125
|
*
|
|
@@ -114,9 +147,9 @@ export function resolveContinuationThread(input) {
|
|
|
114
147
|
}
|
|
115
148
|
return { target: { taskId: named.taskId, thread: named.thread } };
|
|
116
149
|
}
|
|
117
|
-
const knownProjectIds = input.project
|
|
118
|
-
|
|
119
|
-
|
|
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());
|
|
120
153
|
const candidates = withThread
|
|
121
154
|
.filter((consult) => consult.status === "done")
|
|
122
155
|
.filter((consult) => threadMatchesProject(consult.thread, input.project, knownProjectIds))
|
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);
|