@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.
- package/dist/browser-send-lock.js +65 -12
- package/dist/chatgpt-browser.js +41 -5
- package/dist/cli-args.js +3 -0
- package/dist/cli-help.js +2 -2
- package/dist/cli-pro.js +16 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
26
|
+
async function readHolder(file) {
|
|
25
27
|
try {
|
|
26
28
|
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
27
|
-
return
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
130
|
+
await releaseIfOwned(file);
|
|
78
131
|
}
|
|
79
132
|
}
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -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 =
|
|
901
|
-
|
|
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.
|
|
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
|
-
|
|
1205
|
-
|
|
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);
|
|
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.
|
|
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
|
}
|