@youdie006/prodex 0.34.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.
@@ -2545,6 +2545,143 @@ export function resolveConversationToDelete(conversations, request) {
2545
2545
  * Remove one conversation. ChatGPT deletes a chat by hiding it, which is the
2546
2546
  * same call its own UI makes; the caller is responsible for confirming intent.
2547
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
+ }
2548
2685
  export function deleteConversationExpression(conversationId) {
2549
2686
  return `(async () => {
2550
2687
  let token = "";
package/dist/cli-help.js CHANGED
@@ -274,6 +274,9 @@ export function printProBrowserHelp(stdout, sourceCli) {
274
274
  const chatDeleteUsage = sourceCli
275
275
  ? `${cli} pro browser chat-delete${sourceCliOption} [--title "exact title" | --id <id>] [--confirm-delete] # previews unless --confirm-delete`
276
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";
277
280
  const recoverUsage = sourceCli
278
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`
279
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";
@@ -288,6 +291,7 @@ Commands:
288
291
  ${projectDeleteUsage}
289
292
  ${chatsUsage}
290
293
  ${chatDeleteUsage}
294
+ ${resetUsage}
291
295
  ${askUsage}
292
296
  ${recoverUsage}
293
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, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, deleteChatGptConversation, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, 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,47 @@ 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
+ }
540
593
  if (browserSubcommand === "chats") {
541
594
  if (printProBrowserHelpIfRequested(browserArgs, "pro browser chats", io, { valueFlags: ["--port", "--timeout-ms", "--limit", "--source-cli"] }))
542
595
  return 0;
@@ -639,6 +692,7 @@ export async function runProCommand(rest, io, runCliFn) {
639
692
  "project-delete",
640
693
  "chats",
641
694
  "chat-delete",
695
+ "reset",
642
696
  "recover"
643
697
  ]);
644
698
  }
@@ -1393,7 +1447,60 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1393
1447
  * throws) when recovery does not reach readiness, so the original blocker
1394
1448
  * flow stays intact.
1395
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
+ }
1396
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
+ }
1397
1504
  stderr("recover: browser is not running - launching the dedicated ChatGPT browser (Ctrl+C aborts)...");
1398
1505
  try {
1399
1506
  // Reuse the profile the user last logged in with; launching the default
@@ -1869,8 +1976,14 @@ export async function printProductCheck(store, io, args, configCwd = io.cwd) {
1869
1976
  if (browserStatus) {
1870
1977
  const visibilityBlocker = chatGptVisibilityBlocker(browserStatus.visibilityState, browserStatus.url);
1871
1978
  if (!browserStatus.reachable) {
1872
- io.stdout(`chatgpt: ${browserStatus.blocker?.code ?? "unreachable"} - ${browserStatus.blocker?.message ?? "browser is not reachable"}`);
1873
- 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);
1874
1987
  if (nextStep)
1875
1988
  io.stdout(`next: ${nextStep}`);
1876
1989
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.34.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",