@youdie006/prodex 0.3.0 → 0.4.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
@@ -196,18 +196,29 @@ prodex pro browser ask --model Pro --pro-mode 확장 --project "my-project" "Rev
196
196
  prodex pro browser ask --effort "매우 높음" "Draft the release notes"
197
197
  ```
198
198
 
199
- - `--model` picks the composer model by its visible label (for example `Pro` or `GPT-5.5`).
200
- - `--pro-mode 기본|확장` selects the Pro sub-mode; it applies only when the model is Pro.
201
- - `--effort 즉시|중간|높음|"매우 높음"` sets the reasoning effort for non-Pro models. English aliases `instant`/`medium`/`high`/`max` are accepted. `--pro-mode` and `--effort` are different model axes and cannot be combined.
202
- - `--project "name"` enters an existing sidebar project before sending. Creating a new project from the CLI is not supported yet; make it in ChatGPT first, then pass `--project`.
199
+ Prerequisite: selection matches menu items by their visible text in the **Korean ChatGPT UI** (즉시/중간/높음/매우 높음, Pro 기본/확장, "프로젝트" sidebar labels). If your ChatGPT display language is not Korean, the selection flags will fail with "menu item not found"; either switch the UI language or send without selection flags.
203
200
 
204
- Persist defaults so you can omit these flags on routine asks; a per-ask flag always overrides the saved default:
201
+ To see the labels your account currently shows, list them read-only (opens the menu, reads it, presses Escape — nothing is selected):
202
+
203
+ ```bash
204
+ prodex pro browser models
205
+ ```
206
+
207
+ - `--model` picks the composer model by its exact menu label. `Pro` is verified end-to-end. Models whose menu entry opens a submenu of variants (for example GPT-5.5) are rejected with a clear error instead of silently keeping the previous model; direct variant selection is planned.
208
+ - `--pro-mode 기본|확장` selects the Pro sub-mode; it applies only when the model is Pro. `확장` can think for minutes, so it raises the default `--timeout-ms` from 90000 to 300000 (an explicit `--timeout-ms` always wins).
209
+ - `--effort 즉시|중간|높음|"매우 높음"` sets the reasoning effort. English aliases `instant`/`medium`/`high`/`max` are accepted. The effort options and Pro share one radio group in ChatGPT, so picking an effort switches the composer to the standard reasoning model and deselects Pro; for the same reason `--pro-mode` and `--effort` cannot be combined.
210
+ - `--project "name"` enters an existing sidebar project before sending. It cannot be combined with `--target-url` (the project click would navigate away from the confirmed tab). Creating a new project from the CLI is not supported yet; make it in ChatGPT first, then pass `--project`.
211
+
212
+ Selection clicks are guarded: prodex refuses to click a control that is covered or out of view, waits for the menu to actually open instead of sleeping a fixed delay, and treats a menu that stays open after a pick as a failed selection. If any step fails, it backs out with Escape and reports a blocker instead of sending with the wrong model. Note that an applied selection stays active in your ChatGPT session after the send — switch back manually if you were on a different model.
213
+
214
+ Persist defaults so you can omit these flags on routine asks; a per-ask flag always overrides the saved default. View saved defaults with `prodex status`, and clear one with the matching `--clear-*` flag:
205
215
 
206
216
  ```bash
207
217
  prodex setup --model Pro --pro-mode 확장 --project "my-project"
218
+ prodex setup --clear-project
208
219
  ```
209
220
 
210
- Whatever selection is applied is recorded on the consult receipt (`metadata.selection`). `prodex` only clicks the picker you can see; it never selects a model, effort, or project silently outside the visible browser.
221
+ 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.
211
222
 
212
223
  For a source checkout, keep the explicit send and inspection commands source-aware too:
213
224
 
@@ -128,12 +128,30 @@ export declare function getChatGptBrowserStatus(options?: {
128
128
  port?: number;
129
129
  timeoutMs?: number;
130
130
  }): Promise<ChatGptBrowserStatus>;
131
+ export declare function menuOpenExpression(): string;
132
+ export declare function menuClosedExpression(): string;
133
+ export declare function menuItemPresentExpression(label: string): string;
131
134
  export declare function modelButtonRectExpression(): string;
132
135
  export declare function menuItemRectExpression(label: string): string;
133
136
  export declare function submenuItemRectExpression(label: string): string;
137
+ export declare function proRadioRectExpression(): string;
134
138
  export declare function proSubmenuExpanderRectExpression(): string;
135
139
  export declare function projectItemRectExpression(name: string): string;
136
140
  export declare function sendChatGptPrompt(options: SendChatGptPromptOptions): Promise<SendChatGptPromptResult>;
141
+ export interface ChatGptModelOption {
142
+ label: string;
143
+ kind: "radio" | "submenu";
144
+ checked: boolean;
145
+ }
146
+ export interface ListChatGptModelOptionsResult {
147
+ url: string;
148
+ options: ChatGptModelOption[];
149
+ }
150
+ export declare function modelMenuOptionsExpression(): string;
151
+ export declare function listChatGptModelOptions(input?: {
152
+ port?: number;
153
+ timeoutMs?: number;
154
+ }): Promise<ListChatGptModelOptionsResult>;
137
155
  export declare function statusExpression(): string;
138
156
  export declare function setComposerTextExpression(text: string): string;
139
157
  export declare function submitExpression(): string;
@@ -84,8 +84,15 @@ export function isUsableChatGptAnswer(answer) {
84
84
  const normalized = answer.trim();
85
85
  if (!normalized)
86
86
  return false;
87
- if (/^(생각 중|thinking|thought for|thought about)/i.test(normalized.replace(/\.+$/, ""))) {
88
- return normalized.split(/\r?\n/).filter(Boolean).length > 1;
87
+ const stripped = normalized.replace(/\.+$/, "");
88
+ const lineCount = normalized.split(/\r?\n/).filter(Boolean).length;
89
+ if (/^(생각 중|thinking|thought for|thought about)/i.test(stripped)) {
90
+ return lineCount > 1;
91
+ }
92
+ // Model-prefixed thinking status, e.g. "Pro 생각 중": a single short line
93
+ // ending in 생각 중 is the placeholder, not an answer.
94
+ if (lineCount <= 1 && /(^|\s)생각 중$/.test(stripped)) {
95
+ return false;
89
96
  }
90
97
  return true;
91
98
  }
@@ -396,17 +403,75 @@ export async function getChatGptBrowserStatus(options = {}) {
396
403
  blocker
397
404
  };
398
405
  }
399
- async function dispatchMouseClickAt(cdp, x, y) {
400
- await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
401
- await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
402
- await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
403
- }
404
406
  async function dispatchEscapeKey(cdp) {
405
407
  await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
406
408
  await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
407
409
  }
408
- export function modelButtonRectExpression() {
410
+ // Poll a boolean page expression instead of sleeping a fixed duration, so slow
411
+ // renders wait longer and fast ones do not waste time.
412
+ async function waitForExpressionTrue(cdp, expression, timeoutMs, intervalMs = 150) {
413
+ const startedAt = Date.now();
414
+ for (;;) {
415
+ if (await cdp.evaluate(expression))
416
+ return true;
417
+ if (Date.now() - startedAt >= timeoutMs)
418
+ return false;
419
+ await sleep(intervalMs);
420
+ }
421
+ }
422
+ const MENU_OPEN_TIMEOUT_MS = 5_000;
423
+ const MENU_SETTLE_TIMEOUT_MS = 5_000;
424
+ const PROJECT_NAVIGATION_TIMEOUT_MS = 8_000;
425
+ export function menuOpenExpression() {
426
+ return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"]'))`;
427
+ }
428
+ export function menuClosedExpression() {
429
+ return `!document.querySelector('[data-testid="composer-intelligence-picker-content"]')`;
430
+ }
431
+ export function menuItemPresentExpression(label) {
432
+ return `[...document.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')].some((r) => (r.textContent || "").trim() === ${JSON.stringify(label)})`;
433
+ }
434
+ // Shared click-point resolver: scroll the target into view, tag it with a
435
+ // temporary attribute, and return its center. The coverage check happens later
436
+ // in verifiedClickAt AFTER the pointer hovers the point, because ChatGPT uses
437
+ // hover-revealed controls (e.g. the Pro sub-mode chevron) that only become
438
+ // hit-testable once the row is hovered.
439
+ const CLICK_POINT_SNIPPET = `
440
+ const clickPoint = (el, yCap) => {
441
+ document.querySelectorAll('[data-prodex-click]').forEach((n) => n.removeAttribute('data-prodex-click'));
442
+ el.scrollIntoView({ block: "center", inline: "nearest" });
443
+ const r = el.getBoundingClientRect();
444
+ if (r.width < 1 || r.height < 1) return { ok: false, reason: "target element has no visible area" };
445
+ el.setAttribute('data-prodex-click', '1');
446
+ const x = Math.round(r.x + r.width / 2);
447
+ const y = Math.round(r.y + (yCap ? Math.min(r.height / 2, yCap) : r.height / 2));
448
+ return { ok: true, x, y };
449
+ };`;
450
+ function hoverVerifyExpression(x, y) {
409
451
  return `(() => {
452
+ const el = document.querySelector('[data-prodex-click]');
453
+ const hit = document.elementFromPoint(${x}, ${y});
454
+ const ok = Boolean(el && hit && (hit === el || el.contains(hit) || hit.contains(el)));
455
+ if (el) el.removeAttribute('data-prodex-click');
456
+ return ok;
457
+ })()`;
458
+ }
459
+ // Move the pointer first, give hover styles a beat to apply, then confirm the
460
+ // tagged target is what would actually receive the click before pressing.
461
+ // These clicks land in the user's real session, so a covered or scrolled-out
462
+ // target must fail loudly instead of clicking whatever sits at the point.
463
+ async function verifiedClickAt(cdp, x, y, label) {
464
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
465
+ await sleep(150);
466
+ const onTarget = await cdp.evaluate(hoverVerifyExpression(x, y));
467
+ if (!onTarget) {
468
+ throw new Error(`Refusing to click "${label}": another element covers its click point (overlay, scroll, or layout change). Retry, or interact manually in the visible browser.`);
469
+ }
470
+ await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
471
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
472
+ }
473
+ export function modelButtonRectExpression() {
474
+ return `(() => {${CLICK_POINT_SNIPPET}
410
475
  const c = document.querySelector('#prompt-textarea,[contenteditable="true"],textarea');
411
476
  const scope = c ? (c.closest('form') || document) : document;
412
477
  const b = [...scope.querySelectorAll('[aria-haspopup="menu"]')].find((el) => {
@@ -415,38 +480,51 @@ export function modelButtonRectExpression() {
415
480
  return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach/i.test(t + aria);
416
481
  });
417
482
  if (!b) return { ok: false, reason: "model selector button not found" };
418
- const r = b.getBoundingClientRect();
419
- return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
483
+ return clickPoint(b);
420
484
  })()`;
421
485
  }
422
486
  export function menuItemRectExpression(label) {
423
- return `(() => {
487
+ return `(() => {${CLICK_POINT_SNIPPET}
424
488
  const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
425
489
  if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
426
490
  const items = [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
427
491
  const it = items.find((r) => (r.textContent || "").trim() === ${JSON.stringify(label)});
428
492
  if (!it) return { ok: false, reason: "menu item not found", available: items.map((r) => (r.textContent || "").trim()).slice(0, 12) };
429
- const r = it.getBoundingClientRect();
430
- return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
493
+ const point = clickPoint(it);
494
+ if (!point.ok) return point;
495
+ return { ...point, role: it.getAttribute("role"), haspopup: it.getAttribute("aria-haspopup") };
431
496
  })()`;
432
497
  }
433
498
  export function submenuItemRectExpression(label) {
434
- return `(() => {
499
+ return `(() => {${CLICK_POINT_SNIPPET}
435
500
  const items = [...document.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
436
501
  const it = items.find((r) => (r.textContent || "").trim() === ${JSON.stringify(label)});
437
502
  if (!it) return { ok: false, reason: "submenu item not found" };
438
- const r = it.getBoundingClientRect();
439
- return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
503
+ return clickPoint(it);
440
504
  })()`;
441
505
  }
442
506
  // "Pro" itself is a plain radio; its sub-modes (Pro 기본 / Pro 확장) live behind an
443
507
  // unlabeled aria-haspopup="menu" chevron sitting next to the Pro radio. Clicking
444
508
  // that chevron (hovering does not work) opens the sub-mode submenu.
509
+ // The Pro radio's visible label reflects the active sub-mode ("Pro", "Pro 기본",
510
+ // or "Pro 확장"), so it must be matched by prefix, never by exact text.
511
+ const PRO_RADIO_FINDER_SNIPPET = `
512
+ const findProRadio = (scope) =>
513
+ [...scope.querySelectorAll('[role="menuitemradio"]')].find((r) => /^Pro( |$)/.test((r.textContent || "").trim()));`;
514
+ export function proRadioRectExpression() {
515
+ return `(() => {${CLICK_POINT_SNIPPET}${PRO_RADIO_FINDER_SNIPPET}
516
+ const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
517
+ if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
518
+ const proRadio = findProRadio(m);
519
+ if (!proRadio) return { ok: false, reason: "Pro option not found in the model menu" };
520
+ return clickPoint(proRadio);
521
+ })()`;
522
+ }
445
523
  export function proSubmenuExpanderRectExpression() {
446
- return `(() => {
524
+ return `(() => {${CLICK_POINT_SNIPPET}${PRO_RADIO_FINDER_SNIPPET}
447
525
  const m = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]');
448
526
  if (!m) return { ok: false, reason: "model menu is not open" };
449
- const proRadio = [...m.querySelectorAll('[role="menuitemradio"]')].find((r) => (r.textContent || "").trim() === "Pro");
527
+ const proRadio = findProRadio(m);
450
528
  if (!proRadio) return { ok: false, reason: "Pro option not found in the model menu" };
451
529
  const proTop = proRadio.getBoundingClientRect().top;
452
530
  const expanders = [...m.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')].filter((e) => {
@@ -460,12 +538,11 @@ export function proSubmenuExpanderRectExpression() {
460
538
  if (dy < bestDy) { best = e; bestDy = dy; }
461
539
  }
462
540
  if (!best || bestDy > 40) return { ok: false, reason: "Pro sub-mode expander not found next to Pro" };
463
- const r = best.getBoundingClientRect();
464
- return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
541
+ return clickPoint(best);
465
542
  })()`;
466
543
  }
467
544
  export function projectItemRectExpression(name) {
468
- return `(() => {
545
+ return `(() => {${CLICK_POINT_SNIPPET}
469
546
  const opt = [...document.querySelectorAll('[aria-label*="프로젝트 옵션 열기"]')].find((b) => (b.getAttribute("aria-label") || "").startsWith(${JSON.stringify(name)}));
470
547
  let target = opt ? (opt.closest('a,[role="link"],li') || opt.parentElement) : null;
471
548
  if (!target) {
@@ -476,10 +553,17 @@ export function projectItemRectExpression(name) {
476
553
  }
477
554
  }
478
555
  if (!target) return { ok: false, reason: "project not found in sidebar" };
479
- const r = target.getBoundingClientRect();
480
- return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + Math.min(r.height / 2, 18)) };
556
+ return clickPoint(target, 18);
481
557
  })()`;
482
558
  }
559
+ // Clicking a menuitemradio commits the choice and closes the picker; a menu
560
+ // that stays open means the click did not land where we intended.
561
+ async function assertSelectionCommitted(cdp, label) {
562
+ const closed = await waitForExpressionTrue(cdp, menuClosedExpression(), MENU_SETTLE_TIMEOUT_MS);
563
+ if (!closed) {
564
+ throw new Error(`ChatGPT selection "${label}" did not commit; the model menu stayed open. Retry, or pick it manually in the visible browser.`);
565
+ }
566
+ }
483
567
  async function selectModelReasoning(cdp, options) {
484
568
  if (!options.model && !options.proMode && !options.effort)
485
569
  return;
@@ -487,52 +571,90 @@ async function selectModelReasoning(cdp, options) {
487
571
  if (!button.ok || button.x === undefined || button.y === undefined) {
488
572
  throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
489
573
  }
490
- await dispatchMouseClickAt(cdp, button.x, button.y);
491
- await sleep(1000);
492
- const wantsProMode = Boolean(options.proMode) && (!options.model || /pro/i.test(options.model));
493
- if (wantsProMode && options.proMode) {
494
- // Open the Pro sub-mode submenu via the chevron, then pick 기본/확장.
495
- const expander = await cdp.evaluate(proSubmenuExpanderRectExpression());
496
- if (!expander.ok || expander.x === undefined || expander.y === undefined) {
497
- await dispatchEscapeKey(cdp);
498
- throw new Error(expander.reason ?? "Could not open the ChatGPT Pro sub-mode submenu");
574
+ try {
575
+ await verifiedClickAt(cdp, button.x, button.y, "model selector");
576
+ const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
577
+ if (!opened)
578
+ throw new Error("ChatGPT model menu did not open after clicking the selector");
579
+ const wantsProMode = Boolean(options.proMode) && (!options.model || /pro/i.test(options.model));
580
+ if (wantsProMode && options.proMode) {
581
+ // Open the Pro sub-mode submenu via the chevron, then pick 기본/확장.
582
+ const expander = await cdp.evaluate(proSubmenuExpanderRectExpression());
583
+ if (!expander.ok || expander.x === undefined || expander.y === undefined) {
584
+ throw new Error(expander.reason ?? "Could not open the ChatGPT Pro sub-mode submenu");
585
+ }
586
+ await verifiedClickAt(cdp, expander.x, expander.y, "Pro sub-mode expander");
587
+ const subLabel = `Pro ${options.proMode}`;
588
+ const subVisible = await waitForExpressionTrue(cdp, menuItemPresentExpression(subLabel), MENU_OPEN_TIMEOUT_MS);
589
+ if (!subVisible)
590
+ throw new Error(`ChatGPT Pro sub-mode not found: ${subLabel}`);
591
+ const sub = await cdp.evaluate(submenuItemRectExpression(subLabel));
592
+ if (!sub.ok || sub.x === undefined || sub.y === undefined) {
593
+ throw new Error(sub.reason ?? `ChatGPT Pro sub-mode not clickable: ${subLabel}`);
594
+ }
595
+ await verifiedClickAt(cdp, sub.x, sub.y, subLabel);
596
+ await assertSelectionCommitted(cdp, subLabel);
597
+ return;
499
598
  }
500
- await dispatchMouseClickAt(cdp, expander.x, expander.y);
501
- await sleep(900);
502
- const subLabel = `Pro ${options.proMode}`;
503
- const sub = await cdp.evaluate(submenuItemRectExpression(subLabel));
504
- if (!sub.ok || sub.x === undefined || sub.y === undefined) {
599
+ const primaryLabel = options.model ?? options.effort;
600
+ if (!primaryLabel) {
505
601
  await dispatchEscapeKey(cdp);
506
- throw new Error(`ChatGPT Pro sub-mode not found: ${subLabel}`);
602
+ return;
507
603
  }
508
- await dispatchMouseClickAt(cdp, sub.x, sub.y);
509
- await sleep(400);
510
- return;
511
- }
512
- const primaryLabel = options.model ?? options.effort;
513
- if (!primaryLabel) {
514
- await dispatchEscapeKey(cdp);
515
- return;
604
+ // --model Pro without a sub-mode: the Pro radio's label carries the active
605
+ // sub-mode, so exact-label lookup would miss it.
606
+ if (options.model && /^pro$/i.test(options.model)) {
607
+ const proRadio = await cdp.evaluate(proRadioRectExpression());
608
+ if (!proRadio.ok || proRadio.x === undefined || proRadio.y === undefined) {
609
+ throw new Error(proRadio.reason ?? "Pro option not found in the model menu");
610
+ }
611
+ await verifiedClickAt(cdp, proRadio.x, proRadio.y, "Pro");
612
+ await assertSelectionCommitted(cdp, "Pro");
613
+ return;
614
+ }
615
+ const primary = await cdp.evaluate(menuItemRectExpression(primaryLabel));
616
+ if (!primary.ok || primary.x === undefined || primary.y === undefined) {
617
+ throw new Error(`ChatGPT model/effort option not found: ${primaryLabel}${primary.available ? ` (available: ${primary.available.join(", ")})` : ""}`);
618
+ }
619
+ if (primary.role === "menuitem" && primary.haspopup === "menu") {
620
+ throw new Error(`ChatGPT model "${primaryLabel}" opens a submenu of variants instead of committing; selecting it is not supported yet. Supported today: reasoning efforts (--effort) and Pro via --pro-mode.`);
621
+ }
622
+ await verifiedClickAt(cdp, primary.x, primary.y, primaryLabel);
623
+ await assertSelectionCommitted(cdp, primaryLabel);
516
624
  }
517
- const primary = await cdp.evaluate(menuItemRectExpression(primaryLabel));
518
- if (!primary.ok || primary.x === undefined || primary.y === undefined) {
519
- await dispatchEscapeKey(cdp);
520
- throw new Error(`ChatGPT model/effort option not found: ${primaryLabel}${primary.available ? ` (available: ${primary.available.join(", ")})` : ""}`);
625
+ catch (error) {
626
+ // Leave the user's screen clean: back out of any open menu before rethrowing.
627
+ try {
628
+ await dispatchEscapeKey(cdp);
629
+ await sleep(150);
630
+ await dispatchEscapeKey(cdp);
631
+ }
632
+ catch {
633
+ // best effort only
634
+ }
635
+ throw error;
521
636
  }
522
- await dispatchMouseClickAt(cdp, primary.x, primary.y);
523
- await sleep(400);
524
637
  }
525
638
  async function selectProject(cdp, options) {
526
639
  if (options.projectNew) {
527
640
  throw new Error("projectNew (new-project modal) automation is not implemented yet; create the project manually and use --project");
528
641
  }
529
- if (options.project) {
530
- const hit = await cdp.evaluate(projectItemRectExpression(options.project));
531
- if (!hit.ok || hit.x === undefined || hit.y === undefined) {
532
- throw new Error(`ChatGPT project not found in sidebar: ${options.project}`);
533
- }
534
- await dispatchMouseClickAt(cdp, hit.x, hit.y);
535
- await sleep(1200);
642
+ if (!options.project)
643
+ return;
644
+ const hrefBefore = await cdp.evaluate("location.href");
645
+ const hit = await cdp.evaluate(projectItemRectExpression(options.project));
646
+ if (!hit.ok || hit.x === undefined || hit.y === undefined) {
647
+ const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
648
+ throw new Error(`ChatGPT project not found in sidebar: ${options.project}${detail}`);
649
+ }
650
+ await verifiedClickAt(cdp, hit.x, hit.y, `project ${options.project}`);
651
+ const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
652
+ if (!navigated) {
653
+ throw new Error(`Clicking project "${options.project}" did not navigate the visible tab. If the tab is already inside this project, omit --project and retry.`);
654
+ }
655
+ const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
656
+ if (!composerReady) {
657
+ throw new Error(`ChatGPT composer did not appear after entering project "${options.project}"`);
536
658
  }
537
659
  }
538
660
  export async function sendChatGptPrompt(options) {
@@ -638,6 +760,71 @@ export async function sendChatGptPrompt(options) {
638
760
  warnings: []
639
761
  };
640
762
  }
763
+ export function modelMenuOptionsExpression() {
764
+ return `(() => {
765
+ const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
766
+ if (!m) return [];
767
+ return [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')]
768
+ .map((it) => ({
769
+ label: (it.textContent || "").trim(),
770
+ kind: it.getAttribute("aria-haspopup") === "menu" ? "submenu" : "radio",
771
+ checked: it.getAttribute("aria-checked") === "true"
772
+ }))
773
+ .filter((o) => o.label.length > 0);
774
+ })()`;
775
+ }
776
+ // Read-only discovery: open the composer model menu, read the option labels,
777
+ // and press Escape. Nothing is clicked inside the menu, so the user's model
778
+ // selection is never changed.
779
+ export async function listChatGptModelOptions(input = {}) {
780
+ const port = input.port ?? 9333;
781
+ const timeoutMs = input.timeoutMs ?? 15_000;
782
+ const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), undefined);
783
+ if (!pageResult.ok) {
784
+ throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
785
+ }
786
+ if (!pageResult.page) {
787
+ if (pageResult.blocker)
788
+ throw new ChatGptBrowserBlockerError(pageResult.blocker);
789
+ assertChatGptPageAvailable();
790
+ }
791
+ const page = pageResult.page;
792
+ const status = await evaluateOnPage(page, statusExpression());
793
+ const blocker = detectChatGptPageBlocker(status);
794
+ if (blocker)
795
+ throw new ChatGptBrowserBlockerError(blocker);
796
+ assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer);
797
+ assertVisibleChatGptTab(status.visibilityState, status.url, undefined);
798
+ const cdp = await connectCdp(page.webSocketDebuggerUrl);
799
+ try {
800
+ await cdp.send("Runtime.enable");
801
+ const button = await cdp.evaluate(modelButtonRectExpression());
802
+ if (!button.ok || button.x === undefined || button.y === undefined) {
803
+ throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
804
+ }
805
+ try {
806
+ await verifiedClickAt(cdp, button.x, button.y, "model selector");
807
+ const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
808
+ if (!opened)
809
+ throw new Error("ChatGPT model menu did not open after clicking the selector");
810
+ const options = await cdp.evaluate(modelMenuOptionsExpression());
811
+ return { url: status.url, options };
812
+ }
813
+ finally {
814
+ try {
815
+ await dispatchEscapeKey(cdp);
816
+ await sleep(150);
817
+ await dispatchEscapeKey(cdp);
818
+ }
819
+ catch {
820
+ // best effort only
821
+ }
822
+ }
823
+ }
824
+ finally {
825
+ cdp.close();
826
+ }
827
+ }
641
828
  async function findChatGptPage(port, timeoutMs, targetUrl) {
642
829
  try {
643
830
  const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
@@ -810,7 +997,9 @@ export function statusExpression() {
810
997
  }));
811
998
  const assistant = messages.filter((message) => message.role === "assistant").at(-1);
812
999
  const answer = assistant?.text || "";
813
- const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(answer.trim().replace(/\\.+$/, ""));
1000
+ const ansStripped = answer.trim().replace(/\\.+$/, "");
1001
+ const ansLines = ansStripped.split(/\\r?\\n/).filter((l) => l.trim());
1002
+ const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(ansStripped) || (ansLines.length <= 1 && /(^|\\s)생각 중$/.test(ansStripped));
814
1003
  const hasComposer = Boolean(findChatGptComposerCandidate());
815
1004
  return {
816
1005
  title: document.title,
@@ -960,7 +1149,9 @@ function answerExpression() {
960
1149
  .map((node) => (node.innerText || node.getAttribute("aria-label") || node.getAttribute("data-testid") || "").trim())
961
1150
  .filter(Boolean);
962
1151
  const answer = assistant?.text || "";
963
- const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(answer.trim().replace(/\\.+$/, ""));
1152
+ const ansStripped = answer.trim().replace(/\\.+$/, "");
1153
+ const ansLines = ansStripped.split(/\\r?\\n/).filter((l) => l.trim());
1154
+ const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(ansStripped) || (ansLines.length <= 1 && /(^|\\s)생각 중$/.test(ansStripped));
964
1155
  return {
965
1156
  title: document.title,
966
1157
  url: location.href,