@youdie006/prodex 0.19.2 → 0.21.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
@@ -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 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.
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 inline several files. `--file` puts a text file's CONTENTS into the prompt; `--attach` uploads the file itself, which is the only way to hand ChatGPT a pdf, pptx, xlsx or image and let it parse the original (`prodex ask --attach deck.pptx "Review slides 40-60"`). Both are restricted to paths inside the repo, so an agent cannot upload `~/.ssh` by asking nicely. The upload happens before the prompt is submitted and prodex waits for ChatGPT to finish accepting the file - the browser process reads the path, so the file has to live on the machine running the browser. `--tool` turns on a ChatGPT composer tool for that send: `--tool deep-research` (a browsed report - the timeout rises to 30 minutes automatically, and ChatGPT often replies with a clarifying question first, which you answer with a normal follow-up in the same thread), `--tool web-search` (current facts with sources), `--tool create-image`. Any other label the menu shows works too, so a tool ChatGPT adds later needs no prodex release. 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
 
@@ -1065,6 +1065,81 @@ async function verifiedClickAt(cdp, x, y, label) {
1065
1065
  await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
1066
1066
  await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
1067
1067
  }
1068
+ /**
1069
+ * Whether the model picker BUTTON already advertises the requested model, so
1070
+ * the menu never has to be opened. ChatGPT moved the models behind a "Model"
1071
+ * submenu (the top level is now Advanced / Model / Effort), which broke the
1072
+ * flat radio lookup with "Pro option not found in the model menu" - while the
1073
+ * button itself read "Pro, 5 of 5". Selecting what is already selected is
1074
+ * pointless work that a UI change can only break.
1075
+ */
1076
+ export function modelButtonAlreadyShows(requestedModel, buttonLabel) {
1077
+ if (!requestedModel || !buttonLabel)
1078
+ return false;
1079
+ const wanted = requestedModel.trim().toLowerCase();
1080
+ if (!wanted)
1081
+ return false;
1082
+ // The label carries decoration ("Pro, 5 of 5.", "GPT-5.6 Pro"), so match the
1083
+ // model name as a WORD inside it rather than by equality.
1084
+ const label = buttonLabel.trim().toLowerCase();
1085
+ const escaped = wanted.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1086
+ return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`).test(label);
1087
+ }
1088
+ // ---------------------------------------------------------------------------
1089
+ // Power slider (model + effort)
1090
+ //
1091
+ // ChatGPT replaced the model radio list with ONE slider. Measured live, its
1092
+ // five positions render on GPT-5.6 Sol as:
1093
+ // 0 Instant · 1 Medium · 2 High · 3 Extra High · 4 Pro
1094
+ // so "Pro" is now the top EFFORT, not a model - which is exactly why looking
1095
+ // for a "Pro" radio started failing. The slider takes focus and responds to
1096
+ // Arrow keys, so selection is: focus, step toward the wanted label, stop.
1097
+ // ---------------------------------------------------------------------------
1098
+ const POWER_LABEL_SYNONYMS = [
1099
+ { canonical: "instant", aliases: ["instant", "즉시", "빠름", "fast"] },
1100
+ { canonical: "medium", aliases: ["medium", "중간", "보통"] },
1101
+ { canonical: "high", aliases: ["high", "높음"] },
1102
+ { canonical: "extra high", aliases: ["extra high", "extrahigh", "very high", "매우 높음", "매우높음"] },
1103
+ { canonical: "pro", aliases: ["pro", "프로"] }
1104
+ ];
1105
+ function canonicalPowerLabel(value) {
1106
+ const normalized = value.trim().toLowerCase().replace(/\s+/g, " ");
1107
+ const hit = POWER_LABEL_SYNONYMS.find((entry) => entry.aliases.includes(normalized));
1108
+ return hit ? hit.canonical : normalized;
1109
+ }
1110
+ /** Whether a requested model/effort names the same step the slider renders. */
1111
+ export function powerLabelMatches(requested, rendered) {
1112
+ if (!requested || !rendered)
1113
+ return false;
1114
+ return canonicalPowerLabel(requested) === canonicalPowerLabel(rendered);
1115
+ }
1116
+ /** Slider position plus the Model/Effort readout next to it. */
1117
+ export function powerSliderStateExpression() {
1118
+ return `(() => {
1119
+ const slider = document.querySelector('[role="slider"]');
1120
+ const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
1121
+ const lines = menu ? (menu.innerText || "").split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean) : [];
1122
+ const after = (label) => { const i = lines.indexOf(label); return i >= 0 ? lines[i + 1] : null; };
1123
+ if (!slider) return { ok: false, reason: "power slider not found", lines };
1124
+ return {
1125
+ ok: true,
1126
+ position: Number(slider.getAttribute("aria-valuenow")),
1127
+ min: Number(slider.getAttribute("aria-valuemin")),
1128
+ max: Number(slider.getAttribute("aria-valuemax")),
1129
+ model: after("Model"),
1130
+ effort: after("Effort"),
1131
+ lines
1132
+ };
1133
+ })()`;
1134
+ }
1135
+ export function focusPowerSliderExpression() {
1136
+ return `(() => {
1137
+ const slider = document.querySelector('[role="slider"]');
1138
+ if (!slider) return { ok: false, reason: "power slider not found" };
1139
+ slider.focus();
1140
+ return { ok: document.activeElement === slider };
1141
+ })()`;
1142
+ }
1068
1143
  export function modelButtonRectExpression() {
1069
1144
  return `(() => {${CLICK_POINT_SNIPPET}
1070
1145
  const c = document.querySelector('#prompt-textarea,[contenteditable="true"],textarea');
@@ -1075,7 +1150,10 @@ export function modelButtonRectExpression() {
1075
1150
  return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach|file|voice|record|search|mic/i.test(t + aria);
1076
1151
  });
1077
1152
  if (!b) return { ok: false, reason: "model selector button not found" };
1078
- return clickPoint(b);
1153
+ // Return the label too: the caller compares it against the requested model
1154
+ // to skip opening the menu when it is already selected.
1155
+ const label = ((b.getAttribute("aria-label") || b.textContent || "").trim().split(String.fromCharCode(10))[0] || "").trim();
1156
+ return { ...clickPoint(b), label };
1079
1157
  })()`;
1080
1158
  }
1081
1159
  export function menuItemRectExpression(label) {
@@ -1211,7 +1289,44 @@ async function assertSelectionCommitted(cdp, label) {
1211
1289
  throw new Error(`ChatGPT selection "${label}" did not commit; the model menu stayed open. Retry, or pick it manually in the visible browser.`);
1212
1290
  }
1213
1291
  }
1214
- async function selectModelReasoning(cdp, options) {
1292
+ /**
1293
+ * Move the power slider until its Effort readout is the requested step. The
1294
+ * menu must already be open. Returns the quota line so the caller can warn
1295
+ * when Pro runs are nearly spent.
1296
+ */
1297
+ async function selectPowerStep(cdp, requested) {
1298
+ const focused = await cdp.evaluate(focusPowerSliderExpression());
1299
+ if (!focused?.ok) {
1300
+ throw new Error(focused?.reason ??
1301
+ "ChatGPT's model picker did not expose its power slider, so the requested model/effort could not be selected.");
1302
+ }
1303
+ let state = await cdp.evaluate(powerSliderStateExpression());
1304
+ if (!state?.ok)
1305
+ throw new Error(state?.reason ?? "Could not read ChatGPT's power slider");
1306
+ const steps = (state.max ?? 4) - (state.min ?? 0) + 1;
1307
+ for (let attempt = 0; attempt <= steps * 2; attempt += 1) {
1308
+ if (state.effort && powerLabelMatches(requested, state.effort))
1309
+ return { effort: state.effort };
1310
+ // Walk upward first, then back down: the labels are ordered, but their
1311
+ // exact set can change, so this never assumes a fixed index for a name.
1312
+ const atTop = (state.position ?? 0) >= (state.max ?? 4);
1313
+ const key = attempt < steps && !atTop ? "ArrowRight" : "ArrowLeft";
1314
+ await dispatchArrowKey(cdp, key);
1315
+ await sleep(400);
1316
+ state = await cdp.evaluate(powerSliderStateExpression());
1317
+ if (!state?.ok)
1318
+ throw new Error(state?.reason ?? "Could not read ChatGPT's power slider");
1319
+ }
1320
+ const available = (state.lines ?? []).join(" / ");
1321
+ throw new Error(`ChatGPT's model picker has no "${requested}" step. It showed: ${available}`);
1322
+ }
1323
+ async function dispatchArrowKey(cdp, key) {
1324
+ const code = key;
1325
+ const virtualKey = key === "ArrowLeft" ? 37 : 39;
1326
+ await cdp.send("Input.dispatchKeyEvent", { type: "rawKeyDown", key, code, windowsVirtualKeyCode: virtualKey });
1327
+ await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key, code, windowsVirtualKeyCode: virtualKey });
1328
+ }
1329
+ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1215
1330
  if (!options.model && !options.proMode && !options.effort)
1216
1331
  return;
1217
1332
  // --pro-mode selects a Pro sub-mode, so it is meaningless with a non-Pro
@@ -1236,6 +1351,13 @@ async function selectModelReasoning(cdp, options) {
1236
1351
  if (!button.ok || button.x === undefined || button.y === undefined) {
1237
1352
  throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
1238
1353
  }
1354
+ // Skip the menu entirely when the picker already shows the requested model:
1355
+ // it is the same end state, and it survives ChatGPT reshuffling the menu
1356
+ // (which it did - the models moved behind a "Model" submenu and every
1357
+ // --model Pro send started failing).
1358
+ if (options.model && !options.proMode && !options.effort && modelButtonAlreadyShows(options.model, button.label)) {
1359
+ return;
1360
+ }
1239
1361
  try {
1240
1362
  // The hover-verified click can be transiently refused right after a page
1241
1363
  // transition (measured live: the just-closed create-project modal's
@@ -1260,6 +1382,19 @@ async function selectModelReasoning(cdp, options) {
1260
1382
  const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
1261
1383
  if (!opened)
1262
1384
  throw new Error("ChatGPT model menu did not open after clicking the selector");
1385
+ // Current ChatGPT: one power slider (Instant/Medium/High/Extra High/Pro on
1386
+ // GPT-5.6 Sol) instead of a model radio list, so "Pro" is the top EFFORT.
1387
+ // Drive it when it is there and fall through to the legacy radio path when
1388
+ // it is not, so both UI generations work.
1389
+ const sliderState = await cdp.evaluate(powerSliderStateExpression());
1390
+ if (sliderState?.ok) {
1391
+ const wanted = options.effort ?? options.model;
1392
+ if (wanted) {
1393
+ await selectPowerStep(cdp, wanted);
1394
+ }
1395
+ await dispatchEscapeKey(cdp);
1396
+ return;
1397
+ }
1263
1398
  const wantsProMode = Boolean(options.proMode) && (!options.model || /pro/i.test(options.model));
1264
1399
  if (wantsProMode && options.proMode) {
1265
1400
  // Open the Pro sub-mode submenu via the chevron, then pick 기본/확장.
@@ -1709,7 +1844,7 @@ export async function sendChatGptPrompt(options) {
1709
1844
  await cdp.send("Runtime.enable");
1710
1845
  await selectProject(cdp, options);
1711
1846
  try {
1712
- await selectModelReasoning(cdp, options);
1847
+ await selectModelReasoning(cdp, options, sendWarnings);
1713
1848
  }
1714
1849
  catch (modelError) {
1715
1850
  // Pro sub-mode isn't exposed in this UI yet (staged rollout). Pro itself is
@@ -1729,7 +1864,17 @@ export async function sendChatGptPrompt(options) {
1729
1864
  // into; a --project/--project-new hop lands on a page with its own counts.
1730
1865
  beforeSubmit = await evaluateOnPage(page, answerExpression());
1731
1866
  dbgSend(`baseline url=${beforeSubmit.url} user=${beforeSubmit.userMessageCount} assistant=${beforeSubmit.assistantMessageCount}`);
1732
- await insertComposerTextViaCdp(cdp, options.prompt, page);
1867
+ // Attach BEFORE typing: the upload is the slow part, and a file that
1868
+ // arrives after the prompt is submitted is a file ChatGPT never saw.
1869
+ if (options.attachments && options.attachments.length > 0) {
1870
+ emitProgress("selecting", `uploading ${options.attachments.length} file(s)`);
1871
+ const uploaded = await attachFilesToComposer(cdp, options.attachments);
1872
+ emitProgress("selecting", `attached ${uploaded.attached.join(", ")}`);
1873
+ }
1874
+ const toolLabels = (options.tools ?? []).map(resolveComposerToolLabel);
1875
+ if (toolLabels.length > 0)
1876
+ emitProgress("selecting", `tools=${toolLabels.join(", ")}`);
1877
+ await insertComposerTextViaCdp(cdp, options.prompt, page, toolLabels);
1733
1878
  // The send button renders asynchronously after the prompt lands. Poll for it
1734
1879
  // BEFORE submitting so (a) submitButtonFound reflects whether the control
1735
1880
  // actually EXISTS - otherwise a successful Enter-key submit skips the fallback
@@ -1738,7 +1883,12 @@ export async function sendChatGptPrompt(options) {
1738
1883
  // live: submitExpression finds data-testid="send-button" fine, yet the
1739
1884
  // timeout error blamed a UI change) - and (b) we never press Enter before the
1740
1885
  // composer is submit-ready.
1741
- submitButtonFound = await waitForExpressionTrue(cdp, `(${submitExpression()}).ok === true`, 3_000);
1886
+ // With an attachment, ChatGPT keeps the send control disabled while it
1887
+ // ingests the file server-side; 3s expired mid-ingest and the send never
1888
+ // posted (measured live on a markdown attachment: the button was enabled
1889
+ // and clickable a moment after prodex gave up).
1890
+ const submitReadyBudgetMs = options.attachments && options.attachments.length > 0 ? 120_000 : 3_000;
1891
+ submitButtonFound = await waitForExpressionTrue(cdp, `(${submitExpression()}).ok === true`, submitReadyBudgetMs);
1742
1892
  // Submit. Prefer the Enter key: it goes to the focused composer and does
1743
1893
  // not depend on coordinates, whereas the send button moves ~100px as the
1744
1894
  // composer grows after the prompt lands, so a click at captured coordinates
@@ -2293,13 +2443,191 @@ export function insertComposerTextInPageExpression(text) {
2293
2443
  return { ok: true };
2294
2444
  })()`;
2295
2445
  }
2296
- export function composerTextStateExpression(expectedText) {
2446
+ // ---------------------------------------------------------------------------
2447
+ // Attachments (real file upload)
2448
+ //
2449
+ // `--file` inlines a file's TEXT into the prompt; this uploads the file itself
2450
+ // through the composer's file input, which is the only way to hand ChatGPT a
2451
+ // pdf/pptx/image and let it parse the original. CDP's DOM.setFileInputFiles
2452
+ // sets the input without any file dialog.
2453
+ // ---------------------------------------------------------------------------
2454
+ /**
2455
+ * The composer's general file input. Measured live: ChatGPT renders three
2456
+ * inputs - one general (accept="") plus two accept="image/*" - and attaching a
2457
+ * document to an image-only input silently does nothing.
2458
+ */
2459
+ export function composerFileInputSelector() {
2460
+ return 'input[type="file"]:not([accept*="image"])';
2461
+ }
2462
+ /**
2463
+ * Which of the expected attachments the composer shows, and whether one is
2464
+ * still uploading.
2465
+ *
2466
+ * Measured live: a DOCUMENT chip carries its filename in visible text, but an
2467
+ * IMAGE renders as a blob: thumbnail whose only filename is on the remove
2468
+ * button's aria-label ("Remove file 1: cli-banner.png"). Reading innerText
2469
+ * alone therefore reported a perfectly good image upload as never accepted.
2470
+ */
2471
+ export function attachmentStateExpression(fileNames) {
2472
+ const namesJson = JSON.stringify(fileNames);
2473
+ return `(() => {
2474
+ const names = ${namesJson};
2475
+ const text = document.body ? document.body.innerText || "" : "";
2476
+ const labels = [...document.querySelectorAll('[aria-label],[title],[alt]')]
2477
+ .map((el) => (el.getAttribute("aria-label") || el.getAttribute("title") || el.getAttribute("alt") || ""))
2478
+ .join(String.fromCharCode(10));
2479
+ const haystack = text + String.fromCharCode(10) + labels;
2480
+ const present = names.filter((name) => haystack.includes(name));
2481
+ const attachedFiles = [...document.querySelectorAll('input[type="file"]')]
2482
+ .reduce((total, el) => total + (el.files ? el.files.length : 0), 0);
2483
+ // A visible progressbar means a file is still going up; sending now would
2484
+ // post the prompt without it.
2485
+ const uploading = document.querySelectorAll('[role="progressbar"]').length > 0;
2486
+ return { ok: true, present, attachedFiles, uploading };
2487
+ })()`;
2488
+ }
2489
+ /** How many attachments are already sitting in the composer (read-only). */
2490
+ export function attachmentPresenceExpression() {
2491
+ return `(() => {
2492
+ const buttons = [...document.querySelectorAll('button[aria-label]')].filter((b) =>
2493
+ /remove file|파일 제거|첨부 제거/i.test(b.getAttribute("aria-label") || "")
2494
+ );
2495
+ return { ok: true, removed: buttons.length };
2496
+ })()`;
2497
+ }
2498
+ /**
2499
+ * Attach files to the composer and wait until ChatGPT has taken them. The
2500
+ * files must be readable by the BROWSER process, so a browser on another
2501
+ * machine (or another container) cannot see the caller's paths - that shows up
2502
+ * as the attachment never appearing, which this reports as such.
2503
+ */
2504
+ export async function attachFilesToComposer(cdp, absolutePaths, options = {}) {
2505
+ if (absolutePaths.length === 0)
2506
+ return { attached: [] };
2507
+ await cdp.send("DOM.enable");
2508
+ // Drop anything a previous (failed) send left attached: it would ride along
2509
+ // with this prompt, which is exactly the kind of silent contamination the
2510
+ // composer text check already guards against. This must happen BEFORE the
2511
+ // input node is resolved - removing a chip re-renders the composer and
2512
+ // replaces the input element, and setting files on the old (detached) node
2513
+ // silently does nothing (measured live: the file input held the file while
2514
+ // the UI showed no attachment at all).
2515
+ // Leftover attachments from a previous (failed) send would ride along with
2516
+ // this prompt. Clearing them by clicking the remove buttons WEDGES the
2517
+ // composer: measured live, after a removal the file input accepts files
2518
+ // (input.files becomes 1) while the UI never renders the chip again, and
2519
+ // every later attach in that tab silently does nothing. A reload is the only
2520
+ // reliable reset, and it costs a few seconds only when there is something to
2521
+ // clear.
2522
+ const stale = await cdp.evaluate(attachmentPresenceExpression());
2523
+ if ((stale?.removed ?? 0) > 0) {
2524
+ await cdp.evaluate("location.reload()");
2525
+ await sleep(6_000);
2526
+ const settleDeadline = Date.now() + 15_000;
2527
+ for (;;) {
2528
+ const ready = await cdp.evaluate(composerTextStateExpression());
2529
+ if (ready?.ok || Date.now() >= settleDeadline)
2530
+ break;
2531
+ await sleep(500);
2532
+ }
2533
+ }
2534
+ const document = await cdp.send("DOM.getDocument", { depth: -1, pierce: true });
2535
+ const rootNodeId = document.result?.root?.nodeId;
2536
+ if (rootNodeId === undefined)
2537
+ throw new Error("Could not read the ChatGPT page DOM to attach files.");
2538
+ const input = await cdp.send("DOM.querySelector", { nodeId: rootNodeId, selector: composerFileInputSelector() });
2539
+ const inputNodeId = input.result?.nodeId;
2540
+ if (!inputNodeId) {
2541
+ throw new Error("The ChatGPT composer has no file input to attach to. Open a normal chat (not a shared or read-only view) and retry.");
2542
+ }
2543
+ await cdp.send("DOM.setFileInputFiles", { files: absolutePaths, nodeId: inputNodeId });
2544
+ const fileNames = absolutePaths.map((file) => path.basename(file));
2545
+ const deadline = Date.now() + (options.timeoutMs ?? 120_000);
2546
+ let lastPresent = [];
2547
+ while (Date.now() < deadline) {
2548
+ await sleep(1_000);
2549
+ const state = await cdp.evaluate(attachmentStateExpression(fileNames));
2550
+ lastPresent = state?.present ?? [];
2551
+ if (lastPresent.length === fileNames.length && !state?.uploading)
2552
+ return { attached: lastPresent };
2553
+ }
2554
+ const missing = fileNames.filter((name) => !lastPresent.includes(name));
2555
+ throw new Error(`ChatGPT did not finish accepting ${missing.length > 0 ? missing.join(", ") : fileNames.join(", ")} within the upload budget. The file must be readable by the browser process (same machine), and ChatGPT enforces its own size and type limits.`);
2556
+ }
2557
+ // ---------------------------------------------------------------------------
2558
+ // Composer tools (Deep research, Web search, Create image, connectors)
2559
+ //
2560
+ // Measured live: a tool is NOT a chip beside the composer - selecting it
2561
+ // inserts its name as a token INSIDE the ProseMirror editor, and it survives a
2562
+ // page reload. Two consequences drive this code: the tool must be enabled
2563
+ // AFTER the composer is cleared (clearing removes it), and the composer text
2564
+ // check has to ignore the token or it reads as leftover contamination.
2565
+ // ---------------------------------------------------------------------------
2566
+ const COMPOSER_TOOL_ALIASES = [
2567
+ { label: "Deep research", aliases: ["deep research", "deep-research", "deepresearch", "deep", "research"] },
2568
+ { label: "Web search", aliases: ["web search", "web-search", "websearch", "search", "web"] },
2569
+ { label: "Create image", aliases: ["create image", "create-image", "image", "img"] }
2570
+ ];
2571
+ /**
2572
+ * Map what a caller typed to the label ChatGPT renders. An unknown value is
2573
+ * passed through unchanged, so a tool ChatGPT adds tomorrow is reachable by
2574
+ * its label without a prodex release.
2575
+ */
2576
+ export function resolveComposerToolLabel(requested) {
2577
+ const normalized = requested.trim().toLowerCase();
2578
+ const known = COMPOSER_TOOL_ALIASES.find((tool) => tool.aliases.includes(normalized) || tool.label.toLowerCase() === normalized);
2579
+ return known ? known.label : requested.trim();
2580
+ }
2581
+ export const DEEP_RESEARCH_TOOL_LABEL = "Deep research";
2582
+ const DEEP_RESEARCH_MIN_TIMEOUT_MS = 1_800_000;
2583
+ /** Deep research browses for minutes; the ordinary budget abandons it mid-report. */
2584
+ export function defaultTimeoutForTools(tools, fallbackMs) {
2585
+ const wantsDeepResearch = tools.some((tool) => resolveComposerToolLabel(tool) === DEEP_RESEARCH_TOOL_LABEL);
2586
+ return wantsDeepResearch ? Math.max(fallbackMs, DEEP_RESEARCH_MIN_TIMEOUT_MS) : fallbackMs;
2587
+ }
2588
+ export function composerToolsButtonRectExpression() {
2589
+ return `(() => {${CLICK_POINT_SNIPPET}
2590
+ const b = document.querySelector('[data-testid="composer-plus-btn"]');
2591
+ if (!b) return { ok: false, reason: "composer tools button not found" };
2592
+ return clickPoint(b);
2593
+ })()`;
2594
+ }
2595
+ /** Click point for a tools-menu entry, matched by its visible label. */
2596
+ export function composerToolEntryRectExpression(label) {
2597
+ const labelJson = JSON.stringify(label);
2598
+ return `(() => {${CLICK_POINT_SNIPPET}
2599
+ const wanted = ${labelJson}.trim().toLowerCase();
2600
+ const leaves = [...document.querySelectorAll("div,span,button,a")].filter((el) => el.children.length === 0);
2601
+ const leaf = leaves.find((el) => (el.textContent || "").trim().toLowerCase() === wanted);
2602
+ if (!leaf) {
2603
+ const available = [...new Set(leaves.map((el) => (el.textContent || "").trim()).filter((t) => t.length > 1 && t.length < 30))].slice(0, 20);
2604
+ return { ok: false, reason: "tool not found in the composer tools menu", available };
2605
+ }
2606
+ const target = leaf.closest('[role="menuitem"],[role="option"],button,a') || leaf.parentElement || leaf;
2607
+ return clickPoint(target);
2608
+ })()`;
2609
+ }
2610
+ /** Tool tokens currently sitting in the composer. */
2611
+ export function activeComposerToolsExpression(labels) {
2612
+ const labelsJson = JSON.stringify(labels);
2613
+ return `(() => {
2614
+ const el = document.querySelector('#prompt-textarea,[contenteditable="true"]');
2615
+ const text = el ? (el.innerText || "") : "";
2616
+ return { ok: true, active: ${labelsJson}.filter((label) => text.includes(label)) };
2617
+ })()`;
2618
+ }
2619
+ export function composerTextStateExpression(expectedText, toolLabels = []) {
2297
2620
  const expectedJson = JSON.stringify(expectedText ?? null);
2621
+ const toolLabelsJson = JSON.stringify(toolLabels);
2298
2622
  return `(() => {
2299
2623
  ${composerExpressionHelpers()}
2300
2624
  const el = findChatGptComposerCandidate();
2301
2625
  if (!el) return { ok: false, reason: "No visible composer" };
2302
- const raw = ("value" in el ? el.value : el.innerText || el.textContent || "").trim();
2626
+ let raw = ("value" in el ? el.value : el.innerText || el.textContent || "").trim();
2627
+ // An enabled tool lives INSIDE the composer as a token; it is not leftover
2628
+ // text, so strip it before comparing against the prompt.
2629
+ for (const label of ${toolLabelsJson}) raw = raw.split(label).join(" ");
2630
+ raw = raw.trim();
2303
2631
  if (!raw) return { ok: false, reason: "Composer stayed empty after text insertion" };
2304
2632
  const expected = ${expectedJson};
2305
2633
  if (expected === null) return { ok: true, actualText: raw.slice(0, 120) };
@@ -2313,7 +2641,56 @@ export function composerTextStateExpression(expectedText) {
2313
2641
  // Focus the composer, clear any leftover text submit-safely, type the prompt
2314
2642
  // with native CDP input so ProseMirror registers it, then verify the composer
2315
2643
  // holds exactly the prompt.
2316
- async function insertComposerTextViaCdp(cdp, text, page) {
2644
+ /**
2645
+ * Turn on a composer tool by its menu label and confirm the token landed in
2646
+ * the composer. Called after the composer is cleared and before the prompt is
2647
+ * typed, because clearing the composer removes the token.
2648
+ */
2649
+ export async function enableComposerTools(cdp, labels) {
2650
+ const enabled = [];
2651
+ for (const label of labels) {
2652
+ const already = await cdp.evaluate(activeComposerToolsExpression([label]));
2653
+ if ((already?.active ?? []).includes(label)) {
2654
+ enabled.push(label);
2655
+ continue;
2656
+ }
2657
+ const button = await cdp.evaluate(composerToolsButtonRectExpression());
2658
+ if (!button.ok || button.x === undefined || button.y === undefined) {
2659
+ throw new Error(button.reason ?? "Could not open the ChatGPT composer tools menu");
2660
+ }
2661
+ await dispatchMouseClickAt(cdp, button.x, button.y);
2662
+ let entry = { ok: false };
2663
+ const menuDeadline = Date.now() + 6_000;
2664
+ for (;;) {
2665
+ entry = await cdp.evaluate(composerToolEntryRectExpression(label));
2666
+ if (entry.ok && entry.x !== undefined && entry.y !== undefined)
2667
+ break;
2668
+ if (Date.now() >= menuDeadline)
2669
+ break;
2670
+ await sleep(250);
2671
+ }
2672
+ if (!entry.ok || entry.x === undefined || entry.y === undefined) {
2673
+ await dispatchEscapeKey(cdp);
2674
+ const available = entry.available?.length ? ` Menu showed: ${entry.available.slice(0, 12).join(", ")}.` : "";
2675
+ throw new Error(`ChatGPT's composer tools menu has no "${label}".${available}`);
2676
+ }
2677
+ await dispatchMouseClickAt(cdp, entry.x, entry.y);
2678
+ const activeDeadline = Date.now() + 8_000;
2679
+ let active = false;
2680
+ for (;;) {
2681
+ const state = await cdp.evaluate(activeComposerToolsExpression([label]));
2682
+ active = (state?.active ?? []).includes(label);
2683
+ if (active || Date.now() >= activeDeadline)
2684
+ break;
2685
+ await sleep(250);
2686
+ }
2687
+ if (!active)
2688
+ throw new Error(`Selected "${label}" but the composer never showed it as active.`);
2689
+ enabled.push(label);
2690
+ }
2691
+ return enabled;
2692
+ }
2693
+ async function insertComposerTextViaCdp(cdp, text, page, toolLabels = []) {
2317
2694
  const prepared = await cdp.evaluate(prepareComposerExpression());
2318
2695
  if (!prepared.ok)
2319
2696
  throw new Error(prepared.reason ?? "Could not focus the ChatGPT composer");
@@ -2329,6 +2706,12 @@ async function insertComposerTextViaCdp(cdp, text, page) {
2329
2706
  await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
2330
2707
  await sleep(100);
2331
2708
  }
2709
+ // The composer is clear at this point, so the tool tokens can go in now -
2710
+ // enabling them earlier would have been wiped by the clear above.
2711
+ if (toolLabels.length > 0) {
2712
+ await enableComposerTools(cdp, toolLabels);
2713
+ await sleep(300);
2714
+ }
2332
2715
  // Insertion path, chosen by size:
2333
2716
  //
2334
2717
  // Short prompts go through Input.insertText - real key-level input events,
@@ -2367,7 +2750,7 @@ async function insertComposerTextViaCdp(cdp, text, page) {
2367
2750
  }
2368
2751
  }
2369
2752
  await sleep(200);
2370
- const state = await cdp.evaluate(composerTextStateExpression(text));
2753
+ const state = await cdp.evaluate(composerTextStateExpression(text, toolLabels));
2371
2754
  if (!state.ok)
2372
2755
  throw new Error(state.reason ?? "Composer stayed empty after text insertion");
2373
2756
  }
package/dist/cli-args.js CHANGED
@@ -261,6 +261,10 @@ export const ASK_PRO_SELECTION_VALUE_FLAGS = ["--project", "--project-new", "--m
261
261
  export const ASK_PRO_VALUE_FLAGS = new Set([
262
262
  "--cwd",
263
263
  "--file",
264
+ // Upload the file itself (pdf/pptx/image) instead of inlining its text.
265
+ "--attach",
266
+ // Composer tools: deep-research, web-search, create-image, ...
267
+ "--tool",
264
268
  "--port",
265
269
  "--timeout-ms",
266
270
  "--busy-wait-ms",
package/dist/cli-help.js CHANGED
@@ -17,7 +17,7 @@ First-time setup:
17
17
 
18
18
  Ask / consult commands:
19
19
  prodex ask [same flags as pro browser ask] "prompt" # top-level shortcut for pro browser ask
20
- prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] "prompt" # dry-run preview
20
+ prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] [--attach path] [--tool deep-research|web-search|create-image] "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
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|--minimized|--virtual-display] [--wait-timeout-ms 300000] # preview/open visible browser login
23
23
  prodex pro browser help [--source-cli /absolute/path/to/dist/cli.js]
@@ -26,14 +26,14 @@ Ask / consult commands:
26
26
  prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of model menu options
27
27
  prodex pro browser projects [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of sidebar project names (for --project)
28
28
  prodex pro browser recover [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--timeout-ms 60000] # recover a finished answer from a thread whose send timed out
29
- prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
29
+ prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
30
30
  prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
31
31
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
32
32
  prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
33
33
 
34
34
  Bridge ledger (durable tasks/results/receipts/sessions under .bridge/):
35
35
  prodex init [--cwd /absolute/path/to/repo]
36
- prodex tasks create [--cwd /absolute/path/to/repo] --title "Title" --prompt "Prompt" [--repo-id id] [--file path]
36
+ prodex tasks create [--cwd /absolute/path/to/repo] --title "Title" --prompt "Prompt" [--repo-id id] [--file path] [--attach path] [--tool deep-research|web-search|create-image]
37
37
  prodex tasks list [--status new|claimed|done|blocked] [--cwd /absolute/path/to/repo] [--json]
38
38
  prodex tasks show <task-id|latest> [--cwd /absolute/path/to/repo]
39
39
  prodex tasks claim <task-id> [--cwd /absolute/path/to/repo] [--by codex]
@@ -159,14 +159,14 @@ export function printProHelp(stdout) {
159
159
  stdout(`prodex pro
160
160
 
161
161
  Commands:
162
- prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] "prompt"
162
+ prodex pro ask [--dry-run] [--cwd /absolute/path/to/repo] [--file path] [--attach path] [--tool deep-research|web-search|create-image] "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
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|--minimized|--virtual-display] [--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]
169
- prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt"
169
+ prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt"
170
170
  prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
171
171
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
172
172
  prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
@@ -203,7 +203,7 @@ export function printTasksHelp(stdout) {
203
203
  stdout(`prodex tasks
204
204
 
205
205
  Commands:
206
- prodex tasks create [--cwd /absolute/path/to/repo] --title "Title" --prompt "Prompt" [--repo-id id] [--file path]
206
+ prodex tasks create [--cwd /absolute/path/to/repo] --title "Title" --prompt "Prompt" [--repo-id id] [--file path] [--attach path] [--tool deep-research|web-search|create-image]
207
207
  prodex tasks list [--status new|claimed|done|blocked] [--cwd /absolute/path/to/repo] [--json]
208
208
  prodex tasks show <task-id|latest> [--cwd /absolute/path/to/repo]
209
209
  prodex tasks claim <task-id> [--cwd /absolute/path/to/repo] [--by codex]
@@ -252,8 +252,8 @@ export function printProBrowserHelp(stdout, sourceCli) {
252
252
  : "prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 90000]";
253
253
  const selectionUsage = '[--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"]';
254
254
  const askUsage = sourceCli
255
- ? `${cli} pro browser ask${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] ${selectionUsage} "prompt"`
256
- : `prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] ${selectionUsage} "prompt"`;
255
+ ? `${cli} pro browser ask${sourceCliOption} [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`
256
+ : `prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] ${selectionUsage} "prompt"`;
257
257
  const modelsUsage = sourceCli
258
258
  ? `${cli} pro browser models${sourceCliOption} [--port 9333] [--timeout-ms 15000]`
259
259
  : "prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000]";
package/dist/cli-pro.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { existsSync, statSync } from "node:fs";
1
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { buildDryRunBundle } from "./bundle.js";
4
- import { DEFAULT_CDP_PORT, resolveCdpPort, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, parseProMode, parseReasoningEffort, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
5
+ import { DEFAULT_CDP_PORT, resolveCdpPort, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, listChatGptModelOptions, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveVirtualDisplayPreference, resolveHeadlessPreference, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt } from "./chatgpt-browser.js";
5
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";
6
7
  import { printProBrowserHelp, printProHelp } from "./cli-help.js";
7
8
  import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
@@ -722,6 +723,29 @@ export async function runAskProCommand(rest, io) {
722
723
  }
723
724
  return rel;
724
725
  });
726
+ // --attach UPLOADS the file (pdf, pptx, image) instead of inlining its
727
+ // text like --file. Same escape guard: an agent must not be able to upload
728
+ // ~/.ssh or anything else outside the repo to a chat.
729
+ const attachments = readRepeatedFlag(parsedAskPro.optionArgs, "--attach").map((file) => {
730
+ const absolute = path.resolve(targetCwd, file);
731
+ const rel = path.relative(targetCwd, absolute);
732
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
733
+ throw new Error(`--attach "${file}" is outside the repo root (${targetCwd}). Pass a path inside the repo, or point --cwd at that repo.`);
734
+ }
735
+ if (!existsSync(absolute) || !statSync(absolute).isFile()) {
736
+ throw new Error(`--attach "${file}" is not a readable file (looked at ${absolute}).`);
737
+ }
738
+ return absolute;
739
+ });
740
+ if (attachments.length > 0 && !hasSendMode) {
741
+ throw new Error("--attach only applies when sending (`prodex pro browser ask`); the dry-run preview cannot upload files.");
742
+ }
743
+ // --tool turns on a composer tool (deep-research, web-search,
744
+ // create-image, or any label the menu shows) for this send.
745
+ const tools = readRepeatedFlag(parsedAskPro.optionArgs, "--tool");
746
+ if (tools.length > 0 && !hasSendMode) {
747
+ throw new Error("--tool only applies when sending (`prodex pro browser ask`); the dry-run preview cannot open ChatGPT's tools menu.");
748
+ }
725
749
  const targetUrl = readFlag(parsedAskPro.optionArgs, "--target-url");
726
750
  const normalizedTargetUrl = targetUrl ? normalizeChatGptTargetUrl(targetUrl) : undefined;
727
751
  if (!normalizedTargetUrl && parsedAskPro.optionArgs.includes("--confirm-target")) {
@@ -815,7 +839,7 @@ export async function runAskProCommand(rest, io) {
815
839
  // consults (observed in several field sessions).
816
840
  const defaultBrowserTimeoutMs = effectiveProSelection ? 1_200_000 : 300_000;
817
841
  const browserTimeoutMs = hasSendMode
818
- ? (readPositiveIntegerFlag(parsedAskPro.optionArgs, "--timeout-ms") ?? defaultBrowserTimeoutMs)
842
+ ? (readPositiveIntegerFlag(parsedAskPro.optionArgs, "--timeout-ms") ?? defaultTimeoutForTools(tools, defaultBrowserTimeoutMs))
819
843
  : undefined;
820
844
  const sourceCli = resolveOptionalFileFlag(io.cwd, parsedAskPro.optionArgs, "--source-cli");
821
845
  const bundle = await buildDryRunBundle(targetCwd, { prompt: promptText, files });
@@ -880,6 +904,8 @@ export async function runAskProCommand(rest, io) {
880
904
  prompt: bundle.text,
881
905
  targetUrl: normalizedTargetUrl,
882
906
  timeoutMs: browserTimeoutMs,
907
+ ...(attachments.length > 0 ? { attachments } : {}),
908
+ ...(tools.length > 0 ? { tools } : {}),
883
909
  ...(newChat ? { newChat: true } : {}),
884
910
  ...(busyWaitMs !== undefined ? { busyWaitMs } : {}),
885
911
  project: selectionProject,
@@ -1148,6 +1174,8 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1148
1174
  ...(input.project !== undefined ? ["--project", input.project] : []),
1149
1175
  ...(input.timeout_ms !== undefined ? ["--timeout-ms", String(input.timeout_ms)] : []),
1150
1176
  ...(input.files ?? []).flatMap((file) => ["--file", file]),
1177
+ ...(input.attach ?? []).flatMap((file) => ["--attach", file]),
1178
+ ...(input.tools ?? []).flatMap((tool) => ["--tool", tool]),
1151
1179
  ...(input.new_chat ? ["--new-chat"] : []),
1152
1180
  "--",
1153
1181
  input.prompt
package/dist/mcp.js CHANGED
@@ -150,6 +150,16 @@ export function createServer(cwd = process.cwd(), options = {}) {
150
150
  project: McpShortTextSchema.optional(),
151
151
  timeout_ms: z.number().int().positive().max(3_600_000).optional(),
152
152
  files: z.array(McpShortTextSchema).max(20).optional(),
153
+ tools: z
154
+ .array(McpShortTextSchema)
155
+ .max(4)
156
+ .optional()
157
+ .describe("ChatGPT composer tools to enable for this consult: \"deep-research\" (a multi-minute browsed report - the timeout rises to 30 minutes automatically), \"web-search\" (current facts), \"create-image\". Deep research often replies with a CLARIFYING QUESTION first; answer it with a normal follow-up consult in the same thread."),
158
+ attach: z
159
+ .array(McpShortTextSchema)
160
+ .max(10)
161
+ .optional()
162
+ .describe("Repo-relative paths to UPLOAD to ChatGPT as real attachments (pdf, pptx, xlsx, images, or any file you want ChatGPT to parse itself). Use this instead of `files` for binaries and for large documents; `files` inlines a text file's contents into the prompt, which cannot carry a binary and bloats the prompt."),
153
163
  new_chat: z
154
164
  .boolean()
155
165
  .optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.19.2",
3
+ "version": "0.21.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",