@youdie006/prodex 0.40.39 → 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.
@@ -6160,7 +6160,9 @@ export function transcriptContainsWholeSentPrompt(userText, sentPrompt) {
6160
6160
  }
6161
6161
  const NORMALIZED_PROMPT_MATCH_CHARS = 120;
6162
6162
  // 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).
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.
6164
6166
  const AUTOLINKED_URL_PATTERN = /\[(https?:\/\/[^\]\s]+)\]\(\1\)/g;
6165
6167
  /**
6166
6168
  * ChatGPT displays a sent turn's HTML entities decoded (measured 2026-10-19:
@@ -6178,7 +6180,7 @@ function decodeHtmlEntities(value) {
6178
6180
  });
6179
6181
  }
6180
6182
  function normalizeChatGptPromptText(value) {
6181
- const unescaped = decodeHtmlEntities(value).replace(AUTOLINKED_URL_PATTERN, "$1").replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1");
6183
+ const unescaped = decodeHtmlEntities(value).replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1").replace(AUTOLINKED_URL_PATTERN, "$1");
6182
6184
  const renderedFences = unescaped.replace(/(^|\r?\n)```text[ \t]*\r?\n([\s\S]*?)\r?\n```(?=\r?\n|$)/g, "$1text\n$2");
6183
6185
  return renderedFences.replace(/\s+/g, " ").trim();
6184
6186
  }
@@ -6210,8 +6212,8 @@ export function chatGptRequestMatchesUserTurn(userText, sentPrompt, requestId) {
6210
6212
  }
6211
6213
  function withoutFenceLines(value) {
6212
6214
  return decodeHtmlEntities(value)
6213
- .replace(AUTOLINKED_URL_PATTERN, "$1")
6214
6215
  .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
6216
+ .replace(AUTOLINKED_URL_PATTERN, "$1")
6215
6217
  .replace(/^[ \t]*```[\w-]*[ \t]*$/gm, "")
6216
6218
  .replace(/\s+/g, "");
6217
6219
  }
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 it with the friendly
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] [--attach path] [--tool deep-research|web-search|create-image] "prompt" # dry-run preview
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] [--attach path] [--tool deep-research|web-search|create-image]
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] [--attach path] [--tool deep-research|web-search|create-image] "prompt"
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] [--attach path] [--tool deep-research|web-search|create-image]
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
- if (!path.isAbsolute(file))
1179
- return file;
1180
- // Accept an absolute --file that points INSIDE the repo by converting it to
1181
- // the repo-relative path the reader requires (agents naturally pass absolute
1182
- // paths); reject one that escapes the repo so a file outside the project
1183
- // (secrets, ~/.ssh, ...) is not attached to a consult by mistake.
1184
- const rel = path.relative(targetCwd, path.resolve(file));
1185
- if (rel === "" || rel.startsWith("..") || path.isAbsolute(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("..") || path.isAbsolute(rel)) {
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()) {
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) {
@@ -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
- `| prodex | ${environment.version} |`,
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),
@@ -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
- if (current.content === input.content) {
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(input.content);
25
- const diff = buildSimpleDiff(input.path, current.content, input.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, input.content);
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/releasing.md CHANGED
@@ -35,6 +35,26 @@ 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.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
+
38
58
  ## 0.40.38 Release Record
39
59
 
40
60
  Release commit `9541727` on `main` (cut after PR #32, merge `2c049b5`); tag `v0.40.38`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.39",
3
+ "version": "0.40.40",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",