@youdie006/prodex 0.33.0 → 0.35.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 +207 -5
- package/dist/cli-help.js +12 -0
- package/dist/cli-pro.js +175 -3
- package/package.json +1 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -2517,13 +2517,215 @@ export async function navigateChatGptTabTo(url, options = {}) {
|
|
|
2517
2517
|
}
|
|
2518
2518
|
}
|
|
2519
2519
|
/**
|
|
2520
|
-
* Which
|
|
2520
|
+
* Which conversation a delete means - or why it refuses to guess.
|
|
2521
2521
|
*
|
|
2522
|
-
*
|
|
2523
|
-
*
|
|
2524
|
-
*
|
|
2525
|
-
* sharing a name.
|
|
2522
|
+
* Titles are written by ChatGPT and repeat far more often than project names,
|
|
2523
|
+
* so an exact title matching two chats is refused with both ids rather than
|
|
2524
|
+
* resolved by picking the newer one.
|
|
2526
2525
|
*/
|
|
2526
|
+
export function resolveConversationToDelete(conversations, request) {
|
|
2527
|
+
if (request.id) {
|
|
2528
|
+
const byId = conversations.find((conversation) => conversation.id === request.id);
|
|
2529
|
+
return byId ? { ok: true, id: byId.id, title: byId.title } : { ok: false, reason: `No recent conversation has the id ${request.id}.` };
|
|
2530
|
+
}
|
|
2531
|
+
const title = request.title?.trim();
|
|
2532
|
+
if (!title)
|
|
2533
|
+
return { ok: false, reason: "Name the chat to delete with --title, or identify it with --id." };
|
|
2534
|
+
const matches = conversations.filter((conversation) => conversation.title === title);
|
|
2535
|
+
if (matches.length === 0) {
|
|
2536
|
+
return { ok: false, reason: `No recent conversation is titled exactly "${title}". Run \`prodex pro browser chats\` to see them.` };
|
|
2537
|
+
}
|
|
2538
|
+
if (matches.length > 1) {
|
|
2539
|
+
const ids = matches.map((conversation) => conversation.id).join(", ");
|
|
2540
|
+
return { ok: false, reason: `More than one recent conversation is titled "${title}" (${ids}). Pass --id to say which one.` };
|
|
2541
|
+
}
|
|
2542
|
+
return { ok: true, id: matches[0].id, title: matches[0].title };
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* Remove one conversation. ChatGPT deletes a chat by hiding it, which is the
|
|
2546
|
+
* same call its own UI makes; the caller is responsible for confirming intent.
|
|
2547
|
+
*/
|
|
2548
|
+
/**
|
|
2549
|
+
* Processes that ARE the browser prodex launched, from `ps` output.
|
|
2550
|
+
*
|
|
2551
|
+
* prodex only ever asked whether the debug port answered, so a Chrome that
|
|
2552
|
+
* stopped answering read as "not running". Measured on a real machine: such an
|
|
2553
|
+
* instance sat for four days with two renderers pinned near 100% and the
|
|
2554
|
+
* window server burning 75% CPU on its zombie window, and nothing reported it.
|
|
2555
|
+
* The port cannot tell a dead browser from an absent one; the process list can.
|
|
2556
|
+
*/
|
|
2557
|
+
export function findLaunchedBrowserProcesses(psOutput, input) {
|
|
2558
|
+
// `ps -Ao user,pid,command` leads with a user NAME, not a uid.
|
|
2559
|
+
const pidOf = (line) => {
|
|
2560
|
+
const match = /^\s*\S+\s+(\d+)\s/.exec(line);
|
|
2561
|
+
return match ? Number(match[1]) : undefined;
|
|
2562
|
+
};
|
|
2563
|
+
// Mentioning the flag is not being the browser: a shell, an editor, or the
|
|
2564
|
+
// very tool running this scan can carry it on its command line, and this list
|
|
2565
|
+
// is what gets SIGTERM. Caught live - the probe matched its own node process.
|
|
2566
|
+
const isBrowserCommand = (line) => {
|
|
2567
|
+
const command = line.replace(/^\s*\S+\s+\d+\s+/, "");
|
|
2568
|
+
// Only the executable counts, never the arguments: a process that merely
|
|
2569
|
+
// quotes a Chrome path is not Chrome. Everything up to the first flag is
|
|
2570
|
+
// the program, which keeps the spaces macOS puts in "Google Chrome".
|
|
2571
|
+
const executable = command.split(/\s-{1,2}\w/)[0];
|
|
2572
|
+
return /(^|[/\\])(google[ -]?chrome|chromium|chrome)( helper)?( \([^)]*\))?$/i.test(executable.trim());
|
|
2573
|
+
};
|
|
2574
|
+
const lines = psOutput.split(/\r?\n/).filter((line) => !/\bgrep\b/.test(line) && isBrowserCommand(line));
|
|
2575
|
+
// Exactly this port: a plain substring test let port 9 match 9333.
|
|
2576
|
+
const portFlag = new RegExp(`--remote-debugging-port=${input.port}(?!\\d)`);
|
|
2577
|
+
const mains = lines.filter((line) => portFlag.test(line));
|
|
2578
|
+
// The port is the instance's identity. A browser sharing the profile while
|
|
2579
|
+
// listening on another port belongs to someone else, and treating it as ours
|
|
2580
|
+
// made a check against an unused port report a healthy Chrome as wedged.
|
|
2581
|
+
if (mains.length === 0)
|
|
2582
|
+
return [];
|
|
2583
|
+
const helpers = input.profileDir.length > 0 ? lines.filter((line) => line.includes(input.profileDir) && !mains.includes(line)) : [];
|
|
2584
|
+
return [...mains, ...helpers].map(pidOf).filter((pid) => pid !== undefined);
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* A browser that is running but deaf is a different problem from one that is
|
|
2588
|
+
* gone, and it needs a different instruction: relaunching on top of it stacks a
|
|
2589
|
+
* second Chrome on the same profile rather than fixing anything.
|
|
2590
|
+
*/
|
|
2591
|
+
/**
|
|
2592
|
+
* The wedged instance, if there is one: a browser prodex launched that is still
|
|
2593
|
+
* running while its control port answers nothing.
|
|
2594
|
+
*/
|
|
2595
|
+
export function findWedgedBrowser(input = {}) {
|
|
2596
|
+
const port = resolveCdpPort(input.port);
|
|
2597
|
+
const profileDir = input.profileDir ?? defaultChatGptProfileDir();
|
|
2598
|
+
// -A over every user's processes is deliberate: the browser may have been
|
|
2599
|
+
// launched by another shell session than the one asking.
|
|
2600
|
+
const listed = spawnSync("ps", ["-Ao", "user,pid,command"], { encoding: "utf8", timeout: 10_000 });
|
|
2601
|
+
if (listed.status !== 0 || typeof listed.stdout !== "string")
|
|
2602
|
+
return [];
|
|
2603
|
+
return findLaunchedBrowserProcesses(listed.stdout, { port, profileDir });
|
|
2604
|
+
}
|
|
2605
|
+
const realSignals = {
|
|
2606
|
+
kill: (pid, signal) => process.kill(pid, signal),
|
|
2607
|
+
isAlive: (pid) => {
|
|
2608
|
+
try {
|
|
2609
|
+
process.kill(pid, 0);
|
|
2610
|
+
return true;
|
|
2611
|
+
}
|
|
2612
|
+
catch {
|
|
2613
|
+
return false;
|
|
2614
|
+
}
|
|
2615
|
+
},
|
|
2616
|
+
sleep: (ms) => sleep(ms)
|
|
2617
|
+
};
|
|
2618
|
+
/**
|
|
2619
|
+
* End a wedged instance. Callers must have confirmed intent before calling.
|
|
2620
|
+
*
|
|
2621
|
+
* Continue it first: a browser held in a stopped state cannot act on SIGTERM,
|
|
2622
|
+
* so it keeps the profile lock and the replacement launch fails with "no
|
|
2623
|
+
* reachable DevTools endpoint" - measured, on exactly that. Then ask politely,
|
|
2624
|
+
* and only insist on what will not go.
|
|
2625
|
+
*/
|
|
2626
|
+
export async function endWedgedBrowser(pids, signals = realSignals) {
|
|
2627
|
+
const send = (pid, signal) => {
|
|
2628
|
+
try {
|
|
2629
|
+
signals.kill(pid, signal);
|
|
2630
|
+
return true;
|
|
2631
|
+
}
|
|
2632
|
+
catch {
|
|
2633
|
+
return false;
|
|
2634
|
+
}
|
|
2635
|
+
};
|
|
2636
|
+
for (const pid of pids) {
|
|
2637
|
+
send(pid, "SIGCONT");
|
|
2638
|
+
send(pid, "SIGTERM");
|
|
2639
|
+
}
|
|
2640
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
2641
|
+
if (!pids.some((pid) => signals.isAlive(pid)))
|
|
2642
|
+
break;
|
|
2643
|
+
await signals.sleep(500);
|
|
2644
|
+
}
|
|
2645
|
+
for (const pid of pids.filter((pid) => signals.isAlive(pid)))
|
|
2646
|
+
send(pid, "SIGKILL");
|
|
2647
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
2648
|
+
if (!pids.some((pid) => signals.isAlive(pid)))
|
|
2649
|
+
break;
|
|
2650
|
+
await signals.sleep(500);
|
|
2651
|
+
}
|
|
2652
|
+
const failed = pids.filter((pid) => signals.isAlive(pid));
|
|
2653
|
+
return { ended: pids.filter((pid) => !failed.includes(pid)), failed };
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2656
|
+
* What recovery should do when a send finds the browser unreachable.
|
|
2657
|
+
*
|
|
2658
|
+
* Launching is right when the browser is gone and wrong when it is merely deaf:
|
|
2659
|
+
* a second Chrome on the same profile does not replace the wedged one, it joins
|
|
2660
|
+
* it, and the wedged one keeps burning CPU while nobody is looking. That is the
|
|
2661
|
+
* shape of the four-day incident this came from.
|
|
2662
|
+
*/
|
|
2663
|
+
export function browserRecoveryPlan(input) {
|
|
2664
|
+
if (input.reachable)
|
|
2665
|
+
return "reuse";
|
|
2666
|
+
if (input.wedgedPids.length === 0)
|
|
2667
|
+
return "launch";
|
|
2668
|
+
// Reporting a wedged browser is not enough - nobody ran a check for four
|
|
2669
|
+
// days. prodex started this browser and it has stopped answering its own
|
|
2670
|
+
// control port, so clearing it is what a person would do anyway. It waits for
|
|
2671
|
+
// several silent probes first: a browser mid-answer can miss a single poll,
|
|
2672
|
+
// and ending that one would throw away a consult in flight.
|
|
2673
|
+
if (input.confirmedSilent === true && input.autoClearDisabled !== true)
|
|
2674
|
+
return "clear-and-launch";
|
|
2675
|
+
return "reset-first";
|
|
2676
|
+
}
|
|
2677
|
+
export function wedgedBrowserBlocker(pids, port) {
|
|
2678
|
+
return {
|
|
2679
|
+
code: "browser_wedged",
|
|
2680
|
+
message: `The dedicated ChatGPT browser is still running (pid ${pids.join(", ")}) but stopped answering on 127.0.0.1:${port}. A wedged Chrome keeps burning CPU and holding its window, so it will not recover on its own.`,
|
|
2681
|
+
retryable: true,
|
|
2682
|
+
next_step: "Clear it with `prodex pro browser reset --confirm` (it previews first), then run `prodex pro browser login`."
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
export function deleteConversationExpression(conversationId) {
|
|
2686
|
+
return `(async () => {
|
|
2687
|
+
let token = "";
|
|
2688
|
+
try {
|
|
2689
|
+
const session = await fetch("/api/auth/session", { credentials: "include" });
|
|
2690
|
+
if (!session.ok) return { ok: false, reason: "session_http_" + session.status };
|
|
2691
|
+
const parsed = await session.json();
|
|
2692
|
+
token = (parsed && parsed.accessToken) || "";
|
|
2693
|
+
} catch (error) {
|
|
2694
|
+
return { ok: false, reason: "session_error" };
|
|
2695
|
+
}
|
|
2696
|
+
try {
|
|
2697
|
+
const response = await fetch("/backend-api/conversation/" + ${JSON.stringify(conversationId)}, {
|
|
2698
|
+
method: "PATCH",
|
|
2699
|
+
credentials: "include",
|
|
2700
|
+
headers: token ? { Authorization: "Bearer " + token, "Content-Type": "application/json" } : { "Content-Type": "application/json" },
|
|
2701
|
+
body: JSON.stringify({ is_visible: false })
|
|
2702
|
+
});
|
|
2703
|
+
const body = await response.text();
|
|
2704
|
+
if (!response.ok) return { ok: false, reason: "delete_http_" + response.status + " " + body.slice(0, 120) };
|
|
2705
|
+
return { ok: true, reason: "" };
|
|
2706
|
+
} catch (error) {
|
|
2707
|
+
return { ok: false, reason: "delete_error" };
|
|
2708
|
+
}
|
|
2709
|
+
})()`;
|
|
2710
|
+
}
|
|
2711
|
+
/** Remove one conversation. Callers must have confirmed intent before calling. */
|
|
2712
|
+
export async function deleteChatGptConversation(input) {
|
|
2713
|
+
const port = resolveCdpPort(input.port);
|
|
2714
|
+
const page = await findChatGptPage(port, input.timeoutMs ?? 3_000);
|
|
2715
|
+
if (!page.ok || !page.page) {
|
|
2716
|
+
throw new ChatGptBrowserBlockerError(page.blocker ?? {
|
|
2717
|
+
code: "browser_unreachable",
|
|
2718
|
+
message: `No Chrome DevTools endpoint is reachable on 127.0.0.1:${port}.`,
|
|
2719
|
+
retryable: true,
|
|
2720
|
+
next_step: "Run `prodex pro browser login` to reopen the dedicated window, then retry."
|
|
2721
|
+
});
|
|
2722
|
+
}
|
|
2723
|
+
const result = await evaluateOnPage(page.page, deleteConversationExpression(input.conversationId), {
|
|
2724
|
+
timeoutMs: 30_000
|
|
2725
|
+
});
|
|
2726
|
+
if (!result?.ok)
|
|
2727
|
+
throw new Error(`ChatGPT refused to delete the conversation: ${result?.reason ?? "unknown reason"}`);
|
|
2728
|
+
}
|
|
2527
2729
|
export function resolveProjectToDelete(projects, request) {
|
|
2528
2730
|
if (request.id) {
|
|
2529
2731
|
const byId = projects.find((project) => project.id === request.id);
|
package/dist/cli-help.js
CHANGED
|
@@ -268,6 +268,15 @@ export function printProBrowserHelp(stdout, sourceCli) {
|
|
|
268
268
|
const projectDeleteUsage = sourceCli
|
|
269
269
|
? `${cli} pro browser project-delete${sourceCliOption} [--name "exact name" | --id g-p-...] [--confirm-delete] # previews unless --confirm-delete; deleting a project takes its chats with it`
|
|
270
270
|
: `prodex pro browser project-delete [--source-cli /absolute/path/to/dist/cli.js] [--name "exact name" | --id g-p-...] [--confirm-delete] # previews unless --confirm-delete; deleting a project takes its chats with it`;
|
|
271
|
+
const chatsUsage = sourceCli
|
|
272
|
+
? `${cli} pro browser chats${sourceCliOption} [--limit 10] # read-only: recent conversations with their ids`
|
|
273
|
+
: "prodex pro browser chats [--source-cli /absolute/path/to/dist/cli.js] [--limit 10] # read-only: recent conversations with their ids";
|
|
274
|
+
const chatDeleteUsage = sourceCli
|
|
275
|
+
? `${cli} pro browser chat-delete${sourceCliOption} [--title "exact title" | --id <id>] [--confirm-delete] # previews unless --confirm-delete`
|
|
276
|
+
: `prodex pro browser chat-delete [--source-cli /absolute/path/to/dist/cli.js] [--title "exact title" | --id <id>] [--confirm-delete] # previews unless --confirm-delete`;
|
|
277
|
+
const resetUsage = sourceCli
|
|
278
|
+
? `${cli} pro browser reset${sourceCliOption} [--port 9333] [--confirm] # end a browser that runs but stopped answering; previews unless --confirm`
|
|
279
|
+
: "prodex pro browser reset [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--confirm] # end a browser that runs but stopped answering; previews unless --confirm";
|
|
271
280
|
const recoverUsage = sourceCli
|
|
272
281
|
? `${cli} pro browser recover${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--timeout-ms 60000] # fetch a finished answer (deep research reports too) from a thread whose send timed out`
|
|
273
282
|
: "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] # fetch a finished answer (deep research reports too) from a thread whose send timed out";
|
|
@@ -280,6 +289,9 @@ Commands:
|
|
|
280
289
|
${modelsUsage}
|
|
281
290
|
${projectsUsage}
|
|
282
291
|
${projectDeleteUsage}
|
|
292
|
+
${chatsUsage}
|
|
293
|
+
${chatDeleteUsage}
|
|
294
|
+
${resetUsage}
|
|
283
295
|
${askUsage}
|
|
284
296
|
${recoverUsage}
|
|
285
297
|
|
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, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, deleteChatGptProject, listChatGptProjectsWithIds, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
|
|
5
|
+
import { DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, 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 } 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";
|
|
@@ -265,6 +265,18 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
265
265
|
throw new Error(`A ChatGPT browser is already running on port ${port} on ${runningVirtual ? "a virtual display" : "your desktop"}, but ${wantsVirtualDisplay ? "a virtual display" : "your desktop"} was requested. Close it first (\`pkill -f "remote-debugging-port=${port}"\`), then rerun.`);
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
|
+
// A wedged browser is unreachable, so without this login would launch a
|
|
269
|
+
// second Chrome onto the same profile and leave the first one burning
|
|
270
|
+
// CPU - the same mistake the unattended recovery path made. Checked
|
|
271
|
+
// here, after the arguments are validated, so a bad flag still reports
|
|
272
|
+
// as a bad flag.
|
|
273
|
+
if (!alreadyRunning) {
|
|
274
|
+
const wedgedPids = findWedgedBrowser({ port, ...(profileDir ? { profileDir } : {}) });
|
|
275
|
+
if (wedgedPids.length > 0) {
|
|
276
|
+
const wedgedBlocker = wedgedBrowserBlocker(wedgedPids, port);
|
|
277
|
+
throw new Error(`${wedgedBlocker.message} ${wedgedBlocker.next_step}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
268
280
|
const opened = alreadyRunning
|
|
269
281
|
? { profileDir: profileDir ?? defaultChatGptProfileDir(), port }
|
|
270
282
|
: openChatGptBrowser({
|
|
@@ -537,6 +549,104 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
537
549
|
io.stderr(`recovered: answer saved to .bridge; re-print with \`prodex pro latest --cwd ${recoverCwd}\``);
|
|
538
550
|
return 0;
|
|
539
551
|
}
|
|
552
|
+
if (browserSubcommand === "reset") {
|
|
553
|
+
if (printProBrowserHelpIfRequested(browserArgs, "pro browser reset", io, {
|
|
554
|
+
valueFlags: ["--port", "--profile-dir", "--source-cli"],
|
|
555
|
+
booleanFlags: ["--confirm"]
|
|
556
|
+
})) {
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
assertOnlyOptions(browserArgs, "pro browser reset", ["--port", "--profile-dir", "--source-cli"], ["--confirm"]);
|
|
560
|
+
const resetPort = readPortFlag(browserArgs, "--port");
|
|
561
|
+
const resetProfileDir = readFlag(browserArgs, "--profile-dir");
|
|
562
|
+
const reachable = (await getChatGptBrowserStatus({ ...(resetPort !== undefined ? { port: resetPort } : {}), timeoutMs: 2_000 }))
|
|
563
|
+
.reachable;
|
|
564
|
+
const pids = findWedgedBrowser({
|
|
565
|
+
...(resetPort !== undefined ? { port: resetPort } : {}),
|
|
566
|
+
...(resetProfileDir ? { profileDir: resetProfileDir } : {})
|
|
567
|
+
});
|
|
568
|
+
if (pids.length === 0) {
|
|
569
|
+
io.stdout("No browser launched by prodex is running; nothing to reset.");
|
|
570
|
+
return 0;
|
|
571
|
+
}
|
|
572
|
+
// A browser that still answers is doing its job - ending it would take
|
|
573
|
+
// an in-flight consult with it.
|
|
574
|
+
if (reachable) {
|
|
575
|
+
io.stdout(`The dedicated browser is running and answering (pid ${pids.join(", ")}). Nothing was ended.`);
|
|
576
|
+
io.stdout("Close it yourself if you meant to, or run this again once it stops responding.");
|
|
577
|
+
return 0;
|
|
578
|
+
}
|
|
579
|
+
if (!browserArgs.includes("--confirm")) {
|
|
580
|
+
io.stdout(`Would end the wedged browser: pid ${pids.join(", ")}.`);
|
|
581
|
+
io.stdout("It is not answering its control port, so nothing in flight is lost; the profile and login stay on disk.");
|
|
582
|
+
io.stdout("Nothing was ended. Re-run with --confirm to go ahead.");
|
|
583
|
+
return 0;
|
|
584
|
+
}
|
|
585
|
+
const outcome = await endWedgedBrowser(pids);
|
|
586
|
+
if (outcome.ended.length > 0)
|
|
587
|
+
io.stdout(`ended wedged browser: pid ${outcome.ended.join(", ")}`);
|
|
588
|
+
if (outcome.failed.length > 0)
|
|
589
|
+
io.stdout(`could not end: pid ${outcome.failed.join(", ")} (try again from the account that started it)`);
|
|
590
|
+
io.stdout("Run `prodex pro browser login` to start a fresh one; it reuses the same profile.");
|
|
591
|
+
return 0;
|
|
592
|
+
}
|
|
593
|
+
if (browserSubcommand === "chats") {
|
|
594
|
+
if (printProBrowserHelpIfRequested(browserArgs, "pro browser chats", io, { valueFlags: ["--port", "--timeout-ms", "--limit", "--source-cli"] }))
|
|
595
|
+
return 0;
|
|
596
|
+
assertOnlyOptions(browserArgs, "pro browser chats", ["--port", "--timeout-ms", "--limit", "--source-cli"]);
|
|
597
|
+
const chatsPort = readPortFlag(browserArgs, "--port");
|
|
598
|
+
const chatsTimeoutMs = readPositiveIntegerFlag(browserArgs, "--timeout-ms");
|
|
599
|
+
const chatsLimit = readPositiveIntegerFlag(browserArgs, "--limit");
|
|
600
|
+
const chats = await listRecentChatGptConversations({
|
|
601
|
+
...(chatsPort !== undefined ? { port: chatsPort } : {}),
|
|
602
|
+
...(chatsTimeoutMs !== undefined ? { timeoutMs: chatsTimeoutMs } : {}),
|
|
603
|
+
...(chatsLimit !== undefined ? { limit: chatsLimit } : {})
|
|
604
|
+
});
|
|
605
|
+
if (chats.length === 0) {
|
|
606
|
+
io.stdout("No recent conversations were readable.");
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
609
|
+
io.stdout("Recent ChatGPT conversations (newest first):");
|
|
610
|
+
for (const chat of chats)
|
|
611
|
+
io.stdout(` ${chat.title} ${chat.id}`);
|
|
612
|
+
io.stdout("Delete one with `pro browser chat-delete --id <id> --confirm-delete`.");
|
|
613
|
+
return 0;
|
|
614
|
+
}
|
|
615
|
+
if (browserSubcommand === "chat-delete") {
|
|
616
|
+
if (printProBrowserHelpIfRequested(browserArgs, "pro browser chat-delete", io, {
|
|
617
|
+
valueFlags: ["--port", "--timeout-ms", "--title", "--id", "--limit", "--source-cli"],
|
|
618
|
+
booleanFlags: ["--confirm-delete"]
|
|
619
|
+
})) {
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
622
|
+
assertOnlyOptions(browserArgs, "pro browser chat-delete", ["--port", "--timeout-ms", "--title", "--id", "--limit", "--source-cli"], ["--confirm-delete"]);
|
|
623
|
+
const chatDeletePort = readPortFlag(browserArgs, "--port");
|
|
624
|
+
const chatDeleteTimeoutMs = readPositiveIntegerFlag(browserArgs, "--timeout-ms");
|
|
625
|
+
const chatDeleteLimit = readPositiveIntegerFlag(browserArgs, "--limit");
|
|
626
|
+
const chats = await listRecentChatGptConversations({
|
|
627
|
+
...(chatDeletePort !== undefined ? { port: chatDeletePort } : {}),
|
|
628
|
+
...(chatDeleteTimeoutMs !== undefined ? { timeoutMs: chatDeleteTimeoutMs } : {}),
|
|
629
|
+
limit: chatDeleteLimit ?? 40
|
|
630
|
+
});
|
|
631
|
+
const chatTarget = resolveConversationToDelete(chats, {
|
|
632
|
+
...(readFlag(browserArgs, "--title") !== undefined ? { title: readFlag(browserArgs, "--title") } : {}),
|
|
633
|
+
...(readFlag(browserArgs, "--id") !== undefined ? { id: readFlag(browserArgs, "--id") } : {})
|
|
634
|
+
});
|
|
635
|
+
if (!chatTarget.ok)
|
|
636
|
+
throw new Error(chatTarget.reason);
|
|
637
|
+
if (!browserArgs.includes("--confirm-delete")) {
|
|
638
|
+
io.stdout(`Would delete the conversation "${chatTarget.title}" (${chatTarget.id}).`);
|
|
639
|
+
io.stdout("Nothing was deleted. Re-run with --confirm-delete to go ahead.");
|
|
640
|
+
return 0;
|
|
641
|
+
}
|
|
642
|
+
await deleteChatGptConversation({
|
|
643
|
+
conversationId: chatTarget.id,
|
|
644
|
+
...(chatDeletePort !== undefined ? { port: chatDeletePort } : {}),
|
|
645
|
+
...(chatDeleteTimeoutMs !== undefined ? { timeoutMs: chatDeleteTimeoutMs } : {})
|
|
646
|
+
});
|
|
647
|
+
io.stdout(`deleted conversation "${chatTarget.title}" (${chatTarget.id})`);
|
|
648
|
+
return 0;
|
|
649
|
+
}
|
|
540
650
|
if (browserSubcommand === "project-delete") {
|
|
541
651
|
if (printProBrowserHelpIfRequested(browserArgs, "pro browser project-delete", io, {
|
|
542
652
|
valueFlags: ["--cwd", "--port", "--timeout-ms", "--name", "--id", "--source-cli"],
|
|
@@ -580,6 +690,9 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
580
690
|
"models",
|
|
581
691
|
"projects",
|
|
582
692
|
"project-delete",
|
|
693
|
+
"chats",
|
|
694
|
+
"chat-delete",
|
|
695
|
+
"reset",
|
|
583
696
|
"recover"
|
|
584
697
|
]);
|
|
585
698
|
}
|
|
@@ -1334,7 +1447,60 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
|
|
|
1334
1447
|
* throws) when recovery does not reach readiness, so the original blocker
|
|
1335
1448
|
* flow stays intact.
|
|
1336
1449
|
*/
|
|
1450
|
+
/**
|
|
1451
|
+
* Is the control port really dead, or was that one unlucky poll?
|
|
1452
|
+
*
|
|
1453
|
+
* A browser writing a long answer can miss a probe. Ending that one would throw
|
|
1454
|
+
* away a consult in flight, so silence has to hold across several tries before
|
|
1455
|
+
* anything gets killed.
|
|
1456
|
+
*/
|
|
1457
|
+
async function confirmBrowserSilence(port, probes = 3, gapMs = 2_000) {
|
|
1458
|
+
for (let attempt = 0; attempt < probes; attempt += 1) {
|
|
1459
|
+
if (attempt > 0)
|
|
1460
|
+
await sleep(gapMs);
|
|
1461
|
+
const status = await getChatGptBrowserStatus({ ...(port !== undefined ? { port } : {}), timeoutMs: 2_000 });
|
|
1462
|
+
if (status.reachable)
|
|
1463
|
+
return false;
|
|
1464
|
+
}
|
|
1465
|
+
return true;
|
|
1466
|
+
}
|
|
1467
|
+
function autoClearDisabledByEnv(env = process.env) {
|
|
1468
|
+
const raw = (env.PRODEX_NO_AUTO_CLEAR ?? "").trim().toLowerCase();
|
|
1469
|
+
return raw === "1" || raw === "true" || raw === "yes";
|
|
1470
|
+
}
|
|
1337
1471
|
export async function attemptBrowserAutoRecovery(stderr, options) {
|
|
1472
|
+
// Launching is right when the browser is gone and wrong when it is only deaf:
|
|
1473
|
+
// a second Chrome on the same profile joins the wedged one rather than
|
|
1474
|
+
// replacing it, and the wedged one keeps burning CPU while nobody looks. This
|
|
1475
|
+
// runs unattended for agents, so it is the path that let four days pass.
|
|
1476
|
+
const wedged = findWedgedBrowser({ ...(options.port !== undefined ? { port: options.port } : {}) });
|
|
1477
|
+
if (wedged.length > 0) {
|
|
1478
|
+
// Confirm the silence before ending anything: one missed poll is a busy
|
|
1479
|
+
// browser, several in a row is a dead one.
|
|
1480
|
+
const confirmedSilent = await confirmBrowserSilence(options.port);
|
|
1481
|
+
const plan = browserRecoveryPlan({
|
|
1482
|
+
reachable: false,
|
|
1483
|
+
wedgedPids: wedged,
|
|
1484
|
+
confirmedSilent,
|
|
1485
|
+
autoClearDisabled: autoClearDisabledByEnv()
|
|
1486
|
+
});
|
|
1487
|
+
if (plan === "reset-first") {
|
|
1488
|
+
const blocker = wedgedBrowserBlocker(wedged, resolveCdpPort(options.port));
|
|
1489
|
+
stderr(`recover: ${blocker.message}`);
|
|
1490
|
+
stderr(`recover: ${blocker.next_step}`);
|
|
1491
|
+
return false;
|
|
1492
|
+
}
|
|
1493
|
+
stderr(`recover: the browser stopped answering; ending it (pid ${wedged.join(", ")}) and starting a fresh one...`);
|
|
1494
|
+
await endWedgedBrowser(wedged);
|
|
1495
|
+
// Wait for the profile lock to actually clear rather than guessing at a
|
|
1496
|
+
// delay: the replacement launch fails outright if the old process still
|
|
1497
|
+
// holds it, which is how the first self-heal attempt ended.
|
|
1498
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
1499
|
+
if (findWedgedBrowser({ ...(options.port !== undefined ? { port: options.port } : {}) }).length === 0)
|
|
1500
|
+
break;
|
|
1501
|
+
await sleep(1_000);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1338
1504
|
stderr("recover: browser is not running - launching the dedicated ChatGPT browser (Ctrl+C aborts)...");
|
|
1339
1505
|
try {
|
|
1340
1506
|
// Reuse the profile the user last logged in with; launching the default
|
|
@@ -1810,8 +1976,14 @@ export async function printProductCheck(store, io, args, configCwd = io.cwd) {
|
|
|
1810
1976
|
if (browserStatus) {
|
|
1811
1977
|
const visibilityBlocker = chatGptVisibilityBlocker(browserStatus.visibilityState, browserStatus.url);
|
|
1812
1978
|
if (!browserStatus.reachable) {
|
|
1813
|
-
|
|
1814
|
-
|
|
1979
|
+
// An unreachable port has two very different causes, and prodex used to
|
|
1980
|
+
// report both as "not running": the browser really is gone, or it is still
|
|
1981
|
+
// there and has stopped answering. The second keeps burning CPU until
|
|
1982
|
+
// somebody notices, and nobody notices a message that says it is absent.
|
|
1983
|
+
const wedged = findWedgedBrowser({ ...(browserCommandOptions.port !== undefined ? { port: browserCommandOptions.port } : {}) });
|
|
1984
|
+
const blocker = wedged.length > 0 ? wedgedBrowserBlocker(wedged, browserCommandOptions.port ?? DEFAULT_CDP_PORT) : browserStatus.blocker;
|
|
1985
|
+
io.stdout(`chatgpt: ${blocker?.code ?? "unreachable"} - ${blocker?.message ?? "browser is not reachable"}`);
|
|
1986
|
+
const nextStep = productCheckBrowserNextStep(blocker?.next_step, sourceCli, browserCommandOptions);
|
|
1815
1987
|
if (nextStep)
|
|
1816
1988
|
io.stdout(`next: ${nextStep}`);
|
|
1817
1989
|
}
|