@youdie006/prodex 0.16.33 → 0.17.0

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
@@ -256,6 +256,17 @@ prodex setup --interactive # asks model / Pro sub-mode or effort / project
256
256
 
257
257
  The saved default above lives in the repo's `.bridge/config.local.json`, so it only applies when `prodex` runs from that repo. A coding agent often starts the MCP as `prodex mcp` with no `--cwd` (it reads whatever directory the agent launched in), so a per-repo default is missed and consults land in the general chat. For a default that applies from **any** directory, set environment variables instead — `PRODEX_DEFAULT_PROJECT` and `PRODEX_DEFAULT_MODEL` (also `PRODEX_DEFAULT_PRO_MODE`, `PRODEX_DEFAULT_EFFORT`) — in the agent's MCP `env` block or your shell. Use your own project name (list them with `prodex pro browser projects`); with no project set, consults simply go to the general chat. A per-repo config still wins field-by-field over the env fallback.
258
258
 
259
+ ### No window: headless mode
260
+
261
+ `prodex pro browser login --headless` (or `PRODEX_HEADLESS=1`, which also covers the MCP server and its auto-recovery) runs the dedicated browser with no visible window. Two constraints are real, not cosmetic:
262
+
263
+ - **Sign in headed first.** Nobody can log in to a window that does not exist, so headless reuses a profile you already signed into. The headless login verifies the saved session and tells you to run the headed login once if it is not there.
264
+ - **One mode at a time.** A single Chrome profile cannot serve a headed and a headless instance simultaneously; close the running one before switching (prodex refuses the switch instead of silently reusing the wrong mode).
265
+
266
+ Cloudflare treats a fresh headless profile as suspicious ("Just a moment..." interstitial). A profile with an established logged-in session normally passes; if yours gets challenged, run headed for that session — `prodex pro browser check` reports it as a blocker rather than hanging.
267
+
268
+ If a consult finds the browser closed, prodex now relaunches it in the same mode you last used and retries once — including from the MCP server, which has no terminal to prompt in. `PRODEX_NO_AUTO_LOGIN=1` turns that off.
269
+
259
270
  Whatever selection is applied is recorded on the consult receipt (`metadata.selection`); receipt display output redacts the project name, keeping only the model axes visible. `prodex` only clicks the picker you can see; it never selects a model, effort, or project silently outside the visible browser.
260
271
 
261
272
  For a source checkout, keep the explicit send and inspection commands source-aware too:
@@ -167,13 +167,19 @@ export async function readLastBrowserLoginLaunch() {
167
167
  const parsed = JSON.parse(await readFile(lastBrowserLoginPath(), "utf8"));
168
168
  return {
169
169
  ...(typeof parsed.profile_dir === "string" && parsed.profile_dir.length > 0 ? { profile_dir: parsed.profile_dir } : {}),
170
- ...(typeof parsed.port === "number" && Number.isInteger(parsed.port) ? { port: parsed.port } : {})
170
+ ...(typeof parsed.port === "number" && Number.isInteger(parsed.port) ? { port: parsed.port } : {}),
171
+ ...(typeof parsed.headless === "boolean" ? { headless: parsed.headless } : {})
171
172
  };
172
173
  }
173
174
  catch {
174
175
  return undefined;
175
176
  }
176
177
  }
178
+ // Headless Chrome defaults to an 800x600 viewport, at which ChatGPT collapses
179
+ // the sidebar - and the sidebar carries the logged-in signals that
180
+ // inferLoggedInLikely reads ("New chat"/"Projects"). Pin a desktop width so a
181
+ // headless session is not misread as logged out.
182
+ const HEADLESS_WINDOW_SIZE = "1440,900";
177
183
  export function buildChromeLaunchArgs(options) {
178
184
  return [
179
185
  "--remote-debugging-address=127.0.0.1",
@@ -181,10 +187,21 @@ export function buildChromeLaunchArgs(options) {
181
187
  `--user-data-dir=${options.profileDir}`,
182
188
  "--no-first-run",
183
189
  "--no-default-browser-check",
184
- "--new-window",
190
+ ...(options.headless ? ["--headless=new", `--window-size=${HEADLESS_WINDOW_SIZE}`] : ["--new-window"]),
185
191
  options.url
186
192
  ];
187
193
  }
194
+ /**
195
+ * Headless is opt-in: an explicit option wins, otherwise PRODEX_HEADLESS
196
+ * (1/true/yes) decides. The env var is the practical switch because the MCP
197
+ * server and its auto-recovery launch the browser with no CLI flags.
198
+ */
199
+ export function resolveHeadlessPreference(explicit, env = process.env) {
200
+ if (typeof explicit === "boolean")
201
+ return explicit;
202
+ const raw = (env.PRODEX_HEADLESS ?? "").trim().toLowerCase();
203
+ return raw === "1" || raw === "true" || raw === "yes";
204
+ }
188
205
  export function inferLoggedInLikely(text, visibleButtonLabels = []) {
189
206
  // Only sign-up prompts and explicit login/sign-up buttons count as logged-out signals. Bare
190
207
  // "Log in"/"로그인" substrings appear in the menus and footers of a logged-in page, so matching
@@ -638,10 +655,12 @@ export function openChatGptBrowser(options = {}) {
638
655
  const command = resolveChromeCommand();
639
656
  const port = resolveCdpPort(options.port);
640
657
  const profileDir = options.profileDir ?? defaultChatGptProfileDir();
658
+ const headless = resolveHeadlessPreference(options.headless);
641
659
  const args = buildChromeLaunchArgs({
642
660
  port,
643
661
  profileDir,
644
- url: options.url ?? "https://chatgpt.com/"
662
+ url: options.url ?? "https://chatgpt.com/",
663
+ ...(headless ? { headless } : {})
645
664
  });
646
665
  const child = spawn(command, args, { detached: true, stdio: "ignore", env: browserLaunchEnv() });
647
666
  let earlyExit;
@@ -709,7 +728,14 @@ export async function getChatGptBrowserStatus(options = {}) {
709
728
  blocker: chatGptPageMissingBlocker()
710
729
  };
711
730
  }
712
- const state = await evaluateOnPage(page.page, statusExpression());
731
+ // Bound the status read by the CALLER's budget, not the 20s default CDP
732
+ // command timeout: a very heavy ChatGPT thread answers slowly, and
733
+ // `pro browser check --timeout-ms 5000` measured 65 seconds because every
734
+ // evaluate silently used the default. Agents read that silence as a hung
735
+ // bridge and start "recovering" a browser that is merely busy.
736
+ const state = await evaluateOnPage(page.page, statusExpression(), {
737
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
738
+ });
713
739
  const loggedInLikely = inferChatGptPageLoggedInLikely(state);
714
740
  const blocker = chatGptVisibilityBlocker(state.visibilityState, state.url) ?? detectChatGptPageBlocker(state) ?? chatGptBusyBlocker(state.generating);
715
741
  return {
@@ -2076,12 +2102,43 @@ async function insertComposerTextViaCdp(cdp, text) {
2076
2102
  await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
2077
2103
  await sleep(100);
2078
2104
  }
2079
- await cdp.send("Input.insertText", { text });
2105
+ // Insert in bounded chunks: a single multi-KB Input.insertText makes
2106
+ // ProseMirror do one huge transaction, which on a heavy thread stalls past
2107
+ // the 20s CDP command timeout and kills the send with "Chrome DevTools
2108
+ // command timed out: Input.insertText" (field failure on long prompts,
2109
+ // twice in one session). Each chunk gets its own command budget.
2110
+ for (const chunk of chunkComposerText(text)) {
2111
+ await cdp.send("Input.insertText", { text: chunk });
2112
+ }
2080
2113
  await sleep(200);
2081
2114
  const state = await cdp.evaluate(composerTextStateExpression(text));
2082
2115
  if (!state.ok)
2083
2116
  throw new Error(state.reason ?? "Composer stayed empty after text insertion");
2084
2117
  }
2118
+ export const COMPOSER_INSERT_CHUNK_CHARS = 4_000;
2119
+ /**
2120
+ * Split composer text into insertText-sized pieces. Splits on code points (not
2121
+ * UTF-16 units) so a surrogate pair - an emoji, or any astral character - can
2122
+ * never be cut in half and arrive as two replacement characters.
2123
+ */
2124
+ export function chunkComposerText(text, size = COMPOSER_INSERT_CHUNK_CHARS) {
2125
+ if (text.length === 0)
2126
+ return [];
2127
+ if (text.length <= size)
2128
+ return [text];
2129
+ const chunks = [];
2130
+ let current = "";
2131
+ for (const character of text) {
2132
+ if (current.length + character.length > size) {
2133
+ chunks.push(current);
2134
+ current = "";
2135
+ }
2136
+ current += character;
2137
+ }
2138
+ if (current.length > 0)
2139
+ chunks.push(current);
2140
+ return chunks;
2141
+ }
2085
2142
  export function submitExpression() {
2086
2143
  return `(() => {
2087
2144
  ${composerExpressionHelpers()}
package/dist/cli-help.js CHANGED
@@ -19,7 +19,7 @@ Ask / consult commands:
19
19
  prodex ask [same flags as pro browser ask] "prompt" # top-level shortcut for pro browser ask
20
20
  prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] "prompt" # dry-run preview
21
21
  prodex pro debate-prompt [--topic "..."] [--rounds 2] [--source-cli /absolute/path/to/dist/cli.js] # print an agent prompt for a structured GPT Pro debate
22
- prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--wait-timeout-ms 300000] # preview/open visible browser login
22
+ prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headless] [--wait-timeout-ms 300000] # preview/open visible browser login
23
23
  prodex pro browser help [--source-cli /absolute/path/to/dist/cli.js]
24
24
  prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 1500]
25
25
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]
@@ -162,7 +162,7 @@ Commands:
162
162
  prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] "prompt"
163
163
  prodex pro debate-prompt [--topic "..."] [--rounds 2] [--source-cli /absolute/path/to/dist/cli.js]
164
164
  prodex pro browser help [--source-cli /absolute/path/to/dist/cli.js]
165
- prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--launch-timeout-ms 5000] [--wait|--no-wait] [--wait-timeout-ms 300000]
165
+ prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headless] [--wait-timeout-ms 300000]
166
166
  prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
167
167
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
168
168
  prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js]
@@ -242,8 +242,8 @@ export function printProBrowserHelp(stdout, sourceCli) {
242
242
  const cli = formatCliCommand(sourceCli);
243
243
  const sourceCliOption = formatSourceCliOption(sourceCli);
244
244
  const loginUsage = sourceCli
245
- ? `${cli} pro browser login${sourceCliOption} [--cwd /absolute/path/to/repo] [--dry-run] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--wait-timeout-ms 300000]`
246
- : "prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--wait-timeout-ms 300000]";
245
+ ? `${cli} pro browser login${sourceCliOption} [--cwd /absolute/path/to/repo] [--dry-run] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headless] [--wait-timeout-ms 300000]`
246
+ : "prodex pro browser login [--cwd /absolute/path/to/repo] [--dry-run] [--source-cli /absolute/path/to/dist/cli.js] [--profile-dir path] [--port 9333] [--url https://chatgpt.com/...] [--launch-timeout-ms 5000] [--wait|--no-wait] [--headless] [--wait-timeout-ms 300000]";
247
247
  const checkUsage = sourceCli
248
248
  ? `${cli} pro browser check${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 1500]`
249
249
  : "prodex pro browser check [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 1500]";
package/dist/cli-pro.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { buildDryRunBundle } from "./bundle.js";
4
- import { DEFAULT_CDP_PORT, resolveCdpPort, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, parseProMode, parseReasoningEffort, readLastBrowserLoginLaunch, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
4
+ import { DEFAULT_CDP_PORT, resolveCdpPort, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, parseProMode, parseReasoningEffort, readLastBrowserLoginLaunch, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
5
5
  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";
6
6
  import { printProBrowserHelp, printProHelp } from "./cli-help.js";
7
7
  import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
@@ -185,11 +185,11 @@ export async function runProCommand(rest, io, runCliFn) {
185
185
  if (browserSubcommand === "login") {
186
186
  if (printProBrowserHelpIfRequested(browserArgs, "pro browser login", io, {
187
187
  valueFlags: ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"],
188
- booleanFlags: ["--dry-run", "--wait", "--no-wait"]
188
+ booleanFlags: ["--dry-run", "--wait", "--no-wait", "--headless"]
189
189
  })) {
190
190
  return 0;
191
191
  }
192
- assertOnlyOptions(browserArgs, "pro browser login", ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"], ["--dry-run", "--wait", "--no-wait"]);
192
+ assertOnlyOptions(browserArgs, "pro browser login", ["--cwd", "--profile-dir", "--port", "--url", "--source-cli", "--launch-timeout-ms", "--wait-timeout-ms"], ["--dry-run", "--wait", "--no-wait", "--headless"]);
193
193
  if (browserArgs.includes("--wait") && browserArgs.includes("--no-wait")) {
194
194
  throw new Error("pro browser login cannot combine --wait and --no-wait");
195
195
  }
@@ -221,24 +221,51 @@ export async function runProCommand(rest, io, runCliFn) {
221
221
  // again: Chrome's singleton would just open ANOTHER window (the recurring
222
222
  // "extra windows" problem, which then blocks sends as
223
223
  // ambiguous_chatgpt_tabs). Reuse the running instance instead.
224
+ const headless = resolveHeadlessPreference(browserArgs.includes("--headless") ? true : undefined);
224
225
  const alreadyRunning = (await getChatGptBrowserStatus({ port })).reachable;
226
+ if (alreadyRunning) {
227
+ // One Chrome profile cannot serve a headed and a headless instance at
228
+ // once, and reusing the running one would silently ignore the
229
+ // requested mode. Say so instead of pretending the switch took.
230
+ const previous = await readLastBrowserLoginLaunch();
231
+ const runningHeadless = previous?.port === port ? previous.headless === true : undefined;
232
+ if (runningHeadless !== undefined && runningHeadless !== headless) {
233
+ throw new Error(`A ${runningHeadless ? "headless" : "headed"} ChatGPT browser is already running on port ${port}, but ${headless ? "headless" : "headed"} was requested. Close it first (\`pkill -f "remote-debugging-port=${port}"\`), then rerun.`);
234
+ }
235
+ }
225
236
  const opened = alreadyRunning
226
237
  ? { profileDir: profileDir ?? defaultChatGptProfileDir(), port }
227
- : openChatGptBrowser({ port, profileDir, url: loginUrl });
238
+ : openChatGptBrowser({ port, profileDir, url: loginUrl, ...(headless ? { headless } : {}) });
228
239
  if (!alreadyRunning) {
229
240
  await assertBrowserLaunchStayedAlive(opened, launchTimeoutMs);
230
241
  }
231
- // Remember this launch so ask auto-recovery reuses the same profile.
232
- await recordBrowserLoginLaunch({ profile_dir: opened.profileDir, port: opened.port });
242
+ // Remember this launch so ask auto-recovery reuses the same profile
243
+ // AND the same window mode.
244
+ await recordBrowserLoginLaunch({ profile_dir: opened.profileDir, port: opened.port, headless });
233
245
  printBrowserLoginGuide(io.stdout, {
234
246
  opened: !alreadyRunning,
235
247
  reused: alreadyRunning,
248
+ headless,
236
249
  loginUrl,
237
250
  profileDir: opened.profileDir,
238
251
  port: opened.port,
239
252
  sourceCli,
240
253
  commandOptions
241
254
  });
255
+ if (headless) {
256
+ // Nobody can sign in to a window that does not exist, so a headless
257
+ // launch is only useful when the profile is already logged in.
258
+ // Verify it here (bounded, no human to wait for) instead of letting
259
+ // the first consult fail with a confusing not-logged-in blocker.
260
+ const headlessReady = await waitForChatGptLoginReady(io.stderr, { port: opened.port, timeoutMs: 30_000 });
261
+ if (!headlessReady) {
262
+ io.stdout("");
263
+ io.stdout(`headless: the profile is not signed in. Run \`${formatBrowserLoginCommand(sourceCli, commandOptions)}\` WITHOUT --headless once, sign in, close that window, then rerun with --headless.`);
264
+ return 1;
265
+ }
266
+ io.stdout("headless: signed-in session confirmed - consults will run with no visible window.");
267
+ return 0;
268
+ }
242
269
  // Guided wait: interactive terminals walk the user to a verified READY
243
270
  // state instead of returning while login is still unfinished. Scripts
244
271
  // and agents (non-TTY) keep the immediate return unless --wait is
@@ -533,6 +560,10 @@ export async function runProCommand(rest, io, runCliFn) {
533
560
  export async function runConsultsCommand(rest, io) {
534
561
  throw new Error("The legacy `consults` alias is retired. Use `prodex pro list`, `prodex pro latest`, or `prodex pro show <task-id|latest>`.");
535
562
  }
563
+ function autoLoginDisabledByEnv(env = process.env) {
564
+ const raw = (env.PRODEX_NO_AUTO_LOGIN ?? "").trim().toLowerCase();
565
+ return raw === "1" || raw === "true" || raw === "yes";
566
+ }
536
567
  // Retired browser subcommands map to the one that replaced them. Every value
537
568
  // here must be a subcommand that actually exists, so the error is a single hop
538
569
  // to a runnable command.
@@ -1034,6 +1065,12 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1034
1065
  const stderrLines = [];
1035
1066
  const argv = [
1036
1067
  "--send",
1068
+ // MCP callers have no terminal, so the interactive auto-recovery gate
1069
+ // never fired for them: a closed browser made every pro_consult fail with
1070
+ // a step the agent had to shell out for (the single most common field
1071
+ // failure). Recovery reuses the saved profile and window mode, and
1072
+ // PRODEX_NO_AUTO_LOGIN=1 turns it off.
1073
+ ...(autoLoginDisabledByEnv() ? ["--no-auto-login"] : ["--auto-login"]),
1037
1074
  ...(input.model !== undefined ? ["--model", input.model] : []),
1038
1075
  ...(input.pro_mode !== undefined ? ["--pro-mode", input.pro_mode] : []),
1039
1076
  ...(input.effort !== undefined ? ["--effort", input.effort] : []),
@@ -1078,9 +1115,14 @@ export async function attemptBrowserAutoRecovery(stderr, options) {
1078
1115
  // profile for a custom-profile user would wait on the wrong (logged-out)
1079
1116
  // profile or, worse, silently send to a different account.
1080
1117
  const lastLogin = await readLastBrowserLoginLaunch();
1118
+ // Relaunch in the SAME window mode the user chose: silently reopening a
1119
+ // visible window for someone running headless would be exactly the
1120
+ // surprise window they turned headless to avoid.
1121
+ const headless = resolveHeadlessPreference(lastLogin?.headless);
1081
1122
  const opened = openChatGptBrowser({
1082
1123
  ...(options.port !== undefined ? { port: options.port } : {}),
1083
- ...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {})
1124
+ ...(lastLogin?.profile_dir ? { profileDir: lastLogin.profile_dir } : {}),
1125
+ ...(headless ? { headless } : {})
1084
1126
  });
1085
1127
  await assertBrowserLaunchStayedAlive(opened);
1086
1128
  const ready = await waitForChatGptLoginReady(stderr, { port: opened.port, timeoutMs: 120_000 });
@@ -1185,6 +1227,18 @@ export function browserSendBlockerFromError(error) {
1185
1227
  next_step: `Rerun with a bigger budget (${formatDurationMs(suggestedMs)}): \`prodex pro browser ask --timeout-ms ${suggestedMs} "<same prompt>"\`.`
1186
1228
  };
1187
1229
  }
1230
+ // A CDP command timeout means the page's renderer stalled, which in the
1231
+ // field means a very long thread (or a long prompt landing on one). Generic
1232
+ // "resolve the browser issue manually" advice gave the caller nothing to do.
1233
+ const cdpTimeout = message.match(/Chrome DevTools command timed out: (\S+)/);
1234
+ if (cdpTimeout) {
1235
+ return {
1236
+ code: "browser_cdp_timeout",
1237
+ message,
1238
+ retryable: true,
1239
+ next_step: "The ChatGPT tab stopped responding (usually a very long thread). Retry with `--new-chat` for a fresh, light thread, or reload the tab in the browser first."
1240
+ };
1241
+ }
1188
1242
  return {
1189
1243
  code: "browser_send_failed",
1190
1244
  message,
@@ -1260,33 +1314,52 @@ export async function getConsult(store, taskId, options = {}) {
1260
1314
  return isConsultRecord(record) ? record : undefined;
1261
1315
  }
1262
1316
  export async function latestTrustedConsult(store, options = { readOnly: true }) {
1263
- const entries = await listConsultListEntries(store, options);
1264
- const trusted = entries.find((entry) => entry.kind === "trusted");
1265
- if (trusted)
1266
- return trusted.consult;
1267
- const untrusted = entries.find((entry) => entry.kind === "untrusted");
1268
- if (untrusted)
1269
- throw untrusted.error;
1317
+ // Verify lazily, newest first, and stop at the first trusted consult.
1318
+ // Verifying the whole history to report ONE record made `pro browser check`
1319
+ // spend 42 of its 46 seconds in latest_pro on a repo with real history
1320
+ // (measured live) - each record is a full receipt scan - and agents read
1321
+ // that silence as a hung bridge.
1322
+ const records = await listConsultRecordsNewestFirst(store, options);
1323
+ let firstUntrusted;
1324
+ for (const record of records) {
1325
+ try {
1326
+ return { ...record, result: await store.getFinalizedResultReadOnly(record.result.task_id) };
1327
+ }
1328
+ catch (error) {
1329
+ if (isUntrustedResultError(error)) {
1330
+ firstUntrusted ??= error;
1331
+ continue;
1332
+ }
1333
+ throw error;
1334
+ }
1335
+ }
1336
+ if (firstUntrusted)
1337
+ throw firstUntrusted;
1270
1338
  return undefined;
1271
1339
  }
1272
1340
  export function legacyChatGptNamespaceError(subcommand) {
1273
1341
  const prefix = subcommand ? `Unknown legacy chatgpt subcommand: ${subcommand}.` : "The legacy `chatgpt` namespace is hidden.";
1274
1342
  return new Error(`${prefix} Use \`prodex pro browser help\` for visible-browser commands.`);
1275
1343
  }
1276
- export async function listConsultListEntries(store, options = { readOnly: true }) {
1344
+ // Consult records (unverified) newest first, with the ledger-integrity checks
1345
+ // that must run regardless of how many records the caller ends up verifying.
1346
+ async function listConsultRecordsNewestFirst(store, options = { readOnly: true }) {
1277
1347
  const [tasks, results] = options.readOnly === false
1278
1348
  ? await Promise.all([store.listTasks(), store.listResults()])
1279
1349
  : await Promise.all([listTasksForInspection(store), listRawResultsForInspection(store)]);
1280
1350
  const tasksById = new Map(tasks.map((task) => [task.id, task]));
1281
1351
  assertNoMissingTerminalConsultResults(tasks, results);
1282
1352
  assertNoOrphanConsultResults(tasksById, results);
1283
- const records = results
1353
+ return results
1284
1354
  .map((result) => {
1285
1355
  const task = tasksById.get(result.task_id);
1286
1356
  return task ? { task, result } : undefined;
1287
1357
  })
1288
1358
  .filter((record) => Boolean(record && isConsultRecord(record)))
1289
1359
  .sort((a, b) => b.result.created_at.localeCompare(a.result.created_at));
1360
+ }
1361
+ export async function listConsultListEntries(store, options = { readOnly: true }) {
1362
+ const records = await listConsultRecordsNewestFirst(store, options);
1290
1363
  const entries = [];
1291
1364
  for (const record of records) {
1292
1365
  try {
@@ -1303,7 +1376,7 @@ export async function listConsultListEntries(store, options = { readOnly: true }
1303
1376
  return entries;
1304
1377
  }
1305
1378
  export function printBrowserLoginGuide(stdout, input) {
1306
- const windowAvailable = input.opened || input.reused === true;
1379
+ const windowAvailable = (input.opened || input.reused === true) && input.headless !== true;
1307
1380
  const loginCommand = formatBrowserLoginCommand(input.sourceCli, input.commandOptions);
1308
1381
  const runtimeCommandOptions = {
1309
1382
  ...(input.commandOptions?.cwd ? { cwd: input.commandOptions.cwd } : {}),
@@ -1312,11 +1385,20 @@ export function printBrowserLoginGuide(stdout, input) {
1312
1385
  const checkCommand = formatBrowserCheckCommand(input.sourceCli, runtimeCommandOptions);
1313
1386
  const smokeCommand = formatBrowserSmokeCommand(input.sourceCli, runtimeCommandOptions);
1314
1387
  stdout("ChatGPT Pro browser login");
1315
- stdout(input.reused
1316
- ? "Chrome is already running for ChatGPT - reusing it (no new window opened)."
1317
- : input.opened
1318
- ? "Opened the dedicated Chrome window for ChatGPT."
1319
- : "Dry run: no browser was opened.");
1388
+ stdout(input.headless && (input.opened || input.reused)
1389
+ ? input.reused
1390
+ ? "Headless ChatGPT browser is already running - reusing it (no window)."
1391
+ : "Started the dedicated ChatGPT browser headless (no window). It reuses the profile you signed in with."
1392
+ : input.reused
1393
+ ? "Chrome is already running for ChatGPT - reusing it (no new window opened)."
1394
+ : input.opened
1395
+ ? "Opened the dedicated Chrome window for ChatGPT."
1396
+ : "Dry run: no browser was opened.");
1397
+ if (input.headless && (input.opened || input.reused)) {
1398
+ stdout("");
1399
+ stdout(`Next: run \`${checkCommand}\` to confirm the session, then consult as usual.`);
1400
+ return;
1401
+ }
1320
1402
  stdout("");
1321
1403
  stdout("Steps:");
1322
1404
  if (windowAvailable) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.16.33",
3
+ "version": "0.17.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",