@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.
@@ -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
  }
@@ -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 trigger = document.querySelector('button[aria-label="Select ChatGPT model"],[data-testid="model-switcher-dropdown-button"]');
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
- for (const form of [...document.querySelectorAll("form")]) {
579
- if (form.getClientRects().length > 0) sources.push(form.innerText || "");
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);
@@ -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
- const artifacts = [];
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
- ].find((flag) => proArgs.includes(flag));
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 beforePromptSubmit = (await beforeSend?.({
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 configured port.
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 HTTP server answers /mcp - with 401 when the token is missing,
294
- * which is still an answer. Anything else holding the port means `prodex
295
- * start` cannot bind it, and the configured URL points at a stranger.
296
- * Silence (nothing listening) is the healthy case.
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
- let response;
300
- try {
301
- response = await fetch(`http://${host}:${port}/mcp`, {
302
- method: "POST",
303
- headers: { "Content-Type": "application/json" },
304
- body: "{}",
305
- signal: AbortSignal.timeout(1_500)
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
- // Nothing is listening, or it refused the connection: the port is free for
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 transports = new Map();
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, transports, options.cwd, requestBodyLimitBytes, options.registerRoot);
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, transports);
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
- await Promise.all(Array.from(transports.values()).map((entry) => withTimeout(entry.transport.close(), TRANSPORT_CLOSE_TIMEOUT_MS, "timed out closing HTTP MCP transport").catch(() => undefined)));
93
- transports.clear();
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, transports, cwd, requestBodyLimitBytes, registerRoot) {
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 entry = transports.get(sessionId);
148
- if (!entry) {
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 entry.transport.handleRequest(req, res, body);
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
- transports.set(newSessionId, { transport });
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
- transports.delete(id);
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, transports) {
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
- if (!transports.has(sessionId)) {
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 transports.get(sessionId).transport.handleRequest(req, res);
289
+ await transport.handleRequest(req, res);
196
290
  }
197
291
  async function readJsonBody(req, limitBytes) {
198
292
  const contentLength = parseContentLength(req.headers["content-length"]);