@youdie006/prodex 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,7 @@ prodex ask --file src/auth.ts "Review this for security holes"
35
35
 
36
36
  `prodex ask` is the short form of `prodex pro browser ask`; the full form and every flag work identically. In an interactive terminal, `login` keeps watching the opened window and tells you exactly which manual step is still missing (log in, clear a check, open a chat) until it reports READY. If you skip `login` and the browser is not running, an interactive `ask` recovers on its own: it launches the dedicated browser, waits for your saved session to be READY, and retries the send once (disable with `--no-auto-login`; scripts opt in with `--auto-login`). While ChatGPT thinks, `prodex` prints progress to stderr (connecting, prompt sent, elapsed seconds while generating), so a multi-minute Pro answer never looks frozen.
37
37
 
38
- The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and deliberately will not send into a window you cannot watch but a dedicated Chrome window left non-minimized (even behind your editor) counts as watchable, so it sends quietly in the background without stealing focus. Just don't minimize it or switch that window to another tab. Pin per-repo defaults once - `prodex setup --model Pro --project "your-project"` - so every ask runs Pro (15-minute timeout) inside that project instead of whatever the ChatGPT UI last had selected; list exact sidebar project names with `prodex pro browser projects`. Pass `--file` more than once to attach several files. When the thread is still generating a previous answer (common right after a timed-out Pro send), the send automatically queues behind it up to the timeout budget; tune that with `--busy-wait-ms` (0 fails fast with a `response_in_progress` blocker). See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
38
+ The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and will not send into a tab it cannot read, so leave the dedicated window on a ChatGPT tab; it sends quietly in the background without stealing focus. Prefer no window at all? See [virtual display](#no-window-at-all-virtual-display-recommended). Pin per-repo defaults once - `prodex setup --model Pro --project "your-project"` - so every ask runs Pro (20-minute timeout) inside that project instead of whatever the ChatGPT UI last had selected; list exact sidebar project names with `prodex pro browser projects`. Pass `--file` more than once to attach several files. When the thread is still generating a previous answer (common right after a timed-out Pro send), the send automatically queues behind it up to the timeout budget; tune that with `--busy-wait-ms` (0 fails fast with a `response_in_progress` blocker). See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
39
39
 
40
40
  ## Core Shape
41
41
 
@@ -477,6 +477,23 @@ async function waitForFreshChatGptPage(page, timeoutMs) {
477
477
  export function hasChatGptPromptAcceptance(previous, state) {
478
478
  return state.userMessageCount > previous.userMessageCount || state.assistantMessageCount > previous.assistantMessageCount;
479
479
  }
480
+ /**
481
+ * Compare the model that was ASKED for against the model that actually
482
+ * produced the answer (ChatGPT tags each message with data-message-model-slug).
483
+ * prodex used to record only the request, so a model click that silently did
484
+ * not take - or no model pinned at all - was invisible, and the user believed
485
+ * they were getting Pro reasoning when they were not.
486
+ */
487
+ export function modelSelectionWarning(requestedModel, modelSlug) {
488
+ if (!requestedModel || !modelSlug)
489
+ return undefined;
490
+ const wantsPro = /\bpro\b/i.test(requestedModel);
491
+ if (!wantsPro)
492
+ return undefined;
493
+ if (/pro/i.test(modelSlug))
494
+ return undefined;
495
+ return `model_mismatch: you asked for ${requestedModel}, but the answer came from "${modelSlug}". Check the model picker in the browser; the selection did not take.`;
496
+ }
480
497
  export function chatGptBusyBlocker(generating) {
481
498
  if (!generating)
482
499
  return undefined;
@@ -1570,6 +1587,7 @@ export async function recoverChatGptAnswerFromThread(options) {
1570
1587
  title: state.title,
1571
1588
  answer: state.answer.trim(),
1572
1589
  modelHints: state.modelHints,
1590
+ ...(state.modelSlug ? { modelSlug: state.modelSlug } : {}),
1573
1591
  warnings: []
1574
1592
  };
1575
1593
  }
@@ -1842,7 +1860,8 @@ export async function sendChatGptPrompt(options) {
1842
1860
  title: completed.title,
1843
1861
  answer: completed.answer.trim(),
1844
1862
  modelHints: completed.modelHints,
1845
- warnings: [...sendWarnings]
1863
+ ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
1864
+ warnings: [...sendWarnings, modelSelectionWarning(options.model, completed.modelSlug)].filter((warning) => Boolean(warning))
1846
1865
  };
1847
1866
  }
1848
1867
  // Timed out while the answer was still streaming: salvage the partial text
@@ -1855,14 +1874,19 @@ export async function sendChatGptPrompt(options) {
1855
1874
  title: completed.title,
1856
1875
  answer: completed.answer.trim(),
1857
1876
  modelHints: completed.modelHints,
1877
+ ...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
1858
1878
  warnings: [
1859
1879
  ...sendWarnings,
1880
+ ...(modelSelectionWarning(options.model, completed.modelSlug) ? [modelSelectionWarning(options.model, completed.modelSlug)] : []),
1860
1881
  `answer_incomplete: ChatGPT was still generating after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms), so the answer below may be truncated. Raise --timeout-ms and retry for the full response.`
1861
1882
  ]
1862
1883
  };
1863
1884
  }
1864
- throw new Error(`Timed out after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms) waiting for ChatGPT to respond. ` +
1865
- "Pro reasoning can run many minutes. Raise --timeout-ms and retry.");
1885
+ // Carry the thread the prompt landed in: ChatGPT usually finishes the answer
1886
+ // after prodex gives up, and `pro browser recover --target-url` exists to
1887
+ // fetch it - but only if the caller knows which thread to point at.
1888
+ throw Object.assign(new Error(`Timed out after ${formatDurationMs(timeoutMs)} (${timeoutMs}ms) waiting for ChatGPT to respond. ` +
1889
+ "Pro reasoning can run many minutes. Raise --timeout-ms and retry."), completed?.url ? { thread: completed.url } : {});
1866
1890
  }
1867
1891
  export function modelMenuOptionsExpression() {
1868
1892
  return `(() => {
@@ -2409,10 +2433,17 @@ export function answerExpression() {
2409
2433
  return parts.join(String.fromCharCode(10));
2410
2434
  };
2411
2435
  const lines = text.split(String.fromCharCode(10)).map((line) => line.trim()).filter(Boolean);
2412
- const messages = [...document.querySelectorAll('[data-message-author-role]')].map((node) => ({
2413
- role: node.getAttribute('data-message-author-role'),
2414
- text: node.innerText || ""
2415
- }));
2436
+ const messages = [...document.querySelectorAll('[data-message-author-role]')].map((node) => {
2437
+ // ChatGPT tags each message with the model that produced it, on the
2438
+ // message node or an ancestor depending on the build. This is the only
2439
+ // ground truth for "did the Pro selection actually take".
2440
+ let modelSlug = node.getAttribute('data-message-model-slug') || undefined;
2441
+ if (!modelSlug && typeof node.closest === "function") {
2442
+ const tagged = node.closest('[data-message-model-slug]');
2443
+ if (tagged) modelSlug = tagged.getAttribute('data-message-model-slug') || undefined;
2444
+ }
2445
+ return { role: node.getAttribute('data-message-author-role'), text: node.innerText || "", modelSlug };
2446
+ });
2416
2447
  const assistantMessages = messages.filter((message) => message.role === "assistant");
2417
2448
  const userMessages = messages.filter((message) => message.role === "user");
2418
2449
  const assistant = assistantMessages.at(-1);
@@ -2436,6 +2467,9 @@ export function answerExpression() {
2436
2467
  generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || buttons.some((label) => generatingControlPattern.test(label)),
2437
2468
  assistantMessageCount: assistantMessages.length,
2438
2469
  userMessageCount: userMessages.length,
2470
+ // ChatGPT tags each assistant message with the model that produced it -
2471
+ // the only ground truth for "did the Pro selection actually take".
2472
+ modelSlug: assistant ? assistant.modelSlug : undefined,
2439
2473
  modelHints: lines.filter((line) => /GPT|Pro|Thinking|ChatGPT|Extra High|Auto/i.test(line)).slice(0, 30)
2440
2474
  };
2441
2475
  })()`;
package/dist/cli-pro.js CHANGED
@@ -962,7 +962,9 @@ export async function runAskProCommand(rest, io) {
962
962
  // after the 2026-07 update reset that to Medium, consults meant for Pro
963
963
  // quietly ran on a mid-tier model. Warn loudly and record it.
964
964
  if (!selectionModel && !selectionProMode && !selectionEffort) {
965
- persistenceWarnings.push("model_selection_warning: no model/effort was selected for this send (no per-ask flag, no saved default), so it used whatever the ChatGPT UI last had selected. Pin one with `prodex setup --model Pro` or pass --model/--effort.");
965
+ persistenceWarnings.push("model_selection_warning: no model/effort was selected for this send (no per-ask flag, no saved default), so it used whatever the ChatGPT UI last had selected" +
966
+ (consult.modelSlug ? ` - it answered as "${consult.modelSlug}"` : "") +
967
+ ". Pin one with `prodex setup --model Pro` or pass --model/--effort.");
966
968
  }
967
969
  // In-project threads carry the project slug in their URL
968
970
  // (/g/g-p-<project>/c/<id>); a bare /c/<id> after requesting a project
@@ -976,6 +978,8 @@ export async function runAskProCommand(rest, io) {
976
978
  // would otherwise treat a cut-off answer as complete.
977
979
  for (const warning of persistenceWarnings)
978
980
  io.stderr(warning);
981
+ if (consult.modelSlug)
982
+ io.stderr(`model_used: ${consult.modelSlug}`);
979
983
  let answerArtifactPath;
980
984
  const answerArtifactBytes = Buffer.byteLength(answerArtifactText, "utf8");
981
985
  if (answerArtifactBytes > MAX_FETCHABLE_RESULT_ARTIFACT_BYTES) {
@@ -1003,6 +1007,9 @@ export async function runAskProCommand(rest, io) {
1003
1007
  ...(answerArtifactPath ? { artifact_path: answerArtifactPath } : {}),
1004
1008
  thread: consult.url,
1005
1009
  ...(Object.keys(selectionMetadata).length > 0 ? { selection: selectionMetadata } : {}),
1010
+ // What actually answered, straight from ChatGPT's own tag - the
1011
+ // receipt used to record only what prodex asked for.
1012
+ ...(consult.modelSlug ? { model_used: consult.modelSlug } : {}),
1006
1013
  warnings: persistenceWarnings
1007
1014
  }
1008
1015
  });
@@ -1292,6 +1299,9 @@ export function browserSendBlockerFromError(error) {
1292
1299
  }
1293
1300
  // Match the raw ms whether the message uses the old "after 90000ms" form or
1294
1301
  // the newer human-readable "after 20 min (1200000ms)" form.
1302
+ const thread = typeof error === "object" && error !== null && "thread" in error && typeof error.thread === "string"
1303
+ ? (error.thread)
1304
+ : undefined;
1295
1305
  const timedOut = message.match(/Timed out after [\s\S]*?(\d+)\s*ms/);
1296
1306
  if (timedOut) {
1297
1307
  // Suggest a concrete doubled budget so the user can paste a rerun command
@@ -1302,7 +1312,11 @@ export function browserSendBlockerFromError(error) {
1302
1312
  code: "send_timeout",
1303
1313
  message,
1304
1314
  retryable: true,
1305
- next_step: `Rerun with a bigger budget (${formatDurationMs(suggestedMs)}): \`prodex pro browser ask --timeout-ms ${suggestedMs} "<same prompt>"\`.`
1315
+ ...(thread ? { thread } : {}),
1316
+ next_step: `Rerun with a bigger budget (${formatDurationMs(suggestedMs)}): \`prodex pro browser ask --timeout-ms ${suggestedMs} "<same prompt>"\`.` +
1317
+ (thread
1318
+ ? ` ChatGPT often finishes after prodex gives up - fetch that answer instead of re-asking: \`prodex pro browser recover --target-url ${thread}\`.`
1319
+ : "")
1306
1320
  };
1307
1321
  }
1308
1322
  // A CDP command timeout means the page's renderer stalled, which in the
@@ -4,7 +4,7 @@ import { parseProMode, parseReasoningEffort } from "./chatgpt-browser.js";
4
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";
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 } from "./config.js";
7
+ import { getTokenExpiryStatus, loadLocalConfig, writeLocalConfig, composeServerUrlWithToken } 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";
@@ -49,7 +49,7 @@ export async function runSetupCommand(rest, io) {
49
49
  io.stdout("Saved local ChatGPT Developer Mode MCP profile.");
50
50
  io.stdout(`Server URL: ${redactServerUrl(config.server_url)}`);
51
51
  io.stdout(formatTokenExpiryLine(config));
52
- io.stdout("Full URL is stored in .bridge/config.local.json.");
52
+ io.stdout("The token is stored (once) in .bridge/config.local.json; print the full URL with `prodex status --show-token --url-only`.");
53
53
  if (config.browser_defaults) {
54
54
  io.stdout(`Browser send defaults: ${formatBrowserDefaults(config.browser_defaults)}`);
55
55
  }
@@ -102,7 +102,10 @@ export async function runStatusCommand(rest, io) {
102
102
  const nonExpiringRevealWarning = showToken && allowNonExpiringTokenReveal && tokenStatus.status === "non_expiring"
103
103
  ? sourceAwareSetupMessage("Showing a non-expiring token. Keep this local-only and rotate it with `prodex setup --token-ttl-hours <hours>` before any tunnel or ChatGPT Project use.", sourceCli, { cwd: setupHintCwd })
104
104
  : undefined;
105
- const serverUrl = formatServerUrlForOutput(config.server_url, { showToken });
105
+ // The token is no longer persisted inside server_url, so compose the
106
+ // usable URL here; masking still applies when the caller did not ask for
107
+ // the token.
108
+ const serverUrl = formatServerUrlForOutput(composeServerUrlWithToken(config), { showToken });
106
109
  if (rest.includes("--url-only")) {
107
110
  if (showToken)
108
111
  io.stderr(TOKEN_BEARING_MCP_URL_AUTHORITY_WARNING);
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10
10
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11
11
  import { renderBanner, shouldColorize } from "./banner.js";
12
12
  import { getChatGptBrowserStatus, resolveCdpPort } from "./chatgpt-browser.js";
13
- import { getTokenExpiryStatus, loadLocalConfig } from "./config.js";
13
+ import { getTokenExpiryStatus, loadLocalConfig, resolveProdexCwd } from "./config.js";
14
14
  import { startHttpMcpServer } from "./http-mcp.js";
15
15
  import { createMcpToolHandlers } from "./mcp-tools.js";
16
16
  import { runMcpServer } from "./mcp.js";
@@ -216,7 +216,10 @@ export async function runCli(args, io = defaultIo()) {
216
216
  }
217
217
  function defaultIo() {
218
218
  return {
219
- cwd: process.cwd(),
219
+ // PRODEX_CWD wins over a working directory prodex cannot use (a /dev/fd
220
+ // pipe path from an agent harness, a deleted directory); --cwd still wins
221
+ // over both where a command accepts it.
222
+ cwd: resolveProdexCwd(),
220
223
  stdout: (line) => console.log(line),
221
224
  stderr: (line) => console.error(line),
222
225
  isInteractive: process.stdout.isTTY === true,
@@ -303,9 +306,13 @@ repo: ${cwd}
303
306
  ${cli} pro browser login${sourceCliOption} # opens visible browser
304
307
  ${cli} pro browser login --dry-run${sourceCliOption} # preview, no browser opens
305
308
  In an interactive terminal, login waits and narrates until your ChatGPT session is READY.
309
+ Sign in once, then keep the browser off your screen for good:
310
+ ${cli} pro browser login --virtual-display${sourceCliOption} # no window anywhere (needs Xvfb: sudo apt install -y xvfb x11-xkb-utils xauth)
311
+ ${cli} pro browser login --minimized${sourceCliOption} # no install; keeps the window minimized
312
+ Not --headless: Cloudflare rejects headless browsers, so ChatGPT never loads in one.
306
313
  Pin per-repo defaults first - otherwise sends silently use whatever the ChatGPT UI last had selected:
307
314
  ${cli} pro browser projects${sourceCliOption} # read-only: exact sidebar project names
308
- ${cli} setup --cwd ${quotedCwd} --model Pro --project "your-project" # every ask: Pro (15-minute timeout) inside that project
315
+ ${cli} setup --cwd ${quotedCwd} --model Pro --project "your-project" # every ask: Pro (20-minute timeout) inside that project
309
316
  cd ${quotedCwd}
310
317
  ${cli} ask --new-chat "Review this repo"${sourceCliOption} # short form of pro browser ask
311
318
  ${proAskCommand} # dry-run/manual preview
@@ -314,7 +321,7 @@ repo: ${cwd}
314
321
  ${cli} pro browser help${sourceCliOption}
315
322
  ${cli} pro browser check${sourceCliOption} --cwd ${quotedCwd}
316
323
  ${cli} pro browser smoke${sourceCliOption} --cwd ${quotedCwd}
317
- Sharing the browser with other agents? Add --busy-wait-ms 600000 to queue behind an in-flight response instead of failing.
324
+ Sharing the browser with other agents? Sends queue behind an in-flight response automatically; pass --busy-wait-ms 0 to fail fast instead.
318
325
 
319
326
  2. Let coding agents consult ChatGPT (stdio MCP: Claude, Codex, Cursor, ...):
320
327
  ${cli} claude config --cwd ${quotedCwd}${sourceCliOption}
package/dist/config.js CHANGED
@@ -59,6 +59,33 @@ export function localConfigPath(cwd) {
59
59
  export function makeServerUrl(host, port, token) {
60
60
  return `http://${host}:${port}/mcp?prodex_token=${encodeURIComponent(token)}`;
61
61
  }
62
+ /**
63
+ * The endpoint WITHOUT the token, which is what gets persisted. The token used
64
+ * to be stored twice - as `token` and inside `server_url` - so an operator's
65
+ * redaction that masked the key still leaked the secret from the URL (field
66
+ * report). One field is the only shape where masking the token masks it.
67
+ */
68
+ export function makeServerUrlBase(host, port) {
69
+ return `http://${host}:${port}/mcp`;
70
+ }
71
+ /** The token-bearing URL, composed on demand for clients that need it. */
72
+ export function composeServerUrlWithToken(config) {
73
+ const url = new URL(config.server_url);
74
+ url.searchParams.set("prodex_token", config.token);
75
+ return url.toString();
76
+ }
77
+ /** Strip a token that an older config (or a hand edit) left in the URL. */
78
+ export function stripTokenFromServerUrl(serverUrl) {
79
+ try {
80
+ const url = new URL(serverUrl);
81
+ url.searchParams.delete("prodex_token");
82
+ url.search = url.searchParams.toString();
83
+ return url.toString();
84
+ }
85
+ catch {
86
+ return serverUrl;
87
+ }
88
+ }
62
89
  export function normalizeLoopbackHttpHost(host) {
63
90
  const normalized = host.trim().toLowerCase();
64
91
  const isLocalhost = normalized === "localhost";
@@ -90,7 +117,7 @@ export async function writeLocalConfig(cwd, input = {}) {
90
117
  host,
91
118
  port,
92
119
  token,
93
- server_url: makeServerUrl(host, port, token),
120
+ server_url: makeServerUrlBase(host, port),
94
121
  ...(tokenExpiresAt ? { token_expires_at: tokenExpiresAt } : {}),
95
122
  ...(browserDefaults ? { browser_defaults: browserDefaults } : {}),
96
123
  created_at: existing?.created_at ?? now,
@@ -126,7 +153,26 @@ export async function loadLocalConfig(cwd) {
126
153
  }
127
154
  assertLoopbackHttpHost(config.host);
128
155
  assertLoopbackHttpHost(new URL(config.server_url).hostname);
156
+ // Configs written before the split still carry the token in the URL. Drop it
157
+ // in memory AND rewrite the file: leaving it on disk is the whole problem
158
+ // being fixed - an operator reading config.local.json would still find the
159
+ // secret twice, and any redaction keyed to `token` would miss one copy.
160
+ const strippedServerUrl = stripTokenFromServerUrl(config.server_url);
161
+ const carriedDuplicate = strippedServerUrl !== config.server_url;
162
+ config = { ...config, server_url: strippedServerUrl };
129
163
  assertServerUrlMatchesConfig(config);
164
+ if (carriedDuplicate) {
165
+ // Best effort: a read-only checkout or a concurrent writer must not turn
166
+ // a working config into a failed command.
167
+ try {
168
+ await writeVerifiedUtf8File(localConfigPath(cwd), `${JSON.stringify(config, null, 2)}\n`, () => assertLocalConfigTargetSafe(cwd), {
169
+ mode: 0o600
170
+ });
171
+ }
172
+ catch {
173
+ // The in-memory strip above already keeps prodex from printing it.
174
+ }
175
+ }
130
176
  return config;
131
177
  }
132
178
  // Global browser-selection defaults from the environment, used when a repo has
@@ -217,13 +263,13 @@ function isTokenExpired(tokenExpiresAt, now) {
217
263
  }
218
264
  function assertServerUrlMatchesConfig(config) {
219
265
  const serverUrl = new URL(config.server_url);
220
- const tokenParams = serverUrl.searchParams.getAll("prodex_token");
221
266
  const hostMatches = normalizeLoopbackHttpHost(serverUrl.hostname) === normalizeLoopbackHttpHost(config.host);
222
267
  const portMatches = effectiveUrlPort(serverUrl) === config.port;
223
- const tokenMatches = tokenParams.length === 1 && tokenParams[0] === config.token;
224
- const shapeMatches = serverUrl.protocol === "http:" && serverUrl.pathname === "/mcp" && Array.from(serverUrl.searchParams.keys()).length === 1;
225
- if (!hostMatches || !portMatches || !tokenMatches || !shapeMatches) {
226
- throw new Error(".bridge/config.local.json server_url must match host, port, and token. Run `prodex setup` to replace it.");
268
+ // The persisted URL must carry NO query at all: the token lives in `token`
269
+ // and nowhere else.
270
+ const shapeMatches = serverUrl.protocol === "http:" && serverUrl.pathname === "/mcp" && Array.from(serverUrl.searchParams.keys()).length === 0;
271
+ if (!hostMatches || !portMatches || !shapeMatches) {
272
+ throw new Error(".bridge/config.local.json server_url must be the token-free endpoint for host and port. Run `prodex setup` to replace it.");
227
273
  }
228
274
  }
229
275
  function effectiveUrlPort(url) {
@@ -359,3 +405,16 @@ async function assertDirectoryHandle(handle, label) {
359
405
  throw new Error(`${label} must be a real directory`);
360
406
  }
361
407
  }
408
+ /**
409
+ * The repo prodex should operate on. Defaults to the process working
410
+ * directory, but PRODEX_CWD (absolute paths only) wins: the MCP server takes
411
+ * no flags, so when an agent harness starts it from a pipe path or a deleted
412
+ * directory, the env var is the only way an operator can pin the repo without
413
+ * changing how that harness spawns the server.
414
+ */
415
+ export function resolveProdexCwd(fallback = process.cwd(), env = process.env) {
416
+ const raw = (env.PRODEX_CWD ?? "").trim();
417
+ if (raw.length === 0 || !path.isAbsolute(raw))
418
+ return fallback;
419
+ return raw;
420
+ }
package/dist/schema.js CHANGED
@@ -35,7 +35,11 @@ export const BlockerSchema = z.object({
35
35
  code: z.string(),
36
36
  message: z.string(),
37
37
  retryable: z.boolean().default(false),
38
- next_step: z.string().optional()
38
+ next_step: z.string().optional(),
39
+ // The ChatGPT thread the prompt landed in, recorded on send failures so
40
+ // `pro browser recover --target-url` has something to point at: ChatGPT
41
+ // usually finishes the answer after prodex has given up waiting.
42
+ thread: z.string().optional()
39
43
  });
40
44
  export const TaskSchema = z.object({
41
45
  schema_version: z.literal(SCHEMA_VERSION),
package/dist/store.js CHANGED
@@ -35,6 +35,26 @@ async function claimLockIsStale(lockPath) {
35
35
  const FETCHABLE_RESULT_ARTIFACT_PREFIXES = [".bridge/artifacts/pro-consults/", ".bridge/artifacts/results/"];
36
36
  export const MAX_FETCHABLE_RESULT_ARTIFACT_BYTES = 100_000;
37
37
  const MAX_BRIDGE_ARTIFACT_READ_BYTES = 1_000_000;
38
+ // A bridge root has to be a real directory in a real filesystem. Field report
39
+ // (macOS): an agent harness started `prodex mcp` with a working directory of
40
+ // /dev/fd/<n> - a file-descriptor path - so every call failed with a raw
41
+ // ENOENT/ENOTDIR on <root>/tasks, /sessions, /receipts, with a different
42
+ // number each time, and the operator had no way to tell what prodex had
43
+ // resolved or how to override it.
44
+ const NON_REPO_ROOT_PREFIXES = ["/dev/", "/proc/", "/sys/"];
45
+ export async function assertUsableBridgeRoot(root) {
46
+ const looksLikeDevicePath = NON_REPO_ROOT_PREFIXES.some((prefix) => root.startsWith(prefix));
47
+ let isDirectory = false;
48
+ try {
49
+ isDirectory = (await stat(root)).isDirectory();
50
+ }
51
+ catch {
52
+ isDirectory = false;
53
+ }
54
+ if (isDirectory && !looksLikeDevicePath)
55
+ return;
56
+ throw new Error(`Bridge root is not a usable repo directory: ${root}${looksLikeDevicePath ? " (that is a file-descriptor/device path, not a repo)" : ""}. prodex uses the process working directory when no --cwd is given, so a server started from a pipe or a deleted directory lands here. Pass --cwd /absolute/path/to/repo, or set PRODEX_CWD=/absolute/path/to/repo (works for the MCP server, which takes no flags).`);
57
+ }
38
58
  export class BridgeStore {
39
59
  root;
40
60
  bridgeDir;
@@ -43,6 +63,7 @@ export class BridgeStore {
43
63
  this.bridgeDir = path.join(root, ".bridge");
44
64
  }
45
65
  async ensure() {
66
+ await assertUsableBridgeRoot(this.root);
46
67
  await ensurePrivateDirectory(this.bridgeDir, "Bridge directory");
47
68
  await Promise.all([
48
69
  ensurePrivateDirectory(this.dir("tasks"), "Bridge storage directory .bridge/tasks"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",