@youdie006/prodex 0.40.42 → 0.40.44

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.
@@ -2,7 +2,7 @@ import { lstat } from "node:fs/promises";
2
2
  import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
3
3
  const required = [
4
4
  "tasks/*.json", "results/*.json", "sessions/*.json", "receipts/*.json",
5
- "artifacts/*", "config.local.json", "receipt-key.local", "last-browser-send", "!.gitignore"
5
+ "artifacts/*", "diagnostics/", "config.local.json", "receipt-key.local", "last-browser-send", "!.gitignore"
6
6
  ];
7
7
  export async function ensureBridgeGitignore(filePath, validate) {
8
8
  let current = "";
package/dist/cli-args.js CHANGED
@@ -404,3 +404,13 @@ export function printHelpIfRequested(args, command, stdout, printHelp, options =
404
404
  printHelp(stdout);
405
405
  return true;
406
406
  }
407
+ /**
408
+ * A project is found and created by its name, so a name that is only spaces
409
+ * names nothing: it used to reach the browser, where --project-new typed it
410
+ * into ChatGPT's new-project dialog.
411
+ */
412
+ export function assertProjectName(flag, value) {
413
+ if (value !== undefined && !value.trim()) {
414
+ throw new Error(`${flag} is blank; give the ChatGPT project name, or leave the option out.`);
415
+ }
416
+ }
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { assertNoExtraArgs, assertOnlyOptions, isHelpSubcommand, printHelpIfRequested, readFlag, readPositionalsWithOptions, readReceiptKindFlag, readRepeatedFlag, readSessionStatusFlag, readTaskStatusFlag, resolveCwdFlag, unknownSubcommandError } from "./cli-args.js";
2
3
  import { printReceiptsHelp, printResultsHelp, printSessionsHelp, printTasksHelp } from "./cli-help.js";
3
4
  import { sourceAwareResultError } from "./cli-shared.js";
@@ -355,7 +356,8 @@ export function receiptInspectionListSuffix(receipt) {
355
356
  return "";
356
357
  }
357
358
  export async function writeTaskCompleteArtifacts(store, values) {
358
- const artifacts = [];
359
+ const parsed = [];
360
+ const seen = new Set();
359
361
  for (const value of values) {
360
362
  const separator = value.indexOf("=");
361
363
  if (separator <= 0) {
@@ -366,6 +368,17 @@ export async function writeTaskCompleteArtifacts(store, values) {
366
368
  if (!artifactPath.trim()) {
367
369
  throw new Error("tasks complete --artifact requires path=text");
368
370
  }
371
+ // Checked before anything is written: a repeated path used to overwrite
372
+ // the earlier text silently and be recorded twice.
373
+ const key = path.posix.normalize(artifactPath.replaceAll("\\", "/"));
374
+ if (seen.has(key)) {
375
+ throw new Error(`tasks complete --artifact ${artifactPath} names a file more than once; pass each artifact path once.`);
376
+ }
377
+ seen.add(key);
378
+ parsed.push({ artifactPath, content });
379
+ }
380
+ const artifacts = [];
381
+ for (const { artifactPath, content } of parsed) {
369
382
  const storedPath = await store.writeArtifactText(artifactPath, content);
370
383
  artifacts.push({ path: storedPath, role: "result" });
371
384
  }
package/dist/cli-pro.js CHANGED
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { BrowserProcessInspectionError } from "./browser-process.js";
6
6
  import { buildDryRunBundle } from "./bundle.js";
7
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
- 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";
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, assertProjectName } from "./cli-args.js";
9
9
  import { printProBrowserHelp, printProHelp } from "./cli-help.js";
10
10
  import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
11
11
  import { formatBrowserDefaults, redactServerUrl } from "./cli-server.js";
@@ -1270,6 +1270,8 @@ export async function runAskProCommand(rest, io, beforeSend) {
1270
1270
  const sessionKey = resolveProdexSessionKey(readFlag(parsedAskPro.optionArgs, "--session-key"));
1271
1271
  const explicitProject = readFlag(parsedAskPro.optionArgs, "--project");
1272
1272
  const explicitProjectNew = readFlag(parsedAskPro.optionArgs, "--project-new");
1273
+ assertProjectName("--project", explicitProject);
1274
+ assertProjectName("--project-new", explicitProjectNew);
1273
1275
  // Opt out of a pinned default project for one send. Without this a repo
1274
1276
  // that pins a project has no route back to the plain chat list.
1275
1277
  const suppressProject = parsedAskPro.optionArgs.includes("--no-project");
@@ -3050,9 +3052,15 @@ export function readSinceFlag(args) {
3050
3052
  if (relative) {
3051
3053
  const amount = Number(relative[1]);
3052
3054
  const hours = relative[2]?.toLowerCase() === "d" ? amount * 24 : amount;
3053
- return new Date(Date.now() - hours * 3_600_000).toISOString();
3055
+ const since = new Date(Date.now() - hours * 3_600_000);
3056
+ if (!Number.isFinite(since.getTime())) {
3057
+ throw new Error(`--since ${raw} reaches too far back to be a date; use an age like 365d or a date like 2026-01-01.`);
3058
+ }
3059
+ return since.toISOString();
3054
3060
  }
3055
- const parsed = Date.parse(raw);
3061
+ // Only a calendar date: Date.parse also reads a bare "7" as 2001-07-01, so a
3062
+ // forgotten unit silently widened the report to every consult ever recorded.
3063
+ const parsed = /^\d{4}-\d{2}-\d{2}(?:[T ]|$)/.test(raw.trim()) ? Date.parse(raw) : Number.NaN;
3056
3064
  if (!Number.isFinite(parsed)) {
3057
3065
  throw new Error(`--since expects a date or an age like 7d or 24h, not "${raw}".`);
3058
3066
  }
@@ -1,10 +1,10 @@
1
- import { lstat, mkdir } from "node:fs/promises";
1
+ import { lstat, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { parseProMode, parseReasoningEffort } from "./chatgpt-browser.js";
4
- import { ASK_PRO_SELECTION_CLEAR_FLAGS, ASK_PRO_SELECTION_DEFAULT_FLAGS, assertNoExtraArgs, assertOnlyOptions, isHelpSubcommand, printHelpIfRequested, readFlag, readPortFlag, readPositiveNumberFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
4
+ import { ASK_PRO_SELECTION_CLEAR_FLAGS, ASK_PRO_SELECTION_DEFAULT_FLAGS, assertNoExtraArgs, assertOnlyOptions, isHelpSubcommand, printHelpIfRequested, readFlag, readPortFlag, readPositiveNumberFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError, assertProjectName } from "./cli-args.js";
5
5
  import { printInitHelp, printSetupHelp, printStartHelp, printStatusHelp, printTunnelHelp, printTunnelUrlHelp } from "./cli-help.js";
6
6
  import { errorMessage, isLoopbackHost, isMissingFileError, sourceAwareSetupMessage } from "./cli-shared.js";
7
- import { getTokenExpiryStatus, loadLocalConfig, writeLocalConfig, composeServerUrlWithToken } from "./config.js";
7
+ import { getTokenExpiryStatus, loadLocalConfig, writeLocalConfig, composeServerUrlWithToken, lowercaseFirst } from "./config.js";
8
8
  import { startHttpMcpServer } from "./http-mcp.js";
9
9
  import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
10
10
  import { BridgeStore } from "./store.js";
@@ -245,13 +245,21 @@ export const TOKEN_BEARING_MCP_URL_AUTHORITY_WARNING = "Token-bearing MCP URL au
245
245
  export function assertTokenNotExpiredForCommand(config, sourceCli, setupHintCwd) {
246
246
  const tokenStatus = getTokenExpiryStatus(config);
247
247
  if (tokenStatus.status === "expired") {
248
- throw new Error(sourceAwareSetupMessage(tokenStatus.warning.toLowerCase(), sourceCli, { cwd: setupHintCwd }));
248
+ throw new Error(sourceAwareSetupMessage(lowercaseFirst(tokenStatus.warning), sourceCli, { cwd: setupHintCwd }));
249
249
  }
250
250
  }
251
+ /**
252
+ * Root ignores for a prodex source checkout. `.bridge/.gitignore` is written by
253
+ * BridgeStore.ensure() from the one shared list; this used to overwrite it with
254
+ * an older list that lacked last-browser-send.
255
+ *
256
+ * node_modules/ and dist/ go into the ROOT .gitignore only when the target is
257
+ * prodex itself: added to any repo, they silently stopped tracking new build
258
+ * output in projects that commit dist/ (JavaScript GitHub Actions do).
259
+ */
251
260
  export async function ensureBridgeGitignore(cwd) {
252
- const bridgeIgnorePath = path.join(cwd, ".bridge", ".gitignore");
253
- await mkdir(path.dirname(bridgeIgnorePath), { recursive: true });
254
- await writeVerifiedUtf8File(bridgeIgnorePath, ["tasks/*.json", "results/*.json", "sessions/*.json", "receipts/*.json", "artifacts/*", "config.local.json", "receipt-key.local", "!.gitignore", ""].join("\n"), () => assertGitignoreTargetSafe(bridgeIgnorePath), { create: true });
261
+ if (!(await isProdexSourceCheckout(cwd)))
262
+ return;
255
263
  const rootIgnorePath = path.join(cwd, ".gitignore");
256
264
  let current = "";
257
265
  try {
@@ -267,6 +275,15 @@ export async function ensureBridgeGitignore(cwd) {
267
275
  await writeVerifiedUtf8File(rootIgnorePath, `${current}${current && !current.endsWith("\n") ? "\n" : ""}${additions.join("\n")}\n`, () => assertGitignoreTargetSafe(rootIgnorePath), { create: true });
268
276
  }
269
277
  }
278
+ async function isProdexSourceCheckout(cwd) {
279
+ try {
280
+ const manifest = JSON.parse(await readFile(path.join(cwd, "package.json"), "utf8"));
281
+ return manifest.name === "@youdie006/prodex";
282
+ }
283
+ catch {
284
+ return false;
285
+ }
286
+ }
270
287
  export function formatBrowserDefaults(defaults) {
271
288
  const parts = [];
272
289
  if (defaults.model)
@@ -324,6 +341,7 @@ export function parseBrowserDefaultFlags(args) {
324
341
  const proModeRaw = readFlag(args, "--pro-mode");
325
342
  const effortRaw = readFlag(args, "--effort");
326
343
  const project = readFlag(args, "--project");
344
+ assertProjectName("--project", project);
327
345
  if (proModeRaw !== undefined && effortRaw !== undefined) {
328
346
  throw new Error("setup cannot combine --pro-mode and --effort; Pro sub-modes and reasoning effort are different model axes.");
329
347
  }
package/dist/config.js CHANGED
@@ -280,10 +280,18 @@ export function getTokenExpiryStatus(config, now = new Date()) {
280
280
  }
281
281
  : { status: "valid", token_expires_at: config.token_expires_at };
282
282
  }
283
+ /**
284
+ * "Token expired ..." reads as "failed token expired ..." after a status
285
+ * prefix. Only the first letter: lowercasing the whole message printed the
286
+ * expiry as "2026-10-26t00:00:00.000z", which is no longer an ISO time.
287
+ */
288
+ export function lowercaseFirst(message) {
289
+ return message.charAt(0).toLowerCase() + message.slice(1);
290
+ }
283
291
  export function assertTokenNotExpired(config, now = new Date()) {
284
292
  const status = getTokenExpiryStatus(config, now);
285
293
  if (status.status === "expired") {
286
- throw new Error(status.warning.toLowerCase());
294
+ throw new Error(lowercaseFirst(status.warning));
287
295
  }
288
296
  }
289
297
  const MAX_TOKEN_EXPIRES_AT_MS = Date.parse("9999-12-31T23:59:59.999Z");
package/dist/http-mcp.js CHANGED
@@ -13,7 +13,16 @@ export async function startHttpMcpServer(options) {
13
13
  const transports = new Map();
14
14
  const server = http.createServer(async (req, res) => {
15
15
  try {
16
- const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
16
+ // Only the path and query are read, so the base is fixed: taking it from
17
+ // the Host header turned a malformed header into a 500.
18
+ let requestUrl;
19
+ try {
20
+ requestUrl = new URL(req.url ?? "/", "http://localhost");
21
+ }
22
+ catch {
23
+ writeJson(res, 400, { error: "bad_request" });
24
+ return;
25
+ }
17
26
  if (requestUrl.pathname === "/health") {
18
27
  writeJson(res, 200, { ok: true, name: "prodex" });
19
28
  return;
@@ -241,8 +250,9 @@ function isAuthorized(req, url, token, tokenExpiresAt) {
241
250
  const provided = url.searchParams.get("prodex_token");
242
251
  if (provided !== null && safeEqual(provided, token))
243
252
  return true;
244
- const authorization = headerValue(req.headers.authorization);
245
- return authorization !== undefined && safeEqual(authorization, `Bearer ${token}`);
253
+ // The scheme is case-insensitive (RFC 7235): "bearer <token>" is the same.
254
+ const bearer = /^bearer +(.+)$/i.exec(headerValue(req.headers.authorization) ?? "");
255
+ return bearer !== null && safeEqual(bearer[1], token);
246
256
  }
247
257
  function isExpired(tokenExpiresAt) {
248
258
  const expiresAtMs = Date.parse(tokenExpiresAt);
package/dist/registry.js CHANGED
@@ -46,6 +46,24 @@ async function directoryExists(dir) {
46
46
  return false;
47
47
  }
48
48
  }
49
+ /**
50
+ * Which directory a path names, independent of how it is spelled. realpath
51
+ * resolves symlinks but keeps the letter case it was given, so on a
52
+ * case-insensitive drive (WSL /mnt/d, macOS) /mnt/d/MyProject/x and
53
+ * /mnt/d/myproject/x are one directory under two names. Undefined when the
54
+ * path is not a directory, or the filesystem reports no inode to compare.
55
+ */
56
+ async function directoryIdentity(dir) {
57
+ try {
58
+ const stat = await fs.stat(dir, { bigint: true });
59
+ if (!stat.isDirectory() || stat.ino === 0n)
60
+ return undefined;
61
+ return `${stat.dev}:${stat.ino}`;
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
49
67
  /**
50
68
  * Every bridge root this machine knows about. The registry has only ever been
51
69
  * written; reading it is what lets a report span the repos where the failures
@@ -67,14 +85,24 @@ export async function readBridgeRoots() {
67
85
  if (!Array.isArray(raw))
68
86
  return [];
69
87
  const roots = [];
88
+ const identities = new Set();
70
89
  for (const entry of raw.slice(0, MAX_REGISTRY_ROOTS)) {
71
90
  const value = typeof entry === "string" ? entry : entry?.path;
72
91
  if (typeof value !== "string" || value.length === 0)
73
92
  continue;
74
93
  if (!(await directoryExists(path.join(value, ".bridge"))))
75
94
  continue;
76
- if (!roots.includes(value))
77
- roots.push(value);
95
+ if (roots.includes(value))
96
+ continue;
97
+ // One bridge listed under two spellings was read twice: measured, a
98
+ // machine-wide `pro blockers` counted 1247 consults where 648 exist.
99
+ const identity = await directoryIdentity(value);
100
+ if (identity !== undefined) {
101
+ if (identities.has(identity))
102
+ continue;
103
+ identities.add(identity);
104
+ }
105
+ roots.push(value);
78
106
  }
79
107
  return roots;
80
108
  }
@@ -111,10 +139,15 @@ async function registerBridgeRootInner(root) {
111
139
  // A genuinely new root: opportunistically prune entries whose directory
112
140
  // is gone (cheap here since this path only runs on first registration,
113
141
  // not on every ensure()) and cap total growth, keeping the newest.
142
+ const identity = await directoryIdentity(abs);
114
143
  const survivors = [];
115
144
  for (const existing of roots) {
116
- if (await directoryExists(existing))
117
- survivors.push(existing);
145
+ if (!(await directoryExists(existing)))
146
+ continue;
147
+ // Already listed under another spelling (see directoryIdentity).
148
+ if (identity !== undefined && (await directoryIdentity(existing)) === identity)
149
+ return;
150
+ survivors.push(existing);
118
151
  }
119
152
  survivors.push(abs);
120
153
  roots = survivors.length > MAX_REGISTRY_ROOTS ? survivors.slice(survivors.length - MAX_REGISTRY_ROOTS) : survivors;
package/dist/safe-file.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { constants } from "node:fs";
2
+ import { constants, readFileSync } from "node:fs";
3
3
  import { chmod, link, lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  let testHooks = {};
6
7
  export function setSafeFileTestHooks(hooks) {
@@ -38,7 +39,7 @@ export async function withCrossProcessFileLock(filePath, options, fn) {
38
39
  continue;
39
40
  }
40
41
  retriedMissingAtDeadline = false;
41
- const holderAlive = snapshot.holder.pid !== undefined && processIsAlive(snapshot.holder.pid);
42
+ const holderAlive = snapshot.holder.pid !== undefined && processIsAlive(snapshot.holder.pid) && !pidReusedSince(snapshot.holder);
42
43
  if (!holderAlive && (await reapFileLockSnapshot(filePath, snapshot, parentSnapshot)))
43
44
  continue;
44
45
  if (Date.now() >= deadline) {
@@ -419,6 +420,9 @@ async function tryAcquireFileLock(filePath, parentSnapshot) {
419
420
  nonce: randomUUID(),
420
421
  started_at: new Date().toISOString()
421
422
  };
423
+ const pidStart = linuxProcessStartTicks(process.pid);
424
+ if (pidStart !== undefined)
425
+ owner.pid_start = pidStart;
422
426
  const tokenPath = fileLockTokenPath(filePath, owner);
423
427
  const handle = await openStableNoFollow(tokenPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, "write", parentSnapshot, 0o600);
424
428
  try {
@@ -487,7 +491,8 @@ function parseFileLockHolder(content) {
487
491
  return {
488
492
  pid: Number.isSafeInteger(parsed.pid) && Number(parsed.pid) > 0 ? Number(parsed.pid) : undefined,
489
493
  nonce: typeof parsed.nonce === "string" && /^[A-Za-z0-9-]{1,128}$/.test(parsed.nonce) ? parsed.nonce : undefined,
490
- started_at: typeof parsed.started_at === "string" ? parsed.started_at : undefined
494
+ started_at: typeof parsed.started_at === "string" ? parsed.started_at : undefined,
495
+ pid_start: typeof parsed.pid_start === "string" && /^\d{1,20}$/.test(parsed.pid_start) ? parsed.pid_start : undefined
491
496
  };
492
497
  }
493
498
  catch {
@@ -497,7 +502,12 @@ function parseFileLockHolder(content) {
497
502
  function parseFileLockOwner(holder) {
498
503
  if (holder.pid === undefined || holder.nonce === undefined)
499
504
  return undefined;
500
- return { pid: holder.pid, nonce: holder.nonce, started_at: holder.started_at };
505
+ return {
506
+ pid: holder.pid,
507
+ nonce: holder.nonce,
508
+ started_at: holder.started_at,
509
+ ...(holder.pid_start !== undefined ? { pid_start: holder.pid_start } : {})
510
+ };
501
511
  }
502
512
  async function releaseFileLockLease(filePath, lease, parentSnapshot) {
503
513
  try {
@@ -589,6 +599,48 @@ function processIsAlive(pid) {
589
599
  return error.code === "EPERM";
590
600
  }
591
601
  }
602
+ /**
603
+ * Whether the holder's pid now names a different process than the one that
604
+ * wrote the lock, so the writer is gone. A send that dies with the machine,
605
+ * the WSL VM or its container leaves the lock behind, and after a restart pids
606
+ * are handed out again: measured, a 2020 lock naming pid 1 held off every send
607
+ * for its whole wait budget because pid 1 was alive - as a different process.
608
+ *
609
+ * The lock records its writer's start time in clock ticks since boot, and that
610
+ * is compared with the pid's start ticks now: boot clock against boot clock.
611
+ * 0.40.43 converted the start to wall time through /proc/stat btime instead,
612
+ * and on WSL2 btime moves (measured 30 minutes over one day: the VM's boot
613
+ * clock stops while the host sleeps, then the wall clock is resynced), so a
614
+ * live holder looked newer than its own lock and its lock was reaped mid-send.
615
+ */
616
+ function pidReusedSince(holder) {
617
+ if (holder.pid === undefined)
618
+ return false;
619
+ if (holder.pid_start !== undefined) {
620
+ const current = linuxProcessStartTicks(holder.pid);
621
+ return current !== undefined && current !== holder.pid_start;
622
+ }
623
+ // A lock from a prodex that did not record start ticks: the only safe call is
624
+ // that nothing alive now was running before the last boot.
625
+ const writtenAt = Date.parse(holder.started_at ?? "");
626
+ if (!Number.isFinite(writtenAt))
627
+ return false;
628
+ return writtenAt < Date.now() - os.uptime() * 1000 - 60_000;
629
+ }
630
+ function linuxProcessStartTicks(pid) {
631
+ if (process.platform !== "linux")
632
+ return undefined;
633
+ try {
634
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
635
+ // Field 22 (starttime) counted after the parenthesised command name, which
636
+ // may itself contain spaces.
637
+ const ticks = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
638
+ return ticks !== undefined && /^\d+$/.test(ticks) ? ticks : undefined;
639
+ }
640
+ catch {
641
+ return undefined;
642
+ }
643
+ }
592
644
  function delay(ms) {
593
645
  return new Promise((resolve) => setTimeout(resolve, ms));
594
646
  }
package/dist/store.js CHANGED
@@ -233,6 +233,7 @@ export class BridgeStore {
233
233
  async completeTask(taskId, input) {
234
234
  const task = await this.getTask(taskId);
235
235
  assertFetchableResultArtifacts(input.artifacts ?? []);
236
+ assertDistinctArtifactPaths(input.artifacts ?? []);
236
237
  const retryResult = ResultSchema.parse({
237
238
  schema_version: SCHEMA_VERSION,
238
239
  task_id: taskId,
@@ -834,7 +835,7 @@ export class BridgeStore {
834
835
  }
835
836
  async ensureReceiptIntegrityKey() {
836
837
  try {
837
- const [activeKey] = await this.readReceiptIntegrityKeys();
838
+ const [activeKey] = await this.readReceiptIntegrityKeysAfterCreate();
838
839
  return activeKey;
839
840
  }
840
841
  catch (error) {
@@ -857,10 +858,27 @@ export class BridgeStore {
857
858
  catch (error) {
858
859
  if (!isErrorCode(error, "EEXIST"))
859
860
  throw error;
860
- const [activeKey] = await this.readReceiptIntegrityKeys();
861
+ const [activeKey] = await this.readReceiptIntegrityKeysAfterCreate();
861
862
  return activeKey;
862
863
  }
863
864
  }
865
+ // The key is created exclusively and THEN written, so another store opening
866
+ // the same fresh repo can read it in between and find it empty. Measured on
867
+ // CI: that read was reported as "receipt-key.local is corrupt. Move it
868
+ // aside", advice that would discard a good key. An empty file is a write in
869
+ // progress for a short while; only after that is it corrupt.
870
+ async readReceiptIntegrityKeysAfterCreate() {
871
+ for (let attempt = 0;; attempt += 1) {
872
+ try {
873
+ return await this.readReceiptIntegrityKeys();
874
+ }
875
+ catch (error) {
876
+ if (!(error instanceof ReceiptKeyFileEmptyError) || attempt >= 40)
877
+ throw error;
878
+ await new Promise((resolve) => setTimeout(resolve, 50));
879
+ }
880
+ }
881
+ }
864
882
  // The key file holds one 32-byte hex key per line: the first line signs new
865
883
  // receipts, later lines are legacy keys kept so pre-rotation receipts verify.
866
884
  async readReceiptIntegrityKeys() {
@@ -870,7 +888,9 @@ export class BridgeStore {
870
888
  .split(/\r?\n/)
871
889
  .map((line) => line.trim())
872
890
  .filter((line) => line.length > 0);
873
- if (lines.length === 0 || !lines.every((line) => /^[a-f0-9]{64}$/.test(line))) {
891
+ if (lines.length === 0)
892
+ throw new ReceiptKeyFileEmptyError();
893
+ if (!lines.every((line) => /^[a-f0-9]{64}$/.test(line))) {
874
894
  throw new Error(".bridge/receipt-key.local is corrupt. Move it aside and recreate write receipts.");
875
895
  }
876
896
  return lines;
@@ -1462,6 +1482,23 @@ function assertFetchableResultArtifacts(artifacts) {
1462
1482
  }
1463
1483
  }
1464
1484
  }
1485
+ // Two entries for one file record one content under two names: the file holds
1486
+ // whichever was written last, and a reader asking for "the" result artifact is
1487
+ // told there are several.
1488
+ function assertDistinctArtifactPaths(artifacts) {
1489
+ const seen = new Set();
1490
+ for (const artifact of artifacts) {
1491
+ if (seen.has(artifact.path)) {
1492
+ throw new Error(`Artifact ${artifact.path} is listed more than once; list each artifact once.`);
1493
+ }
1494
+ seen.add(artifact.path);
1495
+ }
1496
+ }
1497
+ class ReceiptKeyFileEmptyError extends Error {
1498
+ constructor() {
1499
+ super(".bridge/receipt-key.local is corrupt. Move it aside and recreate write receipts.");
1500
+ }
1501
+ }
1465
1502
  function parseTaskRecord(expectedId, value) {
1466
1503
  const task = TaskSchema.parse(value);
1467
1504
  assertMatchingRecordIdentity("task", expectedId, task.id);
package/dist/tui.js CHANGED
@@ -63,10 +63,15 @@ export function consultArgsFromChoices(choices) {
63
63
  */
64
64
  export function parseAttachmentLine(line) {
65
65
  const out = [];
66
- const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
66
+ // An unquoted word may carry backslash escapes: dropping a file onto a macOS
67
+ // terminal types `My\ File.pdf`, which used to split into two paths that do
68
+ // not exist. Only a space or shell punctuation counts as escaped, so a
69
+ // Windows separator (a backslash before a letter or another backslash) stays.
70
+ const pattern = /"([^"]*)"|'([^']*)'|((?:\\[ ()\[\]{}'"&;!$`*?#~<>|]|\S)+)/g;
67
71
  let match;
68
72
  while ((match = pattern.exec(line)) !== null) {
69
- const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
73
+ const unquoted = match[3]?.replace(/\\([ ()\[\]{}'"&;!$`*?#~<>|])/g, "$1");
74
+ const value = (match[1] ?? match[2] ?? unquoted ?? "").trim();
70
75
  if (value.length > 0)
71
76
  out.push(value);
72
77
  }
package/docs/releasing.md CHANGED
@@ -35,6 +35,49 @@ 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.43 Release Record
39
+
40
+ Release commit `834e3e2` on `main` (cut after PRs #49-#58, last merge `a6bbf85`); tag `v0.40.43`. Ten fixes, including two that block or corrupt work: a send lock left by a dead send whose pid was reused blocked every later send (#54), and two processes opening a fresh repo could fail one with a false "receipt-key.local is corrupt" (#58).
41
+
42
+ **Publishing**
43
+ - `publish.yml` run 40451563636 first failed on macOS 15 Intel only: `tests/cli.test.ts` "uses an explicit --cwd target for local HTTP MCP start" timed out at 90 s; the same commit passed on the five other platforms and in PR CI. The failed job was rerun and passed, then npm publish with SLSA v1 provenance and the GitHub Release ran. The GitHub Release lists the ten entries from `CHANGELOG.md`.
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.43. Running services were not restarted: no host stdio MCP servers on WSL, 1 on M3, none on the Mac mini.
48
+ - The maintainer watchdog checkout was pulled to `834e3e2` and rebuilt.
49
+
50
+ **Container**
51
+ - `container/redesign-patches` `f72862c` ports #49-#58; full suite (164 files, 2098 tests) and `release:verify` pass on it.
52
+ - WSL: image rebuilt and the container swapped to `1d48d753fbf7`; the in-container send lock was idle before and after.
53
+ - M3: not rebuilt in this release. The build over ssh was refused by the agent's permission classifier; the M3 container still runs the 0.40.42 port.
54
+
55
+ **Verified live on installed 0.40.43 (WSL)**
56
+ - `pro blockers --since 7` refused: "--since expects a date or an age like 7d or 24h".
57
+ - `pro blockers --json` across the machine: 21 roots and 657 consults (before: 27 roots, about twice the consults, counting case-variant paths of the same repo).
58
+ - `ask --project " "` refused: "--project is blank".
59
+
60
+ ## 0.40.42 Release Record
61
+
62
+ Release commit `9c97a14` on `main` (cut after PR #48, merge `cc129d8`); tag `v0.40.42`. Released on its own: before it, `--attach` and MCP `attach` uploaded `.env`, `.git` contents, key files and outward symlinks.
63
+
64
+ **Publishing**
65
+ - `publish.yml` run 40284687723 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-26 07:15 UTC start). The GitHub Release lists the one entry from `CHANGELOG.md`.
66
+ - `npm run release:verify` passed on the release commit after a build.
67
+
68
+ **Installed**
69
+ - WSL, M3, Mac mini: `prodex --version` reports 0.40.42. Running services were not restarted: no host stdio MCP servers on WSL, 1 on M3, none on the Mac mini.
70
+ - The maintainer watchdog checkout was pulled to `9c97a14` and rebuilt.
71
+
72
+ **Verified live on installed 0.40.42 (WSL, scratch repo)**
73
+ - `--attach .env` refused: "Path .env is sensitive and cannot be read through repo tools".
74
+ - `--attach link.pdf` (symlink to a file outside the repo) refused: "escapes the repository root after resolving symlinks".
75
+ - `--attach note.txt` still uploads; ChatGPT answered with the code word from the file, `request_verified: yes`.
76
+
77
+ **Browser container**
78
+ - `container/redesign-patches` `41c34de` ports PR #48. Branch suite: 2081 passed, 3 skipped; `release:verify` ok.
79
+ - WSL image `491971a34a26` and M3 image `59747258b693`, each swapped after an idle send-lock check; in both, `--attach .env.probe` is refused and smoke returned `requestVerified: true`.
80
+
38
81
  ## 0.40.41 Release Record
39
82
 
40
83
  Release commit `e0ea707` on `main` (cut after PRs #45-#47, last merge `6911f3e`); tag `v0.40.41`. Released ahead of the usual batch for PR #46, which withheld answers from requests that had posted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.42",
3
+ "version": "0.40.44",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",