@youdie006/prodex 0.40.4 → 0.40.5

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,7 +12,7 @@
12
12
  * reports "the catch-all is biggest" and names nothing to fix - measured, one
13
13
  * of them held 165 of 267 blockers - so their message decides the group.
14
14
  */
15
- const CATCH_ALL_CODES = new Set(["browser_send_failed", "consult_failed", "unknown_error"]);
15
+ export const CATCH_ALL_CODES = new Set(["browser_send_failed", "consult_failed", "unknown_error"]);
16
16
  /**
17
17
  * The part of a message that identifies the failure, with the varying parts
18
18
  * removed: the same picker failure is written once with "Pro" and once with a
package/dist/bundle.js CHANGED
@@ -1,6 +1,37 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { makeBridgeId, nowIso, SCHEMA_VERSION } from "./schema.js";
3
3
  import { readRepoFile } from "./repo.js";
4
+ /**
5
+ * Whether a file prodex was asked to INLINE is text at all.
6
+ *
7
+ * `--file` puts a file's text into the prompt and `--attach` uploads the file
8
+ * itself; pointing `--file` at a binary used to inline the bytes. Measured:
9
+ * `--file` on an executable produced a prompt carrying an ELF header and NUL
10
+ * bytes inside a text fence - 535 control bytes in a 798-byte preview - which
11
+ * costs tokens, answers nothing, and sends control characters through the
12
+ * composer and the prompt-identity check.
13
+ *
14
+ * A NUL byte is the classic test and the one that never false-positives on
15
+ * real source; a high share of other control bytes catches the rest without
16
+ * tripping on text that merely has tabs and newlines.
17
+ */
18
+ export function looksLikeBinaryFileContent(content) {
19
+ if (content.includes("\u0000"))
20
+ return true;
21
+ const sample = content.slice(0, 4096);
22
+ if (sample.length === 0)
23
+ return false;
24
+ let control = 0;
25
+ for (const ch of sample) {
26
+ const code = ch.codePointAt(0) ?? 0;
27
+ // Tab, newline and carriage return are ordinary text.
28
+ if (code === 9 || code === 10 || code === 13)
29
+ continue;
30
+ if (code < 32 || code === 0xfffd)
31
+ control += 1;
32
+ }
33
+ return control / sample.length > 0.1;
34
+ }
4
35
  export async function buildDryRunBundle(root, input) {
5
36
  const sections = [
6
37
  "# prodex consult dry run",
@@ -16,6 +47,10 @@ export async function buildDryRunBundle(root, input) {
16
47
  const sendSections = [input.prompt.trim()];
17
48
  for (const file of input.files) {
18
49
  const content = await readRepoFile(root, file, { maxLines: 500 });
50
+ if (looksLikeBinaryFileContent(content.content)) {
51
+ throw new Error(`--file ${file} is not a text file, and --file inlines a file's TEXT into the prompt. ` +
52
+ `Upload it instead: --attach ${file}.`);
53
+ }
19
54
  files.push({ path: file, role: "context", bytes: Buffer.byteLength(content.content, "utf8") });
20
55
  const fileSection = ["", `## File: ${file}`, "", "```text", content.content, "```"];
21
56
  sections.push(...fileSection);
@@ -1414,6 +1414,23 @@ async function openChatGptThread(cdp, url) {
1414
1414
  * cause was named in the message and then thrown away by the catch-all next
1415
1415
  * step underneath it.
1416
1416
  */
1417
+ /**
1418
+ * The composer's model selector never rendered.
1419
+ *
1420
+ * Always transient in the field: the composer is still being built, which is
1421
+ * why the poll above exists at all. The catch-all it used to fall into said
1422
+ * "resolve the visible browser issue manually" - there is no issue to resolve,
1423
+ * and the next send usually works.
1424
+ */
1425
+ export function chatGptComposerNotReadyBlocker(reason) {
1426
+ return {
1427
+ code: "composer_not_ready",
1428
+ message: `ChatGPT's composer did not finish rendering its model selector${reason ? ` (${reason})` : ""}, so the model could not be chosen.`,
1429
+ retryable: true,
1430
+ next_step: "Nothing was sent. Retry - the composer is usually a moment behind a page that has just navigated, and a send right " +
1431
+ "after another one lands on it mid-render."
1432
+ };
1433
+ }
1417
1434
  export function chatGptThreadUnavailableBlocker(url) {
1418
1435
  return {
1419
1436
  code: "thread_unavailable",
@@ -2461,8 +2478,15 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
2461
2478
  // new-chat navigation the composer form (and the selector inside it) has not
2462
2479
  // finished rendering yet, so a single check throws "model selector button not
2463
2480
  // found" even though the button appears a moment later.
2481
+ //
2482
+ // Four seconds was not enough for the case this browser exists for: two
2483
+ // agents sending one after the other. Measured - a send that started two
2484
+ // seconds behind another, queued on the lock, and entered a page the first
2485
+ // one had only just finished with, failed here once in three attempts while
2486
+ // the same pattern succeeded either side of it. The neighbouring waits for
2487
+ // the same kind of render already allow six to eight.
2464
2488
  let button = { ok: false };
2465
- const buttonDeadline = Date.now() + 4_000;
2489
+ const buttonDeadline = Date.now() + PROJECT_NAVIGATION_TIMEOUT_MS;
2466
2490
  for (;;) {
2467
2491
  button = await cdp.evaluate(modelButtonRectExpression());
2468
2492
  if (button.ok && button.x !== undefined && button.y !== undefined)
@@ -2472,7 +2496,7 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
2472
2496
  await sleep(200);
2473
2497
  }
2474
2498
  if (!button.ok || button.x === undefined || button.y === undefined) {
2475
- throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
2499
+ throw new ChatGptBrowserBlockerError(chatGptComposerNotReadyBlocker(button.reason));
2476
2500
  }
2477
2501
  // Skip the menu entirely when the picker already shows the requested model:
2478
2502
  // it is the same end state, and it survives ChatGPT reshuffling the menu
@@ -3535,7 +3559,7 @@ export async function sendChatGptPrompt(options) {
3535
3559
  // looks the way it looked when it refused, and the selection failures this
3536
3560
  // project keeps hitting are invisible in the error text alone.
3537
3561
  await captureOnFailure(`send-${new Date().toISOString().replace(/[:.]/g, "-")}`);
3538
- throw error;
3562
+ throw attachSendWarnings(error, sendWarnings);
3539
3563
  }
3540
3564
  finally {
3541
3565
  cdp.close();
@@ -4456,6 +4480,39 @@ export function portAccepts(port, timeoutMs = 250) {
4456
4480
  }
4457
4481
  /** True when a fetch failed because its AbortSignal.timeout fired, not because nothing was listening. */
4458
4482
  /** Whether an error is a DevTools command that got no answer in time - the one failure that has closed the socket. */
4483
+ /**
4484
+ * Carry the warnings a failed send collected out with its error.
4485
+ *
4486
+ * They are attached to the RESULT, so a send that throws loses them - and the
4487
+ * one that explains the failure is exactly the kind that gets lost: a send
4488
+ * that had to move off ChatGPT's Work surface, or that recovered a browser,
4489
+ * and then died at the next step reported nothing about either. The error
4490
+ * already carries `thread` this way and the classifier already reads it off,
4491
+ * so this follows the same road.
4492
+ */
4493
+ export function attachSendWarnings(error, warnings) {
4494
+ if (warnings.length === 0)
4495
+ return error;
4496
+ if (typeof error !== "object" || error === null)
4497
+ return error;
4498
+ const carrier = error;
4499
+ if (Array.isArray(carrier.warnings))
4500
+ return error;
4501
+ try {
4502
+ carrier.warnings = [...warnings];
4503
+ }
4504
+ catch {
4505
+ // A frozen error is still the error; the warnings are a bonus, not a duty.
4506
+ }
4507
+ return error;
4508
+ }
4509
+ /** The warnings a failed send carried out with it, if any survived. */
4510
+ export function sendWarningsFromError(error) {
4511
+ if (typeof error !== "object" || error === null)
4512
+ return [];
4513
+ const carried = error.warnings;
4514
+ return Array.isArray(carried) ? carried.filter((entry) => typeof entry === "string") : [];
4515
+ }
4459
4516
  export function cdpCommandTimedOut(error) {
4460
4517
  return error instanceof Error && /Chrome DevTools command timed out/.test(error.message);
4461
4518
  }
@@ -5003,11 +5060,33 @@ export function composerToolsButtonRectExpression() {
5003
5060
  })()`;
5004
5061
  }
5005
5062
  /** Click point for a tools-menu entry, matched by its visible label. */
5063
+ /**
5064
+ * Where a tool's name must NOT be taken from.
5065
+ *
5066
+ * The lookup searches the document for the tool's name, because the menu this
5067
+ * ChatGPT build renders is not reachable by any container selector that was
5068
+ * tried - it carries no menu role, no aria-controls, and no floating popover
5069
+ * node. That search is fine until the sidebar holds a CHAT titled like a tool:
5070
+ * measured, the first `--tool create-image` send of an account succeeds and
5071
+ * leaves a conversation called "Create Image", and every send after it matched
5072
+ * that chat row, clicked it, navigated to the old conversation, and then
5073
+ * reported that the composer never showed the tool as active.
5074
+ *
5075
+ * Scoping the search to an open menu instead looked right and broke every tool
5076
+ * - web-search included, which had just been measured working - because
5077
+ * nothing matched the container. Excluding the places a tool name can only be
5078
+ * a coincidence keeps the search that works and removes the match that lies.
5079
+ */
5080
+ const COMPOSER_TOOL_LOOKUP_EXCLUDED_ANCESTORS = 'nav,aside,[role="navigation"],[data-sidebar-item],[data-testid="conversation-turn"],[data-message-author-role]';
5006
5081
  export function composerToolEntryRectExpression(label) {
5007
5082
  const candidatesJson = JSON.stringify(composerToolMenuTexts(label).map((text) => text.toLowerCase()));
5083
+ const excludedJson = JSON.stringify(COMPOSER_TOOL_LOOKUP_EXCLUDED_ANCESTORS);
5008
5084
  return `(() => {${CLICK_POINT_SNIPPET}
5009
5085
  const candidates = ${candidatesJson};
5010
- const leaves = [...document.querySelectorAll("div,span,button,a")].filter((el) => el.children.length === 0);
5086
+ const excluded = ${excludedJson};
5087
+ const leaves = [...document.querySelectorAll("div,span,button,a")].filter(
5088
+ (el) => el.children.length === 0 && !el.closest(excluded)
5089
+ );
5011
5090
  const leaf = leaves.find((el) => candidates.includes((el.textContent || "").trim().toLowerCase()));
5012
5091
  if (!leaf) {
5013
5092
  const available = [...new Set(leaves.map((el) => (el.textContent || "").trim()).filter((t) => t.length > 1 && t.length < 30))].slice(0, 20);
@@ -5029,9 +5108,7 @@ export function activeComposerToolsExpression(labels) {
5029
5108
  const text = ((el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "")).toLowerCase();
5030
5109
  // Case-insensitively: prodex carries the label as the menu spells it
5031
5110
  // ("Create image") while the page has been measured using "Create Image"
5032
- // for the same tool. This is hardening, not a fix for a failure anyone has
5033
- // seen - the create-image activation failure measured on this account
5034
- // survives it, and its cause is still open.
5111
+ // for the same tool.
5035
5112
  return { ok: true, active: ${labelsJson}.filter((label) => text.includes(String(label).toLowerCase())) };
5036
5113
  })()`;
5037
5114
  }
package/dist/cli-help.js CHANGED
@@ -89,7 +89,7 @@ Optional visible-browser send defaults (applied by \`pro browser ask\` when the
89
89
  --model Composer model by its exact menu label. Only Pro applies on the current picker: it is a slider step, and the model rows cannot be clicked
90
90
  --pro-mode Pro sub-mode: 기본 (standard) or 확장 (extended)
91
91
  --effort Reasoning effort: 즉시 / 중간 / 높음 / 매우 높음 / Max / Ultra / Pro. Max and Ultra are rungs of ChatGPT's Work surface and only apply when the browser is already on Work (prodex does not switch to Work for them); every other value is sent on Chat, whose top step is Pro
92
- --allow-model-fallback Send even when the requested model/effort could not be applied. Off by default: a send that could not reach what was asked for stops instead, because an answer from a step nobody asked for is usually unusable
92
+ --allow-model-fallback Send even when the requested EFFORT step could not be applied. Off by default: a send that could not reach the step it asked for stops instead, because an answer from a step nobody asked for is usually unusable. It does not gate the model axis - this picker's model rows cannot be driven at all, so a model it will not provide is always a warning and the send goes out on whatever the composer had; model_used and pro_verified say what actually replied
93
93
  --project Sidebar project to enter before sending
94
94
  Clear a saved default with --clear-model / --clear-pro-mode / --clear-effort / --clear-project.
95
95
  --pro-mode and --effort are different model axes and cannot be combined. View saved defaults with \`prodex status\`.`);
package/dist/cli-pro.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, statSync } from "node:fs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { buildDryRunBundle } from "./bundle.js";
5
- import { 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, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
5
+ import { 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, resolveVirtualDisplayPreference, resolveBrowserWindowMode, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
6
6
  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";
7
7
  import { printProBrowserHelp, printProHelp } from "./cli-help.js";
8
8
  import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
@@ -10,8 +10,8 @@ import { formatBrowserDefaults, redactServerUrl } from "./cli-server.js";
10
10
  import { errorMessage, firstLine, formatBlockedConsultRecordedMessage, formatProLatestCommand, formatBrowserCheckCommand, formatBrowserLoginCommand, formatBrowserSmokeCommand, formatBrowserTargetAskCommand, formatInitCommand, formatSetupCommand, isMissingFileError, computeSendPacingWaitMs, isUntrustedResultError, resolveMinSendIntervalMs, sourceAwareBrowserBlocker, sourceAwareBrowserNextStep, sourceAwareResultError, sourceAwareResultMessage, sourceAwareSetupMessage } from "./cli-shared.js";
11
11
  import { getTokenExpiryStatus, loadBrowserDefaults, loadLocalConfig } from "./config.js";
12
12
  import { withBrowserSendLock } from "./browser-send-lock.js";
13
- import { blockerCause, buildBlockerReport } from "./blocker-report.js";
14
- import { resolveContinuationThread } from "./continue-thread.js";
13
+ import { blockerCause, buildBlockerReport, CATCH_ALL_CODES } from "./blocker-report.js";
14
+ import { projectIdFromSidebar, resolveContinuationThread } from "./continue-thread.js";
15
15
  import { readBridgeRoots } from "./registry.js";
16
16
  import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
17
17
  import { CLI_VERSION } from "./cli-help.js";
@@ -759,7 +759,7 @@ export async function runProCommand(rest, io, runCliFn) {
759
759
  consults.push({
760
760
  repo: path.basename(root),
761
761
  createdAt: result.created_at,
762
- ...(result.blocker ? { blocker: { code: result.blocker.code, message: result.blocker.message } } : {})
762
+ ...(result.blocker ? { blocker: reclassifyRecordedBlocker(result.blocker) } : {})
763
763
  });
764
764
  }
765
765
  }
@@ -1129,6 +1129,20 @@ export async function runAskProCommand(rest, io) {
1129
1129
  // Scope by the project this send would have used, so a follow-up cannot
1130
1130
  // land in another project's conversation.
1131
1131
  const continuationProject = explicitProject ?? (suppressProject ? undefined : browserDefaults?.project);
1132
+ // The name alone cannot identify a project written in another script -
1133
+ // measured, two of this account's projects slug to nothing - so the id
1134
+ // is read off the live sidebar. A sidebar that cannot be read leaves
1135
+ // the name matching to do what it can; the send would fail on the same
1136
+ // browser anyway.
1137
+ let continuationProjectId;
1138
+ if (continuationProject) {
1139
+ try {
1140
+ continuationProjectId = projectIdFromSidebar(await listChatGptProjectsWithIds({ port: resolveCdpPort(readPortFlag(parsedAskPro.optionArgs, "--port")) }), continuationProject);
1141
+ }
1142
+ catch {
1143
+ continuationProjectId = undefined;
1144
+ }
1145
+ }
1132
1146
  const resolved = resolveContinuationThread({
1133
1147
  consults: (await targetStore.listSessionsReadOnly()).map((session) => ({
1134
1148
  taskId: session.task_id ?? "",
@@ -1137,6 +1151,7 @@ export async function runAskProCommand(rest, io) {
1137
1151
  ...(session.created_at ? { createdAt: session.created_at } : {})
1138
1152
  })),
1139
1153
  ...(continuationProject ? { project: continuationProject } : {}),
1154
+ ...(continuationProjectId ? { projectId: continuationProjectId } : {}),
1140
1155
  ...(continueTaskId !== undefined ? { taskId: continueTaskId } : {})
1141
1156
  });
1142
1157
  if ("error" in resolved)
@@ -1369,6 +1384,12 @@ export async function runAskProCommand(rest, io) {
1369
1384
  // --new-chat there is no target url, and a blocker that started a run
1370
1385
  // still has a thread worth handing back.
1371
1386
  const blockedThread = blocker.thread ?? normalizedTargetUrl;
1387
+ // What the send had already noticed before it died. These used to go
1388
+ // out with the result, so a failure dropped them - including the note
1389
+ // that would explain it, like having just moved off the Work surface.
1390
+ const blockedWarnings = sendWarningsFromError(error).map(redactProject);
1391
+ for (const warning of blockedWarnings)
1392
+ io.stderr(warning);
1372
1393
  const persistedBlocker = {
1373
1394
  ...blocker,
1374
1395
  message: redactProject(blocker.message),
@@ -1379,7 +1400,17 @@ export async function runAskProCommand(rest, io) {
1379
1400
  status: "blocked",
1380
1401
  summary: redactProject(message),
1381
1402
  commands: ["visible ChatGPT browser consult"],
1382
- blocker: persistedBlocker
1403
+ warnings: blockedWarnings,
1404
+ blocker: persistedBlocker,
1405
+ // What was asked for, on the record that failed. Without it a
1406
+ // timeout cannot be read back against the budget it was given.
1407
+ provenance: {
1408
+ ...(blockedThread ? { thread: blockedThread } : {}),
1409
+ ...(Object.keys(selectionMetadata).length > 0
1410
+ ? { selection: redactSelectionForRecord(selectionMetadata, redactProject) }
1411
+ : {}),
1412
+ warnings: blockedWarnings
1413
+ }
1383
1414
  });
1384
1415
  await writeSessionBestEffort(targetStore, {
1385
1416
  id: bundle.id,
@@ -1398,7 +1429,7 @@ export async function runAskProCommand(rest, io) {
1398
1429
  // Keep stdout machine-parseable for --json consumers on the blocked
1399
1430
  // path too; the human-readable error still goes to stderr via throw.
1400
1431
  if (jsonOutput) {
1401
- io.stdout(JSON.stringify({ task_id: task.id, status: "blocked", thread: blockedThread ?? null, answer: null, warnings: [], blocker }, null, 2));
1432
+ io.stdout(JSON.stringify({ task_id: task.id, status: "blocked", thread: blockedThread ?? null, answer: null, warnings: blockedWarnings, blocker }, null, 2));
1402
1433
  }
1403
1434
  throw new Error(formatBlockedConsultRecordedMessage(message, task.id, sourceCli, { cwd: targetCwd }));
1404
1435
  }
@@ -1510,6 +1541,9 @@ export async function runAskProCommand(rest, io) {
1510
1541
  warnings: persistenceWarnings,
1511
1542
  provenance: {
1512
1543
  thread: consult.url,
1544
+ ...(Object.keys(selectionMetadata).length > 0
1545
+ ? { selection: redactSelectionForRecord(selectionMetadata, redactProjectNamesForRecord(selectionMetadata)) }
1546
+ : {}),
1513
1547
  warnings: persistenceWarnings
1514
1548
  }
1515
1549
  });
@@ -1641,6 +1675,30 @@ export async function performBrowserRecoverForMcp(cwd, input) {
1641
1675
  notes: stderrLines
1642
1676
  };
1643
1677
  }
1678
+ /** The marker the send prints before throwing, when the answer outlived its record. */
1679
+ const ANSWER_NOT_SAVED_MARKER = "consult_answer_received_but_not_saved:";
1680
+ /**
1681
+ * Pull an answer out of a send that got one and then failed to record it.
1682
+ *
1683
+ * The CLI prints `consult_answer_received_but_not_saved: <task> <thread>`,
1684
+ * a blank line, and the answer, then throws - so a person still has the text.
1685
+ * An agent calling through MCP only saw the throw, which discarded exactly the
1686
+ * answer that was most expensive to get.
1687
+ */
1688
+ export function answerRescuedFromFailedPersistence(stdoutLines) {
1689
+ const index = stdoutLines.findIndex((line) => line.startsWith(ANSWER_NOT_SAVED_MARKER));
1690
+ if (index === -1)
1691
+ return undefined;
1692
+ const [taskId = "", thread = ""] = stdoutLines[index].slice(ANSWER_NOT_SAVED_MARKER.length).trim().split(/\s+/);
1693
+ const answer = stdoutLines
1694
+ .slice(index + 1)
1695
+ .join("\n")
1696
+ .replace(/^\n+/, "")
1697
+ .trim();
1698
+ if (!answer)
1699
+ return undefined;
1700
+ return { taskId, thread, answer };
1701
+ }
1644
1702
  export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1645
1703
  const stdoutLines = [];
1646
1704
  const stderrLines = [];
@@ -1667,16 +1725,38 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1667
1725
  "--",
1668
1726
  input.prompt
1669
1727
  ];
1670
- await runAskProCommand(argv, {
1671
- cwd,
1672
- stdout: (line) => stdoutLines.push(line),
1673
- stderr: (line) => {
1674
- stderrLines.push(line);
1675
- if (onProgress && line.startsWith("progress:"))
1676
- onProgress(line);
1677
- },
1678
- allowAskProBrowserSend: true
1679
- });
1728
+ try {
1729
+ await runAskProCommand(argv, {
1730
+ cwd,
1731
+ stdout: (line) => stdoutLines.push(line),
1732
+ stderr: (line) => {
1733
+ stderrLines.push(line);
1734
+ if (onProgress && line.startsWith("progress:"))
1735
+ onProgress(line);
1736
+ },
1737
+ allowAskProBrowserSend: true
1738
+ });
1739
+ }
1740
+ catch (error) {
1741
+ // A send whose ANSWER arrived and whose recording then failed prints the
1742
+ // answer and throws, so the CLI caller still has it. Rethrowing here threw
1743
+ // it away instead - the one case where that costs the most, since the
1744
+ // answer is usually a Pro run someone waited minutes for. Hand it back
1745
+ // with the failure attached rather than losing it.
1746
+ const rescued = answerRescuedFromFailedPersistence(stdoutLines);
1747
+ if (!rescued)
1748
+ throw error;
1749
+ return {
1750
+ task_id: rescued.taskId,
1751
+ status: "answered_not_saved",
1752
+ thread: rescued.thread,
1753
+ answer: rescued.answer,
1754
+ notes: [
1755
+ ...stderrLines.filter((line) => !line.startsWith("progress:")),
1756
+ `answer_not_saved: ${errorMessage(error)}`
1757
+ ]
1758
+ };
1759
+ }
1680
1760
  const header = stdoutLines[0] ?? "";
1681
1761
  const [taskId = "", status = "", thread = ""] = header.split("\t");
1682
1762
  return {
@@ -1955,6 +2035,35 @@ export function redactProjectNames(text, names) {
1955
2035
  }
1956
2036
  return redacted;
1957
2037
  }
2038
+ /** The project name is scrubbed in records; the rest of a selection is not. */
2039
+ export function redactSelectionForRecord(selection, redact) {
2040
+ const recorded = {};
2041
+ for (const [key, value] of Object.entries(selection)) {
2042
+ recorded[key] = key === "project" || key === "project_new" ? redact(value) : value;
2043
+ }
2044
+ return recorded;
2045
+ }
2046
+ /** The redactor a record needs when it has only the selection to go on. */
2047
+ function redactProjectNamesForRecord(selection) {
2048
+ return (text) => redactProjectNames(text, [selection.project, selection.project_new]);
2049
+ }
2050
+ /**
2051
+ * A recorded blocker, read with what the classifier knows NOW.
2052
+ *
2053
+ * Records keep the code they were written with. Most of the failures that
2054
+ * recur were the catch-all when they were recorded - 178 of 296 in this
2055
+ * machine's ledger - and only got their own codes afterwards, so a report
2056
+ * grouped by the recorded code kept showing "browser_send_failed: ..." rows
2057
+ * for causes that have names. Re-reading the message through the classifier
2058
+ * names them; a message it still cannot place keeps its recorded code, and a
2059
+ * record that was never the catch-all is left exactly as written.
2060
+ */
2061
+ export function reclassifyRecordedBlocker(recorded) {
2062
+ if (!CATCH_ALL_CODES.has(recorded.code))
2063
+ return { code: recorded.code, message: recorded.message };
2064
+ const live = browserSendBlockerFromError(new Error(recorded.message));
2065
+ return { code: CATCH_ALL_CODES.has(live.code) ? recorded.code : live.code, message: recorded.message };
2066
+ }
1958
2067
  export function browserSendBlockerFromError(error) {
1959
2068
  const blocker = typeof error === "object" && error !== null && "blocker" in error ? error.blocker : undefined;
1960
2069
  if (typeof blocker === "object" &&
@@ -2032,6 +2141,100 @@ export function browserSendBlockerFromError(error) {
2032
2141
  next_step: "Nothing was sent, so nothing landed in the wrong project. Retry - the composer normally binds on the next navigation - or open the project once in the visible browser and send again."
2033
2142
  };
2034
2143
  }
2144
+ // Everything below came out of the ledger rather than out of the code: 178
2145
+ // of 296 recorded blockers were the catch-all, and replaying their messages
2146
+ // through this function showed 109 still landing there - each one told to
2147
+ // "resolve the visible browser issue manually", which is advice for a
2148
+ // problem nobody has. These are the recurring ones that are still reachable,
2149
+ // in the order they actually happen.
2150
+ //
2151
+ // All of them fail BEFORE the prompt is submitted: the selection steps and
2152
+ // the post-insertion check all run ahead of the send, so "nothing was sent"
2153
+ // is a fact here and not a hope.
2154
+ if (/Refusing to click/.test(message)) {
2155
+ return {
2156
+ code: "click_blocked",
2157
+ message,
2158
+ retryable: true,
2159
+ next_step: "Something on the page was sitting over the control prodex needed, so nothing was sent. Retry - these covers are " +
2160
+ "usually a banner or a menu that goes away on its own - or clear it in the visible browser."
2161
+ };
2162
+ }
2163
+ if (/Composer text did not match the prompt after insertion|Composer stayed empty after text insertion/.test(message)) {
2164
+ return {
2165
+ code: "composer_text_mismatch",
2166
+ message,
2167
+ retryable: true,
2168
+ next_step: "The composer did not end up holding the prompt, so nothing was sent. Clear whatever is in it in the visible " +
2169
+ "browser and retry, or send into a fresh chat with --new-chat."
2170
+ };
2171
+ }
2172
+ if (/did not finish accepting .* within the upload budget/.test(message)) {
2173
+ return {
2174
+ code: "attachment_upload_timeout",
2175
+ message,
2176
+ retryable: true,
2177
+ next_step: "ChatGPT was still ingesting the attachment when the budget ran out, so nothing was sent. Retry with a longer " +
2178
+ "--timeout-ms, or inline a text file with --file instead of uploading it."
2179
+ };
2180
+ }
2181
+ if (/composer tools menu has no "/.test(message)) {
2182
+ return {
2183
+ code: "tool_not_offered",
2184
+ message,
2185
+ // The menu was read and the tool is not in it; asking again reads the
2186
+ // same menu.
2187
+ retryable: false,
2188
+ next_step: "Nothing was sent. This account's composer does not offer that tool - drop --tool and ask for the same thing in the prompt."
2189
+ };
2190
+ }
2191
+ if (/model selector button not found|did not expose its power slider|Could not open the ChatGPT model selector|model menu is not open|power slider not found/.test(message)) {
2192
+ return {
2193
+ code: "composer_not_ready",
2194
+ message,
2195
+ retryable: true,
2196
+ next_step: "The composer had not finished rendering its picker, so nothing was sent. Retry - it is usually a moment behind " +
2197
+ "a page that has just navigated, and a send right after another one lands on it mid-render."
2198
+ };
2199
+ }
2200
+ if (/but the composer never showed it as active/.test(message)) {
2201
+ return {
2202
+ code: "tool_not_applied",
2203
+ message,
2204
+ retryable: true,
2205
+ next_step: "The composer tool was chosen but never turned on, so nothing was sent. Retry, or drop --tool and ask for the " +
2206
+ "same thing in the prompt."
2207
+ };
2208
+ }
2209
+ if (/ChatGPT project not found in sidebar/.test(message)) {
2210
+ return {
2211
+ code: "project_not_found",
2212
+ message,
2213
+ // prodex already waits for the sidebar to hydrate before saying this, so
2214
+ // the name is genuinely not there; asking again asks the same sidebar.
2215
+ retryable: false,
2216
+ next_step: "Nothing was sent. The sidebar has no project by that name - list the exact spellings with " +
2217
+ "`prodex pro browser projects`, then pass one of those, or send without --project."
2218
+ };
2219
+ }
2220
+ if (/Clicking project "[^"]*" did not navigate/.test(message)) {
2221
+ return {
2222
+ code: "project_navigation_failed",
2223
+ message,
2224
+ retryable: true,
2225
+ next_step: "The sidebar click did not move the tab into the project, so nothing was sent. Retry, or open the project once " +
2226
+ "in the visible browser and send again."
2227
+ };
2228
+ }
2229
+ if (/Pro option not found in the model menu/.test(message)) {
2230
+ return {
2231
+ code: "selection_not_applied",
2232
+ message,
2233
+ retryable: false,
2234
+ next_step: "Nothing was sent. This account's picker does not offer Pro as a menu option - ask for it on the effort axis " +
2235
+ "with `--effort Pro`, or send without a model and check `model_used` in the answer."
2236
+ };
2237
+ }
2035
2238
  // The picker could not provide the step that was asked for. Retrying asks
2036
2239
  // the same picker the same question, so this is not retryable; the caller
2037
2240
  // either picks a step it offers or opts into whatever the slider is on.
@@ -2117,6 +2320,15 @@ async function recordedModelUsed(store, taskId) {
2117
2320
  return undefined;
2118
2321
  }
2119
2322
  }
2323
+ /** One line naming the model, effort and project a send asked for, or nothing. */
2324
+ export function formatAskedFor(selection) {
2325
+ if (!selection)
2326
+ return undefined;
2327
+ const parts = ["model", "pro_mode", "effort", "project", "project_new"]
2328
+ .filter((key) => selection[key])
2329
+ .map((key) => `${key}=${selection[key]}`);
2330
+ return parts.length > 0 ? `asked_for: ${parts.join(" ")}` : undefined;
2331
+ }
2120
2332
  export function formatProAnswer(consult, sourceCli, options = {}) {
2121
2333
  const blocker = sourceAwareProAnswerBlocker(consult, sourceCli, options);
2122
2334
  const summary = sourceAwareProAnswerSummary(consult.result.summary, consult.result.blocker, blocker);
@@ -2124,6 +2336,10 @@ export function formatProAnswer(consult, sourceCli, options = {}) {
2124
2336
  `task_id: ${consult.task.id}`,
2125
2337
  `status: ${consult.result.status}`,
2126
2338
  consult.task.provenance.thread ? `thread: ${consult.task.provenance.thread}` : undefined,
2339
+ // What the send asked for. Recorded on failures as well as answers now,
2340
+ // and a person reading a timeout needs it on the same screen as the
2341
+ // budget it used up.
2342
+ formatAskedFor(consult.task.provenance.selection),
2127
2343
  `created_at: ${consult.result.created_at}`,
2128
2344
  "",
2129
2345
  summary
package/dist/cli.js CHANGED
@@ -289,6 +289,33 @@ export async function runCli(args, io = defaultIo()) {
289
289
  }
290
290
  throw unknownTopLevelCommandError(command);
291
291
  }
292
+ /**
293
+ * Whether something other than prodex is sitting on the configured port.
294
+ *
295
+ * A prodex HTTP server answers /mcp - with 401 when the token is missing,
296
+ * which is still an answer. Anything else holding the port means `prodex
297
+ * start` cannot bind it, and the configured URL points at a stranger.
298
+ * Silence (nothing listening) is the healthy case.
299
+ */
300
+ export async function describeConfiguredPortHolder(host, port) {
301
+ let response;
302
+ try {
303
+ response = await fetch(`http://${host}:${port}/mcp`, {
304
+ method: "POST",
305
+ headers: { "Content-Type": "application/json" },
306
+ body: "{}",
307
+ signal: AbortSignal.timeout(1_500)
308
+ });
309
+ }
310
+ catch {
311
+ // Nothing is listening, or it refused the connection: the port is free for
312
+ // prodex to take, which is what this check is about.
313
+ return undefined;
314
+ }
315
+ if (response.status === 401 || response.status === 200 || response.status === 400)
316
+ return undefined;
317
+ return `port ${port} on ${host} is held by something that is not a prodex server (it answered /mcp with HTTP ${response.status}), so \`prodex start\` cannot bind it.`;
318
+ }
292
319
  function defaultIo() {
293
320
  return {
294
321
  // PRODEX_CWD wins over a working directory prodex cannot use (a /dev/fd
@@ -1020,6 +1047,14 @@ async function runDoctor(store, io, sourceCli, setupHintCwd) {
1020
1047
  const warningLine = formatConfigWarningLine(tokenStatus, sourceCli, setupHintCwd);
1021
1048
  if (warningLine)
1022
1049
  io.stdout(warningLine);
1050
+ // "ok" has to mean the configured endpoint can actually be served.
1051
+ // Measured here: the configured port was held by an unrelated program,
1052
+ // so `prodex start` could never bind it - and doctor called the config
1053
+ // ok anyway, which is the one line someone reads before believing it.
1054
+ const portHolder = await describeConfiguredPortHolder(config.host, config.port);
1055
+ if (portHolder) {
1056
+ io.stdout(`config_warning: ${portHolder} \`${formatSetupCommand(sourceCli, { cwd: setupHintCwd })} --port <free port>\` moves prodex off it.`);
1057
+ }
1023
1058
  }
1024
1059
  }
1025
1060
  catch (error) {
@@ -1082,7 +1117,9 @@ async function runHttpMcpCatalogSmoke() {
1082
1117
  cwd,
1083
1118
  host: "127.0.0.1",
1084
1119
  port: 0,
1085
- token: "doctor-token"
1120
+ token: "doctor-token",
1121
+ // A smoke bridge in a temp directory is not a place anyone works.
1122
+ registerRoot: false
1086
1123
  });
1087
1124
  client = new Client({ name: "prodex-doctor", version: CLI_VERSION });
1088
1125
  await withTimeout(client.connect(new StreamableHTTPClientTransport(new URL(running.mcp_url))), 20_000, "timed out connecting to HTTP MCP server");
@@ -1353,7 +1390,7 @@ async function runMcpWriteSmoke() {
1353
1390
  await execFileAsync("git", ["commit", "-m", "initial"], { cwd });
1354
1391
  const { stdout: headOut } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd });
1355
1392
  const head = headOut.trim();
1356
- const handlers = createMcpToolHandlers({ cwd });
1393
+ const handlers = createMcpToolHandlers({ cwd, registerRoot: false });
1357
1394
  const dryRun = await handlers.repo_write_file_dry_run({
1358
1395
  path: "notes.md",
1359
1396
  content: "new\n",
@@ -39,7 +39,16 @@ export function chatGptProjectSlug(name) {
39
39
  * exists to remove rather than reproduce.
40
40
  */
41
41
  export function isChatGptConversationUrl(threadUrl) {
42
- return /\/c\/[^/?#]+/.test(threadUrl);
42
+ const id = /\/c\/([^/?#]+)/.exec(threadUrl)?.[1];
43
+ if (!id)
44
+ return false;
45
+ // ChatGPT shows a provisional id - measured as "/c/WEB:<uuid>" - between the
46
+ // prompt posting and the server naming the conversation. It is not a
47
+ // conversation anyone can return to, and the id parser elsewhere already
48
+ // refuses it; accepting it here would let a follow-up pick a thread that
49
+ // then fails to open with an error about the URL rather than about the
50
+ // conversation.
51
+ return !/^web:/i.test(id);
43
52
  }
44
53
  /**
45
54
  * The project ids these recorded threads have been seen using, by project name.
@@ -76,6 +85,11 @@ export function threadMatchesProject(threadUrl, project, knownProjectIds) {
76
85
  return projectSegment === undefined;
77
86
  if (!projectSegment)
78
87
  return false;
88
+ // The slug is the name with everything but ASCII letters and digits turned
89
+ // to dashes, which for a name written in another script is nothing at all:
90
+ // measured, four of this account's five projects have Korean names and two
91
+ // of them slug to "". The id is the only thing that identifies those, and
92
+ // the caller reads it off the live sidebar.
79
93
  const slug = chatGptProjectSlug(project);
80
94
  // The id comes first and the name follows it, so an exact suffix match keeps
81
95
  // "notes" from answering for "notes-archive".
@@ -87,6 +101,25 @@ export function threadMatchesProject(threadUrl, project, knownProjectIds) {
87
101
  const id = /^g-p-([0-9a-f]+)/i.exec(projectSegment)?.[1]?.toLowerCase();
88
102
  return Boolean(id && knownProjectIds?.has(id));
89
103
  }
104
+ /**
105
+ * The id of the project a name refers to, from the sidebar as it is now.
106
+ *
107
+ * Same rules as the sidebar click: the exact name first, then a
108
+ * case-insensitive match when it is the only one - never a guess between two.
109
+ * The id comes back without its "g-p-" prefix, which is how thread URLs are
110
+ * read everywhere else here.
111
+ */
112
+ export function projectIdFromSidebar(projects, name) {
113
+ const wanted = name.trim();
114
+ const strip = (id) => id.replace(/^g-p-/i, "").toLowerCase();
115
+ const exact = projects.filter((project) => project.name.trim() === wanted);
116
+ if (exact.length === 1)
117
+ return strip(exact[0].id);
118
+ if (exact.length > 1)
119
+ return undefined;
120
+ const loose = projects.filter((project) => project.name.trim().toLowerCase() === wanted.toLowerCase());
121
+ return loose.length === 1 ? strip(loose[0].id) : undefined;
122
+ }
90
123
  /**
91
124
  * The thread a follow-up should continue, or why it cannot be resolved.
92
125
  *
@@ -114,9 +147,9 @@ export function resolveContinuationThread(input) {
114
147
  }
115
148
  return { target: { taskId: named.taskId, thread: named.thread } };
116
149
  }
117
- const knownProjectIds = input.project
118
- ? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project))
119
- : undefined;
150
+ const knownProjectIds = new Set(input.project ? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project)) ?? [] : []);
151
+ if (input.projectId)
152
+ knownProjectIds.add(input.projectId.toLowerCase());
120
153
  const candidates = withThread
121
154
  .filter((consult) => consult.status === "done")
122
155
  .filter((consult) => threadMatchesProject(consult.thread, input.project, knownProjectIds))
package/dist/http-mcp.js CHANGED
@@ -32,7 +32,7 @@ export async function startHttpMcpServer(options) {
32
32
  return;
33
33
  }
34
34
  if (req.method === "POST") {
35
- await handlePost(req, res, transports, options.cwd, requestBodyLimitBytes);
35
+ await handlePost(req, res, transports, options.cwd, requestBodyLimitBytes, options.registerRoot);
36
36
  return;
37
37
  }
38
38
  if (req.method === "GET" || req.method === "DELETE") {
@@ -51,10 +51,23 @@ export async function startHttpMcpServer(options) {
51
51
  }
52
52
  }
53
53
  });
54
+ const requestedPort = options.port ?? 8787;
54
55
  await new Promise((resolve, reject) => {
55
- server.once("error", reject);
56
- server.listen(options.port ?? 8787, host, () => {
57
- server.off("error", reject);
56
+ server.once("error", (error) => {
57
+ // "address already in use" names neither who has it nor how to move.
58
+ // Measured here: prodex's default port was held by an unrelated tool of
59
+ // the user's, and the only thing said was the raw errno - on a machine
60
+ // where the fix is one flag.
61
+ if (error.code === "EADDRINUSE") {
62
+ reject(new Error(`Port ${requestedPort} on ${host} is already taken, so the HTTP MCP server did not start. ` +
63
+ `Another prodex may already be running here - check with \`prodex status\` - or another program holds it ` +
64
+ `(\`ss -ltnp | grep ${requestedPort}\` says which). Move prodex with \`prodex setup --port <free port>\`, then start again.`, { cause: error }));
65
+ return;
66
+ }
67
+ reject(error);
68
+ });
69
+ server.listen(requestedPort, host, () => {
70
+ server.removeAllListeners("error");
58
71
  resolve();
59
72
  });
60
73
  });
@@ -118,7 +131,7 @@ async function withTimeout(promise, timeoutMs, message) {
118
131
  clearTimeout(timeout);
119
132
  }
120
133
  }
121
- async function handlePost(req, res, transports, cwd, requestBodyLimitBytes) {
134
+ async function handlePost(req, res, transports, cwd, requestBodyLimitBytes, registerRoot) {
122
135
  const body = await readJsonBody(req, requestBodyLimitBytes);
123
136
  const sessionId = headerValue(req.headers["mcp-session-id"]);
124
137
  if (sessionId) {
@@ -145,7 +158,11 @@ async function handlePost(req, res, transports, cwd, requestBodyLimitBytes) {
145
158
  if (id)
146
159
  transports.delete(id);
147
160
  };
148
- const mcpServer = createMcpServer(cwd, { source: "chatgpt_project", claimedBy: "chatgpt" });
161
+ const mcpServer = createMcpServer(cwd, {
162
+ source: "chatgpt_project",
163
+ claimedBy: "chatgpt",
164
+ ...(registerRoot !== undefined ? { registerRoot } : {})
165
+ });
149
166
  await mcpServer.connect(transport);
150
167
  await transport.handleRequest(req, res, body);
151
168
  return;
package/dist/mcp-tools.js CHANGED
@@ -40,7 +40,9 @@ function redactTaskForMcp(task) {
40
40
  return { ...task, provenance: redactedProvenance };
41
41
  }
42
42
  export function createMcpToolHandlers(context) {
43
- const store = new BridgeStore(context.cwd);
43
+ const store = new BridgeStore(context.cwd, {
44
+ ...(context.registerRoot !== undefined ? { registerRoot: context.registerRoot } : {})
45
+ });
44
46
  const source = context.source ?? "claude";
45
47
  const claimedBy = context.claimedBy ?? source;
46
48
  return {
package/dist/mcp.js CHANGED
@@ -51,7 +51,12 @@ function serverVersionNotice() {
51
51
  }
52
52
  export function createServer(cwd = process.cwd(), options = {}) {
53
53
  const server = new McpServer({ name: "prodex", version: mcpPackageJson.version ?? "0.0.0" });
54
- const handlers = createMcpToolHandlers({ cwd, source: options.source, claimedBy: options.claimedBy });
54
+ const handlers = createMcpToolHandlers({
55
+ cwd,
56
+ source: options.source,
57
+ claimedBy: options.claimedBy,
58
+ ...(options.registerRoot !== undefined ? { registerRoot: options.registerRoot } : {})
59
+ });
55
60
  server.registerTool("bridge_create_task", {
56
61
  description: "Create a durable task for Codex/local execution in .bridge/tasks.",
57
62
  inputSchema: {
@@ -201,7 +206,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
201
206
  allow_model_fallback: z
202
207
  .boolean()
203
208
  .optional()
204
- .describe("Send even when the requested model or effort could not be applied. Off by default, because an answer from a step nobody asked for is usually unusable: a consult asking for Pro that comes back from a lesser model cannot be cited as a Pro review. Pass true only when any answer beats no answer.")
209
+ .describe("Send even when the requested EFFORT step could not be applied. Off by default, because an answer from a step nobody asked for is usually unusable: a consult asking for Pro that comes back from a lesser model cannot be cited as a Pro review. Pass true only when any answer beats no answer. It does not gate the model axis: this picker's model rows cannot be driven at all, so a model it will not provide is always a warning and the send goes out on whatever the composer had - check `model_used` and `pro_verified` in the answer to see what actually replied.")
205
210
  }
206
211
  }, async (input, extra) => {
207
212
  // Bridge send progress to MCP progress notifications, but only when
package/dist/schema.js CHANGED
@@ -23,6 +23,14 @@ export const ProvenanceSchema = z.object({
23
23
  session_id: z.string().optional(),
24
24
  thread: z.string().optional(),
25
25
  project: z.string().optional(),
26
+ /**
27
+ * What the send asked for - model, effort, project. Recorded on failures as
28
+ * well as answers: 57 timeouts in this machine's ledger carry no selection
29
+ * at all, because it was only ever written into the ANSWER receipt, so the
30
+ * ledger cannot say whether a Pro run times out more often than a quick one
31
+ * - which is the first question anyone asks of a timeout.
32
+ */
33
+ selection: z.record(z.string()).optional(),
26
34
  warnings: z.array(z.string()).default([])
27
35
  });
28
36
  export const BridgeFileSchema = z.object({
package/dist/store.js CHANGED
@@ -66,9 +66,17 @@ const READ_ALL_CONCURRENCY = 32;
66
66
  export class BridgeStore {
67
67
  root;
68
68
  bridgeDir;
69
- constructor(root = process.cwd()) {
69
+ registerRoot;
70
+ /**
71
+ * `registerRoot: false` keeps this bridge out of the machine-wide registry.
72
+ * For throwaway roots prodex makes for itself - doctor's smoke checks build
73
+ * a bridge in a temp directory, delete it, and left a dead entry in the
74
+ * user's registry on every run.
75
+ */
76
+ constructor(root = process.cwd(), options = {}) {
70
77
  this.root = root;
71
78
  this.bridgeDir = path.join(root, ".bridge");
79
+ this.registerRoot = options.registerRoot !== false;
72
80
  }
73
81
  async ensure() {
74
82
  await assertUsableBridgeRoot(this.root);
@@ -85,7 +93,8 @@ export class BridgeStore {
85
93
  await this.ensureReceiptIntegrityKey();
86
94
  // Advisory: let local indexers (sessionwiki's prodex adapter) find this
87
95
  // bridge. Best-effort inside - a registry failure never breaks the bridge.
88
- await registerBridgeRoot(this.root);
96
+ if (this.registerRoot)
97
+ await registerBridgeRoot(this.root);
89
98
  }
90
99
  dir(kind) {
91
100
  return path.join(this.bridgeDir, kind);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.4",
3
+ "version": "0.40.5",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",