@youdie006/prodex 0.40.49 → 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/bundle.js +3 -1
- package/dist/chatgpt-browser.js +32 -7
- package/dist/cli-args.js +28 -1
- package/dist/cli-ledger.js +3 -8
- package/dist/cli-pro.js +65 -20
- package/dist/cli.js +66 -21
- package/dist/followup-budget.js +24 -2
- 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 +20 -4
- package/docs/http-mcp.md +3 -1
- package/docs/releasing.md +139 -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/bundle.js
CHANGED
|
@@ -66,7 +66,9 @@ export async function buildDryRunBundle(root, input) {
|
|
|
66
66
|
}
|
|
67
67
|
return {
|
|
68
68
|
schema_version: SCHEMA_VERSION,
|
|
69
|
-
id
|
|
69
|
+
// The id is listed wherever sessions are (MCP bridge_list_sessions among
|
|
70
|
+
// them), so it carries no words from the prompt.
|
|
71
|
+
id: makeBridgeId("sess", randomUUID().slice(0, 8)),
|
|
70
72
|
mode: "manual_copy",
|
|
71
73
|
prompt: input.prompt,
|
|
72
74
|
files,
|
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 = [];
|
|
@@ -2713,12 +2724,19 @@ export function focusPowerSliderExpression() {
|
|
|
2713
2724
|
* only (the exact "Select ChatGPT model" control first, the older heuristic
|
|
2714
2725
|
* second), and an unrendered composer is reported as not found, which the
|
|
2715
2726
|
* caller's poll waits out.
|
|
2727
|
+
*
|
|
2728
|
+
* An open "Edit message" editor is a form inside the transcript, ahead of the
|
|
2729
|
+
* composer in the document, and has no trigger. Measured 2026-11-03: taking it
|
|
2730
|
+
* refused every follow-up that selected a model until the page was reloaded,
|
|
2731
|
+
* so editors inside a message are skipped. Only message ancestors exclude one
|
|
2732
|
+
* here, not aria-hidden as in the typing lookup: an open menu aria-hides the
|
|
2733
|
+
* composer itself.
|
|
2716
2734
|
*/
|
|
2717
2735
|
export function modelButtonRectExpression() {
|
|
2718
2736
|
return `(() => {${CLICK_POINT_SNIPPET}
|
|
2719
2737
|
const composer = [...document.querySelectorAll('#prompt-textarea,[contenteditable="true"],textarea')].find((el) => {
|
|
2720
2738
|
const r = el.getBoundingClientRect();
|
|
2721
|
-
return r.width > 0 && r.height > 0;
|
|
2739
|
+
return r.width > 0 && r.height > 0 && !(el.parentElement && el.parentElement.closest('[data-message-author-role],[data-chatgpt-search-unit-key]'));
|
|
2722
2740
|
});
|
|
2723
2741
|
if (!composer) return { ok: false, reason: "model selector button not found: the composer has not rendered" };
|
|
2724
2742
|
const scope = composer.closest('form');
|
|
@@ -4581,6 +4599,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
4581
4599
|
if (last && expandCollapsedUserTurn(last.textNode)) return false;
|
|
4582
4600
|
return Boolean(last && userMessageText(last.textNode).includes(${JSON.stringify(`[prodex-request:${requestId}]`)}));
|
|
4583
4601
|
})()`;
|
|
4602
|
+
await options.beforePromptSubmit?.();
|
|
4584
4603
|
// A dispatch can reach Chrome even if its acknowledgement is lost.
|
|
4585
4604
|
submissionAttempted = true;
|
|
4586
4605
|
await cdp.send("Input.dispatchKeyEvent", enterKeyEvent("keyDown"));
|
|
@@ -6103,9 +6122,11 @@ export function composerToolsButtonRectExpression() {
|
|
|
6103
6122
|
return `(() => {${CLICK_POINT_SNIPPET}
|
|
6104
6123
|
const legacy = document.querySelector('[data-testid="composer-plus-btn"]');
|
|
6105
6124
|
if (legacy) return clickPoint(legacy);
|
|
6125
|
+
// An open "Edit message" editor sits in the transcript ahead of the
|
|
6126
|
+
// composer, in a form without this button; see modelButtonRectExpression.
|
|
6106
6127
|
const composer = [...document.querySelectorAll('#prompt-textarea,[contenteditable="true"],textarea')].find((el) => {
|
|
6107
6128
|
const r = el.getBoundingClientRect();
|
|
6108
|
-
return r.width > 0 && r.height > 0;
|
|
6129
|
+
return r.width > 0 && r.height > 0 && !(el.parentElement && el.parentElement.closest('[data-message-author-role],[data-chatgpt-search-unit-key]'));
|
|
6109
6130
|
});
|
|
6110
6131
|
const form = composer && composer.closest('form');
|
|
6111
6132
|
if (!form) return { ok: false, reason: "composer tools button not found: the composer has not rendered" };
|
|
@@ -6154,11 +6175,15 @@ export function composerToolEntryRectExpression(label) {
|
|
|
6154
6175
|
/** Tool tokens currently sitting in the composer. */
|
|
6155
6176
|
export function activeComposerToolsExpression(labels) {
|
|
6156
6177
|
const labelsJson = JSON.stringify(labels);
|
|
6157
|
-
return `(() => {
|
|
6178
|
+
return `(() => {${composerExpressionHelpers()}
|
|
6158
6179
|
// The token normally lands inside the editor, but some builds render it as
|
|
6159
6180
|
// a pill in the surrounding composer form - check both before deciding a
|
|
6160
6181
|
// selection did not take.
|
|
6161
|
-
|
|
6182
|
+
// Read the composer a send types into. Measured 2026-11-03: a new chat
|
|
6183
|
+
// leaves the home page's composer mounted in a hidden workspace ahead of
|
|
6184
|
+
// the conversation's, and the first editor in the document was that one,
|
|
6185
|
+
// so a follow-up's tool was on but read as off.
|
|
6186
|
+
const el = findChatGptComposerCandidate();
|
|
6162
6187
|
const form = el ? (el.closest("form") || el.parentElement) : null;
|
|
6163
6188
|
const text = ((el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "")).toLowerCase();
|
|
6164
6189
|
// Case-insensitively: prodex carries the label as the menu spells it
|
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";
|
|
@@ -14,7 +14,7 @@ import { getTokenExpiryStatus, loadBrowserDefaults, loadLocalConfig } from "./co
|
|
|
14
14
|
import { sendPacingMarkerPath, withBrowserSendLock } from "./browser-send-lock.js";
|
|
15
15
|
import { blockerCause, buildBlockerReport, CATCH_ALL_CODES } from "./blocker-report.js";
|
|
16
16
|
import { isChatGptConversationUrl, projectIdFromSidebar, resolveContinuationThread } from "./continue-thread.js";
|
|
17
|
-
import { FollowupApprovalRequired, reserveFollowup, resolveMaxAutoFollowups } from "./followup-budget.js";
|
|
17
|
+
import { assertFollowupAvailable, FollowupApprovalRequired, reserveFollowup, resolveMaxAutoFollowups } from "./followup-budget.js";
|
|
18
18
|
import { readBridgeRoots } from "./registry.js";
|
|
19
19
|
import { ProdexRequestIdSchema, SessionKeySchema } from "./schema.js";
|
|
20
20
|
import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.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
|
}
|
|
@@ -1170,7 +1174,10 @@ export function createBrowserSendProgressPrinter(write, heartbeatMs = 10_000) {
|
|
|
1170
1174
|
write(`progress: ${PROGRESS_PHASE_LABELS[event.phase]}${event.detail ? ` (${event.detail})` : ""}`);
|
|
1171
1175
|
};
|
|
1172
1176
|
}
|
|
1173
|
-
export async function runAskProCommand(rest, io,
|
|
1177
|
+
export async function runAskProCommand(rest, io,
|
|
1178
|
+
// Runs before any task is created. The step it returns runs once the prompt
|
|
1179
|
+
// is typed, right before it is submitted.
|
|
1180
|
+
beforeSend) {
|
|
1174
1181
|
const parsedAskPro = parseAskProArgs(rest);
|
|
1175
1182
|
const hasDryRunMode = parsedAskPro.optionArgs.includes("--dry-run");
|
|
1176
1183
|
const hasSendMode = parsedAskPro.optionArgs.includes("--send");
|
|
@@ -1472,11 +1479,23 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1472
1479
|
if (hasSendMode) {
|
|
1473
1480
|
// MCP approval checkpoints run after target validation but before any
|
|
1474
1481
|
// task creation, pacing, browser recovery, or prompt send.
|
|
1475
|
-
await beforeSend?.({
|
|
1482
|
+
const beforeSubmitStep = (await beforeSend?.({
|
|
1476
1483
|
store: targetStore,
|
|
1477
1484
|
...(normalizedTargetUrl ? { thread: normalizedTargetUrl } : {}),
|
|
1478
1485
|
...(continuedFromTaskId ? { continuedFrom: continuedFromTaskId } : {})
|
|
1479
|
-
});
|
|
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;
|
|
1480
1499
|
const browserCommandOptions = {
|
|
1481
1500
|
cwd: targetCwd,
|
|
1482
1501
|
port: parsedAskPro.optionArgs.includes("--port") ? browserPort : undefined
|
|
@@ -1555,9 +1574,10 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1555
1574
|
proMode: selectionProMode,
|
|
1556
1575
|
effort: selectionEffort,
|
|
1557
1576
|
...(allowSelectionFallback ? { allowSelectionFallback: true } : {}),
|
|
1577
|
+
...(beforePromptSubmit ? { beforePromptSubmit } : {}),
|
|
1558
1578
|
onProgress: createBrowserSendProgressPrinter(io.stderr)
|
|
1559
1579
|
});
|
|
1560
|
-
});
|
|
1580
|
+
}, "--timeout-ms", { ...(io.signal ? { signal: io.signal } : {}), cancelledError: consultCancelledError });
|
|
1561
1581
|
// One-command recovery: interactive terminals (or explicit --auto-login)
|
|
1562
1582
|
// launch the dedicated browser and retry once when no browser runs.
|
|
1563
1583
|
// Scripts and agents keep the plain blocker unless they opt in.
|
|
@@ -1576,8 +1596,9 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1576
1596
|
catch (error) {
|
|
1577
1597
|
const firstBlocker = browserSendBlockerFromError(error);
|
|
1578
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.
|
|
1579
1600
|
if (!["browser_unreachable", "chatgpt_page_missing"].includes(firstBlocker.code) ||
|
|
1580
|
-
!firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed)
|
|
1601
|
+
!firstBlocker.retryable || firstBlocker.thread || !autoLoginAllowed || io.signal?.aborted)
|
|
1581
1602
|
throw error;
|
|
1582
1603
|
const recover = firstBlocker.code === "chatgpt_page_missing" ? attemptMissingChatGptTabRecovery : attemptBrowserAutoRecovery;
|
|
1583
1604
|
const recovered = await recover(io.stderr, {
|
|
@@ -1592,7 +1613,11 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1592
1613
|
}
|
|
1593
1614
|
}
|
|
1594
1615
|
catch (error) {
|
|
1595
|
-
|
|
1616
|
+
// Another follow-up spent the budget while this one waited for the
|
|
1617
|
+
// browser. Nothing was submitted; the caller asks the user instead.
|
|
1618
|
+
const blocker = error instanceof FollowupApprovalRequired
|
|
1619
|
+
? { code: "followup_approval_required", message: error.message, retryable: false }
|
|
1620
|
+
: await withManualStepForWindowMode(sourceAwareBrowserBlocker(browserSendBlockerFromError(error), sourceCli, browserCommandOptions), resolveCdpPort(browserPort), sourceCli, browserCommandOptions);
|
|
1596
1621
|
const message = blocker.next_step ? `${blocker.message} Next: ${blocker.next_step}` : errorMessage(error);
|
|
1597
1622
|
// The blocker text can quote the requested/default project name (e.g. a
|
|
1598
1623
|
// "project not found" error). Local stdout/stderr keep it (useful to the
|
|
@@ -1659,6 +1684,8 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1659
1684
|
blocker
|
|
1660
1685
|
}, null, 2));
|
|
1661
1686
|
}
|
|
1687
|
+
if (error instanceof FollowupApprovalRequired)
|
|
1688
|
+
throw error;
|
|
1662
1689
|
throw new Error(formatBlockedConsultRecordedMessage(message, task.id, sourceCli, { cwd: targetCwd }));
|
|
1663
1690
|
}
|
|
1664
1691
|
const answerArtifactText = formatProConsultArtifact(consult);
|
|
@@ -1930,6 +1957,18 @@ export async function performBrowserRecoverForMcp(cwd, input) {
|
|
|
1930
1957
|
notes: stderrLines
|
|
1931
1958
|
};
|
|
1932
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
|
+
}
|
|
1933
1972
|
/** The marker the send prints before throwing, when the answer outlived its record. */
|
|
1934
1973
|
const ANSWER_NOT_SAVED_MARKER = "consult_answer_received_but_not_saved:";
|
|
1935
1974
|
/**
|
|
@@ -1983,7 +2022,7 @@ export function mcpBlockedConsultOutcome(stdoutLines, error) {
|
|
|
1983
2022
|
...(thread && requestId ? { recover: { tool: "pro_recover", arguments: { thread, request_id: requestId } } } : {})
|
|
1984
2023
|
};
|
|
1985
2024
|
}
|
|
1986
|
-
export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
2025
|
+
export async function performBrowserConsultForMcp(cwd, input, onProgress, signal) {
|
|
1987
2026
|
const stdoutLines = [];
|
|
1988
2027
|
const stderrLines = [];
|
|
1989
2028
|
const sessionKey = resolveProdexSessionKey(input.session_key);
|
|
@@ -2031,17 +2070,23 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
|
2031
2070
|
if (onProgress && line.startsWith("progress:"))
|
|
2032
2071
|
onProgress(line);
|
|
2033
2072
|
},
|
|
2034
|
-
allowAskProBrowserSend: true
|
|
2073
|
+
allowAskProBrowserSend: true,
|
|
2074
|
+
...(signal ? { signal } : {})
|
|
2035
2075
|
}, async ({ store, thread, continuedFrom }) => {
|
|
2036
2076
|
if (!thread || !continuedFrom)
|
|
2037
2077
|
return;
|
|
2038
2078
|
continuationTarget = { taskId: continuedFrom, thread };
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2079
|
+
const reservation = { thread, taskId: continuedFrom, userApproved: input.user_approved === true, limit };
|
|
2080
|
+
await assertFollowupAvailable(store, reservation);
|
|
2081
|
+
// Spent only when the prompt is about to be submitted, and once: a retry
|
|
2082
|
+
// after browser recovery is the same follow-up.
|
|
2083
|
+
let reserved = false;
|
|
2084
|
+
return async () => {
|
|
2085
|
+
if (reserved)
|
|
2086
|
+
return;
|
|
2087
|
+
followupBudget = await reserveFollowup(store, reservation);
|
|
2088
|
+
reserved = true;
|
|
2089
|
+
};
|
|
2045
2090
|
});
|
|
2046
2091
|
}
|
|
2047
2092
|
catch (error) {
|
|
@@ -3400,7 +3445,7 @@ export function printBrowserLoginGuide(stdout, input) {
|
|
|
3400
3445
|
}
|
|
3401
3446
|
}
|
|
3402
3447
|
export function printProBrowserHelpIfRequested(args, command, io, options) {
|
|
3403
|
-
const helpIndex = findHelpFlagIndexBeforePromptDelimiter(args);
|
|
3448
|
+
const helpIndex = options.promptWords ? findHelpRequestIndex(args, options) : findHelpFlagIndexBeforePromptDelimiter(args);
|
|
3404
3449
|
if (helpIndex === -1)
|
|
3405
3450
|
return false;
|
|
3406
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/followup-budget.js
CHANGED
|
@@ -33,7 +33,24 @@ export function resolveMaxAutoFollowups(envValue = process.env.PRODEX_MAX_AUTO_F
|
|
|
33
33
|
}
|
|
34
34
|
return value;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Refuse a follow-up the budget no longer covers, without spending any of it.
|
|
38
|
+
*
|
|
39
|
+
* The follow-up is reserved only once its prompt is about to be submitted.
|
|
40
|
+
* Measured 2026-11-02 in the browser container: two follow-ups in one
|
|
41
|
+
* conversation, refused as composer_not_ready before anything was typed, were
|
|
42
|
+
* recorded as its follow-ups 3 and 4 of 5. This check keeps an exhausted budget
|
|
43
|
+
* from creating a task or touching the browser at all.
|
|
44
|
+
*/
|
|
45
|
+
export async function assertFollowupAvailable(store, input) {
|
|
46
|
+
await withFollowupBudgetLock(store, input, async (conversationKey) => {
|
|
47
|
+
await readConversationLedger(store, { conversationKey, ...input });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
36
50
|
export async function reserveFollowup(store, input) {
|
|
51
|
+
return withFollowupBudgetLock(store, input, async (conversationKey) => reserveFollowupUnderLock(store, { ...input, conversationKey }));
|
|
52
|
+
}
|
|
53
|
+
async function withFollowupBudgetLock(store, input, run) {
|
|
37
54
|
assertFollowupLimit(input.limit);
|
|
38
55
|
const conversationKey = conversationKeyFromThread(input.thread);
|
|
39
56
|
await store.ensure();
|
|
@@ -44,9 +61,10 @@ export async function reserveFollowup(store, input) {
|
|
|
44
61
|
privateParent: true,
|
|
45
62
|
busyError: (holder) => new Error(`Another follow-up reservation is in progress (pid ${holder.pid ?? "unknown"})`),
|
|
46
63
|
unavailableError: () => new Error("The follow-up budget lock could not be recovered. Stop all prodex processes before removing the lock and its matching .reap claim, then retry.")
|
|
47
|
-
}, async () =>
|
|
64
|
+
}, async () => run(conversationKey));
|
|
48
65
|
}
|
|
49
|
-
|
|
66
|
+
/** The conversation's ledger so far; throws when the budget does not cover another follow-up. */
|
|
67
|
+
async function readConversationLedger(store, input) {
|
|
50
68
|
const reservations = await readTrustedReservations(store);
|
|
51
69
|
const conversationReservations = reservations
|
|
52
70
|
.filter((reservation) => reservation.metadata.conversation_key === input.conversationKey)
|
|
@@ -59,6 +77,10 @@ async function reserveFollowupUnderLock(store, input) {
|
|
|
59
77
|
remaining: 0
|
|
60
78
|
});
|
|
61
79
|
}
|
|
80
|
+
return previous;
|
|
81
|
+
}
|
|
82
|
+
async function reserveFollowupUnderLock(store, input) {
|
|
83
|
+
const previous = await readConversationLedger(store, input);
|
|
62
84
|
const budget = input.userApproved === true
|
|
63
85
|
? { limit: input.limit, used: 0, remaining: input.limit }
|
|
64
86
|
: {
|