@youdie006/prodex 0.40.38 → 0.40.40
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 +4 -3
- package/dist/chatgpt-browser.js +29 -4
- package/dist/cli-args.js +9 -1
- package/dist/cli-help.js +4 -4
- package/dist/cli-pro.js +36 -13
- package/dist/config.js +6 -0
- package/dist/issue-report.js +7 -1
- package/dist/mcp.js +2 -2
- package/dist/repo-write.js +21 -4
- package/dist/repo.js +14 -1
- package/dist/safe-file.js +20 -3
- package/docs/releasing.md +58 -0
- package/package.json +1 -1
|
@@ -15,9 +15,10 @@ function lockPath() {
|
|
|
15
15
|
/**
|
|
16
16
|
* Serialize visible-browser sends across processes. Waits up to waitMs for a
|
|
17
17
|
* live holder to finish (0 = fail fast); a lock whose holder process is dead
|
|
18
|
-
* is reaped immediately.
|
|
18
|
+
* is reaped immediately. budgetFlag is the option that sets waitMs for the
|
|
19
|
+
* command that is waiting, named when the wait runs out.
|
|
19
20
|
*/
|
|
20
|
-
export async function withBrowserSendLock(waitMs, onWait, fn) {
|
|
21
|
+
export async function withBrowserSendLock(waitMs, onWait, fn, budgetFlag = "--timeout-ms") {
|
|
21
22
|
const file = lockPath();
|
|
22
23
|
const reapClaim = path.join(path.dirname(file), `.${path.basename(file)}.reap`);
|
|
23
24
|
return withCrossProcessFileLock(file, {
|
|
@@ -25,7 +26,7 @@ export async function withBrowserSendLock(waitMs, onWait, fn) {
|
|
|
25
26
|
retryMs: 2_000,
|
|
26
27
|
privateParent: true,
|
|
27
28
|
onWait: (holder) => onWait(`another prodex send holds the browser (pid ${holder.pid ?? "unknown"}); waiting`),
|
|
28
|
-
busyError: (holder) => new Error(`Another prodex browser send is in progress (pid ${holder.pid ?? "unknown"}) and did not finish within the wait budget. Retry once it finishes, or raise
|
|
29
|
+
busyError: (holder) => new Error(`Another prodex browser send is in progress (pid ${holder.pid ?? "unknown"}) and did not finish within the wait budget. Retry once it finishes, or raise ${budgetFlag} (which is also the queue budget).`),
|
|
29
30
|
unavailableError: () => new Error(`A prodex browser send lock at ${file} is held by nothing and could not be removed, so no send can start. Stop all prodex senders and verify no request is active before deleting that file and the abandoned reaper claim at ${reapClaim}, then retry.`)
|
|
30
31
|
}, fn);
|
|
31
32
|
}
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -252,9 +252,31 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
|
252
252
|
// Inline math carries data-math-display="false" (measured), so the value counts, not its presence.
|
|
253
253
|
const isDisplayMath = (node) => attr(node, "data-math-display") === "true" || hasClass(node, "katex-display");
|
|
254
254
|
const isCodeBlock = (node) => attr(node, "data-markdown-copy") === "code-block" || tagOf(node) === "pre";
|
|
255
|
+
// A mermaid block is drawn as an SVG preview whose only text is the
|
|
256
|
+
// toolbar and node labels (measured 2026-10-21); its source is in the
|
|
257
|
+
// React props of the preview's fiber ancestors, as CodeMirror's is in
|
|
258
|
+
// its editor state.
|
|
259
|
+
const isMermaidPreview = (node) => attr(node, "data-chatgpt-mermaid-preview") !== null;
|
|
260
|
+
const mermaidBlock = (node) => {
|
|
261
|
+
let source = null;
|
|
262
|
+
try {
|
|
263
|
+
const fiberKey = Object.keys(node).find((key) => key.startsWith("__reactFiber"));
|
|
264
|
+
let fiber = fiberKey ? node[fiberKey] : null;
|
|
265
|
+
for (let depth = 0; fiber && depth < 12 && source === null; depth += 1, fiber = fiber.return) {
|
|
266
|
+
const content = fiber.memoizedProps && fiber.memoizedProps.content;
|
|
267
|
+
if (typeof content === "string" && content.trim()) source = trimEnd(content);
|
|
268
|
+
}
|
|
269
|
+
} catch (error) {
|
|
270
|
+
source = null;
|
|
271
|
+
}
|
|
272
|
+
if (source === null) return "[mermaid diagram - open the thread to see its source]";
|
|
273
|
+
const longestRun = (source.match(new RegExp(BT + "+", "g")) || []).reduce((most, run) => Math.max(most, run.length), 0);
|
|
274
|
+
const fence = BT.repeat(Math.max(3, longestRun + 1));
|
|
275
|
+
return fence + "mermaid" + NL + source + NL + fence;
|
|
276
|
+
};
|
|
255
277
|
const BLOCK_TAGS = ["p", "div", "section", "article", "figure", "ul", "ol", "li", "table", "blockquote", "pre", "hr", "h1", "h2", "h3", "h4", "h5", "h6"];
|
|
256
278
|
const isBlock = (node) => node.nodeType === 1 && !skipped(node) &&
|
|
257
|
-
(BLOCK_TAGS.includes(tagOf(node)) || isCodeBlock(node) || (isMath(node) && isDisplayMath(node)) ||
|
|
279
|
+
(BLOCK_TAGS.includes(tagOf(node)) || isCodeBlock(node) || isMermaidPreview(node) || (isMath(node) && isDisplayMath(node)) ||
|
|
258
280
|
(tagOf(node) === "span" && findAll(node, (n) => BLOCK_TAGS.includes(tagOf(n))).length > 0));
|
|
259
281
|
// The renderer web-search answers use since 2026-10-10 marks a source as a
|
|
260
282
|
// badge inside a popover trigger; its urls only exist once it is opened.
|
|
@@ -362,6 +384,7 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
|
362
384
|
const tag = tagOf(node);
|
|
363
385
|
if (isMath(node)) return isDisplayMath(node) ? "$$" + NL + mathSource(node) + NL + "$$" : "$" + mathSource(node) + "$";
|
|
364
386
|
if (isCodeBlock(node)) return codeBlock(node);
|
|
387
|
+
if (isMermaidPreview(node)) return mermaidBlock(node);
|
|
365
388
|
if (tag === "table") return table(node);
|
|
366
389
|
if (/^h[1-6]$/.test(tag)) return "#".repeat(Number(tag[1])) + " " + inline(node).trim();
|
|
367
390
|
if (tag === "ul" || tag === "ol") return list(node, depth);
|
|
@@ -6137,7 +6160,9 @@ export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
|
|
|
6137
6160
|
}
|
|
6138
6161
|
const NORMALIZED_PROMPT_MATCH_CHARS = 120;
|
|
6139
6162
|
// The composer can autolink a url before the send, storing "[url](url)" in
|
|
6140
|
-
// place of the bare url (measured 2026-10-07, under load).
|
|
6163
|
+
// place of the bare url (measured 2026-10-07, under load). It escapes the two
|
|
6164
|
+
// halves differently ("Function\_(x)" vs "Function_\(x\)", measured
|
|
6165
|
+
// 2026-10-21), so the pattern is applied after markdown escapes are undone.
|
|
6141
6166
|
const AUTOLINKED_URL_PATTERN = /\[(https?:\/\/[^\]\s]+)\]\(\1\)/g;
|
|
6142
6167
|
/**
|
|
6143
6168
|
* ChatGPT displays a sent turn's HTML entities decoded (measured 2026-10-19:
|
|
@@ -6155,7 +6180,7 @@ function decodeHtmlEntities(value) {
|
|
|
6155
6180
|
});
|
|
6156
6181
|
}
|
|
6157
6182
|
function normalizeChatGptPromptText(value) {
|
|
6158
|
-
const unescaped = decodeHtmlEntities(value).replace(
|
|
6183
|
+
const unescaped = decodeHtmlEntities(value).replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1").replace(AUTOLINKED_URL_PATTERN, "$1");
|
|
6159
6184
|
const renderedFences = unescaped.replace(/(^|\r?\n)```text[ \t]*\r?\n([\s\S]*?)\r?\n```(?=\r?\n|$)/g, "$1text\n$2");
|
|
6160
6185
|
return renderedFences.replace(/\s+/g, " ").trim();
|
|
6161
6186
|
}
|
|
@@ -6187,8 +6212,8 @@ export function chatGptRequestMatchesUserTurn(userText, sentPrompt, requestId) {
|
|
|
6187
6212
|
}
|
|
6188
6213
|
function withoutFenceLines(value) {
|
|
6189
6214
|
return decodeHtmlEntities(value)
|
|
6190
|
-
.replace(AUTOLINKED_URL_PATTERN, "$1")
|
|
6191
6215
|
.replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
|
|
6216
|
+
.replace(AUTOLINKED_URL_PATTERN, "$1")
|
|
6192
6217
|
.replace(/^[ \t]*```[\w-]*[ \t]*$/gm, "")
|
|
6193
6218
|
.replace(/\s+/g, "");
|
|
6194
6219
|
}
|
package/dist/cli-args.js
CHANGED
|
@@ -72,6 +72,12 @@ export function unknownTopLevelCommandError(command) {
|
|
|
72
72
|
return new Error(`Unknown command: ${command}.${suggestionText} Run \`prodex help\`.`);
|
|
73
73
|
}
|
|
74
74
|
export function unknownOptionError(option, command, candidates) {
|
|
75
|
+
// A prompt that starts with a dash ("-5 + 3, what is it?") lands here.
|
|
76
|
+
// Anything that cannot be a flag gets the way to send it instead of a guess.
|
|
77
|
+
if (!/^--?[A-Za-z][\w-]*(=.*)?$/.test(option)) {
|
|
78
|
+
const context = command ? ` for ${command}` : "";
|
|
79
|
+
return new Error(`Unknown option${context}: ${option}. If this is the prompt, put \`--\` before it: prodex ask -- ${JSON.stringify(option)}`);
|
|
80
|
+
}
|
|
75
81
|
const suggestion = closestSuggestion(option, candidates);
|
|
76
82
|
const suggestionText = suggestion ? `. Did you mean \`${suggestion}\`?` : "";
|
|
77
83
|
const context = command ? ` for ${command}` : "";
|
|
@@ -298,9 +304,11 @@ export const ASK_PRO_PREVIEW_VALUE_FLAGS = new Set([
|
|
|
298
304
|
"--file",
|
|
299
305
|
"--port",
|
|
300
306
|
"--timeout-ms",
|
|
301
|
-
// Accepted here only so the preview path can reject
|
|
307
|
+
// Accepted here only so the preview path can reject them with the friendly
|
|
302
308
|
// "only applies when sending" guidance instead of a raw "Unknown option".
|
|
303
309
|
"--busy-wait-ms",
|
|
310
|
+
"--attach",
|
|
311
|
+
"--tool",
|
|
304
312
|
"--target-url",
|
|
305
313
|
...ASK_PRO_SELECTION_VALUE_FLAGS
|
|
306
314
|
]);
|
package/dist/cli-help.js
CHANGED
|
@@ -17,7 +17,7 @@ First-time setup:
|
|
|
17
17
|
|
|
18
18
|
Ask / consult commands:
|
|
19
19
|
prodex ask [same flags as pro browser ask] "prompt" # top-level shortcut for pro browser ask
|
|
20
|
-
prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path]
|
|
20
|
+
prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] "prompt" # dry-run preview
|
|
21
21
|
prodex pro debate-prompt [--topic "..."] [--rounds 2] [--source-cli /absolute/path/to/dist/cli.js] # print an agent prompt for a structured GPT Pro debate
|
|
22
22
|
prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background] [--recover-visible] # preview/open browser login
|
|
23
23
|
prodex pro browser help [--source-cli /absolute/path/to/dist/cli.js]
|
|
@@ -35,7 +35,7 @@ Ask / consult commands:
|
|
|
35
35
|
|
|
36
36
|
Bridge ledger (durable tasks/results/receipts/sessions under .bridge/):
|
|
37
37
|
prodex init [--cwd /absolute/path/to/repo]
|
|
38
|
-
prodex tasks create [--cwd /absolute/path/to/repo] --title "Title" --prompt "Prompt" [--repo-id id] [--file path]
|
|
38
|
+
prodex tasks create [--cwd /absolute/path/to/repo] --title "Title" --prompt "Prompt" [--repo-id id] [--file path]
|
|
39
39
|
prodex tasks list [--status new|claimed|done|blocked] [--cwd /absolute/path/to/repo] [--json]
|
|
40
40
|
prodex tasks show <task-id|latest> [--cwd /absolute/path/to/repo]
|
|
41
41
|
prodex tasks claim <task-id> [--cwd /absolute/path/to/repo] [--by codex]
|
|
@@ -163,7 +163,7 @@ export function printProHelp(stdout) {
|
|
|
163
163
|
stdout(`prodex pro
|
|
164
164
|
|
|
165
165
|
Commands:
|
|
166
|
-
prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path]
|
|
166
|
+
prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] "prompt"
|
|
167
167
|
prodex pro debate-prompt [--topic "..."] [--rounds 2] [--source-cli /absolute/path/to/dist/cli.js]
|
|
168
168
|
prodex pro browser help [--source-cli /absolute/path/to/dist/cli.js]
|
|
169
169
|
prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headed|--headless|--minimized|--virtual-display] [--wait-timeout-ms 300000] [--background] [--recover-visible]
|
|
@@ -209,7 +209,7 @@ export function printTasksHelp(stdout) {
|
|
|
209
209
|
stdout(`prodex tasks
|
|
210
210
|
|
|
211
211
|
Commands:
|
|
212
|
-
prodex tasks create [--cwd /absolute/path/to/repo] --title "Title" --prompt "Prompt" [--repo-id id] [--file path]
|
|
212
|
+
prodex tasks create [--cwd /absolute/path/to/repo] --title "Title" --prompt "Prompt" [--repo-id id] [--file path]
|
|
213
213
|
prodex tasks list [--status new|claimed|done|blocked] [--cwd /absolute/path/to/repo] [--json]
|
|
214
214
|
prodex tasks show <task-id|latest> [--cwd /absolute/path/to/repo]
|
|
215
215
|
prodex tasks claim <task-id> [--cwd /absolute/path/to/repo] [--by codex]
|
package/dist/cli-pro.js
CHANGED
|
@@ -974,6 +974,7 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
974
974
|
const report = buildIssueReport({
|
|
975
975
|
task_id: record.result.task_id,
|
|
976
976
|
status: record.result.status,
|
|
977
|
+
blockedAt: record.result.created_at,
|
|
977
978
|
...(record.result.blocker ? { blocker: record.result.blocker } : {})
|
|
978
979
|
}, { version: CLI_VERSION, platform: process.platform, nodeVersion: process.version });
|
|
979
980
|
io.stdout(`title: ${report.title}`);
|
|
@@ -1175,14 +1176,17 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1175
1176
|
const targetCwd = resolveCwdFlag(io.cwd, parsedAskPro.optionArgs);
|
|
1176
1177
|
const targetStore = new BridgeStore(targetCwd);
|
|
1177
1178
|
const files = readRepeatedFlag(parsedAskPro.optionArgs, "--file").map((file) => {
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
const rel = path.relative(targetCwd, path.resolve(file));
|
|
1185
|
-
if (rel === ""
|
|
1179
|
+
// Accept a --file that points INSIDE the repo, absolute or relative (even
|
|
1180
|
+
// ../repo/x), by converting it to the repo-relative path the reader
|
|
1181
|
+
// requires; reject one that escapes the repo so a file outside the project
|
|
1182
|
+
// (secrets, ~/.ssh, ...) is not attached to a consult by mistake. A
|
|
1183
|
+
// relative escape used to reach the reader and fail with its internal
|
|
1184
|
+
// "repo-relative root" wording instead of this guidance.
|
|
1185
|
+
const rel = path.relative(targetCwd, path.resolve(targetCwd, file));
|
|
1186
|
+
if (rel === "") {
|
|
1187
|
+
throw new Error(`--file "${file}" is the repo root itself (${targetCwd}). Pass a file inside it.`);
|
|
1188
|
+
}
|
|
1189
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
1186
1190
|
throw new Error(`--file "${file}" is outside the repo root (${targetCwd}). Pass a path inside the repo (absolute or relative), or point --cwd at that repo.`);
|
|
1187
1191
|
}
|
|
1188
1192
|
return rel;
|
|
@@ -1193,7 +1197,7 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1193
1197
|
const attachments = readRepeatedFlag(parsedAskPro.optionArgs, "--attach").map((file) => {
|
|
1194
1198
|
const absolute = path.resolve(targetCwd, file);
|
|
1195
1199
|
const rel = path.relative(targetCwd, absolute);
|
|
1196
|
-
if (rel === "" || rel.startsWith(
|
|
1200
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
1197
1201
|
throw new Error(`--attach "${file}" is outside the repo root (${targetCwd}). Pass a path inside the repo, or point --cwd at that repo.`);
|
|
1198
1202
|
}
|
|
1199
1203
|
if (!existsSync(absolute) || !statSync(absolute).isFile()) {
|
|
@@ -2305,7 +2309,7 @@ async function recoverBrowserUnderSendLock(stderr, options) {
|
|
|
2305
2309
|
}
|
|
2306
2310
|
}
|
|
2307
2311
|
async function completeVisibleAuthRecovery(io, options) {
|
|
2308
|
-
return withBrowserSendLock(
|
|
2312
|
+
return withBrowserSendLock(options.timeoutMs, (detail) => io.stderr(`recovery: ${detail}`), async () => {
|
|
2309
2313
|
const { closeBlockedHeadlessBrowserForVisibleAuth, getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
|
|
2310
2314
|
io.stderr("recovery: verifying the blocked headless browser before graceful visible handoff...");
|
|
2311
2315
|
let url;
|
|
@@ -2342,10 +2346,26 @@ async function completeVisibleAuthRecovery(io, options) {
|
|
|
2342
2346
|
headedLoginCommand: formatHeadedBrowserLoginCommand(options.sourceCli, { ...options.commandOptions, profileDir: opened.profileDir, port: opened.port })
|
|
2343
2347
|
});
|
|
2344
2348
|
return ready ? 0 : 1;
|
|
2345
|
-
});
|
|
2349
|
+
}, "--wait-timeout-ms");
|
|
2350
|
+
}
|
|
2351
|
+
/**
|
|
2352
|
+
* Why a headless handoff sat on Cloudflare's check, and the way out. Measured
|
|
2353
|
+
* 2026-10-20 on M3: headless Chrome reported "HeadlessChrome/155.0.0.0" and an
|
|
2354
|
+
* 800x600 screen and stayed on "Just a moment", right after a visible sign-in.
|
|
2355
|
+
* WSL works because its browser is ordinary Chrome on a virtual display.
|
|
2356
|
+
*/
|
|
2357
|
+
export function headlessCloudflareHint(code, platform) {
|
|
2358
|
+
if (code !== "cloudflare_check")
|
|
2359
|
+
return undefined;
|
|
2360
|
+
const cause = "Cloudflare keeps headless Chrome on its check (it identifies itself as HeadlessChrome), so another login or handoff will not get past it.";
|
|
2361
|
+
return platform === "darwin"
|
|
2362
|
+
? `${cause} macOS has no virtual display for Chrome; run the dedicated browser in the browser container instead (docs/container-browser.md), or keep a visible window open behind your other windows.`
|
|
2363
|
+
: `${cause} Run it on a virtual display instead, with no window on screen: \`prodex pro browser login --virtual-display\`.`;
|
|
2346
2364
|
}
|
|
2347
2365
|
async function completeBackgroundBrowserLogin(io, options) {
|
|
2348
|
-
|
|
2366
|
+
// An agent sending consults back to back left no 5 s gap (measured
|
|
2367
|
+
// 2026-10-20 on M3), so the handoff queues for its turn like a send does.
|
|
2368
|
+
return withBrowserSendLock(options.timeoutMs, (detail) => io.stderr(`background: ${detail}`), async () => {
|
|
2349
2369
|
const { closeIdleChatGptBrowserForHandoff, getDedicatedBrowserHeadlessMode } = await import("./browser-handoff.js");
|
|
2350
2370
|
io.stderr("background: checking the dedicated browser before graceful handoff...");
|
|
2351
2371
|
const { url } = await closeIdleChatGptBrowserForHandoff({ port: options.port, profileDir: options.profileDir });
|
|
@@ -2367,13 +2387,16 @@ async function completeBackgroundBrowserLogin(io, options) {
|
|
|
2367
2387
|
if (!ready) {
|
|
2368
2388
|
const status = await getChatGptBrowserStatus({ port: opened.port, timeoutMs: 5_000 }).catch(() => undefined);
|
|
2369
2389
|
io.stderr(`background: NOT READY (${status?.blocker?.code ?? "not_ready"}). The same profile was kept; this is not proof that your saved login was lost. No prompt was sent.`);
|
|
2390
|
+
const hint = headlessCloudflareHint(status?.blocker?.code, process.platform);
|
|
2391
|
+
if (hint)
|
|
2392
|
+
io.stderr(`background: ${hint}`);
|
|
2370
2393
|
return 1;
|
|
2371
2394
|
}
|
|
2372
2395
|
if (!getDedicatedBrowserHeadlessMode({ port: opened.port, profileDir: opened.profileDir }))
|
|
2373
2396
|
throw new Error("The replacement browser is not actually headless; background readiness was not confirmed.");
|
|
2374
2397
|
io.stdout("background: READY - the saved login works headless, with no visible window. You may close this terminal; later consults reuse this mode.");
|
|
2375
2398
|
return 0;
|
|
2376
|
-
});
|
|
2399
|
+
}, "--wait-timeout-ms");
|
|
2377
2400
|
}
|
|
2378
2401
|
function needsVisibleAuthRecovery(code) {
|
|
2379
2402
|
return code !== undefined && ["login_required", "cloudflare_check", "captcha_required", "permission_required"].includes(code);
|
package/dist/config.js
CHANGED
|
@@ -286,6 +286,7 @@ export function assertTokenNotExpired(config, now = new Date()) {
|
|
|
286
286
|
throw new Error(status.warning.toLowerCase());
|
|
287
287
|
}
|
|
288
288
|
}
|
|
289
|
+
const MAX_TOKEN_EXPIRES_AT_MS = Date.parse("9999-12-31T23:59:59.999Z");
|
|
289
290
|
function computeTokenExpiresAt(tokenTtlHours, nowIso) {
|
|
290
291
|
if (tokenTtlHours === undefined)
|
|
291
292
|
return undefined;
|
|
@@ -296,6 +297,11 @@ function computeTokenExpiresAt(tokenTtlHours, nowIso) {
|
|
|
296
297
|
if (!Number.isFinite(expiresAtMs)) {
|
|
297
298
|
throw new Error("token ttl produced an invalid expiry time");
|
|
298
299
|
}
|
|
300
|
+
// Past year 9999 the ISO date gains a sign and six-digit year, which the
|
|
301
|
+
// config schema rejects with a raw validation dump (measured 2026-10-21).
|
|
302
|
+
if (expiresAtMs > MAX_TOKEN_EXPIRES_AT_MS) {
|
|
303
|
+
throw new Error("--token-ttl-hours is too large: the token would expire after year 9999. Use a smaller value, such as 8760 for a year.");
|
|
304
|
+
}
|
|
299
305
|
return new Date(expiresAtMs).toISOString();
|
|
300
306
|
}
|
|
301
307
|
function isTokenExpired(tokenExpiresAt, now) {
|
package/dist/issue-report.js
CHANGED
|
@@ -37,6 +37,9 @@ export function buildIssueReport(consult, environment) {
|
|
|
37
37
|
? "The browser response did not match the smoke-test token."
|
|
38
38
|
: "Consult blocked; detailed diagnostics are kept in the local receipt.";
|
|
39
39
|
const area = issueAreaLabel(code);
|
|
40
|
+
// Only a bare timestamp travels: the field comes from a local record, and
|
|
41
|
+
// anything else in it stays local like the rest of the blocker prose.
|
|
42
|
+
const blockedAt = consult.blockedAt && /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/.test(consult.blockedAt) ? consult.blockedAt : "(not recorded)";
|
|
40
43
|
const body = [
|
|
41
44
|
"A consult was blocked. Filed from its receipt, so the prompt and the answer are not included.",
|
|
42
45
|
"",
|
|
@@ -45,7 +48,10 @@ export function buildIssueReport(consult, environment) {
|
|
|
45
48
|
`| blocker | \`${code}\` |`,
|
|
46
49
|
`| message | ${message || "(none)"} |`,
|
|
47
50
|
`| retryable | ${consult.blocker.retryable === true ? "yes" : "no"} |`,
|
|
48
|
-
`|
|
|
51
|
+
`| blocked at | ${blockedAt} |`,
|
|
52
|
+
// Records do not store the version that ran the consult, so name this one
|
|
53
|
+
// for what it is; the time above places the failure among releases.
|
|
54
|
+
`| prodex filing this report | ${environment.version} |`,
|
|
49
55
|
`| platform | ${environment.platform} |`,
|
|
50
56
|
`| node | ${environment.nodeVersion} |`,
|
|
51
57
|
"",
|
package/dist/mcp.js
CHANGED
|
@@ -140,11 +140,11 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
140
140
|
}
|
|
141
141
|
}, async (input) => asText(await handlers.repo_read_file(input)));
|
|
142
142
|
server.registerTool("repo_search", {
|
|
143
|
-
description: "Search the current repo with ripgrep. Read-only.",
|
|
143
|
+
description: "Search the current repo with ripgrep. The query is a regular expression: escape ( ) [ ] . * + ? with a backslash to match them literally. Read-only.",
|
|
144
144
|
inputSchema: { query: McpBridgeTextSchema.min(1), glob: McpShortTextSchema.optional() }
|
|
145
145
|
}, async (input) => asText(await handlers.repo_search(input)));
|
|
146
146
|
server.registerTool("repo_write_file_dry_run", {
|
|
147
|
-
description: "Create a receipt-gated write preview for an existing repo-relative text file. Does not modify files.",
|
|
147
|
+
description: "Create a receipt-gated write preview for an existing repo-relative text file. Does not modify files. The file keeps its CRLF line endings and UTF-8 BOM when the new content leaves them out.",
|
|
148
148
|
inputSchema: {
|
|
149
149
|
path: McpShortTextSchema,
|
|
150
150
|
content: z.string().max(1_000_000),
|
package/dist/repo-write.js
CHANGED
|
@@ -11,21 +11,38 @@ let testHooks = {};
|
|
|
11
11
|
export function setRepoWriteTestHooks(hooks) {
|
|
12
12
|
testHooks = hooks;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* repo_read_file hands an agent "\n" endings for a CRLF file, so writing the
|
|
16
|
+
* edited text back turned every line into a change (measured 2026-10-21), and
|
|
17
|
+
* a UTF-8 BOM was dropped the same way. Keep what the file already uses, as an
|
|
18
|
+
* editor does: CRLF only when the file uses it throughout and the new content
|
|
19
|
+
* carries no CR of its own, and a BOM only when the new content has none.
|
|
20
|
+
*/
|
|
21
|
+
export function keepFileConventions(current, next) {
|
|
22
|
+
let content = next;
|
|
23
|
+
const crlf = current.includes("\r\n") && !/(^|[^\r])\n/.test(current);
|
|
24
|
+
if (crlf && !content.includes("\r"))
|
|
25
|
+
content = content.replace(/\n/g, "\r\n");
|
|
26
|
+
if (current.startsWith("\uFEFF") && !content.startsWith("\uFEFF"))
|
|
27
|
+
content = `\uFEFF${content}`;
|
|
28
|
+
return content;
|
|
29
|
+
}
|
|
14
30
|
export async function createRepoWriteDryRun(root, store, input) {
|
|
15
31
|
const head = await assertGitHead(root, input.expected_head);
|
|
16
32
|
if (Buffer.byteLength(input.content, "utf8") > MAX_WRITE_BYTES) {
|
|
17
33
|
throw new Error(`New content is too large to write through repo tools`);
|
|
18
34
|
}
|
|
19
35
|
const current = await readWritableExistingFile(root, input.path);
|
|
20
|
-
|
|
36
|
+
const content = keepFileConventions(current.content, input.content);
|
|
37
|
+
if (current.content === content) {
|
|
21
38
|
throw new Error(`No changes for ${input.path}`);
|
|
22
39
|
}
|
|
23
40
|
const preimage = sha256(current.content);
|
|
24
|
-
const newSha = sha256(
|
|
25
|
-
const diff = buildSimpleDiff(input.path, current.content,
|
|
41
|
+
const newSha = sha256(content);
|
|
42
|
+
const diff = buildSimpleDiff(input.path, current.content, content);
|
|
26
43
|
const candidateArtifactPath = `.bridge/artifacts/repo-writes/${newSha}.txt`;
|
|
27
44
|
const artifactAlreadyExisted = await store.hasArtifactText(candidateArtifactPath);
|
|
28
|
-
const artifactPath = await store.writeArtifactText(candidateArtifactPath,
|
|
45
|
+
const artifactPath = await store.writeArtifactText(candidateArtifactPath, content);
|
|
29
46
|
const metadata = {
|
|
30
47
|
path: input.path,
|
|
31
48
|
expected_head: head,
|
package/dist/repo.js
CHANGED
|
@@ -70,7 +70,8 @@ export async function readRepoFile(root, repoPath, options = {}) {
|
|
|
70
70
|
const resolved = resolveRepoPath(root, repoPath);
|
|
71
71
|
await assertResolvedRepoPathAllowed(root, resolved, repoPath);
|
|
72
72
|
const text = await readVerifiedUtf8File(resolved, () => assertResolvedRepoPathAllowed(root, resolved, repoPath), {
|
|
73
|
-
maxBytes: MAX_REPO_READ_BYTES
|
|
73
|
+
maxBytes: MAX_REPO_READ_BYTES,
|
|
74
|
+
decodeUtf16Bom: true
|
|
74
75
|
}).catch((error) => {
|
|
75
76
|
if (error instanceof Error && /too large/.test(error.message)) {
|
|
76
77
|
throw new Error(`Path ${repoPath} is too large to read through repo tools`);
|
|
@@ -181,9 +182,21 @@ export async function searchRepoWithMetadata(root, query, glob) {
|
|
|
181
182
|
}
|
|
182
183
|
if (maybe.code === 1)
|
|
183
184
|
return { matches: [], truncated: false, limit: MAX_REPO_SEARCH_MATCHES };
|
|
185
|
+
// An invalid pattern used to surface as "Command failed: <rg path> ..."
|
|
186
|
+
// with rg's parse error buried in it (measured 2026-10-21, query "(").
|
|
187
|
+
const reason = ripgrepRegexError(error.stderr);
|
|
188
|
+
if (reason) {
|
|
189
|
+
throw new Error(`repo_search treats the query as a regular expression, and ${JSON.stringify(query)} is not valid (${reason}). Escape characters such as ( ) [ ] { } . * + ? | with a backslash to search for them literally.`);
|
|
190
|
+
}
|
|
184
191
|
throw error;
|
|
185
192
|
}
|
|
186
193
|
}
|
|
194
|
+
function ripgrepRegexError(stderr) {
|
|
195
|
+
if (!stderr || !/regex parse error/i.test(stderr))
|
|
196
|
+
return undefined;
|
|
197
|
+
const detail = stderr.split("\n").map((line) => line.trim()).find((line) => /^error:/i.test(line));
|
|
198
|
+
return detail ? detail.replace(/^error:\s*/i, "") : "regex parse error";
|
|
199
|
+
}
|
|
187
200
|
function parseRipgrepJsonMatch(line) {
|
|
188
201
|
let event;
|
|
189
202
|
try {
|
package/dist/safe-file.js
CHANGED
|
@@ -71,7 +71,7 @@ export async function readVerifiedUtf8File(filePath, validate, options = {}) {
|
|
|
71
71
|
if (options.maxBytes !== undefined && stat.size > options.maxBytes) {
|
|
72
72
|
throw new Error(`Target file is too large (${stat.size} bytes)`);
|
|
73
73
|
}
|
|
74
|
-
const content = await readHandleUtf8(handle, filePath, options.maxBytes);
|
|
74
|
+
const content = await readHandleUtf8(handle, filePath, options.maxBytes, options.decodeUtf16Bom === true);
|
|
75
75
|
if (options.mode !== undefined) {
|
|
76
76
|
await testHooks.beforeChmod?.(filePath, "read");
|
|
77
77
|
await assertSafeOpenFile(handle, filePath, "read");
|
|
@@ -185,7 +185,7 @@ async function replaceByVerifiedTempFile(filePath, content, validate, options =
|
|
|
185
185
|
throw error;
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
-
async function readHandleUtf8(handle, filePath, maxBytes) {
|
|
188
|
+
async function readHandleUtf8(handle, filePath, maxBytes, decodeUtf16Bom = false) {
|
|
189
189
|
await assertOpenFileMatchesPath(handle, filePath, "read");
|
|
190
190
|
const stat = await handle.stat();
|
|
191
191
|
if (!stat.isFile()) {
|
|
@@ -206,7 +206,24 @@ async function readHandleUtf8(handle, filePath, maxBytes) {
|
|
|
206
206
|
throw new Error(`Target file is too large (${offset} bytes)`);
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
|
-
return buffer.subarray(0, offset)
|
|
209
|
+
return decodeFileText(buffer.subarray(0, offset), decodeUtf16Bom);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* UTF-8, or UTF-16 when asked and the file starts with its BOM. Windows
|
|
213
|
+
* Notepad's "Unicode" saves UTF-16 LE with a BOM, and reading that as UTF-8
|
|
214
|
+
* turned every other byte into a NUL (measured 2026-10-20: --file refused it
|
|
215
|
+
* as "not a text file"). Only readers opt in, so nothing is re-encoded on write.
|
|
216
|
+
*/
|
|
217
|
+
export function decodeFileText(bytes, decodeUtf16Bom = false) {
|
|
218
|
+
if (decodeUtf16Bom && bytes.length >= 2) {
|
|
219
|
+
if (bytes[0] === 0xff && bytes[1] === 0xfe)
|
|
220
|
+
return bytes.subarray(2).toString("utf16le");
|
|
221
|
+
if (bytes[0] === 0xfe && bytes[1] === 0xff) {
|
|
222
|
+
const body = Buffer.from(bytes.subarray(2, 2 + ((bytes.length - 2) & ~1)));
|
|
223
|
+
return body.swap16().toString("utf16le");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return bytes.toString("utf8");
|
|
210
227
|
}
|
|
211
228
|
async function writeHandleUtf8(handle, filePath, content) {
|
|
212
229
|
const replacement = Buffer.from(content, "utf8");
|
package/docs/releasing.md
CHANGED
|
@@ -16,6 +16,64 @@ Release target: `v0.40.13` on `main`. The GitHub Release records the resolved co
|
|
|
16
16
|
|
|
17
17
|
The release adds a guarded, opt-in transition and more accurate blocker reporting. It does not claim that headless Pro consultation is verified on either deployment target or that authentication can be retained indefinitely.
|
|
18
18
|
|
|
19
|
+
## Browser Incident 2026-10-20: M3 headless browser behind Cloudflare
|
|
20
|
+
|
|
21
|
+
**What happened**
|
|
22
|
+
- A read-only `prodex pro browser check` on M3 (0.40.38) reported `cloudflare_check`: the dedicated browser had run headless since 2026-09-16 and its ChatGPT tab sat on Cloudflare's human-verification page. Consults from M3's stdio MCP servers could not send.
|
|
23
|
+
- `login --headed --recover-visible` could not take it over (`Chrome DevTools command timed out: Runtime.enable` on the stuck tab), and `reset` declined because the browser itself still answered.
|
|
24
|
+
|
|
25
|
+
**Recovery**
|
|
26
|
+
- The dedicated browser was closed with CDP `Browser.close` (send lock idle), then started with a window. Cloudflare passed. `--minimized` was not kept: this macOS desktop reports a minimized window as hidden, so prodex restored it.
|
|
27
|
+
- The profile's ChatGPT login had expired; the maintainer signed in on the Mac. `pro browser check` then reported `ok logged_in=true composer=true`, and `pro browser smoke` answered `PRODEX_PRO_SMOKE_OK`.
|
|
28
|
+
|
|
29
|
+
**Headless handoff, then the cause (same day)**
|
|
30
|
+
- `login --background` handed the signed-in profile to headless (after PR #35's lock-wait problem was worked around by retrying), and the headless tab was immediately back on Cloudflare's "Just a moment" page (`NOT READY - cloudflare_check ... not ready headlessly`).
|
|
31
|
+
- Read over CDP: the headless browser reports `HeadlessChrome/155.0.0.0` in its user agent and an 800x600 screen. WSL never runs headless - its dedicated browser is ordinary Chrome on the Xvfb display - which is why the same account works there. macOS has no Xvfb. Faking the user agent was not done: the container design excludes stealth settings.
|
|
32
|
+
- M3's Claude and Codex MCP configs already use the browser container (`container-client.mjs --context colima-prodex-check mcp`), which runs ordinary Chromium on Xvfb and was logged in and ready. Three long-running sessions (two Claude, started via cmux with `--resume`, and a Codex running since 2026-09-15) still held host `prodex mcp` connections; they reach the container after a `/mcp` reconnect.
|
|
33
|
+
- M3's container was rebuilt from `container/redesign-patches` `a870b0c` (image `feb5a47aae06`, restart `unless-stopped`) under the container's send-lock check. Its login was kept, the ChatGPT tab opened by itself, `pro browser check` was ok and the smoke answered `PRODEX_PRO_SMOKE_OK`. The container's `prodex --version` still prints 0.40.18 because that branch never bumped its version.
|
|
34
|
+
|
|
35
|
+
**Open**
|
|
36
|
+
- A long-running headless browser ends up behind Cloudflare's check (also noted on WSL, where the Xvfb display is used instead). On macOS there is no virtual display, and a minimized window counts as hidden, so the remaining no-headless option there is a window kept behind others. Changing the headless preference is a design decision that was not made here.
|
|
37
|
+
|
|
38
|
+
## 0.40.39 Release Record
|
|
39
|
+
|
|
40
|
+
Release commit `c8a170f` on `main` (cut after PRs #33-#37, last merge `09ad42e`); tag `v0.40.39`. First batched release: five fixes merged on green CI and released together.
|
|
41
|
+
|
|
42
|
+
**Publishing**
|
|
43
|
+
- `publish.yml` run 39525668044 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-21 01:09 UTC start).
|
|
44
|
+
- `npm run release:verify` passed on the release commit after a build.
|
|
45
|
+
|
|
46
|
+
**Installed**
|
|
47
|
+
- WSL, M3, Mac mini: `prodex --version` reports 0.40.39. Running services were not restarted: 10 stdio MCP servers on WSL and 4 on M3 keep older code until their clients reconnect. The Mac mini had none running.
|
|
48
|
+
- The maintainer watchdog checkout was pulled to `c8a170f` and rebuilt.
|
|
49
|
+
|
|
50
|
+
**Verified live**
|
|
51
|
+
- Installed 0.40.39 on WSL: `prodex ask "- list these"` refuses with the hint to put `--` before the prompt.
|
|
52
|
+
|
|
53
|
+
**Browser container**
|
|
54
|
+
- `container/redesign-patches` `d3d7b66` ports PRs #34-#37 (UTF-16 decoding from #33 was already on the branch). The mermaid serializer was applied by hand because the container's transcript helpers differ from `main`. Branch suite: 2061 passed, 3 skipped.
|
|
55
|
+
- WSL: image `67b473c3e850`, swapped under the send lock check. Live: a prompt asking for a mermaid flowchart saved the answer with a ```` ```mermaid ```` fence and its source.
|
|
56
|
+
- M3 (colima `prodex-check`): image `ad0746f93e40`, swapped under the send lock check; `pro browser smoke` in the container returned `requestVerified: true`.
|
|
57
|
+
|
|
58
|
+
## 0.40.38 Release Record
|
|
59
|
+
|
|
60
|
+
Release commit `9541727` on `main` (cut after PR #32, merge `2c049b5`); tag `v0.40.38`.
|
|
61
|
+
|
|
62
|
+
**Publishing**
|
|
63
|
+
- `publish.yml` run 39362699716 passed all six platforms on the first attempt, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-20 03:08 UTC).
|
|
64
|
+
- `npm run release:verify` passed on the release commit after a build.
|
|
65
|
+
|
|
66
|
+
**Installed**
|
|
67
|
+
- WSL, M3, Mac mini: `prodex --version` reports 0.40.38. Running services were not restarted: 9 stdio MCP servers on WSL and 3 on M3 keep older code until their clients reconnect. The Mac mini had none running.
|
|
68
|
+
- The maintainer watchdog checkout was pulled to `9541727` and rebuilt.
|
|
69
|
+
|
|
70
|
+
**Verified live**
|
|
71
|
+
- Over a fresh stdio MCP server on installed 0.40.38: a `pro_consult` with a 15 s timeout returned `status: "blocked"` (`send_timeout`) with `recover`, and `pro_recover` with `recover.arguments` returned `status: "done"`. On 0.40.37 the same consult returned only error text naming a CLI command.
|
|
72
|
+
- Earlier the same day over MCP: `files`, `continue_thread`, and refusals for a path outside the repo, `.env`, a missing attachment, `new_chat` with `continue_thread`, and `deep-research` all behaved as documented.
|
|
73
|
+
|
|
74
|
+
**Browser container**
|
|
75
|
+
- `container/redesign-patches` `9c8a3d9` ports PR #32. Image `7486d2054a82`; branch suite 2049 passed, 3 skipped. Swapped under the send lock check. Live over MCP in the container: the same timeout returned `recover`, and `pro_recover` returned `done`.
|
|
76
|
+
|
|
19
77
|
## 0.40.37 Release Record
|
|
20
78
|
|
|
21
79
|
Release commit `437590d` on `main` (cut after PR #31, merge `fd0ef2e`); tag `v0.40.37`.
|