/** * ChatGPT web adapter. * * Drives an already-logged-in chatgpt.com conversation tab through CDP: * send a message -> wait for the reply -> click the "one-click copy" button * -> capture the clean Markdown that ChatGPT puts on the clipboard. * * Clipboard capture uses a *hook* instead of reading the real clipboard, so it * needs no clipboard permission and no window focus: we wrap * `navigator.clipboard.writeText` (and `document.execCommand('copy')`) before * clicking, and read back whatever value ChatGPT writes. */ import { CdpError, CdpSession, DeliveryError, DEFAULT_CDP_PORT, activateTarget, cdpVersion, listTargets, openTab, type CdpTarget, } from "./cdp.ts"; import { classifyCopyButtons, codeSourceDominates, copyViaTag, describeCopyMiss, VIA_DOM_FALLBACK, VIA_DOM_ONLY, } from "./copySources.ts"; import { assertCapability, type Capability } from "../core/sideEffects.ts"; import { confirmDelivery, deliveryFailureMessage, judgeSubmitProbe, submitUnconfirmedMessage, type SubmitObservation } from "../core/delivery.ts"; import { classifyConversationRoute, judgeDelivery, judgeReply, newestAssistant, type MessageRef, } from "../core/identity.ts"; import { parseProtocolFrames, type SessionDiscovery } from "../core/handshake.ts"; import { BrowserMutex } from "../runtime/mutex.ts"; import { detectPlatformBlock, platformBlockMessage, type PlatformBlocker } from "../runtime/guards.ts"; /** ChatGPT's clipboard Markdown uses CRLF; normalize so pasted plans stay clean. */ function normalizePlan(text: string): string { return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); } /** * The legal `copyMode` values, as a runtime list and a type derived from it. * * The list is the single source of truth on purpose: `loadConfig` normalizes a config * file's value against it (BUG-015 -- the legacy value `"latest"` was silently treated as * `auto` by the copy path, so nothing ever said the file was wrong), and a hand-written * mirror of this list would be exactly the drift that made the value unusable. */ export const COPY_MODES = ["auto", "code", "turn", "dom"] as const; export type CopyMode = (typeof COPY_MODES)[number]; /** * How text is placed into ChatGPT's composer. * * - "trusted": clear with a real Delete key event, then CDP `Input.insertText`. * Every DOM event this produces has `isTrusted === true`, so the page sees * nothing that a human could not have produced. Slower (two round trips). * - "paste": a synthetic `ClipboardEvent`, which the page sees as * `isTrusted === false`. Faster, and ProseMirror handles it fine, but it is * one thing a page *could* single out. */ export type InputMode = "trusted" | "paste"; export interface CopySource { /** Position inside the newest turn's copy-button list. */ index: number; label: string; /** * "code" = a code block's copy button, "turn" = the whole-turn action button, * "canvas" = a writing-block toolbar button (its own document; measured live to carry * exactly that canvas's markdown). Canvas is a structural-origin fact, not a claim that * the payload is ever complete -- code-source semantics stay code-only (BUG-052). */ kind: "code" | "turn" | "canvas" | "other"; /** True for "复制回复" / "Copy response" rather than "复制消息" / "Copy message". */ isReply: boolean; nearCodeBlock: boolean; /** * The ambiguity rule chose this button: a bare 复制 with no structural proof that it * belongs to this turn (BUG-063, STRUCTURAL_RISK). Carried out of the classifier rather * than hidden, because such a button can be a code block's own copy button wearing no * marker -- the 2026-10 run-block renders exactly that way -- and a caller that claims * "the whole reply" on top of that guess is claiming something nobody verified. */ promoted: boolean; /** * For a code-block source: its position among the code blocks of this turn. * Used by the DOM fallback so a page with several code blocks still returns * the one the caller actually chose. */ codeOrdinal?: number | null; /** * The DOM text this exact source is expected to produce. * * Validating clipboard content against the whole reply is not enough: in a * turn holding blocks A/B/C, a stale clipboard value of "A" is still a * substring of the reply, so it would be accepted as the answer for B. The * expectation must be per-source. */ expectedText?: string | null; } export interface CopySourcesReport { /** How the newest turn was located. */ anchor: "conversation-turn" | "ancestor" | "message" | "none"; /** e.g. "conversation-turn-12" */ turnId: string | null; replyPreview: string; /** Full text of the newest reply, used to verify clipboard content. */ replyText: string; sources: CopySource[]; /** Whether the newest reply renders as plain message text or as writing-block canvas(es). */ surface: "message" | "canvas"; /** Number of canvases holding the reply (0 for message surfaces). */ canvasCount: number; } export interface PageState { url: string; title: string; assistantCount: number; /** Every message on screen with its stable identity, role, turn and text. */ messages: MessageRef[]; /** Stable message ids currently mounted. The predicates compare against these. */ userIds: string[]; assistantIds: string[]; /** * Number of the user's own messages on screen. * * Delivery is verified against this rather than against the reply: the reply * cannot arrive if the question never left, and the page shows "thinking" * during both. */ userCount: number; /** * Highest `conversation-turn-N` number currently mounted, and how many turn * sections are mounted. The assistant-node count is NOT a reliable * "a new reply appeared" signal: ChatGPT virtualizes long threads, so old * messages unmount as new ones arrive and the total can stay flat. */ turnNumber: number; turnCount: number; /** The id of the assistant message inside the highest turn, when it has one. */ newestAssistantId: string | null; /** * Set when the page exposed no message ids and a weaker predicate was used. * * Surfaced rather than swallowed: it means the defence against returning the * previous reply is not in effect for this read. */ identityWarning?: string | null; /** True when the newest assistant node only holds a "Thinking…" placeholder. */ thinking: boolean; /** document.visibilityState — a hidden tab renders slowly and lags behind. */ visibility: string; streaming: boolean; text: string; composerText: string; composerFound: boolean; sendButtonFound: boolean; /** Independent "reply finished" UI evidence: the per-reply action bar * (copy-turn-action-button) only mounts after generation completes. Unlike * the stop button (whose absence can be a false negative), its presence is * positive proof — used by waitForReply when streaming was never observed * (BUG-018). */ copyTurnFound?: boolean; loggedOut: boolean; } /** Facts observed from the attached document before drawing an auth conclusion. */ export interface ChatGptPageObservation { url: string; protocol: string; origin: string; chromeError: boolean; principal: "present" | "absent" | "unavailable"; loginVisible: boolean; composerFound: boolean; /** * Platform boundary hit by this page state (C 轮 C4): CAPTCHA / security challenge / * rate or usage limit / auth. Wired into the send and create-conversation boundaries; * `null` is the ordinary state. */ platformBlock: PlatformBlocker | null; } /** UNKNOWN is fail-closed: a storage failure alone is never proof of logged-out. */ export type ChatGptPageStatus = | "UNREACHABLE" | "AUTH_REQUIRED" | "AUTHENTICATED_NOT_READY" | "READY" | "UNKNOWN"; /** * BUG-045's one decision order: error document, normal origin, authentication, * then composer readiness. Chrome error pages deny localStorage too, so they * must finish at Stage 1 before any identity inference. */ export function classifyChatGptPageState(observation: ChatGptPageObservation): ChatGptPageStatus { const errorDocument = observation.chromeError || observation.protocol === "chrome-error:" || observation.url.startsWith("chrome-error://"); if (errorDocument) return "UNREACHABLE"; if (observation.protocol !== "https:" || observation.origin !== "https://chatgpt.com") return "UNKNOWN"; if (observation.loginVisible) return "AUTH_REQUIRED"; if (observation.principal === "present") { return observation.composerFound ? "READY" : "AUTHENTICATED_NOT_READY"; } // A mounted composer is independent positive app evidence. This keeps a harmless // storage-key migration from becoming a false authentication failure. if (observation.composerFound) return "READY"; return "UNKNOWN"; } export interface ReplyBaseline { assistantCount?: number; turnNumber?: number; /** * Assistant message ids present before the send. The stable identity. * * `assistantCount` and `turnNumber` are kept for callers that still pass them, but * neither can decide "is this reply new": the count falls when old turns are * virtualized away, and the turn number indexes position inside a sliding window, * so it retreats too. A new reply is a message id that was not in this set. */ assistantIds?: string[]; } export interface DoctorReport { cdpOk: boolean; browser?: string; targets: Array<{ id: string; url: string; title: string }>; pageUrl?: string; pageTitle?: string; pageState?: ChatGptPageStatus; loggedIn?: boolean; loggedOut?: boolean; composerFound?: boolean; sendButtonFound?: boolean; stopButtonFound?: boolean; assistantCount?: number; copyButtons: Array<{ label: string; nearCodeBlock: boolean; scope?: string }>; clipboardHook: boolean; problems: string[]; /** * Observations that are worth showing but are not defects (O1 / verdict #38). * * The send button is the fallback path: submit() uses trusted Enter first because the button * can be present-and-enabled while .click() does nothing (see submit()). Calling its absence a * "problem" made a healthy doctor render as a warning — self-contradictory with the note text * that already said it only matters if the Enter fallback also fails. */ notes?: string[]; } export interface WaitOptions { /** How long to wait for proof that the question reached the server. */ deliveryTimeoutMs?: number; timeoutMs?: number; stabilityMs?: number; /** * If the new reply's text stops changing while the page still claims to be * streaming (a long reasoning phase, or a stale stop button), return what we * have after this long instead of blocking for the full timeout. */ stallMs?: number; onTick?: (info: { elapsedMs: number; textLength: number; streaming: boolean; count: number }) => void; /** * Called as soon as the submission is confirmed, BEFORE anything that can still throw. * * BUG-060 ruling: the send evidence (materialization lineage) has to be recorded at * the earliest point where it is true, so a later failure -- the post-submit route * check, the delivery wait, a stream that dies -- does not leave a PROVISIONAL binding * with no lineage. That state is unrecoverable by the product: the next reconcile * refuses the durable id it cannot attribute, and every binding-dependent command * fails until a human rebinds. Recording early widens nothing: the evidence only * claims "this pinned target submitted", and the ownership / durable-id / lineage * checks at reconcile time are unchanged. */ onSubmitConfirmed?: () => void | Promise; /** Called exactly once after submit is accepted, before delivery/reply polling. */ onSubmitted?: () => void | Promise; } const CHATGPT_URL_PREFIX = "https://chatgpt.com"; const DEFAULT_WAIT_TIMEOUT_MS = 15 * 60 * 1000; /* ------------------------------------------------------------------ */ /* Injected page scripts */ /* ------------------------------------------------------------------ */ /* * The 2026-09 ChatGPT UI rollout removed nearly every data-testid and the * per-message role attributes this bridge was built on: * * legacy UI new UI * ----------------------------------------------- * #prompt-textarea div.ProseMirror[contenteditable] (id gone; * writing-block editors are ProseMirror too) * [data-testid=send-button] button[aria-label="发送"], appears once the * composer holds text * section[data-testid^=conversation-turn-N] * one section per message [class*="transcriptContent"] > inner list, * one child per EXCHANGE PAIR (user bubble + * assistant reply in the same container) * [data-message-author-role] [class*="group/user-message"] for the user * bubble; the assistant half is found through * its action bar (aria-label 重新生成回复) * [data-testid=copy-turn-action-button] * action-bar button[aria-label="复制"], always * rendered (no hover needed) * * Every resolver below is dual-mode: legacy anchors win when present, the new-UI * path is the fallback. That keeps older served versions working and makes the * next rollout a smaller diff. */ const TURN_MODEL = ` const __legacyUi = () => !!document.querySelector('[data-testid^="conversation-turn"], [data-message-author-role]'); // New UI (2026-09): exchanges mount inside ONE virtualized container -- the // transcript list's children are NOT one-per-exchange (Edge 实测:一个容器装 9 轮)。 // The stable per-exchange anchor is the regenerate button in the reply action bar: // exactly one per completed assistant exchange, in document (chronological) order. // An exchange unit = the highest ancestor of a regen button holding no OTHER regen. const __REGEN_SEL = 'button[aria-label="重新生成回复"], button[aria-label*="Regenerate"]'; // 2026-09-25 二波:重新生成回复按钮从操作条消失(现为 复制/评价回复/分享/朗读/更多操作), // regen 不再是稳定锚点。turn-action-controls 容器在新旧两版 UI 都存在(每完成轮一个), // 因此作为主锚,regen 仅作回退。 const __ACTION_BAR_SEL = '[class*="turn-action-controls"]'; // 2026-09-25 二波:助手操作条换掉了 turn-action-controls 类名(改为 mt-1.5 flex), // 而用户条反而保留旧类——类名锚点不可再信。助手条的内容标记(评价回复/朗读)跨版本稳定, // 且用户条不含它们,用它做助手侧锚点;regen 仍作最老版本回退。 const __ASSISTANT_ANCHOR_SEL = 'button[aria-label="评价回复"], button[aria-label="朗读"], ' + __REGEN_SEL; const __exchangeUnits = () => { const tr = document.querySelector('[class*="transcriptContent"]'); if (!tr) return []; // One anchor selector, chosen by availability: a union selector would count TWO // anchors per action bar (评价回复 and 朗读 both match), the climb would stop at the // bar's own container, and every unit would be a textless shell. const anchorSel = ['button[aria-label="评价回复"]', 'button[aria-label="朗读"]', 'button[aria-label="重新生成回复"]', 'button[aria-label*="Regenerate"]'] .find((sel) => tr.querySelector(sel)) || null; if (!anchorSel) return []; const anchors = [...tr.querySelectorAll(anchorSel)]; const units = []; for (const rg of anchors) { let n = rg; while (n.parentElement && n.parentElement !== tr && n.parentElement.querySelectorAll(anchorSel).length === 1) n = n.parentElement; if (!units.includes(n)) units.push(n); } // Virtualization renders off-screen turns with empty text (content-visibility), so a // DOM-order-last unit can be an empty shell while the newest real reply sits earlier. // Keep only units that actually carry text: an empty shell has nothing to read or copy. return units.filter((u) => (u.innerText || '').trim().length > 0); }; const __newTurnRoots = () => __exchangeUnits(); const __turnRoots = () => { const legacy = [...document.querySelectorAll('[data-testid^="conversation-turn"]')]; return legacy.length ? legacy : __newTurnRoots(); }; // The bubble is wrapped in nested group/user-message divs; only the outermost counts. const __userBubbles = () => [...document.querySelectorAll('[class*="group/user-message"]')] .filter((b) => !(b.parentElement && b.parentElement.closest('[class*="group/user-message"]'))); const __userBubbleOf = (root) => { const legacy = root.querySelector('[data-message-author-role="user"]'); if (legacy) return legacy; return __userBubbles().find((b) => root === b || root.contains(b)) || null; }; // Assistant half of an exchange pair: the smallest ancestor of the regenerate // button that also holds message content -- excludes the user bubble, which keeps // the user's own 复制消息 button out of the copy classification scope. // Canvas / writing-block replies (2026-09): ChatGPT renders long documents into a // writing-block surface. It is a NEW legitimate reply surface, not an anomaly -- // excluding it (as this walk used to) collapsed the scope onto the whole exchange, // polluted replyText with the user bubble and broke the authorship check (BUG-052). const __writingBlocksOf = (root) => [...root.querySelectorAll('[class*="writing-block-surface"]')] .filter((w) => !w.closest('[class*="group/user-message"]')) // The overlay shadow copy renders as the same surface carrying no text; drop empties. .filter((w) => (w.innerText || '').replace(/\\s+/g, '').length > 0); const __writingBlockBodyOf = (w) => w.querySelector('[class*="writing-block-editor"]') || w; const __canvasBodiesOf = (root) => __writingBlocksOf(root) .map((w) => (__writingBlockBodyOf(w).innerText || '').trim()) .filter((text) => text.length > 0); const __assistantScopeOf = (root) => { const legacy = root.querySelector('[data-message-author-role="assistant"]'); if (legacy) return legacy; // Judgment order: canvas body -> reply markdown -> unit fallback. Returning the // unit would leak the user bubble ('你说:' header) into replyText and trip the // authorship check. const canvases = __writingBlocksOf(root); if (canvases.length) return __writingBlockBodyOf(canvases[canvases.length - 1]); const mds = [...root.querySelectorAll('[class*="markdown" i]')] .filter((m) => !m.closest('[class*="writing-block"]')); // 2026-09-25 二波实测:一条回复可能被拆成多个 markdown 节点,另有 3-7 字的围栏 // 标签碎片跟在后面;回复正文 = 最长节点,取"最后一个"会拿到碎片("BRB")。 if (mds.length) return mds.reduce((best, m) => ((m.innerText || '').length > (best.innerText || '').length ? m : best), mds[0]); return (root.querySelector(__ASSISTANT_ANCHOR_SEL) || root.querySelector(__ACTION_BAR_SEL)) ? root : null; }; // Text of the assistant half. A multi-canvas reply (one document per canvas) is ONE // reply, so its text is every canvas body joined -- never just the last one. const __assistantTextOf = (root) => { const legacy = root.querySelector('[data-message-author-role="assistant"]'); if (legacy) return legacy.innerText || ''; const canvases = __canvasBodiesOf(root); if (canvases.length) return canvases.join(String.fromCharCode(10, 10)); const mds = [...root.querySelectorAll('[class*="markdown" i]')] .filter((m) => !m.closest('[class*="writing-block"]')); if (mds.length) { const body = mds.reduce((best, m) => ((m.innerText || '').length > (best.innerText || '').length ? m : best), mds[0]); return body.innerText || ''; } return root.innerText || ''; }; const __turnIndexOf = (el) => { const section = el.closest ? el.closest('[data-testid^="conversation-turn"]') : null; if (section) { const m = /conversation-turn-([0-9]+)/.exec(section.getAttribute('data-testid') || ''); return m ? Number(m[1]) : -1; } const roots = __newTurnRoots(); for (let i = 0; i < roots.length; i++) if (roots[i] === el || roots[i].contains(el)) return i; return -1; }; const __loginVisible = () => !!(document.querySelector('[data-testid="login-button"]') || document.querySelector('[data-testid="signup-button"]') || /\/auth\//.test(location.pathname) || location.host.startsWith('auth.openai.com') || [...document.querySelectorAll('button, a')].some((e) => /^(登录|Log in|注册|Sign up)$/i.test((e.innerText || '').trim()))); `; const EDITOR_FINDER = ` const __pickVisible = (els) => { const inView = els.filter((e) => { const r = e.getBoundingClientRect(); return r.width > 0 && r.bottom > 0 && r.top < (window.innerHeight || 800); }); return inView[inView.length - 1] || null; }; const findEditor = () => document.querySelector('#prompt-textarea') || __pickVisible([...document.querySelectorAll('div.ProseMirror[contenteditable="true"]')] .filter((e) => !e.closest('[class*="writing-block"]'))) || __pickVisible([...document.querySelectorAll('[contenteditable="true"]')]) || document.querySelector('div.ProseMirror[contenteditable="true"]') || document.querySelector('[contenteditable="true"]'); `; const SEND_BUTTON_FINDER = ` const findSendButton = () => document.querySelector('[data-testid="send-button"]:not([disabled])') || document.querySelector('#composer-submit-button:not([disabled])') || document.querySelector('button[aria-label*="Send"]:not([disabled])') || document.querySelector('button[aria-label*="发送"]:not([disabled])'); `; const STOP_BUTTON_FINDER = ` const findStopButton = () => document.querySelector('[data-testid="stop-button"]') || document.querySelector('button[aria-label*="Stop"]') || document.querySelector('button[aria-label*="停止"]'); `; const MODEL_UI = ` const findModelSwitcher = () => { const byTestId = document.querySelector('[data-testid="model-switcher-dropdown-button"]'); if (byTestId) return byTestId; return [...document.querySelectorAll('button, [role="button"]')].find((el) => { const own = el.getAttribute('aria-label') || ''; const blob = own + ' ' + (el.getAttribute('data-testid') || ''); return /切换模型|选择.*模型|switch model|model-switcher/i.test(blob) && !/对话选项|置顶/.test(own); }) || null; }; const describeButton = (el) => (el.getAttribute('aria-label') || el.getAttribute('data-testid') || 'unnamed'); const labelOf = (el) => (el.innerText || '').split(String.fromCharCode(10)).map((s) => s.trim()).filter(Boolean).join(' | '); // Radix menus ignore a bare .click(); they need the full pointer sequence. const openWithPointer = (el) => { const options = { bubbles: true, cancelable: true, view: window, button: 0 }; for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) { try { el.dispatchEvent(new MouseEvent(type, options)); } catch { /* ignore */ } } }; const menuItems = () => [...document.querySelectorAll('[role="menuitemradio"], [role="menuitem"], [role="option"]')]; `; /** * Hard ceiling on a single paste. Not a token concern -- a rendering one. See * pastePath(): one giant transaction is what wedged the browser. */ export const MAX_PASTE_CHUNK_CHARS = 2000; /** Split text at line boundaries so a chunk never cuts a line in half. */ export function chunkForInput(text: string, maxChars: number): string[] { if (text.length <= maxChars) return [text]; const chunks: string[] = []; let current = ""; const flush = () => { if (current.length > 0) chunks.push(current); current = ""; }; for (const rawLine of text.split("\n")) { // A single line longer than the limit must still be split: minified code, // JSON on one line and base64 blobs are all one "line". Without this the // chunker would happily emit one giant paste again. let line = rawLine; while (line.length > maxChars) { flush(); chunks.push(line.slice(0, maxChars)); line = line.slice(maxChars); } if (current.length > 0 && current.length + line.length + 1 > maxChars) flush(); current = current.length === 0 ? line : current + "\n" + line; } flush(); return chunks; } const ASSISTANT_NODES = `(() => { const legacy = document.querySelectorAll('[data-message-author-role="assistant"]'); if (legacy.length) return [...legacy]; return __newTurnRoots().map((root) => __assistantScopeOf(root)).filter(Boolean); })()`; /** * The user's own messages, counted so a send can be verified. * * Asking and being answered were treated as one step, so a message that never * reached the server was indistinguishable from a slow reply: the page draws an * optimistic "thinking" turn either way, and the bridge then reported "no plan * text" -- which points at the copy path, not at the send. The user-message count * is the cheap ground truth: a delivered send increments it, a lost one does not. */ const USER_NODES = `(() => { const legacy = document.querySelectorAll('[data-message-author-role="user"]'); if (legacy.length) return [...legacy]; return __userBubbles(); })()`; /** * Collect every message with a stable identity, a role, its turn and its text. * * The id is looked for on the role element itself, then on an ancestor, then on a * descendant. Which of the three holds it was not probed -- the automation browser was * closed at the time -- so this accepts all three rather than assuming one and being * corrected by a real page. Accepting a superset costs nothing: the value is only ever * compared for equality against a baseline. */ const MESSAGE_COLLECTOR = ` const __idOf = (el) => { const own = el.getAttribute && el.getAttribute('data-message-id'); if (own) return own; const up = el.closest ? el.closest('[data-message-id]') : null; if (up) return up.getAttribute('data-message-id') || ''; const down = el.querySelector ? el.querySelector('[data-message-id]') : null; return down ? (down.getAttribute('data-message-id') || '') : ''; }; const __messages = (() => { const legacy = [...document.querySelectorAll('[data-message-author-role]')]; if (legacy.length) { return legacy.map((el) => ({ id: __idOf(el), role: el.getAttribute('data-message-author-role'), turn: __turnIndexOf(el), text: el.innerText || '', })); } // New UI: one root per exchange pair. Ids are synthetic (the markup carries none) // and index-based: stable within a render, which is all equality checks need. // They shift if virtualized earlier turns mount mid-wait (user scrolls up); // a send+wait never scrolls, so baselines stay comparable. // New UI: one root per exchange pair. The markup carries no message ids, so // ids are CONTENT-anchored hashes: index-based ids broke delivery verification // because virtualization remounts a different turn under the same index, making // a just-sent message wear the baseline's id (send declared lost while delivered). const __msgId = (role, text, i) => { const s = (text || '').replace(/\\s+/g, ' ').trim(); let h = 5381; for (let k = 0; k < s.length; k++) h = ((h << 5) + h + s.charCodeAt(k)) >>> 0; // No index suffix: a remount would mint 'new' ids for old messages and a lost // send would be declared delivered -- the worse direction. The duplicate-text edge // (identical re-send reads as the same id) fails closed instead: a retry is asked. return role + '-' + h.toString(36) + '-' + s.length; }; const units = __newTurnRoots(); const collected = units.flatMap((root, i) => { const out = []; const ub = __userBubbleOf(root); if (ub) { const t = ub.innerText || ''; out.push({ id: __msgId('user', t, i), role: 'user', turn: i, text: t }); } const as = __assistantTextOf(root); if (as) { out.push({ id: __msgId('assistant', as, i), role: 'assistant', turn: i, text: as }); } return out; }); // A still-streaming exchange has no regen button yet, so it is not in units -- // but its user bubble IS mounted, and delivery verification is waiting for it. const pending = __userBubbles() .filter((b) => !units.some((u) => u === b || u.contains(b))) .map((b, k) => { const t = b.innerText || ''; return { id: __msgId('user', t, 'p' + k), role: 'user', turn: -1, text: t }; }); return collected.concat(pending); })(); `; /** * Shared page-side helpers for locating the newest turn and the copy buttons * that belong to it. * * Two anchors are used, in order: * 1. `
` — ChatGPT's turn wrapper. * The per-turn action bar lives inside it but OUTSIDE the message bubble, * which is why scoping to the message element alone misses it. * 2. The smallest ancestor that holds exactly this one assistant message plus * at least one copy button. * * Scoping this way means an older turn's code-block copy button can never be * mistaken for the newest reply's, no matter how many copy buttons exist. */ const COPY_ANCHORS = ["conversation-turn", "ancestor", "message", "none"] as const; const COPY_SCOPE = ` ${TURN_MODEL} const describeButton = (el) => [ el.getAttribute('aria-label'), el.getAttribute('title'), el.getAttribute('data-testid'), ].filter(Boolean).join(' '); const isCopyButton = (el) => /copy|复制|拷贝/i.test(describeButton(el)); // Identity is judged from STRUCTURE, not from "is there a
 somewhere above" --
	// a canvas full of code blocks made every turn-bar button read as a code source
	// (BUG-052: "扫到 1 个候选:code:复制"). The turn bar is anchored on its own container
	// (turn-action-controls) with a bounded innerText-emptiness climb as fallback.
	const inTurnActionBar = (btn) => {
		if (btn.closest('[class*="turn-action-controls"]')) return true;
		let node = btn.parentElement;
		for (let i = 0; i < 4 && node; i += 1) {
			// A substantive ancestor is a content area, not a button bar: stop there. Without
			// this, climbing out of a canvas reaches the exchange unit (which holds the regen
			// button) and every canvas button would claim to live in the action bar.
			if ((node.innerText || '').length > 40) return false;
			if (node.querySelector && (node.querySelector(__REGEN_SEL) || node.querySelector(__ASSISTANT_ANCHOR_SEL))) return true;
			node = node.parentElement;
		}
		return false;
	};
	// inCode is a fact about THIS button: the turn bar is never a code source (key
	// invariant); inside a canvas only a button sitting in the code block itself is one;
	// otherwise the previous bounded ancestry rule stands.
	const nearCode = (btn) => {
		if (inTurnActionBar(btn)) return false;
		if (btn.closest('[class*="writing-block"]')) return !!btn.closest('pre');
		// 2026-09: a code block can render as wrapper > (toolbar, body > code) with NO
		// 
 at all (live: the code copy button then classified as 'other'). The
		// word-wrap toggle is the block's own toolbar marker, so requiring it keeps
		// inline  inside prose from turning an unrelated button into a code source.
		const __hasWrapToggle = (el) => [...el.querySelectorAll('button')].some((b) => /换行|wrap/i.test(b.getAttribute('aria-label') || ''));
		let node = btn;
		for (let i = 0; i < 6 && node; i += 1) {
			node = node.parentElement;
			if (!node || !node.querySelector) continue;
			if (node.querySelector('pre')) return true;
			if (node.querySelector('code') && __hasWrapToggle(node)) return true;
		}
		return false;
	};
	const findNewestTurnScope = () => {
		// querySelectorAll returns a NodeList: no reduce, so spread it first.
		const nodes = [...${ASSISTANT_NODES}];
		if (nodes.length === 0) return { last: null, scope: null, anchor: 'none' };
		if (!__legacyUi()) {
			// New UI: last is the reply's markdown node (clean text for replyText),
			// but the copy BUTTONS live in the exchange unit's action bar -- a sibling
			// subtree. Button scope must be the unit; text stays on the markdown node.
			const last = nodes.reduce((best, candidate) =>
				__turnIndexOf(candidate) >= __turnIndexOf(best) ? candidate : best, nodes[0]);
			const units = __newTurnRoots();
			const unit = units.find((u) => u === last || u.contains(last)) || last;
			return { last, scope: unit, anchor: 'ancestor' };
		}
		// Pick the assistant message inside the HIGHEST conversation-turn-N, not the
		// last node in document order. Observed live: after ChatGPT turned a large
		// paste into a file attachment, the DOM held the new reply and the previous
		// one in an order where the older node came last, and the copy button handed
		// back the stale answer as if it were the new one.
		const turnNumberOf = (el) => {
			const section = el.closest('[data-testid^="conversation-turn"]');
			if (!section) return -1;
			const match = /conversation-turn-([0-9]+)/.exec(section.getAttribute('data-testid') || '');
			return match ? Number(match[1]) : -1;
		};
		const last = nodes.reduce((best, candidate) =>
			turnNumberOf(candidate) >= turnNumberOf(best) ? candidate : best, nodes[0]);
		let scope = last.closest('[data-testid^="conversation-turn"]');
		let anchor = 'conversation-turn';
		if (!scope) {
			let node = last.parentElement;
			while (node) {
				const assistants = node.querySelectorAll('[data-message-author-role="assistant"]').length;
				if (assistants > 1) break;
				const copies = [...node.querySelectorAll('button')].filter(isCopyButton);
				if (assistants === 1 && copies.length > 0) { scope = node; anchor = 'ancestor'; break; }
				node = node.parentElement;
			}
		}
		if (!scope) { scope = last.parentElement || last; anchor = 'message'; }
		return { last, scope, anchor };
	};
	// ChatGPT can mount more than one 
 per logical code block (nested or
	// duplicated nodes), which shifts every ordinal after the first. Collapse
	// nested duplicates and repeated text so ordinal N maps to block N.
	const collectCodeBlocks = (scope) => {
		// Legacy code blocks are 
. The 2026-09 UI also renders blocks as
		// wrapper > (toolbar, body > code) with no 
; those are collected via the
		// same word-wrap toolbar marker nearCode uses, then both sets are ordered by
		// document position so ordinals stay faithful to what the user sees.
		const hasWrap = (el) => [...el.querySelectorAll('button')].some((b) => /换行|wrap/i.test(b.getAttribute('aria-label') || ''));
		const nodes = [...scope.querySelectorAll('pre')].filter((p) => !p.querySelector('pre'));
		for (const code of scope.querySelectorAll('code')) {
			if (code.closest('pre')) continue;
			let n = code.parentElement;
			for (let i = 0; i < 4 && n && n !== scope; i += 1) {
				if (hasWrap(n)) { nodes.push(code); break; }
				n = n.parentElement;
			}
		}
		nodes.sort((a, b) => (a.compareDocumentPosition(b) & 4 ? -1 : 1));
		const seen = Object.create(null);
		const out = [];
		for (const node of nodes) {
			const text = node.innerText || '';
			const key = text.trim();
			if (key.length === 0 || seen[key]) continue;
			seen[key] = true;
			out.push(node);
		}
		return out;
	};
	const collectCopySources = () => {
		const found = findNewestTurnScope();
		if (!found.last || !found.scope) {
			return { anchor: 'none', turnId: null, replyPreview: '', replyText: '', buttons: [], codeBlocks: [], surface: 'message', canvasCount: 0 };
		}
		// Surface facts first: a reply rendered into writing-block(s) is a canvas reply,
		// and canvasCount > 1 means one reply written as several documents. The host
		// decides the copy priority from these facts instead of re-guessing the DOM.
		const canvasBodies = __canvasBodiesOf(found.scope);
		const surface = canvasBodies.length ? 'canvas' : 'message';
		const replyText = __assistantTextOf(found.scope);
		// Raw facts only. Classification happens on the host, where it is a pure function
		// that can be tested -- the injected script is a string that nothing checks, and
		// "which button is the turn's copy button" lived here as a substring test on a
		// test id that is not always present. See copySources.ts.
		const buttons = [...found.scope.querySelectorAll('button')]
			.filter(isCopyButton)
			// The user bubble's 复制消息 is never a reply-copy source.
			.filter((btn) => !btn.closest('[class*="group/user-message"]'))
			// Canvas toolbar copies are enumerated rather than dropped: for a canvas reply
			// they are the structurally precise source (measured live: one canvas toolbar
			// copy yields exactly that canvas's markdown). Their identity travels as raw
			// facts; the host classifies them as "canvas" instead of deleting them.
			.map((btn) => ({
				label: describeButton(btn),
				inCode: nearCode(btn),
				inTurnActionBar: inTurnActionBar(btn),
				inWritingBlock: !!btn.closest('[class*="writing-block"]'),
			}));
		// Code block texts in document order, so the host can attach the text each source
		// is expected to yield and validate clipboard reads per source.
		const blocks = collectCodeBlocks(found.scope).map((node) => node.innerText || '');
		return {
			anchor: found.anchor,
			turnId: found.scope.getAttribute('data-testid') || null,
			replyPreview: replyText.slice(0, 120),
			replyText,
			buttons,
			codeBlocks: blocks,
			surface,
			canvasCount: canvasBodies.length,
		};
	};
`;

function jsString(value: string): string {
	return JSON.stringify(value);
}

/* ------------------------------------------------------------------ */
/* ChatGptPage                                                        */
/* ------------------------------------------------------------------ */

export class ChatGptPage {
	/** Target id returned by the create/open operation; pinned for auto-create lineage. */
	createdTargetId: string;
	private constructor(
		readonly target: CdpTarget,
		private readonly page: CdpSession,
		private readonly browser: CdpSession | null,
		readonly port: number,
	) {
		this.createdTargetId = target.id;
	}

	static async attach(
		options: {
			port?: number;
			url?: string;
			conversationId?: string;
			targetId?: string;
			openIfMissing?: boolean;
			signal?: AbortSignal;
		} = {},
	): Promise {
		const port = options.port ?? DEFAULT_CDP_PORT;
		const version = await cdpVersion(port, options.signal).catch((error) => {
			throw new CdpError(
				`Cannot reach Chrome DevTools on port ${port} (${String(error)}). ` +
					`Run /brb launch to start the automation browser.`,
			);
		});

		const target = await findChatGptTarget(port, options);
		if (!target) {
			// Name the actual cause. "No chatgpt.com tab" is wrong when tabs exist but none
			// matches the requested conversation -- that means the id/URL itself is suspect
			// (a spliced id once burned a whole debugging session).
			const others = (await listTargets(port, options.signal).catch(() => [] as never[])).filter(
				(t: { type?: string; url?: string }) =>
					t.type === "page" && typeof t.url === "string" && t.url.startsWith(CHATGPT_URL_PREFIX),
			);
			if ((options.url || options.conversationId) && others.length > 0) {
				const wanted = conversationIdOf(options.url ?? "") ?? options.conversationId ?? "?";
				throw new CdpError(
					`没有找到会话 ${wanted.slice(0, 12)}… 的标签页(浏览器里有 ${others.length} 个其他 ChatGPT 会话:` +
						`${others.map((t: { url?: string }) => conversationIdOf(t.url ?? "")?.slice(0, 8) ?? "?").join(", ")})。` +
						"请核对会话 id 是否完整——前缀相近不等于同一会话。",
				);
			}
			throw new CdpError(
				"No chatgpt.com tab found in the automation browser. Run /brb open  first.",
			);
		}
		if (!target.webSocketDebuggerUrl) {
			throw new CdpError(`Target ${target.id} has no webSocketDebuggerUrl`);
		}

		const page = await CdpSession.attach(target.webSocketDebuggerUrl, `page:${target.url}`, options.signal);
		let browser: CdpSession | null = null;
		try {
			browser = await CdpSession.attach(version.webSocketDebuggerUrl, "browser", options.signal);
			await browser.grantClipboard(CHATGPT_URL_PREFIX);
		} catch (error) {
			if (options.signal?.aborted) {
				await page.close().catch(() => undefined);
				throw error;
			}
			browser = null; // clipboard permission is only a fallback path
		}
		if (options.signal?.aborted) {
			await page.close().catch(() => undefined);
			await browser?.close().catch(() => undefined);
			throw new CdpError("CDP page attach cancelled");
		}

		return new ChatGptPage(target, page, browser, port);
	}

	/** Open a URL in the automation browser and return a page bound to it. */
	/**
	 * Open a tab for a conversation that already exists.
	 *
	 * Refuses a URL that carries no conversation id, because that URL means "create a new
	 * conversation" and creating one is a Level 2 side effect. The refusal is the mechanism:
	 * there is exactly one way to create a conversation, `createConversation`, and it cannot be
	 * called without a capability. A comment asking people to route through policy would have
	 * been another guard that exists in intent only, which is what this registry is for.
	 */
	static async open(url: string, options: { port?: number } = {}): Promise {
		if (conversationIdOf(url) === null) {
			throw new Error(
				`ChatGptPage.open 拒绝打开没有会话 id 的 URL(${url}):那意味着新建会话,` +
					"而新建是 Level 2 外部副作用。请改用 ChatGptPage.createConversation(capability)," +
					"其中 capability 必须来自 requestCapability('conversation.create', …)。",
			);
		}
		return ChatGptPage.openRaw(url, options);
	}

	/**
	 * Create a new conversation. The only path to it, and it requires proof that policy agreed.
	 *
	 * `assertCapability` is not ceremony: the extension is loaded by jiti, which does not type
	 * check, so the brand on `Capability` is enforced at `npm test` and at nothing else. This is
	 * what stops a cast from bypassing the registry at runtime.
	 */
	static async createConversation(
		capability: Capability,
		options: { port?: number } = {},
	): Promise {
		assertCapability(capability, "conversation.create");
		return ChatGptPage.openRaw("https://chatgpt.com/", options);
	}

	/** The raw tab-opening primitive. Not exported, so it cannot be reached from outside. */
	private static async openRaw(
		url: string,
		options: { port?: number } = {},
	): Promise {
		const port = options.port ?? DEFAULT_CDP_PORT;
		const opened = await openTab(url, port);
		await activateTarget(opened.id, port);
		await sleep(1200);
		// Bind to the target we just created, by id. Re-attaching by URL heuristic
		// would prefer an existing conversation over this brand-new tab, so a caller
		// that opens a new chat would silently be handed somebody else's thread --
		// which is how the lazy-create path ended up trying to claim a binding that
		// already belonged to another pi thread.
		let page: ChatGptPage;
		try {
			page = await ChatGptPage.attach({ port, targetId: opened.id });
		} catch (error) {
			// Preserve the operation-scoped target id so the host can distinguish a
			// provably closed target from an otherwise ambiguous create reply.
			if (error && typeof error === "object") (error as { createdTargetId?: string }).createdTargetId = opened.id;
			throw error;
		}
		// Preserve the identity returned by /json/new even if a later attach snapshot
		// reports a changed live URL. Auto-create must pin this target, never re-select.
		page.createdTargetId = opened.id;
		// A cold profile can redirect away from a conversation it fails to load quickly
		// (BUG-013: observed live, the redirect created a brand-new conversation on
		// submit). The target info is a snapshot from open time; the live location is
		// the truth, so wait until the page actually lands on the conversation.
		const wantedId = conversationIdOf(url);
		if (wantedId) {
			await page.settleOnConversation(wantedId, 20_000);
		}
		// Early, explicit platform-boundary check (C 轮 C3): the remote side effect of a
		// created conversation is its first send, and ask() enforces the boundary there too --
		// but failing HERE names the real reason (CAPTCHA / limit) instead of letting a
		// downstream send fail against a page that never became usable.
		const created = await page.observePageState();
		if (created.observation.platformBlock) {
			throw new CdpError(platformBlockMessage(created.observation.platformBlock, "创建新会话"));
		}
		return page;
	}

	/**
	 * Wait until the page's live location names the conversation. A redirect away from
	 * it (cold-load failure, SPA error) is a refusal, not something to type into.
	 */
	async settleOnConversation(expectedId: string, timeoutMs: number): Promise {
		const deadline = Date.now() + timeoutMs;
		for (;;) {
			const href = await this.page
				.evaluate("location.href", { returnByValue: true })
				.catch(() => "");
			if (conversationIdOf(href) === expectedId) return;
			if (Date.now() > deadline) {
				throw new CdpError(
					`页面没能落在目标会话 ${expectedId.slice(0, 8)}…(实际 ${href || "(读不到 location)"})。` +
						"冷加载可能被重定向;重试通常即可。未输入任何内容。",
				);
			}
			await sleep(600);
		}
	}

	async close(): Promise {
		await this.page.close();
		await this.browser?.close();
	}

	async state(): Promise {
		const raw = await this.page.evaluate<
			Omit & { messages: MessageRef[] }
		>(`(() => {
			${EDITOR_FINDER}
			${SEND_BUTTON_FINDER}
			${STOP_BUTTON_FINDER}
			${TURN_MODEL}
			${MESSAGE_COLLECTOR}
			const nodes = ${ASSISTANT_NODES};
			const editor = findEditor();
			const turns = __turnRoots();
			let turnNumber = 0;
			for (const turn of turns) {
				const match = /conversation-turn-([0-9]+)/.exec(turn.getAttribute('data-testid') || '');
				if (match) turnNumber = Math.max(turnNumber, Number(match[1]));
			}
			if (turnNumber === 0 && turns.length > 0 && !__legacyUi()) turnNumber = turns.length - 1;
			return {
				url: location.href,
				title: document.title,
				userCount: (${USER_NODES}).length,
				messages: __messages,
				userIds: __messages.filter((m) => m.role === 'user' && m.id).map((m) => m.id),
				assistantIds: __messages.filter((m) => m.role === 'assistant' && m.id).map((m) => m.id),
				turnNumber,
				turnCount: turns.length,
				visibility: document.visibilityState,
				assistantCount: nodes.length,
				streaming: !!findStopButton(),
				composerText: editor ? (editor.value !== undefined ? editor.value : (editor.innerText || '')) : '',
				composerFound: !!editor,
				sendButtonFound: !!findSendButton(),
				copyTurnFound: !!(document.querySelector('[data-testid="copy-turn-action-button"]') ||
					document.querySelector('button[aria-label="复制"], button[aria-label*="Copy"]')),
				loggedOut: __loginVisible(),
			};
		})()`);
		// Derived on the host, so the selection rule is the one identity.ts tests offline.
		// The previous rule was "last assistant node in document order", which during a
		// re-render can be the previous reply -- BUG-001's mechanism ①.
		const newest = newestAssistant(raw.messages ?? []);
		// `newest.text` may be undefined when the newest assistant turn exists but has
		// no text node yet (streaming first frame, placeholder mount). BUG-022: the
		// wait loop then crashed on `state.text.replace` instead of treating the
		// empty frame as "still building" -- one undefined read took down the whole
		// relay and, because the harness command layer caught it, masqueraded as a
		// copy-path failure.
		const text = newest ? newest.text ?? "" : "";
		return {
			...raw,
			text,
			newestAssistantId: newest?.id ?? null,
			identityWarning: this.lastIdentityWarning,
			thinking: isThinkingPlaceholder(text),
		};
	}

	/** Read the evidence used by the single BUG-045 classifier. This is read-only. */
	async observePageState(): Promise<{ observation: ChatGptPageObservation; status: ChatGptPageStatus }> {
		const raw = await this.page.evaluate<{
			url: string;
			protocol: string;
			origin: string;
			chromeError: boolean;
			storageKeys: string[] | null;
			loginVisible: boolean;
			composerFound: boolean;
			blockerText: string;
		}>(`(() => {
			${EDITOR_FINDER}
			${TURN_MODEL}
			let storageKeys = null;
			try { storageKeys = Object.keys(localStorage); } catch {}
			// Platform-blocker scan, bounded and cheap. Sampling surface matters as much as the
			// patterns: conversation CONTENT must never enter this text. Canvas replies render
			// headings as real h1/h2 inside main, and a reply that DISCUSSES CAPTCHA or usage
			// limits made the whole page read as blocked (live false positive, 2026-09-25:
			// a READY page with a compliance report on screen refused to send). So: in-app
			// signals only (alerts/dialogs/toasts outside the transcript), plus the page title
			// and captcha iframes for the case where a challenge page has replaced the app.
			const inTranscript = (el) => !!(el.closest && (el.closest('[class*="transcriptContent"]') || el.closest('[class*="group/user-message"]') || el.closest('[class*="writing-block"]')));
			const blockerParts = [document.title];
			for (const el of document.querySelectorAll('[role="alert"], [role="dialog"], [data-testid*="toast"]')) {
				if (inTranscript(el)) continue;
				const t = el.innerText || '';
				if (t) blockerParts.push(t);
				if (blockerParts.join(String.fromCharCode(10)).length > 3000) break;
			}
			const captchaFrame = [...document.querySelectorAll('iframe[src]')]
				.some((f) => !inTranscript(f) && /captcha|challenge|hcaptcha|recaptcha/i.test(f.getAttribute('src') || ''));
			if (captchaFrame) blockerParts.push('captcha-iframe');
			const blockerText = blockerParts.join(String.fromCharCode(10)).slice(0, 4000);
			return {
				url: location.href,
				protocol: location.protocol,
				origin: location.origin,
				chromeError: location.protocol === 'chrome-error:' || document.URL.startsWith('chrome-error://'),
				storageKeys,
				loginVisible: __loginVisible(),
				composerFound: !!findEditor(),
				blockerText,
			};
		})()`);
		// Fixtures (and any older page-script shape) may not carry blockerText; a missing
		// observation is not itself a block, but it must not crash the whole state read.
		const blockerText = typeof raw.blockerText === "string" ? raw.blockerText : "";
		const observation: ChatGptPageObservation = {
			url: raw.url,
			protocol: raw.protocol,
			origin: raw.origin,
			chromeError: raw.chromeError,
			principal: raw.storageKeys === null
				? "unavailable"
				: extractPrincipalFromKeys(raw.storageKeys) ? "present" : "absent",
			loginVisible: raw.loginVisible,
			composerFound: raw.composerFound,
			platformBlock: detectPlatformBlock({
				url: raw.url,
				title: blockerText.split(String.fromCharCode(10))[0] ?? "",
				bodyText: blockerText,
				loggedOut: raw.loginVisible,
			}),
		};
		return { observation, status: classifyChatGptPageState(observation) };
	}

	/**
	 * A hidden tab renders slowly, so long replies lag far behind the stream and
	 * the DOM can sit on a placeholder for minutes. Bringing the tab forward is
	 * cheap and removes the whole failure mode.
	 */
	async ensureVisible(): Promise {
		const visibility = await this.page
			.evaluate("document.visibilityState")
			.catch(() => "unknown");
		if (visibility === "visible") return true;
		await this.bringToFront();
		await activateTarget(this.target.id, this.port).catch(() => undefined);
		await sleep(400);
		const after = await this.page.evaluate("document.visibilityState").catch(() => "unknown");
		return after === "visible";
	}

	/** Poll until the ProseMirror composer exists (React mounts it late). */
	async waitForEditor(timeoutMs = 20_000): Promise {
		const deadline = Date.now() + timeoutMs;
		for (;;) {
			const found = await this.page
				.evaluate(`(() => {
					${EDITOR_FINDER}
					return !!findEditor();
				})()`)
				.catch(() => false);
			if (found) return true;
			if (Date.now() > deadline) return false;
			await sleep(400);
		}
	}

	/** True only when the classifier has positive ready evidence. */
	async isLoggedIn(): Promise {
		return (await this.observePageState()).status === "READY";
	}

	/**
	 * Replace the composer content with `text` (ProseMirror-safe).
	 *
	 * ProseMirror applies pasted input through an async transaction, so the
	 * composer cannot be read back synchronously. Verifying immediately used to
	 * report `length: 0`, which then triggered the trusted-input fallback on top
	 * of an already-successful paste — inserting the text twice. Every path here
	 * therefore polls for the result before deciding to fall back.
	 */
	setInputMode(mode: InputMode): void {
		this.inputMode = mode;
	}

	private inputMode: InputMode = "trusted";

	async setComposer(text: string): Promise<{ ok: boolean; strategy: string; length: number; duplicated?: boolean }> {
		const kind = await this.page.evaluate(`(() => {
			${EDITOR_FINDER}
			const el = findEditor();
			if (!el) return 'none';
			return el.tagName === 'TEXTAREA' ? 'textarea' : 'prosemirror';
		})()`);

		if (kind === "none") return { ok: false, strategy: "none", length: 0 };

		if (kind === "textarea") {
			await this.page.evaluate(`(() => {
				${EDITOR_FINDER}
				const el = findEditor();
				if (!el) return false;
				const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
				if (setter) setter.call(el, ${jsString(text)}); else el.value = ${jsString(text)};
				el.dispatchEvent(new Event('input', { bubbles: true }));
				return true;
			})()`);
			const length = (await this.waitForComposerText(2000)).length;
			return { ok: length > 0, strategy: "textarea", length };
		}

		/**
		 * Paste, in chunks.
		 *
		 * A single synthetic paste of tens of thousands of characters makes
		 * ProseMirror apply one enormous transaction and then forces ChatGPT's
		 * React tree to re-render the whole thread on the main thread. Observed
		 * live: that wedged the renderer -- CDP evaluate timed out and the human
		 * using the browser could not even scroll. Chunking turns that into many
		 * small transactions, each of which yields back to the event loop.
		 */
		const pastePath = async (): Promise => {
			await this.selectAllInComposer();
			const chunks = chunkForInput(text, MAX_PASTE_CHUNK_CHARS);
			for (let i = 0; i < chunks.length; i += 1) {
				const chunk = chunks[i];
				const first = i === 0;
				await this.page.evaluate(`(() => {
					${EDITOR_FINDER}
					const el = findEditor();
					if (!el) return false;
					if (${first ? "true" : "false"}) {
						const selection = window.getSelection();
						if (selection) {
							selection.removeAllRanges();
							const range = document.createRange();
							range.selectNodeContents(el);
							selection.addRange(range);
						}
					}
					const data = new DataTransfer();
					data.setData('text/plain', __PI_CHUNK__);
					el.dispatchEvent(new ClipboardEvent('paste', {
						clipboardData: data,
						bubbles: true,
						cancelable: true,
					}));
					return true;
				})()`.replace("__PI_CHUNK__", jsString(chunk)));
				if (chunks.length > 1) await sleep(120);
			}
			return this.waitForComposerText(6000, { acceptLength: text.length });
		};

		const trustedPath = async (): Promise => {
			await this.clearComposer();
			await this.page.evaluate(`(() => {
				${EDITOR_FINDER}
				const el = findEditor();
				if (el) el.focus();
				return true;
			})()`);
			await this.page.send("Input.insertText", { text }).catch(() => undefined);
			return this.waitForComposerText(3000, { acceptLength: text.length });
		};

		// The trusted path is preferred so the page never observes an untrusted
		// event; the paste path is faster and kept as the fallback.
		const order: Array<["Input.insertText" | "paste", () => Promise]> =
			this.inputMode === "paste"
				? [["paste", pastePath], ["Input.insertText", trustedPath]]
				: [["Input.insertText", trustedPath], ["paste", pastePath]];

		let current = "";
		let strategy = "none";
		for (const [name, run] of order) {
			current = await run();
			if (current.length > 0) {
				strategy = name;
				break;
			}
		}
		return { ...this.describeComposition(current, text, strategy), ok: current.length > 0 };
	}

	/** Guard against silent double insertion. */
	private describeComposition(
		composed: string,
		wanted: string,
		strategy: string,
	): { ok: boolean; strategy: string; length: number; duplicated?: boolean } {
		// ProseMirror's innerText adds one newline per block, so allow a little
		// slack; a real double insertion overshoots by roughly the whole prompt.
		const expected = wanted.trim().length;
		const overshoot = composed.trim().length - expected;
		const doubled = expected >= 6 && overshoot >= 4 && composed.trim().length >= expected * 1.5;
		return { ok: composed.length > 0, strategy, length: composed.length, duplicated: doubled || undefined };
	}

	private async selectAllInComposer(): Promise {
		await this.page.evaluate(`(() => {
			${EDITOR_FINDER}
			const el = findEditor();
			if (!el) return false;
			el.focus();
			const selection = window.getSelection();
			if (selection) {
				selection.removeAllRanges();
				const range = document.createRange();
				range.selectNodeContents(el);
				selection.addRange(range);
			}
			return true;
		})()`);
	}

	/** Poll the composer until it settles on a non-empty value. */
	async waitForComposerText(
		timeoutMs = 3000,
		options: { acceptLength?: number } = {},
	): Promise {
		const deadline = Date.now() + timeoutMs;
		let last = "";
		for (;;) {
			last = await this.readComposer().catch(() => "");
			if (last.length > 0) {
				const expected = options.acceptLength ?? 0;
				// Wait for the value to grow to roughly what we asked for, so we do
				// not accept a partially applied paste.
				if (expected === 0 || last.trim().length >= Math.max(1, Math.floor(expected * 0.8))) return last;
			}
			if (Date.now() > deadline) return last;
			await sleep(150);
		}
	}

	async readComposer(): Promise {
		return this.page.evaluate(`(() => {
			${EDITOR_FINDER}
			const el = findEditor();
			if (!el) return '';
			return el.value !== undefined ? String(el.value) : (el.innerText || '');
		})()`);
	}

	async clearComposer(): Promise {
		const deadline = Date.now() + 3000;
		for (;;) {
			await this.selectAllInComposer();
			// A trusted Delete key event is what ProseMirror actually reacts to;
			// a synthetic execCommand('delete') often leaves the text in place.
			for (const type of ["rawKeyDown", "keyUp"]) {
				await this.page
					.send("Input.dispatchKeyEvent", {
						type,
						key: "Delete",
						code: "Delete",
						windowsVirtualKeyCode: 46,
						nativeVirtualKeyCode: 46,
					})
					.catch(() => undefined);
			}
			await this.page.evaluate(`(() => { document.execCommand('delete'); return true; })()`).catch(() => undefined);
			const current = await this.readComposer().catch(() => "");
			if (current.length === 0) return;
			if (Date.now() > deadline) return;
			await sleep(200);
		}
	}

	/** Focus the composer so a trusted key event lands in the editor. */
	private async focusComposer(): Promise {
		await this.page
			.evaluate(`(() => {
				${EDITOR_FINDER}
				const el = findEditor();
				if (el) el.focus();
				return true;
			})()`)
			.catch(() => undefined);
	}

	/**
	 * Press Enter as a real (trusted) key event.
	 *
	 * `rawKeyDown` + `char` + `keyUp` is the sequence ProseMirror/ChatGPT
	 * actually submits on. A plain `keyDown` with `text` does nothing.
	 */
	private async pressEnter(): Promise {
		const base = {
			key: "Enter",
			code: "Enter",
			windowsVirtualKeyCode: 13,
			nativeVirtualKeyCode: 13,
		};
		// A/B on the 2026-09 UI: plain keyDown WITHOUT text submits; the old
		// rawKeyDown + char("") sequence (A/B-proven on the pre-2026-09 UI) now
		// only feeds a newline into ProseMirror and the message never leaves. The
		// trusted send-button fallback in submit() covers any UI that needs more.
		await this.page
			.send("Input.dispatchKeyEvent", { ...base, type: "keyDown" })
			.catch(() => undefined);
		await this.page.send("Input.dispatchKeyEvent", { ...base, type: "keyUp" }).catch(() => undefined);
	}

	/** Did the message actually leave the composer? */
	private async probeSubmission(
		before: PageState,
		timeoutMs: number,
	): Promise<{ state: SubmitObservation; observations: number; unobservable: number }> {
		const deadline = Date.now() + timeoutMs;
		const seen = new Set(before.userIds);
		let observations = 0;
		let unobservable = 0;
		let sawNewTurn = false;
		for (;;) {
			// A read that fails tells us NOTHING about the page. It used to be swallowed
			// into the same null the "no new turn" case produced, so a busy or
			// mid-transition page made submit() conclude "消息没有发出去" about a message
			// that had been delivered (BUG-062). Count the two outcomes separately.
			const state = await this.state().catch(() => null);
			if (state) {
				observations += 1;
				// Proof of submission is a NEW user message, by identity. The old rule also
				// accepted "the composer is now empty" as proof -- and the 2026-09 UI can
				// clear the composer WITHOUT sending (verified live: an 871-char prompt
				// vanished with no new bubble). That shortcut made submit() report success
				// for a send that never happened, and the caller then read the PREVIOUS
				// reply as if it were the answer (BUG-056). A destroyed draft is exactly
				// the case that must surface as a failure.
				if (state.userIds.some((id) => !seen.has(id))) sawNewTurn = true;
				// Legacy UI has no per-message ids; a new turn number is its equivalent.
				else if (state.turnNumber > before.turnNumber) sawNewTurn = true;
			} else {
				unobservable += 1;
			}
			const verdict = judgeSubmitProbe({ sawNewTurn, observations, unobservable });
			if (verdict === "submitted") return { state: verdict, observations, unobservable };
			if (verdict === "unknown") return { state: verdict, observations, unobservable };
			if (Date.now() > deadline) return { state: verdict, observations, unobservable };
			await sleep(250);
		}
	}

	/**
	 * Trusted mouse click at viewport coordinates.
	 *
	 * The 2026-09 ChatGPT UI ignores untrusted clicks for real actions: the handler
	 * never runs (verified by A/B on the copy button -- dispatched MouseEvents and
	 * .click() produce no clipboard write, Input.dispatchMouseEvent does). Every
	 * click that must DO something goes through here.
	 */
	private async trustedClick(x: number, y: number): Promise {
		await this.page.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none" }).catch(() => undefined);
		await this.page.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
		await this.page.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
	}

	/**
	 * Submit the composer content.
	 *
	 * Trusted Enter first: clicking `[data-testid="send-button"]` is unreliable
	 * here — the element can be present and enabled while `.click()` does nothing
	 * at all (verified by A/B comparison). Every attempt is followed by a check
	 * that a new turn actually appeared, so a swallowed send surfaces as an error
	 * instead of a silent hang.
	 */
	async submit(options: { waitForButtonMs?: number } = {}): Promise {
		const before = await this.state();
		// BUG-062 ruling: an unreadable page is its own answer (unknown), it is never
		// reported as "not sent", and it is not answered by pressing Enter again.
		const unconfirmed = (probe: { unobservable: number }): CdpError =>
			new CdpError(submitUnconfirmedMessage({ waitedMs: options.waitForButtonMs ?? 6000, unobservable: probe.unobservable }));

		await this.focusComposer();
		await this.pressEnter();
		const first = await this.probeSubmission(before, 6000);
		if (first.state === "submitted") return "enter-key";
		if (first.state === "unknown") throw unconfirmed(first);

		const deadline = Date.now() + (options.waitForButtonMs ?? 6000);
		for (;;) {
			const point = await this.page
				.evaluate<{ x: number; y: number } | null>(`(() => {
					${SEND_BUTTON_FINDER}
					const btn = findSendButton();
					if (!btn) return null;
					btn.scrollIntoView({ block: 'center' });
					const r = btn.getBoundingClientRect();
					if (r.width === 0 || r.bottom < 0 || r.top > window.innerHeight) return null;
					return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
				})()`)
				.catch(() => null);
			if (point) await this.trustedClick(point.x, point.y);
			if (point) {
				const clicked = await this.probeSubmission(before, 4000);
				if (clicked.state === "submitted") return "send-button";
				if (clicked.state === "unknown") throw unconfirmed(clicked);
			}
			if (Date.now() > deadline) break;
			await sleep(300);
		}

		await this.focusComposer();
		await this.pressEnter();
		const retried = await this.probeSubmission(before, 6000);
		if (retried.state === "submitted") return "enter-key-retry";
		if (retried.state === "unknown") throw unconfirmed(retried);

		throw new CdpError(
			"消息没有发出去:输入框里仍有内容,也没有出现新一轮。运行 /brb doctor 检查页面状态。",
		);
	}

	/** Send `text` and wait for the assistant reply to finish streaming. */
	/**
	 * Send a message into the conversation. Level 2, external, account-visible.
	 *
	 * The capability is a required parameter rather than a check inside, so a callsite that
	 * forgets it does not compile -- and `assertCapability` covers the runtime, where the
	 * extension is loaded by jiti and the type is not enforced. Both halves are needed for the
	 * same reason the registry exists: a guard that only lives in the type system is the shape
	 * this repository has already been bitten by four times.
	 */
	/**
	 * A17: observe the current account principal from the page's own localStorage
	 * key namespace (identity-principal.ts documents the discovery evidence).
	 * Read-only; never a side effect. Returns null when unobservable.
	 */
	async observePrincipal(): Promise {
		const raw = await this.page
			.evaluate(`(() => {
				try {
					const keys = Object.keys(localStorage);
					return JSON.stringify(keys);
				} catch (error) {
					return JSON.stringify([]);
				}
			})()`, { returnByValue: true })
			.catch(() => "[]");
		const keys = JSON.parse(String(raw ?? "[]")) as string[];
		return extractPrincipalFromKeys(keys);
	}

	/**
	 * Wait (bounded) for the account principal to become observable.
	 *
	 * A freshly created conversation page writes the `.../user-/...` keys into
	 * localStorage asynchronously. The one-shot read raced that window and reported
	 * "no account" for a page that simply had not initialized yet -- every auto-create
	 * then produced a false IDENTITY_UNKNOWN orphan (five accumulated across sessions,
	 * which in turn paused auto-create globally).
	 *
	 * The result also reports WHETHER any keys existed at all: "the page never
	 * initialized" and "keys exist but no principal matches" are different facts, and
	 * the orphan record must not conflate them (the old one-shot quieted both into the
	 * same misleading sentence).
	 */
	async waitForPrincipal(options: { timeoutMs?: number; pollMs?: number } = {}): Promise<{
		principal: string | null;
		sawAnyKeys: boolean;
		attempts: number;
		elapsedMs: number;
	}> {
		const timeoutMs = options.timeoutMs ?? 20_000;
		const pollMs = options.pollMs ?? 500;
		const startedAt = Date.now();
		let attempts = 0;
		let sawAnyKeys = false;
		for (;;) {
			attempts += 1;
			const raw = await this.page
				.evaluate(`(() => {
					try {
						return JSON.stringify(Object.keys(localStorage));
					} catch (error) {
						return JSON.stringify([]);
					}
				})()`, { returnByValue: true })
				.catch(() => "[]");
			let keys: string[] = [];
			try {
				keys = JSON.parse(String(raw ?? "[]")) as string[];
			} catch {
				keys = [];
			}
			if (keys.length > 0) sawAnyKeys = true;
			const principal = extractPrincipalFromKeys(keys);
			if (principal) return { principal, sawAnyKeys, attempts, elapsedMs: Date.now() - startedAt };
			if (Date.now() - startedAt >= timeoutMs) {
				return { principal: null, sawAnyKeys, attempts, elapsedMs: Date.now() - startedAt };
			}
			await sleep(pollMs);
		}
	}

	/**
	 * The live location must name the expected conversation. `this.target.url` is a
	 * snapshot from attach time and cannot prove anything about where the page is NOW --
	 * that distinction is the whole of BUG-013.
	 */
	private async assertOnConversation(expectedId: string | undefined, phase: string): Promise {
		const href = await this.page
			.evaluate("location.href", { returnByValue: true })
			.catch(() => "");
		const actual = conversationIdOf(href);
		if (!expectedId) {
			const route = classifyConversationRoute(href);
			// A provisional target may remain bare, expose ChatGPT's WEB draft route, or pass
			// through the client-local `local-chatgpt:` route (BUG-060) after submit. None of the
			// three is a durable identity, but all three are valid transport locations while
			// materialization is being reconciled -- and calling the third "unknown" turned a
			// delivered message into a reported failure.
			if (route === "OTHER") {
				throw new CdpError(`${phase}页面落在不可识别的 ChatGPT 路由:${href || "(读不到 location)"}。`);
			}
			if (phase !== "提交后" && actual) {
				throw new CdpError(`${phase}页面已落到已有会话 ${actual.slice(0, 12)}…:INVALID_TARGET,拒绝发送。`);
			}
			return;
		}
		if (actual !== expectedId) {
			throw new CdpError(
				`${phase}页面不在目标会话上:期望 ${expectedId.slice(0, 8)}…,` +
					`实际是 ${actual ? `${actual.slice(0, 8)}…` : href || "(读不到 location)"}。` +
					"拒绝继续——消息绝不能落在未声明的会话里。",
			);
		}
	}

	async ask(
		capability: Capability,
		text: string,
		options: WaitOptions & { submitDelayMs?: number; expectConversationId: string; expectedTargetId?: string },
	): Promise<{ reply: string; submitVia: string; composerLength: number; duplicated?: boolean }> {
		assertCapability(capability, "message.send");
		// The capability proves the send is authorized; it says nothing about WHERE the
		// page currently is. The attach-time target info is a frozen snapshot, and the SPA
		// can navigate away after it (BUG-013: a cold load redirected to the new-chat route
		// and the submit created a brand-new conversation). The live location is the only
		// truth, checked immediately before and after the side effect.
		if (options.expectedTargetId && options.expectedTargetId !== this.target.id) {
			throw new CdpError("TARGET_DRIFT:发送页面不是创建时固定的 target。");
		}
		if (!options.expectConversationId && !options.expectedTargetId) {
			throw new CdpError("ask 需要 expectConversationId 或 expectedTargetId:发送必须声明目标身份。");
		}
		// Page attribution precedes target-route validation. A Chrome error document has no
		// conversation id either, but calling that a target mismatch would again conceal the
		// actual network/proxy failure from the operator.
		const initialPageState = await this.observePageState();
		if (initialPageState.status === "UNREACHABLE") {
			throw new CdpError("无法访问 ChatGPT 页面。请先检查网络或代理连接,然后重试。可以运行 /brb doctor 查看当前环境状态。");
		}
		if (initialPageState.status === "AUTH_REQUIRED") {
			throw new CdpError("这个自动化浏览器还没有登录 ChatGPT。请在自动化窗口里登录一次,或用 /brb mode existing 复用你已有的浏览器登录态。");
		}
		if (initialPageState.status === "UNKNOWN") {
			throw new CdpError("BRB 无法确认当前 ChatGPT 页面状态,本次操作已停止。请运行 /brb doctor 查看详细状态。");
		}
		// Platform boundary before any remote side effect (C 轮 C3/C4): a page showing a
		// CAPTCHA / security challenge / rate or usage limit stops the send HERE, with the
		// fact named and nothing retried. detectPlatformBlock shares its pattern table with
		// detectBlocker, so the observed state and the reason cannot drift apart.
		if (initialPageState.observation.platformBlock) {
			throw new CdpError(
				platformBlockMessage(initialPageState.observation.platformBlock, "发送消息"),
			);
		}
		await this.assertOnConversation(options.expectConversationId, "提交前");
		// BUG-028 (verdict #38): wait for the composer BEFORE capturing the baseline.
		//
		// Live failure this fixes: a freshly (re)loaded conversation page had not mounted the
		// editor yet, so setComposer returned {ok:false, strategy:"none"} 30 ms after "已连接" and
		// ask() threw "Could not type into the ChatGPT composer. Run /brb doctor…" — honest-looking
		// advice pointing at the wrong cause, on a page that only needed a moment. Waiting here
		// also protects the baseline: before.userIds/assistantIds captured on a half-rendered page
		// are incomplete, which is the raw material of the BUG-001 family.
		const EDITOR_READY_MS = 20_000;
		const editorReady = await this.waitForEditor(EDITOR_READY_MS).catch(() => false);
		const before = await this.state();
		if (before.streaming) {
			throw new CdpError("ChatGPT is still streaming a previous reply; wait for it to finish first.");
		}

		const composed = await this.setComposer(text);
		if (!composed.ok || composed.length === 0) {
			const pageState = await this.observePageState();
			if (pageState.status === "UNREACHABLE") {
				throw new CdpError("无法访问 ChatGPT 页面。请先检查网络或代理连接,然后重试。可以运行 /brb doctor 查看当前环境状态。");
			}
			if (pageState.status === "AUTH_REQUIRED") {
				throw new CdpError(
					"这个自动化浏览器还没有登录 ChatGPT。请在自动化窗口里登录一次,或用 /brb mode existing 复用你已有的浏览器登录态。",
				);
			}
			if (pageState.status === "AUTHENTICATED_NOT_READY" || !editorReady || !pageState.observation.composerFound) {
				// State the real cause. doctor stays available as an ADDITIONAL diagnostic below, never
				// as the primary explanation (verdict #38, Q4).
				throw new CdpError(
					`目标会话已打开,但 ChatGPT 编辑器在 ${EDITOR_READY_MS / 1000}s 内没有挂载完成` +
						`(composerFound=${pageState.observation.composerFound})。未输入任何内容,也未发送消息。` +
						"页面可能仍在渲染,稍后重试即可;/brb doctor 可作附加诊断。",
				);
			}
			throw new CdpError(
				"Could not type into the ChatGPT composer. Run /brb doctor to inspect the page.",
			);
		}
		if (composed.duplicated) {
			// Recover instead of sending a garbled prompt.
			await this.clearComposer();
			const retry = await this.setComposer(text);
			if (!retry.ok) {
				throw new CdpError("Composer text was inserted twice and could not be cleared; aborting this send.");
			}
		}

		const submitVia = await this.submit();
		// Record the send evidence first (BUG-060 ruling), then check where the page went.
		await options.onSubmitConfirmed?.();
		await sleep(options.submitDelayMs ?? 1200);
		// In an existing conversation the route does not move on submit. If it moved, the
		// message went somewhere else -- that is a misdelivery, and it must be LOUD,
		// naming where it actually landed, never a quiet success.
		await this.assertOnConversation(options.expectConversationId, "提交后");
		await options.onSubmitted?.();

		// Prove the question left before waiting for an answer to it.
		//
		// Asking and being answered used to be one indivisible step, so a send that
		// never reached the server was indistinguishable from a slow reply: the page
		// draws an optimistic "thinking" turn either way. The bridge then reported
		// "no plan text", which points at the copy path -- the wrong place to look,
		// and it sent a real investigation down the wrong branch. A delivered send
		// increments the user-message count and a lost one does not, which is cheap
		// ground truth.
		const delivered = await this.waitForDelivery(before.userIds, options.deliveryTimeoutMs);
		if (!delivered.ok) {
			throw new DeliveryError(
				deliveryFailureMessage({
					baselineCount: before.userIds.length,
					waitedMs: delivered.waitedMs,
					identityUnavailable: delivered.identityUnavailable,
				}),
				delivered.waitedMs,
			);
		}

		const reply = await this.waitForReply(
			{
				assistantCount: before.assistantCount,
				turnNumber: before.turnNumber,
				// The stable baseline: ids present before the send.
				assistantIds: before.assistantIds,
			},
			options,
		);
		return { reply, submitVia, composerLength: composed.length, duplicated: composed.duplicated };
	}

	/** Wait until a new assistant reply appears and stops changing. */
	/**
	 * Wait for the user-message count to increase, which is what a delivered send
	 * looks like from the page.
	 *
	 * Thin wrapper: the polling and the decision live in delivery.ts so the case that
	 * is hard to produce on demand -- a send that never arrives -- can be tested.
	 */
	async waitForDelivery(baselineUserIds: readonly string[], timeoutMs?: number) {
		return confirmDelivery(
			baselineUserIds,
			{
				// Message identity, not a count and not a turn index. Both of those are
				// window relative and both were wrong: see delivery.ts.
				readMessages: async () => (await this.state()).messages ?? [],
				sleep,
				now: () => Date.now(),
			},
			{ timeoutMs },
		);
	}

	async waitForReply(since: number | ReplyBaseline, options: WaitOptions = {}): Promise {
		const baseline: ReplyBaseline = typeof since === "number" ? { assistantCount: since } : since;
		let warnedNoIdentity = false;
		const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
		const stabilityMs = options.stabilityMs ?? 1500;
		const stallMs = options.stallMs ?? 45_000;
		// Polling every 500ms means re-serialising the whole page DOM twice a
		// second, which is real main-thread pressure on a long thread. Start
		// responsive (short replies finish fast) and back off as the wait grows.
		const pollIntervalMs = (elapsed: number) =>
			elapsed < 10_000 ? 500 : elapsed < 60_000 ? 1200 : elapsed < 180_000 ? 2500 : 4000;
		// Compare whitespace-normalized text: ChatGPT re-renders trailing
		// whitespace during streaming, which would otherwise reset the stability
		// timer forever and push every reply into the stall escape hatch.
		const normalizeForCompare = (value: string) => value.replace(/\s+/g, " ").trim();
		const startedAt = Date.now();
		// Starts at the beginning of the wait: nothing has been observed quiet yet, so the
		// stability window has to elapse before a short answer can be believed.
		let lastStreamingAt = startedAt;
		// BUG-018: "the stop button was never observed" is NOT proof that
		// generation ended. The BUG-008 fix replaced an instant reading with a
		// history (lastStreamingAt), but the history starts at `startedAt` — so
		// when the stop button is missing for the WHOLE generation (mount gap,
		// selector drift, hidden-tab render lag), a stable 1.5s prefix like
		// "HAND" still satisfied every condition and was returned as the full
		// reply while the DOM kept growing to "HANDSHAKE_OK". Track whether a
		// generation lifecycle was ever observed; without it, completion needs a
		// longer quiet window AND an independent UI signal (reply action bar).
		let sawStreaming = false;
		let lastText = "";
		let lastChangeAt = Date.now();
		let sawNewMessage = false;

		for (;;) {
			const state = await this.state().catch(() => null);
			if (!state) {
				await sleep(700);
				continue;
			}
			const elapsed = Date.now() - startedAt;
			// Remember the last moment the page admitted to streaming. The fast path below used
			// to trust `!state.streaming` at the sampling instant, and the stop button has a
			// documented gap at the start of generation -- so a reply's first few characters,
			// unchanged for 1.5s, satisfied every condition and were returned as the finished
			// answer. Observed live: a round returned the five characters "下面是一版", which
			// later grew into a 2872-character reply. The placeholder guard did not help,
			// because it matches known placeholder strings and this was real text.
			//
			// Using the history instead of the instant turns a brittle reading into one that
			// cannot be fooled by a gap: the page must have been quiet for the whole window.
			if (state.streaming) {
				sawStreaming = true;
				lastStreamingAt = Date.now();
			}
			options.onTick?.({
				elapsedMs: elapsed,
				textLength: state.text.length,
				streaming: state.streaming,
				count: state.assistantCount,
			});

			// Identity first. The count/index predicates below are only a fallback for a
			// caller that supplied no baseline ids, and they are the ones BUG-001 blames:
			// the user turn landing satisfies a turn-number comparison before the
			// assistant reply is mounted, and the count can fall rather than rise.
			const verdict = judgeReply(state.messages ?? [], baseline.assistantIds ?? []);
			const isNewReply =
				verdict.reply !== null ||
				(verdict.identityUnavailable &&
					((baseline.turnNumber !== undefined && state.turnNumber > baseline.turnNumber) ||
						(baseline.assistantCount !== undefined && state.assistantCount > baseline.assistantCount)));

			if (verdict.identityUnavailable && !warnedNoIdentity) {
				warnedNoIdentity = true;
				// Not fatal: the fallback keeps working on a page that exposes no message
				// ids. It is worth saying out loud because it means the stale-reply defence
				// is not in effect.
				this.lastIdentityWarning =
					"页面没有提供 data-message-id,无法用稳定身份判断新回复,已退回计数/轮号判据。" +
					"该判据在窗口滑动时可能取到上一轮回复(BUG-001)。";
			}

			if (isNewReply) {
				sawNewMessage = true;
				const current = normalizeForCompare(state.text);
				if (current !== lastText) {
					lastText = current;
					lastChangeAt = Date.now();
				}
				const stableFor = Date.now() - lastChangeAt;
				// The placeholder guard belongs here too. It was applied only on the
				// stall and timeout paths, so a brief gap in the stop button while the
				// page still showed "正在思考" satisfied every condition below and was
				// returned as a finished 4-character reply -- which then failed the
				// gate with "no plan text" and looked like a copy problem.
				const lifecycleUnobserved = !sawStreaming;
				// Never observed streaming -> demand 4x the quiet window. Short replies
				// that genuinely complete inside one polling gap still finish here,
				// just more conservatively; a mid-generation prefix does not.
				const requiredStability = lifecycleUnobserved ? stabilityMs * 4 : stabilityMs;
				if (
					!state.streaming &&
					// Quiet for the whole stability window, not just at this sample.
					Date.now() - lastStreamingAt >= requiredStability &&
					state.text.trim().length > 0 &&
					!isThinkingPlaceholder(state.text) &&
					stableFor >= requiredStability &&
					// With no lifecycle observation, require independent positive proof
					// that generation completed (the reply action bar mounts only after
					// the reply is done). If that signal itself drifts, the stall escape
					// hatch below still bounds the wait — we never hang forever.
					(sawStreaming || state.copyTurnFound === true)
				) {
					return state.text;
				}
				// Escape hatch: text has been frozen for a long time even though the
				// page still reports streaming. Return rather than hang forever --
				// unless the reply is visibly incomplete (an unclosed code fence),
				// in which case wait more, and only give up at the hard cap.
				if (state.text.trim().length > 0 && stableFor >= stallMs) {
					const placeholder = isThinkingPlaceholder(state.text);
					if (!placeholder && !looksIncomplete(state.text)) return state.text;
					// The hard cap must not return the placeholder. Observed live: a stalled
					// round returned "正在思考" -- four characters -- as the reply after exactly
					// this branch, which is the same defect the fast path was fixed for. The
					// guard held everywhere except the one path taken when the page is
					// genuinely stuck, and it also shadowed the timeout path below, which
					// already reports this correctly and never got the chance.
					if (stableFor >= stallMs * 4) {
						throw this.noReplyFailure(
							state.text,
							Date.now() - startedAt,
							await this.state().catch(() => null),
						);
					}
				}
			}

			if (elapsed > timeoutMs) {
				if (sawNewMessage && lastText.trim().length > 0) {
					throw this.noReplyFailure(lastText, elapsed, await this.state().catch(() => null));
				}
				throw new CdpError(`Timed out after ${Math.round(elapsed / 1000)}s waiting for a ChatGPT reply.`);
			}
			await sleep(pollIntervalMs(Date.now() - startedAt));
		}
	}

	/**
	 * The honest failure for "no reply arrived".
	 *
	 * Shared by the hard cap and the timeout so the two cannot disagree, and so the placeholder
	 * can never be returned from either. A caller that receives "正在思考" as the reply will
	 * treat it as the answer -- that is what the live round did, reporting a four-character
	 * plan. Reporting a failure is recoverable; returning a placeholder is not, because the
	 * caller cannot tell it apart from a short real answer.
	 */
	private noReplyFailure(text: string, elapsedMs: number, state: PageState | null): CdpError {
		if (isThinkingPlaceholder(text)) {
			return new CdpError(
				`ChatGPT 在 ${Math.round(elapsedMs / 1000)}s 内一直停在「正在思考」占位符,` +
					`没有产出任何正文。这种情况通常是因为标签页在后台被降级渲染` +
					`(visibilityState=${state?.visibility ?? "?"}),` +
					`或者提示词太大让它陷入长思考。可点击「停止回答」后换一个更聚焦的提示词。`,
			);
		}
		if (looksIncomplete(text)) {
			return new CdpError(
				`ChatGPT 的回复看起来被截断了(代码围栏未闭合,共 ${text.length} 字):` +
					`等待 ${Math.round(elapsedMs / 1000)}s 后仍是残缺状态。可点击「停止回答」后重发一个更聚焦的提示词。`,
			);
		}
		return new CdpError(`Timed out after ${Math.round(elapsedMs / 1000)}s waiting for a ChatGPT reply.`);
	}

	async readLastReply(): Promise {
		return (await this.state()).text;
	}

	/** Install the clipboard-capture hook (idempotent). */
	async installClipboardHook(): Promise {
		await this.page.evaluate(`(() => {
			if (window.__piGptBridge?.hooked) { window.__piGptBridge.last = null; return true; }
			const bridge = { hooked: true, last: null, source: null };
			window.__piGptBridge = bridge;

			try {
				const clipboard = navigator.clipboard;
				if (clipboard && typeof clipboard.writeText === 'function') {
					const original = clipboard.writeText.bind(clipboard);
					clipboard.writeText = (value) => {
						bridge.last = String(value ?? '');
						bridge.source = 'clipboard.writeText';
						return original(value);
					};
				}
				// ChatGPT uses the ClipboardItem form for some copy paths.
				if (clipboard && typeof clipboard.write === 'function') {
					const originalWrite = clipboard.write.bind(clipboard);
					clipboard.write = async (items) => {
						try {
							for (const item of items ?? []) {
								for (const type of item?.types ?? []) {
									if (type === 'text/plain') {
										const blob = await item.getType(type);
										bridge.last = await blob.text();
										bridge.source = 'clipboard.write(ClipboardItem)';
									}
								}
							}
						} catch { /* ignore */ }
						return originalWrite(items);
					};
				}
			} catch { /* ignore */ }

			try {
				const originalExec = document.execCommand.bind(document);
				document.execCommand = (command, ...rest) => {
					if (String(command).toLowerCase() === 'copy') {
						const selected = window.getSelection()?.toString();
						if (selected) {
							bridge.last = selected;
							bridge.source = 'execCommand.copy';
						}
					}
					return originalExec(command, ...rest);
				};
			} catch { /* ignore */ }

			document.addEventListener('copy', (event) => {
				try {
					const fromEvent = event.clipboardData?.getData('text/plain');
					if (fromEvent) {
						bridge.last = fromEvent;
						bridge.source = 'copy-event';
					}
				} catch { /* ignore */ }
			}, true);

			return true;
		})()`);
	}

	async readClipboardHook(): Promise<{ value: string | null; source: string | null }> {
		return this.page.evaluate<{ value: string | null; source: string | null }>(`(() => {
			const bridge = window.__piGptBridge;
			if (!bridge) return { value: null, source: null };
			return { value: bridge.last ?? null, source: bridge.source ?? null };
		})()`);
	}

	/** Copy sources visible in the newest turn (used by doctor and /brb sources). */
	/**
	 * Copy sources in the shape `DoctorReport` declares.
	 *
	 * It used to return `kind` and `isReply` while the only consumer read `nearCodeBlock`,
	 * so the "/代码块" marker in `doctor` output never appeared -- both halves were
	 * plausible and nothing compared them. Returning the declared shape removes the
	 * opportunity.
	 */
	async listCopyButtons(): Promise> {
		await this.hoverLastTurn();
		const report = await this.resolveCopySources();
		return report.sources.map((source) => ({
			label: source.label,
			nearCodeBlock: source.nearCodeBlock,
			scope: report.turnId ?? report.anchor,
		}));
	}

	/**
	 * Copy sources that belong to the NEWEST assistant turn.
	 *
	 * ChatGPT renders each turn as `
` * and puts the per-turn action bar *inside that section but outside the * message bubble*. Scoping to the newest turn is what makes a page with many * copy buttons safe: "复制消息" belongs to the user turn, "复制回复" to the * assistant turn, and every older turn keeps its own code-block copy button. */ async resolveCopySources(): Promise { const raw = await this.page.evaluate<{ anchor: string; turnId: string | null; replyPreview: string; replyText: string; buttons: Array<{ label: string; inCode: boolean; inTurnActionBar?: boolean; inWritingBlock?: boolean }>; codeBlocks: string[]; surface?: string; canvasCount?: number; }>(`(() => { ${COPY_SCOPE} return collectCopySources(); })()`); // Assembly happens here, not in the page script, so the rule is the one // copySources.ts tests offline. const buttons = raw.buttons ?? []; let codeSeen = 0; const sources = classifyCopyButtons(buttons).map((entry, index) => { const source = { index, label: buttons[index]?.label ?? "", kind: entry.kind, isReply: entry.isReply, nearCodeBlock: entry.nearCodeBlock, promoted: entry.promoted, codeOrdinal: null as number | null, expectedText: null as string | null, }; if (entry.kind === "code") { source.codeOrdinal = codeSeen; source.expectedText = raw.codeBlocks?.[codeSeen] ?? null; codeSeen += 1; } else if (entry.kind === "turn") { source.expectedText = raw.replyText || ""; } return source; }); // The page script returns a plain string; the report declares a union. Checked rather // than asserted, so an anchor this code does not know about reads as "none" instead of // being claimed as one of the known ones. const anchor = (COPY_ANCHORS as readonly string[]).includes(raw.anchor) ? (raw.anchor as CopySourcesReport["anchor"]) : "none"; // Surface is a page observation: an unknown value reads as "message" and no canvas, // which is the pre-BUG-052 behaviour rather than a claim the page never made. const surface: CopySourcesReport["surface"] = raw.surface === "canvas" ? "canvas" : "message"; return { anchor, turnId: raw.turnId, replyPreview: raw.replyPreview, replyText: raw.replyText, sources, surface, canvasCount: typeof raw.canvasCount === "number" ? raw.canvasCount : 0, }; } /** * Click one resolved copy source. * * Identity is checked by *kind*, not by the visible label: ChatGPT rewrites a * copy button's aria-label to "已复制" for a moment after it is clicked, so a * label comparison would spuriously fail on a second read. */ async clickCopySource( index: number, expectedKind?: CopySource["kind"], ): Promise<{ ok: boolean; error?: string; label?: string; kind?: string }> { const found = await this.page.evaluate<{ ok: boolean; error?: string; label?: string; kind?: string; x?: number; y?: number }>(`(() => { ${COPY_SCOPE} const { scope } = findNewestTurnScope(); if (!scope) return { ok: false, error: 'no_scope' }; // Same filters as the sources listing, or the indices would not line up: // the user bubble's 复制消息 is not a reply-copy source and never joins the // enumeration; canvas toolbar copies DO join it (same raw enumeration as the // listing), since for canvas replies they are first-class sources. const buttons = [...scope.querySelectorAll('button')] .filter(isCopyButton) .filter((btn) => !btn.closest('[class*="group/user-message"]')); const target = buttons[${index}]; if (!target) return { ok: false, error: 'index_out_of_range', count: buttons.length }; const label = describeButton(target); const isTurn = /copy-turn-action-button/i.test(label); // Same identity rules the host classification uses (BUG-052): structural facts // first (turn action bar, canvas), then label hints, then code. A canvas toolbar // button reads as 'canvas', not 'other' -- otherwise every canvas capture was // rejected as kind_mismatch and degraded to the DOM fallback. const inBar = inTurnActionBar(target); const inWb = !!target.closest('[class*="writing-block"]'); const kind = isTurn ? 'turn' : inBar ? 'turn' : /回复|reply|response/i.test(label) ? 'turn' : inWb ? (nearCode(target) ? 'code' : 'canvas') : nearCode(target) ? 'code' : 'other'; const expected = ${jsString(expectedKind ?? "")}; const kindOk = !expected || expected === kind || (expected === 'turn' && kind === 'other'); if (!kindOk) return { ok: false, error: 'kind_mismatch', label, kind }; // Drop any previously captured value first: a stale capture is // indistinguishable from a fresh one once it is read back. if (window.__piGptBridge) { window.__piGptBridge.last = null; window.__piGptBridge.source = null; } // The 2026-09 UI runs the copy handler only for trusted clicks; report the // coordinates and let the host do an Input-domain click (see trustedClick). target.scrollIntoView({ block: 'center' }); const r = target.getBoundingClientRect(); if (r.width === 0 || r.bottom < 0 || r.top > window.innerHeight) { return { ok: false, error: 'not_clickable', label, kind }; } return { ok: true, label, kind, x: r.x + r.width / 2, y: r.y + r.height / 2 }; })()`); if (found.ok && found.x !== undefined && found.y !== undefined) { await this.trustedClick(found.x, found.y); } return found; } /** * Choose which copy source to use inside the newest turn. * * `index` wins, then `label` (a regex), then the mode ordering: * auto -> code block, then the reply's turn button, then any turn button * code -> the code block's copy button only * turn -> the whole-reply copy button only */ private pickCopySource( sources: CopySource[], mode: CopyMode, options: { index?: number; label?: string } = {}, surface: "message" | "canvas" = "message", canvasCount = 0, replyTextLength = 0, ): { source: CopySource; index: number } | null { if (typeof options.index === "number") { const source = sources[options.index]; return source ? { source, index: options.index } : null; } if (options.label) { let regex: RegExp; try { regex = new RegExp(options.label, "i"); } catch { regex = new RegExp(options.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"); } const index = sources.findIndex((source) => regex.test(source.label)); return index >= 0 ? { source: sources[index], index } : null; } // A reply may carry several code blocks. A code-block copy button then // returns only that one block, so "auto" must fall back to the whole-turn // copy -- otherwise a plan spanning blocks is silently truncated. const codeBlockCount = sources.filter((source) => source.kind === "code").length; const singleCodeBlock = codeBlockCount === 1; // BUG-059 ruling: "exactly one code block" is not proof that the reply IS that // block. A lone block is taken automatically only when it dominates the reply // (>= 90% of its characters) AND the text around it is trivial (<= 200 chars); // otherwise the whole reply is the safer payload, because prose + one block was // measured live at 4194 chars total against a 2082-char block -- half the answer, // handed over without a word. Explicit `copyMode: "code"` is unaffected: asking // for the block is a different statement from guessing it. const loneCodeSource = singleCodeBlock ? sources.find((source) => source.kind === "code") : undefined; // The rule itself is a pure function (copySources.ts) so the thresholds are testable // without a browser -- this call site only supplies the two lengths. const codeDominates = loneCodeSource !== undefined && codeSourceDominates({ codeChars: loneCodeSource.expectedText?.length ?? 0, replyTextLength }); // Canvas replies choose by surface, not by the message rules (BUG-052): // single canvas -> the canvas toolbar copy first (structurally precise: its // payload is exactly that canvas), then the turn copy; // multi canvas -> the turn copy only, because a per-canvas button carries only // ONE document and the reply is all of them. Without a turn copy // there is no whole-reply source, and "auto" must fail closed // rather than silently hand back one document. // Code copies never enter the canvas auto chain: a canvas full of code is still // one reply, and picking a block would truncate it. const canvasPredicates: Array<(source: CopySource) => boolean> = surface === "canvas" ? canvasCount === 1 ? [(source) => source.kind === "canvas", (source) => source.kind === "turn" && source.isReply, (source) => source.kind === "turn"] : [(source) => source.kind === "turn" && source.isReply, (source) => source.kind === "turn"] : []; const predicates: Array<(source: CopySource) => boolean> = mode === "code" ? [(source) => source.kind === "code"] : mode === "turn" ? [ (source) => source.kind === "turn" && source.isReply, (source) => source.kind === "turn", ] : [ ...canvasPredicates, // auto: only prefer a code block when it is the only one AND it is what // the reply is made of (BUG-059). (source) => codeDominates && source.kind === "code", (source) => source.kind === "turn" && source.isReply, (source) => source.kind === "turn", () => true, ]; for (const predicate of predicates) { const index = sources.findIndex(predicate); if (index >= 0) return { source: sources[index], index }; } return null; } /** * Click the newest turn's one-click-copy button and return the Markdown it * placed on the clipboard. */ async copyLastPlan( mode: CopyMode = "auto", options: { index?: number; label?: string } = {}, ): Promise<{ text: string; via: string; source: string | null; turnId: string | null; anchor: string; label: string; candidates: CopySource[]; /** * Length of the whole reply on screen, so a caller can say how much of it the * payload represents (BUG-059 ruling: a code-block capture must name the total). */ replyTextLength: number; /** The chosen source was PROMOTED by the ambiguity rule (BUG-063): worth disclosing. */ promoted: boolean; }> { // The per-turn action bar is mounted lazily on hover, so a reply with no // code block has no copy button in the DOM until we nudge the turn. await this.hoverLastTurn(); const report = await this.resolveCopySources(); const picked = this.pickCopySource( report.sources, mode, options, report.surface, report.canvasCount, (report.replyText ?? "").length, ); const base = { turnId: report.turnId, anchor: report.anchor, candidates: report.sources, label: picked?.source.label ?? "", replyTextLength: (report.replyText ?? "").length, promoted: picked?.source.promoted ?? false, }; // Two different failures, named differently, because they lead somewhere different: // if no source was found the problem is identification, and `copyLabel` is the way out; // if a source was found and the click did nothing, the problem is interaction. // Reporting both as "no plan text" sent a real investigation down the copy path when the // reply was complete and on screen (BUG-003). let clipboardError: string | undefined; if (!picked) { // BUG-058: name the cause, and do not claim one the code has not established. // "没有定位到助手回复" (a selector/DOM-shape mismatch) and "这一轮没有复制控件" // are different investigations, and the old single sentence led with the wrong // one. The split is a pure function so it is testable offline (copySources.ts). clipboardError = describeCopyMiss({ anchor: report.anchor, replyTextLength: (report.replyText ?? "").length, sourceCount: report.sources.length, sourceLabels: report.sources.map((source) => `${source.kind}:${source.label}`).join(", "), surface: report.surface, canvasCount: report.canvasCount, }); } // The clipboard APIs only answer while the document has focus. An automation // browser that is not the foreground window is guaranteed to fail the write: // ChatGPT surfaces its own "无法复制" toast and the capture loop burns its // retries before reaching the DOM fallback that would have produced the same // text immediately. Detect it up front and skip the doomed path (BUG-021). const documentFocused = await this.page.evaluate(`document.hasFocus()`).catch(() => true); if (!documentFocused && (mode === "auto" || mode === "dom" || picked)) { const dom = await this.extractDomPlan( picked?.source.kind === "code" ? "code" : "message", picked?.source.codeOrdinal, ); if (dom) { return { ...base, text: normalizePlan(dom), via: `${base.label ? VIA_DOM_FALLBACK : VIA_DOM_ONLY} (browser unfocused)`, source: "dom", }; } } if (picked) { // Click-then-read is a read-modify-write on the OS clipboard, which is the // one resource here that is not per-conversation. Held as one critical // section so a concurrent thread cannot slip its own copy in between. const attempt = await this.clipboardMutex().run( async () => { const clicked = await this.clickCopySource(picked.index, picked.source.kind); if (!clicked.ok) return { clicked, captured: null as Awaited> | null }; const expectation = picked.source.expectedText ?? report.replyText ?? ""; const captured = await this.captureForSource( expectation, picked.source.kind, report.sources.some((candidate) => candidate.kind === "code"), report.surface, ); return { clicked, captured }; }, { purpose: `copy-plan:${this.target.id.slice(0, 8)}` }, ); const { clicked, captured } = attempt; if (clicked.ok && captured) { // `+clipboard` records that the value came through the clipboard hook without // naming its mechanism. Not a degradation -- authorship was verified against this // source's own text -- but "an unnamed clipboard" is a different claim from // "read through source X", so the tag does not pretend they are the same. const via = copyViaTag(report.turnId, picked.source.kind, picked.source.isReply, captured.source === null); if (captured.text) { return { ...base, text: normalizePlan(captured.text), via, source: captured.source ?? "navigator.clipboard.readText", }; } clipboardError = captured.error; } else { clipboardError = `click failed: ${clicked.error ?? "unknown"}`; } } // Degrade to reading the DOM rather than losing the plan entirely. The // requested mode still decides which copy source we clicked; it only // relaxes how the text is finally obtained. if (mode === "auto" || mode === "dom" || picked) { const dom = await this.extractDomPlan( picked?.source.kind === "code" ? "code" : "message", picked?.source.codeOrdinal, ); if (dom) { return { ...base, text: normalizePlan(dom), via: `${base.label ? VIA_DOM_FALLBACK : VIA_DOM_ONLY}${clipboardError ? " (clipboard unavailable)" : ""}`, source: "dom", }; } } throw new CdpError( `Could not copy from the newest turn${report.turnId ? ` (${report.turnId})` : ""}. ` + `${report.surface === "canvas" ? `Surface: canvas x${report.canvasCount}. ` : ""}` + `Found ${report.sources.length} copy source(s): ${report.sources.map((s) => `${s.kind}:${s.label}`).join(", ") || "none"}. ` + `Clipboard: ${clipboardError ?? "n/a"}. Run /brb sources to inspect the page.`, ); } /** * Last resort: read the plan straight out of the DOM. * * `prefer` mirrors the copy source that was chosen, so a degraded read still * returns the same *kind* of content the caller asked for. */ async extractDomPlan( prefer: "code" | "message" = "message", codeOrdinal?: number | null, ): Promise { return this.page.evaluate(`(() => { ${COPY_SCOPE} // Spread first: querySelectorAll returns a NodeList, which has no reduce. const nodes = [...${ASSISTANT_NODES}]; if (nodes.length === 0) return null; // The same rule the primary copy path uses: the assistant message inside the // highest conversation-turn-N, not the last node in document order. The // primary path was corrected for BUG-001's family and this fallback was not, // so it could hand back the previous reply on exactly the path a caller // reaches after the primary one fails. const __turnOf = (el) => { const section = el.closest('[data-testid^="conversation-turn"]'); if (!section) return -1; const m = /conversation-turn-([0-9]+)/.exec(section.getAttribute('data-testid') || ''); return m ? Number(m[1]) : -1; }; const last = nodes.reduce((best, c) => (__turnOf(c) >= __turnOf(best) ? c : best), nodes[0]); // New UI: the node already IS the assistant scope, but a canvas reply is ONE reply // across possibly several canvases -- so the fallback reads every canvas body of // the newest exchange. Returning just the last canvas node would silently hand // back one document of a two-document reply (BUG-052). if (!__legacyUi()) { const units = __newTurnRoots(); const unit = units.find((u) => u === last || u.contains(last)) || last; const canvases = __canvasBodiesOf(unit); if (canvases.length) { if (${jsString(prefer)} === 'code') { const wanted = ${typeof codeOrdinal === "number" ? codeOrdinal : "null"}; const pres = collectCodeBlocks(unit); if (wanted !== null && pres[wanted]) return pres[wanted].innerText; const longest = pres.map((p) => p.innerText || '').sort((a, b) => b.length - a.length)[0]; if (longest) return longest; } return canvases.join(String.fromCharCode(10, 10)); } } // Legacy UI (and any future shape without canvases): the assistant node's own // section, with parentElement as the last resort. const scope = last.closest('[data-testid^="conversation-turn"]') || (__legacyUi() ? (last.parentElement || last) : last); const pres = collectCodeBlocks(scope); const wanted = ${typeof codeOrdinal === "number" ? codeOrdinal : "null"}; if (${jsString(prefer)} === 'code') { if (wanted !== null && pres[wanted]) return pres[wanted].innerText; const longest = pres.map((p) => p.innerText || '').sort((a, b) => b.length - a.length)[0]; if (longest) return longest; } return last.innerText || (pres[0] ? pres[0].innerText : null); })()`); } /** Force React to mount the last turn's hover-only action bar. */ async hoverLastTurn(): Promise { await this.page .evaluate(`(() => { ${TURN_MODEL} const nodes = ${ASSISTANT_NODES}; const last = nodes[nodes.length - 1]; if (!last) return false; const scope = last.closest('article') || (__legacyUi() ? (last.parentElement || last) : last); const options = { bubbles: true, cancelable: true, view: window }; for (const type of ['pointerover', 'pointerenter', 'mouseover', 'mouseenter', 'mousemove']) { try { scope.dispatchEvent(new MouseEvent(type, options)); } catch { /* ignore */ } try { last.dispatchEvent(new MouseEvent(type, options)); } catch { /* ignore */ } } return true; })()`) .catch(() => undefined); await sleep(350); } async readRealClipboard(retries = 3): Promise<{ text: string | null; error?: string }> { let lastError: string | undefined; for (let attempt = 0; attempt < retries; attempt += 1) { try { await this.bringToFront(); const value = await this.page.evaluate("navigator.clipboard.readText()"); if (value && value.trim().length > 0) return { text: value }; lastError = "clipboard is empty"; } catch (error) { lastError = error instanceof Error ? error.message : String(error); } await sleep(300); } return { text: null, error: lastError }; } /** * Read whatever the copy button produced, accepting only content that * actually matches the chosen source. * * Both routes are polled, because ChatGPT writes to the clipboard * asynchronously: the first read after a click often still sees the previous * value. Content is checked against `expectedText` (this source's own DOM * text), not the whole reply. */ private async captureForSource( expectedText: string, kind: "code" | "turn" | "canvas" | "other", replyHasCodeSource: boolean, surface: "message" | "canvas" = "message", ): Promise<{ text: string | null; source: string | null; error?: string }> { // Canvas payloads get the stricter whole-surface check: looksLikeReply accepts a // short placeholder that happens to sit inside the reply (BUG-052), which is // exactly what a canvas-adjacent copy button can produce. The ordinary path's // validation is NOT relaxed for canvas -- it is the message path that stays as it // was, and this is the additional rule canvas adds. const accepts = (candidate: string) => surface === "canvas" ? matchesCanvasSource(candidate, expectedText) : matchesSource(candidate, expectedText, kind === "canvas" ? "turn" : kind, replyHasCodeSource); let lastError: string | undefined; let lastSeen: string | null = null; for (let attempt = 0; attempt < 5; attempt += 1) { const captured = await this.readClipboardHook().catch(() => ({ value: null, source: null })); if (captured.value && captured.value.trim().length > 0) { lastSeen = captured.value; if (accepts(captured.value)) { return { text: captured.value, source: captured.source }; } } const real = await this.readRealClipboard(1); if (real.text) { lastSeen = real.text; if (accepts(real.text)) { return { text: real.text, source: "navigator.clipboard.readText" }; } } else if (real.error) { lastError = real.error; } await sleep(300); } return { text: null, source: null, error: lastSeen !== null ? `clipboard held ${lastSeen.length} chars that do not match this copy source` : (lastError ?? "clipboard empty"), }; } bringToFront(): Promise { return this.page.bringToFront().catch(() => undefined); } /** * Set when a read had to fall back to a weaker "is this reply new" predicate. * * Declared rather than assigned on the fly: assigning an undeclared property silently * works in JavaScript, so the value was being written and never read -- the state it * was supposed to surface stayed empty. A type checker found it; nothing else would * have, because the assignment does not throw. */ private lastIdentityWarning: string | null = null; /** Lazily created so the adapter stays constructible without touching disk. */ private mutexInstance: BrowserMutex | null = null; private clipboardMutex(): BrowserMutex { this.mutexInstance ??= new BrowserMutex({}); return this.mutexInstance; } /* ---------------- recovery primitives ---------------- */ /** * Read the recent control messages back out of the conversation. * * Thin on purpose: it only extracts turn texts and delegates everything with * logic in it to parseProtocolFrames(), which is pure and offline-tested. * * Only the last few turns are scanned. Pulling the whole thread would put the * DOM under exactly the load that wedged the renderer earlier. */ /** * Read protocol frames out of the conversation. * * Patient when an ACK is required. A single read was enough to conclude "the * other side never answered" when the answer was only not on screen yet: a * background tab renders slowly, and two tabs competing for focus is enough to * miss it. Under concurrency that showed up as an empty capability hash, which * reads like a contract mismatch rather than a timing problem. */ async discoverProtocolFrames( limitTurns = 12, options: { timeoutMs?: number; requireAck?: boolean } = {}, ): Promise { const deadline = Date.now() + (options.timeoutMs ?? (options.requireAck ? 45_000 : 0)); for (;;) { const discovery = await this.readProtocolFrames(limitTurns); if (!options.requireAck) return discovery; // The ACK arrives token by token. Its marker is on screen while the line is // still incomplete, so a parsed-but-truncated ACK is worse than none at all: // a half-written sid reads as a contract mismatch rather than as "not yet". // Wait for the page to stop generating before believing what was read. if (discovery.ackSeen && !(await this.isGenerating())) return discovery; if (Date.now() >= deadline) return discovery; await new Promise((resolve) => setTimeout(resolve, 1200)); } } /** True while the page is still producing text (stop button present). */ private async isGenerating(): Promise { return await this.state() .then((state) => state.streaming) .catch(() => false); } private async readProtocolFrames(limitTurns = 12): Promise { const turns = await this.page .evaluate>(`(() => { const all = [...document.querySelectorAll('[data-testid^="conversation-turn"]')]; return all.slice(-${limitTurns}).map((turn) => ({ turnId: turn.getAttribute('data-testid') || undefined, text: turn.innerText || '', })); })()`) .catch(() => []); return parseProtocolFrames(turns); } /** Interrupt an in-flight generation. Used by the stall ladder. */ async pressStop(): Promise { const point = await this.page .evaluate<{ x: number; y: number } | null>(`(() => { const button = document.querySelector('[data-testid="stop-button"]') || document.querySelector('button[aria-label*="停止"]') || document.querySelector('button[aria-label*="Stop"]'); if (!button) return null; button.scrollIntoView({ block: 'center' }); const r = button.getBoundingClientRect(); if (r.width === 0 || r.bottom < 0 || r.top > window.innerHeight) return null; return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; })()`) .catch(() => null); if (!point) return false; await this.trustedClick(point.x, point.y); return true; } /** * Reload the tab. * * Cheap and lossless here: the conversation lives on the server, so a reload * only discards a wedged renderer. */ async reload(): Promise { await this.page.send("Page.reload", { ignoreCache: false }).catch(() => undefined); await sleep(2500); await this.waitForEditor(20_000).catch(() => false); } /** Open a brand new conversation and return a page bound to it. */ /* ---------------- model / reasoning control ---------------- */ /** * Open the model switcher. * * Two different buttons exist depending on the view: the composer-less landing * page uses `[data-testid="model-switcher-dropdown-button"]`, while a * conversation view renders an unnamed button whose only handle is its * aria-label ("切换模型" / "Switch model"). The menu is a Radix portal, so a * synthetic `.click()` does not open it -- it needs a full pointer sequence, * the same trick the hover-only action bar needs. */ async openModelMenu(): Promise<{ opened: boolean; how: string }> { const found = await this.page.evaluate(`(() => { ${MODEL_UI} const button = findModelSwitcher(); if (!button) return 'missing'; openWithPointer(button); return describeButton(button); })()`); if (found === "missing") return { opened: false, how: "model switcher not found" }; await sleep(1200); return { opened: true, how: found }; } async closeMenus(): Promise { await this.page.evaluate(`(() => { document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); return true; })()`).catch(() => undefined); await sleep(300); } /** * List what the switcher actually offers. * * This is the discovery step: the menu markup is not part of any stable API, * so the bridge reports what it sees instead of assuming option names. */ async listModels(): Promise<{ items: Array<{ label: string; selected: boolean; role: string }>; how: string }> { const opened = await this.openModelMenu(); if (!opened.opened) return { items: [], how: opened.how }; const items = await this.page.evaluate>(`(() => { ${MODEL_UI} return menuItems().map((el) => ({ label: labelOf(el), selected: el.getAttribute('aria-checked') === 'true' || el.getAttribute('data-state') === 'checked', role: el.getAttribute('role') || 'unknown', })); })()`); await this.closeMenus(); return { items, how: opened.how }; } /** * Select a model by (case-insensitive) substring or regex. * * Returns what it matched so the caller can verify rather than assume. */ async setModel(match: string): Promise<{ ok: boolean; matched: string | null; available: string[] }> { const opened = await this.openModelMenu(); if (!opened.opened) return { ok: false, matched: null, available: [] }; const result = await this.page.evaluate<{ ok: boolean; matched: string | null; available: string[] }>(`(() => { ${MODEL_UI} const items = menuItems(); const available = items.map(labelOf); let regex; try { regex = new RegExp(${jsString(match)}, 'i'); } catch { regex = null; } const wanted = items.find((el) => { const text = labelOf(el); return regex ? regex.test(text) : text.toLowerCase().includes(${jsString(match.toLowerCase())}); }); if (!wanted) return { ok: false, matched: null, available }; openWithPointer(wanted); return { ok: true, matched: labelOf(wanted), available }; })()`); await sleep(800); await this.closeMenus(); return result; } /** * Pick a reasoning-effort option. * * ChatGPT exposes this either as a nested submenu off the model switcher or as * a sibling control, and the wording differs per model, so this searches both * and reports what it found instead of hard-coding levels. */ async setReasoningEffort(match: string): Promise<{ ok: boolean; matched: string | null; available: string[] }> { const opened = await this.openModelMenu(); if (!opened.opened) return { ok: false, matched: null, available: [] }; // Step 1: open a nested "thinking / 思考" submenu if one exists. const submenu = await this.page.evaluate(`(() => { ${MODEL_UI} const trigger = menuItems().find((el) => /思考|thinking|推理|reasoning|强度|effort/i.test(labelOf(el))); if (!trigger) return null; openWithPointer(trigger); return labelOf(trigger); })()`); if (submenu) await sleep(900); // Step 2: pick the level. const result = await this.page.evaluate<{ ok: boolean; matched: string | null; available: string[] }>(`(() => { ${MODEL_UI} const items = menuItems(); const available = items.map(labelOf); let regex; try { regex = new RegExp(${jsString(match)}, 'i'); } catch { regex = null; } const wanted = items.find((el) => { const text = labelOf(el); return regex ? regex.test(text) : text.toLowerCase().includes(${jsString(match.toLowerCase())}); }); if (!wanted) return { ok: false, matched: null, available }; openWithPointer(wanted); return { ok: true, matched: labelOf(wanted), available }; })()`); await sleep(600); await this.closeMenus(); return result; } /** Best-effort: which model is currently selected. */ async currentModel(): Promise { return this.page .evaluate(`(() => { ${MODEL_UI} const button = findModelSwitcher(); if (!button) return null; const text = (button.innerText || '').trim(); return text.length > 0 ? text.split(String.fromCharCode(10))[0] : null; })()`) .catch(() => null); } async doctor(): Promise { const problems: string[] = []; // O1 (verdict #38): observations that are not defects. Kept separate from problems so a // healthy page does not make the whole diagnostic read as a warning. const notes: string[] = []; let version: Awaited> | null = null; let targets: CdpTarget[] = []; try { version = await cdpVersion(); targets = await listTargets(); } catch (error) { return { cdpOk: false, targets: [], copyButtons: [], clipboardHook: false, problems: [ `Cannot reach Chrome DevTools on port ${DEFAULT_CDP_PORT}: ${String(error)}`, "Fix: run /brb launch (starts the automation browser with the debug port).", ], notes: [], }; } const report: DoctorReport = { cdpOk: true, browser: version?.Browser, targets: targets.map((t) => ({ id: t.id, url: t.url, title: t.title })), copyButtons: [], clipboardHook: false, problems, notes, }; report.pageUrl = this.target.url; try { const state = await this.state(); report.pageUrl = state.url; report.pageTitle = state.title; report.composerFound = state.composerFound; report.sendButtonFound = state.sendButtonFound; report.assistantCount = state.assistantCount; report.stopButtonFound = state.streaming; report.loggedOut = state.loggedOut; const pageState = await this.observePageState(); report.pageState = pageState.status; report.loggedIn = pageState.status === "READY" || pageState.status === "AUTHENTICATED_NOT_READY"; if (pageState.status === "AUTH_REQUIRED") { problems.push( "这个自动化浏览器未登录 ChatGPT。请在自动化窗口登录一次,或切换 loginMode=existing 复用已有浏览器登录态。", ); } else if (pageState.status === "UNREACHABLE") { problems.push("无法访问 ChatGPT 页面。请先检查网络或代理连接,然后重试。"); } else if (pageState.status === "AUTHENTICATED_NOT_READY") { problems.push("ChatGPT 已登录,但页面还没有准备好。请等待页面加载完成后重试;如果持续出现,可运行 /brb doctor 查看状态。"); } else if (pageState.status === "UNKNOWN") { problems.push("BRB 无法确认当前 ChatGPT 页面状态,本次操作已停止。请运行 /brb doctor 查看详细状态。"); } // 平台边界(C 轮 C3/C4):页面上真实存在的阻断必须在「问题」里显式上报, // 否则 doctor 会把“页面看起来正常但下一步必然失败”报成健康。 if (pageState.observation.platformBlock) { problems.push(platformBlockMessage(pageState.observation.platformBlock, "执行远端操作")); } if (!state.sendButtonFound) { notes.push("发送按钮未找到(仅在 trusted Enter 回退也失败时才有影响)——submit 主路径用 trusted Enter。"); } report.copyButtons = await this.listCopyButtons(); if (report.copyButtons.length === 0) { problems.push("No copy button found in the last reply — /brb copy will fall back to DOM text."); } await this.installClipboardHook(); const captured = await this.readClipboardHook(); report.clipboardHook = captured.source !== null ? true : (await this.page.evaluate( "!!window.__piGptBridge?.hooked", )); } catch (error) { problems.push(`Page probe failed: ${String(error)}`); } return report; } } /* ------------------------------------------------------------------ */ /* Target discovery */ /* ------------------------------------------------------------------ */ export async function findChatGptTarget( port = DEFAULT_CDP_PORT, options: { url?: string; conversationId?: string; targetId?: string; signal?: AbortSignal } = {}, ): Promise { const all = await listTargets(port, options.signal); if (options.targetId) { return all.find((t) => t.id === options.targetId) ?? null; } const targets = all.filter( (t) => t.type === "page" && typeof t.url === "string" && t.url.startsWith(CHATGPT_URL_PREFIX), ); if (targets.length === 0) return null; if (options.url) { const wanted = normalizeUrl(options.url); const exact = targets.find((t) => normalizeUrl(t.url) === wanted); if (exact) return exact; const id = conversationIdOf(options.url); if (id) { const byId = targets.find((t) => t.url.includes(id)); if (byId) return byId; } return null; } if (options.conversationId) { const byId = targets.find((t) => t.url.includes(options.conversationId!)); if (byId) return byId; } // Prefer a real conversation over the bare / landing page. const conversation = targets.find((t) => /\/c\/[0-9a-f-]{8,}/i.test(t.url)); return conversation ?? targets[0]; } // Single implementation lives in identity.ts (verdict #11). Re-exported so every // existing importer of chatgpt.ts keeps a working name while copies stay impossible. import { conversationIdOf } from "../core/identity.ts"; import { extractPrincipalFromKeys } from "./identity-principal.ts"; export { conversationIdOf }; // single implementation lives in identity.ts (verdict #11) export function normalizeUrl(url: string): string { try { const parsed = new URL(url); parsed.hash = ""; parsed.search = ""; return parsed.toString().replace(/\/+$/, ""); } catch { return url.replace(/\/+$/, ""); } } /** * Is `candidate` plausibly the text of `reply`? * * The real clipboard is a shared, user-owned resource. If the copy button never * reached it, `navigator.clipboard.readText()` happily returns whatever the user * last copied — which would be silently reported as "the plan". So any clipboard * read must prove it came from this reply before we trust it. */ /** * Does `candidate` plausibly come from *this* copy source? * * `looksLikeReply` alone cannot separate a code-block source from the whole-turn * source when the reply is a single code block: the DOM text of both is the same * string, and the only difference is that the turn copy wraps it in a Markdown * fence. So fence presence is the discriminator. */ /** * Does this reply look like it stopped mid-flight? * * The stall escape hatch exists because a page can report "streaming" forever. * But returning whatever text happens to be on screen can hand back a fragment: * observed live, a long prompt left ChatGPT showing only "Markdown # chat" for * minutes, with the fenced block never closed. An odd number of code fences is a * cheap, reliable signal that the reply is still incomplete. */ export function looksIncomplete(text: string): boolean { const fences = text.match(/```|~~~/g); if (fences && fences.length % 2 === 1) return true; return false; } /** * Is this the "Thinking…" placeholder rather than actual reply text? * * Observed live: a long prompt left the newest assistant node containing the * 4-character string "正在思考" while the page still reported streaming. Without * this check that placeholder gets accepted as a finished reply, and the caller * is handed 4 characters where a plan should be. Matching the whole (trimmed) * string avoids rejecting a genuinely short reply such as "收到". */ export function isThinkingPlaceholder(text: string): boolean { const trimmed = text.trim(); if (trimmed.length === 0 || trimmed.length > 24) return false; return /^(正在思考|思考中|思考\.{0,3}|Thinking\.{0,3}|正在生成|生成中|Analyzing\.{0,3})$/i.test(trimmed); } export function matchesSource( candidate: string, expected: string, kind: "code" | "turn" | "other", replyHasCodeSource: boolean, ): boolean { const hasFence = /```|~~~/.test(candidate); if (kind === "code" && hasFence) return false; if (kind === "turn" && replyHasCodeSource && !hasFence) return false; return looksLikeReply(candidate, expected); } /** * Whole-surface validation for canvas captures (BUG-052). * * The author of this rule is the canvas: ChatGPT renders the reply into a writing * block, and the clipboard payload can be either that document's markdown (toolbar copy) * or all canvases joined (turn copy). `looksLikeReply` is too permissive here -- a short * placeholder that merely sits inside the document would pass its "contains" window. So * acceptance needs BOTH a length floor relative to the expected text and agreement at the * head, middle and tail: a fragment or a different document fails all three at once. * * Normalisation strips what the transport adds, never what the content says: line * endings, NBSP, fence language tags, leading heading marks and inline markers. The same * normalisation is applied to both sides, so matching stays content-faithful. */ export function matchesCanvasSource(candidate: string, expected: string): boolean { if (typeof candidate !== "string" || typeof expected !== "string") return false; const norm = (value: string): string => value .replace(/\r\n?/g, "\n") .replace(/\u00a0/g, " ") .replace(/```[a-zA-Z0-9_-]*/g, "") .replace(/^[ \t]*#{1,6}[ \t]+/gm, "") .replace(/[*_`>]/g, "") .replace(/\s+/g, " ") .trim(); const c = norm(candidate); const e = norm(expected); if (c.length === 0 || e.length === 0) return false; // Length floor first: a stub, a toast or a single block cannot claim a document. The // floor also gates the containment shortcuts below, so "c is a short prefix of e" // cannot pass as the whole surface. if (c.length < Math.max(24, Math.floor(e.length * 0.6))) return false; if (c.includes(e) || e.includes(c)) return true; // Head, middle and tail windows must all be present. For short documents the windows // overlap, which only makes the check stricter than expected, never weaker. const size = Math.min(48, Math.max(24, Math.floor(e.length / 8))); const starts = [0, Math.floor((e.length - size) / 2), Math.max(0, e.length - size)]; return starts.every((start) => c.includes(e.slice(start, start + size))); } export function looksLikeReply(candidate: string, reply: string): boolean { // BUG-022: an "other" source carries no expectedText of its own and falls // back to report.replyText, which can be undefined on a freshly-mounted // short reply. That undefined flowed into strip() and crashed the whole // copy path with "reading 'replace'" instead of degrading to DOM. if (typeof candidate !== "string" || typeof reply !== "string") return false; const strip = (value: string) => value .replace(/```[a-zA-Z0-9_-]*/g, "") .replace(/[\s ]+/g, "") .trim(); const a = strip(candidate); const b = strip(reply); if (a.length === 0 || b.length === 0) return false; if (a === b || b.includes(a) || a.includes(b)) return true; // Markdown copy buttons add fences / escaping, so compare a few windows. const size = Math.min(24, Math.max(8, Math.floor(a.length / 4))); const starts = [0, Math.floor((a.length - size) / 2), Math.max(0, a.length - size)]; return starts.some((start) => b.includes(a.slice(start, start + size))); } export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }