@youdie006/prodex 0.40.39 → 0.40.41
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 +1 -1
- package/dist/chatgpt-browser.js +96 -11
- package/dist/cli-args.js +3 -1
- package/dist/cli-help.js +4 -4
- package/dist/cli-pro.js +20 -11
- 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 +12 -0
- package/docs/cli-reference.md +1 -1
- package/docs/releasing.md +45 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -163,7 +163,7 @@ Power slider on this account (the slider was walked and put back):
|
|
|
163
163
|
* 5/5 6 - Pro
|
|
164
164
|
```
|
|
165
165
|
|
|
166
|
-
- `--effort 즉시|중간|높음|"매우 높음"|Pro` picks a rung; English aliases `instant`, `medium`, `high`, `extrahigh` and `max` are accepted, and `--model Pro`
|
|
166
|
+
- `--effort 즉시|중간|높음|"매우 높음"|Pro` picks a rung; English aliases `instant`, `medium`, `high`, `extrahigh` and `max` are accepted, and a step name given as a model (`--model Pro`, `--model Instant`) is applied to the slider with a `model_is_now_an_effort` warning. Korean and English (US) ChatGPT labels are both matched; on another display language, pass the exact label `models` shows.
|
|
167
167
|
- ChatGPT now has two surfaces, Chat and Work, with different pickers; Work's ladder ends in Max and Ultra and offers no Pro. prodex puts the browser back on Chat before a send (and says so on the receipt), so a drifted browser cannot quietly send on the wrong picker. `Max` and `Ultra` are accepted for a browser already on Work.
|
|
168
168
|
- The model rows themselves (Latest, GPT-5.6 Sol, GPT-5.5) cannot be clicked by automation in the current picker; the slider is the lever, and prodex says so rather than pretending a row was chosen.
|
|
169
169
|
- Selection is guarded: a control that is covered or off screen is not clicked, a menu that stays open after a pick counts as a failed pick, and any failure backs out with Escape and reports a blocker instead of sending with the wrong model. What was applied is recorded on the receipt (`metadata.selection`, project name redacted).
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -410,7 +410,43 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
|
410
410
|
// A sent user turn renders inline code as <code>, dropping its backticks
|
|
411
411
|
// from innerText (measured 2026-10-07), and the request is matched against
|
|
412
412
|
// what was typed. Each inline code span gets its backticks back, in page order.
|
|
413
|
+
const inlineCodes = (root) =>
|
|
414
|
+
[...root.querySelectorAll('code')].filter((code) => {
|
|
415
|
+
if (code.closest('pre')) return false;
|
|
416
|
+
const value = code.textContent || "";
|
|
417
|
+
// A fenced block in a user turn is one <code> outside any <pre>
|
|
418
|
+
// (measured); inline code is a single line.
|
|
419
|
+
return value.length > 0 && !value.includes(String.fromCharCode(10));
|
|
420
|
+
});
|
|
421
|
+
// Backtick text nodes go in beside each span for one innerText read and come
|
|
422
|
+
// straight out again, so they land where the span is. Searching the text for
|
|
423
|
+
// the span's value put them on an earlier plain occurrence of the same words
|
|
424
|
+
// and refused a request that had posted (measured 2026-10-24). All of it runs
|
|
425
|
+
// in one synchronous evaluation, so the page never renders in between.
|
|
426
|
+
const userMessageTextAtCodePositions = (root) => {
|
|
427
|
+
const doc = root.ownerDocument;
|
|
428
|
+
if (!doc || typeof doc.createTextNode !== "function") return undefined;
|
|
429
|
+
const tick = String.fromCharCode(96);
|
|
430
|
+
const inserted = [];
|
|
431
|
+
try {
|
|
432
|
+
for (const code of inlineCodes(root)) {
|
|
433
|
+
const parent = code.parentNode;
|
|
434
|
+
if (!parent || typeof parent.insertBefore !== "function") return undefined;
|
|
435
|
+
const before = doc.createTextNode(tick);
|
|
436
|
+
const after = doc.createTextNode(tick);
|
|
437
|
+
parent.insertBefore(before, code);
|
|
438
|
+
inserted.push(before);
|
|
439
|
+
parent.insertBefore(after, code.nextSibling);
|
|
440
|
+
inserted.push(after);
|
|
441
|
+
}
|
|
442
|
+
return root.innerText || "";
|
|
443
|
+
} finally {
|
|
444
|
+
for (const node of inserted) node.parentNode?.removeChild(node);
|
|
445
|
+
}
|
|
446
|
+
};
|
|
413
447
|
const userMessageText = (root) => {
|
|
448
|
+
const placed = userMessageTextAtCodePositions(root);
|
|
449
|
+
if (placed !== undefined) return placed;
|
|
414
450
|
let text = root.innerText || "";
|
|
415
451
|
const tick = String.fromCharCode(96);
|
|
416
452
|
let cursor = 0;
|
|
@@ -1254,23 +1290,48 @@ export function proModeNotAppliedWarning(proMode, sliderEffort) {
|
|
|
1254
1290
|
*/
|
|
1255
1291
|
export function pickerSelectionPlan(input) {
|
|
1256
1292
|
const plan = {};
|
|
1257
|
-
const
|
|
1293
|
+
const modelAsEffort = input.model !== undefined ? chatStepNamedAsModel(input.model) : undefined;
|
|
1258
1294
|
if (input.effort !== undefined)
|
|
1259
1295
|
plan.sliderLabel = input.effort;
|
|
1260
|
-
else if (
|
|
1261
|
-
plan.sliderLabel =
|
|
1262
|
-
if (input.model !== undefined &&
|
|
1296
|
+
else if (modelAsEffort !== undefined)
|
|
1297
|
+
plan.sliderLabel = modelAsEffort;
|
|
1298
|
+
if (input.model !== undefined && modelAsEffort === undefined)
|
|
1263
1299
|
plan.modelLabel = input.model;
|
|
1264
|
-
if (
|
|
1300
|
+
if (modelAsEffort !== undefined) {
|
|
1265
1301
|
plan.warning =
|
|
1266
|
-
`model_is_now_an_effort: ChatGPT's picker lists
|
|
1302
|
+
`model_is_now_an_effort: ChatGPT's picker lists ${input.model.trim()} as an effort step, not a model, so --model ${input.model} was ` +
|
|
1267
1303
|
(input.effort === undefined
|
|
1268
|
-
?
|
|
1304
|
+
? `applied to the effort slider instead. Say --effort ${input.model.trim()} to be explicit`
|
|
1269
1305
|
: `ignored in favour of --effort ${input.effort}`) +
|
|
1270
1306
|
", or clear a saved default with `prodex setup --clear-model`.";
|
|
1271
1307
|
}
|
|
1272
1308
|
return plan;
|
|
1273
1309
|
}
|
|
1310
|
+
/**
|
|
1311
|
+
* The Chat slider step a --model value names, if it names one.
|
|
1312
|
+
*
|
|
1313
|
+
* Pro was the first model to become a step, but Instant through Extra High sit
|
|
1314
|
+
* on the same slider: `--model Instant` warned "does not offer" and went out on
|
|
1315
|
+
* whatever step was saved, so a request for a fast answer waited on Pro
|
|
1316
|
+
* reasoning (measured 2026-10-22). Max and Ultra are Work-only rungs that a
|
|
1317
|
+
* model name does not reach, so they stay model names.
|
|
1318
|
+
*/
|
|
1319
|
+
export function chatStepNamedAsModel(model) {
|
|
1320
|
+
let step;
|
|
1321
|
+
try {
|
|
1322
|
+
step = parseReasoningEffort(model);
|
|
1323
|
+
}
|
|
1324
|
+
catch {
|
|
1325
|
+
return undefined;
|
|
1326
|
+
}
|
|
1327
|
+
if (step === "Max" || step === "Ultra")
|
|
1328
|
+
return undefined;
|
|
1329
|
+
// "max" is an alias of 매우 높음 for --effort, but a model called Max is
|
|
1330
|
+
// asking for the Work rung, not for Extra High.
|
|
1331
|
+
if (/^\s*max\s*$/i.test(model))
|
|
1332
|
+
return undefined;
|
|
1333
|
+
return step;
|
|
1334
|
+
}
|
|
1274
1335
|
export function chatGptBusyBlocker(state) {
|
|
1275
1336
|
// Waiting for a choice is not generating. Conflating them turned a click
|
|
1276
1337
|
// away from ready into a full send budget spent on "still generating".
|
|
@@ -1460,6 +1521,28 @@ export function normalizeChatGptTargetUrl(value) {
|
|
|
1460
1521
|
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
1461
1522
|
return url.toString();
|
|
1462
1523
|
}
|
|
1524
|
+
/**
|
|
1525
|
+
* A conversation URL, the only thing recovery can read an answer from.
|
|
1526
|
+
*
|
|
1527
|
+
* Recovery navigates the shared tab to its target. A URL that only passed the
|
|
1528
|
+
* host check (`/c/abc def`, the home page, a project page) took the send lock
|
|
1529
|
+
* and moved the tab somewhere no answer could be (measured 2026-10-26).
|
|
1530
|
+
*/
|
|
1531
|
+
export function normalizeChatGptThreadUrl(value) {
|
|
1532
|
+
const describe = `It must be a ChatGPT conversation URL such as https://chatgpt.com/c/<id> (or https://chatgpt.com/g/<project>/c/<id>), not ${JSON.stringify(value)}.`;
|
|
1533
|
+
let url;
|
|
1534
|
+
try {
|
|
1535
|
+
url = normalizeChatGptTargetUrl(value);
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
throw new Error(`Recovery target is not a ChatGPT URL. ${describe}`);
|
|
1539
|
+
}
|
|
1540
|
+
const pathname = new URL(url).pathname;
|
|
1541
|
+
if (!/^(?:\/g\/[A-Za-z0-9_-]+)?\/c\/[A-Za-z0-9][A-Za-z0-9_-]*$/.test(pathname)) {
|
|
1542
|
+
throw new Error(`Recovery target is not a conversation URL. ${describe}`);
|
|
1543
|
+
}
|
|
1544
|
+
return url;
|
|
1545
|
+
}
|
|
1463
1546
|
export function chatGptUrlsReferToSameTarget(currentUrl, expectedUrl) {
|
|
1464
1547
|
try {
|
|
1465
1548
|
return normalizeChatGptTargetUrl(currentUrl) === normalizeChatGptTargetUrl(expectedUrl);
|
|
@@ -3741,7 +3824,7 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
3741
3824
|
}
|
|
3742
3825
|
const port = resolveCdpPort(options.port);
|
|
3743
3826
|
const timeoutMs = Math.max(1_000, options.timeoutMs ?? 60_000);
|
|
3744
|
-
const url =
|
|
3827
|
+
const url = normalizeChatGptThreadUrl(options.targetUrl);
|
|
3745
3828
|
const page = await findChatGptPage(port, 3_000);
|
|
3746
3829
|
if (!page.ok || !page.page) {
|
|
3747
3830
|
throw new ChatGptBrowserBlockerError(page.blocker ?? {
|
|
@@ -6160,7 +6243,9 @@ export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
|
|
|
6160
6243
|
}
|
|
6161
6244
|
const NORMALIZED_PROMPT_MATCH_CHARS = 120;
|
|
6162
6245
|
// The composer can autolink a url before the send, storing "[url](url)" in
|
|
6163
|
-
// place of the bare url (measured 2026-10-07, under load).
|
|
6246
|
+
// place of the bare url (measured 2026-10-07, under load). It escapes the two
|
|
6247
|
+
// halves differently ("Function\_(x)" vs "Function_\(x\)", measured
|
|
6248
|
+
// 2026-10-21), so the pattern is applied after markdown escapes are undone.
|
|
6164
6249
|
const AUTOLINKED_URL_PATTERN = /\[(https?:\/\/[^\]\s]+)\]\(\1\)/g;
|
|
6165
6250
|
/**
|
|
6166
6251
|
* ChatGPT displays a sent turn's HTML entities decoded (measured 2026-10-19:
|
|
@@ -6178,7 +6263,7 @@ function decodeHtmlEntities(value) {
|
|
|
6178
6263
|
});
|
|
6179
6264
|
}
|
|
6180
6265
|
function normalizeChatGptPromptText(value) {
|
|
6181
|
-
const unescaped = decodeHtmlEntities(value).replace(
|
|
6266
|
+
const unescaped = decodeHtmlEntities(value).replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1").replace(AUTOLINKED_URL_PATTERN, "$1");
|
|
6182
6267
|
const renderedFences = unescaped.replace(/(^|\r?\n)```text[ \t]*\r?\n([\s\S]*?)\r?\n```(?=\r?\n|$)/g, "$1text\n$2");
|
|
6183
6268
|
return renderedFences.replace(/\s+/g, " ").trim();
|
|
6184
6269
|
}
|
|
@@ -6210,8 +6295,8 @@ export function chatGptRequestMatchesUserTurn(userText, sentPrompt, requestId) {
|
|
|
6210
6295
|
}
|
|
6211
6296
|
function withoutFenceLines(value) {
|
|
6212
6297
|
return decodeHtmlEntities(value)
|
|
6213
|
-
.replace(AUTOLINKED_URL_PATTERN, "$1")
|
|
6214
6298
|
.replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
|
|
6299
|
+
.replace(AUTOLINKED_URL_PATTERN, "$1")
|
|
6215
6300
|
.replace(/^[ \t]*```[\w-]*[ \t]*$/gm, "")
|
|
6216
6301
|
.replace(/\s+/g, "");
|
|
6217
6302
|
}
|
package/dist/cli-args.js
CHANGED
|
@@ -304,9 +304,11 @@ export const ASK_PRO_PREVIEW_VALUE_FLAGS = new Set([
|
|
|
304
304
|
"--file",
|
|
305
305
|
"--port",
|
|
306
306
|
"--timeout-ms",
|
|
307
|
-
// Accepted here only so the preview path can reject
|
|
307
|
+
// Accepted here only so the preview path can reject them with the friendly
|
|
308
308
|
// "only applies when sending" guidance instead of a raw "Unknown option".
|
|
309
309
|
"--busy-wait-ms",
|
|
310
|
+
"--attach",
|
|
311
|
+
"--tool",
|
|
310
312
|
"--target-url",
|
|
311
313
|
...ASK_PRO_SELECTION_VALUE_FLAGS
|
|
312
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
|
@@ -4,7 +4,7 @@ import { createServer as createNetServer } from "node:net";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { BrowserProcessInspectionError } from "./browser-process.js";
|
|
6
6
|
import { buildDryRunBundle } from "./bundle.js";
|
|
7
|
-
import { ChatGptBrowserBlockerError, DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveBrowserWindowMode, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
|
|
7
|
+
import { ChatGptBrowserBlockerError, DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveBrowserWindowMode, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl, chatStepNamedAsModel, normalizeChatGptThreadUrl } from "./chatgpt-browser.js";
|
|
8
8
|
import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
|
|
9
9
|
import { printProBrowserHelp, printProHelp } from "./cli-help.js";
|
|
10
10
|
import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
|
|
@@ -652,6 +652,9 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
652
652
|
if (!targetUrl) {
|
|
653
653
|
throw new Error("pro browser recover requires --target-url <thread-url> - the ChatGPT conversation URL whose finished answer to recover (e.g. the thread from a send_timeout blocker).");
|
|
654
654
|
}
|
|
655
|
+
// Checked here, before the send lock and the navigation it guards, and
|
|
656
|
+
// outside the catch below that dresses errors as browser failures.
|
|
657
|
+
normalizeChatGptThreadUrl(targetUrl);
|
|
655
658
|
const recoverPort = readPortFlag(browserArgs, "--port");
|
|
656
659
|
const recoverTimeoutMs = readPositiveIntegerFlag(browserArgs, "--timeout-ms");
|
|
657
660
|
const recoverRequestIdRaw = readFlag(browserArgs, "--request-id");
|
|
@@ -974,6 +977,7 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
974
977
|
const report = buildIssueReport({
|
|
975
978
|
task_id: record.result.task_id,
|
|
976
979
|
status: record.result.status,
|
|
980
|
+
blockedAt: record.result.created_at,
|
|
977
981
|
...(record.result.blocker ? { blocker: record.result.blocker } : {})
|
|
978
982
|
}, { version: CLI_VERSION, platform: process.platform, nodeVersion: process.version });
|
|
979
983
|
io.stdout(`title: ${report.title}`);
|
|
@@ -1175,14 +1179,17 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1175
1179
|
const targetCwd = resolveCwdFlag(io.cwd, parsedAskPro.optionArgs);
|
|
1176
1180
|
const targetStore = new BridgeStore(targetCwd);
|
|
1177
1181
|
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 === ""
|
|
1182
|
+
// Accept a --file that points INSIDE the repo, absolute or relative (even
|
|
1183
|
+
// ../repo/x), by converting it to the repo-relative path the reader
|
|
1184
|
+
// requires; reject one that escapes the repo so a file outside the project
|
|
1185
|
+
// (secrets, ~/.ssh, ...) is not attached to a consult by mistake. A
|
|
1186
|
+
// relative escape used to reach the reader and fail with its internal
|
|
1187
|
+
// "repo-relative root" wording instead of this guidance.
|
|
1188
|
+
const rel = path.relative(targetCwd, path.resolve(targetCwd, file));
|
|
1189
|
+
if (rel === "") {
|
|
1190
|
+
throw new Error(`--file "${file}" is the repo root itself (${targetCwd}). Pass a file inside it.`);
|
|
1191
|
+
}
|
|
1192
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
1186
1193
|
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
1194
|
}
|
|
1188
1195
|
return rel;
|
|
@@ -1193,7 +1200,7 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1193
1200
|
const attachments = readRepeatedFlag(parsedAskPro.optionArgs, "--attach").map((file) => {
|
|
1194
1201
|
const absolute = path.resolve(targetCwd, file);
|
|
1195
1202
|
const rel = path.relative(targetCwd, absolute);
|
|
1196
|
-
if (rel === "" || rel.startsWith(
|
|
1203
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
1197
1204
|
throw new Error(`--attach "${file}" is outside the repo root (${targetCwd}). Pass a path inside the repo, or point --cwd at that repo.`);
|
|
1198
1205
|
}
|
|
1199
1206
|
if (!existsSync(absolute) || !statSync(absolute).isFile()) {
|
|
@@ -2622,7 +2629,9 @@ export function resolveSelectionAxes(input) {
|
|
|
2622
2629
|
const { explicit } = input;
|
|
2623
2630
|
const defaults = input.defaults;
|
|
2624
2631
|
const reasoningAxisChosen = explicit.proMode !== undefined || explicit.effort !== undefined;
|
|
2625
|
-
|
|
2632
|
+
// Pro, and any other Chat step given as a model, is a position of the same
|
|
2633
|
+
// slider a saved effort sets, so asking for it answers the effort question.
|
|
2634
|
+
const explicitlyPro = explicit.model !== undefined && (namesPro(explicit.model) || chatStepNamedAsModel(explicit.model) !== undefined);
|
|
2626
2635
|
const model = explicit.model ?? (explicit.effort !== undefined ? undefined : defaults?.model);
|
|
2627
2636
|
const proMode = explicit.proMode ?? (reasoningAxisChosen ? undefined : defaults?.pro_mode);
|
|
2628
2637
|
const effort = explicit.effort ?? (reasoningAxisChosen || explicitlyPro ? undefined : defaults?.effort);
|
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
|
@@ -182,9 +182,21 @@ export async function searchRepoWithMetadata(root, query, glob) {
|
|
|
182
182
|
}
|
|
183
183
|
if (maybe.code === 1)
|
|
184
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
|
+
}
|
|
185
191
|
throw error;
|
|
186
192
|
}
|
|
187
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
|
+
}
|
|
188
200
|
function parseRipgrepJsonMatch(line) {
|
|
189
201
|
let event;
|
|
190
202
|
try {
|
package/docs/cli-reference.md
CHANGED
|
@@ -174,7 +174,7 @@ prodex pro browser models
|
|
|
174
174
|
```
|
|
175
175
|
|
|
176
176
|
- `--effort 즉시|중간|높음|"매우 높음"|Max|Ultra|Pro` sets the rung. English aliases `instant`/`light`, `medium`, `high`, `extrahigh`/`max`, `ultra` are accepted, and both the Korean and the English (US) ChatGPT labels are matched. `Max` and `Ultra` belong to the Work surface's ladder and apply only when the browser is already on Work; every other value is sent on Chat, whose top step is Pro.
|
|
177
|
-
- `--model Pro` reaches the same top rung. The model rows in the picker (Latest, GPT-5.6 Sol, GPT-5.5) refuse automation clicks in the current UI - they carry `pointer-events: none` - so `--model` with any other label reports `model_not_applied` rather than pretending. On a display language other than Korean or English, pass the exact label `models` prints.
|
|
177
|
+
- `--model Pro` reaches the same top rung, and so does any other Chat step given as a model (`--model Instant`, `--model High`): it is applied to the slider with a `model_is_now_an_effort` warning. Max and Ultra are not, because they are Work rungs. The model rows in the picker (Latest, GPT-5.6 Sol, GPT-5.5) refuse automation clicks in the current UI - they carry `pointer-events: none` - so `--model` with any other label reports `model_not_applied` rather than pretending. On a display language other than Korean or English, pass the exact label `models` prints.
|
|
178
178
|
- `--pro-mode 기본|확장` selects a Pro sub-mode where the picker still exposes one (the GPT-5.5 generation); with a single Pro rung it fails with guidance. `--pro-mode` and `--effort` are different axes of the same control and cannot be combined. Any Pro selection raises the default `--timeout-ms` to 1200000, because Pro reasoning routinely runs for many minutes; an explicit `--timeout-ms` always wins.
|
|
179
179
|
- ChatGPT keeps two surfaces, Chat and Work, with different pickers; Work's ladder has no Pro. A send puts the browser back on Chat first and notes it on the receipt, so a browser that drifted onto Work does not quietly send on the wrong picker.
|
|
180
180
|
- `--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.
|
package/docs/releasing.md
CHANGED
|
@@ -35,6 +35,51 @@ The release adds a guarded, opt-in transition and more accurate blocker reportin
|
|
|
35
35
|
**Open**
|
|
36
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
37
|
|
|
38
|
+
## 0.40.40 Release Record
|
|
39
|
+
|
|
40
|
+
Release commit `3d671f5` on `main` (cut after PRs #38-#44, last merge `5609f4c`); tag `v0.40.40`.
|
|
41
|
+
|
|
42
|
+
**Publishing**
|
|
43
|
+
- `publish.yml` run 39563515199 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-21 07:39 UTC start).
|
|
44
|
+
- `npm run release:verify` passed on the release commit after a build.
|
|
45
|
+
|
|
46
|
+
**Changelog defect**
|
|
47
|
+
- The released `CHANGELOG.md` (also inside the npm tarball) repeats five entries in the 0.40.40 section and leaves the token TTL fix (#44) under a stray `## Unreleased` heading below it. Cause: the script that resolved CHANGELOG conflicts while rebasing queued PRs copied the branch's whole Unreleased section, which already held the entries merged before it, and #44's branch added a second Unreleased section. `main` was corrected right after the release, and the GitHub Release notes were replaced with the corrected eight entries. The npm tarball cannot be changed. The rebase script now carries only lines missing from `main` and stops on duplicate lines or a second Unreleased section; the release script stops if an Unreleased section survives the rename.
|
|
48
|
+
|
|
49
|
+
**Installed**
|
|
50
|
+
- WSL, M3, Mac mini: `prodex --version` reports 0.40.40. Running services were not restarted: 9 stdio MCP servers on WSL and 1 on M3 keep older code until their clients reconnect. The Mac mini had none running.
|
|
51
|
+
- The maintainer watchdog checkout was pulled to `3d671f5` and rebuilt.
|
|
52
|
+
|
|
53
|
+
**Verified live**
|
|
54
|
+
- Installed 0.40.40 on WSL: a prompt with `[the docs](https://en.wikipedia.org/wiki/Function_(mathematics))`, refused twice as `request_mismatch` by 0.40.39, returned `done` with `request_verified: yes`.
|
|
55
|
+
- Over MCP on installed 0.40.40: `repo_search "("` explains that the query is a regular expression.
|
|
56
|
+
- `pro report-issue` preview shows `blocked at` and `prodex filing this report`; `pro ask --tool web-search` names the send command.
|
|
57
|
+
|
|
58
|
+
**Browser container**
|
|
59
|
+
- `container/redesign-patches` `1f4baa6` ports PRs #38-#41 and `5812053` ports PRs #42-#44. Branch suite at `5812053`: 2074 passed, 3 skipped; `release:verify` ok. One full run failed `project-sidebar-current-ui` "retries a covered target three times" (untouched; passed 3/3 alone and on the rerun).
|
|
60
|
+
- WSL: image `75828cfb7ed5` after #38-#41 (the autolink prompt went through live with `request_verified: yes`), then `6042260bebe7` after #42-#44 (smoke `requestVerified: true`). Both swapped under the send lock check.
|
|
61
|
+
- M3 (colima `prodex-check`): image `59fd7b2f0e89`, then `2ab687763354`; smoke `requestVerified: true` after each swap, under the send lock check.
|
|
62
|
+
|
|
63
|
+
## 0.40.39 Release Record
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
**Publishing**
|
|
68
|
+
- `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).
|
|
69
|
+
- `npm run release:verify` passed on the release commit after a build.
|
|
70
|
+
|
|
71
|
+
**Installed**
|
|
72
|
+
- 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.
|
|
73
|
+
- The maintainer watchdog checkout was pulled to `c8a170f` and rebuilt.
|
|
74
|
+
|
|
75
|
+
**Verified live**
|
|
76
|
+
- Installed 0.40.39 on WSL: `prodex ask "- list these"` refuses with the hint to put `--` before the prompt.
|
|
77
|
+
|
|
78
|
+
**Browser container**
|
|
79
|
+
- `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.
|
|
80
|
+
- 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.
|
|
81
|
+
- M3 (colima `prodex-check`): image `ad0746f93e40`, swapped under the send lock check; `pro browser smoke` in the container returned `requestVerified: true`.
|
|
82
|
+
|
|
38
83
|
## 0.40.38 Release Record
|
|
39
84
|
|
|
40
85
|
Release commit `9541727` on `main` (cut after PR #32, merge `2c049b5`); tag `v0.40.38`.
|