@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.
@@ -2517,13 +2517,215 @@ export async function navigateChatGptTabTo(url, options = {}) {
2517
2517
  }
2518
2518
  }
2519
2519
  /**
2520
- * Which project a delete means - or why it refuses to guess.
2520
+ * Which conversation a delete means - or why it refuses to guess.
2521
2521
  *
2522
- * Deleting a project is not undoable from here, so the request has to identify
2523
- * exactly one. Names are matched exactly (a typo must not delete a neighbour),
2524
- * and an id is accepted for the case this account actually has: two projects
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
- io.stdout(`chatgpt: ${browserStatus.blocker?.code ?? "unreachable"} - ${browserStatus.blocker?.message ?? "browser is not reachable"}`);
1814
- const nextStep = productCheckBrowserNextStep(browserStatus.blocker?.next_step, sourceCli, browserCommandOptions);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",