@youdie006/prodex 0.40.50 → 0.40.51
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/browser-send-lock.js +6 -3
- package/dist/chatgpt-browser.js +14 -3
- package/dist/cli-args.js +28 -1
- package/dist/cli-ledger.js +3 -8
- package/dist/cli-pro.js +40 -10
- package/dist/cli.js +66 -21
- package/dist/http-mcp.js +108 -14
- package/dist/mcp.js +86 -39
- package/dist/repo-write.js +25 -6
- package/dist/repo.js +59 -27
- package/dist/safe-file.js +41 -7
- package/dist/store.js +102 -63
- package/dist/tui-run.js +111 -37
- package/dist/tui.js +27 -10
- package/docs/clients.md +11 -0
- package/docs/http-mcp.md +3 -1
- package/docs/releasing.md +38 -0
- package/package.json +1 -1
|
@@ -24,9 +24,10 @@ export function sendPacingMarkerPath() {
|
|
|
24
24
|
* Serialize visible-browser sends across processes. Waits up to waitMs for a
|
|
25
25
|
* live holder to finish (0 = fail fast); a lock whose holder process is dead
|
|
26
26
|
* is reaped immediately. budgetFlag is the option that sets waitMs for the
|
|
27
|
-
* command that is waiting, named when the wait runs out.
|
|
27
|
+
* command that is waiting, named when the wait runs out. A waiter whose signal
|
|
28
|
+
* aborts leaves the queue with cancelledError.
|
|
28
29
|
*/
|
|
29
|
-
export async function withBrowserSendLock(waitMs, onWait, fn, budgetFlag = "--timeout-ms") {
|
|
30
|
+
export async function withBrowserSendLock(waitMs, onWait, fn, budgetFlag = "--timeout-ms", cancel = {}) {
|
|
30
31
|
const file = lockPath();
|
|
31
32
|
const reapClaim = path.join(path.dirname(file), `.${path.basename(file)}.reap`);
|
|
32
33
|
return withCrossProcessFileLock(file, {
|
|
@@ -35,6 +36,8 @@ export async function withBrowserSendLock(waitMs, onWait, fn, budgetFlag = "--ti
|
|
|
35
36
|
privateParent: true,
|
|
36
37
|
onWait: (holder) => onWait(`another prodex send holds the browser (pid ${holder.pid ?? "unknown"}); waiting`),
|
|
37
38
|
busyError: (holder) => new Error(`Another prodex browser send is in progress (pid ${holder.pid ?? "unknown"}) and did not finish within the wait budget. Retry once it finishes, or raise ${budgetFlag} (which is also the queue budget).`),
|
|
38
|
-
unavailableError: () => new Error(`A prodex browser send lock at ${file} is held by nothing and could not be removed, so no send can start. Stop all prodex senders and verify no request is active before deleting that file and the abandoned reaper claim at ${reapClaim}, then retry.`)
|
|
39
|
+
unavailableError: () => new Error(`A prodex browser send lock at ${file} is held by nothing and could not be removed, so no send can start. Stop all prodex senders and verify no request is active before deleting that file and the abandoned reaper claim at ${reapClaim}, then retry.`),
|
|
40
|
+
...(cancel.signal ? { signal: cancel.signal } : {}),
|
|
41
|
+
...(cancel.cancelledError ? { abortedError: cancel.cancelledError } : {})
|
|
39
42
|
}, fn);
|
|
40
43
|
}
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -568,15 +568,26 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
|
568
568
|
* and the request marker (measured 2026-10-07: 179 saved answers in one test
|
|
569
569
|
* repo carried a project name). They now come only from the model selector
|
|
570
570
|
* and the visible composer form, matched on word boundaries.
|
|
571
|
+
*
|
|
572
|
+
* Measured 2026-11-03, a page can hold more than the composer: a new chat keeps
|
|
573
|
+
* the home page's composer, with its own trigger, mounted but hidden ahead of
|
|
574
|
+
* the conversation's, and an "Edit message" editor is a visible form inside
|
|
575
|
+
* the user turn. Taking the first trigger and every visible form reported the
|
|
576
|
+
* hidden composer's model and the prompt being edited, so only the visible
|
|
577
|
+
* composer outside the messages counts, and not the text typed into it.
|
|
571
578
|
*/
|
|
572
579
|
export const CHATGPT_MODEL_HINTS_JS = `
|
|
573
580
|
const modelHintLines = () => {
|
|
574
581
|
const sources = [];
|
|
575
|
-
const
|
|
582
|
+
const shown = (el) => el.getClientRects().length > 0 && !(el.parentElement && el.parentElement.closest('[data-message-author-role],[data-chatgpt-search-unit-key]'));
|
|
583
|
+
const trigger = [...document.querySelectorAll('button[aria-label="Select ChatGPT model"],[data-testid="model-switcher-dropdown-button"]')].find(shown);
|
|
576
584
|
// The trigger's text names the model; its aria-label only names the control.
|
|
577
585
|
if (trigger) sources.push(trigger.innerText || "");
|
|
578
|
-
|
|
579
|
-
|
|
586
|
+
const editor = [...document.querySelectorAll('#prompt-textarea,[contenteditable="true"],textarea')].find(shown);
|
|
587
|
+
const form = editor && editor.closest("form");
|
|
588
|
+
if (form) {
|
|
589
|
+
const typed = (editor.innerText || editor.value || "").split(String.fromCharCode(10)).map((line) => line.trim());
|
|
590
|
+
sources.push((form.innerText || "").split(String.fromCharCode(10)).filter((line) => !typed.includes(line.trim())).join(String.fromCharCode(10)));
|
|
580
591
|
}
|
|
581
592
|
const modelish = /\\b(?:ChatGPT|GPT(?:-[\\w.]+)?|Pro|Plus|Thinking|Instant|Extra High|High|Medium|Auto|Latest)\\b/i;
|
|
582
593
|
const seen = [];
|
package/dist/cli-args.js
CHANGED
|
@@ -31,6 +31,33 @@ export function findHelpFlagIndexBeforePromptDelimiter(args) {
|
|
|
31
31
|
const limit = delimiterIndex === -1 ? args.length : delimiterIndex;
|
|
32
32
|
return args.findIndex((arg, index) => index < limit && isHelpSubcommand(arg));
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Where a help request is, or -1. In a command whose unquoted words are its
|
|
36
|
+
* prompt, the word "help" asks for help only on its own: found 2026-11-09,
|
|
37
|
+
* `prodex ask can you help me fix this` was taken for a help request and
|
|
38
|
+
* failed with "Unexpected argument for ask: can". --help and -h always ask.
|
|
39
|
+
*/
|
|
40
|
+
export function findHelpRequestIndex(args, options = {}) {
|
|
41
|
+
const delimiterIndex = args.indexOf("--");
|
|
42
|
+
const limit = delimiterIndex === -1 ? args.length : delimiterIndex;
|
|
43
|
+
const valueFlagSet = new Set(options.valueFlags ?? []);
|
|
44
|
+
const words = [];
|
|
45
|
+
for (let index = 0; index < limit; index += 1) {
|
|
46
|
+
const arg = args[index];
|
|
47
|
+
if (arg === "--help" || arg === "-h")
|
|
48
|
+
return index;
|
|
49
|
+
if (valueFlagSet.has(arg)) {
|
|
50
|
+
index += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!arg.startsWith("-"))
|
|
54
|
+
words.push(index);
|
|
55
|
+
}
|
|
56
|
+
const helpWord = words.find((index) => args[index] === "help");
|
|
57
|
+
if (helpWord === undefined)
|
|
58
|
+
return -1;
|
|
59
|
+
return !options.promptWords || words.length === 1 ? helpWord : -1;
|
|
60
|
+
}
|
|
34
61
|
export function assertHelpRequestArgs(args, command, options) {
|
|
35
62
|
const delimiterIndex = args.indexOf("--");
|
|
36
63
|
const commandArgs = delimiterIndex === -1 ? args : args.slice(0, delimiterIndex);
|
|
@@ -397,7 +424,7 @@ export function readNumberFlag(args, flag) {
|
|
|
397
424
|
}
|
|
398
425
|
export const RECEIPT_KINDS = ReceiptKindSchema.options;
|
|
399
426
|
export function printHelpIfRequested(args, command, stdout, printHelp, options = {}) {
|
|
400
|
-
const helpIndex = findHelpFlagIndexBeforePromptDelimiter(args);
|
|
427
|
+
const helpIndex = options.promptWords ? findHelpRequestIndex(args, options) : findHelpFlagIndexBeforePromptDelimiter(args);
|
|
401
428
|
if (helpIndex === -1)
|
|
402
429
|
return false;
|
|
403
430
|
assertHelpRequestArgs(args, command, options);
|
package/dist/cli-ledger.js
CHANGED
|
@@ -92,7 +92,7 @@ export async function runTasksCommand(rest, io) {
|
|
|
92
92
|
status: "done",
|
|
93
93
|
summary,
|
|
94
94
|
commands: readRepeatedFlag(taskArgs, "--command"),
|
|
95
|
-
artifacts: await writeTaskCompleteArtifacts(targetStore, readRepeatedFlag(taskArgs, "--artifact"))
|
|
95
|
+
artifacts: await writeTaskCompleteArtifacts(targetStore, taskId, readRepeatedFlag(taskArgs, "--artifact"))
|
|
96
96
|
});
|
|
97
97
|
io.stdout(`${result.task_id}\t${result.status}\t${result.summary}`);
|
|
98
98
|
return 0;
|
|
@@ -355,7 +355,7 @@ export function receiptInspectionListSuffix(receipt) {
|
|
|
355
355
|
}
|
|
356
356
|
return "";
|
|
357
357
|
}
|
|
358
|
-
export async function writeTaskCompleteArtifacts(store, values) {
|
|
358
|
+
export async function writeTaskCompleteArtifacts(store, taskId, values) {
|
|
359
359
|
const parsed = [];
|
|
360
360
|
const seen = new Set();
|
|
361
361
|
for (const value of values) {
|
|
@@ -377,12 +377,7 @@ export async function writeTaskCompleteArtifacts(store, values) {
|
|
|
377
377
|
seen.add(key);
|
|
378
378
|
parsed.push({ artifactPath, content });
|
|
379
379
|
}
|
|
380
|
-
|
|
381
|
-
for (const { artifactPath, content } of parsed) {
|
|
382
|
-
const storedPath = await store.writeArtifactText(artifactPath, content);
|
|
383
|
-
artifacts.push({ path: storedPath, role: "result" });
|
|
384
|
-
}
|
|
385
|
-
return artifacts;
|
|
380
|
+
return store.writeResultArtifactTexts(taskId, parsed.map(({ artifactPath, content }) => ({ path: artifactPath, content })));
|
|
386
381
|
}
|
|
387
382
|
export async function listTasksForInspection(store, status) {
|
|
388
383
|
return store.listTasksReadOnly(status);
|
package/dist/cli-pro.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { BrowserProcessInspectionError } from "./browser-process.js";
|
|
6
6
|
import { buildDryRunBundle } from "./bundle.js";
|
|
7
7
|
import { ChatGptBrowserBlockerError, 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, resolveBrowserWindowMode, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl, chatStepNamedAsModel, normalizeChatGptThreadUrl } from "./chatgpt-browser.js";
|
|
8
|
-
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, assertProjectName } from "./cli-args.js";
|
|
8
|
+
import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, findHelpRequestIndex, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError, assertProjectName } from "./cli-args.js";
|
|
9
9
|
import { printProBrowserHelp, printProHelp } from "./cli-help.js";
|
|
10
10
|
import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
|
|
11
11
|
import { formatBrowserDefaults, redactServerUrl } from "./cli-server.js";
|
|
@@ -158,7 +158,8 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
158
158
|
if (subcommand === "ask") {
|
|
159
159
|
if (printHelpIfRequested(proArgs, "pro ask", io.stdout, printProHelp, {
|
|
160
160
|
valueFlags: [...ASK_PRO_PREVIEW_VALUE_FLAGS],
|
|
161
|
-
booleanFlags: [...ASK_PRO_BOOLEAN_FLAGS]
|
|
161
|
+
booleanFlags: [...ASK_PRO_BOOLEAN_FLAGS],
|
|
162
|
+
promptWords: true
|
|
162
163
|
})) {
|
|
163
164
|
return 0;
|
|
164
165
|
}
|
|
@@ -187,7 +188,9 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
187
188
|
"--continue-task",
|
|
188
189
|
"--auto-login",
|
|
189
190
|
"--no-auto-login"
|
|
190
|
-
|
|
191
|
+
// Only before --: the words after it are the prompt, which may well
|
|
192
|
+
// mention an option (found 2026-11-09: "what does --project do").
|
|
193
|
+
].find((flag) => (proArgs.includes("--") ? proArgs.slice(0, proArgs.indexOf("--")) : proArgs).includes(flag));
|
|
191
194
|
if (straySendFlag) {
|
|
192
195
|
throw new Error(`${straySendFlag} only applies when sending; \`prodex pro ask\` is a dry-run preview. Use \`prodex pro browser ask\` (or \`prodex ask\`) to send.`);
|
|
193
196
|
}
|
|
@@ -497,7 +500,8 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
497
500
|
if (browserSubcommand === "ask") {
|
|
498
501
|
if (printProBrowserHelpIfRequested(browserArgs, "pro browser ask", io, {
|
|
499
502
|
valueFlags: [...ASK_PRO_VALUE_FLAGS],
|
|
500
|
-
booleanFlags: [...ASK_PRO_BOOLEAN_FLAGS]
|
|
503
|
+
booleanFlags: [...ASK_PRO_BOOLEAN_FLAGS],
|
|
504
|
+
promptWords: true
|
|
501
505
|
})) {
|
|
502
506
|
return 0;
|
|
503
507
|
}
|
|
@@ -1475,11 +1479,23 @@ beforeSend) {
|
|
|
1475
1479
|
if (hasSendMode) {
|
|
1476
1480
|
// MCP approval checkpoints run after target validation but before any
|
|
1477
1481
|
// task creation, pacing, browser recovery, or prompt send.
|
|
1478
|
-
const
|
|
1482
|
+
const beforeSubmitStep = (await beforeSend?.({
|
|
1479
1483
|
store: targetStore,
|
|
1480
1484
|
...(normalizedTargetUrl ? { thread: normalizedTargetUrl } : {}),
|
|
1481
1485
|
...(continuedFromTaskId ? { continuedFrom: continuedFromTaskId } : {})
|
|
1482
1486
|
})) || undefined;
|
|
1487
|
+
// A cancelled call is stopped before its prompt is submitted: here, in
|
|
1488
|
+
// the send queue, instead of a browser recovery, and right before the
|
|
1489
|
+
// submit. After the submit it is not: an answer already on its way is
|
|
1490
|
+
// waited for and recorded as usual. The last check runs ahead of the
|
|
1491
|
+
// step, so a cancelled follow-up does not spend the budget it reserves.
|
|
1492
|
+
throwIfConsultCancelled(io.signal);
|
|
1493
|
+
const beforePromptSubmit = io.signal || beforeSubmitStep
|
|
1494
|
+
? async () => {
|
|
1495
|
+
throwIfConsultCancelled(io.signal);
|
|
1496
|
+
await beforeSubmitStep?.();
|
|
1497
|
+
}
|
|
1498
|
+
: undefined;
|
|
1483
1499
|
const browserCommandOptions = {
|
|
1484
1500
|
cwd: targetCwd,
|
|
1485
1501
|
port: parsedAskPro.optionArgs.includes("--port") ? browserPort : undefined
|
|
@@ -1561,7 +1577,7 @@ beforeSend) {
|
|
|
1561
1577
|
...(beforePromptSubmit ? { beforePromptSubmit } : {}),
|
|
1562
1578
|
onProgress: createBrowserSendProgressPrinter(io.stderr)
|
|
1563
1579
|
});
|
|
1564
|
-
});
|
|
1580
|
+
}, "--timeout-ms", { ...(io.signal ? { signal: io.signal } : {}), cancelledError: consultCancelledError });
|
|
1565
1581
|
// One-command recovery: interactive terminals (or explicit --auto-login)
|
|
1566
1582
|
// launch the dedicated browser and retry once when no browser runs.
|
|
1567
1583
|
// Scripts and agents keep the plain blocker unless they opt in.
|
|
@@ -1580,8 +1596,9 @@ beforeSend) {
|
|
|
1580
1596
|
catch (error) {
|
|
1581
1597
|
const firstBlocker = browserSendBlockerFromError(error);
|
|
1582
1598
|
// Loss while reading an already-submitted prompt must not send it again.
|
|
1599
|
+
// Nor is a browser launched for a call that has been cancelled.
|
|
1583
1600
|
if (!["browser_unreachable", "chatgpt_page_missing"].includes(firstBlocker.code) ||
|
|
1584
|
-
!firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed)
|
|
1601
|
+
!firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed || io.signal?.aborted)
|
|
1585
1602
|
throw error;
|
|
1586
1603
|
const recover = firstBlocker.code === "chatgpt_page_missing" ? attemptMissingChatGptTabRecovery : attemptBrowserAutoRecovery;
|
|
1587
1604
|
const recovered = await recover(io.stderr, {
|
|
@@ -1940,6 +1957,18 @@ export async function performBrowserRecoverForMcp(cwd, input) {
|
|
|
1940
1957
|
notes: stderrLines
|
|
1941
1958
|
};
|
|
1942
1959
|
}
|
|
1960
|
+
function consultCancelledError() {
|
|
1961
|
+
return new ChatGptBrowserBlockerError({
|
|
1962
|
+
code: "consult_cancelled",
|
|
1963
|
+
message: "The client cancelled this consult before its prompt was submitted, so nothing was sent to ChatGPT.",
|
|
1964
|
+
retryable: true,
|
|
1965
|
+
next_step: "Nothing to recover. Send the consult again if it is still wanted."
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
function throwIfConsultCancelled(signal) {
|
|
1969
|
+
if (signal?.aborted)
|
|
1970
|
+
throw consultCancelledError();
|
|
1971
|
+
}
|
|
1943
1972
|
/** The marker the send prints before throwing, when the answer outlived its record. */
|
|
1944
1973
|
const ANSWER_NOT_SAVED_MARKER = "consult_answer_received_but_not_saved:";
|
|
1945
1974
|
/**
|
|
@@ -1993,7 +2022,7 @@ export function mcpBlockedConsultOutcome(stdoutLines, error) {
|
|
|
1993
2022
|
...(thread && requestId ? { recover: { tool: "pro_recover", arguments: { thread, request_id: requestId } } } : {})
|
|
1994
2023
|
};
|
|
1995
2024
|
}
|
|
1996
|
-
export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
2025
|
+
export async function performBrowserConsultForMcp(cwd, input, onProgress, signal) {
|
|
1997
2026
|
const stdoutLines = [];
|
|
1998
2027
|
const stderrLines = [];
|
|
1999
2028
|
const sessionKey = resolveProdexSessionKey(input.session_key);
|
|
@@ -2041,7 +2070,8 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
|
2041
2070
|
if (onProgress && line.startsWith("progress:"))
|
|
2042
2071
|
onProgress(line);
|
|
2043
2072
|
},
|
|
2044
|
-
allowAskProBrowserSend: true
|
|
2073
|
+
allowAskProBrowserSend: true,
|
|
2074
|
+
...(signal ? { signal } : {})
|
|
2045
2075
|
}, async ({ store, thread, continuedFrom }) => {
|
|
2046
2076
|
if (!thread || !continuedFrom)
|
|
2047
2077
|
return;
|
|
@@ -3415,7 +3445,7 @@ export function printBrowserLoginGuide(stdout, input) {
|
|
|
3415
3445
|
}
|
|
3416
3446
|
}
|
|
3417
3447
|
export function printProBrowserHelpIfRequested(args, command, io, options) {
|
|
3418
|
-
const helpIndex = findHelpFlagIndexBeforePromptDelimiter(args);
|
|
3448
|
+
const helpIndex = options.promptWords ? findHelpRequestIndex(args, options) : findHelpFlagIndexBeforePromptDelimiter(args);
|
|
3419
3449
|
if (helpIndex === -1)
|
|
3420
3450
|
return false;
|
|
3421
3451
|
assertHelpRequestArgs(args, command, options);
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
|
+
import http from "node:http";
|
|
4
5
|
import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import path from "node:path";
|
|
@@ -280,7 +281,7 @@ export async function runCli(args, io = defaultIo()) {
|
|
|
280
281
|
// pro_consult is stdio-only: the HTTP MCP surface is exposed to ChatGPT
|
|
281
282
|
// itself (and possibly a tunnel) and must never drive the user's browser.
|
|
282
283
|
await runMcpServer(mcpCwd, {
|
|
283
|
-
browserConsult: (input, onProgress) => performBrowserConsultForMcp(mcpCwd, input, onProgress),
|
|
284
|
+
browserConsult: (input, onProgress, signal) => performBrowserConsultForMcp(mcpCwd, input, onProgress, signal),
|
|
284
285
|
browserRecover: (input) => performBrowserRecoverForMcp(mcpCwd, input)
|
|
285
286
|
});
|
|
286
287
|
return 0;
|
|
@@ -288,31 +289,75 @@ export async function runCli(args, io = defaultIo()) {
|
|
|
288
289
|
throw unknownTopLevelCommandError(command);
|
|
289
290
|
}
|
|
290
291
|
/**
|
|
291
|
-
* Whether something other than prodex is sitting on the
|
|
292
|
+
* Whether something other than this repo's prodex server is sitting on the
|
|
293
|
+
* configured port, which `prodex start` then cannot bind.
|
|
292
294
|
*
|
|
293
|
-
* A prodex
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
295
|
+
* A prodex server names itself on /health, without a token. Found
|
|
296
|
+
* 2026-11-09: the check used to take any server answering /mcp with 200, 400
|
|
297
|
+
* or 401 for prodex, and a program that does not speak HTTP, or accepts the
|
|
298
|
+
* connection and says nothing, for a free port. Another repo's prodex server
|
|
299
|
+
* is the likeliest holder, since every repo's setup defaults to port 8787; it
|
|
300
|
+
* refuses this repo's token. A refused connection is the healthy case.
|
|
297
301
|
*/
|
|
298
|
-
export async function describeConfiguredPortHolder(host, port) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
302
|
+
export async function describeConfiguredPortHolder(host, port, token) {
|
|
303
|
+
const cannotBind = "so `prodex start` cannot bind it";
|
|
304
|
+
const health = await probeHttpEndpoint(host, port, "/health", { method: "GET" });
|
|
305
|
+
if (health.kind === "free")
|
|
306
|
+
return undefined;
|
|
307
|
+
if (health.kind === "not-http")
|
|
308
|
+
return `port ${port} on ${host} is held by a program that does not answer HTTP, ${cannotBind}.`;
|
|
309
|
+
if (!isProdexHealth(health.body)) {
|
|
310
|
+
return `port ${port} on ${host} is held by something that is not a prodex server (it answered /health with HTTP ${health.status}), ${cannotBind}.`;
|
|
311
|
+
}
|
|
312
|
+
if (token === undefined)
|
|
313
|
+
return undefined;
|
|
314
|
+
const mcp = await probeHttpEndpoint(host, port, "/mcp", {
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
317
|
+
body: "{}"
|
|
318
|
+
});
|
|
319
|
+
if (mcp.kind === "http" && mcp.status === 401) {
|
|
320
|
+
return `port ${port} on ${host} is held by another prodex server, one that refuses this repo's token (another repo's \`prodex start\`, or one started before this token was set), ${cannotBind} here.`;
|
|
321
|
+
}
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
function probeHttpEndpoint(host, port, requestPath, options) {
|
|
325
|
+
return new Promise((resolve) => {
|
|
326
|
+
let connected = false;
|
|
327
|
+
const request = http.request({ host, port, path: requestPath, method: options.method, headers: options.headers, timeout: 1_500, agent: false }, (response) => {
|
|
328
|
+
let body = "";
|
|
329
|
+
response.setEncoding("utf8");
|
|
330
|
+
response.on("data", (chunk) => {
|
|
331
|
+
if (body.length < 4_096)
|
|
332
|
+
body += chunk;
|
|
333
|
+
});
|
|
334
|
+
response.on("end", () => resolve({ kind: "http", status: response.statusCode ?? 0, body }));
|
|
335
|
+
response.on("error", () => resolve({ kind: "http", status: response.statusCode ?? 0, body }));
|
|
306
336
|
});
|
|
337
|
+
request.on("socket", (socket) => socket.once("connect", () => (connected = true)));
|
|
338
|
+
// A connection accepted and then left silent is a program holding the
|
|
339
|
+
// port. One never accepted is not: on WSL a free loopback port does not
|
|
340
|
+
// refuse a connection, it lets the attempt time out.
|
|
341
|
+
request.on("timeout", () => {
|
|
342
|
+
request.destroy();
|
|
343
|
+
resolve({ kind: connected ? "not-http" : "free" });
|
|
344
|
+
});
|
|
345
|
+
request.on("error", (error) => {
|
|
346
|
+
request.destroy();
|
|
347
|
+
const nothingListening = !connected || ["ECONNREFUSED", "EADDRNOTAVAIL", "EHOSTUNREACH", "ENETUNREACH"].includes(error.code ?? "");
|
|
348
|
+
resolve({ kind: nothingListening ? "free" : "not-http" });
|
|
349
|
+
});
|
|
350
|
+
request.end(options.body);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
function isProdexHealth(body) {
|
|
354
|
+
try {
|
|
355
|
+
const parsed = JSON.parse(body);
|
|
356
|
+
return parsed.name === "prodex";
|
|
307
357
|
}
|
|
308
358
|
catch {
|
|
309
|
-
|
|
310
|
-
// prodex to take, which is what this check is about.
|
|
311
|
-
return undefined;
|
|
359
|
+
return false;
|
|
312
360
|
}
|
|
313
|
-
if (response.status === 401 || response.status === 200 || response.status === 400)
|
|
314
|
-
return undefined;
|
|
315
|
-
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.`;
|
|
316
361
|
}
|
|
317
362
|
function defaultIo() {
|
|
318
363
|
return {
|
|
@@ -1046,7 +1091,7 @@ async function runDoctor(store, io, sourceCli, setupHintCwd) {
|
|
|
1046
1091
|
// Measured here: the configured port was held by an unrelated program,
|
|
1047
1092
|
// so `prodex start` could never bind it - and doctor called the config
|
|
1048
1093
|
// ok anyway, which is the one line someone reads before believing it.
|
|
1049
|
-
const portHolder = await describeConfiguredPortHolder(config.host, config.port);
|
|
1094
|
+
const portHolder = await describeConfiguredPortHolder(config.host, config.port, config.token);
|
|
1050
1095
|
if (portHolder) {
|
|
1051
1096
|
io.stdout(`config_warning: ${portHolder} \`${formatSetupCommand(sourceCli, { cwd: setupHintCwd })} --port <free port>\` moves prodex off it.`);
|
|
1052
1097
|
}
|
package/dist/http-mcp.js
CHANGED
|
@@ -6,11 +6,101 @@ import { createServer as createMcpServer } from "./mcp.js";
|
|
|
6
6
|
import { normalizeLoopbackHttpHost } from "./config.js";
|
|
7
7
|
const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 1_048_576;
|
|
8
8
|
const TRANSPORT_CLOSE_TIMEOUT_MS = 5_000;
|
|
9
|
+
/**
|
|
10
|
+
* Every initialize used to keep its MCP server and transport until a DELETE
|
|
11
|
+
* that clients rarely send, about 170 KB each: measured 2026-11-09, RSS went
|
|
12
|
+
* from 92 MB to 1 GB over 5,000 initializes. The bridge tools answer in
|
|
13
|
+
* seconds, so a session with no request for half an hour and no stream open
|
|
14
|
+
* has been left behind; a client that does come back gets 404, which MCP
|
|
15
|
+
* clients answer by initializing again, at the cost of one round trip.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60_000;
|
|
18
|
+
/**
|
|
19
|
+
* A personal server sees a few clients at a time (a ChatGPT project, Codex, a
|
|
20
|
+
* Claude session), so 100 live sessions is far above real use while holding
|
|
21
|
+
* at most about 17 MB, also against a client that initializes in a loop.
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_MAX_SESSIONS = 100;
|
|
24
|
+
const SESSION_SWEEP_INTERVAL_MAX_MS = 60_000;
|
|
25
|
+
/**
|
|
26
|
+
* The live MCP sessions, kept in least-recently-used order. A session is in
|
|
27
|
+
* use while a request of its own is open (a GET stream stays open for as long
|
|
28
|
+
* as the client listens), and idle from when its last request ended.
|
|
29
|
+
*/
|
|
30
|
+
class HttpMcpSessions {
|
|
31
|
+
limits;
|
|
32
|
+
entries = new Map();
|
|
33
|
+
constructor(limits) {
|
|
34
|
+
this.limits = limits;
|
|
35
|
+
}
|
|
36
|
+
/** The session's transport, counted as in use until res closes; undefined when unknown or closed. */
|
|
37
|
+
use(sessionId, res) {
|
|
38
|
+
const entry = this.entries.get(sessionId);
|
|
39
|
+
if (!entry)
|
|
40
|
+
return undefined;
|
|
41
|
+
this.entries.delete(sessionId);
|
|
42
|
+
this.entries.set(sessionId, entry);
|
|
43
|
+
entry.openRequests += 1;
|
|
44
|
+
entry.lastActiveMs = this.limits.now();
|
|
45
|
+
res.once("close", () => {
|
|
46
|
+
entry.openRequests -= 1;
|
|
47
|
+
entry.lastActiveMs = this.limits.now();
|
|
48
|
+
});
|
|
49
|
+
return entry.transport;
|
|
50
|
+
}
|
|
51
|
+
add(sessionId, transport) {
|
|
52
|
+
this.entries.set(sessionId, { transport, lastActiveMs: this.limits.now(), openRequests: 0 });
|
|
53
|
+
while (this.entries.size > this.limits.maxSessions) {
|
|
54
|
+
// The least recently used session without an open request, else the
|
|
55
|
+
// least recently used of all; never the one just added, which is last.
|
|
56
|
+
let victim;
|
|
57
|
+
for (const [id, entry] of this.entries) {
|
|
58
|
+
if (entry.openRequests === 0) {
|
|
59
|
+
victim = id;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
this.close(victim ?? this.entries.keys().next().value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
remove(sessionId, transport) {
|
|
67
|
+
if (this.entries.get(sessionId)?.transport === transport)
|
|
68
|
+
this.entries.delete(sessionId);
|
|
69
|
+
}
|
|
70
|
+
sweep() {
|
|
71
|
+
const now = this.limits.now();
|
|
72
|
+
for (const [id, entry] of this.entries) {
|
|
73
|
+
if (entry.openRequests === 0 && now - entry.lastActiveMs >= this.limits.idleTimeoutMs)
|
|
74
|
+
this.close(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async closeAll() {
|
|
78
|
+
const transports = Array.from(this.entries.values(), (entry) => entry.transport);
|
|
79
|
+
this.entries.clear();
|
|
80
|
+
await Promise.all(transports.map((transport) => closeTransport(transport)));
|
|
81
|
+
}
|
|
82
|
+
/** Forgotten at once, so the id answers 404 like any unknown session. */
|
|
83
|
+
close(sessionId) {
|
|
84
|
+
const entry = this.entries.get(sessionId);
|
|
85
|
+
if (!entry)
|
|
86
|
+
return;
|
|
87
|
+
this.entries.delete(sessionId);
|
|
88
|
+
void closeTransport(entry.transport);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function closeTransport(transport) {
|
|
92
|
+
return withTimeout(transport.close(), TRANSPORT_CLOSE_TIMEOUT_MS, "timed out closing HTTP MCP transport").catch(() => undefined);
|
|
93
|
+
}
|
|
9
94
|
export async function startHttpMcpServer(options) {
|
|
10
95
|
const host = normalizeLoopbackHttpHost(options.host ?? "127.0.0.1");
|
|
11
96
|
const token = options.token;
|
|
12
97
|
const requestBodyLimitBytes = options.requestBodyLimitBytes ?? DEFAULT_REQUEST_BODY_LIMIT_BYTES;
|
|
13
|
-
const
|
|
98
|
+
const sessionIdleTimeoutMs = Math.max(1, options.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS);
|
|
99
|
+
const sessions = new HttpMcpSessions({
|
|
100
|
+
idleTimeoutMs: sessionIdleTimeoutMs,
|
|
101
|
+
maxSessions: Math.max(1, options.maxSessions ?? DEFAULT_MAX_SESSIONS),
|
|
102
|
+
now: options.now ?? Date.now
|
|
103
|
+
});
|
|
14
104
|
const server = http.createServer(async (req, res) => {
|
|
15
105
|
try {
|
|
16
106
|
// Only the path and query are read, so the base is fixed: taking it from
|
|
@@ -41,11 +131,11 @@ export async function startHttpMcpServer(options) {
|
|
|
41
131
|
return;
|
|
42
132
|
}
|
|
43
133
|
if (req.method === "POST") {
|
|
44
|
-
await handlePost(req, res,
|
|
134
|
+
await handlePost(req, res, sessions, options.cwd, requestBodyLimitBytes, options.registerRoot);
|
|
45
135
|
return;
|
|
46
136
|
}
|
|
47
137
|
if (req.method === "GET" || req.method === "DELETE") {
|
|
48
|
-
await handleSessionRequest(req, res,
|
|
138
|
+
await handleSessionRequest(req, res, sessions);
|
|
49
139
|
return;
|
|
50
140
|
}
|
|
51
141
|
writeJson(res, 405, { error: "method_not_allowed" });
|
|
@@ -80,6 +170,9 @@ export async function startHttpMcpServer(options) {
|
|
|
80
170
|
resolve();
|
|
81
171
|
});
|
|
82
172
|
});
|
|
173
|
+
// Unref'd: an idle sweep is no reason for the process to stay up.
|
|
174
|
+
const sweepTimer = setInterval(() => sessions.sweep(), Math.min(SESSION_SWEEP_INTERVAL_MAX_MS, sessionIdleTimeoutMs));
|
|
175
|
+
sweepTimer.unref();
|
|
83
176
|
const address = server.address();
|
|
84
177
|
const port = typeof address === "object" && address ? address.port : options.port ?? 8787;
|
|
85
178
|
const url = `http://${host}:${port}`;
|
|
@@ -89,8 +182,8 @@ export async function startHttpMcpServer(options) {
|
|
|
89
182
|
url,
|
|
90
183
|
mcp_url: token ? `${url}/mcp?prodex_token=${encodeURIComponent(token)}` : `${url}/mcp`,
|
|
91
184
|
close: async (closeOptions = {}) => {
|
|
92
|
-
|
|
93
|
-
|
|
185
|
+
clearInterval(sweepTimer);
|
|
186
|
+
await sessions.closeAll();
|
|
94
187
|
await closeHttpServer(server, closeOptions);
|
|
95
188
|
}
|
|
96
189
|
};
|
|
@@ -140,16 +233,16 @@ async function withTimeout(promise, timeoutMs, message) {
|
|
|
140
233
|
clearTimeout(timeout);
|
|
141
234
|
}
|
|
142
235
|
}
|
|
143
|
-
async function handlePost(req, res,
|
|
236
|
+
async function handlePost(req, res, sessions, cwd, requestBodyLimitBytes, registerRoot) {
|
|
144
237
|
const body = await readJsonBody(req, requestBodyLimitBytes);
|
|
145
238
|
const sessionId = headerValue(req.headers["mcp-session-id"]);
|
|
146
239
|
if (sessionId) {
|
|
147
|
-
const
|
|
148
|
-
if (!
|
|
240
|
+
const transport = sessions.use(sessionId, res);
|
|
241
|
+
if (!transport) {
|
|
149
242
|
writeJson(res, 404, { error: "session_not_found" });
|
|
150
243
|
return;
|
|
151
244
|
}
|
|
152
|
-
await
|
|
245
|
+
await transport.handleRequest(req, res, body);
|
|
153
246
|
return;
|
|
154
247
|
}
|
|
155
248
|
if (!sessionId && isInitializeRequest(body)) {
|
|
@@ -159,13 +252,13 @@ async function handlePost(req, res, transports, cwd, requestBodyLimitBytes, regi
|
|
|
159
252
|
enableJsonResponse: true,
|
|
160
253
|
onsessioninitialized: (newSessionId) => {
|
|
161
254
|
initializedSessionId = newSessionId;
|
|
162
|
-
|
|
255
|
+
sessions.add(newSessionId, transport);
|
|
163
256
|
}
|
|
164
257
|
});
|
|
165
258
|
transport.onclose = () => {
|
|
166
259
|
const id = transport.sessionId || initializedSessionId;
|
|
167
260
|
if (id)
|
|
168
|
-
|
|
261
|
+
sessions.remove(id, transport);
|
|
169
262
|
};
|
|
170
263
|
const mcpServer = createMcpServer(cwd, {
|
|
171
264
|
source: "chatgpt_project",
|
|
@@ -182,17 +275,18 @@ async function handlePost(req, res, transports, cwd, requestBodyLimitBytes, regi
|
|
|
182
275
|
id: null
|
|
183
276
|
});
|
|
184
277
|
}
|
|
185
|
-
async function handleSessionRequest(req, res,
|
|
278
|
+
async function handleSessionRequest(req, res, sessions) {
|
|
186
279
|
const sessionId = headerValue(req.headers["mcp-session-id"]);
|
|
187
280
|
if (!sessionId) {
|
|
188
281
|
writeJson(res, 400, { error: "invalid_or_missing_session" });
|
|
189
282
|
return;
|
|
190
283
|
}
|
|
191
|
-
|
|
284
|
+
const transport = sessions.use(sessionId, res);
|
|
285
|
+
if (!transport) {
|
|
192
286
|
writeJson(res, 404, { error: "session_not_found" });
|
|
193
287
|
return;
|
|
194
288
|
}
|
|
195
|
-
await
|
|
289
|
+
await transport.handleRequest(req, res);
|
|
196
290
|
}
|
|
197
291
|
async function readJsonBody(req, limitBytes) {
|
|
198
292
|
const contentLength = parseContentLength(req.headers["content-length"]);
|