@youdie006/prodex 0.16.20 → 0.16.21

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.
@@ -1,4 +1,4 @@
1
- import { mkdir, open, readFile, rm } from "node:fs/promises";
1
+ import { link, mkdir, open, readFile, rm } 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
@@ -17,25 +17,69 @@ function holderIsAlive(pid) {
17
17
  process.kill(pid, 0);
18
18
  return true;
19
19
  }
20
- catch {
21
- return false;
20
+ catch (error) {
21
+ // EPERM: the process exists but is owned by another user -> alive. Only
22
+ // ESRCH (no such process) means the holder is truly dead and reapable.
23
+ return error.code === "EPERM";
22
24
  }
23
25
  }
24
- async function readHolderPid(file) {
26
+ async function readHolder(file) {
25
27
  try {
26
28
  const parsed = JSON.parse(await readFile(file, "utf8"));
27
- return typeof parsed.pid === "number" ? parsed.pid : undefined;
29
+ return {
30
+ pid: typeof parsed.pid === "number" ? parsed.pid : undefined,
31
+ started_at: typeof parsed.started_at === "string" ? parsed.started_at : undefined
32
+ };
28
33
  }
29
34
  catch {
30
35
  return undefined;
31
36
  }
32
37
  }
38
+ // A holder whose process is alive but has held the lock far longer than any real
39
+ // send (default 60 min, well beyond the 15-min Pro timeout) is treated as wedged
40
+ // and reapable, so a hung browser cannot block every send on the machine forever.
41
+ // Deliberately generous so it never reaps a genuinely in-flight send; override
42
+ // with PRODEX_SEND_LOCK_STALE_MS.
43
+ function staleMs() {
44
+ const raw = Number(process.env.PRODEX_SEND_LOCK_STALE_MS);
45
+ return Number.isFinite(raw) && raw > 0 ? raw : 3_600_000;
46
+ }
47
+ function holderIsStale(startedAt) {
48
+ if (!startedAt)
49
+ return false;
50
+ const started = Date.parse(startedAt);
51
+ if (Number.isNaN(started))
52
+ return false;
53
+ return Date.now() - started > staleMs();
54
+ }
55
+ // Release only our own lock: if ours was already reaped (e.g. as stale) and a
56
+ // new holder took over, we must not delete their lock on the way out.
57
+ async function releaseIfOwned(file) {
58
+ const holder = await readHolder(file);
59
+ if (holder?.pid === process.pid) {
60
+ await rm(file, { force: true }).catch(() => undefined);
61
+ }
62
+ }
63
+ let acquireSeq = 0;
33
64
  async function tryAcquire(file) {
34
65
  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
66
+ // Publish atomically: write the pid into a temp file, then hard-link it into
67
+ // place. link() fails with EEXIST when a holder already exists (our exclusivity
68
+ // check) and, unlike open("wx")+writeFile, the lock file is never observed
69
+ // empty - so a concurrent waiter can never mistake a mid-publish lock for a
70
+ // dead one and reap it out from under us (which let two clients send at once).
71
+ // The temp name is unique per acquisition (pid + seq) so two concurrent
72
+ // same-process acquires never share a temp inode and truncate each other.
73
+ const temp = `${file}.${process.pid}.${(acquireSeq += 1)}.tmp`;
74
+ const handle = await open(temp, "w", 0o600);
35
75
  try {
36
- const handle = await open(file, "wx", 0o600);
37
76
  await handle.writeFile(`${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() })}\n`);
77
+ }
78
+ finally {
38
79
  await handle.close();
80
+ }
81
+ try {
82
+ await link(temp, file);
39
83
  return true;
40
84
  }
41
85
  catch (error) {
@@ -43,6 +87,9 @@ async function tryAcquire(file) {
43
87
  throw error;
44
88
  return false;
45
89
  }
90
+ finally {
91
+ await rm(temp, { force: true }).catch(() => undefined);
92
+ }
46
93
  }
47
94
  /**
48
95
  * Serialize visible-browser sends across processes. Waits up to waitMs for a
@@ -56,17 +103,23 @@ export async function withBrowserSendLock(waitMs, onWait, fn) {
56
103
  for (;;) {
57
104
  if (await tryAcquire(file))
58
105
  break;
59
- const holder = await readHolderPid(file);
60
- if (holder === undefined || !holderIsAlive(holder)) {
61
- await rm(file, { force: true }).catch(() => undefined);
106
+ const holder = await readHolder(file);
107
+ const reapable = holder === undefined || holder.pid === undefined || !holderIsAlive(holder.pid) || holderIsStale(holder.started_at);
108
+ if (reapable) {
109
+ // Re-verify the same holder still owns the file before removing it, so we
110
+ // don't delete a fresh lock another reaper just acquired in between.
111
+ const current = await readHolder(file);
112
+ if (current?.pid === holder?.pid) {
113
+ await rm(file, { force: true }).catch(() => undefined);
114
+ }
62
115
  continue;
63
116
  }
64
117
  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.`);
118
+ throw new Error(`Another prodex browser send is in progress (pid ${holder.pid}). Wait for it to finish, or pass --busy-wait-ms to queue behind it.`);
66
119
  }
67
120
  if (!waited) {
68
121
  waited = true;
69
- onWait(`another prodex send holds the browser (pid ${holder}); waiting`);
122
+ onWait(`another prodex send holds the browser (pid ${holder.pid}); waiting`);
70
123
  }
71
124
  await new Promise((resolve) => setTimeout(resolve, 2_000));
72
125
  }
@@ -74,6 +127,6 @@ export async function withBrowserSendLock(waitMs, onWait, fn) {
74
127
  return await fn();
75
128
  }
76
129
  finally {
77
- await rm(file, { force: true }).catch(() => undefined);
130
+ await releaseIfOwned(file);
78
131
  }
79
132
  }
@@ -892,17 +892,49 @@ export function proSubmenuExpanderRectExpression() {
892
892
  return clickPoint(best);
893
893
  })()`;
894
894
  }
895
+ // The sidebar project option button's aria-label wraps the project name:
896
+ // English "Open project options for <name>", Korean "<name> 프로젝트 옵션 열기".
897
+ export function projectOptionButtonName(ariaLabel) {
898
+ return (ariaLabel || "").replace(/^open project options for /i, "").replace(/\s*프로젝트 옵션 열기$/, "").trim();
899
+ }
900
+ // Resolve which sidebar project button matches `wanted`, mirroring the in-page
901
+ // logic in projectItemRectExpression: exact name match first, then a unique
902
+ // case-insensitive match; ambiguity refuses rather than guessing. Matching by
903
+ // EQUALITY (not substring) is what keeps "Codex" from selecting "Codex Review".
904
+ // Returns the matched index, -1 when not found, or "ambiguous".
905
+ export function matchProjectOptionName(ariaLabels, wanted) {
906
+ const names = ariaLabels.map(projectOptionButtonName);
907
+ const exact = names.flatMap((n, i) => (n === wanted ? [i] : []));
908
+ if (exact.length === 1)
909
+ return exact[0];
910
+ if (exact.length > 1)
911
+ return "ambiguous";
912
+ const ci = names.flatMap((n, i) => (n.toLowerCase() === wanted.toLowerCase() ? [i] : []));
913
+ if (ci.length === 1)
914
+ return ci[0];
915
+ if (ci.length > 1)
916
+ return "ambiguous";
917
+ return -1;
918
+ }
895
919
  export function projectItemRectExpression(name) {
896
920
  return `(() => {${CLICK_POINT_SNIPPET}
897
921
  // Korean: "<name> 프로젝트 옵션 열기"; English: "Open project options for <name>".
898
922
  const wanted = ${JSON.stringify(name)};
923
+ // Extract the project name from the aria-label wrapper, then match by
924
+ // EQUALITY (mirror of matchProjectOptionName). A bare .includes() let
925
+ // "Codex" select "Codex Review" (substring) and silently sent the prompt
926
+ // into the wrong project.
927
+ const projName = (b) => (b.getAttribute("aria-label") || "").replace(/^open project options for /i, "").replace(/\\s*프로젝트 옵션 열기$/, "").trim();
899
928
  const optionButtons = [...document.querySelectorAll('[aria-label*="프로젝트 옵션"],[aria-label*="project options" i]')];
900
- let opt = optionButtons.find((b) => (b.getAttribute("aria-label") || "").includes(wanted));
901
- if (!opt) {
929
+ let opt = null;
930
+ const exact = optionButtons.filter((b) => projName(b) === wanted);
931
+ if (exact.length === 1) opt = exact[0];
932
+ else if (exact.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects; rename one to disambiguate" };
933
+ else {
902
934
  // Case-insensitive fallback: sidebar names are user-typed ("Codex") and
903
935
  // an agent asking for "codex" should still resolve when unambiguous.
904
936
  // Ambiguity fails loudly rather than guessing.
905
- const ci = optionButtons.filter((b) => (b.getAttribute("aria-label") || "").toLowerCase().includes(wanted.toLowerCase()));
937
+ const ci = optionButtons.filter((b) => projName(b).toLowerCase() === wanted.toLowerCase());
906
938
  if (ci.length === 1) opt = ci[0];
907
939
  else if (ci.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects case-insensitively; use the exact name" };
908
940
  }
@@ -1201,8 +1233,12 @@ async function selectProject(cdp, options) {
1201
1233
  const alreadyInRequestedProject = await cdp.evaluate(`(() => {
1202
1234
  if (!/^https:\\/\\/chatgpt\\.com\\/g\\/g-p-/.test(location.href)) return false;
1203
1235
  const name = ${JSON.stringify(options.project)};
1204
- if ((document.title || "").includes(name)) return true;
1205
- return [...document.querySelectorAll('h1,[role="heading"]')].some((h) => (h.innerText || "").includes(name));
1236
+ // Equality, not substring: a stalled cross-project navigation must not
1237
+ // be accepted just because the current project's name CONTAINS the
1238
+ // requested one (e.g. sitting on "Research Lab" while "Research" was
1239
+ // requested), which would silently send into the wrong project.
1240
+ if ((document.title || "").trim() === name) return true;
1241
+ return [...document.querySelectorAll('h1,[role="heading"]')].some((h) => (h.innerText || "").trim() === name);
1206
1242
  })()`);
1207
1243
  if (!alreadyInRequestedProject) {
1208
1244
  throw new Error(`Clicking project "${options.project}" did not navigate the visible tab. If the tab is already inside this project, omit --project and retry.`);
package/dist/cli-args.js CHANGED
@@ -263,6 +263,9 @@ export const ASK_PRO_PREVIEW_VALUE_FLAGS = new Set([
263
263
  "--file",
264
264
  "--port",
265
265
  "--timeout-ms",
266
+ // Accepted here only so the preview path can reject it with the friendly
267
+ // "only applies when sending" guidance instead of a raw "Unknown option".
268
+ "--busy-wait-ms",
266
269
  "--target-url",
267
270
  ...ASK_PRO_SELECTION_VALUE_FLAGS
268
271
  ]);
package/dist/cli-help.js CHANGED
@@ -174,7 +174,7 @@ Use \`prodex pro ask\` for dry-run/manual previews.
174
174
  Use \`prodex pro browser ask\` only when you want an explicit visible-browser send.
175
175
  Model/project selection (visible-browser send):
176
176
  --model "label" Pick the composer model by its exact menu label (verified: Pro). Submenu models (e.g. the GPT-5.6 Sol variants) are rejected for now.
177
- --pro-mode 기본 | 확장 Pro sub-mode (only when the model is Pro); 확장 raises the default timeout to 300000 ms
177
+ --pro-mode 기본 | 확장 Pro sub-mode (only when the model is Pro); a Pro selection raises the default timeout to 900000 ms
178
178
  --effort 즉시|중간|높음|매우 높음 Reasoning effort (aliases: instant/medium/high/max); picking one deselects Pro
179
179
  --project "name" Enter an existing sidebar project first (cannot combine with --target-url)
180
180
  Labels are matched in both the Korean and English (US) UI; run \`prodex pro browser models\` to list what your account shows.
@@ -268,7 +268,7 @@ Commands:
268
268
  Visible-browser sends require a manual browser session and stop on login, captcha, Cloudflare, permission, rate-limit, or usage-limit blockers.
269
269
  Model/project selection (ask):
270
270
  --model Composer model to pick by its exact menu label (verified: Pro). Models whose menu entry opens a submenu of variants are rejected with a clear error for now.
271
- --pro-mode Pro sub-mode: 기본 (standard) or 확장 (extended), used when the model is Pro. 확장 raises the default --timeout-ms to 300000.
271
+ --pro-mode Pro sub-mode: 기본 (standard) or 확장 (extended), used when the model is Pro. A Pro selection raises the default --timeout-ms to 900000.
272
272
  --effort Reasoning effort: 즉시 / 중간 / 높음 / 매우 높음 (aliases: instant/medium/high/max). Picking an effort switches the composer to the standard reasoning model, deselecting Pro.
273
273
  --project Enter an existing sidebar project before sending. Cannot be combined with --target-url.
274
274
  --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).
package/dist/cli-pro.js CHANGED
@@ -703,12 +703,25 @@ export async function runAskProCommand(rest, io) {
703
703
  catch (error) {
704
704
  const blocker = sourceAwareBrowserBlocker(browserSendBlockerFromError(error), sourceCli, browserCommandOptions);
705
705
  const message = blocker.next_step ? `${blocker.message} Next: ${blocker.next_step}` : errorMessage(error);
706
+ // The blocker text can quote the requested/default project name (e.g. a
707
+ // "project not found" error). Local stdout/stderr keep it (useful to the
708
+ // operator), but the persisted task/session cross the MCP boundary, so
709
+ // scrub the project name there the same way provenance.project is redacted.
710
+ const redactProject = (text) => {
711
+ const name = selectionMetadata.project;
712
+ return name ? text.split(name).join("<project>") : text;
713
+ };
714
+ const persistedBlocker = {
715
+ ...blocker,
716
+ message: redactProject(blocker.message),
717
+ ...(blocker.next_step ? { next_step: redactProject(blocker.next_step) } : {})
718
+ };
706
719
  try {
707
720
  await targetStore.completeTask(task.id, {
708
721
  status: "blocked",
709
- summary: message,
722
+ summary: redactProject(message),
710
723
  commands: ["visible ChatGPT browser consult"],
711
- blocker
724
+ blocker: persistedBlocker
712
725
  });
713
726
  await writeSessionBestEffort(targetStore, {
714
727
  id: bundle.id,
@@ -717,7 +730,7 @@ export async function runAskProCommand(rest, io) {
717
730
  task_id: task.id,
718
731
  thread: normalizedTargetUrl,
719
732
  status: "blocked",
720
- blocker,
733
+ blocker: persistedBlocker,
721
734
  warnings: []
722
735
  }, io);
723
736
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.16.20",
3
+ "version": "0.16.21",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",