@youdie006/prodex 0.40.4 → 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 +84 -7
- package/dist/cli-help.js +1 -1
- package/dist/cli-pro.js +232 -16
- 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
|
}
|
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)
|
|
@@ -1369,6 +1384,12 @@ export async function runAskProCommand(rest, io) {
|
|
|
1369
1384
|
// --new-chat there is no target url, and a blocker that started a run
|
|
1370
1385
|
// still has a thread worth handing back.
|
|
1371
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);
|
|
1372
1393
|
const persistedBlocker = {
|
|
1373
1394
|
...blocker,
|
|
1374
1395
|
message: redactProject(blocker.message),
|
|
@@ -1379,7 +1400,17 @@ export async function runAskProCommand(rest, io) {
|
|
|
1379
1400
|
status: "blocked",
|
|
1380
1401
|
summary: redactProject(message),
|
|
1381
1402
|
commands: ["visible ChatGPT browser consult"],
|
|
1382
|
-
|
|
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
|
+
}
|
|
1383
1414
|
});
|
|
1384
1415
|
await writeSessionBestEffort(targetStore, {
|
|
1385
1416
|
id: bundle.id,
|
|
@@ -1398,7 +1429,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
1398
1429
|
// Keep stdout machine-parseable for --json consumers on the blocked
|
|
1399
1430
|
// path too; the human-readable error still goes to stderr via throw.
|
|
1400
1431
|
if (jsonOutput) {
|
|
1401
|
-
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));
|
|
1402
1433
|
}
|
|
1403
1434
|
throw new Error(formatBlockedConsultRecordedMessage(message, task.id, sourceCli, { cwd: targetCwd }));
|
|
1404
1435
|
}
|
|
@@ -1510,6 +1541,9 @@ export async function runAskProCommand(rest, io) {
|
|
|
1510
1541
|
warnings: persistenceWarnings,
|
|
1511
1542
|
provenance: {
|
|
1512
1543
|
thread: consult.url,
|
|
1544
|
+
...(Object.keys(selectionMetadata).length > 0
|
|
1545
|
+
? { selection: redactSelectionForRecord(selectionMetadata, redactProjectNamesForRecord(selectionMetadata)) }
|
|
1546
|
+
: {}),
|
|
1513
1547
|
warnings: persistenceWarnings
|
|
1514
1548
|
}
|
|
1515
1549
|
});
|
|
@@ -1641,6 +1675,30 @@ export async function performBrowserRecoverForMcp(cwd, input) {
|
|
|
1641
1675
|
notes: stderrLines
|
|
1642
1676
|
};
|
|
1643
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
|
+
}
|
|
1644
1702
|
export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
1645
1703
|
const stdoutLines = [];
|
|
1646
1704
|
const stderrLines = [];
|
|
@@ -1667,16 +1725,38 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
|
1667
1725
|
"--",
|
|
1668
1726
|
input.prompt
|
|
1669
1727
|
];
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
onProgress(
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
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
|
+
}
|
|
1680
1760
|
const header = stdoutLines[0] ?? "";
|
|
1681
1761
|
const [taskId = "", status = "", thread = ""] = header.split("\t");
|
|
1682
1762
|
return {
|
|
@@ -1955,6 +2035,35 @@ export function redactProjectNames(text, names) {
|
|
|
1955
2035
|
}
|
|
1956
2036
|
return redacted;
|
|
1957
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
|
+
}
|
|
1958
2067
|
export function browserSendBlockerFromError(error) {
|
|
1959
2068
|
const blocker = typeof error === "object" && error !== null && "blocker" in error ? error.blocker : undefined;
|
|
1960
2069
|
if (typeof blocker === "object" &&
|
|
@@ -2032,6 +2141,100 @@ export function browserSendBlockerFromError(error) {
|
|
|
2032
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."
|
|
2033
2142
|
};
|
|
2034
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
|
+
}
|
|
2035
2238
|
// The picker could not provide the step that was asked for. Retrying asks
|
|
2036
2239
|
// the same picker the same question, so this is not retryable; the caller
|
|
2037
2240
|
// either picks a step it offers or opts into whatever the slider is on.
|
|
@@ -2117,6 +2320,15 @@ async function recordedModelUsed(store, taskId) {
|
|
|
2117
2320
|
return undefined;
|
|
2118
2321
|
}
|
|
2119
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
|
+
}
|
|
2120
2332
|
export function formatProAnswer(consult, sourceCli, options = {}) {
|
|
2121
2333
|
const blocker = sourceAwareProAnswerBlocker(consult, sourceCli, options);
|
|
2122
2334
|
const summary = sourceAwareProAnswerSummary(consult.result.summary, consult.result.blocker, blocker);
|
|
@@ -2124,6 +2336,10 @@ export function formatProAnswer(consult, sourceCli, options = {}) {
|
|
|
2124
2336
|
`task_id: ${consult.task.id}`,
|
|
2125
2337
|
`status: ${consult.result.status}`,
|
|
2126
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),
|
|
2127
2343
|
`created_at: ${consult.result.created_at}`,
|
|
2128
2344
|
"",
|
|
2129
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/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);
|