@youdie006/prodex 0.3.0 → 0.5.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,30 @@ 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 `--effort`/`--pro-mode`/`--project` flags will fail with "menu item not found"; either switch the UI language, send without selection flags, or use the escape hatch: `--model "<exact label>"` clicks any radio entry in the picker by the exact text your UI shows (run `pro browser models` to list them).
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. `--project-new "name"` creates a new project (sidebar 새 프로젝트 popover, committed with Enter) and sends inside it. Neither can be combined with `--target-url` (the project step would navigate away from the confirmed tab), and `--project-new` never comes from saved defaults — creating a project is always an explicit per-ask choice.
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`, clear one with the matching `--clear-*` flag, or answer a short wizard instead of remembering flags:
205
215
 
206
216
  ```bash
207
217
  prodex setup --model Pro --pro-mode 확장 --project "my-project"
218
+ prodex setup --clear-project
219
+ prodex setup --interactive # asks model / Pro sub-mode or effort / project
208
220
  ```
209
221
 
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.
222
+ 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
223
 
212
224
  For a source checkout, keep the explicit send and inspection commands source-aware too:
213
225
 
@@ -230,6 +242,14 @@ prodex results reseal <task-id> --confirm-current-result
230
242
 
231
243
  This writes a new local `task_completed` receipt for the current result payload. Prefer the explicit task id you just reviewed; `latest` is accepted for convenience but resolves from the current raw result list at execution time. It does not reseal unsigned receipts, forged receipts, or receipts that already point at a different result digest.
232
244
 
245
+ Receipts are HMAC-signed with a local key in `.bridge/receipt-key.local`. If you suspect the key was exposed, rotate it:
246
+
247
+ ```bash
248
+ prodex receipts rotate-key
249
+ ```
250
+
251
+ New receipts are signed with the fresh key; previous keys stay in the file (verification only) so receipts signed before the rotation remain trusted.
252
+
233
253
  To send into a specific visible Project or thread, open that ChatGPT URL in the dedicated browser first, confirm it is the right destination, then pass the same URL:
234
254
 
235
255
  ```bash
@@ -128,12 +128,33 @@ 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;
140
+ export declare function newProjectButtonRectExpression(): string;
141
+ export declare function newProjectNameInputRectExpression(): string;
142
+ export declare function newProjectNameInputVisibleExpression(): string;
136
143
  export declare function sendChatGptPrompt(options: SendChatGptPromptOptions): Promise<SendChatGptPromptResult>;
144
+ export interface ChatGptModelOption {
145
+ label: string;
146
+ kind: "radio" | "submenu";
147
+ checked: boolean;
148
+ }
149
+ export interface ListChatGptModelOptionsResult {
150
+ url: string;
151
+ options: ChatGptModelOption[];
152
+ }
153
+ export declare function modelMenuOptionsExpression(): string;
154
+ export declare function listChatGptModelOptions(input?: {
155
+ port?: number;
156
+ timeoutMs?: number;
157
+ }): Promise<ListChatGptModelOptionsResult>;
137
158
  export declare function statusExpression(): string;
138
159
  export declare function setComposerTextExpression(text: string): string;
139
160
  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,57 +403,128 @@ 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) => {
413
478
  const t = (el.textContent || "").trim();
414
479
  const aria = el.getAttribute("aria-label") || "";
415
- return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach/i.test(t + aria);
480
+ return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach|file|voice|record|search|mic/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,178 @@ 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) {
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) {
497
601
  await dispatchEscapeKey(cdp);
498
- throw new Error(expander.reason ?? "Could not open the ChatGPT Pro sub-mode submenu");
602
+ return;
499
603
  }
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) {
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);
505
630
  await dispatchEscapeKey(cdp);
506
- throw new Error(`ChatGPT Pro sub-mode not found: ${subLabel}`);
507
631
  }
508
- await dispatchMouseClickAt(cdp, sub.x, sub.y);
509
- await sleep(400);
510
- return;
632
+ catch {
633
+ // best effort only
634
+ }
635
+ throw error;
511
636
  }
512
- const primaryLabel = options.model ?? options.effort;
513
- if (!primaryLabel) {
514
- await dispatchEscapeKey(cdp);
515
- return;
637
+ }
638
+ export function newProjectButtonRectExpression() {
639
+ return `(() => {${CLICK_POINT_SNIPPET}
640
+ const el =
641
+ document.querySelector('button[aria-label="새 프로젝트"]') ||
642
+ [...document.querySelectorAll('button,[role="button"]')].find((b) =>
643
+ /새 프로젝트|new project/i.test(((b.textContent || "") + (b.getAttribute("aria-label") || "")).trim())
644
+ );
645
+ if (!el) return { ok: false, reason: "new-project button not found in the sidebar" };
646
+ return clickPoint(el);
647
+ })()`;
648
+ }
649
+ // The new-project popover has a single visible text input for the name;
650
+ // creation is committed by pressing Enter (there is no dedicated create button).
651
+ const VISIBLE_TEXT_INPUT_FINDER = `
652
+ const findNameInput = () =>
653
+ [...document.querySelectorAll('input[type="text"]')].find((i) => {
654
+ const r = i.getBoundingClientRect();
655
+ return r.width > 0 && r.height > 0;
656
+ });`;
657
+ export function newProjectNameInputRectExpression() {
658
+ return `(() => {${CLICK_POINT_SNIPPET}${VISIBLE_TEXT_INPUT_FINDER}
659
+ const input = findNameInput();
660
+ if (!input) return { ok: false, reason: "new-project name input did not appear" };
661
+ return clickPoint(input);
662
+ })()`;
663
+ }
664
+ export function newProjectNameInputVisibleExpression() {
665
+ return `(() => {${VISIBLE_TEXT_INPUT_FINDER}
666
+ return Boolean(findNameInput());
667
+ })()`;
668
+ }
669
+ function newProjectNameInputValueExpression() {
670
+ return `(() => {${VISIBLE_TEXT_INPUT_FINDER}
671
+ const input = findNameInput();
672
+ return input ? input.value : null;
673
+ })()`;
674
+ }
675
+ async function dispatchEnterKey(cdp) {
676
+ await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" });
677
+ await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 });
678
+ }
679
+ // Creates a project in the user's real ChatGPT account: sidebar 새 프로젝트 →
680
+ // type the name into the popover input → Enter → wait for navigation into the
681
+ // new project. Backs out with Escape on any failure.
682
+ async function createChatGptProject(cdp, name) {
683
+ const hrefBefore = await cdp.evaluate("location.href");
684
+ const button = await cdp.evaluate(newProjectButtonRectExpression());
685
+ if (!button.ok || button.x === undefined || button.y === undefined) {
686
+ throw new Error(button.reason ?? "new-project button not found in the sidebar");
516
687
  }
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(", ")})` : ""}`);
688
+ try {
689
+ await verifiedClickAt(cdp, button.x, button.y, "새 프로젝트");
690
+ const inputVisible = await waitForExpressionTrue(cdp, newProjectNameInputVisibleExpression(), MENU_OPEN_TIMEOUT_MS);
691
+ if (!inputVisible)
692
+ throw new Error("new-project name input did not appear");
693
+ const input = await cdp.evaluate(newProjectNameInputRectExpression());
694
+ if (!input.ok || input.x === undefined || input.y === undefined) {
695
+ throw new Error(input.reason ?? "new-project name input is not clickable");
696
+ }
697
+ await verifiedClickAt(cdp, input.x, input.y, "project name input");
698
+ await sleep(150);
699
+ await cdp.send("Input.insertText", { text: name });
700
+ await sleep(150);
701
+ const typed = await cdp.evaluate(newProjectNameInputValueExpression());
702
+ if (typed !== name) {
703
+ throw new Error(`could not type the project name into the new-project input (saw: ${typed === null ? "no input" : JSON.stringify(typed)})`);
704
+ }
705
+ await dispatchEnterKey(cdp);
706
+ const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
707
+ if (!navigated)
708
+ throw new Error(`Creating project "${name}" did not navigate into the new project`);
709
+ const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
710
+ if (!composerReady)
711
+ throw new Error(`ChatGPT composer did not appear after creating project "${name}"`);
712
+ }
713
+ catch (error) {
714
+ try {
715
+ await dispatchEscapeKey(cdp);
716
+ await sleep(150);
717
+ await dispatchEscapeKey(cdp);
718
+ }
719
+ catch {
720
+ // best effort only
721
+ }
722
+ throw error;
521
723
  }
522
- await dispatchMouseClickAt(cdp, primary.x, primary.y);
523
- await sleep(400);
524
724
  }
525
725
  async function selectProject(cdp, options) {
526
726
  if (options.projectNew) {
527
- throw new Error("projectNew (new-project modal) automation is not implemented yet; create the project manually and use --project");
727
+ await createChatGptProject(cdp, options.projectNew);
728
+ return;
528
729
  }
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);
730
+ if (!options.project)
731
+ return;
732
+ const hrefBefore = await cdp.evaluate("location.href");
733
+ const hit = await cdp.evaluate(projectItemRectExpression(options.project));
734
+ if (!hit.ok || hit.x === undefined || hit.y === undefined) {
735
+ const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
736
+ throw new Error(`ChatGPT project not found in sidebar: ${options.project}${detail}`);
737
+ }
738
+ await verifiedClickAt(cdp, hit.x, hit.y, `project ${options.project}`);
739
+ const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
740
+ if (!navigated) {
741
+ 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.`);
742
+ }
743
+ const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
744
+ if (!composerReady) {
745
+ throw new Error(`ChatGPT composer did not appear after entering project "${options.project}"`);
536
746
  }
537
747
  }
538
748
  export async function sendChatGptPrompt(options) {
@@ -566,12 +776,16 @@ export async function sendChatGptPrompt(options) {
566
776
  if (busyBlocker) {
567
777
  throw new ChatGptBrowserBlockerError(busyBlocker);
568
778
  }
569
- const beforeSubmit = await evaluateOnPage(page, answerExpression());
779
+ let beforeSubmit;
570
780
  const cdp = await connectCdp(page.webSocketDebuggerUrl);
571
781
  try {
572
782
  await cdp.send("Runtime.enable");
573
783
  await selectProject(cdp, options);
574
784
  await selectModelReasoning(cdp, options);
785
+ // Capture the answer baseline AFTER any project navigation or model switch
786
+ // so assistant-message counts compare within the thread we actually send
787
+ // into; a --project/--project-new hop lands on a page with its own counts.
788
+ beforeSubmit = await evaluateOnPage(page, answerExpression());
575
789
  const inserted = await cdp.evaluate(setComposerTextExpression(options.prompt));
576
790
  if (!inserted.ok)
577
791
  throw new Error(inserted.reason ?? "Could not insert text into ChatGPT composer");
@@ -638,6 +852,71 @@ export async function sendChatGptPrompt(options) {
638
852
  warnings: []
639
853
  };
640
854
  }
855
+ export function modelMenuOptionsExpression() {
856
+ return `(() => {
857
+ const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
858
+ if (!m) return [];
859
+ return [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')]
860
+ .map((it) => ({
861
+ label: (it.textContent || "").trim(),
862
+ kind: it.getAttribute("aria-haspopup") === "menu" ? "submenu" : "radio",
863
+ checked: it.getAttribute("aria-checked") === "true"
864
+ }))
865
+ .filter((o) => o.label.length > 0);
866
+ })()`;
867
+ }
868
+ // Read-only discovery: open the composer model menu, read the option labels,
869
+ // and press Escape. Nothing is clicked inside the menu, so the user's model
870
+ // selection is never changed.
871
+ export async function listChatGptModelOptions(input = {}) {
872
+ const port = input.port ?? 9333;
873
+ const timeoutMs = input.timeoutMs ?? 15_000;
874
+ const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), undefined);
875
+ if (!pageResult.ok) {
876
+ throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
877
+ }
878
+ if (!pageResult.page) {
879
+ if (pageResult.blocker)
880
+ throw new ChatGptBrowserBlockerError(pageResult.blocker);
881
+ assertChatGptPageAvailable();
882
+ }
883
+ const page = pageResult.page;
884
+ const status = await evaluateOnPage(page, statusExpression());
885
+ const blocker = detectChatGptPageBlocker(status);
886
+ if (blocker)
887
+ throw new ChatGptBrowserBlockerError(blocker);
888
+ assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer);
889
+ assertVisibleChatGptTab(status.visibilityState, status.url, undefined);
890
+ const cdp = await connectCdp(page.webSocketDebuggerUrl);
891
+ try {
892
+ await cdp.send("Runtime.enable");
893
+ const button = await cdp.evaluate(modelButtonRectExpression());
894
+ if (!button.ok || button.x === undefined || button.y === undefined) {
895
+ throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
896
+ }
897
+ try {
898
+ await verifiedClickAt(cdp, button.x, button.y, "model selector");
899
+ const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
900
+ if (!opened)
901
+ throw new Error("ChatGPT model menu did not open after clicking the selector");
902
+ const options = await cdp.evaluate(modelMenuOptionsExpression());
903
+ return { url: status.url, options };
904
+ }
905
+ finally {
906
+ try {
907
+ await dispatchEscapeKey(cdp);
908
+ await sleep(150);
909
+ await dispatchEscapeKey(cdp);
910
+ }
911
+ catch {
912
+ // best effort only
913
+ }
914
+ }
915
+ }
916
+ finally {
917
+ cdp.close();
918
+ }
919
+ }
641
920
  async function findChatGptPage(port, timeoutMs, targetUrl) {
642
921
  try {
643
922
  const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
@@ -810,7 +1089,9 @@ export function statusExpression() {
810
1089
  }));
811
1090
  const assistant = messages.filter((message) => message.role === "assistant").at(-1);
812
1091
  const answer = assistant?.text || "";
813
- const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(answer.trim().replace(/\\.+$/, ""));
1092
+ const ansStripped = answer.trim().replace(/\\.+$/, "");
1093
+ const ansLines = ansStripped.split(/\\r?\\n/).filter((l) => l.trim());
1094
+ const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(ansStripped) || (ansLines.length <= 1 && /(^|\\s)생각 중$/.test(ansStripped));
814
1095
  const hasComposer = Boolean(findChatGptComposerCandidate());
815
1096
  return {
816
1097
  title: document.title,
@@ -960,7 +1241,9 @@ function answerExpression() {
960
1241
  .map((node) => (node.innerText || node.getAttribute("aria-label") || node.getAttribute("data-testid") || "").trim())
961
1242
  .filter(Boolean);
962
1243
  const answer = assistant?.text || "";
963
- const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(answer.trim().replace(/\\.+$/, ""));
1244
+ const ansStripped = answer.trim().replace(/\\.+$/, "");
1245
+ const ansLines = ansStripped.split(/\\r?\\n/).filter((l) => l.trim());
1246
+ const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(ansStripped) || (ansLines.length <= 1 && /(^|\\s)생각 중$/.test(ansStripped));
964
1247
  return {
965
1248
  title: document.title,
966
1249
  url: location.href,