@youdie006/prodex 0.16.19 → 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.
package/README.md CHANGED
@@ -35,7 +35,7 @@ prodex ask --file src/auth.ts "Review this for security holes"
35
35
 
36
36
  `prodex ask` is the short form of `prodex pro browser ask`; the full form and every flag work identically. In an interactive terminal, `login` keeps watching the opened window and tells you exactly which manual step is still missing (log in, clear a check, open a chat) until it reports READY. If you skip `login` and the browser is not running, an interactive `ask` recovers on its own: it launches the dedicated browser, waits for your saved session to be READY, and retries the send once (disable with `--no-auto-login`; scripts opt in with `--auto-login`). While ChatGPT thinks, `prodex` prints progress to stderr (connecting, prompt sent, elapsed seconds while generating), so a multi-minute Pro answer never looks frozen.
37
37
 
38
- The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and deliberately will not send into a window you cannot watch — but a dedicated Chrome window left non-minimized (even behind your editor) counts as watchable, so it sends quietly in the background without stealing focus. Just don't minimize it or switch that window to another tab. Add `--pro-mode 확장` for extended reasoning, or `--file` more than once to attach several files. See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
38
+ The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and deliberately will not send into a window you cannot watch — but a dedicated Chrome window left non-minimized (even behind your editor) counts as watchable, so it sends quietly in the background without stealing focus. Just don't minimize it or switch that window to another tab. Pin per-repo defaults once - `prodex setup --model Pro --project "your-project"` - so every ask runs Pro (15-minute timeout) inside that project instead of whatever the ChatGPT UI last had selected; list exact sidebar project names with `prodex pro browser projects`. Pass `--file` more than once to attach several files, and `--busy-wait-ms 600000` to queue behind an in-flight response when several agents share the browser. See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
39
39
 
40
40
  ## Core Shape
41
41
 
@@ -225,7 +225,7 @@ The visible-browser send drives the same composer picker you use by hand, so you
225
225
 
226
226
  ```bash
227
227
  # Pro extended sub-mode, inside an existing sidebar project
228
- prodex pro browser ask --model Pro --pro-mode 확장 --project "my-project" "Review the migration plan"
228
+ prodex pro browser ask --model Pro --project "my-project" "Review the migration plan"
229
229
 
230
230
  # A non-Pro model at a specific reasoning effort
231
231
  prodex pro browser ask --effort "매우 높음" "Draft the release notes"
@@ -240,7 +240,7 @@ prodex pro browser models
240
240
  ```
241
241
 
242
242
  - `--model` picks the composer model by its exact menu label. `Pro` is verified end-to-end. Models whose menu entry opens a submenu of variants (for example GPT-5.5) are rejected with a clear error instead of silently keeping the previous model; direct variant selection is planned.
243
- - `--pro-mode 기본|확장` selects the Pro sub-mode; it applies only when the model is Pro. `확장` can think for minutes, so it raises the default `--timeout-ms` from 90000 to 300000 (an explicit `--timeout-ms` always wins).
243
+ - `--pro-mode 기본|확장` selects the Pro sub-mode where the ChatGPT picker exposes one: sub-modes belong to the GPT-5.5 generation ("Pro Standard/Extended use GPT-5.5 Pro" per OpenAI docs), so with the GPT-5.6 generation selected the picker shows a single Pro and this flag fails with guidance. Any effective Pro selection (`--model Pro` or a sub-mode) raises the default `--timeout-ms` to 900000 - Pro reasoning routinely runs for many minutes (an explicit `--timeout-ms` always wins).
244
244
  - `--effort 즉시|중간|높음|"매우 높음"` sets the reasoning effort. English aliases `instant`/`medium`/`high`/`max` are accepted. The effort options and Pro share one radio group in ChatGPT, so picking an effort switches the composer to the standard reasoning model and deselects Pro; for the same reason `--pro-mode` and `--effort` cannot be combined.
245
245
  - `--project "name"` enters an existing sidebar project before sending. `--project-new "name"` creates a new project (sidebar 새 프로젝트 popover, committed with Enter) and sends inside it. Neither can be combined with `--target-url` (the project step would navigate away from the confirmed tab), and `--project-new` never comes from saved defaults — creating a project is always an explicit per-ask choice.
246
246
 
@@ -249,7 +249,7 @@ Selection clicks are guarded: prodex refuses to click a control that is covered
249
249
  Persist defaults so you can omit these flags on routine asks; a per-ask flag always overrides the saved default. View saved defaults with `prodex status`, clear one with the matching `--clear-*` flag, or answer a short wizard instead of remembering flags:
250
250
 
251
251
  ```bash
252
- prodex setup --model Pro --pro-mode 확장 --project "my-project"
252
+ prodex setup --model Pro --project "my-project"
253
253
  prodex setup --clear-project
254
254
  prodex setup --interactive # asks model / Pro sub-mode or effort / project
255
255
  ```
@@ -402,7 +402,7 @@ npm run dev -- tasks list
402
402
 
403
403
  **The browser was closed — do I have to run `login` again?** Not in a terminal: an interactive `ask` notices `browser_unreachable`, relaunches the dedicated browser, waits for your saved session to report READY, and retries the send once. Scripts opt in with `--auto-login`; `--no-auto-login` disables it. Only a missing browser triggers this — login/captcha/limit blockers still stop and report.
404
404
 
405
- **The answer timed out.** Pro extended reasoning can take minutes. `--pro-mode 확장` already raises the default budget to 300s; the `send_timeout` blocker suggests a paste-ready rerun command with a doubled `--timeout-ms`. If the answer was mid-stream when time ran out, prodex salvages the partial text and records an `answer_incomplete` warning instead of discarding it.
405
+ **The answer timed out.** Pro reasoning can take many minutes. A Pro selection already raises the default budget to 900s; the `send_timeout` blocker suggests a paste-ready rerun command with a doubled `--timeout-ms`. If the answer was mid-stream when time ran out, prodex salvages the partial text and records an `answer_incomplete` warning instead of discarding it.
406
406
 
407
407
  **Sends started failing after many consults in one chat.** Long accumulated threads eventually confuse prompt-acceptance detection (measured live around ten-plus messages). Send repeated consults into fresh chats with `--new-chat` (`new_chat: true` on the MCP tool) — the answer still lands in your account and in `.bridge/` receipts either way.
408
408
 
@@ -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.19",
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",