@youdie006/prodex 0.40.43 → 0.40.45

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.
@@ -12,6 +12,14 @@ function lockPath() {
12
12
  return override;
13
13
  return path.join(os.homedir(), ".local", "share", "prodex", "browser-send.lock");
14
14
  }
15
+ /**
16
+ * When the last visible-browser send on this machine started. It sits beside
17
+ * the send lock because it paces the same shared browser: kept per repo, sends
18
+ * from two repos (or two worktrees of one) were never paced against each other.
19
+ */
20
+ export function sendPacingMarkerPath() {
21
+ return path.join(path.dirname(lockPath()), "last-browser-send");
22
+ }
15
23
  /**
16
24
  * Serialize visible-browser sends across processes. Waits up to waitMs for a
17
25
  * live holder to finish (0 = fail fast); a lock whose holder process is dead
@@ -1337,13 +1337,27 @@ export function chatGptBusyBlocker(state) {
1337
1337
  // away from ready into a full send budget spent on "still generating".
1338
1338
  if (!state.generating || state.awaitingResponseChoice === true)
1339
1339
  return undefined;
1340
+ // The thread on screen is the one generating. It goes into the advice only:
1341
+ // `thread` on a blocker means where THIS send's prompt landed, and it landed
1342
+ // nowhere.
1343
+ let generatingThread = "<thread-url>";
1344
+ try {
1345
+ if (state.url)
1346
+ generatingThread = normalizeChatGptThreadUrl(state.url);
1347
+ }
1348
+ catch {
1349
+ // Not a conversation page; keep the placeholder.
1350
+ }
1340
1351
  return {
1341
1352
  code: "response_in_progress",
1342
1353
  message: "ChatGPT is still generating a previous response in this thread.",
1343
1354
  retryable: true,
1355
+ // It used to end "A new topic can go to a new chat instead", which a send
1356
+ // already started with --new-chat had just done: every send waits for the
1357
+ // shared tab, since leaving a thread mid-answer could lose that answer.
1344
1358
  next_step: "Wait for it to finish and retry (pass --busy-wait-ms to queue behind it longer). " +
1345
- "If that in-flight answer is the one you need, fetch it once it settles: `prodex pro browser recover --target-url <thread-url>`. " +
1346
- "A new topic can go to a new chat instead."
1359
+ "A new chat waits as well: the browser tab is shared, and leaving a thread mid-answer could lose that answer. " +
1360
+ `If that in-flight answer is the one you need, fetch it once it settles: \`prodex pro browser recover --target-url ${generatingThread}\`.`
1347
1361
  };
1348
1362
  }
1349
1363
  /**
package/dist/cli-pro.js CHANGED
@@ -11,7 +11,7 @@ import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledge
11
11
  import { formatBrowserDefaults, redactServerUrl } from "./cli-server.js";
12
12
  import { errorMessage, firstLine, formatBlockedConsultRecordedMessage, formatProLatestCommand, formatBrowserCheckCommand, formatBrowserLoginCommand, formatBrowserSmokeCommand, formatBrowserTargetAskCommand, formatInitCommand, formatSetupCommand, isMissingFileError, computeSendPacingWaitMs, isUntrustedResultError, resolveMinSendIntervalMs, sourceAwareBrowserBlocker, sourceAwareBrowserNextStep, sourceAwareResultError, sourceAwareResultMessage, sourceAwareSetupMessage } from "./cli-shared.js";
13
13
  import { getTokenExpiryStatus, loadBrowserDefaults, loadLocalConfig } from "./config.js";
14
- import { withBrowserSendLock } from "./browser-send-lock.js";
14
+ import { sendPacingMarkerPath, withBrowserSendLock } from "./browser-send-lock.js";
15
15
  import { blockerCause, buildBlockerReport, CATCH_ALL_CODES } from "./blocker-report.js";
16
16
  import { isChatGptConversationUrl, projectIdFromSidebar, resolveContinuationThread } from "./continue-thread.js";
17
17
  import { FollowupApprovalRequired, reserveFollowup, resolveMaxAutoFollowups } from "./followup-budget.js";
@@ -842,10 +842,13 @@ export async function runProCommand(rest, io, runCliFn) {
842
842
  if (!target.ok)
843
843
  throw new Error(target.reason);
844
844
  // Deleting a project is not undoable from here, so the default is a
845
- // preview: say exactly what would go, and delete nothing.
845
+ // preview: say exactly what would go, and delete nothing. The browser
846
+ // layer does not automate deletion at all, so the preview must not
847
+ // promise that --confirm-delete would carry it out - it used to, and
848
+ // the confirmed run then failed as unsupported.
846
849
  if (!browserArgs.includes("--confirm-delete")) {
847
- io.stdout(`Would delete project "${target.name}" (${target.id}) and everything filed under it.`);
848
- io.stdout("Nothing was deleted. Re-run with --confirm-delete to go ahead.");
850
+ io.stdout(`Deleting project "${target.name}" (${target.id}) would remove everything filed under it.`);
851
+ io.stdout("Nothing was deleted. prodex does not delete projects itself: delete it from its menu in the ChatGPT UI.");
849
852
  return 0;
850
853
  }
851
854
  await deleteChatGptProject({
@@ -1109,11 +1112,14 @@ function legacyBrowserSubcommandReplacement(subcommand) {
1109
1112
  return "check";
1110
1113
  }
1111
1114
  // File marker + human-pacing gate for visible-browser sends. Reads the previous
1112
- // send's start time from .bridge/last-browser-send, waits out any remaining
1113
- // minimum interval (auto-throttle, not an error), then stamps the new send.
1114
- async function enforceVisibleBrowserSendPacing(cwd, stderr) {
1115
+ // send's start time from the machine-wide marker beside the send lock, waits
1116
+ // out any remaining minimum interval (auto-throttle, not an error), then stamps
1117
+ // the new send. Runs while the send lock is held: run before it, a send queued
1118
+ // behind another posted the moment the lock freed, and the start it stamped was
1119
+ // when it began waiting rather than when it sent.
1120
+ async function enforceVisibleBrowserSendPacing(stderr) {
1115
1121
  const intervalMs = resolveMinSendIntervalMs();
1116
- const markerPath = path.join(cwd, ".bridge", "last-browser-send");
1122
+ const markerPath = sendPacingMarkerPath();
1117
1123
  let lastSendAtMs;
1118
1124
  try {
1119
1125
  const parsed = Date.parse((await readFile(markerPath, "utf8")).trim());
@@ -1308,6 +1314,7 @@ export async function runAskProCommand(rest, io, beforeSend) {
1308
1314
  // Scope by the project this send would have used, so a follow-up cannot
1309
1315
  // land in another project's conversation.
1310
1316
  const continuationProject = explicitProject ?? (suppressProject ? undefined : browserDefaults?.project);
1317
+ assertSavedProjectNotBlank(continuationProject);
1311
1318
  // The name alone cannot identify a project written in another script -
1312
1319
  // measured, two of this account's projects slug to nothing - so the id
1313
1320
  // is read off the live sidebar. A sidebar that cannot be read leaves
@@ -1414,6 +1421,7 @@ export async function runAskProCommand(rest, io, beforeSend) {
1414
1421
  (normalizedTargetUrl || selectionProjectNew !== undefined || suppressProject || temporary
1415
1422
  ? undefined
1416
1423
  : browserDefaults?.project);
1424
+ assertSavedProjectNotBlank(selectionProject);
1417
1425
  const selectionProMode = selectionAxes.proMode;
1418
1426
  const selectionEffort = selectionAxes.effort;
1419
1427
  const continuationScopeProject = continuedFromTaskId
@@ -1464,7 +1472,6 @@ export async function runAskProCommand(rest, io, beforeSend) {
1464
1472
  ...(normalizedTargetUrl ? { thread: normalizedTargetUrl } : {}),
1465
1473
  ...(continuedFromTaskId ? { continuedFrom: continuedFromTaskId } : {})
1466
1474
  });
1467
- await enforceVisibleBrowserSendPacing(targetCwd, io.stderr);
1468
1475
  const browserCommandOptions = {
1469
1476
  cwd: targetCwd,
1470
1477
  port: parsedAskPro.optionArgs.includes("--port") ? browserPort : undefined
@@ -1521,28 +1528,31 @@ export async function runAskProCommand(rest, io, beforeSend) {
1521
1528
  // fail-fast default made pro_consult die instantly with advice the
1522
1529
  // caller had no way to follow; --busy-wait-ms 0 opts back into failing
1523
1530
  // fast. A dead holder is still reaped immediately.
1524
- const sendOnce = () => withBrowserSendLock(busyWaitMs ?? browserTimeoutMs ?? 0, (detail) => io.stderr(`progress: ${detail}`), () => sendChatGptPrompt({
1525
- port: browserPort,
1526
- prompt: bundle.sendText,
1527
- targetUrl: normalizedTargetUrl,
1528
- // Continuations and explicit TUI picks navigate while this lock is
1529
- // held. Direct CLI targets keep their existing verification-only semantics.
1530
- ...(continuedFromTaskId || (io.navigateInteractiveTarget === true && normalizedTargetUrl) ? { navigateToTargetUrl: true } : {}),
1531
- timeoutMs: browserTimeoutMs,
1532
- ...(attachments.length > 0 ? { attachments } : {}),
1533
- ...(tools.length > 0 ? { tools } : {}),
1534
- ...(newChat ? { newChat: true } : {}),
1535
- ...(temporary ? { temporary: true } : {}),
1536
- diagnosticsCwd: targetCwd,
1537
- ...(busyWaitMs !== undefined ? { busyWaitMs } : {}),
1538
- project: selectionProject,
1539
- projectNew: selectionProjectNew,
1540
- model: selectionModel,
1541
- proMode: selectionProMode,
1542
- effort: selectionEffort,
1543
- ...(allowSelectionFallback ? { allowSelectionFallback: true } : {}),
1544
- onProgress: createBrowserSendProgressPrinter(io.stderr)
1545
- }));
1531
+ const sendOnce = () => withBrowserSendLock(busyWaitMs ?? browserTimeoutMs ?? 0, (detail) => io.stderr(`progress: ${detail}`), async () => {
1532
+ await enforceVisibleBrowserSendPacing(io.stderr);
1533
+ return sendChatGptPrompt({
1534
+ port: browserPort,
1535
+ prompt: bundle.sendText,
1536
+ targetUrl: normalizedTargetUrl,
1537
+ // Continuations and explicit TUI picks navigate while this lock is
1538
+ // held. Direct CLI targets keep their existing verification-only semantics.
1539
+ ...(continuedFromTaskId || (io.navigateInteractiveTarget === true && normalizedTargetUrl) ? { navigateToTargetUrl: true } : {}),
1540
+ timeoutMs: browserTimeoutMs,
1541
+ ...(attachments.length > 0 ? { attachments } : {}),
1542
+ ...(tools.length > 0 ? { tools } : {}),
1543
+ ...(newChat ? { newChat: true } : {}),
1544
+ ...(temporary ? { temporary: true } : {}),
1545
+ diagnosticsCwd: targetCwd,
1546
+ ...(busyWaitMs !== undefined ? { busyWaitMs } : {}),
1547
+ project: selectionProject,
1548
+ projectNew: selectionProjectNew,
1549
+ model: selectionModel,
1550
+ proMode: selectionProMode,
1551
+ effort: selectionEffort,
1552
+ ...(allowSelectionFallback ? { allowSelectionFallback: true } : {}),
1553
+ onProgress: createBrowserSendProgressPrinter(io.stderr)
1554
+ });
1555
+ });
1546
1556
  // One-command recovery: interactive terminals (or explicit --auto-login)
1547
1557
  // launch the dedicated browser and retry once when no browser runs.
1548
1558
  // Scripts and agents keep the plain blocker unless they opt in.
@@ -2659,6 +2669,18 @@ export function resolveSelectionAxes(input) {
2659
2669
  ...(effort !== undefined ? { effort } : {})
2660
2670
  };
2661
2671
  }
2672
+ /**
2673
+ * A blank project here can only be the repo's saved default: explicit flags are
2674
+ * refused as blank before this. setup accepted one until 0.40.43, and a send
2675
+ * then searched the shared browser's sidebar for a project with no name before
2676
+ * failing as project_not_found (measured 2026-10-28).
2677
+ */
2678
+ function assertSavedProjectNotBlank(project) {
2679
+ if (project !== undefined && !project.trim()) {
2680
+ throw new Error("The default project saved for this repo in .bridge/config.local.json is blank, so there is no project to send into. " +
2681
+ 'Clear it with `prodex setup --clear-project` (or pin one with `prodex setup --project "name"`), or pass --project or --no-project for this send.');
2682
+ }
2683
+ }
2662
2684
  /**
2663
2685
  * Scrub project names out of text that gets persisted.
2664
2686
  *
@@ -2671,9 +2693,16 @@ export function resolveSelectionAxes(input) {
2671
2693
  export function redactProjectNames(text, names) {
2672
2694
  let redacted = text;
2673
2695
  // Longest first: a project named "Notes" inside "Notes Archive" would
2674
- // otherwise leave "<project> Archive" behind.
2675
- for (const name of [...names].filter((name) => Boolean(name)).sort((a, b) => b.length - a.length)) {
2676
- redacted = redacted.split(name).join("<project>");
2696
+ // otherwise leave "<project> Archive" behind. A blank name names nothing;
2697
+ // replacing it turned every space of a record into "<project>".
2698
+ for (const name of [...names].filter((name) => Boolean(name?.trim())).sort((a, b) => b.length - a.length)) {
2699
+ // As a word: a plain substring replacement read "Go" inside "Google". The
2700
+ // boundary applies only at an ASCII word character, since a name in a
2701
+ // script like Korean takes its particles directly ("회의록에서").
2702
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2703
+ const before = /^[A-Za-z0-9_]/.test(name) ? "(?<![A-Za-z0-9_])" : "";
2704
+ const after = /[A-Za-z0-9_]$/.test(name) ? "(?![A-Za-z0-9_])" : "";
2705
+ redacted = redacted.replace(new RegExp(`${before}${escaped}${after}`, "g"), "<project>");
2677
2706
  }
2678
2707
  return redacted;
2679
2708
  }
package/dist/config.js CHANGED
@@ -94,6 +94,11 @@ export function normalizeLoopbackHttpHost(host) {
94
94
  const isIpv4Loopback = isIP(normalized) === 4 && normalized.startsWith("127.");
95
95
  if (isLocalhost || isIpv4Loopback)
96
96
  return normalized;
97
+ // ::1 IS loopback; it was refused as if it were not. The listener and every
98
+ // URL prodex prints are IPv4-shaped, so say that rather than "loopback-only".
99
+ if (normalized.replace(/^\[|\]$/g, "") === "::1") {
100
+ throw new Error("HTTP MCP host ::1 (IPv6 loopback) is not supported: prodex prints and serves IPv4 URLs. Use --host 127.0.0.1 or localhost.");
101
+ }
97
102
  throw new Error("HTTP MCP host must be loopback-only, such as 127.0.0.1 or localhost. Keep prodex local and put your own tunnel in front of it when needed.");
98
103
  }
99
104
  export function assertLoopbackHttpHost(host) {
package/dist/safe-file.js CHANGED
@@ -420,6 +420,9 @@ async function tryAcquireFileLock(filePath, parentSnapshot) {
420
420
  nonce: randomUUID(),
421
421
  started_at: new Date().toISOString()
422
422
  };
423
+ const pidStart = linuxProcessStartTicks(process.pid);
424
+ if (pidStart !== undefined)
425
+ owner.pid_start = pidStart;
423
426
  const tokenPath = fileLockTokenPath(filePath, owner);
424
427
  const handle = await openStableNoFollow(tokenPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, "write", parentSnapshot, 0o600);
425
428
  try {
@@ -488,7 +491,8 @@ function parseFileLockHolder(content) {
488
491
  return {
489
492
  pid: Number.isSafeInteger(parsed.pid) && Number(parsed.pid) > 0 ? Number(parsed.pid) : undefined,
490
493
  nonce: typeof parsed.nonce === "string" && /^[A-Za-z0-9-]{1,128}$/.test(parsed.nonce) ? parsed.nonce : undefined,
491
- 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
492
496
  };
493
497
  }
494
498
  catch {
@@ -498,7 +502,12 @@ function parseFileLockHolder(content) {
498
502
  function parseFileLockOwner(holder) {
499
503
  if (holder.pid === undefined || holder.nonce === undefined)
500
504
  return undefined;
501
- 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
+ };
502
511
  }
503
512
  async function releaseFileLockLease(filePath, lease, parentSnapshot) {
504
513
  try {
@@ -591,36 +600,42 @@ function processIsAlive(pid) {
591
600
  }
592
601
  }
593
602
  /**
594
- * Whether the holder's pid now names a process that started after the lock was
595
- * written, so the writer is gone. A send that dies with the machine, the WSL VM
596
- * or its container leaves the lock behind, and after a restart pids are handed
597
- * out again: measured, a 2020 lock naming pid 1 held off every send for its
598
- * whole wait budget because pid 1 was alive - as a different process.
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.
599
615
  */
600
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.
601
625
  const writtenAt = Date.parse(holder.started_at ?? "");
602
- if (holder.pid === undefined || !Number.isFinite(writtenAt))
626
+ if (!Number.isFinite(writtenAt))
603
627
  return false;
604
- const processStartedAt = linuxProcessStartMs(holder.pid);
605
- // A holder writes the lock after it starts; a few seconds absorb clock steps.
606
- if (processStartedAt !== undefined)
607
- return processStartedAt > writtenAt + 5_000;
608
- // No per-process start time here: nothing alive now predates the boot.
609
628
  return writtenAt < Date.now() - os.uptime() * 1000 - 60_000;
610
629
  }
611
- function linuxProcessStartMs(pid) {
630
+ function linuxProcessStartTicks(pid) {
612
631
  if (process.platform !== "linux")
613
632
  return undefined;
614
633
  try {
615
- const btime = /^btime (\d+)$/m.exec(readFileSync("/proc/stat", "utf8"))?.[1];
616
634
  const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
617
- // Field 22 (starttime, in clock ticks since boot) counted after the
618
- // parenthesised command name, which may itself contain spaces.
619
- const ticks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
620
- if (!btime || !Number.isFinite(ticks))
621
- return undefined;
622
- // USER_HZ is 100 on every Linux ABI Node supports.
623
- return Number(btime) * 1000 + ticks * 10;
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;
624
639
  }
625
640
  catch {
626
641
  return undefined;
package/dist/store.js CHANGED
@@ -517,6 +517,13 @@ export class BridgeStore {
517
517
  // fields; throws if the session does not exist.
518
518
  async cancelSession(sessionId) {
519
519
  const existing = await this.getSession(sessionId);
520
+ // Cancel marks a session left running (or a preview) as abandoned. A
521
+ // finished one was rewritten too, so a consult that had answered read
522
+ // "cancelled" here while its task said done.
523
+ if (existing.status === "done" || existing.status === "blocked") {
524
+ const why = existing.blocker?.code === "cancelled" ? "it was cancelled before" : "its outcome is recorded";
525
+ throw new Error(`Session ${existing.id} is already ${existing.status}; ${why}, so there is nothing to cancel.`);
526
+ }
520
527
  return this.writeSession({
521
528
  id: existing.id,
522
529
  direction: existing.direction,
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,47 @@ 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.44 Release Record
39
+
40
+ Release commit `060d71c` on `main` (cut after PRs #60-#62, last merge `95c5ddc`); tag `v0.40.44`. Released promptly because of #61: 0.40.43's stale-lock check (#54) derived a holder's wall-clock start from `/proc/stat` btime, which drifts on WSL2 (measured 2026-10-27: 3 to 4 seconds every 30 seconds, 30 minutes over a day), so a send a few minutes into another session's consult freed the live lock and drove the same tab. #60 (picker paths with escaped spaces) went out with it; #62 changes CI only. #59 (machine-wide send pacing) was not ready and is not in this release.
41
+
42
+ **Publishing**
43
+ - The first local `release:verify` failed on `tests/browser-slow-vs-dead.test.ts` "tiny timeout on a closed port" while other tests loaded the machine; it passed alone (8/8), the uncommitted release edits were discarded and the release rerun passed `release:verify`.
44
+ - `publish.yml` run 40618706024 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release, which lists the two entries from `CHANGELOG.md`.
45
+
46
+ **Installed**
47
+ - WSL, M3, Mac mini: `prodex --version` reports 0.40.44. 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 `060d71c` and rebuilt.
49
+
50
+ **Container**
51
+ - `container/redesign-patches` `1258052` ports #61 (tsc, build, lock/send/follow-up tests and `release:verify` pass on it).
52
+ - The images were not rebuilt: the agent's permission classifier refused the `docker compose build` on WSL and the build over ssh on M3. The WSL container still runs image `1d48d753fbf7` (the #49-#58 port, with the drifting check) and the M3 container the 0.40.42 port, until someone with access rebuilds them.
53
+
54
+ **Verified live on installed 0.40.44 (WSL)**
55
+ - A live `sleep` holding a lock that records its start ticks, with `started_at` set 40 minutes back to stand in for a clock step: the send waited ("another prodex send holds the browser"); installed 0.40.43 had reaped the same lock and sent.
56
+
57
+ ## 0.40.43 Release Record
58
+
59
+ 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).
60
+
61
+ **Publishing**
62
+ - `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`.
63
+ - `npm run release:verify` passed on the release commit after a build.
64
+
65
+ **Installed**
66
+ - 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.
67
+ - The maintainer watchdog checkout was pulled to `834e3e2` and rebuilt.
68
+
69
+ **Container**
70
+ - `container/redesign-patches` `f72862c` ports #49-#58; full suite (164 files, 2098 tests) and `release:verify` pass on it.
71
+ - WSL: image rebuilt and the container swapped to `1d48d753fbf7`; the in-container send lock was idle before and after.
72
+ - 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.
73
+
74
+ **Verified live on installed 0.40.43 (WSL)**
75
+ - `pro blockers --since 7` refused: "--since expects a date or an age like 7d or 24h".
76
+ - `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).
77
+ - `ask --project " "` refused: "--project is blank".
78
+
38
79
  ## 0.40.42 Release Record
39
80
 
40
81
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.43",
3
+ "version": "0.40.45",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",