@youdie006/prodex 0.16.13 → 0.16.14

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.
@@ -0,0 +1,79 @@
1
+ import { mkdir, open, readFile, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ // One visible-browser send at a time per machine: the dedicated Chrome is a
5
+ // single shared tab, and two concurrent prodex clients interleave composer
6
+ // input and navigation, silently cross-contaminating each other's threads
7
+ // (measured live: one client's token prompt landed inside the other client's
8
+ // consult thread, and a 15-minute consult never actually posted).
9
+ function lockPath() {
10
+ const override = process.env.PRODEX_SEND_LOCK_FILE;
11
+ if (override)
12
+ return override;
13
+ return path.join(os.homedir(), ".local", "share", "prodex", "browser-send.lock");
14
+ }
15
+ function holderIsAlive(pid) {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ async function readHolderPid(file) {
25
+ try {
26
+ const parsed = JSON.parse(await readFile(file, "utf8"));
27
+ return typeof parsed.pid === "number" ? parsed.pid : undefined;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ async function tryAcquire(file) {
34
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
35
+ try {
36
+ const handle = await open(file, "wx", 0o600);
37
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() })}\n`);
38
+ await handle.close();
39
+ return true;
40
+ }
41
+ catch (error) {
42
+ if (error.code !== "EEXIST")
43
+ throw error;
44
+ return false;
45
+ }
46
+ }
47
+ /**
48
+ * Serialize visible-browser sends across processes. Waits up to waitMs for a
49
+ * live holder to finish (0 = fail fast); a lock whose holder process is dead
50
+ * is reaped immediately.
51
+ */
52
+ export async function withBrowserSendLock(waitMs, onWait, fn) {
53
+ const file = lockPath();
54
+ const deadline = Date.now() + Math.max(0, waitMs);
55
+ let waited = false;
56
+ for (;;) {
57
+ if (await tryAcquire(file))
58
+ break;
59
+ const holder = await readHolderPid(file);
60
+ if (holder === undefined || !holderIsAlive(holder)) {
61
+ await rm(file, { force: true }).catch(() => undefined);
62
+ continue;
63
+ }
64
+ if (Date.now() >= deadline) {
65
+ throw new Error(`Another prodex browser send is in progress (pid ${holder}). Wait for it to finish, or pass --busy-wait-ms to queue behind it.`);
66
+ }
67
+ if (!waited) {
68
+ waited = true;
69
+ onWait(`another prodex send holds the browser (pid ${holder}); waiting`);
70
+ }
71
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
72
+ }
73
+ try {
74
+ return await fn();
75
+ }
76
+ finally {
77
+ await rm(file, { force: true }).catch(() => undefined);
78
+ }
79
+ }
package/dist/cli-pro.js CHANGED
@@ -8,6 +8,7 @@ import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledge
8
8
  import { formatBrowserDefaults, redactServerUrl } from "./cli-server.js";
9
9
  import { errorMessage, firstLine, formatBlockedConsultRecordedMessage, formatProLatestCommand, formatBrowserCheckCommand, formatBrowserLoginCommand, formatBrowserSmokeCommand, formatBrowserTargetAskCommand, formatInitCommand, formatSetupCommand, isMissingFileError, computeSendPacingWaitMs, isUntrustedResultError, resolveMinSendIntervalMs, sourceAwareBrowserBlocker, sourceAwareBrowserNextStep, sourceAwareResultError, sourceAwareResultMessage, sourceAwareSetupMessage } from "./cli-shared.js";
10
10
  import { getTokenExpiryStatus, loadBrowserDefaults, loadLocalConfig } from "./config.js";
11
+ import { withBrowserSendLock } from "./browser-send-lock.js";
11
12
  import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
12
13
  export async function runChatgptCommand(rest, io) {
13
14
  const [subcommand, ...chatgptArgs] = rest;
@@ -80,12 +81,12 @@ export async function runChatgptCommand(rest, io) {
80
81
  };
81
82
  let result;
82
83
  try {
83
- result = await sendChatGptPrompt({
84
+ result = await withBrowserSendLock(0, (detail) => io.stderr(`progress: ${detail}`), () => sendChatGptPrompt({
84
85
  port,
85
86
  prompt: smokePrompt,
86
87
  timeoutMs,
87
88
  onProgress: createBrowserSendProgressPrinter(io.stderr)
88
- });
89
+ }));
89
90
  }
90
91
  catch (error) {
91
92
  const blocker = sourceAwareBrowserBlocker(browserSendBlockerFromError(error), sourceCli, commandOptions);
@@ -655,7 +656,7 @@ export async function runAskProCommand(rest, io) {
655
656
  }
656
657
  throw new Error(formatBlockedConsultRecordedMessage(blocker.message, task.id, sourceCli, { cwd: targetCwd }));
657
658
  }
658
- const sendOnce = () => sendChatGptPrompt({
659
+ const sendOnce = () => withBrowserSendLock(busyWaitMs ?? 0, (detail) => io.stderr(`progress: ${detail}`), () => sendChatGptPrompt({
659
660
  port: browserPort,
660
661
  prompt: bundle.text,
661
662
  targetUrl: normalizedTargetUrl,
@@ -668,7 +669,7 @@ export async function runAskProCommand(rest, io) {
668
669
  proMode: selectionProMode,
669
670
  effort: selectionEffort,
670
671
  onProgress: createBrowserSendProgressPrinter(io.stderr)
671
- });
672
+ }));
672
673
  // One-command recovery: interactive terminals (or explicit --auto-login)
673
674
  // launch the dedicated browser and retry once when no browser runs.
674
675
  // Scripts and agents keep the plain blocker unless they opt in.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.16.13",
3
+ "version": "0.16.14",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",