@youdie006/prodex 0.19.1 → 0.20.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. 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,26 @@ 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
+ }
1068
1088
  export function modelButtonRectExpression() {
1069
1089
  return `(() => {${CLICK_POINT_SNIPPET}
1070
1090
  const c = document.querySelector('#prompt-textarea,[contenteditable="true"],textarea');
@@ -1075,7 +1095,10 @@ export function modelButtonRectExpression() {
1075
1095
  return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach|file|voice|record|search|mic/i.test(t + aria);
1076
1096
  });
1077
1097
  if (!b) return { ok: false, reason: "model selector button not found" };
1078
- return clickPoint(b);
1098
+ // Return the label too: the caller compares it against the requested model
1099
+ // to skip opening the menu when it is already selected.
1100
+ const label = ((b.getAttribute("aria-label") || b.textContent || "").trim().split(String.fromCharCode(10))[0] || "").trim();
1101
+ return { ...clickPoint(b), label };
1079
1102
  })()`;
1080
1103
  }
1081
1104
  export function menuItemRectExpression(label) {
@@ -1236,6 +1259,13 @@ async function selectModelReasoning(cdp, options) {
1236
1259
  if (!button.ok || button.x === undefined || button.y === undefined) {
1237
1260
  throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
1238
1261
  }
1262
+ // Skip the menu entirely when the picker already shows the requested model:
1263
+ // it is the same end state, and it survives ChatGPT reshuffling the menu
1264
+ // (which it did - the models moved behind a "Model" submenu and every
1265
+ // --model Pro send started failing).
1266
+ if (options.model && !options.proMode && !options.effort && modelButtonAlreadyShows(options.model, button.label)) {
1267
+ return;
1268
+ }
1239
1269
  try {
1240
1270
  // The hover-verified click can be transiently refused right after a page
1241
1271
  // transition (measured live: the just-closed create-project modal's
@@ -1729,7 +1759,14 @@ export async function sendChatGptPrompt(options) {
1729
1759
  // into; a --project/--project-new hop lands on a page with its own counts.
1730
1760
  beforeSubmit = await evaluateOnPage(page, answerExpression());
1731
1761
  dbgSend(`baseline url=${beforeSubmit.url} user=${beforeSubmit.userMessageCount} assistant=${beforeSubmit.assistantMessageCount}`);
1732
- await insertComposerTextViaCdp(cdp, options.prompt);
1762
+ // Attach BEFORE typing: the upload is the slow part, and a file that
1763
+ // arrives after the prompt is submitted is a file ChatGPT never saw.
1764
+ if (options.attachments && options.attachments.length > 0) {
1765
+ emitProgress("selecting", `uploading ${options.attachments.length} file(s)`);
1766
+ const uploaded = await attachFilesToComposer(cdp, options.attachments);
1767
+ emitProgress("selecting", `attached ${uploaded.attached.join(", ")}`);
1768
+ }
1769
+ await insertComposerTextViaCdp(cdp, options.prompt, page);
1733
1770
  // The send button renders asynchronously after the prompt lands. Poll for it
1734
1771
  // BEFORE submitting so (a) submitButtonFound reflects whether the control
1735
1772
  // actually EXISTS - otherwise a successful Enter-key submit skips the fallback
@@ -1738,7 +1775,12 @@ export async function sendChatGptPrompt(options) {
1738
1775
  // live: submitExpression finds data-testid="send-button" fine, yet the
1739
1776
  // timeout error blamed a UI change) - and (b) we never press Enter before the
1740
1777
  // composer is submit-ready.
1741
- submitButtonFound = await waitForExpressionTrue(cdp, `(${submitExpression()}).ok === true`, 3_000);
1778
+ // With an attachment, ChatGPT keeps the send control disabled while it
1779
+ // ingests the file server-side; 3s expired mid-ingest and the send never
1780
+ // posted (measured live on a markdown attachment: the button was enabled
1781
+ // and clickable a moment after prodex gave up).
1782
+ const submitReadyBudgetMs = options.attachments && options.attachments.length > 0 ? 120_000 : 3_000;
1783
+ submitButtonFound = await waitForExpressionTrue(cdp, `(${submitExpression()}).ok === true`, submitReadyBudgetMs);
1742
1784
  // Submit. Prefer the Enter key: it goes to the focused composer and does
1743
1785
  // not depend on coordinates, whereas the send button moves ~100px as the
1744
1786
  // composer grows after the prompt lands, so a click at captured coordinates
@@ -2263,6 +2305,147 @@ export function prepareComposerExpression() {
2263
2305
  // just that it is non-empty): a failed clear would leave stale text prepended,
2264
2306
  // silently submitting a contaminated prompt. Whitespace is collapsed on both
2265
2307
  // sides because ProseMirror round-trips newlines as extra blank lines.
2308
+ /**
2309
+ * Insert the whole prompt with ONE in-page execCommand("insertText"). The
2310
+ * editor applies it as a single input event, so - unlike a chunked
2311
+ * Input.insertText sequence - nothing can interleave at a boundary and the
2312
+ * text lands byte-for-byte. Used for prompts too large to push through
2313
+ * Input.insertText in one CDP command.
2314
+ */
2315
+ export function insertComposerTextInPageExpression(text) {
2316
+ const textJson = JSON.stringify(text);
2317
+ return `(() => {
2318
+ ${composerExpressionHelpers()}
2319
+ const el = findChatGptComposerCandidate();
2320
+ if (!el) return { ok: false, reason: "No visible composer" };
2321
+ el.focus();
2322
+ if ("value" in el) {
2323
+ el.value = ${textJson};
2324
+ el.dispatchEvent(new Event("input", { bubbles: true }));
2325
+ return { ok: true };
2326
+ }
2327
+ const selection = window.getSelection();
2328
+ const all = document.createRange();
2329
+ all.selectNodeContents(el);
2330
+ selection.removeAllRanges();
2331
+ selection.addRange(all);
2332
+ document.execCommand("delete");
2333
+ const inserted = document.execCommand("insertText", false, ${textJson});
2334
+ if (!inserted) return { ok: false, reason: "The ChatGPT composer refused the prompt text" };
2335
+ return { ok: true };
2336
+ })()`;
2337
+ }
2338
+ // ---------------------------------------------------------------------------
2339
+ // Attachments (real file upload)
2340
+ //
2341
+ // `--file` inlines a file's TEXT into the prompt; this uploads the file itself
2342
+ // through the composer's file input, which is the only way to hand ChatGPT a
2343
+ // pdf/pptx/image and let it parse the original. CDP's DOM.setFileInputFiles
2344
+ // sets the input without any file dialog.
2345
+ // ---------------------------------------------------------------------------
2346
+ /**
2347
+ * The composer's general file input. Measured live: ChatGPT renders three
2348
+ * inputs - one general (accept="") plus two accept="image/*" - and attaching a
2349
+ * document to an image-only input silently does nothing.
2350
+ */
2351
+ export function composerFileInputSelector() {
2352
+ return 'input[type="file"]:not([accept*="image"])';
2353
+ }
2354
+ /**
2355
+ * Which of the expected attachments the composer shows, and whether one is
2356
+ * still uploading.
2357
+ *
2358
+ * Measured live: a DOCUMENT chip carries its filename in visible text, but an
2359
+ * IMAGE renders as a blob: thumbnail whose only filename is on the remove
2360
+ * button's aria-label ("Remove file 1: cli-banner.png"). Reading innerText
2361
+ * alone therefore reported a perfectly good image upload as never accepted.
2362
+ */
2363
+ export function attachmentStateExpression(fileNames) {
2364
+ const namesJson = JSON.stringify(fileNames);
2365
+ return `(() => {
2366
+ const names = ${namesJson};
2367
+ const text = document.body ? document.body.innerText || "" : "";
2368
+ const labels = [...document.querySelectorAll('[aria-label],[title],[alt]')]
2369
+ .map((el) => (el.getAttribute("aria-label") || el.getAttribute("title") || el.getAttribute("alt") || ""))
2370
+ .join(String.fromCharCode(10));
2371
+ const haystack = text + String.fromCharCode(10) + labels;
2372
+ const present = names.filter((name) => haystack.includes(name));
2373
+ const attachedFiles = [...document.querySelectorAll('input[type="file"]')]
2374
+ .reduce((total, el) => total + (el.files ? el.files.length : 0), 0);
2375
+ // A visible progressbar means a file is still going up; sending now would
2376
+ // post the prompt without it.
2377
+ const uploading = document.querySelectorAll('[role="progressbar"]').length > 0;
2378
+ return { ok: true, present, attachedFiles, uploading };
2379
+ })()`;
2380
+ }
2381
+ /** How many attachments are already sitting in the composer (read-only). */
2382
+ export function attachmentPresenceExpression() {
2383
+ return `(() => {
2384
+ const buttons = [...document.querySelectorAll('button[aria-label]')].filter((b) =>
2385
+ /remove file|파일 제거|첨부 제거/i.test(b.getAttribute("aria-label") || "")
2386
+ );
2387
+ return { ok: true, removed: buttons.length };
2388
+ })()`;
2389
+ }
2390
+ /**
2391
+ * Attach files to the composer and wait until ChatGPT has taken them. The
2392
+ * files must be readable by the BROWSER process, so a browser on another
2393
+ * machine (or another container) cannot see the caller's paths - that shows up
2394
+ * as the attachment never appearing, which this reports as such.
2395
+ */
2396
+ export async function attachFilesToComposer(cdp, absolutePaths, options = {}) {
2397
+ if (absolutePaths.length === 0)
2398
+ return { attached: [] };
2399
+ await cdp.send("DOM.enable");
2400
+ // Drop anything a previous (failed) send left attached: it would ride along
2401
+ // with this prompt, which is exactly the kind of silent contamination the
2402
+ // composer text check already guards against. This must happen BEFORE the
2403
+ // input node is resolved - removing a chip re-renders the composer and
2404
+ // replaces the input element, and setting files on the old (detached) node
2405
+ // silently does nothing (measured live: the file input held the file while
2406
+ // the UI showed no attachment at all).
2407
+ // Leftover attachments from a previous (failed) send would ride along with
2408
+ // this prompt. Clearing them by clicking the remove buttons WEDGES the
2409
+ // composer: measured live, after a removal the file input accepts files
2410
+ // (input.files becomes 1) while the UI never renders the chip again, and
2411
+ // every later attach in that tab silently does nothing. A reload is the only
2412
+ // reliable reset, and it costs a few seconds only when there is something to
2413
+ // clear.
2414
+ const stale = await cdp.evaluate(attachmentPresenceExpression());
2415
+ if ((stale?.removed ?? 0) > 0) {
2416
+ await cdp.evaluate("location.reload()");
2417
+ await sleep(6_000);
2418
+ const settleDeadline = Date.now() + 15_000;
2419
+ for (;;) {
2420
+ const ready = await cdp.evaluate(composerTextStateExpression());
2421
+ if (ready?.ok || Date.now() >= settleDeadline)
2422
+ break;
2423
+ await sleep(500);
2424
+ }
2425
+ }
2426
+ const document = await cdp.send("DOM.getDocument", { depth: -1, pierce: true });
2427
+ const rootNodeId = document.result?.root?.nodeId;
2428
+ if (rootNodeId === undefined)
2429
+ throw new Error("Could not read the ChatGPT page DOM to attach files.");
2430
+ const input = await cdp.send("DOM.querySelector", { nodeId: rootNodeId, selector: composerFileInputSelector() });
2431
+ const inputNodeId = input.result?.nodeId;
2432
+ if (!inputNodeId) {
2433
+ 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.");
2434
+ }
2435
+ await cdp.send("DOM.setFileInputFiles", { files: absolutePaths, nodeId: inputNodeId });
2436
+ const fileNames = absolutePaths.map((file) => path.basename(file));
2437
+ const deadline = Date.now() + (options.timeoutMs ?? 120_000);
2438
+ let lastPresent = [];
2439
+ while (Date.now() < deadline) {
2440
+ await sleep(1_000);
2441
+ const state = await cdp.evaluate(attachmentStateExpression(fileNames));
2442
+ lastPresent = state?.present ?? [];
2443
+ if (lastPresent.length === fileNames.length && !state?.uploading)
2444
+ return { attached: lastPresent };
2445
+ }
2446
+ const missing = fileNames.filter((name) => !lastPresent.includes(name));
2447
+ 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.`);
2448
+ }
2266
2449
  export function composerTextStateExpression(expectedText) {
2267
2450
  const expectedJson = JSON.stringify(expectedText ?? null);
2268
2451
  return `(() => {
@@ -2283,7 +2466,7 @@ export function composerTextStateExpression(expectedText) {
2283
2466
  // Focus the composer, clear any leftover text submit-safely, type the prompt
2284
2467
  // with native CDP input so ProseMirror registers it, then verify the composer
2285
2468
  // holds exactly the prompt.
2286
- async function insertComposerTextViaCdp(cdp, text) {
2469
+ async function insertComposerTextViaCdp(cdp, text, page) {
2287
2470
  const prepared = await cdp.evaluate(prepareComposerExpression());
2288
2471
  if (!prepared.ok)
2289
2472
  throw new Error(prepared.reason ?? "Could not focus the ChatGPT composer");
@@ -2299,13 +2482,42 @@ async function insertComposerTextViaCdp(cdp, text) {
2299
2482
  await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
2300
2483
  await sleep(100);
2301
2484
  }
2302
- // Insert in bounded chunks: a single multi-KB Input.insertText makes
2303
- // ProseMirror do one huge transaction, which on a heavy thread stalls past
2304
- // the 20s CDP command timeout and kills the send with "Chrome DevTools
2305
- // command timed out: Input.insertText" (field failure on long prompts,
2306
- // twice in one session). Each chunk gets its own command budget.
2307
- for (const chunk of chunkComposerText(text)) {
2308
- await cdp.send("Input.insertText", { text: chunk });
2485
+ // Insertion path, chosen by size:
2486
+ //
2487
+ // Short prompts go through Input.insertText - real key-level input events,
2488
+ // which is what ChatGPT's composer is built for.
2489
+ //
2490
+ // Long prompts do NOT. One giant insertText stalls ProseMirror past the CDP
2491
+ // command timeout, and splitting it into chunks corrupts the text at the
2492
+ // chunk boundaries: measured live on a 95 KB prompt, the composer ended up
2493
+ // the right LENGTH but with content shifted from the first boundary on
2494
+ // (first divergence at 3,938 chars with a 4,000-char chunk size), which is
2495
+ // what surfaced to users as "Composer text did not match after insertion".
2496
+ // Both failures come from crossing the CDP boundary mid-edit, so large text
2497
+ // is inserted by a single in-page execCommand instead: the editor applies it
2498
+ // as one input event and nothing can interleave. Measured: 67 KB inserted in
2499
+ // ~20s, verified byte-for-byte.
2500
+ if (text.length <= COMPOSER_INSERT_CHUNK_CHARS) {
2501
+ await cdp.send("Input.insertText", { text });
2502
+ }
2503
+ else {
2504
+ // A 67 KB insert measured ~20s in the page, which the default 20s CDP
2505
+ // command budget would cut off, so this one call gets its own connection
2506
+ // with a size-scaled budget instead of loosening the budget for every
2507
+ // command on the shared connection.
2508
+ const budgetMs = Math.max(60_000, Math.ceil(text.length / 1_000) * 1_000);
2509
+ const slowCdp = page ? await connectCdp(page.webSocketDebuggerUrl, budgetMs) : undefined;
2510
+ try {
2511
+ const target = slowCdp ?? cdp;
2512
+ if (slowCdp)
2513
+ await slowCdp.send("Runtime.enable");
2514
+ const inserted = await target.evaluate(insertComposerTextInPageExpression(text));
2515
+ if (!inserted?.ok)
2516
+ throw new Error(inserted?.reason ?? "Could not insert the prompt into the ChatGPT composer");
2517
+ }
2518
+ finally {
2519
+ slowCdp?.close();
2520
+ }
2309
2521
  }
2310
2522
  await sleep(200);
2311
2523
  const state = await cdp.evaluate(composerTextStateExpression(text));
package/dist/cli-args.js CHANGED
@@ -261,6 +261,8 @@ 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",
264
266
  "--port",
265
267
  "--timeout-ms",
266
268
  "--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] "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] [--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]
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] "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] [--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]
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] ${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] ${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,3 +1,4 @@
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";
@@ -722,6 +723,23 @@ 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
+ }
725
743
  const targetUrl = readFlag(parsedAskPro.optionArgs, "--target-url");
726
744
  const normalizedTargetUrl = targetUrl ? normalizeChatGptTargetUrl(targetUrl) : undefined;
727
745
  if (!normalizedTargetUrl && parsedAskPro.optionArgs.includes("--confirm-target")) {
@@ -880,6 +898,7 @@ export async function runAskProCommand(rest, io) {
880
898
  prompt: bundle.text,
881
899
  targetUrl: normalizedTargetUrl,
882
900
  timeoutMs: browserTimeoutMs,
901
+ ...(attachments.length > 0 ? { attachments } : {}),
883
902
  ...(newChat ? { newChat: true } : {}),
884
903
  ...(busyWaitMs !== undefined ? { busyWaitMs } : {}),
885
904
  project: selectionProject,
@@ -1148,6 +1167,7 @@ export async function performBrowserConsultForMcp(cwd, input, onProgress) {
1148
1167
  ...(input.project !== undefined ? ["--project", input.project] : []),
1149
1168
  ...(input.timeout_ms !== undefined ? ["--timeout-ms", String(input.timeout_ms)] : []),
1150
1169
  ...(input.files ?? []).flatMap((file) => ["--file", file]),
1170
+ ...(input.attach ?? []).flatMap((file) => ["--attach", file]),
1151
1171
  ...(input.new_chat ? ["--new-chat"] : []),
1152
1172
  "--",
1153
1173
  input.prompt
package/dist/mcp.js CHANGED
@@ -150,6 +150,11 @@ 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
+ attach: z
154
+ .array(McpShortTextSchema)
155
+ .max(10)
156
+ .optional()
157
+ .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
158
  new_chat: z
154
159
  .boolean()
155
160
  .optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",