ask-pro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.codex-plugin/plugin.json +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +231 -0
  4. package/assets/ask-pro_logo.png +0 -0
  5. package/dist/bin/ask-pro-cli.js +507 -0
  6. package/dist/scripts/run-cli.js +27 -0
  7. package/dist/src/ask-pro/atomicWrite.js +26 -0
  8. package/dist/src/ask-pro/browserRunner.js +796 -0
  9. package/dist/src/ask-pro/responseZip.js +349 -0
  10. package/dist/src/ask-pro/session.js +662 -0
  11. package/dist/src/ask-pro/sessionControllerLease.js +64 -0
  12. package/dist/src/ask-pro/toon.js +26 -0
  13. package/dist/src/ask-pro/zip.js +85 -0
  14. package/dist/src/browser/actions/assistantResponse.js +1245 -0
  15. package/dist/src/browser/actions/attachmentDataTransfer.js +140 -0
  16. package/dist/src/browser/actions/attachments.js +1720 -0
  17. package/dist/src/browser/actions/composerSendReadiness.js +369 -0
  18. package/dist/src/browser/actions/domEvents.js +31 -0
  19. package/dist/src/browser/actions/inputGuard.js +52 -0
  20. package/dist/src/browser/actions/modelPickerDom.js +68 -0
  21. package/dist/src/browser/actions/modelSelection.js +576 -0
  22. package/dist/src/browser/actions/navigation.js +510 -0
  23. package/dist/src/browser/actions/promptComposer.js +824 -0
  24. package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
  25. package/dist/src/browser/actions/thinkingStatus.js +408 -0
  26. package/dist/src/browser/actions/thinkingTime.js +635 -0
  27. package/dist/src/browser/actions/windowState.js +47 -0
  28. package/dist/src/browser/attachRunning.js +31 -0
  29. package/dist/src/browser/chatgptModelCatalog.js +321 -0
  30. package/dist/src/browser/chromeLifecycle.js +807 -0
  31. package/dist/src/browser/config.js +110 -0
  32. package/dist/src/browser/constants.js +85 -0
  33. package/dist/src/browser/cookies.js +191 -0
  34. package/dist/src/browser/detect.js +337 -0
  35. package/dist/src/browser/domDebug.js +72 -0
  36. package/dist/src/browser/errors.js +20 -0
  37. package/dist/src/browser/format.js +16 -0
  38. package/dist/src/browser/index.js +2631 -0
  39. package/dist/src/browser/language.js +97 -0
  40. package/dist/src/browser/liveTabs.js +434 -0
  41. package/dist/src/browser/modelStrategy.js +13 -0
  42. package/dist/src/browser/pageActions.js +5 -0
  43. package/dist/src/browser/profilePaths.js +282 -0
  44. package/dist/src/browser/profileState.js +413 -0
  45. package/dist/src/browser/providerDomFlow.js +17 -0
  46. package/dist/src/browser/providers/chatgptDomProvider.js +50 -0
  47. package/dist/src/browser/reattach.js +534 -0
  48. package/dist/src/browser/reattachHelpers.js +387 -0
  49. package/dist/src/browser/utils.js +122 -0
  50. package/dist/src/browserMode.js +1 -0
  51. package/dist/src/version.js +39 -0
  52. package/package.json +114 -0
  53. package/scripts/refresh-local-plugin.mjs +179 -0
  54. package/scripts/refresh-local-plugin.ps1 +93 -0
  55. package/skills/ask-pro/SKILL.md +181 -0
@@ -0,0 +1,2631 @@
1
+ import { mkdtemp, rm, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import net from "node:net";
5
+ import CDP from "chrome-remote-interface";
6
+ import { DEFAULT_BROWSER_CONFIG, resolveBrowserConfig } from "./config.js";
7
+ import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeChromeGracefully, listRemoteChromeTargets, maybeReuseRunningChrome, releaseChromeProcessHandle, restoreChromeWindowByPid, shouldLaunchChromeMinimized, } from "./chromeLifecycle.js";
8
+ import { syncCookies } from "./cookies.js";
9
+ import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
10
+ import { INPUT_SELECTORS } from "./constants.js";
11
+ import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js";
12
+ import { ensureThinkingTime } from "./actions/thinkingTime.js";
13
+ import { startThinkingStatusMonitor } from "./actions/thinkingStatus.js";
14
+ import { createAssistantContinuation } from "./actions/assistantResponse.js";
15
+ import { createPostSubmitInputGuard } from "./actions/inputGuard.js";
16
+ import { isChromeWindowMinimized, setChromeWindowState } from "./actions/windowState.js";
17
+ import { estimateTokenCount, withRetries, delay } from "./utils.js";
18
+ import { formatElapsed } from "./format.js";
19
+ import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from "./constants.js";
20
+ import { AssistantStoppedError, BrowserAutomationError } from "./errors.js";
21
+ import { defaultAskProBrowserProfileDir } from "./profilePaths.js";
22
+ import { applyPageLanguageOverrides, seedChromeProfileLanguage } from "./language.js";
23
+ import { alignPromptEchoPair, buildPromptEchoMatcher, withTimeout } from "./reattachHelpers.js";
24
+ import { cleanupStaleProfileState, acquireProfileRunLock, createManagedChromeRunLease, releaseManagedChromeRunLeaseAndCountPeers, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "./profileState.js";
25
+ import { runProviderSubmissionFlow } from "./providerDomFlow.js";
26
+ import { chatgptDomProvider } from "./providers/chatgptDomProvider.js";
27
+ import { resolveAttachRunningConnection } from "./attachRunning.js";
28
+ import { connectToExistingChatGptTab } from "./liveTabs.js";
29
+ export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js";
30
+ export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from "./utils.js";
31
+ export { formatThinkingLog, formatThinkingWaitingLog, buildThinkingStatusExpressionForTest, readThinkingStatusForTest, sanitizeThinkingText, startThinkingStatusMonitorForTest, } from "./actions/thinkingStatus.js";
32
+ function redactBrowserConfigForDebugLog(config) {
33
+ const redacted = { ...config };
34
+ if (Array.isArray(config.inlineCookies)) {
35
+ redacted.inlineCookies = `[redacted:${config.inlineCookies.length} cookies]`;
36
+ redacted.inlineCookieCount = config.inlineCookies.length;
37
+ }
38
+ return redacted;
39
+ }
40
+ function isHumanInterventionError(error) {
41
+ if (!(error instanceof BrowserAutomationError))
42
+ return false;
43
+ const stage = error.details?.stage;
44
+ return stage === "login-required" || stage === "cloudflare-challenge";
45
+ }
46
+ function shouldPreserveBrowserOnError(error, headless) {
47
+ return !headless && isHumanInterventionError(error);
48
+ }
49
+ export function shouldPreserveBrowserOnErrorForTest(error, headless) {
50
+ return shouldPreserveBrowserOnError(error, headless);
51
+ }
52
+ function hasBrowserErrorCode(error, code) {
53
+ return (error instanceof BrowserAutomationError &&
54
+ error.details?.code === code);
55
+ }
56
+ async function runSubmissionWithRecovery({ prompt, attachments, fallbackSubmission, submit, reloadPromptComposer, prepareFallbackSubmission, logger, }) {
57
+ let currentPrompt = prompt;
58
+ let currentAttachments = attachments;
59
+ let retriedDeadComposer = false;
60
+ let usedFallbackSubmission = false;
61
+ while (true) {
62
+ try {
63
+ return await submit(currentPrompt, currentAttachments);
64
+ }
65
+ catch (error) {
66
+ const isPromptTooLarge = hasBrowserErrorCode(error, "prompt-too-large");
67
+ const isDeadComposer = hasBrowserErrorCode(error, "dead-composer");
68
+ if (isDeadComposer && !retriedDeadComposer) {
69
+ retriedDeadComposer = true;
70
+ await reloadPromptComposer();
71
+ continue;
72
+ }
73
+ if (fallbackSubmission && !usedFallbackSubmission && isPromptTooLarge) {
74
+ usedFallbackSubmission = true;
75
+ logger("[browser] Inline prompt too large; retrying with file uploads.");
76
+ await prepareFallbackSubmission();
77
+ currentPrompt = fallbackSubmission.prompt;
78
+ currentAttachments = fallbackSubmission.attachments;
79
+ continue;
80
+ }
81
+ throw error;
82
+ }
83
+ }
84
+ }
85
+ export async function runSubmissionWithRecoveryForTest(args) {
86
+ return runSubmissionWithRecovery(args);
87
+ }
88
+ function listIgnoredRemoteChromeFlags(config) {
89
+ return [
90
+ config.headless ? "--browser-headless" : null,
91
+ config.hideWindow ? "--browser-hide-window" : null,
92
+ config.keepBrowser ? "--browser-keep-browser" : null,
93
+ !config.attachRunning && config.chromePath ? "--browser-chrome-path" : null,
94
+ ].filter((value) => Boolean(value));
95
+ }
96
+ function shouldEnablePostSubmitInputGuard(config) {
97
+ return !config.remoteChrome && !config.browserTabRef;
98
+ }
99
+ function decideManagedChromeCleanup(config) {
100
+ if (config.connectionClosedUnexpectedly)
101
+ return "connection-lost";
102
+ if (config.keepBrowserOpen ||
103
+ config.peerRunCount === null ||
104
+ config.peerRunCount > 0 ||
105
+ config.remainingTargets === null ||
106
+ shouldRetainLaunchedChromeAfterRun(config.remainingTargets, config.completedRunTargetId)) {
107
+ return "retain-browser";
108
+ }
109
+ return "close-browser";
110
+ }
111
+ function shouldCaptureLaunchTargetsForCleanup(config) {
112
+ return !config.reusedChrome;
113
+ }
114
+ function shouldRetainLaunchedChromeAfterRun(targets, completedRunTargetId) {
115
+ return targets.some((target) => {
116
+ const targetId = target.targetId ?? target.id;
117
+ return Boolean(targetId && targetId !== completedRunTargetId && target.type === "page");
118
+ });
119
+ }
120
+ function isDisposableLaunchPageUrl(url) {
121
+ const normalized = (url ?? "").trim().toLowerCase();
122
+ return (normalized === "about:blank" ||
123
+ /^[a-z][a-z0-9+.-]*:\/\/newtab\/$/.test(normalized) ||
124
+ normalized === "chrome://new-tab-page/");
125
+ }
126
+ function selectDisposableLaunchTargetIds(targets, currentTargetId) {
127
+ return targets
128
+ .filter((target) => {
129
+ const targetId = target.targetId ?? target.id;
130
+ if (!targetId || targetId === currentTargetId)
131
+ return false;
132
+ if (target.type && target.type !== "page")
133
+ return false;
134
+ return isDisposableLaunchPageUrl(target.url);
135
+ })
136
+ .map((target) => (target.targetId ?? target.id));
137
+ }
138
+ function selectClosableLaunchTargetIds(launchTargetIds, currentTargets, currentTargetId) {
139
+ const launchTargetSet = new Set(launchTargetIds);
140
+ return selectDisposableLaunchTargetIds(currentTargets, currentTargetId).filter((targetId) => launchTargetSet.has(targetId));
141
+ }
142
+ function buildHumanInterventionProbeExpression() {
143
+ return `(() => {
144
+ const path = String(location?.pathname || '').toLowerCase();
145
+ const composerSelector = 'textarea,[contenteditable="true"]';
146
+ const isVisible = (node) => {
147
+ if (!(node instanceof HTMLElement)) return false;
148
+ if (node.closest('[hidden],[aria-hidden="true"],[inert]')) return false;
149
+ const style = getComputedStyle(node);
150
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
151
+ const rect = node.getBoundingClientRect();
152
+ if (rect.width <= 0 || rect.height <= 0 || node.hasAttribute('disabled')) return false;
153
+ const centerX = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
154
+ const centerY = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
155
+ const topNode = document.elementFromPoint(centerX, centerY);
156
+ return topNode === node || node.contains(topNode);
157
+ };
158
+ const composerVisible = Array.from(document.querySelectorAll(composerSelector)).some(isVisible);
159
+ const hasConversation = Boolean(document.querySelector(${JSON.stringify(CONVERSATION_TURN_SELECTOR)}));
160
+ if (/\\/(auth|login|signin)/i.test(path)) return 'login';
161
+ const challengeControls = Array.from(document.querySelectorAll('input[autocomplete="one-time-code"],input[name*="otp" i],input[id*="otp" i],iframe[src*="captcha" i],iframe[src*="/challenge-platform/" i]'));
162
+ if (challengeControls.some(isVisible)) return 'browser_challenge';
163
+ const challengeSurfaces = Array.from(document.querySelectorAll('form,[role="dialog"]'))
164
+ .filter((node) => isVisible(node) && !node.querySelector(composerSelector));
165
+ const challengeText = challengeSurfaces.map((node) => node.textContent || '').join(' ').toLowerCase();
166
+ if (/\\b(mfa|two-factor|2fa|verification code|security check|verify you are human|captcha)\\b/i.test(challengeText)) return 'browser_challenge';
167
+ if (composerVisible) return null;
168
+ if (hasConversation) return null;
169
+ const nodes = Array.from(document.querySelectorAll('button,a,[role="button"]'));
170
+ for (const node of nodes) {
171
+ if (!(node instanceof HTMLElement)) continue;
172
+ const label = String(node.textContent || node.getAttribute('aria-label') || node.getAttribute('title') || '')
173
+ .toLowerCase()
174
+ .trim();
175
+ if (/^(log in|login|sign in|signin|continue with)\\b/i.test(label)) return 'login';
176
+ }
177
+ return null;
178
+ })()`;
179
+ }
180
+ async function detectHumanInterventionReason(Runtime) {
181
+ const { result } = await Runtime.evaluate({
182
+ expression: buildHumanInterventionProbeExpression(),
183
+ returnByValue: true,
184
+ });
185
+ return typeof result?.value === "string" && result.value.length > 0 ? result.value : null;
186
+ }
187
+ function startHumanInterventionRestoreMonitor({ Runtime, logger, revealWindow, disableInputGuard, }) {
188
+ let stopped = false;
189
+ let restoring = false;
190
+ let rejectPromise = () => { };
191
+ const promise = new Promise((_, reject) => {
192
+ rejectPromise = reject;
193
+ });
194
+ void promise.catch(() => undefined);
195
+ const check = async () => {
196
+ if (stopped || restoring)
197
+ return;
198
+ const reason = await detectHumanInterventionReason(Runtime).catch(() => null);
199
+ if (!reason)
200
+ return;
201
+ restoring = true;
202
+ logger(`[browser] ${reason} detected while waiting; restoring Chrome for human action.`);
203
+ await disableInputGuard().catch(() => false);
204
+ if (reason === "login") {
205
+ await openLoginSurfaceForHumanAction(Runtime, logger).catch(() => undefined);
206
+ }
207
+ await revealWindow(`human-intervention:${reason}`).catch(() => undefined);
208
+ rejectPromise(new BrowserAutomationError(reason === "login"
209
+ ? "ChatGPT login appeared while ask-pro was waiting; sign in in the restored browser, then resume."
210
+ : "Browser challenge appeared while ask-pro was waiting; complete it in the restored browser, then resume.", { stage: reason === "login" ? "login-required" : "cloudflare-challenge" }));
211
+ };
212
+ const timer = setInterval(() => void check(), 5_000);
213
+ timer.unref?.();
214
+ void check();
215
+ return {
216
+ promise,
217
+ stop: () => {
218
+ stopped = true;
219
+ clearInterval(timer);
220
+ },
221
+ };
222
+ }
223
+ async function openLoginSurfaceForHumanAction(Runtime, logger) {
224
+ const outcome = await Runtime.evaluate({
225
+ expression: `(() => {
226
+ const isVisible = (node) => {
227
+ if (!(node instanceof HTMLElement)) return false;
228
+ if (node.closest('[hidden],[aria-hidden="true"],[inert]')) return false;
229
+ const style = getComputedStyle(node);
230
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
231
+ const rect = node.getBoundingClientRect();
232
+ return rect.width > 0 && rect.height > 0 && !node.hasAttribute('disabled');
233
+ };
234
+ const labelFor = (node) =>
235
+ String(node?.textContent || node?.getAttribute?.('aria-label') || node?.getAttribute?.('title') || '')
236
+ .toLowerCase()
237
+ .replace(/\\s+/g, ' ')
238
+ .trim();
239
+ const pageText = String(document.body?.textContent || '').toLowerCase();
240
+ const hasExpiredSessionDialog = pageText.includes('session has expired');
241
+ if (!hasExpiredSessionDialog) {
242
+ return { opened: false, method: 'no-expired-session-dialog' };
243
+ }
244
+ const login = Array.from(document.querySelectorAll('a,button,[role="button"]')).find((node) => {
245
+ if (!isVisible(node)) return false;
246
+ const label = labelFor(node);
247
+ return label === 'log in' || label === 'login';
248
+ });
249
+ if (login) {
250
+ login.click();
251
+ return { opened: true, method: 'click', label: labelFor(login) };
252
+ }
253
+ return { opened: false, method: 'missing-control' };
254
+ })()`,
255
+ returnByValue: true,
256
+ });
257
+ const result = outcome.result?.value;
258
+ if (result?.opened) {
259
+ logger(`[browser] Opened ChatGPT login surface for human action (${result.method ?? "unknown"}${result.label ? `: ${result.label}` : ""}).`);
260
+ }
261
+ else if (result?.method) {
262
+ logger(`[browser] ChatGPT login control not found (${result.method}).`);
263
+ }
264
+ }
265
+ export async function runBrowserMode(options) {
266
+ const promptText = options.prompt?.trim();
267
+ if (!promptText) {
268
+ throw new Error("Prompt text is required when using browser mode.");
269
+ }
270
+ const attachments = options.attachments ?? [];
271
+ const fallbackSubmission = options.fallbackSubmission;
272
+ let config = resolveBrowserConfig(options.config);
273
+ const logger = options.log ?? ((_message) => { });
274
+ if (logger.verbose === undefined) {
275
+ logger.verbose = Boolean(config.debug);
276
+ }
277
+ if (logger.sessionLog === undefined && options.log?.sessionLog) {
278
+ logger.sessionLog = options.log.sessionLog;
279
+ }
280
+ const runtimeHintCb = options.runtimeHintCb;
281
+ let lastTargetId;
282
+ let lastUrl;
283
+ const emitRuntimeHint = async () => {
284
+ if (!runtimeHintCb || !chrome?.port) {
285
+ return;
286
+ }
287
+ const conversationId = lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined;
288
+ const hint = {
289
+ chromePid: chrome.pid,
290
+ chromePort: chrome.port,
291
+ chromeHost,
292
+ chromeTargetId: lastTargetId,
293
+ tabUrl: lastUrl,
294
+ conversationId,
295
+ userDataDir,
296
+ controllerPid: process.pid,
297
+ };
298
+ try {
299
+ await runtimeHintCb(hint);
300
+ }
301
+ catch (error) {
302
+ const message = error instanceof Error ? error.message : String(error);
303
+ logger(`Failed to persist runtime hint: ${message}`);
304
+ }
305
+ };
306
+ if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") {
307
+ logger(`[browser-mode] config: ${JSON.stringify({
308
+ ...redactBrowserConfigForDebugLog(config),
309
+ promptLength: promptText.length,
310
+ })}`);
311
+ }
312
+ if (config.attachRunning) {
313
+ const attached = await resolveAttachRunningConnection(config, logger);
314
+ config = {
315
+ ...config,
316
+ remoteChrome: { host: attached.host, port: attached.port },
317
+ remoteChromeBrowserWSEndpoint: attached.browserWSEndpoint,
318
+ remoteChromeProfileRoot: attached.profileRoot,
319
+ };
320
+ }
321
+ if (!config.remoteChrome && !config.manualLogin) {
322
+ const preferredPort = config.debugPort ?? DEFAULT_DEBUG_PORT;
323
+ const availablePort = await pickAvailableDebugPort(preferredPort, logger);
324
+ if (availablePort !== preferredPort) {
325
+ logger(`DevTools port ${preferredPort} busy; using ${availablePort} to avoid attaching to stray Chrome.`);
326
+ }
327
+ config = { ...config, debugPort: availablePort };
328
+ }
329
+ // Remote Chrome mode - connect to existing browser
330
+ if (config.remoteChrome) {
331
+ // Warn about ignored local-only options
332
+ const ignoredFlags = listIgnoredRemoteChromeFlags(config);
333
+ if (ignoredFlags.length > 0) {
334
+ logger(`Note: --remote-chrome ignores local Chrome flags (${ignoredFlags.join(", ")}).`);
335
+ }
336
+ return runRemoteBrowserMode(promptText, attachments, config, logger, options);
337
+ }
338
+ const manualLogin = Boolean(config.manualLogin);
339
+ const manualProfileDir = config.manualLoginProfileDir
340
+ ? path.resolve(config.manualLoginProfileDir)
341
+ : defaultAskProBrowserProfileDir();
342
+ const userDataDir = manualLogin
343
+ ? manualProfileDir
344
+ : await mkdtemp(path.join(await resolveUserDataBaseDir(), "ask-pro-browser-"));
345
+ if (manualLogin) {
346
+ // Learned: manual login reuses a persistent profile so cookies/SSO survive.
347
+ await mkdir(userDataDir, { recursive: true });
348
+ logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
349
+ }
350
+ else {
351
+ logger(`Created temporary Chrome profile at ${userDataDir}`);
352
+ }
353
+ const profileLockTimeoutMs = manualLogin ? (config.profileLockTimeoutMs ?? 0) : 0;
354
+ const lifecycleLockTimeoutMs = manualLogin
355
+ ? profileLockTimeoutMs > 0
356
+ ? profileLockTimeoutMs
357
+ : (DEFAULT_BROWSER_CONFIG.profileLockTimeoutMs ?? 300_000)
358
+ : 0;
359
+ let profileLock = null;
360
+ const acquireProfileLockIfNeeded = async (timeoutMs = profileLockTimeoutMs) => {
361
+ if (timeoutMs <= 0 || profileLock)
362
+ return false;
363
+ profileLock = await acquireProfileRunLock(userDataDir, {
364
+ timeoutMs,
365
+ logger,
366
+ });
367
+ return true;
368
+ };
369
+ const releaseProfileLockIfHeld = async () => {
370
+ if (!profileLock)
371
+ return;
372
+ const handle = profileLock;
373
+ profileLock = null;
374
+ await handle.release().catch(() => undefined);
375
+ };
376
+ const effectiveKeepBrowser = Boolean(config.keepBrowser);
377
+ let managedChromeRunLease = null;
378
+ await acquireProfileLockIfNeeded(lifecycleLockTimeoutMs);
379
+ let reusedChrome = null;
380
+ let chrome = null;
381
+ try {
382
+ reusedChrome = manualLogin ? await maybeReuseRunningChrome(userDataDir, logger) : null;
383
+ if (!reusedChrome) {
384
+ await seedChromeProfileLanguage(userDataDir, config.acceptLanguage, logger);
385
+ }
386
+ chrome =
387
+ reusedChrome ??
388
+ (await launchChrome({
389
+ ...config,
390
+ remoteChrome: config.remoteChrome,
391
+ }, userDataDir, logger));
392
+ // Persist profile state so future manual-login runs can reuse this Chrome.
393
+ if (manualLogin && chrome.port) {
394
+ await writeDevToolsActivePort(userDataDir, chrome.port);
395
+ if (!reusedChrome && chrome.pid) {
396
+ await writeChromePid(userDataDir, chrome.pid);
397
+ }
398
+ managedChromeRunLease = await createManagedChromeRunLease(userDataDir);
399
+ }
400
+ }
401
+ catch (error) {
402
+ if (chrome && !reusedChrome) {
403
+ const closed = await closeChromeGracefully(chrome, logger).then(() => true, () => {
404
+ releaseChromeProcessHandle(chrome);
405
+ return false;
406
+ });
407
+ if (closed) {
408
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined);
409
+ }
410
+ }
411
+ else {
412
+ releaseChromeProcessHandle(chrome);
413
+ }
414
+ throw error;
415
+ }
416
+ finally {
417
+ await releaseProfileLockIfHeld();
418
+ }
419
+ if (!chrome)
420
+ throw new Error("Failed to start or reuse managed Chrome.");
421
+ const chromeLaunchMinimized = !reusedChrome && shouldLaunchChromeMinimized(config);
422
+ const chromeHost = chrome.host ?? "127.0.0.1";
423
+ let removeTerminationHooks = null;
424
+ try {
425
+ removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser || manualLogin, logger, {
426
+ isInFlight: () => runStatus !== "complete",
427
+ emitRuntimeHint,
428
+ preserveUserDataDir: manualLogin,
429
+ });
430
+ }
431
+ catch {
432
+ // ignore failure; cleanup still happens below
433
+ }
434
+ let client = null;
435
+ let isolatedTargetId = null;
436
+ let launchTargetIds = [];
437
+ let closedLaunchTabs = false;
438
+ const closeLaunchTabs = async () => {
439
+ if (closedLaunchTabs || launchTargetIds.length === 0)
440
+ return;
441
+ closedLaunchTabs = true;
442
+ const currentTargets = await listRemoteChromeTargets({
443
+ host: chromeHost,
444
+ port: chrome.port,
445
+ }).catch(() => []);
446
+ const targetIds = selectClosableLaunchTargetIds(launchTargetIds, currentTargets, isolatedTargetId);
447
+ launchTargetIds = [];
448
+ await Promise.all(targetIds.map((targetId) => closeTab(chrome.port, targetId, logger, chromeHost).catch(() => undefined)));
449
+ };
450
+ let ownsTarget = true;
451
+ const startedAt = Date.now();
452
+ let answerText = "";
453
+ let answerMarkdown = "";
454
+ let answerHtml = "";
455
+ let runStatus = "attempted";
456
+ let connectionClosedUnexpectedly = false;
457
+ let stopThinkingMonitor = null;
458
+ let removeDialogHandler = null;
459
+ let appliedCookies = 0;
460
+ let windowParkedAfterSetup = false;
461
+ let windowWasMinimized = false;
462
+ let preserveBrowserOnError = false;
463
+ let preserveBrowserAfterComplete = false;
464
+ let preserveWindowStateOnError = false;
465
+ let revealAuthenticatedWindow = async () => { };
466
+ let disablePostSubmitInputGuard = async () => true;
467
+ let stopHumanInterventionMonitor = null;
468
+ let humanInterventionPromise = null;
469
+ let manualLoginRecoveryInProgress = false;
470
+ try {
471
+ try {
472
+ if (config.browserTabRef) {
473
+ const attached = await connectToExistingChatGptTab({
474
+ host: chromeHost,
475
+ port: chrome.port,
476
+ ref: config.browserTabRef,
477
+ });
478
+ client = attached.client;
479
+ isolatedTargetId = attached.targetId ?? null;
480
+ lastTargetId = attached.targetId ?? undefined;
481
+ lastUrl = attached.tab.url || lastUrl;
482
+ ownsTarget = false;
483
+ logger(`Attached to existing ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`);
484
+ }
485
+ else {
486
+ const strictTabIsolation = Boolean(manualLogin && reusedChrome);
487
+ const manageManagedWindowState = manualLogin &&
488
+ shouldLaunchChromeMinimized({
489
+ ...config,
490
+ startMinimized: true,
491
+ });
492
+ let windowClient = null;
493
+ let tabSetupFailed = false;
494
+ try {
495
+ if (manageManagedWindowState) {
496
+ await acquireProfileLockIfNeeded(lifecycleLockTimeoutMs);
497
+ }
498
+ windowClient = manageManagedWindowState
499
+ ? (await CDP({ host: chromeHost, port: chrome.port }).catch(() => null))
500
+ : null;
501
+ const observedWindowMinimized = windowClient
502
+ ? await isChromeWindowMinimized(windowClient)
503
+ : null;
504
+ windowWasMinimized = Boolean(manageManagedWindowState &&
505
+ (observedWindowMinimized ?? (config.startMinimized ? chromeLaunchMinimized : true)));
506
+ if (shouldCaptureLaunchTargetsForCleanup({ reusedChrome: Boolean(reusedChrome) })) {
507
+ const initialTargets = await listRemoteChromeTargets({
508
+ host: chromeHost,
509
+ port: chrome.port,
510
+ }).catch(() => []);
511
+ launchTargetIds = selectDisposableLaunchTargetIds(initialTargets, null);
512
+ }
513
+ const connection = await connectWithNewTab(chrome.port, logger, "about:blank", chromeHost, {
514
+ fallbackToDefault: !strictTabIsolation,
515
+ retries: strictTabIsolation ? 3 : 0,
516
+ retryDelayMs: 500,
517
+ });
518
+ client = connection.client;
519
+ isolatedTargetId = connection.targetId ?? null;
520
+ if (!isolatedTargetId) {
521
+ launchTargetIds = [];
522
+ }
523
+ if (config.startMinimized && manageManagedWindowState) {
524
+ windowParkedAfterSetup = await hideChromeWindow(chrome, logger);
525
+ }
526
+ else if (!config.startMinimized && manageManagedWindowState) {
527
+ let restored = await restoreChromeWindowByPid(chrome.pid, logger);
528
+ if (!restored) {
529
+ restored = await setChromeWindowState(client, "normal", logger, {
530
+ targetId: isolatedTargetId ?? undefined,
531
+ reason: "recovery-tab",
532
+ });
533
+ }
534
+ windowParkedAfterSetup = !restored;
535
+ if (restored)
536
+ windowWasMinimized = false;
537
+ }
538
+ ownsTarget = true;
539
+ }
540
+ catch (error) {
541
+ tabSetupFailed = true;
542
+ throw error;
543
+ }
544
+ finally {
545
+ if (tabSetupFailed && manageManagedWindowState && profileLock) {
546
+ const restoreClient = client ?? windowClient;
547
+ let restored = await restoreChromeWindowByPid(chrome.pid, logger);
548
+ if (!restored && restoreClient) {
549
+ restored = await setChromeWindowState(restoreClient, "normal", logger, {
550
+ targetId: isolatedTargetId ?? undefined,
551
+ reason: "tab-setup-failed",
552
+ });
553
+ }
554
+ if (restored)
555
+ windowWasMinimized = false;
556
+ }
557
+ else if (config.startMinimized && manageManagedWindowState && !windowParkedAfterSetup) {
558
+ windowParkedAfterSetup = await hideChromeWindow(chrome, logger);
559
+ }
560
+ await windowClient?.close().catch(() => undefined);
561
+ if (manageManagedWindowState)
562
+ await releaseProfileLockIfHeld();
563
+ }
564
+ }
565
+ }
566
+ catch (error) {
567
+ const hint = describeDevtoolsFirewallHint(chromeHost, chrome.port);
568
+ if (hint) {
569
+ logger(hint);
570
+ }
571
+ throw error;
572
+ }
573
+ const disconnectPromise = new Promise((_, reject) => {
574
+ client?.on("disconnect", () => {
575
+ if (manualLoginRecoveryInProgress) {
576
+ logger("Managed Chrome target changed during manual login; discovering its replacement.");
577
+ return;
578
+ }
579
+ connectionClosedUnexpectedly = true;
580
+ logger("Managed Chrome connection lost; preserving the run for resume.");
581
+ reject(new Error("Managed Chrome process or DevTools target was lost before ask-pro finished."));
582
+ });
583
+ });
584
+ const raceWithDisconnect = (promise) => Promise.race([
585
+ promise,
586
+ disconnectPromise,
587
+ ...(humanInterventionPromise ? [humanInterventionPromise] : []),
588
+ ]);
589
+ const { Network, Page, Runtime, Input, DOM } = client;
590
+ const postSubmitInputGuard = shouldEnablePostSubmitInputGuard({
591
+ ...config,
592
+ })
593
+ ? createPostSubmitInputGuard(Input, logger)
594
+ : null;
595
+ const continueResponse = createAssistantContinuation(Runtime, Input, logger, postSubmitInputGuard);
596
+ disablePostSubmitInputGuard = () => postSubmitInputGuard?.disable() ?? Promise.resolve(true);
597
+ let authenticatedWindowParked = chromeLaunchMinimized || windowParkedAfterSetup || windowWasMinimized;
598
+ revealAuthenticatedWindow = async (reason) => {
599
+ if (!authenticatedWindowParked)
600
+ return;
601
+ const acquiredProfileLock = manualLogin
602
+ ? await acquireProfileLockIfNeeded(lifecycleLockTimeoutMs)
603
+ : false;
604
+ try {
605
+ let restored = await restoreChromeWindowByPid(chrome?.pid, logger);
606
+ if (!restored && client) {
607
+ restored = await setChromeWindowState(client, "normal", logger, {
608
+ targetId: isolatedTargetId ?? lastTargetId,
609
+ reason,
610
+ });
611
+ }
612
+ if (restored) {
613
+ authenticatedWindowParked = false;
614
+ }
615
+ }
616
+ finally {
617
+ if (acquiredProfileLock)
618
+ await releaseProfileLockIfHeld();
619
+ }
620
+ };
621
+ if (!config.headless && config.hideWindow) {
622
+ await hideChromeWindow(chrome, logger);
623
+ }
624
+ const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
625
+ if (DOM && typeof DOM.enable === "function") {
626
+ domainEnablers.push(DOM.enable());
627
+ }
628
+ await Promise.all(domainEnablers);
629
+ if (config.acceptLanguage) {
630
+ await applyPageLanguageOverrides(client, config.acceptLanguage, logger);
631
+ }
632
+ removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
633
+ if (!manualLogin) {
634
+ await Network.clearBrowserCookies();
635
+ }
636
+ const manualLoginCookieSync = manualLogin && Boolean(config.manualLoginCookieSync);
637
+ const cookieSyncEnabled = config.cookieSync && (!manualLogin || manualLoginCookieSync);
638
+ if (cookieSyncEnabled) {
639
+ if (manualLoginCookieSync) {
640
+ logger("Manual login mode: seeding persistent profile with cookies from your Chrome profile.");
641
+ }
642
+ if (!config.inlineCookies) {
643
+ logger("Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; for answer-bearing sessions, use --copy to print a copy target or --harvest to print the raw answer.");
644
+ }
645
+ else {
646
+ logger("Applying inline cookies (skipping Chrome profile read and Keychain prompt)");
647
+ }
648
+ // Learned: always sync cookies before the first navigation so /backend-api/me succeeds.
649
+ const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, {
650
+ allowErrors: config.allowCookieErrors ?? false,
651
+ filterNames: config.cookieNames ?? undefined,
652
+ inlineCookies: config.inlineCookies ?? undefined,
653
+ cookiePath: config.chromeCookiePath ?? undefined,
654
+ waitMs: config.cookieSyncWaitMs ?? 0,
655
+ });
656
+ appliedCookies = cookieCount;
657
+ if (config.inlineCookies && cookieCount === 0) {
658
+ throw new Error("No inline cookies were applied; aborting before navigation.");
659
+ }
660
+ logger(cookieCount > 0
661
+ ? config.inlineCookies
662
+ ? `Applied ${cookieCount} inline cookies`
663
+ : `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ?? "Default"}`
664
+ : config.inlineCookies
665
+ ? "No inline cookies applied; continuing without session reuse"
666
+ : "No Chrome cookies found; continuing without session reuse");
667
+ }
668
+ else {
669
+ logger(manualLogin
670
+ ? "Skipping Chrome cookie sync because manual browser login is enabled; reuse the opened profile after signing in."
671
+ : "Skipping Chrome cookie sync because cookie sync is disabled.");
672
+ }
673
+ if (cookieSyncEnabled && !manualLogin && (appliedCookies ?? 0) === 0 && !config.inlineCookies) {
674
+ // Learned: if the profile has no ChatGPT cookies, browser mode will just bounce to login.
675
+ // Fail early so the user knows to sign in.
676
+ throw new BrowserAutomationError("No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. " +
677
+ "Make sure ChatGPT is signed in in the selected profile, then retry or use the manual-login path.", {
678
+ stage: "execute-browser",
679
+ details: {
680
+ profile: config.chromeProfile ?? "Default",
681
+ cookiePath: config.chromeCookiePath ?? null,
682
+ hint: "If macOS Keychain prompts or denies access, run ask-pro from a GUI session or use the manual browser profile.",
683
+ },
684
+ });
685
+ }
686
+ const baseUrl = CHATGPT_URL;
687
+ // First load the base ChatGPT homepage to satisfy potential interstitials,
688
+ // then hop to the requested URL if it differs.
689
+ await raceWithDisconnect(navigateToChatGPT(Page, Runtime, baseUrl, logger));
690
+ await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
691
+ // Learned: login checks must happen on the base domain before jumping into project URLs.
692
+ if (manualLogin)
693
+ manualLoginRecoveryInProgress = true;
694
+ await raceWithDisconnect(waitForLogin({
695
+ runtime: Runtime,
696
+ logger,
697
+ appliedCookies,
698
+ manualLogin,
699
+ onAuthNeeded: async () => {
700
+ await revealAuthenticatedWindow("login-required");
701
+ },
702
+ }));
703
+ if (manualLogin)
704
+ manualLoginRecoveryInProgress = false;
705
+ if (config.url !== baseUrl) {
706
+ await raceWithDisconnect(navigateToPromptReadyWithFallback(Page, Runtime, {
707
+ url: config.url,
708
+ fallbackUrl: baseUrl,
709
+ timeoutMs: config.inputTimeoutMs,
710
+ headless: config.headless,
711
+ logger,
712
+ }));
713
+ }
714
+ else {
715
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
716
+ }
717
+ logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
718
+ const captureRuntimeSnapshot = async () => {
719
+ try {
720
+ if (client?.Target?.getTargetInfo) {
721
+ const info = await client.Target.getTargetInfo({});
722
+ lastTargetId = info?.targetInfo?.targetId ?? lastTargetId;
723
+ lastUrl = info?.targetInfo?.url ?? lastUrl;
724
+ }
725
+ }
726
+ catch {
727
+ // ignore
728
+ }
729
+ try {
730
+ const { result } = await Runtime.evaluate({
731
+ expression: "location.href",
732
+ returnByValue: true,
733
+ });
734
+ if (typeof result?.value === "string") {
735
+ lastUrl = result.value;
736
+ }
737
+ }
738
+ catch {
739
+ // ignore
740
+ }
741
+ if (lastUrl) {
742
+ logger(`[browser] url = ${lastUrl}`);
743
+ }
744
+ if (chrome?.port) {
745
+ const suffix = lastTargetId ? ` target=${lastTargetId}` : "";
746
+ if (lastUrl) {
747
+ logger(`[reattach] chrome port=${chrome.port} host=${chromeHost} url=${lastUrl}${suffix}`);
748
+ }
749
+ else {
750
+ logger(`[reattach] chrome port=${chrome.port} host=${chromeHost}${suffix}`);
751
+ }
752
+ await emitRuntimeHint();
753
+ }
754
+ };
755
+ await captureRuntimeSnapshot().catch(() => undefined);
756
+ if (postSubmitInputGuard) {
757
+ const monitor = startHumanInterventionRestoreMonitor({
758
+ Runtime,
759
+ logger,
760
+ revealWindow: revealAuthenticatedWindow,
761
+ disableInputGuard: disablePostSubmitInputGuard,
762
+ });
763
+ stopHumanInterventionMonitor = monitor.stop;
764
+ humanInterventionPromise = monitor.promise;
765
+ }
766
+ let expectedConversationUrl;
767
+ let expectedConversationId;
768
+ let conversationHintInFlight = null;
769
+ const lockConversationUrl = async (candidateUrl, label) => {
770
+ if (!candidateUrl || !isConversationUrl(candidateUrl)) {
771
+ return false;
772
+ }
773
+ const candidateId = extractConversationIdFromUrl(candidateUrl);
774
+ if (!candidateId) {
775
+ return false;
776
+ }
777
+ if (expectedConversationId && candidateId !== expectedConversationId) {
778
+ logger(`[browser] Ignoring conversation drift (${label}); expected ${expectedConversationUrl}, saw ${candidateUrl}`);
779
+ return false;
780
+ }
781
+ expectedConversationUrl = candidateUrl;
782
+ expectedConversationId = candidateId;
783
+ lastUrl = candidateUrl;
784
+ logger(`[browser] conversation url (${label}) = ${candidateUrl}`);
785
+ await emitRuntimeHint();
786
+ return true;
787
+ };
788
+ const updateConversationHint = async (label, timeoutMs = 10_000) => {
789
+ if (!chrome?.port) {
790
+ return false;
791
+ }
792
+ const start = Date.now();
793
+ while (Date.now() - start < timeoutMs) {
794
+ try {
795
+ const { result } = await Runtime.evaluate({
796
+ expression: "location.href",
797
+ returnByValue: true,
798
+ });
799
+ if (typeof result?.value === "string" &&
800
+ (await lockConversationUrl(result.value, label))) {
801
+ return true;
802
+ }
803
+ }
804
+ catch {
805
+ // ignore; keep polling until timeout
806
+ }
807
+ await delay(250);
808
+ }
809
+ return false;
810
+ };
811
+ const scheduleConversationHint = (label, timeoutMs) => {
812
+ if (conversationHintInFlight) {
813
+ return;
814
+ }
815
+ // Learned: the /c/ URL can update after the answer; emit hints in the background.
816
+ // Run in the background so prompt submission/streaming isn't blocked by slow URL updates.
817
+ conversationHintInFlight = updateConversationHint(label, timeoutMs)
818
+ .catch(() => false)
819
+ .finally(() => {
820
+ conversationHintInFlight = null;
821
+ });
822
+ };
823
+ const ensureExpectedConversation = async (label) => {
824
+ if (!expectedConversationUrl || !expectedConversationId) {
825
+ return false;
826
+ }
827
+ const currentUrl = await readConversationUrl(Runtime);
828
+ const currentId = currentUrl ? extractConversationIdFromUrl(currentUrl) : undefined;
829
+ if (currentId === expectedConversationId) {
830
+ if (currentUrl && currentUrl !== lastUrl) {
831
+ lastUrl = currentUrl;
832
+ await emitRuntimeHint();
833
+ }
834
+ return true;
835
+ }
836
+ logger(`[browser] Conversation drifted during ${label}; restoring ${expectedConversationUrl}`);
837
+ await raceWithDisconnect(Page.navigate({ url: expectedConversationUrl }));
838
+ await raceWithDisconnect(delay(1000));
839
+ lastUrl = expectedConversationUrl;
840
+ await emitRuntimeHint();
841
+ return true;
842
+ };
843
+ await captureRuntimeSnapshot();
844
+ const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
845
+ if (config.desiredModel && modelStrategy !== "ignore") {
846
+ await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
847
+ retries: 2,
848
+ delayMs: 300,
849
+ onRetry: (attempt, error) => {
850
+ if (options.verbose) {
851
+ logger(`[retry] Model picker attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
852
+ }
853
+ },
854
+ })).catch((error) => {
855
+ const base = error instanceof Error ? error.message : String(error);
856
+ const hint = appliedCookies === 0
857
+ ? " No cookies were applied; sign in to ChatGPT in the opened browser, then resume."
858
+ : "";
859
+ throw new Error(`${base}${hint}`);
860
+ });
861
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
862
+ logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
863
+ }
864
+ else if (modelStrategy === "ignore") {
865
+ logger("Model picker: skipped (strategy=ignore)");
866
+ }
867
+ // Handle thinking time selection if specified
868
+ const thinkingTime = config.thinkingTime;
869
+ if (thinkingTime) {
870
+ await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
871
+ retries: 2,
872
+ delayMs: 300,
873
+ onRetry: (attempt, error) => {
874
+ if (options.verbose) {
875
+ logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
876
+ }
877
+ },
878
+ }));
879
+ }
880
+ const submitOnce = async (prompt, submissionAttachments) => {
881
+ try {
882
+ const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
883
+ const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
884
+ const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
885
+ let inputOnlyAttachments = false;
886
+ if (submissionAttachments.length > 0) {
887
+ if (!DOM) {
888
+ throw new Error("Chrome DOM domain unavailable while uploading attachments.");
889
+ }
890
+ await clearComposerAttachments(Runtime, 5_000, logger);
891
+ for (let attachmentIndex = 0; attachmentIndex < submissionAttachments.length; attachmentIndex += 1) {
892
+ const attachment = submissionAttachments[attachmentIndex];
893
+ logger(`Uploading attachment: ${attachment.displayPath}`);
894
+ const uiConfirmed = await raceWithDisconnect(uploadAttachmentFile({ runtime: Runtime, dom: DOM, input: Input }, attachment, logger, { expectedCount: attachmentIndex + 1 }));
895
+ if (!uiConfirmed) {
896
+ inputOnlyAttachments = true;
897
+ }
898
+ await delay(500);
899
+ }
900
+ // Scale timeout based on number of files: base 45s + 20s per additional file.
901
+ const baseTimeout = config.inputTimeoutMs ?? 30_000;
902
+ const perFileTimeout = 20_000;
903
+ const waitBudget = Math.max(baseTimeout, 45_000) + (submissionAttachments.length - 1) * perFileTimeout;
904
+ await raceWithDisconnect(waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger));
905
+ logger("All attachments uploaded");
906
+ }
907
+ let baselineTurns = await readConversationTurnCount(Runtime, logger);
908
+ // Learned: return baselineTurns so assistant polling can ignore earlier content.
909
+ const providerState = {
910
+ runtime: Runtime,
911
+ input: Input,
912
+ logger,
913
+ timeoutMs: config.timeoutMs,
914
+ inputTimeoutMs: config.inputTimeoutMs ?? undefined,
915
+ baselineTurns: baselineTurns ?? undefined,
916
+ attachmentNames,
917
+ afterSubmit: postSubmitInputGuard ? () => postSubmitInputGuard.enable() : undefined,
918
+ };
919
+ await raceWithDisconnect(runProviderSubmissionFlow(chatgptDomProvider, {
920
+ prompt,
921
+ evaluate: async () => undefined,
922
+ delay,
923
+ log: logger,
924
+ state: providerState,
925
+ }));
926
+ const providerBaselineTurns = providerState.baselineTurns;
927
+ if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
928
+ baselineTurns = providerBaselineTurns;
929
+ }
930
+ if (attachmentNames.length > 0) {
931
+ if (inputOnlyAttachments) {
932
+ logger("Attachment UI did not render before send; skipping user-turn attachment verification.");
933
+ }
934
+ else {
935
+ const verified = await raceWithDisconnect(waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger, {
936
+ minTurnIndex: baselineTurns ?? undefined,
937
+ expectedPrompt: prompt,
938
+ expectedConversationId,
939
+ }));
940
+ if (!verified) {
941
+ logger("Sent user message attachment UI was not visible after upload; continuing because upload and send completed.");
942
+ }
943
+ else {
944
+ logger("Verified attachments present on sent user message");
945
+ }
946
+ }
947
+ }
948
+ // Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
949
+ scheduleConversationHint("post-submit", config.timeoutMs ?? 120_000);
950
+ await updateConversationHint("post-submit", 15_000).catch(() => false);
951
+ return { baselineTurns, baselineAssistantText };
952
+ }
953
+ catch (error) {
954
+ await postSubmitInputGuard?.disable();
955
+ throw error;
956
+ }
957
+ };
958
+ const reloadPromptComposer = async () => {
959
+ logger("[browser] Composer became unresponsive; reloading page and retrying once.");
960
+ await raceWithDisconnect(Page.reload({ ignoreCache: true }));
961
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
962
+ };
963
+ let baselineTurns = null;
964
+ let baselineAssistantText = null;
965
+ await acquireProfileLockIfNeeded();
966
+ try {
967
+ const submission = await runSubmissionWithRecovery({
968
+ prompt: promptText,
969
+ attachments,
970
+ fallbackSubmission,
971
+ submit: (submissionPrompt, submissionAttachments) => raceWithDisconnect(submitOnce(submissionPrompt, submissionAttachments)),
972
+ reloadPromptComposer,
973
+ prepareFallbackSubmission: async () => {
974
+ await raceWithDisconnect(clearPromptComposer(Runtime, logger));
975
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
976
+ },
977
+ logger,
978
+ });
979
+ baselineTurns = submission.baselineTurns;
980
+ baselineAssistantText = submission.baselineAssistantText;
981
+ await closeLaunchTabs();
982
+ }
983
+ finally {
984
+ await releaseProfileLockIfHeld();
985
+ }
986
+ // Helper to normalize text for echo detection (collapse whitespace, lowercase)
987
+ const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, " ").trim();
988
+ const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => {
989
+ const baselinePrefix = baselineNormalized.length >= 80
990
+ ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
991
+ : "";
992
+ const deadline = Date.now() + timeoutMs;
993
+ while (Date.now() < deadline) {
994
+ const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId).catch(() => null);
995
+ const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
996
+ if (text) {
997
+ const normalized = normalizeForComparison(text);
998
+ const isBaseline = normalized === baselineNormalized ||
999
+ (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
1000
+ if (!isBaseline) {
1001
+ return {
1002
+ text,
1003
+ html: snapshot?.html ?? undefined,
1004
+ meta: {
1005
+ turnId: snapshot?.turnId ?? undefined,
1006
+ messageId: snapshot?.messageId ?? undefined,
1007
+ },
1008
+ };
1009
+ }
1010
+ }
1011
+ await delay(350);
1012
+ }
1013
+ return null;
1014
+ };
1015
+ let answer;
1016
+ const waitWithThinkingMonitor = async (operation) => {
1017
+ stopThinkingMonitor?.();
1018
+ stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, {
1019
+ intervalMs: options.heartbeatIntervalMs,
1020
+ });
1021
+ try {
1022
+ return await operation();
1023
+ }
1024
+ finally {
1025
+ stopThinkingMonitor?.();
1026
+ stopThinkingMonitor = null;
1027
+ }
1028
+ };
1029
+ const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
1030
+ const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
1031
+ const attemptAssistantRecheck = async () => {
1032
+ if (!recheckDelayMs)
1033
+ return null;
1034
+ logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`);
1035
+ await raceWithDisconnect(delay(recheckDelayMs));
1036
+ await updateConversationHint("assistant-recheck", 15_000).catch(() => false);
1037
+ await ensureExpectedConversation("assistant-recheck").catch(() => false);
1038
+ await captureRuntimeSnapshot().catch(() => undefined);
1039
+ const conversationUrl = expectedConversationUrl ?? (await readConversationUrl(Runtime));
1040
+ if (conversationUrl && isConversationUrl(conversationUrl)) {
1041
+ logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
1042
+ await raceWithDisconnect(Page.navigate({ url: conversationUrl }));
1043
+ await raceWithDisconnect(delay(1000));
1044
+ }
1045
+ // Validate session before attempting recheck - sessions can expire during the delay
1046
+ const sessionValid = await validateChatGPTSession(Runtime, logger);
1047
+ if (!sessionValid.valid) {
1048
+ logger(`[browser] Session validation failed: ${sessionValid.reason}`);
1049
+ // Update session metadata to indicate login is needed
1050
+ await emitRuntimeHint();
1051
+ throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
1052
+ `Conversation URL: ${conversationUrl || lastUrl || "unknown"}. ` +
1053
+ `Please sign in and retry.`, {
1054
+ stage: "assistant-recheck",
1055
+ details: {
1056
+ conversationUrl: conversationUrl || lastUrl || null,
1057
+ sessionStatus: "needs_login",
1058
+ validationReason: sessionValid.reason,
1059
+ },
1060
+ runtime: {
1061
+ chromePid: chrome.pid,
1062
+ chromePort: chrome.port,
1063
+ chromeHost,
1064
+ userDataDir,
1065
+ chromeTargetId: lastTargetId,
1066
+ tabUrl: lastUrl,
1067
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1068
+ controllerPid: process.pid,
1069
+ },
1070
+ });
1071
+ }
1072
+ const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
1073
+ const rechecked = await waitWithThinkingMonitor(() => raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined, expectedConversationUrl, expectedConversationId, continueResponse)));
1074
+ logger("Recovered assistant response after delayed recheck");
1075
+ return rechecked;
1076
+ };
1077
+ try {
1078
+ try {
1079
+ await ensureExpectedConversation("assistant-wait").catch(() => false);
1080
+ answer = await waitWithThinkingMonitor(() => raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined, expectedConversationUrl, expectedConversationId, continueResponse)));
1081
+ }
1082
+ catch (error) {
1083
+ if (isAssistantResponseTimeoutError(error)) {
1084
+ const rechecked = await attemptAssistantRecheck().catch((error) => {
1085
+ if (error instanceof AssistantStoppedError)
1086
+ throw error;
1087
+ return null;
1088
+ });
1089
+ if (rechecked) {
1090
+ answer = rechecked;
1091
+ }
1092
+ else {
1093
+ await updateConversationHint("assistant-timeout", 15_000).catch(() => false);
1094
+ await ensureExpectedConversation("assistant-timeout").catch(() => false);
1095
+ await captureRuntimeSnapshot().catch(() => undefined);
1096
+ const runtime = {
1097
+ chromePid: chrome.pid,
1098
+ chromePort: chrome.port,
1099
+ chromeHost,
1100
+ userDataDir,
1101
+ chromeTargetId: lastTargetId,
1102
+ tabUrl: lastUrl,
1103
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1104
+ controllerPid: process.pid,
1105
+ };
1106
+ throw new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime }, error);
1107
+ }
1108
+ }
1109
+ else {
1110
+ throw error;
1111
+ }
1112
+ }
1113
+ }
1114
+ finally {
1115
+ await postSubmitInputGuard?.disable();
1116
+ }
1117
+ // Ensure we store the final conversation URL even if the UI updated late.
1118
+ await updateConversationHint("post-response", 15_000);
1119
+ await ensureExpectedConversation("post-response").catch(() => false);
1120
+ const baselineNormalized = baselineAssistantText
1121
+ ? normalizeForComparison(baselineAssistantText)
1122
+ : "";
1123
+ if (baselineNormalized) {
1124
+ const normalizedAnswer = normalizeForComparison(answer.text ?? "");
1125
+ const baselinePrefix = baselineNormalized.length >= 80
1126
+ ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
1127
+ : "";
1128
+ const isBaseline = normalizedAnswer === baselineNormalized ||
1129
+ (baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
1130
+ if (isBaseline) {
1131
+ logger("Detected stale assistant response; waiting for new response...");
1132
+ const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
1133
+ if (refreshed) {
1134
+ answer = refreshed;
1135
+ }
1136
+ }
1137
+ }
1138
+ answerText = answer.text;
1139
+ answerHtml = answer.html ?? "";
1140
+ const copiedMarkdown = await raceWithDisconnect(withRetries(async () => {
1141
+ const attempt = await captureAssistantMarkdown(Runtime, answer.meta, logger);
1142
+ if (!attempt) {
1143
+ throw new Error("copy-missing");
1144
+ }
1145
+ return attempt;
1146
+ }, {
1147
+ retries: 2,
1148
+ delayMs: 350,
1149
+ onRetry: (attempt, error) => {
1150
+ if (options.verbose) {
1151
+ logger(`[retry] Markdown capture attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
1152
+ }
1153
+ },
1154
+ })).catch(() => null);
1155
+ answerMarkdown = copiedMarkdown ?? answerText;
1156
+ const promptEchoMatcher = buildPromptEchoMatcher(promptText);
1157
+ ({ answerText, answerMarkdown } = await maybeRecoverLongAssistantResponse({
1158
+ runtime: Runtime,
1159
+ baselineTurns,
1160
+ expectedConversationId,
1161
+ answerText,
1162
+ answerMarkdown,
1163
+ logger,
1164
+ allowMarkdownUpdate: !copiedMarkdown,
1165
+ }));
1166
+ // Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
1167
+ const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId).catch(() => null);
1168
+ const finalText = typeof finalSnapshot?.text === "string" ? finalSnapshot.text.trim() : "";
1169
+ if (finalText && finalText !== promptText.trim()) {
1170
+ const trimmedMarkdown = answerMarkdown.trim();
1171
+ const finalIsEcho = promptEchoMatcher ? promptEchoMatcher.isEcho(finalText) : false;
1172
+ const lengthDelta = finalText.length - trimmedMarkdown.length;
1173
+ const missingCopy = !copiedMarkdown && lengthDelta >= 0;
1174
+ const likelyTruncatedCopy = copiedMarkdown &&
1175
+ trimmedMarkdown.length > 0 &&
1176
+ lengthDelta >= Math.max(12, Math.floor(trimmedMarkdown.length * 0.75));
1177
+ if ((missingCopy || likelyTruncatedCopy) && !finalIsEcho && finalText !== trimmedMarkdown) {
1178
+ logger("Refreshed assistant response via final DOM snapshot");
1179
+ answerText = finalText;
1180
+ answerMarkdown = finalText;
1181
+ }
1182
+ }
1183
+ // Detect prompt echo using normalized comparison (whitespace-insensitive).
1184
+ const alignedEcho = alignPromptEchoPair(answerText, answerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
1185
+ text: "Aligned assistant response text to copied markdown after prompt echo",
1186
+ markdown: "Aligned assistant markdown to response text after prompt echo",
1187
+ });
1188
+ answerText = alignedEcho.answerText;
1189
+ answerMarkdown = alignedEcho.answerMarkdown;
1190
+ const isPromptEcho = alignedEcho.isEcho;
1191
+ if (isPromptEcho) {
1192
+ logger("Detected prompt echo in response; waiting for actual assistant response...");
1193
+ const deadline = Date.now() + 15_000;
1194
+ let bestText = null;
1195
+ let stableCount = 0;
1196
+ while (Date.now() < deadline) {
1197
+ const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId).catch(() => null);
1198
+ const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
1199
+ const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
1200
+ if (!isStillEcho) {
1201
+ if (!bestText || text.length > bestText.length) {
1202
+ bestText = text;
1203
+ stableCount = 0;
1204
+ }
1205
+ else if (text === bestText) {
1206
+ stableCount += 1;
1207
+ }
1208
+ if (stableCount >= 2) {
1209
+ break;
1210
+ }
1211
+ }
1212
+ await new Promise((resolve) => setTimeout(resolve, 300));
1213
+ }
1214
+ if (bestText) {
1215
+ logger("Recovered assistant response after detecting prompt echo");
1216
+ answerText = bestText;
1217
+ answerMarkdown = bestText;
1218
+ }
1219
+ }
1220
+ const minAnswerChars = 16;
1221
+ if (answerText.trim().length > 0 && answerText.trim().length < minAnswerChars) {
1222
+ const deadline = Date.now() + 12_000;
1223
+ let bestText = answerText.trim();
1224
+ let stableCycles = 0;
1225
+ while (Date.now() < deadline) {
1226
+ const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId).catch(() => null);
1227
+ const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
1228
+ if (text && text.length > bestText.length) {
1229
+ bestText = text;
1230
+ stableCycles = 0;
1231
+ }
1232
+ else {
1233
+ stableCycles += 1;
1234
+ }
1235
+ if (stableCycles >= 3 && bestText.length >= minAnswerChars) {
1236
+ break;
1237
+ }
1238
+ await delay(400);
1239
+ }
1240
+ if (bestText.length > answerText.trim().length) {
1241
+ logger("Refreshed short assistant response from latest DOM snapshot");
1242
+ answerText = bestText;
1243
+ answerMarkdown = bestText;
1244
+ }
1245
+ }
1246
+ if (connectionClosedUnexpectedly) {
1247
+ // Bail out on mid-run disconnects so the session stays reattachable.
1248
+ throw new Error("Chrome disconnected before completion");
1249
+ }
1250
+ if (options.afterAnswerCb) {
1251
+ const afterAnswerResult = await options.afterAnswerCb({
1252
+ Runtime,
1253
+ Page,
1254
+ Input,
1255
+ answer: {
1256
+ text: answerText,
1257
+ markdown: answerMarkdown,
1258
+ html: answerHtml || undefined,
1259
+ meta: answer.meta,
1260
+ },
1261
+ });
1262
+ preserveBrowserAfterComplete = Boolean(afterAnswerResult?.keepBrowserOpen);
1263
+ if (preserveBrowserAfterComplete) {
1264
+ await revealAuthenticatedWindow("debug-retention");
1265
+ }
1266
+ }
1267
+ runStatus = "complete";
1268
+ const durationMs = Date.now() - startedAt;
1269
+ const answerChars = answerText.length;
1270
+ const answerTokens = estimateTokenCount(answerMarkdown);
1271
+ return {
1272
+ answerText,
1273
+ answerMarkdown,
1274
+ answerHtml: answerHtml.length > 0 ? answerHtml : undefined,
1275
+ tookMs: durationMs,
1276
+ answerTokens,
1277
+ answerChars,
1278
+ chromePid: chrome.pid,
1279
+ chromePort: chrome.port,
1280
+ chromeHost,
1281
+ userDataDir,
1282
+ chromeTargetId: lastTargetId,
1283
+ tabUrl: lastUrl,
1284
+ controllerPid: process.pid,
1285
+ };
1286
+ }
1287
+ catch (error) {
1288
+ let normalizedError = error instanceof Error ? error : new Error(String(error));
1289
+ if (manualLoginRecoveryInProgress && isWebSocketClosureError(normalizedError)) {
1290
+ const livePort = await readDevToolsPort(userDataDir);
1291
+ const endpoint = livePort
1292
+ ? await verifyDevToolsReachable({
1293
+ host: chromeHost,
1294
+ port: livePort,
1295
+ attempts: 2,
1296
+ timeoutMs: 1000,
1297
+ })
1298
+ : null;
1299
+ if (endpoint?.ok) {
1300
+ normalizedError = new BrowserAutomationError("ChatGPT login target changed; discovering the authenticated replacement.", { stage: "login-required" }, normalizedError);
1301
+ connectionClosedUnexpectedly = false;
1302
+ }
1303
+ }
1304
+ preserveWindowStateOnError =
1305
+ options.shouldPreserveWindowStateOnError?.(normalizedError) ?? false;
1306
+ const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
1307
+ connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
1308
+ if (shouldPreserveBrowserOnError(normalizedError, config.headless)) {
1309
+ preserveBrowserOnError = true;
1310
+ await revealAuthenticatedWindow("manual-recovery");
1311
+ const stage = normalizedError instanceof BrowserAutomationError
1312
+ ? String(normalizedError.details?.stage ?? "")
1313
+ : "";
1314
+ const isLoginRequired = stage === "login-required";
1315
+ const runtime = {
1316
+ chromePid: chrome.pid,
1317
+ chromePort: chrome.port,
1318
+ chromeHost,
1319
+ userDataDir,
1320
+ chromeTargetId: lastTargetId,
1321
+ tabUrl: lastUrl,
1322
+ controllerPid: process.pid,
1323
+ };
1324
+ const reuseProfileHint = `ask-pro --resume <session-id> # browser profile: ${JSON.stringify(userDataDir)}`;
1325
+ await emitRuntimeHint();
1326
+ logger(isLoginRequired
1327
+ ? "ChatGPT login required; leaving browser open so you can sign in."
1328
+ : "Cloudflare challenge detected; leaving browser open so you can complete the check.");
1329
+ logger(`Reuse this browser profile with: ${reuseProfileHint}`);
1330
+ if (isLoginRequired && manualLogin) {
1331
+ const recoveryRuntime = client?.Runtime;
1332
+ if (!recoveryRuntime) {
1333
+ throw normalizedError;
1334
+ }
1335
+ manualLoginRecoveryInProgress = true;
1336
+ try {
1337
+ logger("Manual login mode: waiting for sign-in to complete, then restarting the ask-pro submission...");
1338
+ await openLoginSurfaceForHumanAction(recoveryRuntime, logger).catch(() => undefined);
1339
+ await revealAuthenticatedWindow("login-required");
1340
+ const recovered = await waitForManualLoginOnLiveChrome({
1341
+ host: chromeHost,
1342
+ port: chrome.port,
1343
+ userDataDir,
1344
+ previousTargetId: isolatedTargetId,
1345
+ previousTargetOwned: ownsTarget,
1346
+ logger,
1347
+ timeoutMs: Math.min(config.manualLoginWaitMs ?? config.timeoutMs, config.timeoutMs),
1348
+ });
1349
+ logger("Manual login completed; restarting ask-pro submission.");
1350
+ preserveBrowserOnError = false;
1351
+ await client?.close().catch(() => undefined);
1352
+ if (isolatedTargetId && ownsTarget) {
1353
+ await closeTab(chrome.port, isolatedTargetId, logger, chromeHost);
1354
+ ownsTarget = false;
1355
+ }
1356
+ if (chrome.port !== recovered.port) {
1357
+ const replacementChrome = await maybeReuseRunningChrome(userDataDir, logger);
1358
+ if (!replacementChrome || replacementChrome.port !== recovered.port) {
1359
+ throw new BrowserAutomationError("Managed Chrome lifecycle changed during login and could not be reacquired.", { stage: "connection-lost" });
1360
+ }
1361
+ releaseChromeProcessHandle(chrome);
1362
+ Object.assign(chrome, replacementChrome);
1363
+ }
1364
+ isolatedTargetId = recovered.ownsTarget ? recovered.targetId : null;
1365
+ ownsTarget = recovered.ownsTarget;
1366
+ connectionClosedUnexpectedly = false;
1367
+ const restartedResult = await runBrowserMode(config.browserTabRef
1368
+ ? {
1369
+ ...options,
1370
+ config: { ...options.config, browserTabRef: recovered.targetId },
1371
+ }
1372
+ : options);
1373
+ runStatus = "complete";
1374
+ return restartedResult;
1375
+ }
1376
+ finally {
1377
+ manualLoginRecoveryInProgress = false;
1378
+ }
1379
+ }
1380
+ throw new BrowserAutomationError(isLoginRequired
1381
+ ? "ChatGPT login required. Sign in in the open browser, then rerun."
1382
+ : "Cloudflare challenge detected. Complete the “Just a moment…” check in the open browser, then rerun.", {
1383
+ stage: isLoginRequired ? "login-required" : "cloudflare-challenge",
1384
+ runtime,
1385
+ reuseProfileHint,
1386
+ }, normalizedError);
1387
+ }
1388
+ if (!socketClosed) {
1389
+ logger(`Failed to complete ChatGPT run: ${normalizedError.message}`);
1390
+ if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) {
1391
+ logger(normalizedError.stack);
1392
+ }
1393
+ throw normalizedError;
1394
+ }
1395
+ if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) {
1396
+ logger(`Managed Chrome connection lost before completion: ${normalizedError.message}`);
1397
+ logger(normalizedError.stack);
1398
+ }
1399
+ await emitRuntimeHint();
1400
+ throw new BrowserAutomationError("Managed Chrome process or DevTools target was lost before ask-pro finished. The session remains resumable.", {
1401
+ stage: "connection-lost",
1402
+ runtime: {
1403
+ chromePid: chrome.pid,
1404
+ chromePort: chrome.port,
1405
+ chromeHost,
1406
+ userDataDir,
1407
+ chromeTargetId: lastTargetId,
1408
+ tabUrl: lastUrl,
1409
+ controllerPid: process.pid,
1410
+ },
1411
+ }, normalizedError);
1412
+ }
1413
+ finally {
1414
+ stopHumanInterventionMonitor?.();
1415
+ const keepBrowserOpen = preserveBrowserAfterComplete ||
1416
+ preserveBrowserOnError ||
1417
+ (effectiveKeepBrowser && runStatus !== "complete");
1418
+ try {
1419
+ const guardDisabled = await disablePostSubmitInputGuard();
1420
+ if (!guardDisabled && (keepBrowserOpen || connectionClosedUnexpectedly)) {
1421
+ logger("[browser] Post-submit input guard may still be active; retained browser may require closing and relaunching.");
1422
+ }
1423
+ if (keepBrowserOpen && !preserveWindowStateOnError) {
1424
+ await revealAuthenticatedWindow("browser-retained");
1425
+ }
1426
+ else if (connectionClosedUnexpectedly) {
1427
+ await revealAuthenticatedWindow("connection-lost");
1428
+ }
1429
+ if (!connectionClosedUnexpectedly) {
1430
+ await client?.close();
1431
+ }
1432
+ }
1433
+ catch {
1434
+ // ignore
1435
+ }
1436
+ // Close the isolated tab once the response has been fully captured to prevent
1437
+ // tab accumulation across repeated runs. Keep the tab open on incomplete runs
1438
+ // so reattach can recover the response.
1439
+ if (runStatus === "complete" &&
1440
+ !preserveBrowserAfterComplete &&
1441
+ isolatedTargetId &&
1442
+ chrome?.port &&
1443
+ ownsTarget) {
1444
+ await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
1445
+ }
1446
+ await closeLaunchTabs();
1447
+ try {
1448
+ let peerRunCount = 0;
1449
+ if (managedChromeRunLease) {
1450
+ try {
1451
+ await acquireProfileLockIfNeeded(lifecycleLockTimeoutMs);
1452
+ peerRunCount = await releaseManagedChromeRunLeaseAndCountPeers(userDataDir, managedChromeRunLease, logger);
1453
+ managedChromeRunLease = null;
1454
+ }
1455
+ catch (error) {
1456
+ peerRunCount = null;
1457
+ const message = error instanceof Error ? error.message : String(error);
1458
+ logger(`Failed to resolve shared Chrome ownership; retaining browser (${message})`);
1459
+ }
1460
+ }
1461
+ let remainingTargets = null;
1462
+ if (peerRunCount === 0 && !keepBrowserOpen && !connectionClosedUnexpectedly) {
1463
+ try {
1464
+ remainingTargets = await listRemoteChromeTargets({ host: chromeHost, port: chrome.port });
1465
+ }
1466
+ catch (error) {
1467
+ const message = error instanceof Error ? error.message : String(error);
1468
+ logger(`Failed to inspect remaining Chrome tabs; retaining browser (${message})`);
1469
+ }
1470
+ }
1471
+ const cleanupDecision = decideManagedChromeCleanup({
1472
+ keepBrowserOpen,
1473
+ connectionClosedUnexpectedly,
1474
+ peerRunCount,
1475
+ remainingTargets,
1476
+ completedRunTargetId: ownsTarget ? isolatedTargetId : null,
1477
+ });
1478
+ removeDialogHandler?.();
1479
+ removeTerminationHooks?.();
1480
+ if (!keepBrowserOpen) {
1481
+ if (cleanupDecision === "close-browser") {
1482
+ try {
1483
+ await closeChromeGracefully(chrome, logger);
1484
+ }
1485
+ catch {
1486
+ // ignore close failures
1487
+ }
1488
+ }
1489
+ else if (cleanupDecision === "retain-browser") {
1490
+ releaseChromeProcessHandle(chrome);
1491
+ logger(`Chrome left running on port ${chrome.port} with profile ${userDataDir}`);
1492
+ }
1493
+ if (manualLogin) {
1494
+ const shouldCleanup = cleanupDecision !== "retain-browser" &&
1495
+ (await shouldCleanupManualLoginProfileState(userDataDir, logger.verbose ? logger : undefined, {
1496
+ connectionClosedUnexpectedly,
1497
+ host: chromeHost,
1498
+ }));
1499
+ if (shouldCleanup) {
1500
+ // Preserve the persistent manual-login profile, but clear stale reattach hints.
1501
+ await cleanupStaleProfileState(userDataDir, logger, {
1502
+ lockRemovalMode: "never",
1503
+ }).catch(() => undefined);
1504
+ }
1505
+ }
1506
+ else if (cleanupDecision !== "retain-browser") {
1507
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
1508
+ }
1509
+ if (!connectionClosedUnexpectedly) {
1510
+ const totalSeconds = (Date.now() - startedAt) / 1000;
1511
+ logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`);
1512
+ }
1513
+ }
1514
+ else if (!connectionClosedUnexpectedly) {
1515
+ releaseChromeProcessHandle(chrome);
1516
+ logger(`Chrome left running on port ${chrome.port} with profile ${userDataDir}`);
1517
+ }
1518
+ }
1519
+ finally {
1520
+ await releaseProfileLockIfHeld();
1521
+ }
1522
+ }
1523
+ }
1524
+ const DEFAULT_DEBUG_PORT = 9222;
1525
+ async function pickAvailableDebugPort(preferredPort, logger) {
1526
+ const start = Number.isFinite(preferredPort) && preferredPort > 0 ? preferredPort : DEFAULT_DEBUG_PORT;
1527
+ for (let offset = 0; offset < 10; offset++) {
1528
+ const candidate = start + offset;
1529
+ if (await isPortAvailable(candidate)) {
1530
+ return candidate;
1531
+ }
1532
+ }
1533
+ const fallback = await findEphemeralPort();
1534
+ logger(`DevTools ports ${start}-${start + 9} are occupied; falling back to ${fallback}.`);
1535
+ return fallback;
1536
+ }
1537
+ async function isPortAvailable(port) {
1538
+ return new Promise((resolve) => {
1539
+ const server = net.createServer();
1540
+ server.once("error", () => resolve(false));
1541
+ server.once("listening", () => {
1542
+ server.close(() => resolve(true));
1543
+ });
1544
+ server.listen(port, "127.0.0.1");
1545
+ });
1546
+ }
1547
+ async function findEphemeralPort() {
1548
+ return new Promise((resolve, reject) => {
1549
+ const server = net.createServer();
1550
+ server.once("error", (error) => {
1551
+ server.close();
1552
+ reject(error);
1553
+ });
1554
+ server.listen(0, "127.0.0.1", () => {
1555
+ const address = server.address();
1556
+ if (address && typeof address === "object") {
1557
+ const port = address.port;
1558
+ server.close(() => resolve(port));
1559
+ }
1560
+ else {
1561
+ server.close(() => reject(new Error("Failed to acquire ephemeral port")));
1562
+ }
1563
+ });
1564
+ });
1565
+ }
1566
+ async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, onAuthNeeded, ensureLoggedInFn, }) {
1567
+ const checkLogin = ensureLoggedInFn ?? ensureLoggedIn;
1568
+ const notifyAuthNeeded = async () => {
1569
+ try {
1570
+ await onAuthNeeded?.();
1571
+ }
1572
+ catch (hookError) {
1573
+ logger(`Failed to reveal browser for auth recovery: ${hookError instanceof Error ? hookError.message : String(hookError)}`);
1574
+ }
1575
+ };
1576
+ if (!manualLogin) {
1577
+ try {
1578
+ await checkLogin(runtime, logger, { appliedCookies });
1579
+ }
1580
+ catch (error) {
1581
+ await notifyAuthNeeded();
1582
+ throw error;
1583
+ }
1584
+ return;
1585
+ }
1586
+ try {
1587
+ await checkLogin(runtime, logger, { appliedCookies });
1588
+ }
1589
+ catch (error) {
1590
+ await notifyAuthNeeded();
1591
+ const message = error instanceof Error ? error.message : String(error);
1592
+ if (!isRecoverableManualLoginMessage(message))
1593
+ throw error;
1594
+ throw new BrowserAutomationError("ChatGPT login required; waiting for sign-in in the open managed browser.", { stage: "login-required" }, error);
1595
+ }
1596
+ }
1597
+ function isRecoverableManualLoginMessage(message) {
1598
+ const normalized = message.toLowerCase();
1599
+ return (normalized.includes("session not detected") ||
1600
+ normalized.includes("login button") ||
1601
+ normalized.includes("login appears missing") ||
1602
+ normalized.includes("not signed into chatgpt") ||
1603
+ normalized.includes("no chatgpt cookies") ||
1604
+ normalized.includes("sign in to chatgpt"));
1605
+ }
1606
+ async function waitForManualLoginOnLiveChrome({ host, port, userDataDir, previousTargetId, previousTargetOwned, logger, timeoutMs, }) {
1607
+ const deadline = Date.now() + timeoutMs;
1608
+ let lastNotice = 0;
1609
+ let currentPort = port;
1610
+ while (Date.now() < deadline) {
1611
+ const savedPort = await readDevToolsPort(userDataDir);
1612
+ if (savedPort && savedPort !== currentPort) {
1613
+ currentPort = savedPort;
1614
+ logger(`Managed Chrome DevTools endpoint changed during login; continuing on port ${savedPort}.`);
1615
+ }
1616
+ let targets;
1617
+ try {
1618
+ targets = await listLoginRecoveryTargets(host, currentPort);
1619
+ }
1620
+ catch {
1621
+ const replacementPort = await readDevToolsPort(userDataDir);
1622
+ if (replacementPort && replacementPort !== currentPort) {
1623
+ currentPort = replacementPort;
1624
+ logger(`Managed Chrome DevTools endpoint changed during login; continuing on port ${replacementPort}.`);
1625
+ continue;
1626
+ }
1627
+ const endpoint = await verifyDevToolsReachable({
1628
+ host,
1629
+ port: currentPort,
1630
+ attempts: 1,
1631
+ timeoutMs: 1000,
1632
+ });
1633
+ if (!endpoint.ok) {
1634
+ throw new BrowserAutomationError("Managed Chrome process or DevTools endpoint was lost during manual login. The session remains resumable.", { stage: "connection-lost" });
1635
+ }
1636
+ targets = [];
1637
+ }
1638
+ for (const target of targets) {
1639
+ const targetId = target.targetId ?? target.id;
1640
+ if (!targetId)
1641
+ continue;
1642
+ let client = null;
1643
+ try {
1644
+ client = (await CDP({
1645
+ host,
1646
+ port: currentPort,
1647
+ target: targetId,
1648
+ }));
1649
+ const targetInfo = await client.Target.getTargetInfo({ targetId }).catch(() => null);
1650
+ const openerId = targetInfo?.targetInfo?.openerId;
1651
+ if (!isEligibleManualLoginRecoveryTarget(previousTargetOwned, previousTargetId, targetId, openerId)) {
1652
+ continue;
1653
+ }
1654
+ const remainingMs = deadline - Date.now();
1655
+ if (remainingMs <= 0)
1656
+ break;
1657
+ await withTimeout(ensureLoggedIn(client.Runtime, logger, { appliedCookies: 0, passive: true }), remainingMs, "Manual login recovery probe timed out");
1658
+ return {
1659
+ port: currentPort,
1660
+ targetId,
1661
+ ownsTarget: isRecoveredTargetOwned(previousTargetOwned, previousTargetId, targetId, openerId),
1662
+ };
1663
+ }
1664
+ catch (error) {
1665
+ if (logger.verbose) {
1666
+ logger(`Manual login passive probe failed: ${error instanceof Error ? error.message : String(error)}`);
1667
+ }
1668
+ }
1669
+ finally {
1670
+ await client?.close().catch(() => undefined);
1671
+ }
1672
+ }
1673
+ if (Date.now() >= deadline)
1674
+ break;
1675
+ const now = Date.now();
1676
+ if (now - lastNotice > 5000) {
1677
+ logger("Manual login mode: waiting in the opened Chrome login tab...");
1678
+ lastNotice = now;
1679
+ }
1680
+ await delay(1000);
1681
+ }
1682
+ throw new BrowserAutomationError("Manual login mode timed out waiting for ChatGPT session; sign in in the open browser, then resume.", { stage: "login-required" });
1683
+ }
1684
+ function isRecoveredTargetOwned(previousTargetOwned, previousTargetId, recoveredTargetId, openerId) {
1685
+ return (previousTargetOwned && isRelatedManualLoginTarget(previousTargetId, recoveredTargetId, openerId));
1686
+ }
1687
+ function isEligibleManualLoginRecoveryTarget(previousTargetOwned, previousTargetId, candidateTargetId, openerId) {
1688
+ return (previousTargetOwned || isRelatedManualLoginTarget(previousTargetId, candidateTargetId, openerId));
1689
+ }
1690
+ function isRelatedManualLoginTarget(previousTargetId, candidateTargetId, openerId) {
1691
+ return (previousTargetId !== null &&
1692
+ (candidateTargetId === previousTargetId || openerId === previousTargetId));
1693
+ }
1694
+ async function listLoginRecoveryTargets(host, port) {
1695
+ const targets = await listRemoteChromeTargets({ host, port });
1696
+ const pages = targets.filter((target) => !target.type || target.type === "page");
1697
+ return pages.filter((target) => isLoginRecoveryUrl(target.url));
1698
+ }
1699
+ function isLoginRecoveryUrl(url) {
1700
+ if (!url)
1701
+ return false;
1702
+ try {
1703
+ const parsed = new URL(url);
1704
+ return (isAllowedLoginRecoveryHost(parsed.hostname, "chatgpt.com") ||
1705
+ isAllowedLoginRecoveryHost(parsed.hostname, "openai.com") ||
1706
+ isAllowedLoginRecoveryHost(parsed.hostname, "auth0.com"));
1707
+ }
1708
+ catch {
1709
+ return false;
1710
+ }
1711
+ }
1712
+ function isAllowedLoginRecoveryHost(hostname, domain) {
1713
+ const normalized = hostname.toLowerCase();
1714
+ return normalized === domain || normalized.endsWith(`.${domain}`);
1715
+ }
1716
+ async function maybeRecoverLongAssistantResponse({ runtime, baselineTurns, expectedConversationId, answerText, answerMarkdown, logger, allowMarkdownUpdate, }) {
1717
+ // Learned: long streaming responses can still be rendering after initial capture.
1718
+ // Add a brief delay and re-poll to catch any additional content (#71).
1719
+ const capturedLength = answerText.trim().length;
1720
+ if (capturedLength <= 500) {
1721
+ return { answerText, answerMarkdown };
1722
+ }
1723
+ await delay(1500);
1724
+ let bestLength = capturedLength;
1725
+ let bestText = answerText;
1726
+ for (let i = 0; i < 5; i++) {
1727
+ const laterSnapshot = await readAssistantSnapshot(runtime, baselineTurns ?? undefined, expectedConversationId).catch(() => null);
1728
+ const laterText = typeof laterSnapshot?.text === "string" ? laterSnapshot.text.trim() : "";
1729
+ if (laterText.length > bestLength) {
1730
+ bestLength = laterText.length;
1731
+ bestText = laterText;
1732
+ await delay(800); // More content appeared, keep waiting
1733
+ }
1734
+ else {
1735
+ break; // Stable, stop polling
1736
+ }
1737
+ }
1738
+ if (bestLength > capturedLength) {
1739
+ logger(`Recovered ${bestLength - capturedLength} additional chars via delayed re-read`);
1740
+ return {
1741
+ answerText: bestText,
1742
+ answerMarkdown: allowMarkdownUpdate ? bestText : answerMarkdown,
1743
+ };
1744
+ }
1745
+ return { answerText, answerMarkdown };
1746
+ }
1747
+ async function _assertNavigatedToHttp(runtime, _logger, timeoutMs = 10_000) {
1748
+ const deadline = Date.now() + timeoutMs;
1749
+ let lastUrl = "";
1750
+ while (Date.now() < deadline) {
1751
+ const { result } = await runtime.evaluate({
1752
+ expression: 'typeof location === "object" && location.href ? location.href : ""',
1753
+ returnByValue: true,
1754
+ });
1755
+ const url = typeof result?.value === "string" ? result.value : "";
1756
+ lastUrl = url;
1757
+ if (/^https?:\/\//i.test(url)) {
1758
+ return url;
1759
+ }
1760
+ await delay(250);
1761
+ }
1762
+ throw new BrowserAutomationError("ChatGPT session not detected; page never left new tab.", {
1763
+ stage: "execute-browser",
1764
+ details: { url: lastUrl || "(empty)" },
1765
+ });
1766
+ }
1767
+ async function runRemoteBrowserMode(promptText, attachments, config, logger, options) {
1768
+ const remoteChromeConfig = config.remoteChrome;
1769
+ if (!remoteChromeConfig) {
1770
+ throw new Error("Remote Chrome configuration missing. Pass --remote-chrome <host:port> to use this mode.");
1771
+ }
1772
+ const { host, port } = remoteChromeConfig;
1773
+ logger(`Connecting to remote Chrome at ${host}:${port}`);
1774
+ let client = null;
1775
+ let remoteTargetId = null;
1776
+ let lastUrl;
1777
+ let expectedConversationUrl;
1778
+ let expectedConversationId;
1779
+ let attachedExistingTab = false;
1780
+ let ownsTarget = true;
1781
+ const runtimeHintCb = options.runtimeHintCb;
1782
+ const emitRuntimeHint = async () => {
1783
+ if (!runtimeHintCb)
1784
+ return;
1785
+ try {
1786
+ const conversationId = lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined;
1787
+ await runtimeHintCb({
1788
+ chromePort: port,
1789
+ chromeHost: host,
1790
+ chromeBrowserWSEndpoint: browserWSEndpoint,
1791
+ chromeProfileRoot,
1792
+ chromeTargetId: remoteTargetId ?? undefined,
1793
+ tabUrl: lastUrl,
1794
+ conversationId,
1795
+ controllerPid: process.pid,
1796
+ });
1797
+ }
1798
+ catch (error) {
1799
+ const message = error instanceof Error ? error.message : String(error);
1800
+ logger(`Failed to persist runtime hint: ${message}`);
1801
+ }
1802
+ };
1803
+ const startedAt = Date.now();
1804
+ let answerText = "";
1805
+ let answerMarkdown = "";
1806
+ let answerHtml = "";
1807
+ let connectionClosedUnexpectedly = false;
1808
+ let stopThinkingMonitor = null;
1809
+ let removeDialogHandler = null;
1810
+ let connection = null;
1811
+ const browserWSEndpoint = config.remoteChromeBrowserWSEndpoint ?? undefined;
1812
+ const chromeProfileRoot = config.remoteChromeProfileRoot ?? undefined;
1813
+ try {
1814
+ if (config.browserTabRef) {
1815
+ const attached = await connectToExistingChatGptTab({
1816
+ host,
1817
+ port,
1818
+ ref: config.browserTabRef,
1819
+ });
1820
+ client = attached.client;
1821
+ remoteTargetId = attached.targetId ?? null;
1822
+ lastUrl = attached.tab.url || lastUrl;
1823
+ attachedExistingTab = true;
1824
+ ownsTarget = false;
1825
+ logger(`Attached to existing remote ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`);
1826
+ }
1827
+ else {
1828
+ connection = await connectToRemoteChrome(host, port, logger, "about:blank", browserWSEndpoint, {
1829
+ approvalWaitMs: config.attachRunning && browserWSEndpoint ? 20_000 : undefined,
1830
+ });
1831
+ client = connection.client;
1832
+ remoteTargetId = connection.targetId ?? null;
1833
+ ownsTarget = true;
1834
+ }
1835
+ await emitRuntimeHint();
1836
+ const markConnectionLost = () => {
1837
+ connectionClosedUnexpectedly = true;
1838
+ };
1839
+ client.on("disconnect", markConnectionLost);
1840
+ const disconnectPromise = new Promise((_, reject) => {
1841
+ client?.on("disconnect", () => {
1842
+ connectionClosedUnexpectedly = true;
1843
+ reject(new Error("Remote Chrome connection lost during browser automation."));
1844
+ });
1845
+ });
1846
+ const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
1847
+ const lockConversationUrl = async (candidateUrl, label) => {
1848
+ if (!candidateUrl || !isConversationUrl(candidateUrl)) {
1849
+ return false;
1850
+ }
1851
+ const candidateId = extractConversationIdFromUrl(candidateUrl);
1852
+ if (!candidateId) {
1853
+ return false;
1854
+ }
1855
+ if (expectedConversationId && candidateId !== expectedConversationId) {
1856
+ logger(`[browser] Ignoring conversation drift (${label}); expected ${expectedConversationUrl}, saw ${candidateUrl}`);
1857
+ return false;
1858
+ }
1859
+ expectedConversationUrl = candidateUrl;
1860
+ expectedConversationId = candidateId;
1861
+ lastUrl = candidateUrl;
1862
+ logger(`[browser] conversation url (${label}) = ${candidateUrl}`);
1863
+ await emitRuntimeHint();
1864
+ return true;
1865
+ };
1866
+ const updateConversationHint = async (label, timeoutMs = 10_000) => {
1867
+ const start = Date.now();
1868
+ while (Date.now() - start < timeoutMs) {
1869
+ try {
1870
+ const { result } = await Runtime.evaluate({
1871
+ expression: "location.href",
1872
+ returnByValue: true,
1873
+ });
1874
+ if (typeof result?.value === "string" &&
1875
+ (await lockConversationUrl(result.value, label))) {
1876
+ return true;
1877
+ }
1878
+ }
1879
+ catch {
1880
+ // ignore; keep polling until timeout
1881
+ }
1882
+ await delay(250);
1883
+ }
1884
+ return false;
1885
+ };
1886
+ const ensureExpectedConversation = async (label) => {
1887
+ if (!expectedConversationUrl || !expectedConversationId) {
1888
+ return false;
1889
+ }
1890
+ const currentUrl = await readConversationUrl(Runtime);
1891
+ const currentId = currentUrl ? extractConversationIdFromUrl(currentUrl) : undefined;
1892
+ if (currentId === expectedConversationId) {
1893
+ if (currentUrl && currentUrl !== lastUrl) {
1894
+ lastUrl = currentUrl;
1895
+ await emitRuntimeHint();
1896
+ }
1897
+ return true;
1898
+ }
1899
+ logger(`[browser] Conversation drifted during ${label}; restoring ${expectedConversationUrl}`);
1900
+ await raceWithDisconnect(Page.navigate({ url: expectedConversationUrl }));
1901
+ await raceWithDisconnect(delay(1000));
1902
+ lastUrl = expectedConversationUrl;
1903
+ await emitRuntimeHint();
1904
+ return true;
1905
+ };
1906
+ const { Network, Page, Runtime, Input, DOM } = client;
1907
+ const postSubmitInputGuard = shouldEnablePostSubmitInputGuard(config)
1908
+ ? createPostSubmitInputGuard(Input, logger)
1909
+ : null;
1910
+ const continueResponse = createAssistantContinuation(Runtime, Input, logger, postSubmitInputGuard);
1911
+ const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
1912
+ if (DOM && typeof DOM.enable === "function") {
1913
+ domainEnablers.push(DOM.enable());
1914
+ }
1915
+ await Promise.all(domainEnablers);
1916
+ if (config.acceptLanguage) {
1917
+ await applyPageLanguageOverrides(client, config.acceptLanguage, logger);
1918
+ }
1919
+ removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
1920
+ // Skip cookie sync for remote Chrome - it already has cookies
1921
+ logger("Skipping cookie sync for remote Chrome (using existing session)");
1922
+ if (!attachedExistingTab) {
1923
+ await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.url, logger));
1924
+ await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
1925
+ await raceWithDisconnect(ensureLoggedIn(Runtime, logger, { remoteSession: true }));
1926
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1927
+ }
1928
+ else {
1929
+ await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
1930
+ await raceWithDisconnect(ensureLoggedIn(Runtime, logger, { remoteSession: true }));
1931
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1932
+ }
1933
+ logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
1934
+ try {
1935
+ const { result } = await Runtime.evaluate({
1936
+ expression: "location.href",
1937
+ returnByValue: true,
1938
+ });
1939
+ if (typeof result?.value === "string") {
1940
+ lastUrl = result.value;
1941
+ }
1942
+ await emitRuntimeHint();
1943
+ }
1944
+ catch {
1945
+ // ignore
1946
+ }
1947
+ const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
1948
+ if (config.desiredModel && modelStrategy !== "ignore") {
1949
+ await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
1950
+ retries: 2,
1951
+ delayMs: 300,
1952
+ onRetry: (attempt, error) => {
1953
+ if (options.verbose) {
1954
+ logger(`[retry] Model picker attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
1955
+ }
1956
+ },
1957
+ });
1958
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1959
+ logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
1960
+ }
1961
+ else if (modelStrategy === "ignore") {
1962
+ logger("Model picker: skipped (strategy=ignore)");
1963
+ }
1964
+ // Handle thinking time selection if specified
1965
+ const thinkingTime = config.thinkingTime;
1966
+ if (thinkingTime) {
1967
+ await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
1968
+ retries: 2,
1969
+ delayMs: 300,
1970
+ onRetry: (attempt, error) => {
1971
+ if (options.verbose) {
1972
+ logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
1973
+ }
1974
+ },
1975
+ }));
1976
+ }
1977
+ const submitOnce = async (prompt, submissionAttachments) => {
1978
+ try {
1979
+ const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
1980
+ const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
1981
+ const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
1982
+ if (submissionAttachments.length > 0) {
1983
+ if (!DOM) {
1984
+ throw new Error("Chrome DOM domain unavailable while uploading attachments.");
1985
+ }
1986
+ await clearComposerAttachments(Runtime, 5_000, logger);
1987
+ // Use remote file transfer for remote Chrome (reads local files and injects via CDP)
1988
+ for (const attachment of submissionAttachments) {
1989
+ logger(`Uploading attachment: ${attachment.displayPath}`);
1990
+ await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
1991
+ await delay(500);
1992
+ }
1993
+ // Scale timeout based on number of files: base 30s + 15s per additional file
1994
+ const baseTimeout = config.inputTimeoutMs ?? 30_000;
1995
+ const perFileTimeout = 15_000;
1996
+ const waitBudget = Math.max(baseTimeout, 30_000) + (submissionAttachments.length - 1) * perFileTimeout;
1997
+ await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
1998
+ logger("All attachments uploaded");
1999
+ }
2000
+ let baselineTurns = await readConversationTurnCount(Runtime, logger);
2001
+ const providerState = {
2002
+ runtime: Runtime,
2003
+ input: Input,
2004
+ logger,
2005
+ timeoutMs: config.timeoutMs,
2006
+ inputTimeoutMs: config.inputTimeoutMs ?? undefined,
2007
+ baselineTurns: baselineTurns ?? undefined,
2008
+ attachmentNames,
2009
+ afterSubmit: postSubmitInputGuard ? () => postSubmitInputGuard.enable() : undefined,
2010
+ };
2011
+ await runProviderSubmissionFlow(chatgptDomProvider, {
2012
+ prompt,
2013
+ evaluate: async () => undefined,
2014
+ delay,
2015
+ log: logger,
2016
+ state: providerState,
2017
+ });
2018
+ const providerBaselineTurns = providerState.baselineTurns;
2019
+ if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
2020
+ baselineTurns = providerBaselineTurns;
2021
+ }
2022
+ await updateConversationHint("post-submit", 15_000).catch(() => false);
2023
+ return { baselineTurns, baselineAssistantText };
2024
+ }
2025
+ catch (error) {
2026
+ await postSubmitInputGuard?.disable();
2027
+ throw error;
2028
+ }
2029
+ };
2030
+ const reloadPromptComposer = async () => {
2031
+ logger("[browser] Composer became unresponsive; reloading page and retrying once.");
2032
+ await raceWithDisconnect(Page.reload({ ignoreCache: true }));
2033
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
2034
+ };
2035
+ let baselineTurns = null;
2036
+ let baselineAssistantText = null;
2037
+ const submission = await runSubmissionWithRecovery({
2038
+ prompt: promptText,
2039
+ attachments,
2040
+ fallbackSubmission: options.fallbackSubmission,
2041
+ submit: (submissionPrompt, submissionAttachments) => raceWithDisconnect(submitOnce(submissionPrompt, submissionAttachments)),
2042
+ reloadPromptComposer,
2043
+ prepareFallbackSubmission: async () => {
2044
+ await raceWithDisconnect(clearPromptComposer(Runtime, logger));
2045
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
2046
+ },
2047
+ logger,
2048
+ });
2049
+ baselineTurns = submission.baselineTurns;
2050
+ baselineAssistantText = submission.baselineAssistantText;
2051
+ // Helper to normalize text for echo detection (collapse whitespace, lowercase)
2052
+ const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, " ").trim();
2053
+ const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => {
2054
+ const baselinePrefix = baselineNormalized.length >= 80
2055
+ ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
2056
+ : "";
2057
+ const deadline = Date.now() + timeoutMs;
2058
+ while (Date.now() < deadline) {
2059
+ const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId).catch(() => null);
2060
+ const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
2061
+ if (text) {
2062
+ const normalized = normalizeForComparison(text);
2063
+ const isBaseline = normalized === baselineNormalized ||
2064
+ (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
2065
+ if (!isBaseline) {
2066
+ return {
2067
+ text,
2068
+ html: snapshot?.html ?? undefined,
2069
+ meta: {
2070
+ turnId: snapshot?.turnId ?? undefined,
2071
+ messageId: snapshot?.messageId ?? undefined,
2072
+ },
2073
+ };
2074
+ }
2075
+ }
2076
+ await delay(350);
2077
+ }
2078
+ return null;
2079
+ };
2080
+ let answer;
2081
+ const waitWithThinkingMonitor = async (operation) => {
2082
+ stopThinkingMonitor?.();
2083
+ stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, {
2084
+ intervalMs: options.heartbeatIntervalMs,
2085
+ });
2086
+ try {
2087
+ return await operation();
2088
+ }
2089
+ finally {
2090
+ stopThinkingMonitor?.();
2091
+ stopThinkingMonitor = null;
2092
+ }
2093
+ };
2094
+ const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
2095
+ const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
2096
+ const attemptAssistantRecheck = async () => {
2097
+ if (!recheckDelayMs)
2098
+ return null;
2099
+ logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`);
2100
+ await delay(recheckDelayMs);
2101
+ await updateConversationHint("assistant-recheck", 15_000).catch(() => false);
2102
+ await ensureExpectedConversation("assistant-recheck").catch(() => false);
2103
+ const conversationUrl = expectedConversationUrl ?? (await readConversationUrl(Runtime));
2104
+ if (conversationUrl && isConversationUrl(conversationUrl)) {
2105
+ logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
2106
+ await raceWithDisconnect(Page.navigate({ url: conversationUrl }));
2107
+ await raceWithDisconnect(delay(1000));
2108
+ }
2109
+ // Validate session before attempting recheck - sessions can expire during the delay
2110
+ const sessionValid = await validateChatGPTSession(Runtime, logger);
2111
+ if (!sessionValid.valid) {
2112
+ logger(`[browser] Session validation failed: ${sessionValid.reason}`);
2113
+ // Update session metadata to indicate login is needed
2114
+ await emitRuntimeHint();
2115
+ throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
2116
+ `Conversation URL: ${conversationUrl || lastUrl || "unknown"}. ` +
2117
+ `Please sign in and retry.`, {
2118
+ stage: "assistant-recheck",
2119
+ details: {
2120
+ conversationUrl: conversationUrl || lastUrl || null,
2121
+ sessionStatus: "needs_login",
2122
+ validationReason: sessionValid.reason,
2123
+ },
2124
+ runtime: {
2125
+ chromeHost: host,
2126
+ chromePort: port,
2127
+ chromeBrowserWSEndpoint: browserWSEndpoint,
2128
+ chromeProfileRoot,
2129
+ chromeTargetId: remoteTargetId ?? undefined,
2130
+ tabUrl: lastUrl,
2131
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2132
+ controllerPid: process.pid,
2133
+ },
2134
+ });
2135
+ }
2136
+ await emitRuntimeHint();
2137
+ const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
2138
+ const rechecked = await waitWithThinkingMonitor(() => waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined, expectedConversationUrl, expectedConversationId, continueResponse));
2139
+ logger("Recovered assistant response after delayed recheck");
2140
+ return rechecked;
2141
+ };
2142
+ try {
2143
+ try {
2144
+ await ensureExpectedConversation("assistant-wait").catch(() => false);
2145
+ answer = await waitWithThinkingMonitor(() => waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined, expectedConversationUrl, expectedConversationId, continueResponse));
2146
+ }
2147
+ catch (error) {
2148
+ if (isAssistantResponseTimeoutError(error)) {
2149
+ const rechecked = await attemptAssistantRecheck().catch((error) => {
2150
+ if (error instanceof AssistantStoppedError)
2151
+ throw error;
2152
+ return null;
2153
+ });
2154
+ if (rechecked) {
2155
+ answer = rechecked;
2156
+ }
2157
+ else {
2158
+ try {
2159
+ const conversationUrl = expectedConversationUrl ?? (await readConversationUrl(Runtime));
2160
+ if (conversationUrl) {
2161
+ lastUrl = conversationUrl;
2162
+ }
2163
+ }
2164
+ catch {
2165
+ // ignore
2166
+ }
2167
+ await ensureExpectedConversation("assistant-timeout").catch(() => false);
2168
+ await emitRuntimeHint();
2169
+ const runtime = {
2170
+ chromePort: port,
2171
+ chromeHost: host,
2172
+ chromeBrowserWSEndpoint: browserWSEndpoint,
2173
+ chromeProfileRoot,
2174
+ chromeTargetId: remoteTargetId ?? undefined,
2175
+ tabUrl: lastUrl,
2176
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2177
+ controllerPid: process.pid,
2178
+ };
2179
+ throw new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime }, error);
2180
+ }
2181
+ }
2182
+ else {
2183
+ throw error;
2184
+ }
2185
+ }
2186
+ }
2187
+ finally {
2188
+ await postSubmitInputGuard?.disable();
2189
+ }
2190
+ const baselineNormalized = baselineAssistantText
2191
+ ? normalizeForComparison(baselineAssistantText)
2192
+ : "";
2193
+ if (baselineNormalized) {
2194
+ const normalizedAnswer = normalizeForComparison(answer.text ?? "");
2195
+ const baselinePrefix = baselineNormalized.length >= 80
2196
+ ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
2197
+ : "";
2198
+ const isBaseline = normalizedAnswer === baselineNormalized ||
2199
+ (baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
2200
+ if (isBaseline) {
2201
+ logger("Detected stale assistant response; waiting for new response...");
2202
+ const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
2203
+ if (refreshed) {
2204
+ answer = refreshed;
2205
+ }
2206
+ }
2207
+ }
2208
+ answerText = answer.text;
2209
+ answerHtml = answer.html ?? "";
2210
+ const copiedMarkdown = await withRetries(async () => {
2211
+ const attempt = await captureAssistantMarkdown(Runtime, answer.meta, logger);
2212
+ if (!attempt) {
2213
+ throw new Error("copy-missing");
2214
+ }
2215
+ return attempt;
2216
+ }, {
2217
+ retries: 2,
2218
+ delayMs: 350,
2219
+ onRetry: (attempt, error) => {
2220
+ if (options.verbose) {
2221
+ logger(`[retry] Markdown capture attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
2222
+ }
2223
+ },
2224
+ }).catch(() => null);
2225
+ answerMarkdown = copiedMarkdown ?? answerText;
2226
+ ({ answerText, answerMarkdown } = await maybeRecoverLongAssistantResponse({
2227
+ runtime: Runtime,
2228
+ baselineTurns,
2229
+ expectedConversationId,
2230
+ answerText,
2231
+ answerMarkdown,
2232
+ logger,
2233
+ allowMarkdownUpdate: !copiedMarkdown,
2234
+ }));
2235
+ // Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
2236
+ const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId).catch(() => null);
2237
+ const finalText = typeof finalSnapshot?.text === "string" ? finalSnapshot.text.trim() : "";
2238
+ if (finalText &&
2239
+ finalText !== answerMarkdown.trim() &&
2240
+ finalText !== promptText.trim() &&
2241
+ finalText.length >= answerMarkdown.trim().length) {
2242
+ logger("Refreshed assistant response via final DOM snapshot");
2243
+ answerText = finalText;
2244
+ answerMarkdown = finalText;
2245
+ }
2246
+ // Detect prompt echo using normalized comparison (whitespace-insensitive).
2247
+ const promptEchoMatcher = buildPromptEchoMatcher(promptText);
2248
+ const alignedEcho = alignPromptEchoPair(answerText, answerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
2249
+ text: "Aligned assistant response text to copied markdown after prompt echo",
2250
+ markdown: "Aligned assistant markdown to response text after prompt echo",
2251
+ });
2252
+ answerText = alignedEcho.answerText;
2253
+ answerMarkdown = alignedEcho.answerMarkdown;
2254
+ const isPromptEcho = alignedEcho.isEcho;
2255
+ if (isPromptEcho) {
2256
+ logger("Detected prompt echo in response; waiting for actual assistant response...");
2257
+ const deadline = Date.now() + 15_000;
2258
+ let bestText = null;
2259
+ let stableCount = 0;
2260
+ while (Date.now() < deadline) {
2261
+ const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId).catch(() => null);
2262
+ const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
2263
+ const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
2264
+ if (!isStillEcho) {
2265
+ if (!bestText || text.length > bestText.length) {
2266
+ bestText = text;
2267
+ stableCount = 0;
2268
+ }
2269
+ else if (text === bestText) {
2270
+ stableCount += 1;
2271
+ }
2272
+ if (stableCount >= 2) {
2273
+ break;
2274
+ }
2275
+ }
2276
+ await new Promise((resolve) => setTimeout(resolve, 300));
2277
+ }
2278
+ if (bestText) {
2279
+ logger("Recovered assistant response after detecting prompt echo");
2280
+ answerText = bestText;
2281
+ answerMarkdown = bestText;
2282
+ }
2283
+ }
2284
+ if (options.afterAnswerCb) {
2285
+ await options.afterAnswerCb({
2286
+ Runtime,
2287
+ Page,
2288
+ Input,
2289
+ answer: {
2290
+ text: answerText,
2291
+ markdown: answerMarkdown,
2292
+ html: answerHtml || undefined,
2293
+ meta: answer.meta,
2294
+ },
2295
+ });
2296
+ }
2297
+ const durationMs = Date.now() - startedAt;
2298
+ const answerChars = answerText.length;
2299
+ const answerTokens = estimateTokenCount(answerMarkdown);
2300
+ return {
2301
+ answerText,
2302
+ answerMarkdown,
2303
+ answerHtml: answerHtml.length > 0 ? answerHtml : undefined,
2304
+ tookMs: durationMs,
2305
+ answerTokens,
2306
+ answerChars,
2307
+ browserTransport: "cdp",
2308
+ chromePid: undefined,
2309
+ chromePort: port,
2310
+ chromeHost: host,
2311
+ chromeBrowserWSEndpoint: browserWSEndpoint,
2312
+ chromeProfileRoot,
2313
+ userDataDir: undefined,
2314
+ chromeTargetId: remoteTargetId ?? undefined,
2315
+ tabUrl: lastUrl,
2316
+ controllerPid: process.pid,
2317
+ };
2318
+ }
2319
+ catch (error) {
2320
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
2321
+ const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
2322
+ connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
2323
+ if (!socketClosed) {
2324
+ logger(`Failed to complete ChatGPT run: ${normalizedError.message}`);
2325
+ if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) {
2326
+ logger(normalizedError.stack);
2327
+ }
2328
+ throw normalizedError;
2329
+ }
2330
+ throw new BrowserAutomationError("Remote Chrome connection lost before ask-pro finished.", {
2331
+ stage: "connection-lost",
2332
+ runtime: {
2333
+ chromeHost: host,
2334
+ chromePort: port,
2335
+ chromeBrowserWSEndpoint: browserWSEndpoint,
2336
+ chromeProfileRoot,
2337
+ chromeTargetId: remoteTargetId ?? undefined,
2338
+ tabUrl: lastUrl,
2339
+ controllerPid: process.pid,
2340
+ },
2341
+ });
2342
+ }
2343
+ finally {
2344
+ try {
2345
+ if (!connectionClosedUnexpectedly && connection) {
2346
+ await connection.close();
2347
+ }
2348
+ }
2349
+ catch {
2350
+ // ignore
2351
+ }
2352
+ removeDialogHandler?.();
2353
+ if (ownsTarget) {
2354
+ await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
2355
+ }
2356
+ // Don't kill remote Chrome - it's not ours to manage
2357
+ const totalSeconds = (Date.now() - startedAt) / 1000;
2358
+ logger(`Remote session complete • ${totalSeconds.toFixed(1)}s total`);
2359
+ }
2360
+ }
2361
+ export { estimateTokenCount } from "./utils.js";
2362
+ export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from "./config.js";
2363
+ // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
2364
+ export const __test__ = {
2365
+ listIgnoredRemoteChromeFlags,
2366
+ shouldEnablePostSubmitInputGuard,
2367
+ decideManagedChromeCleanup,
2368
+ shouldCaptureLaunchTargetsForCleanup,
2369
+ shouldRetainLaunchedChromeAfterRun,
2370
+ isDisposableLaunchPageUrl,
2371
+ selectDisposableLaunchTargetIds,
2372
+ selectClosableLaunchTargetIds,
2373
+ buildHumanInterventionProbeExpression,
2374
+ detectHumanInterventionReason,
2375
+ isLoginRecoveryUrl,
2376
+ isEligibleManualLoginRecoveryTarget,
2377
+ isRecoveredTargetOwned,
2378
+ waitForLogin,
2379
+ };
2380
+ export { syncCookies } from "./cookies.js";
2381
+ export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from "./pageActions.js";
2382
+ export function isWebSocketClosureError(error) {
2383
+ const message = error.message.toLowerCase();
2384
+ return (message.includes("websocket connection closed") ||
2385
+ message.includes("websocket is closed") ||
2386
+ message.includes("websocket error") ||
2387
+ message.includes("inspected target navigated or closed") ||
2388
+ message.includes("target closed"));
2389
+ }
2390
+ async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, minTurnIndex, expectedConversationUrl, expectedConversationId, continueResponse) {
2391
+ try {
2392
+ return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId, continueResponse);
2393
+ }
2394
+ catch (error) {
2395
+ if (!shouldReloadAfterAssistantError(error)) {
2396
+ throw error;
2397
+ }
2398
+ const conversationUrl = expectedConversationUrl ?? (await readConversationUrl(Runtime));
2399
+ if (!conversationUrl || !isConversationUrl(conversationUrl)) {
2400
+ throw error;
2401
+ }
2402
+ logger("Assistant response stalled; reloading conversation and retrying once");
2403
+ await Page.navigate({ url: conversationUrl });
2404
+ await delay(1000);
2405
+ return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId, continueResponse);
2406
+ }
2407
+ }
2408
+ function shouldReloadAfterAssistantError(error) {
2409
+ if (!(error instanceof Error))
2410
+ return false;
2411
+ const message = error.message.toLowerCase();
2412
+ return (message.includes("assistant-response") ||
2413
+ message.includes("watchdog") ||
2414
+ message.includes("timeout") ||
2415
+ message.includes("capture assistant response"));
2416
+ }
2417
+ function isAssistantResponseTimeoutError(error) {
2418
+ if (!(error instanceof Error))
2419
+ return false;
2420
+ const message = error.message.toLowerCase();
2421
+ if (!message)
2422
+ return false;
2423
+ return (message.includes("assistant-response") ||
2424
+ message.includes("assistant response") ||
2425
+ message.includes("watchdog") ||
2426
+ message.includes("capture assistant response"));
2427
+ }
2428
+ async function readConversationUrl(Runtime) {
2429
+ try {
2430
+ const currentUrl = await Runtime.evaluate({ expression: "location.href", returnByValue: true });
2431
+ return typeof currentUrl.result?.value === "string" ? currentUrl.result.value : null;
2432
+ }
2433
+ catch {
2434
+ return null;
2435
+ }
2436
+ }
2437
+ /**
2438
+ * Validates that the ChatGPT session is still active by checking for login CTAs
2439
+ * and textarea availability. Sessions can expire during long delays (e.g., recheck).
2440
+ *
2441
+ * @param Runtime - Chrome Runtime client
2442
+ * @param logger - Browser logger for diagnostics
2443
+ * @returns SessionValidationResult indicating if session is valid and reason if not
2444
+ */
2445
+ async function validateChatGPTSession(Runtime, logger) {
2446
+ try {
2447
+ const outcome = await Runtime.evaluate({
2448
+ expression: buildSessionValidationExpression(),
2449
+ awaitPromise: true,
2450
+ returnByValue: true,
2451
+ });
2452
+ const result = outcome.result?.value;
2453
+ if (!result) {
2454
+ return { valid: false, reason: "Failed to evaluate session state" };
2455
+ }
2456
+ if (result.onAuthPage) {
2457
+ return { valid: false, reason: "Redirected to auth page" };
2458
+ }
2459
+ if (result.hasLoginCta) {
2460
+ return { valid: false, reason: "Login button detected on page" };
2461
+ }
2462
+ if (!result.hasTextarea) {
2463
+ return { valid: false, reason: "Prompt textarea not available" };
2464
+ }
2465
+ return { valid: true };
2466
+ }
2467
+ catch (error) {
2468
+ const message = error instanceof Error ? error.message : String(error);
2469
+ logger(`[browser] Session validation error: ${message}`);
2470
+ return { valid: false, reason: `Validation error: ${message}` };
2471
+ }
2472
+ }
2473
+ function buildSessionValidationExpression() {
2474
+ const selectorLiteral = JSON.stringify(INPUT_SELECTORS);
2475
+ return `(async () => {
2476
+ const pageUrl = typeof location === 'object' && location?.href ? location.href : null;
2477
+ const onAuthPage =
2478
+ typeof location === 'object' &&
2479
+ typeof location.pathname === 'string' &&
2480
+ /^\\/(auth|login|signin)/i.test(location.pathname);
2481
+
2482
+ // Check for login CTAs (similar to ensureLoggedIn logic)
2483
+ const hasLoginCta = (() => {
2484
+ const candidates = Array.from(
2485
+ document.querySelectorAll(
2486
+ [
2487
+ 'a[href*="/auth/login"]',
2488
+ 'a[href*="/auth/signin"]',
2489
+ 'button[type="submit"]',
2490
+ 'button[data-testid*="login"]',
2491
+ 'button[data-testid*="log-in"]',
2492
+ 'button[data-testid*="sign-in"]',
2493
+ 'button[data-testid*="signin"]',
2494
+ 'button',
2495
+ 'a',
2496
+ ].join(','),
2497
+ ),
2498
+ );
2499
+ const textMatches = (text) => {
2500
+ if (!text) return false;
2501
+ const normalized = text.toLowerCase().trim();
2502
+ return ['log in', 'login', 'sign in', 'signin', 'continue with'].some((needle) =>
2503
+ normalized.startsWith(needle),
2504
+ );
2505
+ };
2506
+ for (const node of candidates) {
2507
+ if (!(node instanceof HTMLElement)) continue;
2508
+ const label =
2509
+ node.textContent?.trim() ||
2510
+ node.getAttribute('aria-label') ||
2511
+ node.getAttribute('title') ||
2512
+ '';
2513
+ if (textMatches(label)) {
2514
+ return true;
2515
+ }
2516
+ }
2517
+ return false;
2518
+ })();
2519
+
2520
+ // Check for textarea availability
2521
+ const hasTextarea = (() => {
2522
+ const selectors = ${selectorLiteral};
2523
+ for (const selector of selectors) {
2524
+ const node = document.querySelector(selector);
2525
+ if (node) {
2526
+ return true;
2527
+ }
2528
+ }
2529
+ return false;
2530
+ })();
2531
+
2532
+ return {
2533
+ valid: !onAuthPage && !hasLoginCta && hasTextarea,
2534
+ hasLoginCta,
2535
+ hasTextarea,
2536
+ onAuthPage,
2537
+ pageUrl,
2538
+ };
2539
+ })()`;
2540
+ }
2541
+ async function readConversationTurnCount(Runtime, logger) {
2542
+ const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
2543
+ const attempts = 4;
2544
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
2545
+ try {
2546
+ const { result } = await Runtime.evaluate({
2547
+ expression: `document.querySelectorAll(${selectorLiteral}).length`,
2548
+ returnByValue: true,
2549
+ });
2550
+ const raw = typeof result?.value === "number" ? result.value : Number(result?.value);
2551
+ if (!Number.isFinite(raw)) {
2552
+ throw new Error("Turn count not numeric");
2553
+ }
2554
+ return Math.max(0, Math.floor(raw));
2555
+ }
2556
+ catch (error) {
2557
+ if (attempt < attempts - 1) {
2558
+ await delay(150);
2559
+ continue;
2560
+ }
2561
+ if (logger?.verbose) {
2562
+ logger(`Failed to read conversation turn count: ${error instanceof Error ? error.message : String(error)}`);
2563
+ }
2564
+ return null;
2565
+ }
2566
+ }
2567
+ return null;
2568
+ }
2569
+ function isConversationUrl(url) {
2570
+ return /\/c\/[a-z0-9-]+/i.test(url);
2571
+ }
2572
+ function describeDevtoolsFirewallHint(host, port) {
2573
+ if (!isWsl())
2574
+ return null;
2575
+ return [
2576
+ `DevTools port ${host}:${port} is blocked from WSL.`,
2577
+ "",
2578
+ "PowerShell (admin):",
2579
+ `New-NetFirewallRule -DisplayName 'Chrome DevTools ${port}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${port}`,
2580
+ "New-NetFirewallRule -DisplayName 'Chrome DevTools (chrome.exe)' -Direction Inbound -Action Allow -Program 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' -Protocol TCP",
2581
+ "",
2582
+ "Re-run the same ask-pro command after adding the rule.",
2583
+ ].join("\n");
2584
+ }
2585
+ function isWsl() {
2586
+ if (process.platform !== "linux")
2587
+ return false;
2588
+ if (process.env.WSL_DISTRO_NAME)
2589
+ return true;
2590
+ return os.release().toLowerCase().includes("microsoft");
2591
+ }
2592
+ function extractConversationIdFromUrl(url) {
2593
+ const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
2594
+ return match?.[1];
2595
+ }
2596
+ async function resolveUserDataBaseDir() {
2597
+ // On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
2598
+ if (isWsl()) {
2599
+ const candidates = [
2600
+ "/mnt/c/Users/Public/AppData/Local/Temp",
2601
+ "/mnt/c/Temp",
2602
+ "/mnt/c/Windows/Temp",
2603
+ ];
2604
+ for (const candidate of candidates) {
2605
+ try {
2606
+ await mkdir(candidate, { recursive: true });
2607
+ return candidate;
2608
+ }
2609
+ catch {
2610
+ // try next
2611
+ }
2612
+ }
2613
+ }
2614
+ const tmpDir = os.tmpdir();
2615
+ if (process.platform === "linux") {
2616
+ const homeDir = os.homedir();
2617
+ const relativeToHome = homeDir && tmpDir.startsWith(homeDir + path.sep) ? tmpDir.slice(homeDir.length + 1) : "";
2618
+ const firstSegment = relativeToHome.split(path.sep, 1)[0];
2619
+ const isHiddenHomeTmp = Boolean(firstSegment?.startsWith("."));
2620
+ if (isHiddenHomeTmp) {
2621
+ try {
2622
+ await mkdir("/tmp", { recursive: true });
2623
+ return "/tmp";
2624
+ }
2625
+ catch {
2626
+ // Fall back to the inherited tmpdir if /tmp is unavailable.
2627
+ }
2628
+ }
2629
+ }
2630
+ return tmpDir;
2631
+ }