@youdie006/prodex 0.40.2 → 0.40.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blocker-report.js +7 -1
- package/dist/browser-send-lock.js +21 -1
- package/dist/chatgpt-browser.js +227 -34
- package/dist/cli-args.js +5 -0
- package/dist/cli-help.js +9 -4
- package/dist/cli-pro.js +150 -33
- package/dist/config.js +40 -6
- package/dist/continue-thread.js +84 -0
- package/dist/mcp.js +12 -2
- package/package.json +1 -1
package/dist/blocker-report.js
CHANGED
|
@@ -53,8 +53,14 @@ export function buildBlockerReport(input) {
|
|
|
53
53
|
const existing = groups.get(key);
|
|
54
54
|
if (existing) {
|
|
55
55
|
existing.count += 1;
|
|
56
|
-
|
|
56
|
+
// The example belongs to the record the row dates itself by. Keeping the
|
|
57
|
+
// first one seen printed a cause's oldest wording next to its newest
|
|
58
|
+
// timestamp: a group holding both `has no "Pro" step` and a pre-fix
|
|
59
|
+
// `has no "<a model>" step` showed the fixed one as what is failing now.
|
|
60
|
+
if (at > existing.lastSeen) {
|
|
57
61
|
existing.lastSeen = at;
|
|
62
|
+
existing.example = consult.blocker.message;
|
|
63
|
+
}
|
|
58
64
|
existing.repos.set(consult.repo, (existing.repos.get(consult.repo) ?? 0) + 1);
|
|
59
65
|
}
|
|
60
66
|
else {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { link, mkdir, open, readFile, rm } from "node:fs/promises";
|
|
1
|
+
import { link, mkdir, open, readFile, rm, stat } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
// One visible-browser send at a time per machine: the dedicated Chrome is a
|
|
@@ -91,6 +91,15 @@ async function tryAcquire(file) {
|
|
|
91
91
|
await rm(temp, { force: true }).catch(() => undefined);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
async function lockFileExists(file) {
|
|
95
|
+
try {
|
|
96
|
+
await stat(file);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
94
103
|
/**
|
|
95
104
|
* Serialize visible-browser sends across processes. Waits up to waitMs for a
|
|
96
105
|
* live holder to finish (0 = fail fast); a lock whose holder process is dead
|
|
@@ -112,6 +121,17 @@ export async function withBrowserSendLock(waitMs, onWait, fn) {
|
|
|
112
121
|
if (current?.pid === holder?.pid) {
|
|
113
122
|
await rm(file, { force: true }).catch(() => undefined);
|
|
114
123
|
}
|
|
124
|
+
// A reap that did not actually remove the file - another user's lock in a
|
|
125
|
+
// shared directory, or a directory sitting in its place - used to retry
|
|
126
|
+
// immediately, skipping both the sleep and the deadline: a hot loop that
|
|
127
|
+
// never returned and never timed out. Waiting here costs a reap that
|
|
128
|
+
// raced nothing; not waiting costs the process.
|
|
129
|
+
if (await lockFileExists(file)) {
|
|
130
|
+
if (Date.now() >= deadline) {
|
|
131
|
+
throw new Error(`A prodex browser send lock at ${file} is held by nothing and could not be removed, so no send can start. Delete that file and retry.`);
|
|
132
|
+
}
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
134
|
+
}
|
|
115
135
|
continue;
|
|
116
136
|
}
|
|
117
137
|
if (Date.now() >= deadline) {
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -437,17 +437,29 @@ export function resolveHeadlessPreference(explicit, env = process.env) {
|
|
|
437
437
|
const raw = (env.PRODEX_HEADLESS ?? "").trim().toLowerCase();
|
|
438
438
|
return raw === "1" || raw === "true" || raw === "yes";
|
|
439
439
|
}
|
|
440
|
-
|
|
440
|
+
/**
|
|
441
|
+
* The two verdicts read different text on purpose.
|
|
442
|
+
*
|
|
443
|
+
* `text` answers "is this a login screen": it has to be the sample with chat
|
|
444
|
+
* MESSAGES excluded, or an old conversation quoting a signup page reports the
|
|
445
|
+
* session as logged out.
|
|
446
|
+
*
|
|
447
|
+
* `loggedInSignalText` answers "is the app here": the sidebar furniture, which
|
|
448
|
+
* survives in whichever sample happened to keep it. Measured live, that is not
|
|
449
|
+
* always the filtered one - on a project home the filter left 111 characters
|
|
450
|
+
* of a banner while document.body.innerText held the whole sidebar.
|
|
451
|
+
*/
|
|
452
|
+
export function inferLoggedInLikely(text, visibleButtonLabels = [], loggedInSignalText = text) {
|
|
441
453
|
// Only sign-up prompts and explicit login/sign-up buttons count as logged-out signals. Bare
|
|
442
454
|
// "Log in"/"로그인" substrings appear in the menus and footers of a logged-in page, so matching
|
|
443
455
|
// them against the full page text falsely reported logged-in Pro users as logged out.
|
|
444
456
|
const hasLoginPrompt = text.includes("Sign up for free") ||
|
|
445
457
|
text.includes("무료로 가입") ||
|
|
446
458
|
visibleButtonLabels.some((label) => /^(log in|sign up|로그인|회원가입)$/i.test(label.trim()));
|
|
447
|
-
const hasNewChat =
|
|
448
|
-
const hasProjectNav =
|
|
459
|
+
const hasNewChat = loggedInSignalText.includes("New chat") || loggedInSignalText.includes("새 채팅");
|
|
460
|
+
const hasProjectNav = loggedInSignalText.includes("Projects") || loggedInSignalText.includes("프로젝트");
|
|
449
461
|
const hasProfileButton = visibleButtonLabels.some((label) => /profile|account|프로필|계정/i.test(label));
|
|
450
|
-
const hasPlanHint = /\bPro\b|Plus|Team|Enterprise|매우 높음|Extra High/i.test(
|
|
462
|
+
const hasPlanHint = /\bPro\b|Plus|Team|Enterprise|매우 높음|Extra High/i.test(loggedInSignalText);
|
|
451
463
|
return !hasLoginPrompt && hasNewChat && (hasProfileButton || hasProjectNav || hasPlanHint);
|
|
452
464
|
}
|
|
453
465
|
export function isUsableChatGptAnswer(answer) {
|
|
@@ -842,9 +854,23 @@ export function detectChatGptPageBlocker(state) {
|
|
|
842
854
|
return detectChatGptBlocker(state.blockerScanTextSample ?? state.blockerTextSample ?? state.textSample, state.visibleButtonLabels);
|
|
843
855
|
}
|
|
844
856
|
export function inferChatGptPageLoggedInLikely(state) {
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
|
|
857
|
+
// The logged-in signals live in the sidebar - "New chat", "Projects", the
|
|
858
|
+
// plan hint - so this needs the sample that HAS the sidebar in it. That was
|
|
859
|
+
// meant to be blockerTextSample, and measured live on a project home it is
|
|
860
|
+
// not: its text walk keeps only nodes whose own parent has a box, and what
|
|
861
|
+
// survived there was 111 characters of a promotional banner while
|
|
862
|
+
// document.body.innerText carried the whole sidebar. So a logged-in Pro
|
|
863
|
+
// account on a working page was told to go and log in.
|
|
864
|
+
//
|
|
865
|
+
// The logged-OUT question keeps the message-excluded sample, so a chat
|
|
866
|
+
// quoting a signup page cannot report the session as dead. The logged-IN
|
|
867
|
+
// question reads both, because the sidebar turns up in whichever one kept
|
|
868
|
+
// it - and being wrong in that direction is caught at once by the composer
|
|
869
|
+
// check beside it, while being wrong the other way tells someone with a
|
|
870
|
+
// working browser to go and log in.
|
|
871
|
+
const messageExcluded = state.blockerTextSample ?? state.textSample;
|
|
872
|
+
const anySample = [state.textSample, state.blockerTextSample].filter(Boolean).join(String.fromCharCode(10));
|
|
873
|
+
return inferLoggedInLikely(messageExcluded, state.visibleButtonLabels, anySample);
|
|
848
874
|
}
|
|
849
875
|
function hasLikelyChatGptLoginPrompt(haystack) {
|
|
850
876
|
const hasSpecificSignup = /sign up for free|무료로 가입/i.test(haystack);
|
|
@@ -1893,11 +1919,18 @@ export function projectItemRectExpression(name) {
|
|
|
1893
1919
|
}
|
|
1894
1920
|
let target = opt ? (opt.closest('a,[role="link"],li') || opt.parentElement) : null;
|
|
1895
1921
|
if (!target) {
|
|
1922
|
+
// The fallback for a sidebar whose option buttons this cannot read. It
|
|
1923
|
+
// used to take the first row CONTAINING the name, which is the substring
|
|
1924
|
+
// match the exact comparison above exists to prevent: asking for "Codex"
|
|
1925
|
+
// took "Codex Review" and sent the prompt into a project nobody named.
|
|
1926
|
+
// A row's first line is its name; anything else here is a guess, and a
|
|
1927
|
+
// guess about which project to post into is the failure being fixed.
|
|
1896
1928
|
const icons = [...document.querySelectorAll('[data-testid="project-folder-icon"]')];
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
}
|
|
1929
|
+
const rowName = (row) => ((row.innerText || row.textContent || "").split("\\n").map((line) => line.trim()).find((line) => line.length > 0) || "");
|
|
1930
|
+
const rows = icons.map((ic) => ic.closest('a,li,[role="link"]') || ic.parentElement?.parentElement).filter(Boolean);
|
|
1931
|
+
const named = rows.filter((row) => rowName(row).toLowerCase() === wanted.toLowerCase());
|
|
1932
|
+
if (named.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects; rename one to disambiguate" };
|
|
1933
|
+
if (named.length === 1) target = named[0];
|
|
1901
1934
|
}
|
|
1902
1935
|
if (!target) {
|
|
1903
1936
|
return { ok: false, reason: "project not found in sidebar (" + optionButtons.length + " projects visible; names are matched exactly first, then case-insensitively - check the exact sidebar spelling)" };
|
|
@@ -2093,6 +2126,21 @@ async function selectPickerModel(cdp, requested, warnings = []) {
|
|
|
2093
2126
|
* without this the step that follows reported "the picker did not expose its
|
|
2094
2127
|
* power slider" on a picker that was simply shut.
|
|
2095
2128
|
*/
|
|
2129
|
+
/**
|
|
2130
|
+
* How to apply the stored Chat-surface preference on a page that renders no
|
|
2131
|
+
* surface toggle at all.
|
|
2132
|
+
*
|
|
2133
|
+
* Reloading in place is the only option for a send that has to stay where it
|
|
2134
|
+
* is - a pinned thread, a continuation. On a project home it is the wrong one:
|
|
2135
|
+
* a hard load of one comes back as ChatGPT's error page (measured on two
|
|
2136
|
+
* projects), which leaves the send on a document with no sidebar, and the
|
|
2137
|
+
* project step then reports the project missing from a sidebar that was never
|
|
2138
|
+
* drawn. A send that is going to navigate anyway takes the root, which loads.
|
|
2139
|
+
*/
|
|
2140
|
+
export function chatSurfaceRecoveryPlan(input) {
|
|
2141
|
+
const onProjectPage = /^https:\/\/chatgpt\.com\/g\/g-p-/.test(input.href);
|
|
2142
|
+
return input.mayLeaveCurrentPage && onProjectPage ? "fresh-root" : "reload";
|
|
2143
|
+
}
|
|
2096
2144
|
/**
|
|
2097
2145
|
* Put the browser back on ChatGPT's Chat surface when it has drifted onto Work.
|
|
2098
2146
|
*
|
|
@@ -2100,7 +2148,7 @@ async function selectPickerModel(cdp, requested, warnings = []) {
|
|
|
2100
2148
|
* the page announces which one is live, so a drifted browser silently drives
|
|
2101
2149
|
* the wrong picker. Returns a warning to carry to the caller when it moved.
|
|
2102
2150
|
*/
|
|
2103
|
-
async function ensureChatSurface(cdp) {
|
|
2151
|
+
async function ensureChatSurface(cdp, options) {
|
|
2104
2152
|
const read = async () => {
|
|
2105
2153
|
try {
|
|
2106
2154
|
return await cdp.evaluate(chatSurfaceProbeExpression());
|
|
@@ -2158,8 +2206,23 @@ async function ensureChatSurface(cdp) {
|
|
|
2158
2206
|
try {
|
|
2159
2207
|
await cdp.evaluate(selectChatSurfaceExpression());
|
|
2160
2208
|
// Reading the persisted value back right after writing it proves nothing;
|
|
2161
|
-
// the
|
|
2162
|
-
|
|
2209
|
+
// the new document rendering its composer is what proves the switch.
|
|
2210
|
+
const plan = chatSurfaceRecoveryPlan({
|
|
2211
|
+
href: await cdp.evaluate("location.href"),
|
|
2212
|
+
mayLeaveCurrentPage: options.mayLeaveCurrentPage
|
|
2213
|
+
});
|
|
2214
|
+
let applied;
|
|
2215
|
+
if (plan === "fresh-root") {
|
|
2216
|
+
// Throws when the root never rendered a composer, which the catch below
|
|
2217
|
+
// turns into the same "could not be switched back" warning as a reload
|
|
2218
|
+
// that never settled.
|
|
2219
|
+
await openFreshChatGptHome(cdp);
|
|
2220
|
+
applied = true;
|
|
2221
|
+
}
|
|
2222
|
+
else {
|
|
2223
|
+
applied = await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS);
|
|
2224
|
+
}
|
|
2225
|
+
if (applied && (await confirm()))
|
|
2163
2226
|
return note;
|
|
2164
2227
|
}
|
|
2165
2228
|
catch (error) {
|
|
@@ -2601,11 +2664,27 @@ async function createChatGptProject(cdp, name) {
|
|
|
2601
2664
|
}
|
|
2602
2665
|
}
|
|
2603
2666
|
/** The placeholder of the composer the send will actually type into. */
|
|
2604
|
-
|
|
2667
|
+
/**
|
|
2668
|
+
* Read the label the composer carries, wherever it keeps it.
|
|
2669
|
+
*
|
|
2670
|
+
* Measured live on a project home: the editor prodex types into is the
|
|
2671
|
+
* contenteditable div, and it carries the label ONLY as `aria-label` -
|
|
2672
|
+
* `data-placeholder` is null on it. The 0x0 textarea beside it does carry
|
|
2673
|
+
* `placeholder`, but the composer finder rejects that one on size, exactly as
|
|
2674
|
+
* it should. Reading a single attribute meant the label was there and prodex
|
|
2675
|
+
* could not see it, so every project send refused with "the placeholder could
|
|
2676
|
+
* not be read" - the fail-closed branch doing its job on a page that was fine.
|
|
2677
|
+
*/
|
|
2678
|
+
export function composerProjectBindingExpression() {
|
|
2605
2679
|
return `(() => {${composerExpressionHelpers()}
|
|
2606
2680
|
const node = findChatGptComposerCandidate();
|
|
2607
2681
|
if (!node) return { found: false };
|
|
2608
|
-
|
|
2682
|
+
const label =
|
|
2683
|
+
node.getAttribute("data-placeholder") ||
|
|
2684
|
+
node.getAttribute("placeholder") ||
|
|
2685
|
+
node.getAttribute("aria-label") ||
|
|
2686
|
+
"";
|
|
2687
|
+
return { found: true, placeholder: label };
|
|
2609
2688
|
})()`;
|
|
2610
2689
|
}
|
|
2611
2690
|
/**
|
|
@@ -2620,9 +2699,20 @@ async function waitForComposerProjectBinding(cdp, project, timeoutMs) {
|
|
|
2620
2699
|
const deadline = Date.now() + timeoutMs;
|
|
2621
2700
|
let verdict = "unknown";
|
|
2622
2701
|
for (;;) {
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
.
|
|
2702
|
+
let read;
|
|
2703
|
+
try {
|
|
2704
|
+
read = await cdp.evaluate(composerProjectBindingExpression());
|
|
2705
|
+
}
|
|
2706
|
+
catch (error) {
|
|
2707
|
+
// A read that lands between documents answers about neither, and the next
|
|
2708
|
+
// poll lands on the new one. A command timeout is a different thing: the
|
|
2709
|
+
// tab stopped answering, and swallowing it spent this whole budget and
|
|
2710
|
+
// the recovery's before refusing with "the placeholder could not be
|
|
2711
|
+
// read" - a binding failure reported for a browser that was gone.
|
|
2712
|
+
if (cdpCommandTimedOut(error))
|
|
2713
|
+
throw error;
|
|
2714
|
+
read = { found: false };
|
|
2715
|
+
}
|
|
2626
2716
|
if (read.found) {
|
|
2627
2717
|
const sample = composerProjectBinding({
|
|
2628
2718
|
...(read.placeholder !== undefined ? { placeholder: read.placeholder } : {}),
|
|
@@ -2691,6 +2781,35 @@ async function navigateToExistingProject(cdp, project) {
|
|
|
2691
2781
|
}
|
|
2692
2782
|
}
|
|
2693
2783
|
}
|
|
2784
|
+
/**
|
|
2785
|
+
* The refusal to post into a project prodex cannot confirm, as a blocker.
|
|
2786
|
+
*
|
|
2787
|
+
* It used to be a plain Error whose English prose a regex in the CLI matched to
|
|
2788
|
+
* recover the code - so the difference between "the composer belongs elsewhere"
|
|
2789
|
+
* and "the browser stopped answering" survived only as a sentence, and a
|
|
2790
|
+
* reworded message would have quietly become an unclassified send failure. The
|
|
2791
|
+
* wording still carries the phrase the classifier keys on, because older
|
|
2792
|
+
* senders and other paths still reach it as text.
|
|
2793
|
+
*
|
|
2794
|
+
* The project it OFFERED instead is deliberately absent: naming it would put
|
|
2795
|
+
* another project's name in a persisted record, which redaction covers only for
|
|
2796
|
+
* the ones this send asked for.
|
|
2797
|
+
*/
|
|
2798
|
+
export function projectNotBoundBlocker(input) {
|
|
2799
|
+
const detail = input.reason === "elsewhere"
|
|
2800
|
+
? "after entering it, the composer still offers a chat that belongs somewhere else"
|
|
2801
|
+
: input.reason === "unknown"
|
|
2802
|
+
? "after entering it, the composer's placeholder could not be read, so where the prompt would land is unknown"
|
|
2803
|
+
: "it read as this project's after entering it and no longer does";
|
|
2804
|
+
return {
|
|
2805
|
+
code: "project_not_bound",
|
|
2806
|
+
message: `ChatGPT composer did not bind to project "${input.project}": ${detail}, so nothing was sent.` +
|
|
2807
|
+
(input.recoveryNote ?? ""),
|
|
2808
|
+
retryable: true,
|
|
2809
|
+
next_step: "Nothing was sent, so nothing landed in the wrong project. Retry - the composer normally binds on the next navigation - " +
|
|
2810
|
+
"or open the project once in the visible browser and send again."
|
|
2811
|
+
};
|
|
2812
|
+
}
|
|
2694
2813
|
/**
|
|
2695
2814
|
* The project name the composer has to agree with before anything is typed,
|
|
2696
2815
|
* or undefined for a send that pins no project.
|
|
@@ -2715,11 +2834,18 @@ export function composerBindingTarget(options) {
|
|
|
2715
2834
|
async function selectProject(cdp, options) {
|
|
2716
2835
|
const wanted = composerBindingTarget(options);
|
|
2717
2836
|
if (!wanted)
|
|
2718
|
-
return;
|
|
2837
|
+
return undefined;
|
|
2719
2838
|
if (options.projectNew)
|
|
2720
2839
|
await createChatGptProject(cdp, options.projectNew);
|
|
2721
|
-
|
|
2840
|
+
// Clicking the sidebar row when the composer already belongs to this project
|
|
2841
|
+
// is work that can only fail. Measured: a send into the project the tab was
|
|
2842
|
+
// already sitting in refused with "another element covers its click point" -
|
|
2843
|
+
// a promotional banner over the sidebar - on a page that was ready to accept
|
|
2844
|
+
// the prompt. The read below is the same evidence the gate accepts, so a
|
|
2845
|
+
// composer that already answers with this project needs no navigation.
|
|
2846
|
+
else if ((await waitForComposerProjectBinding(cdp, options.project, 0)) !== "bound") {
|
|
2722
2847
|
await navigateToExistingProject(cdp, options.project);
|
|
2848
|
+
}
|
|
2723
2849
|
// A sidebar SPA navigation moves the URL to the target project while the
|
|
2724
2850
|
// composer can stay bound to the PREVIOUS project's conversation target, so
|
|
2725
2851
|
// the send silently creates the thread in the OLD project (reproduced live
|
|
@@ -2766,20 +2892,17 @@ async function selectProject(cdp, options) {
|
|
|
2766
2892
|
// read is not evidence that the composer is this project's, and what it
|
|
2767
2893
|
// guards against - a prompt posted into another project, recorded under
|
|
2768
2894
|
// the requested one - costs far more than a send the caller can retry.
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
? "the composer still offers a chat that belongs somewhere else"
|
|
2775
|
-
: "the composer's placeholder could not be read, so where the prompt would land is unknown";
|
|
2776
|
-
throw new Error(`ChatGPT composer did not bind to project "${wanted}": after entering it, ${detail}, ` +
|
|
2777
|
-
`so nothing was sent.${recoveryNote}`);
|
|
2895
|
+
throw new ChatGptBrowserBlockerError(projectNotBoundBlocker({
|
|
2896
|
+
project: wanted,
|
|
2897
|
+
reason: binding,
|
|
2898
|
+
...(recoveryNote ? { recoveryNote } : {})
|
|
2899
|
+
}));
|
|
2778
2900
|
}
|
|
2779
2901
|
const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2780
2902
|
if (!composerReady) {
|
|
2781
2903
|
throw new Error(`ChatGPT composer did not appear after entering project "${wanted}"`);
|
|
2782
2904
|
}
|
|
2905
|
+
return chatGptProjectIdFromUrl(await cdp.evaluate("location.href"));
|
|
2783
2906
|
}
|
|
2784
2907
|
// Read the finished answer from an existing ChatGPT thread WITHOUT sending a new
|
|
2785
2908
|
// prompt. Recovers a consult whose send timed out but whose answer ChatGPT
|
|
@@ -3147,6 +3270,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3147
3270
|
process.stderr.write(`DBG-SEND +${Date.now() - sendStartedAt}ms ${msg}\n`);
|
|
3148
3271
|
};
|
|
3149
3272
|
let beforeSubmit;
|
|
3273
|
+
let boundProjectId;
|
|
3150
3274
|
let submitButtonFound = false;
|
|
3151
3275
|
let wantsDeepResearch = false;
|
|
3152
3276
|
const sendWarnings = [];
|
|
@@ -3192,11 +3316,16 @@ export async function sendChatGptPrompt(options) {
|
|
|
3192
3316
|
// Max and Ultra are rungs of Work's slider, so asking for one means staying
|
|
3193
3317
|
// there; anything else belongs on Chat, whose top step is Pro.
|
|
3194
3318
|
if (!effortNeedsWorkSurface(options.effort)) {
|
|
3195
|
-
|
|
3319
|
+
// Leaving the current page is safe only for a send that was going to
|
|
3320
|
+
// navigate anyway; a continuation or a pinned tab has to be reloaded
|
|
3321
|
+
// where it stands, because that page IS the destination.
|
|
3322
|
+
const surfaceWarning = await ensureChatSurface(cdp, {
|
|
3323
|
+
mayLeaveCurrentPage: Boolean(options.newChat || options.project || options.projectNew)
|
|
3324
|
+
});
|
|
3196
3325
|
if (surfaceWarning)
|
|
3197
3326
|
sendWarnings.push(surfaceWarning);
|
|
3198
3327
|
}
|
|
3199
|
-
await selectProject(cdp, options);
|
|
3328
|
+
boundProjectId = await selectProject(cdp, options);
|
|
3200
3329
|
try {
|
|
3201
3330
|
await selectModelReasoning(cdp, options, sendWarnings);
|
|
3202
3331
|
}
|
|
@@ -3230,8 +3359,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3230
3359
|
const stillBound = await waitForComposerProjectBinding(cdp, boundProject, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
3231
3360
|
dbgSend(`project binding before typing=${stillBound}`);
|
|
3232
3361
|
if (stillBound !== "bound") {
|
|
3233
|
-
throw new
|
|
3234
|
-
`no longer does, so nothing was sent.`);
|
|
3362
|
+
throw new ChatGptBrowserBlockerError(projectNotBoundBlocker({ project: boundProject, reason: "drifted" }));
|
|
3235
3363
|
}
|
|
3236
3364
|
}
|
|
3237
3365
|
// Attach BEFORE typing: the upload is the slow part, and a file that
|
|
@@ -3428,6 +3556,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3428
3556
|
answer: transcript.answer,
|
|
3429
3557
|
modelHints: finalState?.modelHints ?? [],
|
|
3430
3558
|
...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
|
|
3559
|
+
...(boundProjectId ? { boundProjectId } : {}),
|
|
3431
3560
|
warnings: withDialogNote([...sendWarnings, selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...((transcript.modelSlug || finalState?.modelSlug) ? { modelSlug: (transcript.modelSlug || finalState?.modelSlug) } : {}) })]).filter((warning) => Boolean(warning))
|
|
3432
3561
|
};
|
|
3433
3562
|
};
|
|
@@ -3576,6 +3705,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3576
3705
|
answer: completed.answer.trim(),
|
|
3577
3706
|
modelHints: completed.modelHints,
|
|
3578
3707
|
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
3708
|
+
...(boundProjectId ? { boundProjectId } : {}),
|
|
3579
3709
|
warnings: withDialogNote([...sendWarnings, selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })]).filter((warning) => Boolean(warning))
|
|
3580
3710
|
};
|
|
3581
3711
|
}
|
|
@@ -3590,6 +3720,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3590
3720
|
answer: completed.answer.trim(),
|
|
3591
3721
|
modelHints: completed.modelHints,
|
|
3592
3722
|
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
3723
|
+
...(boundProjectId ? { boundProjectId } : {}),
|
|
3593
3724
|
warnings: withDialogNote([
|
|
3594
3725
|
...sendWarnings,
|
|
3595
3726
|
...(selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) }) ? [selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })] : []),
|
|
@@ -5299,6 +5430,68 @@ export function deepResearchReportExpression(conversationId) {
|
|
|
5299
5430
|
* Thread urls come in a plain (`/c/<id>`) and a project (`/g/g-p-.../c/<id>`)
|
|
5300
5431
|
* shape; both end in the conversation id the backend API is keyed by.
|
|
5301
5432
|
*/
|
|
5433
|
+
/**
|
|
5434
|
+
* The project a ChatGPT URL belongs to, as its stable id.
|
|
5435
|
+
*
|
|
5436
|
+
* Measured live within one send: the SAME project renders as
|
|
5437
|
+
* `/g/g-p-<hash>/project` on its home and `/g/g-p-<hash>-<name>/c/<id>` on the
|
|
5438
|
+
* thread that came out of it. Comparing the slugs whole would call those two
|
|
5439
|
+
* different projects, so only the hash - the part that does not depend on how
|
|
5440
|
+
* the page felt like writing the name - identifies it.
|
|
5441
|
+
*/
|
|
5442
|
+
export function chatGptProjectIdFromUrl(url) {
|
|
5443
|
+
if (!url)
|
|
5444
|
+
return undefined;
|
|
5445
|
+
const match = /\/g\/g-p-([0-9a-f]+)(?:[-/?#]|$)/i.exec(url);
|
|
5446
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
5447
|
+
}
|
|
5448
|
+
/**
|
|
5449
|
+
* Where the prompt actually landed, against where it was aimed.
|
|
5450
|
+
*
|
|
5451
|
+
* The receipt recorded the project the caller ASKED for, which is intent, not
|
|
5452
|
+
* evidence: a send that ended up somewhere else was recorded under the name of
|
|
5453
|
+
* the place it never reached. The answered thread's URL carries the project it
|
|
5454
|
+
* really belongs to, and the project step knows the id it bound to, so the two
|
|
5455
|
+
* can be compared instead of assumed. "unverified" is its own answer - better
|
|
5456
|
+
* than a receipt that certifies what nobody checked.
|
|
5457
|
+
*/
|
|
5458
|
+
export function destinationVerification(input) {
|
|
5459
|
+
const answeredProjectId = chatGptProjectIdFromUrl(input.answeredUrl);
|
|
5460
|
+
const destination = !input.answeredUrl ? "unknown" : answeredProjectId ? "project" : "root";
|
|
5461
|
+
if (!input.requestedProject)
|
|
5462
|
+
return { destination };
|
|
5463
|
+
if (destination === "unknown")
|
|
5464
|
+
return { destination, verified: false };
|
|
5465
|
+
// Landing outside every project is wrong on its own evidence: it needs no id
|
|
5466
|
+
// to compare against, and requiring one would have retired a warning that
|
|
5467
|
+
// caught this in the field.
|
|
5468
|
+
if (destination === "root") {
|
|
5469
|
+
return {
|
|
5470
|
+
destination,
|
|
5471
|
+
verified: false,
|
|
5472
|
+
warning: "project_landing_warning: a project was requested but the answered thread is a root chat, so it landed OUTSIDE the project. " +
|
|
5473
|
+
"Move it via the thread menu (Move to project) or re-run; list projects with `prodex pro browser projects`."
|
|
5474
|
+
};
|
|
5475
|
+
}
|
|
5476
|
+
// In a project, with nothing to check it against: not a warning, but not a
|
|
5477
|
+
// verified landing either.
|
|
5478
|
+
if (!input.boundProjectId)
|
|
5479
|
+
return { destination, verified: false };
|
|
5480
|
+
const verified = answeredProjectId === input.boundProjectId;
|
|
5481
|
+
return {
|
|
5482
|
+
destination,
|
|
5483
|
+
verified,
|
|
5484
|
+
...(verified
|
|
5485
|
+
? {}
|
|
5486
|
+
: {
|
|
5487
|
+
// Naming the project it landed in would put another project's name in
|
|
5488
|
+
// a persisted record; the thread URL is already there for anyone who
|
|
5489
|
+
// needs to go look.
|
|
5490
|
+
warning: "project_landing_warning: the answered thread belongs to a different project than the one this send entered. " +
|
|
5491
|
+
"Open the thread URL in the receipt to see where it went."
|
|
5492
|
+
})
|
|
5493
|
+
};
|
|
5494
|
+
}
|
|
5302
5495
|
export function conversationIdFromThreadUrl(url) {
|
|
5303
5496
|
const match = /\/c\/([0-9a-fA-F-]{16,})/.exec(url);
|
|
5304
5497
|
return match ? match[1] : undefined;
|
package/dist/cli-args.js
CHANGED
|
@@ -260,6 +260,9 @@ export const ASK_PRO_BOOLEAN_FLAGS = new Set([
|
|
|
260
260
|
"--no-auto-login",
|
|
261
261
|
// Send outside any project for once, overriding a pinned default.
|
|
262
262
|
"--no-project",
|
|
263
|
+
// Continue the conversation a previous consult left off in, resolved from
|
|
264
|
+
// this repo's own records rather than from whatever the shared tab shows.
|
|
265
|
+
"--continue",
|
|
263
266
|
// Send even when the requested model/effort could not be applied, rather
|
|
264
267
|
// than stopping. Off by default: an answer from a step nobody asked for is
|
|
265
268
|
// usually thrown away.
|
|
@@ -268,6 +271,8 @@ export const ASK_PRO_BOOLEAN_FLAGS = new Set([
|
|
|
268
271
|
export const ASK_PRO_SELECTION_VALUE_FLAGS = ["--project", "--project-new", "--model", "--pro-mode", "--effort"];
|
|
269
272
|
export const ASK_PRO_VALUE_FLAGS = new Set([
|
|
270
273
|
"--cwd",
|
|
274
|
+
// Continue one NAMED past consult, when "the last one" is not the one meant.
|
|
275
|
+
"--continue-task",
|
|
271
276
|
"--file",
|
|
272
277
|
// Upload the file itself (pdf/pptx/image) instead of inlining its text.
|
|
273
278
|
"--attach",
|
package/dist/cli-help.js
CHANGED
|
@@ -26,7 +26,7 @@ Ask / consult commands:
|
|
|
26
26
|
prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of model menu options
|
|
27
27
|
prodex pro browser projects [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of sidebar project names (for --project)
|
|
28
28
|
prodex pro browser recover [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--timeout-ms 60000] # recover a finished answer from a thread whose send timed out
|
|
29
|
-
prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
|
|
29
|
+
prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
|
|
30
30
|
prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
|
|
31
31
|
prodex pro blockers [--cwd /absolute/path/to/repo] [--since 7d] [--limit 10] [--json] # what actually blocks consults, ranked, across every bridge root on this machine
|
|
32
32
|
prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
|
|
@@ -170,7 +170,7 @@ Commands:
|
|
|
170
170
|
prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
|
|
171
171
|
prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
|
|
172
172
|
prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js]
|
|
173
|
-
prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--target-url url --confirm-target] [--new-chat] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt"
|
|
173
|
+
prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"] "prompt"
|
|
174
174
|
prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
|
|
175
175
|
prodex pro blockers [--cwd /absolute/path/to/repo] [--since 7d] [--limit 10] [--json] # what actually blocks consults, ranked, across every bridge root on this machine
|
|
176
176
|
prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
|
|
@@ -258,8 +258,8 @@ export function printProBrowserHelp(stdout, sourceCli) {
|
|
|
258
258
|
: "prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]";
|
|
259
259
|
const selectionUsage = '[--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro] [--project "name" | --project-new "name"]';
|
|
260
260
|
const askUsage = sourceCli
|
|
261
|
-
? `${cli} pro browser ask${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`
|
|
262
|
-
: `prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`;
|
|
261
|
+
? `${cli} pro browser ask${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`
|
|
262
|
+
: `prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--continue | --continue-task task_id] [--temporary] [--allow-model-fallback] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`;
|
|
263
263
|
const modelsUsage = sourceCli
|
|
264
264
|
? `${cli} pro browser models${sourceCliOption} [--port 9333] [--timeout-ms 15000]`
|
|
265
265
|
: "prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000]";
|
|
@@ -306,6 +306,11 @@ Model/project selection (ask):
|
|
|
306
306
|
--pro-mode Pro sub-mode: 기본 (standard) or 확장 (extended), used when the model is Pro. A Pro selection raises the default --timeout-ms to 1200000.
|
|
307
307
|
--effort Reasoning effort: 즉시 / 중간 / 높음 / 매우 높음 / Max / Ultra / Pro (aliases: instant/light, medium, high, extrahigh/max, ultra). One power slider sets the model and the effort together, so picking an effort moves off Pro. Max and Ultra are rungs of the Work surface and only apply when the browser is already on Work; everything else is sent on Chat.
|
|
308
308
|
--project Enter an existing sidebar project before sending. Cannot be combined with --target-url.
|
|
309
|
+
|
|
310
|
+
Continuing a conversation (ask):
|
|
311
|
+
--continue Send into the conversation a previous consult is already in: the newest FINISHED consult of the same project, read from this repo's own .bridge records. The browser tab is shared and a pinned project starts a new chat on every send, so the tab is not what decides this. A --project here scopes the search rather than navigating. Refuses instead of guessing when this project has no finished consult yet.
|
|
312
|
+
--continue-task Continue one named past consult by its task_id, when the newest is not the conversation meant. List them with \`${cli} pro list\`.
|
|
313
|
+
Cannot be combined with --new-chat, --target-url, --project-new or --temporary.
|
|
309
314
|
--pro-mode and --effort cannot be combined. Labels are matched in both the Korean and English (US) ChatGPT UI (e.g. 높음/High, Pro 확장/Pro Extended).
|
|
310
315
|
Run \`${cli} pro browser models${sourceCliOption}\` to list the labels your account currently shows.
|
|
311
316
|
Persist defaults with \`${cli} setup${sourceCliOption}\`; per-ask flags override them.
|
package/dist/cli-pro.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, statSync } from "node:fs";
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { buildDryRunBundle } from "./bundle.js";
|
|
5
|
-
import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro } from "./chatgpt-browser.js";
|
|
5
|
+
import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
|
|
6
6
|
import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
|
|
7
7
|
import { printProBrowserHelp, printProHelp } from "./cli-help.js";
|
|
8
8
|
import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
|
|
@@ -11,6 +11,7 @@ import { errorMessage, firstLine, formatBlockedConsultRecordedMessage, formatPro
|
|
|
11
11
|
import { getTokenExpiryStatus, loadBrowserDefaults, loadLocalConfig } from "./config.js";
|
|
12
12
|
import { withBrowserSendLock } from "./browser-send-lock.js";
|
|
13
13
|
import { blockerCause, buildBlockerReport } from "./blocker-report.js";
|
|
14
|
+
import { resolveContinuationThread } from "./continue-thread.js";
|
|
14
15
|
import { readBridgeRoots } from "./registry.js";
|
|
15
16
|
import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
|
|
16
17
|
import { CLI_VERSION } from "./cli-help.js";
|
|
@@ -162,6 +163,8 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
162
163
|
"--effort",
|
|
163
164
|
"--new-chat",
|
|
164
165
|
"--temporary",
|
|
166
|
+
"--continue",
|
|
167
|
+
"--continue-task",
|
|
165
168
|
"--auto-login",
|
|
166
169
|
"--no-auto-login"
|
|
167
170
|
].find((flag) => proArgs.includes(flag));
|
|
@@ -1058,7 +1061,8 @@ export async function runAskProCommand(rest, io) {
|
|
|
1058
1061
|
throw new Error("--tool only applies when sending (`prodex pro browser ask`); the dry-run preview cannot open ChatGPT's tools menu.");
|
|
1059
1062
|
}
|
|
1060
1063
|
const targetUrl = readFlag(parsedAskPro.optionArgs, "--target-url");
|
|
1061
|
-
|
|
1064
|
+
let normalizedTargetUrl = targetUrl ? normalizeChatGptTargetUrl(targetUrl) : undefined;
|
|
1065
|
+
let continuedFromTaskId;
|
|
1062
1066
|
if (!normalizedTargetUrl && parsedAskPro.optionArgs.includes("--confirm-target")) {
|
|
1063
1067
|
throw new Error("--confirm-target requires --target-url so the visible browser target is explicit.");
|
|
1064
1068
|
}
|
|
@@ -1104,6 +1108,46 @@ export async function runAskProCommand(rest, io) {
|
|
|
1104
1108
|
if (normalizedTargetUrl && (explicitProject !== undefined || explicitProjectNew !== undefined)) {
|
|
1105
1109
|
throw new Error("ask-pro cannot combine --target-url with --project/--project-new: --target-url pins the confirmed tab while the project step navigates the sidebar away from it. Open the project thread in the browser and pass its URL as --target-url instead.");
|
|
1106
1110
|
}
|
|
1111
|
+
// Continue the conversation a previous consult left off in. Resolved from
|
|
1112
|
+
// this repo's records rather than from the shared tab: the tab is whatever
|
|
1113
|
+
// the last person or session left on screen, and a pinned project
|
|
1114
|
+
// navigates away from it before every send anyway - which is why a
|
|
1115
|
+
// "continuing" consult on a machine with a default project measurably
|
|
1116
|
+
// started a new thread every time.
|
|
1117
|
+
const continueRequested = parsedAskPro.optionArgs.includes("--continue");
|
|
1118
|
+
const continueTaskId = readFlag(parsedAskPro.optionArgs, "--continue-task");
|
|
1119
|
+
if (continueRequested || continueTaskId !== undefined) {
|
|
1120
|
+
const conflict = [
|
|
1121
|
+
parsedAskPro.optionArgs.includes("--new-chat") ? "--new-chat" : undefined,
|
|
1122
|
+
targetUrl !== undefined ? "--target-url" : undefined,
|
|
1123
|
+
explicitProjectNew !== undefined ? "--project-new" : undefined,
|
|
1124
|
+
parsedAskPro.optionArgs.includes("--temporary") ? "--temporary" : undefined
|
|
1125
|
+
].find(Boolean);
|
|
1126
|
+
if (conflict) {
|
|
1127
|
+
throw new Error(`ask-pro cannot combine --continue with ${conflict}: continuing means sending into the conversation a previous consult is already in.`);
|
|
1128
|
+
}
|
|
1129
|
+
// Scope by the project this send would have used, so a follow-up cannot
|
|
1130
|
+
// land in another project's conversation.
|
|
1131
|
+
const continuationProject = explicitProject ?? (suppressProject ? undefined : browserDefaults?.project);
|
|
1132
|
+
const resolved = resolveContinuationThread({
|
|
1133
|
+
consults: (await targetStore.listSessionsReadOnly()).map((session) => ({
|
|
1134
|
+
taskId: session.task_id ?? "",
|
|
1135
|
+
...(session.thread ? { thread: session.thread } : {}),
|
|
1136
|
+
status: session.status,
|
|
1137
|
+
...(session.created_at ? { createdAt: session.created_at } : {})
|
|
1138
|
+
})),
|
|
1139
|
+
...(continuationProject ? { project: continuationProject } : {}),
|
|
1140
|
+
...(continueTaskId !== undefined ? { taskId: continueTaskId } : {})
|
|
1141
|
+
});
|
|
1142
|
+
if ("error" in resolved)
|
|
1143
|
+
throw new Error(resolved.error);
|
|
1144
|
+
continuedFromTaskId = resolved.target.taskId;
|
|
1145
|
+
// The thread pins the tab exactly as --target-url does, and carries its
|
|
1146
|
+
// own project with it - so the project step is suppressed below for the
|
|
1147
|
+
// same reason a pinned target suppresses it.
|
|
1148
|
+
normalizedTargetUrl = normalizeChatGptTargetUrl(resolved.target.thread);
|
|
1149
|
+
io.stderr(`progress: continuing ${resolved.target.taskId}`);
|
|
1150
|
+
}
|
|
1107
1151
|
const newChat = parsedAskPro.optionArgs.includes("--new-chat");
|
|
1108
1152
|
// A temporary chat is not saved, so there is nothing to come back to: the
|
|
1109
1153
|
// recovery path every timeout message points at cannot fetch it later.
|
|
@@ -1130,18 +1174,19 @@ export async function runAskProCommand(rest, io) {
|
|
|
1130
1174
|
}
|
|
1131
1175
|
const explicitProMode = explicitProModeRaw === undefined ? undefined : parseProMode(explicitProModeRaw);
|
|
1132
1176
|
const explicitEffort = explicitEffortRaw === undefined ? undefined : parseReasoningEffort(explicitEffortRaw);
|
|
1133
|
-
// Explicit per-ask flags override persisted defaults
|
|
1134
|
-
//
|
|
1135
|
-
// pinning --target-url suppresses a default project
|
|
1136
|
-
// from the confirmed tab
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1177
|
+
// Explicit per-ask flags override persisted defaults; resolveSelectionAxes
|
|
1178
|
+
// holds which default a given flag suppresses and why. The project axis is
|
|
1179
|
+
// separate: pinning --target-url suppresses a default project, because
|
|
1180
|
+
// entering one would navigate away from the confirmed tab.
|
|
1181
|
+
const selectionAxes = resolveSelectionAxes({
|
|
1182
|
+
explicit: {
|
|
1183
|
+
...(explicitModel !== undefined ? { model: explicitModel } : {}),
|
|
1184
|
+
...(explicitProMode !== undefined ? { proMode: explicitProMode } : {}),
|
|
1185
|
+
...(explicitEffort !== undefined ? { effort: explicitEffort } : {})
|
|
1186
|
+
},
|
|
1187
|
+
...(browserDefaults ? { defaults: browserDefaults } : {})
|
|
1188
|
+
});
|
|
1189
|
+
const selectionModel = selectionAxes.model;
|
|
1145
1190
|
const selectionProjectNew = explicitProjectNew;
|
|
1146
1191
|
// A persisted default project APPLIES under --new-chat: since 0.16.11 a
|
|
1147
1192
|
// fresh chat inside the project is exactly what "--new-chat + project"
|
|
@@ -1154,15 +1199,24 @@ export async function runAskProCommand(rest, io) {
|
|
|
1154
1199
|
// produced "composer did not rebind after entering project" - a temporary
|
|
1155
1200
|
// chat is never saved, a project chat is, and entering a project leaves
|
|
1156
1201
|
// temporary mode.
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
const
|
|
1163
|
-
|
|
1202
|
+
// A continuation sends into a thread that already lives in its project, so
|
|
1203
|
+
// the project step must not run - entering a project navigates AWAY from
|
|
1204
|
+
// the pinned thread and starts a new chat, which is the very failure
|
|
1205
|
+
// --continue exists to fix. An explicit --project on a continuation is the
|
|
1206
|
+
// SCOPE of the search, not an instruction to navigate.
|
|
1207
|
+
const selectionProject = continuedFromTaskId
|
|
1208
|
+
? undefined
|
|
1209
|
+
: explicitProject ??
|
|
1210
|
+
(normalizedTargetUrl || selectionProjectNew !== undefined || suppressProject || temporary
|
|
1211
|
+
? undefined
|
|
1212
|
+
: browserDefaults?.project);
|
|
1213
|
+
const selectionProMode = selectionAxes.proMode;
|
|
1214
|
+
const selectionEffort = selectionAxes.effort;
|
|
1215
|
+
const continuationScopeProject = continuedFromTaskId
|
|
1216
|
+
? explicitProject ?? (suppressProject ? undefined : browserDefaults?.project)
|
|
1217
|
+
: undefined;
|
|
1164
1218
|
const selectionMetadata = {
|
|
1165
|
-
...(selectionProject ? { project: selectionProject } : {}),
|
|
1219
|
+
...(selectionProject ?? continuationScopeProject ? { project: (selectionProject ?? continuationScopeProject) } : {}),
|
|
1166
1220
|
...(selectionProjectNew ? { project_new: selectionProjectNew } : {}),
|
|
1167
1221
|
...(selectionModel ? { model: selectionModel } : {}),
|
|
1168
1222
|
...(selectionProMode ? { pro_mode: selectionProMode } : {}),
|
|
@@ -1307,10 +1361,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
1307
1361
|
// "project not found" error). Local stdout/stderr keep it (useful to the
|
|
1308
1362
|
// operator), but the persisted task/session cross the MCP boundary, so
|
|
1309
1363
|
// scrub the project name there the same way provenance.project is redacted.
|
|
1310
|
-
const redactProject = (text) =>
|
|
1311
|
-
const name = selectionMetadata.project;
|
|
1312
|
-
return name ? text.split(name).join("<project>") : text;
|
|
1313
|
-
};
|
|
1364
|
+
const redactProject = (text) => redactProjectNames(text, [selectionMetadata.project, selectionMetadata.project_new]);
|
|
1314
1365
|
// Where the prompt actually landed beats where the caller aimed: with
|
|
1315
1366
|
// --new-chat there is no target url, and a blocker that started a run
|
|
1316
1367
|
// still has a thread worth handing back.
|
|
@@ -1359,13 +1410,21 @@ export async function runAskProCommand(rest, io) {
|
|
|
1359
1410
|
(consult.modelSlug ? ` - it answered as "${consult.modelSlug}"` : "") +
|
|
1360
1411
|
". Pin one with `prodex setup --model Pro` or pass --model/--effort.");
|
|
1361
1412
|
}
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1413
|
+
// Where the answer actually IS, checked rather than assumed: the project
|
|
1414
|
+
// id the composer bound to before typing, against the project the
|
|
1415
|
+
// answered thread belongs to. The requested name is intent - a send that
|
|
1416
|
+
// ended up elsewhere was recorded under the name of the place it never
|
|
1417
|
+
// reached.
|
|
1418
|
+
const continuationProjectId = continuedFromTaskId ? chatGptProjectIdFromUrl(normalizedTargetUrl) : undefined;
|
|
1419
|
+
const destination = destinationVerification({
|
|
1420
|
+
requestedProject: Boolean(selectionMetadata.project || selectionMetadata.project_new),
|
|
1421
|
+
...(consult.boundProjectId ?? continuationProjectId
|
|
1422
|
+
? { boundProjectId: (consult.boundProjectId ?? continuationProjectId) }
|
|
1423
|
+
: {}),
|
|
1424
|
+
...(consult.url ? { answeredUrl: consult.url } : {})
|
|
1425
|
+
});
|
|
1426
|
+
if (destination.warning)
|
|
1427
|
+
persistenceWarnings.push(destination.warning);
|
|
1369
1428
|
// Truncation and other send warnings must be visible at runtime, not
|
|
1370
1429
|
// only inside the persisted receipt: a caller who never opens .bridge
|
|
1371
1430
|
// would otherwise treat a cut-off answer as complete.
|
|
@@ -1414,6 +1473,14 @@ export async function runAskProCommand(rest, io) {
|
|
|
1414
1473
|
// receipt used to record only what prodex asked for.
|
|
1415
1474
|
...(consult.modelSlug ? { model_used: consult.modelSlug } : {}),
|
|
1416
1475
|
...(proVerified !== undefined ? { pro_verified: proVerified } : {}),
|
|
1476
|
+
// Intent and evidence, kept apart. `selection` is what was asked
|
|
1477
|
+
// for; this is where the answer turned out to be, and whether
|
|
1478
|
+
// anything actually confirmed it.
|
|
1479
|
+
...(continuedFromTaskId ? { continued_from: continuedFromTaskId } : {}),
|
|
1480
|
+
destination: {
|
|
1481
|
+
observed: destination.destination,
|
|
1482
|
+
...(destination.verified !== undefined ? { verified: destination.verified } : {})
|
|
1483
|
+
},
|
|
1417
1484
|
warnings: persistenceWarnings
|
|
1418
1485
|
}
|
|
1419
1486
|
});
|
|
@@ -1579,6 +1646,8 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
|
1579
1646
|
...(input.attach ?? []).flatMap((file) => ["--attach", file]),
|
|
1580
1647
|
...(input.tools ?? []).flatMap((tool) => ["--tool", tool]),
|
|
1581
1648
|
...(input.new_chat ? ["--new-chat"] : []),
|
|
1649
|
+
...(input.continue_thread ? ["--continue"] : []),
|
|
1650
|
+
...(input.continue_task !== undefined ? ["--continue-task", input.continue_task] : []),
|
|
1582
1651
|
...(input.allow_model_fallback ? ["--allow-model-fallback"] : []),
|
|
1583
1652
|
"--",
|
|
1584
1653
|
input.prompt
|
|
@@ -1823,6 +1892,54 @@ export function temporaryProjectConflict(input) {
|
|
|
1823
1892
|
return (`--temporary and --project cannot be combined: a temporary chat is never saved, and a chat inside a project is. ` +
|
|
1824
1893
|
`Drop --temporary to send into "${input.explicitProject}", or drop --project to send a throwaway chat.`);
|
|
1825
1894
|
}
|
|
1895
|
+
/**
|
|
1896
|
+
* What a send actually selects, given the per-ask flags and the persisted
|
|
1897
|
+
* defaults.
|
|
1898
|
+
*
|
|
1899
|
+
* Explicit flags beat persisted defaults - except that a saved effort used to
|
|
1900
|
+
* beat an explicit `--model Pro`, because Pro IS the top step of the effort
|
|
1901
|
+
* slider: setting any other step deselects it. So the send ran at the saved
|
|
1902
|
+
* effort, the answer came from a lesser model, and the warning about it told
|
|
1903
|
+
* the caller to clear a saved MODEL default, which was not what overrode
|
|
1904
|
+
* anything. Asking for Pro on the model axis now suppresses a saved effort
|
|
1905
|
+
* exactly as `--effort` suppresses a saved model. A saved pro_mode survives:
|
|
1906
|
+
* it only refines Pro.
|
|
1907
|
+
*
|
|
1908
|
+
* Combining an explicit model with an explicit effort is left alone - that is
|
|
1909
|
+
* the caller saying both out loud, and the picker warns about it.
|
|
1910
|
+
*/
|
|
1911
|
+
export function resolveSelectionAxes(input) {
|
|
1912
|
+
const { explicit } = input;
|
|
1913
|
+
const defaults = input.defaults;
|
|
1914
|
+
const reasoningAxisChosen = explicit.proMode !== undefined || explicit.effort !== undefined;
|
|
1915
|
+
const explicitlyPro = explicit.model !== undefined && namesPro(explicit.model);
|
|
1916
|
+
const model = explicit.model ?? (explicit.effort !== undefined ? undefined : defaults?.model);
|
|
1917
|
+
const proMode = explicit.proMode ?? (reasoningAxisChosen ? undefined : defaults?.pro_mode);
|
|
1918
|
+
const effort = explicit.effort ?? (reasoningAxisChosen || explicitlyPro ? undefined : defaults?.effort);
|
|
1919
|
+
return {
|
|
1920
|
+
...(model !== undefined ? { model } : {}),
|
|
1921
|
+
...(proMode !== undefined ? { proMode } : {}),
|
|
1922
|
+
...(effort !== undefined ? { effort } : {})
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Scrub project names out of text that gets persisted.
|
|
1927
|
+
*
|
|
1928
|
+
* Both names count. A send that CREATES its project names it in exactly the
|
|
1929
|
+
* same failures - the binding refusal, the composer that never appeared, the
|
|
1930
|
+
* sidebar click that missed - and only the requested name was ever scrubbed,
|
|
1931
|
+
* so those records kept a real project name while the equivalent record for an
|
|
1932
|
+
* existing project did not.
|
|
1933
|
+
*/
|
|
1934
|
+
export function redactProjectNames(text, names) {
|
|
1935
|
+
let redacted = text;
|
|
1936
|
+
// Longest first: a project named "Notes" inside "Notes Archive" would
|
|
1937
|
+
// otherwise leave "<project> Archive" behind.
|
|
1938
|
+
for (const name of [...names].filter((name) => Boolean(name)).sort((a, b) => b.length - a.length)) {
|
|
1939
|
+
redacted = redacted.split(name).join("<project>");
|
|
1940
|
+
}
|
|
1941
|
+
return redacted;
|
|
1942
|
+
}
|
|
1826
1943
|
export function browserSendBlockerFromError(error) {
|
|
1827
1944
|
const blocker = typeof error === "object" && error !== null && "blocker" in error ? error.blocker : undefined;
|
|
1828
1945
|
if (typeof blocker === "object" &&
|
package/dist/config.js
CHANGED
|
@@ -208,22 +208,56 @@ export function envBrowserDefaults() {
|
|
|
208
208
|
});
|
|
209
209
|
return (model || project) && freeText.success ? freeText.data : undefined;
|
|
210
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Combine the global env defaults with this repo's.
|
|
213
|
+
*
|
|
214
|
+
* The project is independent of the rest, so the repo's wins that field and
|
|
215
|
+
* the env fills it in. The model and the two reasoning fields are NOT
|
|
216
|
+
* independent: an effort is a step ChatGPT deselects the model to reach, and a
|
|
217
|
+
* pro_mode only refines Pro. Merging those field-by-field built a request
|
|
218
|
+
* neither side asked for - a repo pinning `model: Pro` next to
|
|
219
|
+
* PRODEX_DEFAULT_EFFORT ran at the effort and dropped Pro - so whichever side
|
|
220
|
+
* names the reasoning selection provides all of it.
|
|
221
|
+
*/
|
|
222
|
+
export function mergeBrowserDefaultSources(env, repo) {
|
|
223
|
+
if (!repo && !env)
|
|
224
|
+
return undefined;
|
|
225
|
+
const namesSelection = (defaults) => Boolean(defaults && (defaults.model !== undefined || defaults.pro_mode !== undefined || defaults.effort !== undefined));
|
|
226
|
+
const selection = namesSelection(repo) ? repo : env;
|
|
227
|
+
const merged = {
|
|
228
|
+
...(selection?.model !== undefined ? { model: selection.model } : {}),
|
|
229
|
+
...(selection?.pro_mode !== undefined ? { pro_mode: selection.pro_mode } : {}),
|
|
230
|
+
...(selection?.effort !== undefined ? { effort: selection.effort } : {})
|
|
231
|
+
};
|
|
232
|
+
const project = repo?.project ?? env?.project;
|
|
233
|
+
if (project !== undefined)
|
|
234
|
+
merged.project = project;
|
|
235
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
236
|
+
}
|
|
211
237
|
// Read persisted browser-selection defaults without failing when the local
|
|
212
238
|
// config is absent or unrelated to this cwd (defaults are optional convenience).
|
|
213
|
-
//
|
|
214
|
-
//
|
|
239
|
+
// The repo's config provides the project and, if it names any of them, the
|
|
240
|
+
// reasoning selection; PRODEX_DEFAULT_* env vars are the global fallback so a
|
|
241
|
+
// pinned default project/model applies from any cwd.
|
|
215
242
|
export async function loadBrowserDefaults(cwd) {
|
|
216
243
|
const env = envBrowserDefaults();
|
|
217
244
|
let repo;
|
|
218
245
|
try {
|
|
219
246
|
repo = (await loadLocalConfig(cwd)).browser_defaults;
|
|
220
247
|
}
|
|
221
|
-
catch {
|
|
248
|
+
catch (error) {
|
|
249
|
+
// Having no config is the ordinary case - browser sends work without one -
|
|
250
|
+
// and stays silent. A config that EXISTS and cannot be read is a different
|
|
251
|
+
// thing, and swallowing it was the quiet failure: the defaults it pins
|
|
252
|
+
// stop applying with nothing said, so a consult that should have landed in
|
|
253
|
+
// a project lands in the general chat and looks like it worked.
|
|
254
|
+
if (!isMissingFileError(error)) {
|
|
255
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} Until then prodex will not apply the browser defaults ` +
|
|
256
|
+
`pinned there (project, model), so pass them explicitly if you need to send before fixing it.`, { cause: error });
|
|
257
|
+
}
|
|
222
258
|
repo = undefined;
|
|
223
259
|
}
|
|
224
|
-
|
|
225
|
-
return undefined;
|
|
226
|
-
return { ...(env ?? {}), ...(repo ?? {}) };
|
|
260
|
+
return mergeBrowserDefaultSources(env, repo);
|
|
227
261
|
}
|
|
228
262
|
export function getTokenExpiryStatus(config, now = new Date()) {
|
|
229
263
|
if (!config.token_expires_at) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which conversation a follow-up consult belongs to.
|
|
3
|
+
*
|
|
4
|
+
* "Continue" used to mean "whatever thread the shared browser tab is showing",
|
|
5
|
+
* which is not a conversation anyone named: another session, or a person
|
|
6
|
+
* clicking around, moves it. Worse, a pinned project navigates AWAY from that
|
|
7
|
+
* tab before every send, so on a machine with a default project every consult
|
|
8
|
+
* started a fresh thread while the tool description promised the opposite
|
|
9
|
+
* (measured: two consecutive sends into one project landed in two different
|
|
10
|
+
* /c/ threads).
|
|
11
|
+
*
|
|
12
|
+
* prodex already writes down where each consult landed. That record - not the
|
|
13
|
+
* tab - is what a follow-up should resolve against. Pure on purpose: the
|
|
14
|
+
* reading lives in the command.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The project name as it appears inside a thread URL.
|
|
18
|
+
*
|
|
19
|
+
* Measured: a project named "prodex-smoke-project" answers on
|
|
20
|
+
* `/g/g-p-<id>-prodex-smoke-project/c/<id>`, and "Codex" on
|
|
21
|
+
* `/g/g-p-<id>-codex/c/<id>` - the name lowercased, with runs of anything else
|
|
22
|
+
* collapsed to a single dash.
|
|
23
|
+
*/
|
|
24
|
+
export function chatGptProjectSlug(name) {
|
|
25
|
+
return name
|
|
26
|
+
.trim()
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
29
|
+
.replace(/^-+|-+$/g, "");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether a recorded thread belongs to the project this send is for.
|
|
33
|
+
*
|
|
34
|
+
* A send with no project continues only a thread that belongs to no project,
|
|
35
|
+
* so a follow-up meant for the general chat cannot walk into a project - and a
|
|
36
|
+
* project's follow-up cannot land in another project's conversation.
|
|
37
|
+
*/
|
|
38
|
+
export function threadMatchesProject(threadUrl, project) {
|
|
39
|
+
const projectSegment = /\/g\/(g-p-[^/?#]+)/.exec(threadUrl)?.[1];
|
|
40
|
+
if (!project)
|
|
41
|
+
return projectSegment === undefined;
|
|
42
|
+
if (!projectSegment)
|
|
43
|
+
return false;
|
|
44
|
+
const slug = chatGptProjectSlug(project);
|
|
45
|
+
if (!slug)
|
|
46
|
+
return false;
|
|
47
|
+
// The id comes first and the name follows it, so an exact suffix match keeps
|
|
48
|
+
// "notes" from answering for "notes-archive".
|
|
49
|
+
return projectSegment.toLowerCase().endsWith(`-${slug}`);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The thread a follow-up should continue, or why it cannot be resolved.
|
|
53
|
+
*
|
|
54
|
+
* Naming a task wins over the search, because the caller who names one knows
|
|
55
|
+
* which conversation they mean. Otherwise it is the most recent consult that
|
|
56
|
+
* finished, in this project - fail-closed when there is none, since guessing
|
|
57
|
+
* the conversation is the failure this exists to prevent.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveContinuationThread(input) {
|
|
60
|
+
const withThread = input.consults.filter((consult) => Boolean(consult.thread));
|
|
61
|
+
if (input.taskId) {
|
|
62
|
+
const named = withThread.find((consult) => consult.taskId === input.taskId);
|
|
63
|
+
if (!named) {
|
|
64
|
+
return {
|
|
65
|
+
error: `No recorded consult thread for "${input.taskId}". List what is here with \`prodex pro list\`, ` +
|
|
66
|
+
`or pass the thread itself with --target-url --confirm-target.`
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { target: { taskId: named.taskId, thread: named.thread } };
|
|
70
|
+
}
|
|
71
|
+
const candidates = withThread
|
|
72
|
+
.filter((consult) => consult.status === "done")
|
|
73
|
+
.filter((consult) => threadMatchesProject(consult.thread, input.project))
|
|
74
|
+
.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
|
75
|
+
const latest = candidates[0];
|
|
76
|
+
if (!latest) {
|
|
77
|
+
const where = input.project ? `project "${input.project}"` : "a chat outside any project";
|
|
78
|
+
return {
|
|
79
|
+
error: `No finished consult of this repo has a thread in ${where} to continue. ` +
|
|
80
|
+
`Send once without --continue, or name a consult with --continue-task <task_id>.`
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { target: { taskId: latest.taskId, thread: latest.thread } };
|
|
84
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -162,7 +162,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
162
162
|
const browserConsult = options.browserConsult;
|
|
163
163
|
if (browserConsult) {
|
|
164
164
|
server.registerTool("pro_consult", {
|
|
165
|
-
description: "Ask the user's logged-in ChatGPT (Pro) in the visible browser and wait for the full answer. This drives a real browser send: it can take minutes (Pro extended reasoning), is human-paced, and records a durable receipt under .bridge/. Requires a running `prodex pro browser login` session. By
|
|
165
|
+
description: "Ask the user's logged-in ChatGPT (Pro) in the visible browser and wait for the full answer. This drives a real browser send: it can take minutes (Pro extended reasoning), is human-paced, and records a durable receipt under .bridge/. Requires a running `prodex pro browser login` session. By default the consult continues in whatever thread the browser tab is showing - EXCEPT when a project applies (passed here, or pinned as a saved default), because entering a project starts a new chat in it. To follow up on a previous consult, pass continue_thread:true: it resolves the thread from prodex's own records - the newest finished consult of the same project - instead of trusting the shared tab, and continue_task with a task_id names one exactly. Pass new_chat:true to start a fresh thread for a genuinely new topic. If the thread is still generating a previous answer, the send automatically queues behind it (up to the timeout budget) - long 'tab busy' progress is normal, not stuck. `project` and `model` come from saved defaults (per-repo config, or PRODEX_DEFAULT_PROJECT / PRODEX_DEFAULT_MODEL env vars) when omitted - do NOT pass them per-call unless deliberately overriding. Returns task_id, thread URL, and the answer text.",
|
|
166
166
|
inputSchema: {
|
|
167
167
|
prompt: McpBridgeTextSchema.min(1),
|
|
168
168
|
model: McpShortTextSchema.optional(),
|
|
@@ -187,7 +187,17 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
187
187
|
new_chat: z
|
|
188
188
|
.boolean()
|
|
189
189
|
.optional()
|
|
190
|
-
.describe("Start a fresh thread
|
|
190
|
+
.describe("Start a fresh thread for a new topic."),
|
|
191
|
+
continue_thread: z
|
|
192
|
+
.boolean()
|
|
193
|
+
.optional()
|
|
194
|
+
.describe("Follow up inside the conversation a previous consult is already in, resolved from prodex's records: the newest finished consult of the same project. This is the reliable way to keep a follow-up in one conversation - the tab is shared, and a project default starts a new chat on every send. Fails rather than guessing when this project has no finished consult yet."),
|
|
195
|
+
continue_task: z
|
|
196
|
+
.string()
|
|
197
|
+
.min(1)
|
|
198
|
+
.max(200)
|
|
199
|
+
.optional()
|
|
200
|
+
.describe("Continue one NAMED past consult by its task_id, when the newest one is not the conversation meant."),
|
|
191
201
|
allow_model_fallback: z
|
|
192
202
|
.boolean()
|
|
193
203
|
.optional()
|