@youdie006/prodex 0.2.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
@@ -184,6 +184,42 @@ prodex sessions show latest
184
184
 
185
185
  This uses the currently available ChatGPT web session and model selection. It is not a hidden API client, and it does not read cookies, tokens, localStorage, or sessionStorage.
186
186
 
187
+ #### Choosing the model, reasoning effort, and project
188
+
189
+ The visible-browser send drives the same composer picker you use by hand, so you can pick the model, reasoning effort, or a sidebar project per ask:
190
+
191
+ ```bash
192
+ # Pro extended sub-mode, inside an existing sidebar project
193
+ prodex pro browser ask --model Pro --pro-mode 확장 --project "my-project" "Review the migration plan"
194
+
195
+ # A non-Pro model at a specific reasoning effort
196
+ prodex pro browser ask --effort "매우 높음" "Draft the release notes"
197
+ ```
198
+
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.
200
+
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:
215
+
216
+ ```bash
217
+ prodex setup --model Pro --pro-mode 확장 --project "my-project"
218
+ prodex setup --clear-project
219
+ ```
220
+
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.
222
+
187
223
  For a source checkout, keep the explicit send and inspection commands source-aware too:
188
224
 
189
225
  ```bash
@@ -34,11 +34,27 @@ export declare class ChatGptBrowserBlockerError extends Error {
34
34
  readonly blocker: NonNullable<ChatGptBrowserStatus["blocker"]>;
35
35
  constructor(blocker: NonNullable<ChatGptBrowserStatus["blocker"]>);
36
36
  }
37
+ export type ChatGptReasoningEffort = "즉시" | "중간" | "높음" | "매우 높음";
38
+ export type ChatGptProMode = "기본" | "확장";
39
+ /** Normalize a CLI reasoning-effort value onto the exact ChatGPT menu label. */
40
+ export declare function parseReasoningEffort(raw: string): ChatGptReasoningEffort;
41
+ /** Normalize a CLI Pro sub-mode value onto the exact ChatGPT menu label. */
42
+ export declare function parseProMode(raw: string): ChatGptProMode;
37
43
  export interface SendChatGptPromptOptions {
38
44
  port?: number;
39
45
  prompt: string;
40
46
  targetUrl?: string;
41
47
  timeoutMs?: number;
48
+ /** Switch into this sidebar project (by visible name) before sending. */
49
+ project?: string;
50
+ /** Create a new project with this name before sending. */
51
+ projectNew?: string;
52
+ /** Model to select in the composer picker, e.g. "Pro" or "GPT-5.5". */
53
+ model?: string;
54
+ /** Pro sub-mode, used when model is Pro. */
55
+ proMode?: ChatGptProMode;
56
+ /** Reasoning effort, used for non-Pro models. */
57
+ effort?: ChatGptReasoningEffort;
42
58
  }
43
59
  export interface SendChatGptPromptResult {
44
60
  url: string;
@@ -112,7 +128,30 @@ export declare function getChatGptBrowserStatus(options?: {
112
128
  port?: number;
113
129
  timeoutMs?: number;
114
130
  }): Promise<ChatGptBrowserStatus>;
131
+ export declare function menuOpenExpression(): string;
132
+ export declare function menuClosedExpression(): string;
133
+ export declare function menuItemPresentExpression(label: string): string;
134
+ export declare function modelButtonRectExpression(): string;
135
+ export declare function menuItemRectExpression(label: string): string;
136
+ export declare function submenuItemRectExpression(label: string): string;
137
+ export declare function proRadioRectExpression(): string;
138
+ export declare function proSubmenuExpanderRectExpression(): string;
139
+ export declare function projectItemRectExpression(name: string): string;
115
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>;
116
155
  export declare function statusExpression(): string;
117
156
  export declare function setComposerTextExpression(text: string): string;
118
157
  export declare function submitExpression(): string;
@@ -10,6 +10,43 @@ export class ChatGptBrowserBlockerError extends Error {
10
10
  this.blocker = blocker;
11
11
  }
12
12
  }
13
+ const REASONING_EFFORTS = ["즉시", "중간", "높음", "매우 높음"];
14
+ const PRO_MODES = ["기본", "확장"];
15
+ // Aliases map friendly CLI input onto the exact Korean menu labels the picker
16
+ // clicks by text. Keys are lowercased and space-stripped before lookup.
17
+ const REASONING_EFFORT_ALIASES = {
18
+ "매우높음": "매우 높음",
19
+ instant: "즉시",
20
+ medium: "중간",
21
+ high: "높음",
22
+ max: "매우 높음"
23
+ };
24
+ const PRO_MODE_ALIASES = {
25
+ standard: "기본",
26
+ extended: "확장"
27
+ };
28
+ /** Normalize a CLI reasoning-effort value onto the exact ChatGPT menu label. */
29
+ export function parseReasoningEffort(raw) {
30
+ const trimmed = raw.trim();
31
+ const match = REASONING_EFFORTS.find((effort) => effort === trimmed);
32
+ if (match)
33
+ return match;
34
+ const alias = REASONING_EFFORT_ALIASES[trimmed.toLowerCase().replace(/\s+/g, "")];
35
+ if (alias)
36
+ return alias;
37
+ throw new Error(`--effort must be one of ${REASONING_EFFORTS.join(", ")}`);
38
+ }
39
+ /** Normalize a CLI Pro sub-mode value onto the exact ChatGPT menu label. */
40
+ export function parseProMode(raw) {
41
+ const trimmed = raw.trim();
42
+ const match = PRO_MODES.find((mode) => mode === trimmed);
43
+ if (match)
44
+ return match;
45
+ const alias = PRO_MODE_ALIASES[trimmed.toLowerCase().replace(/\s+/g, "")];
46
+ if (alias)
47
+ return alias;
48
+ throw new Error(`--pro-mode must be one of ${PRO_MODES.join(", ")}`);
49
+ }
13
50
  export const CHATGPT_RUNTIME_BLOCKER_TEXT_EXCLUDED_ANCESTORS = '[data-message-author-role],script,style,noscript,[aria-hidden="true"],div[role="textbox"],textarea,[contenteditable="true"]';
14
51
  export const CHATGPT_COMPOSER_CANDIDATE_EXCLUDED_ANCESTORS = '[data-message-author-role],script,style,noscript,[aria-hidden="true"]';
15
52
  export const PRODEX_ACTIVE_COMPOSER_ATTRIBUTE = "data-prodex-active-composer";
@@ -47,8 +84,15 @@ export function isUsableChatGptAnswer(answer) {
47
84
  const normalized = answer.trim();
48
85
  if (!normalized)
49
86
  return false;
50
- if (/^(생각 중|thinking|thought for|thought about)/i.test(normalized.replace(/\.+$/, ""))) {
51
- 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;
52
96
  }
53
97
  return true;
54
98
  }
@@ -359,6 +403,260 @@ export async function getChatGptBrowserStatus(options = {}) {
359
403
  blocker
360
404
  };
361
405
  }
406
+ async function dispatchEscapeKey(cdp) {
407
+ await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
408
+ await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
409
+ }
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) {
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}
475
+ const c = document.querySelector('#prompt-textarea,[contenteditable="true"],textarea');
476
+ const scope = c ? (c.closest('form') || document) : document;
477
+ const b = [...scope.querySelectorAll('[aria-haspopup="menu"]')].find((el) => {
478
+ const t = (el.textContent || "").trim();
479
+ const aria = el.getAttribute("aria-label") || "";
480
+ return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach/i.test(t + aria);
481
+ });
482
+ if (!b) return { ok: false, reason: "model selector button not found" };
483
+ return clickPoint(b);
484
+ })()`;
485
+ }
486
+ export function menuItemRectExpression(label) {
487
+ return `(() => {${CLICK_POINT_SNIPPET}
488
+ const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
489
+ if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
490
+ const items = [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
491
+ const it = items.find((r) => (r.textContent || "").trim() === ${JSON.stringify(label)});
492
+ if (!it) return { ok: false, reason: "menu item not found", available: items.map((r) => (r.textContent || "").trim()).slice(0, 12) };
493
+ const point = clickPoint(it);
494
+ if (!point.ok) return point;
495
+ return { ...point, role: it.getAttribute("role"), haspopup: it.getAttribute("aria-haspopup") };
496
+ })()`;
497
+ }
498
+ export function submenuItemRectExpression(label) {
499
+ return `(() => {${CLICK_POINT_SNIPPET}
500
+ const items = [...document.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
501
+ const it = items.find((r) => (r.textContent || "").trim() === ${JSON.stringify(label)});
502
+ if (!it) return { ok: false, reason: "submenu item not found" };
503
+ return clickPoint(it);
504
+ })()`;
505
+ }
506
+ // "Pro" itself is a plain radio; its sub-modes (Pro 기본 / Pro 확장) live behind an
507
+ // unlabeled aria-haspopup="menu" chevron sitting next to the Pro radio. Clicking
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
+ }
523
+ export function proSubmenuExpanderRectExpression() {
524
+ return `(() => {${CLICK_POINT_SNIPPET}${PRO_RADIO_FINDER_SNIPPET}
525
+ const m = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]');
526
+ if (!m) return { ok: false, reason: "model menu is not open" };
527
+ const proRadio = findProRadio(m);
528
+ if (!proRadio) return { ok: false, reason: "Pro option not found in the model menu" };
529
+ const proTop = proRadio.getBoundingClientRect().top;
530
+ const expanders = [...m.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')].filter((e) => {
531
+ const t = (e.textContent || "").trim();
532
+ return !/gpt|claude|gemini|o\\d|mini|thinking/i.test(t);
533
+ });
534
+ let best = null;
535
+ let bestDy = Infinity;
536
+ for (const e of expanders) {
537
+ const dy = Math.abs(e.getBoundingClientRect().top - proTop);
538
+ if (dy < bestDy) { best = e; bestDy = dy; }
539
+ }
540
+ if (!best || bestDy > 40) return { ok: false, reason: "Pro sub-mode expander not found next to Pro" };
541
+ return clickPoint(best);
542
+ })()`;
543
+ }
544
+ export function projectItemRectExpression(name) {
545
+ return `(() => {${CLICK_POINT_SNIPPET}
546
+ const opt = [...document.querySelectorAll('[aria-label*="프로젝트 옵션 열기"]')].find((b) => (b.getAttribute("aria-label") || "").startsWith(${JSON.stringify(name)}));
547
+ let target = opt ? (opt.closest('a,[role="link"],li') || opt.parentElement) : null;
548
+ if (!target) {
549
+ const icons = [...document.querySelectorAll('[data-testid="project-folder-icon"]')];
550
+ for (const ic of icons) {
551
+ const row = ic.closest('a,li,[role="link"]') || ic.parentElement?.parentElement;
552
+ if (row && (row.textContent || "").includes(${JSON.stringify(name)})) { target = row; break; }
553
+ }
554
+ }
555
+ if (!target) return { ok: false, reason: "project not found in sidebar" };
556
+ return clickPoint(target, 18);
557
+ })()`;
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
+ }
567
+ async function selectModelReasoning(cdp, options) {
568
+ if (!options.model && !options.proMode && !options.effort)
569
+ return;
570
+ const button = await cdp.evaluate(modelButtonRectExpression());
571
+ if (!button.ok || button.x === undefined || button.y === undefined) {
572
+ throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
573
+ }
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;
598
+ }
599
+ const primaryLabel = options.model ?? options.effort;
600
+ if (!primaryLabel) {
601
+ await dispatchEscapeKey(cdp);
602
+ return;
603
+ }
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);
624
+ }
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;
636
+ }
637
+ }
638
+ async function selectProject(cdp, options) {
639
+ if (options.projectNew) {
640
+ throw new Error("projectNew (new-project modal) automation is not implemented yet; create the project manually and use --project");
641
+ }
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}"`);
658
+ }
659
+ }
362
660
  export async function sendChatGptPrompt(options) {
363
661
  const port = options.port ?? 9333;
364
662
  const timeoutMs = options.timeoutMs ?? 90_000;
@@ -394,6 +692,8 @@ export async function sendChatGptPrompt(options) {
394
692
  const cdp = await connectCdp(page.webSocketDebuggerUrl);
395
693
  try {
396
694
  await cdp.send("Runtime.enable");
695
+ await selectProject(cdp, options);
696
+ await selectModelReasoning(cdp, options);
397
697
  const inserted = await cdp.evaluate(setComposerTextExpression(options.prompt));
398
698
  if (!inserted.ok)
399
699
  throw new Error(inserted.reason ?? "Could not insert text into ChatGPT composer");
@@ -460,6 +760,71 @@ export async function sendChatGptPrompt(options) {
460
760
  warnings: []
461
761
  };
462
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
+ }
463
828
  async function findChatGptPage(port, timeoutMs, targetUrl) {
464
829
  try {
465
830
  const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
@@ -632,7 +997,9 @@ export function statusExpression() {
632
997
  }));
633
998
  const assistant = messages.filter((message) => message.role === "assistant").at(-1);
634
999
  const answer = assistant?.text || "";
635
- 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));
636
1003
  const hasComposer = Boolean(findChatGptComposerCandidate());
637
1004
  return {
638
1005
  title: document.title,
@@ -782,7 +1149,9 @@ function answerExpression() {
782
1149
  .map((node) => (node.innerText || node.getAttribute("aria-label") || node.getAttribute("data-testid") || "").trim())
783
1150
  .filter(Boolean);
784
1151
  const answer = assistant?.text || "";
785
- 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));
786
1155
  return {
787
1156
  title: document.title,
788
1157
  url: location.href,