@youdie006/prodex 0.35.1 → 0.36.0
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/chatgpt-browser.js +118 -10
- package/dist/cli-pro.js +11 -3
- package/dist/cli.js +4 -1
- package/dist/tui-run.js +17 -0
- package/package.json +1 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -596,8 +596,46 @@ export function modelSelectionWarning(requestedModel, modelSlug) {
|
|
|
596
596
|
return undefined;
|
|
597
597
|
return `model_mismatch: you asked for ${requestedModel}, but the answer came from "${modelSlug}". Check the model picker in the browser; the selection did not take.`;
|
|
598
598
|
}
|
|
599
|
-
|
|
600
|
-
|
|
599
|
+
/**
|
|
600
|
+
* The panel ChatGPT raises to ask which of two answers you prefer.
|
|
601
|
+
*
|
|
602
|
+
* Recognized by its testids rather than its wording, so a UI in any language
|
|
603
|
+
* still matches. Read off the live page: the title is `paragen-feedback-title`
|
|
604
|
+
* and both choices are `paragen-prefer-response-button`.
|
|
605
|
+
*/
|
|
606
|
+
export const CHATGPT_RESPONSE_CHOICE_SELECTOR = '[data-testid="paragen-feedback-title"],[data-testid="paragen-prefer-response-button"]';
|
|
607
|
+
/**
|
|
608
|
+
* A thread parked on that question, which no amount of waiting will clear.
|
|
609
|
+
*
|
|
610
|
+
* Measured live: both candidate answers had finished, the page had not changed
|
|
611
|
+
* in twenty seconds, and the composer's submit control was still
|
|
612
|
+
* `data-testid="stop-button"` - which prodex read as "generating". Every send
|
|
613
|
+
* afterwards waited out its whole budget and then advised waiting some more.
|
|
614
|
+
*/
|
|
615
|
+
export function chatGptResponseChoiceBlocker(awaitingResponseChoice) {
|
|
616
|
+
if (!awaitingResponseChoice)
|
|
617
|
+
return undefined;
|
|
618
|
+
return {
|
|
619
|
+
code: "response_choice_pending",
|
|
620
|
+
message: "ChatGPT is asking which of two answers you prefer, and this thread stays locked until one is picked.",
|
|
621
|
+
retryable: true,
|
|
622
|
+
next_step: "Pick one in the browser (the two \"I prefer this response\" buttons), " +
|
|
623
|
+
"or leave the thread alone and send into a new chat (`--new-chat`)."
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Whether that question stands in the way of THIS send. A send that navigates
|
|
628
|
+
* away - a fresh chat, or a project home - never touches the parked thread.
|
|
629
|
+
*/
|
|
630
|
+
export function responseChoiceBlocksThisSend(input) {
|
|
631
|
+
if (!input.awaitingResponseChoice)
|
|
632
|
+
return false;
|
|
633
|
+
return !input.newChat && input.project === undefined && input.projectNew === undefined;
|
|
634
|
+
}
|
|
635
|
+
export function chatGptBusyBlocker(state) {
|
|
636
|
+
// Waiting for a choice is not generating. Conflating them turned a click
|
|
637
|
+
// away from ready into a full send budget spent on "still generating".
|
|
638
|
+
if (!state.generating || state.awaitingResponseChoice === true)
|
|
601
639
|
return undefined;
|
|
602
640
|
return {
|
|
603
641
|
code: "response_in_progress",
|
|
@@ -608,6 +646,39 @@ export function chatGptBusyBlocker(generating) {
|
|
|
608
646
|
"A new topic can go to a new chat instead."
|
|
609
647
|
};
|
|
610
648
|
}
|
|
649
|
+
/**
|
|
650
|
+
* The busy verdict, re-decided against the transcript.
|
|
651
|
+
*
|
|
652
|
+
* Measured on the real page: an ordinary conversation whose answer was complete
|
|
653
|
+
* kept `data-testid="stop-button"` (aria-label "Stop answering") unchanged over
|
|
654
|
+
* thirty seconds, while the transcript for that same conversation reported
|
|
655
|
+
* finished_successfully with end_turn set. The page control is decoration once
|
|
656
|
+
* the turn is over; the transcript is the record. Only a transcript that can be
|
|
657
|
+
* read AND says the turn finished clears the blocker - an unreadable one is no
|
|
658
|
+
* evidence of anything, and typing into a live generation corrupts the thread.
|
|
659
|
+
*/
|
|
660
|
+
export function busyBlockerAfterTranscriptCheck(busyBlocker, transcript) {
|
|
661
|
+
if (!busyBlocker)
|
|
662
|
+
return undefined;
|
|
663
|
+
return transcript?.ok === true && transcript.isComplete === true ? undefined : busyBlocker;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Ask the transcript whether the conversation the tab is showing has finished.
|
|
667
|
+
* Undefined when there is nothing to ask about (a fresh chat or project home
|
|
668
|
+
* carries no conversation id) or the read fails.
|
|
669
|
+
*/
|
|
670
|
+
async function readTranscriptCompletion(page, url) {
|
|
671
|
+
const conversationId = conversationIdFromThreadUrl(url);
|
|
672
|
+
if (!conversationId)
|
|
673
|
+
return undefined;
|
|
674
|
+
try {
|
|
675
|
+
const state = await evaluateOnPage(page, transcriptAnswerExpression(conversationId));
|
|
676
|
+
return { ok: state.ok === true, ...(state.isComplete !== undefined ? { isComplete: state.isComplete } : {}) };
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
611
682
|
export function isLikelyChatGptSubmitButton(label, dataTestId) {
|
|
612
683
|
const normalized = label.trim().toLowerCase();
|
|
613
684
|
return dataTestId === "send-button" || /\b(send|submit)\b|보내기|전송/.test(normalized);
|
|
@@ -878,8 +949,12 @@ function chatGptPageMissingBlocker() {
|
|
|
878
949
|
// readiness assert - otherwise it is misreported as "missing a visible prompt
|
|
879
950
|
// composer" (measured live: continue-by-default consults landing on a thread
|
|
880
951
|
// still generating the previous prodex answer).
|
|
881
|
-
export function assertChatGptIdleAndReadyForPrompt(status
|
|
882
|
-
|
|
952
|
+
export function assertChatGptIdleAndReadyForPrompt(status,
|
|
953
|
+
// The caller may have already weighed the page against the transcript and
|
|
954
|
+
// found the stop control stale. Re-deriving the verdict from the DOM here
|
|
955
|
+
// threw away that answer and failed the send the queue had just cleared.
|
|
956
|
+
decidedBusyBlocker, busyVerdictDecided = false) {
|
|
957
|
+
const busyBlocker = busyVerdictDecided ? decidedBusyBlocker : chatGptBusyBlocker(status);
|
|
883
958
|
if (busyBlocker)
|
|
884
959
|
throw new ChatGptBrowserBlockerError(busyBlocker);
|
|
885
960
|
assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
|
|
@@ -1054,7 +1129,13 @@ export async function getChatGptBrowserStatus(options = {}) {
|
|
|
1054
1129
|
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
|
|
1055
1130
|
});
|
|
1056
1131
|
const loggedInLikely = inferChatGptPageLoggedInLikely(state);
|
|
1057
|
-
|
|
1132
|
+
// The busy verdict is checked against the transcript here too, so `check`
|
|
1133
|
+
// does not report a finished conversation as one still being written.
|
|
1134
|
+
const busyBlocker = chatGptBusyBlocker(state);
|
|
1135
|
+
const blocker = chatGptVisibilityBlocker(state.visibilityState, state.url) ??
|
|
1136
|
+
detectChatGptPageBlocker(state) ??
|
|
1137
|
+
chatGptResponseChoiceBlocker(state.awaitingResponseChoice === true) ??
|
|
1138
|
+
(busyBlocker ? busyBlockerAfterTranscriptCheck(busyBlocker, await readTranscriptCompletion(page.page, state.url)) : undefined);
|
|
1058
1139
|
return {
|
|
1059
1140
|
reachable: true,
|
|
1060
1141
|
loggedInLikely,
|
|
@@ -2009,7 +2090,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
2009
2090
|
// timeout: consults continue threads by default, so landing on a thread
|
|
2010
2091
|
// whose previous (often timed-out Pro) answer is still streaming is a when,
|
|
2011
2092
|
// not an if - queueing behind it beats failing.
|
|
2012
|
-
let busyBlocker = chatGptBusyBlocker(status.
|
|
2093
|
+
let busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
|
|
2013
2094
|
const busyWaitBudgetMs = options.busyWaitMs ?? timeoutMs;
|
|
2014
2095
|
if (busyBlocker && busyWaitBudgetMs > 0) {
|
|
2015
2096
|
// Queue behind the in-flight response instead of failing: shared-tab
|
|
@@ -2023,17 +2104,32 @@ export async function sendChatGptPrompt(options) {
|
|
|
2023
2104
|
const midBlocker = detectChatGptPageBlocker(status);
|
|
2024
2105
|
if (midBlocker)
|
|
2025
2106
|
throw new ChatGptBrowserBlockerError(midBlocker);
|
|
2026
|
-
busyBlocker = chatGptBusyBlocker(status.
|
|
2107
|
+
busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
|
|
2027
2108
|
if (busyBlocker)
|
|
2028
2109
|
emitProgress("waiting", "tab busy with another response; waiting");
|
|
2029
2110
|
}
|
|
2030
2111
|
if (!busyBlocker) {
|
|
2031
2112
|
// The composer takes a moment to unlock after generation ends; settle
|
|
2032
|
-
// again so the readiness assert below sees the reopened composer
|
|
2113
|
+
// again so the readiness assert below sees the reopened composer, and
|
|
2114
|
+
// weigh that fresh reading the same way - a generation that started in
|
|
2115
|
+
// the meantime must still hold the send back.
|
|
2033
2116
|
status = await readSettledChatGptPageStatus(page);
|
|
2117
|
+
busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
|
|
2034
2118
|
}
|
|
2035
2119
|
}
|
|
2036
|
-
|
|
2120
|
+
// A thread ChatGPT has parked on "which response do you prefer?" will not
|
|
2121
|
+
// unpark on its own. Sends that navigate away - a fresh chat, a project home
|
|
2122
|
+
// - never touch it; a send that means to continue THAT thread has to say so
|
|
2123
|
+
// now, rather than type into a composer that will not post.
|
|
2124
|
+
if (responseChoiceBlocksThisSend({
|
|
2125
|
+
awaitingResponseChoice: status.awaitingResponseChoice === true,
|
|
2126
|
+
...(options.newChat !== undefined ? { newChat: options.newChat } : {}),
|
|
2127
|
+
...(options.project !== undefined ? { project: options.project } : {}),
|
|
2128
|
+
...(options.projectNew !== undefined ? { projectNew: options.projectNew } : {})
|
|
2129
|
+
})) {
|
|
2130
|
+
throw new ChatGptBrowserBlockerError(chatGptResponseChoiceBlocker(true));
|
|
2131
|
+
}
|
|
2132
|
+
assertChatGptIdleAndReadyForPrompt(status, busyBlocker, true);
|
|
2037
2133
|
if (normalizedTargetUrl)
|
|
2038
2134
|
assertChatGptTargetUrlMatches(status.url, normalizedTargetUrl);
|
|
2039
2135
|
assertVisibleChatGptTab(status.visibilityState, status.url, normalizedTargetUrl);
|
|
@@ -2580,7 +2676,15 @@ export function findLaunchedBrowserProcesses(psOutput, input) {
|
|
|
2580
2676
|
// made a check against an unused port report a healthy Chrome as wedged.
|
|
2581
2677
|
if (mains.length === 0)
|
|
2582
2678
|
return [];
|
|
2583
|
-
|
|
2679
|
+
// Which profile the helpers belong to is the browser's answer, not the
|
|
2680
|
+
// caller's: `check` has only a port, and matching against the profile it
|
|
2681
|
+
// assumed both missed this browser's renderers and collected a stranger's.
|
|
2682
|
+
// Read it off the process that answered to the port; fall back to what the
|
|
2683
|
+
// caller passed only when the command line does not say.
|
|
2684
|
+
// Stop at the next flag, so a profile path containing spaces survives.
|
|
2685
|
+
const profileOf = (line) => /--user-data-dir=(.*?)(?=\s+-{1,2}\w|\s*$)/.exec(line)?.[1];
|
|
2686
|
+
const profileDir = profileOf(mains[0]) ?? input.profileDir;
|
|
2687
|
+
const helpers = profileDir.length > 0 ? lines.filter((line) => profileOf(line) === profileDir && !mains.includes(line)) : [];
|
|
2584
2688
|
return [...mains, ...helpers].map(pidOf).filter((pid) => pid !== undefined);
|
|
2585
2689
|
}
|
|
2586
2690
|
/**
|
|
@@ -3074,6 +3178,7 @@ export function statusExpression() {
|
|
|
3074
3178
|
const excludedTextSelector = JSON.stringify(CHATGPT_RUNTIME_BLOCKER_TEXT_EXCLUDED_ANCESTORS);
|
|
3075
3179
|
const blockerScanExcludedSelector = JSON.stringify(CHATGPT_BLOCKER_SCAN_EXCLUDED_ANCESTORS);
|
|
3076
3180
|
const streamingSelector = JSON.stringify(CHATGPT_STREAMING_SELECTOR);
|
|
3181
|
+
const responseChoiceSelector = JSON.stringify(CHATGPT_RESPONSE_CHOICE_SELECTOR);
|
|
3077
3182
|
const generatingControlPattern = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.source);
|
|
3078
3183
|
const generatingControlFlags = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.flags);
|
|
3079
3184
|
return `(() => {
|
|
@@ -3127,6 +3232,7 @@ export function statusExpression() {
|
|
|
3127
3232
|
visibleButtonLabels,
|
|
3128
3233
|
hasComposer,
|
|
3129
3234
|
generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || visibleButtonLabels.some((label) => generatingControlPattern.test(label)),
|
|
3235
|
+
awaitingResponseChoice: Boolean(document.querySelector(${responseChoiceSelector})),
|
|
3130
3236
|
modelHints: lines.filter((line) => /GPT|Pro|Thinking|ChatGPT|Extra High|Auto/i.test(line)).slice(0, 30),
|
|
3131
3237
|
openDialogText: (([...document.querySelectorAll('[role="dialog"]')].find((d) => d.offsetWidth || d.offsetHeight || d.getClientRects().length)?.innerText) || "").trim().slice(0, 200)
|
|
3132
3238
|
};
|
|
@@ -3961,6 +4067,7 @@ export function answerExpression() {
|
|
|
3961
4067
|
const excludedTextSelector = JSON.stringify(CHATGPT_RUNTIME_BLOCKER_TEXT_EXCLUDED_ANCESTORS);
|
|
3962
4068
|
const blockerScanExcludedSelector = JSON.stringify(CHATGPT_BLOCKER_SCAN_EXCLUDED_ANCESTORS);
|
|
3963
4069
|
const streamingSelector = JSON.stringify(CHATGPT_STREAMING_SELECTOR);
|
|
4070
|
+
const responseChoiceSelector = JSON.stringify(CHATGPT_RESPONSE_CHOICE_SELECTOR);
|
|
3964
4071
|
const generatingControlPattern = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.source);
|
|
3965
4072
|
const generatingControlFlags = JSON.stringify(CHATGPT_GENERATING_CONTROL_PATTERN.flags);
|
|
3966
4073
|
return `(() => {
|
|
@@ -4025,6 +4132,7 @@ export function answerExpression() {
|
|
|
4025
4132
|
blockerScanTextSample: visibleTextOutsideMessages(blockerScanExcludedSelector).slice(0, 12000),
|
|
4026
4133
|
visibleButtonLabels: buttons,
|
|
4027
4134
|
generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || buttons.some((label) => generatingControlPattern.test(label)),
|
|
4135
|
+
awaitingResponseChoice: Boolean(document.querySelector(${responseChoiceSelector})),
|
|
4028
4136
|
assistantMessageCount: assistantMessages.length,
|
|
4029
4137
|
userMessageCount: userMessages.length,
|
|
4030
4138
|
// ChatGPT tags each assistant message with the model that produced it -
|
package/dist/cli-pro.js
CHANGED
|
@@ -1473,7 +1473,16 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
|
1473
1473
|
// a second Chrome on the same profile joins the wedged one rather than
|
|
1474
1474
|
// replacing it, and the wedged one keeps burning CPU while nobody looks. This
|
|
1475
1475
|
// runs unattended for agents, so it is the path that let four days pass.
|
|
1476
|
-
|
|
1476
|
+
// The same record the relaunch below reads, read once and used for both: the
|
|
1477
|
+
// scan matches the main process by port but its helpers by profile, so
|
|
1478
|
+
// scanning the DEFAULT profile for a custom-profile user put a second,
|
|
1479
|
+
// healthy browser's renderers on the list this function kills.
|
|
1480
|
+
const lastLogin = await readLastBrowserLoginLaunch().catch(() => undefined);
|
|
1481
|
+
const scanFor = {
|
|
1482
|
+
...(options.port !== undefined ? { port: options.port } : {}),
|
|
1483
|
+
...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {})
|
|
1484
|
+
};
|
|
1485
|
+
const wedged = findWedgedBrowser(scanFor);
|
|
1477
1486
|
if (wedged.length > 0) {
|
|
1478
1487
|
// Confirm the silence before ending anything: one missed poll is a busy
|
|
1479
1488
|
// browser, several in a row is a dead one.
|
|
@@ -1496,7 +1505,7 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
|
1496
1505
|
// delay: the replacement launch fails outright if the old process still
|
|
1497
1506
|
// holds it, which is how the first self-heal attempt ended.
|
|
1498
1507
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
1499
|
-
if (findWedgedBrowser(
|
|
1508
|
+
if (findWedgedBrowser(scanFor).length === 0)
|
|
1500
1509
|
break;
|
|
1501
1510
|
await sleep(1_000);
|
|
1502
1511
|
}
|
|
@@ -1506,7 +1515,6 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
|
1506
1515
|
// Reuse the profile the user last logged in with; launching the default
|
|
1507
1516
|
// profile for a custom-profile user would wait on the wrong (logged-out)
|
|
1508
1517
|
// profile or, worse, silently send to a different account.
|
|
1509
|
-
const lastLogin = await readLastBrowserLoginLaunch();
|
|
1510
1518
|
// Relaunch in the SAME window mode the user chose: silently reopening a
|
|
1511
1519
|
// visible window for someone running headless would be exactly the
|
|
1512
1520
|
// surprise window they turned headless to avoid.
|
package/dist/cli.js
CHANGED
|
@@ -116,7 +116,10 @@ export async function runCli(args, io = defaultIo()) {
|
|
|
116
116
|
// A person typing `prodex` used to get the agent-facing command wall and no
|
|
117
117
|
// way in. On a terminal, walk them through a consult instead; piped or
|
|
118
118
|
// scripted callers still get the banner and the command list they parse.
|
|
119
|
-
|
|
119
|
+
// A keyboard as well as a screen: with stdin redirected there is nothing to
|
|
120
|
+
// answer the picker's questions with, and `prodex` alone should still print
|
|
121
|
+
// the command list rather than refuse.
|
|
122
|
+
if (!command && io.isInteractive === true && process.stdin.isTTY === true)
|
|
120
123
|
return runInteractiveUi(io);
|
|
121
124
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
122
125
|
if (shouldColorize())
|
package/dist/tui-run.js
CHANGED
|
@@ -136,6 +136,17 @@ async function askLine(io, question) {
|
|
|
136
136
|
* Returns the process exit code.
|
|
137
137
|
*/
|
|
138
138
|
export async function runInteractiveConsult(io, deps) {
|
|
139
|
+
// Every question here is answered with a keystroke, so a stdin that cannot
|
|
140
|
+
// deliver one has to be turned away at the door. Measured with stdin
|
|
141
|
+
// redirected: the picker painted the alternate screen, hid the cursor, and
|
|
142
|
+
// waited forever for a key that could not arrive - and never returning meant
|
|
143
|
+
// the screen was never restored either, leaving a terminal that needed
|
|
144
|
+
// `reset`.
|
|
145
|
+
if (io.input.isTTY !== true) {
|
|
146
|
+
io.write("The picker reads single keystrokes, so it needs a terminal on stdin.\n");
|
|
147
|
+
io.write('Send without one: prodex pro browser ask "your prompt"\n');
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
139
150
|
const now = deps.now ?? Date.now;
|
|
140
151
|
readline.emitKeypressEvents(io.input);
|
|
141
152
|
io.input.setRawMode?.(true);
|
|
@@ -278,6 +289,12 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
278
289
|
// Leave the alternate screen before the send: the answer, the receipt id
|
|
279
290
|
// and any blocker belong in the scrollback the user keeps.
|
|
280
291
|
io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
|
|
292
|
+
// Hand the terminal back too. Raw mode is what let the pickers read single
|
|
293
|
+
// keys, and it also turns off the terminal's own ctrl-c: the key arrives as
|
|
294
|
+
// a keypress nothing is waiting for. The progress bar promises "ctrl-c to
|
|
295
|
+
// stop", and under raw mode that promise was false for the whole ten
|
|
296
|
+
// minutes a deep research send runs. No key is read from here on.
|
|
297
|
+
io.input.setRawMode?.(false);
|
|
281
298
|
// --target-url confirms which conversation a send means; it deliberately
|
|
282
299
|
// does not navigate. Picking one from a list IS a request to go there, so
|
|
283
300
|
// move the tab first and let the flag confirm it landed.
|