@steipete/oracle 0.7.6 → 0.8.1

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.
@@ -5,15 +5,16 @@ import net from 'node:net';
5
5
  import { resolveBrowserConfig } from './config.js';
6
6
  import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
7
7
  import { syncCookies } from './cookies.js';
8
- import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
8
+ import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
9
9
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
10
10
  import { ensureThinkingTime } from './actions/thinkingTime.js';
11
11
  import { estimateTokenCount, withRetries, delay } from './utils.js';
12
12
  import { formatElapsed } from '../oracle/format.js';
13
- import { CHATGPT_URL } from './constants.js';
13
+ import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from './constants.js';
14
14
  import { BrowserAutomationError } from '../oracle/errors.js';
15
- import { cleanupStaleProfileState, readChromePid, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
16
- export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
15
+ import { alignPromptEchoPair, buildPromptEchoMatcher } from './reattachHelpers.js';
16
+ import { cleanupStaleProfileState, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
17
+ export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from './constants.js';
17
18
  export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
18
19
  export async function runBrowserMode(options) {
19
20
  const promptText = options.prompt?.trim();
@@ -87,13 +88,14 @@ export async function runBrowserMode(options) {
87
88
  ? manualProfileDir
88
89
  : await mkdtemp(path.join(await resolveUserDataBaseDir(), 'oracle-browser-'));
89
90
  if (manualLogin) {
91
+ // Learned: manual login reuses a persistent profile so cookies/SSO survive.
90
92
  await mkdir(userDataDir, { recursive: true });
91
93
  logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
92
94
  }
93
95
  else {
94
96
  logger(`Created temporary Chrome profile at ${userDataDir}`);
95
97
  }
96
- const effectiveKeepBrowser = config.keepBrowser || manualLogin;
98
+ const effectiveKeepBrowser = Boolean(config.keepBrowser);
97
99
  const reusedChrome = manualLogin ? await maybeReuseRunningChrome(userDataDir, logger) : null;
98
100
  const chrome = reusedChrome ??
99
101
  (await launchChrome({
@@ -113,6 +115,7 @@ export async function runBrowserMode(options) {
113
115
  removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
114
116
  isInFlight: () => runStatus !== 'complete',
115
117
  emitRuntimeHint,
118
+ preserveUserDataDir: manualLogin,
116
119
  });
117
120
  }
118
121
  catch {
@@ -158,14 +161,19 @@ export async function runBrowserMode(options) {
158
161
  if (!manualLogin) {
159
162
  await Network.clearBrowserCookies();
160
163
  }
161
- const cookieSyncEnabled = config.cookieSync && !manualLogin;
164
+ const manualLoginCookieSync = manualLogin && Boolean(config.manualLoginCookieSync);
165
+ const cookieSyncEnabled = config.cookieSync && (!manualLogin || manualLoginCookieSync);
162
166
  if (cookieSyncEnabled) {
167
+ if (manualLoginCookieSync) {
168
+ logger('Manual login mode: seeding persistent profile with cookies from your Chrome profile.');
169
+ }
163
170
  if (!config.inlineCookies) {
164
171
  logger('Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow.');
165
172
  }
166
173
  else {
167
174
  logger('Applying inline cookies (skipping Chrome profile read and Keychain prompt)');
168
175
  }
176
+ // Learned: always sync cookies before the first navigation so /backend-api/me succeeds.
169
177
  const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, {
170
178
  allowErrors: config.allowCookieErrors ?? false,
171
179
  filterNames: config.cookieNames ?? undefined,
@@ -190,13 +198,15 @@ export async function runBrowserMode(options) {
190
198
  : 'Skipping Chrome cookie sync (--browser-no-cookie-sync)');
191
199
  }
192
200
  if (cookieSyncEnabled && !manualLogin && (appliedCookies ?? 0) === 0 && !config.inlineCookies) {
201
+ // Learned: if the profile has no ChatGPT cookies, browser mode will just bounce to login.
202
+ // Fail early so the user knows to sign in.
193
203
  throw new BrowserAutomationError('No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. ' +
194
- 'Make sure ChatGPT is signed in in the selected profile or rebuild the keytar native module if it failed to load.', {
204
+ 'Make sure ChatGPT is signed in in the selected profile, or use --browser-manual-login / inline cookies.', {
195
205
  stage: 'execute-browser',
196
206
  details: {
197
207
  profile: config.chromeProfile ?? 'Default',
198
208
  cookiePath: config.chromeCookiePath ?? null,
199
- hint: 'Rebuild keytar: PYTHON=/usr/bin/python3 /Users/steipete/Projects/oracle/runner npx node-gyp rebuild (run inside the keytar path from the error), then retry.',
209
+ hint: 'If macOS Keychain prompts or denies access, run oracle from a GUI session or use --copy/--render for the manual flow.',
200
210
  },
201
211
  });
202
212
  }
@@ -205,6 +215,7 @@ export async function runBrowserMode(options) {
205
215
  // then hop to the requested URL if it differs.
206
216
  await raceWithDisconnect(navigateToChatGPT(Page, Runtime, baseUrl, logger));
207
217
  await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
218
+ // Learned: login checks must happen on the base domain before jumping into project URLs.
208
219
  await raceWithDisconnect(waitForLogin({ runtime: Runtime, logger, appliedCookies, manualLogin, timeoutMs: config.timeoutMs }));
209
220
  if (config.url !== baseUrl) {
210
221
  await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.url, logger));
@@ -235,6 +246,9 @@ export async function runBrowserMode(options) {
235
246
  catch {
236
247
  // ignore
237
248
  }
249
+ if (lastUrl) {
250
+ logger(`[browser] url = ${lastUrl}`);
251
+ }
238
252
  if (chrome?.port) {
239
253
  const suffix = lastTargetId ? ` target=${lastTargetId}` : '';
240
254
  if (lastUrl) {
@@ -246,9 +260,45 @@ export async function runBrowserMode(options) {
246
260
  await emitRuntimeHint();
247
261
  }
248
262
  };
263
+ let conversationHintInFlight = null;
264
+ const updateConversationHint = async (label, timeoutMs = 10_000) => {
265
+ if (!chrome?.port) {
266
+ return false;
267
+ }
268
+ const start = Date.now();
269
+ while (Date.now() - start < timeoutMs) {
270
+ try {
271
+ const { result } = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
272
+ if (typeof result?.value === 'string' && result.value.includes('/c/')) {
273
+ lastUrl = result.value;
274
+ logger(`[browser] conversation url (${label}) = ${lastUrl}`);
275
+ await emitRuntimeHint();
276
+ return true;
277
+ }
278
+ }
279
+ catch {
280
+ // ignore; keep polling until timeout
281
+ }
282
+ await delay(250);
283
+ }
284
+ return false;
285
+ };
286
+ const scheduleConversationHint = (label, timeoutMs) => {
287
+ if (conversationHintInFlight) {
288
+ return;
289
+ }
290
+ // Learned: the /c/ URL can update after the answer; emit hints in the background.
291
+ // Run in the background so prompt submission/streaming isn't blocked by slow URL updates.
292
+ conversationHintInFlight = updateConversationHint(label, timeoutMs)
293
+ .catch(() => false)
294
+ .finally(() => {
295
+ conversationHintInFlight = null;
296
+ });
297
+ };
249
298
  await captureRuntimeSnapshot();
250
- if (config.desiredModel) {
251
- await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger), {
299
+ const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
300
+ if (config.desiredModel && modelStrategy !== 'ignore') {
301
+ await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
252
302
  retries: 2,
253
303
  delayMs: 300,
254
304
  onRetry: (attempt, error) => {
@@ -266,6 +316,9 @@ export async function runBrowserMode(options) {
266
316
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
267
317
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
268
318
  }
319
+ else if (modelStrategy === 'ignore') {
320
+ logger('Model picker: skipped (strategy=ignore)');
321
+ }
269
322
  // Handle thinking time selection if specified
270
323
  const thinkingTime = config.thinkingTime;
271
324
  if (thinkingTime) {
@@ -280,14 +333,22 @@ export async function runBrowserMode(options) {
280
333
  }));
281
334
  }
282
335
  const submitOnce = async (prompt, submissionAttachments) => {
336
+ const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
337
+ const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
283
338
  const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
339
+ let inputOnlyAttachments = false;
284
340
  if (submissionAttachments.length > 0) {
285
341
  if (!DOM) {
286
342
  throw new Error('Chrome DOM domain unavailable while uploading attachments.');
287
343
  }
288
- for (const attachment of submissionAttachments) {
344
+ await clearComposerAttachments(Runtime, 5_000, logger);
345
+ for (let attachmentIndex = 0; attachmentIndex < submissionAttachments.length; attachmentIndex += 1) {
346
+ const attachment = submissionAttachments[attachmentIndex];
289
347
  logger(`Uploading attachment: ${attachment.displayPath}`);
290
- await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
348
+ const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger, { expectedCount: attachmentIndex + 1 });
349
+ if (!uiConfirmed) {
350
+ inputOnlyAttachments = true;
351
+ }
291
352
  await delay(500);
292
353
  }
293
354
  // Scale timeout based on number of files: base 30s + 15s per additional file
@@ -297,30 +358,103 @@ export async function runBrowserMode(options) {
297
358
  await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
298
359
  logger('All attachments uploaded');
299
360
  }
300
- await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, prompt, logger);
361
+ let baselineTurns = await readConversationTurnCount(Runtime, logger);
362
+ // Learned: return baselineTurns so assistant polling can ignore earlier content.
363
+ const committedTurns = await submitPrompt({
364
+ runtime: Runtime,
365
+ input: Input,
366
+ attachmentNames,
367
+ baselineTurns: baselineTurns ?? undefined,
368
+ inputTimeoutMs: config.inputTimeoutMs ?? undefined,
369
+ }, prompt, logger);
370
+ if (typeof committedTurns === 'number' && Number.isFinite(committedTurns)) {
371
+ if (baselineTurns === null || committedTurns > baselineTurns) {
372
+ baselineTurns = Math.max(0, committedTurns - 1);
373
+ }
374
+ }
301
375
  if (attachmentNames.length > 0) {
302
- await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
303
- logger('Verified attachments present on sent user message');
376
+ if (inputOnlyAttachments) {
377
+ logger('Attachment UI did not render before send; skipping user-turn attachment verification.');
378
+ }
379
+ else {
380
+ const verified = await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
381
+ if (verified) {
382
+ logger('Verified attachments present on sent user message');
383
+ }
384
+ }
304
385
  }
386
+ // Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
387
+ scheduleConversationHint('post-submit', config.timeoutMs ?? 120_000);
388
+ return { baselineTurns, baselineAssistantText };
305
389
  };
390
+ let baselineTurns = null;
391
+ let baselineAssistantText = null;
306
392
  try {
307
- await raceWithDisconnect(submitOnce(promptText, attachments));
393
+ const submission = await raceWithDisconnect(submitOnce(promptText, attachments));
394
+ baselineTurns = submission.baselineTurns;
395
+ baselineAssistantText = submission.baselineAssistantText;
308
396
  }
309
397
  catch (error) {
310
398
  const isPromptTooLarge = error instanceof BrowserAutomationError &&
311
399
  error.details?.code === 'prompt-too-large';
312
400
  if (fallbackSubmission && isPromptTooLarge) {
401
+ // Learned: when prompts truncate, retry with file uploads so the UI receives the full content.
313
402
  logger('[browser] Inline prompt too large; retrying with file uploads.');
314
403
  await raceWithDisconnect(clearPromptComposer(Runtime, logger));
315
404
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
316
- await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
405
+ const submission = await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
406
+ baselineTurns = submission.baselineTurns;
407
+ baselineAssistantText = submission.baselineAssistantText;
317
408
  }
318
409
  else {
319
410
  throw error;
320
411
  }
321
412
  }
322
413
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
323
- const answer = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger));
414
+ // Helper to normalize text for echo detection (collapse whitespace, lowercase)
415
+ const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
416
+ const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => {
417
+ const baselinePrefix = baselineNormalized.length >= 80
418
+ ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
419
+ : '';
420
+ const deadline = Date.now() + timeoutMs;
421
+ while (Date.now() < deadline) {
422
+ const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
423
+ const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
424
+ if (text) {
425
+ const normalized = normalizeForComparison(text);
426
+ const isBaseline = normalized === baselineNormalized || (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
427
+ if (!isBaseline) {
428
+ return {
429
+ text,
430
+ html: snapshot?.html ?? undefined,
431
+ meta: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
432
+ };
433
+ }
434
+ }
435
+ await delay(350);
436
+ }
437
+ return null;
438
+ };
439
+ let answer = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined));
440
+ // Ensure we store the final conversation URL even if the UI updated late.
441
+ await updateConversationHint('post-response', 15_000);
442
+ const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
443
+ if (baselineNormalized) {
444
+ const normalizedAnswer = normalizeForComparison(answer.text ?? '');
445
+ const baselinePrefix = baselineNormalized.length >= 80
446
+ ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
447
+ : '';
448
+ const isBaseline = normalizedAnswer === baselineNormalized ||
449
+ (baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
450
+ if (isBaseline) {
451
+ logger('Detected stale assistant response; waiting for new response...');
452
+ const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
453
+ if (refreshed) {
454
+ answer = refreshed;
455
+ }
456
+ }
457
+ }
324
458
  answerText = answer.text;
325
459
  answerHtml = answer.html ?? '';
326
460
  const copiedMarkdown = await raceWithDisconnect(withRetries(async () => {
@@ -339,39 +473,41 @@ export async function runBrowserMode(options) {
339
473
  },
340
474
  })).catch(() => null);
341
475
  answerMarkdown = copiedMarkdown ?? answerText;
342
- // Helper to normalize text for echo detection (collapse whitespace, lowercase)
343
- const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
476
+ const promptEchoMatcher = buildPromptEchoMatcher(promptText);
344
477
  // Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
345
- const finalSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
478
+ const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
346
479
  const finalText = typeof finalSnapshot?.text === 'string' ? finalSnapshot.text.trim() : '';
347
- if (!copiedMarkdown &&
348
- finalText &&
349
- finalText !== answerMarkdown.trim() &&
350
- finalText !== promptText.trim() &&
351
- finalText.length >= answerMarkdown.trim().length) {
352
- logger('Refreshed assistant response via final DOM snapshot');
353
- answerText = finalText;
354
- answerMarkdown = finalText;
480
+ if (finalText && finalText !== promptText.trim()) {
481
+ const trimmedMarkdown = answerMarkdown.trim();
482
+ const finalIsEcho = promptEchoMatcher ? promptEchoMatcher.isEcho(finalText) : false;
483
+ const lengthDelta = finalText.length - trimmedMarkdown.length;
484
+ const missingCopy = !copiedMarkdown && lengthDelta >= 0;
485
+ const likelyTruncatedCopy = copiedMarkdown &&
486
+ trimmedMarkdown.length > 0 &&
487
+ lengthDelta >= Math.max(12, Math.floor(trimmedMarkdown.length * 0.75));
488
+ if ((missingCopy || likelyTruncatedCopy) && !finalIsEcho && finalText !== trimmedMarkdown) {
489
+ logger('Refreshed assistant response via final DOM snapshot');
490
+ answerText = finalText;
491
+ answerMarkdown = finalText;
492
+ }
355
493
  }
356
- // Detect prompt echo using normalized comparison (whitespace-insensitive)
357
- const normalizedAnswer = normalizeForComparison(answerMarkdown);
358
- const normalizedPrompt = normalizeForComparison(promptText);
359
- const promptPrefix = normalizedPrompt.length >= 80
360
- ? normalizedPrompt.slice(0, Math.min(200, normalizedPrompt.length))
361
- : '';
362
- const isPromptEcho = normalizedAnswer === normalizedPrompt || (promptPrefix.length > 0 && normalizedAnswer.startsWith(promptPrefix));
494
+ // Detect prompt echo using normalized comparison (whitespace-insensitive).
495
+ const alignedEcho = alignPromptEchoPair(answerText, answerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
496
+ text: 'Aligned assistant response text to copied markdown after prompt echo',
497
+ markdown: 'Aligned assistant markdown to response text after prompt echo',
498
+ });
499
+ answerText = alignedEcho.answerText;
500
+ answerMarkdown = alignedEcho.answerMarkdown;
501
+ const isPromptEcho = alignedEcho.isEcho;
363
502
  if (isPromptEcho) {
364
503
  logger('Detected prompt echo in response; waiting for actual assistant response...');
365
- const deadline = Date.now() + 8_000;
504
+ const deadline = Date.now() + 15_000;
366
505
  let bestText = null;
367
506
  let stableCount = 0;
368
507
  while (Date.now() < deadline) {
369
- const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
508
+ const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
370
509
  const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
371
- const normalizedText = normalizeForComparison(text);
372
- const isStillEcho = !text ||
373
- normalizedText === normalizedPrompt ||
374
- (promptPrefix.length > 0 && normalizedText.startsWith(promptPrefix));
510
+ const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
375
511
  if (!isStillEcho) {
376
512
  if (!bestText || text.length > bestText.length) {
377
513
  bestText = text;
@@ -392,6 +528,36 @@ export async function runBrowserMode(options) {
392
528
  answerMarkdown = bestText;
393
529
  }
394
530
  }
531
+ const minAnswerChars = 16;
532
+ if (answerText.trim().length > 0 && answerText.trim().length < minAnswerChars) {
533
+ const deadline = Date.now() + 12_000;
534
+ let bestText = answerText.trim();
535
+ let stableCycles = 0;
536
+ while (Date.now() < deadline) {
537
+ const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
538
+ const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
539
+ if (text && text.length > bestText.length) {
540
+ bestText = text;
541
+ stableCycles = 0;
542
+ }
543
+ else {
544
+ stableCycles += 1;
545
+ }
546
+ if (stableCycles >= 3 && bestText.length >= minAnswerChars) {
547
+ break;
548
+ }
549
+ await delay(400);
550
+ }
551
+ if (bestText.length > answerText.trim().length) {
552
+ logger('Refreshed short assistant response from latest DOM snapshot');
553
+ answerText = bestText;
554
+ answerMarkdown = bestText;
555
+ }
556
+ }
557
+ if (connectionClosedUnexpectedly) {
558
+ // Bail out on mid-run disconnects so the session stays reattachable.
559
+ throw new Error('Chrome disconnected before completion');
560
+ }
395
561
  stopThinkingMonitor?.();
396
562
  runStatus = 'complete';
397
563
  const durationMs = Date.now() - startedAt;
@@ -462,7 +628,19 @@ export async function runBrowserMode(options) {
462
628
  // ignore kill failures
463
629
  }
464
630
  }
465
- await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
631
+ if (manualLogin) {
632
+ const shouldCleanup = await shouldCleanupManualLoginProfileState(userDataDir, logger.verbose ? logger : undefined, {
633
+ connectionClosedUnexpectedly,
634
+ host: chromeHost,
635
+ });
636
+ if (shouldCleanup) {
637
+ // Preserve the persistent manual-login profile, but clear stale reattach hints.
638
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: 'never' }).catch(() => undefined);
639
+ }
640
+ }
641
+ else {
642
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
643
+ }
466
644
  if (!connectionClosedUnexpectedly) {
467
645
  const totalSeconds = (Date.now() - startedAt) / 1000;
468
646
  logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`);
@@ -653,8 +831,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
653
831
  catch {
654
832
  // ignore
655
833
  }
656
- if (config.desiredModel) {
657
- await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger), {
834
+ const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
835
+ if (config.desiredModel && modelStrategy !== 'ignore') {
836
+ await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
658
837
  retries: 2,
659
838
  delayMs: 300,
660
839
  onRetry: (attempt, error) => {
@@ -666,6 +845,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
666
845
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
667
846
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
668
847
  }
848
+ else if (modelStrategy === 'ignore') {
849
+ logger('Model picker: skipped (strategy=ignore)');
850
+ }
669
851
  // Handle thinking time selection if specified
670
852
  const thinkingTime = config.thinkingTime;
671
853
  if (thinkingTime) {
@@ -680,11 +862,14 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
680
862
  });
681
863
  }
682
864
  const submitOnce = async (prompt, submissionAttachments) => {
865
+ const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
866
+ const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
683
867
  const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
684
868
  if (submissionAttachments.length > 0) {
685
869
  if (!DOM) {
686
870
  throw new Error('Chrome DOM domain unavailable while uploading attachments.');
687
871
  }
872
+ await clearComposerAttachments(Runtime, 5_000, logger);
688
873
  // Use remote file transfer for remote Chrome (reads local files and injects via CDP)
689
874
  for (const attachment of submissionAttachments) {
690
875
  logger(`Uploading attachment: ${attachment.displayPath}`);
@@ -698,10 +883,27 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
698
883
  await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
699
884
  logger('All attachments uploaded');
700
885
  }
701
- await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, prompt, logger);
886
+ let baselineTurns = await readConversationTurnCount(Runtime, logger);
887
+ const committedTurns = await submitPrompt({
888
+ runtime: Runtime,
889
+ input: Input,
890
+ attachmentNames,
891
+ baselineTurns: baselineTurns ?? undefined,
892
+ inputTimeoutMs: config.inputTimeoutMs ?? undefined,
893
+ }, prompt, logger);
894
+ if (typeof committedTurns === 'number' && Number.isFinite(committedTurns)) {
895
+ if (baselineTurns === null || committedTurns > baselineTurns) {
896
+ baselineTurns = Math.max(0, committedTurns - 1);
897
+ }
898
+ }
899
+ return { baselineTurns, baselineAssistantText };
702
900
  };
901
+ let baselineTurns = null;
902
+ let baselineAssistantText = null;
703
903
  try {
704
- await submitOnce(promptText, attachments);
904
+ const submission = await submitOnce(promptText, attachments);
905
+ baselineTurns = submission.baselineTurns;
906
+ baselineAssistantText = submission.baselineAssistantText;
705
907
  }
706
908
  catch (error) {
707
909
  const isPromptTooLarge = error instanceof BrowserAutomationError &&
@@ -710,14 +912,57 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
710
912
  logger('[browser] Inline prompt too large; retrying with file uploads.');
711
913
  await clearPromptComposer(Runtime, logger);
712
914
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
713
- await submitOnce(options.fallbackSubmission.prompt, options.fallbackSubmission.attachments);
915
+ const submission = await submitOnce(options.fallbackSubmission.prompt, options.fallbackSubmission.attachments);
916
+ baselineTurns = submission.baselineTurns;
917
+ baselineAssistantText = submission.baselineAssistantText;
714
918
  }
715
919
  else {
716
920
  throw error;
717
921
  }
718
922
  }
719
923
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
720
- const answer = await waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger);
924
+ // Helper to normalize text for echo detection (collapse whitespace, lowercase)
925
+ const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
926
+ const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => {
927
+ const baselinePrefix = baselineNormalized.length >= 80
928
+ ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
929
+ : '';
930
+ const deadline = Date.now() + timeoutMs;
931
+ while (Date.now() < deadline) {
932
+ const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
933
+ const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
934
+ if (text) {
935
+ const normalized = normalizeForComparison(text);
936
+ const isBaseline = normalized === baselineNormalized || (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
937
+ if (!isBaseline) {
938
+ return {
939
+ text,
940
+ html: snapshot?.html ?? undefined,
941
+ meta: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
942
+ };
943
+ }
944
+ }
945
+ await delay(350);
946
+ }
947
+ return null;
948
+ };
949
+ let answer = await waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined);
950
+ const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
951
+ if (baselineNormalized) {
952
+ const normalizedAnswer = normalizeForComparison(answer.text ?? '');
953
+ const baselinePrefix = baselineNormalized.length >= 80
954
+ ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
955
+ : '';
956
+ const isBaseline = normalizedAnswer === baselineNormalized ||
957
+ (baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
958
+ if (isBaseline) {
959
+ logger('Detected stale assistant response; waiting for new response...');
960
+ const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
961
+ if (refreshed) {
962
+ answer = refreshed;
963
+ }
964
+ }
965
+ }
721
966
  answerText = answer.text;
722
967
  answerHtml = answer.html ?? '';
723
968
  const copiedMarkdown = await withRetries(async () => {
@@ -736,10 +981,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
736
981
  },
737
982
  }).catch(() => null);
738
983
  answerMarkdown = copiedMarkdown ?? answerText;
739
- // Helper to normalize text for echo detection (collapse whitespace, lowercase)
740
- const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
741
984
  // Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
742
- const finalSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
985
+ const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
743
986
  const finalText = typeof finalSnapshot?.text === 'string' ? finalSnapshot.text.trim() : '';
744
987
  if (finalText &&
745
988
  finalText !== answerMarkdown.trim() &&
@@ -749,25 +992,24 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
749
992
  answerText = finalText;
750
993
  answerMarkdown = finalText;
751
994
  }
752
- // Detect prompt echo using normalized comparison (whitespace-insensitive)
753
- const normalizedAnswer = normalizeForComparison(answerMarkdown);
754
- const normalizedPrompt = normalizeForComparison(promptText);
755
- const promptPrefix = normalizedPrompt.length >= 80
756
- ? normalizedPrompt.slice(0, Math.min(200, normalizedPrompt.length))
757
- : '';
758
- const isPromptEcho = normalizedAnswer === normalizedPrompt || (promptPrefix.length > 0 && normalizedAnswer.startsWith(promptPrefix));
995
+ // Detect prompt echo using normalized comparison (whitespace-insensitive).
996
+ const promptEchoMatcher = buildPromptEchoMatcher(promptText);
997
+ const alignedEcho = alignPromptEchoPair(answerText, answerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
998
+ text: 'Aligned assistant response text to copied markdown after prompt echo',
999
+ markdown: 'Aligned assistant markdown to response text after prompt echo',
1000
+ });
1001
+ answerText = alignedEcho.answerText;
1002
+ answerMarkdown = alignedEcho.answerMarkdown;
1003
+ const isPromptEcho = alignedEcho.isEcho;
759
1004
  if (isPromptEcho) {
760
1005
  logger('Detected prompt echo in response; waiting for actual assistant response...');
761
- const deadline = Date.now() + 8_000;
1006
+ const deadline = Date.now() + 15_000;
762
1007
  let bestText = null;
763
1008
  let stableCount = 0;
764
1009
  while (Date.now() < deadline) {
765
- const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
1010
+ const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
766
1011
  const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
767
- const normalizedText = normalizeForComparison(text);
768
- const isStillEcho = !text ||
769
- normalizedText === normalizedPrompt ||
770
- (promptPrefix.length > 0 && normalizedText.startsWith(promptPrefix));
1012
+ const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
771
1013
  if (!isStillEcho) {
772
1014
  if (!bestText || text.length > bestText.length) {
773
1015
  bestText = text;
@@ -867,9 +1109,9 @@ export function formatThinkingLog(startedAt, now, message, locatorSuffix) {
867
1109
  const statusLabel = message ? ` — ${message}` : '';
868
1110
  return `${pct}% [${elapsedText} / ~10m]${statusLabel}${locatorSuffix}`;
869
1111
  }
870
- async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger) {
1112
+ async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, minTurnIndex) {
871
1113
  try {
872
- return await waitForAssistantResponse(Runtime, timeoutMs, logger);
1114
+ return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex);
873
1115
  }
874
1116
  catch (error) {
875
1117
  if (!shouldReloadAfterAssistantError(error)) {
@@ -882,14 +1124,17 @@ async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logg
882
1124
  logger('Assistant response stalled; reloading conversation and retrying once');
883
1125
  await Page.navigate({ url: conversationUrl });
884
1126
  await delay(1000);
885
- return await waitForAssistantResponse(Runtime, timeoutMs, logger);
1127
+ return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex);
886
1128
  }
887
1129
  }
888
1130
  function shouldReloadAfterAssistantError(error) {
889
1131
  if (!(error instanceof Error))
890
1132
  return false;
891
1133
  const message = error.message.toLowerCase();
892
- return message.includes('assistant-response') || message.includes('watchdog') || message.includes('timeout');
1134
+ return (message.includes('assistant-response') ||
1135
+ message.includes('watchdog') ||
1136
+ message.includes('timeout') ||
1137
+ message.includes('capture assistant response'));
893
1138
  }
894
1139
  async function readConversationUrl(Runtime) {
895
1140
  try {
@@ -900,6 +1145,34 @@ async function readConversationUrl(Runtime) {
900
1145
  return null;
901
1146
  }
902
1147
  }
1148
+ async function readConversationTurnCount(Runtime, logger) {
1149
+ const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
1150
+ const attempts = 4;
1151
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1152
+ try {
1153
+ const { result } = await Runtime.evaluate({
1154
+ expression: `document.querySelectorAll(${selectorLiteral}).length`,
1155
+ returnByValue: true,
1156
+ });
1157
+ const raw = typeof result?.value === 'number' ? result.value : Number(result?.value);
1158
+ if (!Number.isFinite(raw)) {
1159
+ throw new Error('Turn count not numeric');
1160
+ }
1161
+ return Math.max(0, Math.floor(raw));
1162
+ }
1163
+ catch (error) {
1164
+ if (attempt < attempts - 1) {
1165
+ await delay(150);
1166
+ continue;
1167
+ }
1168
+ if (logger?.verbose) {
1169
+ logger(`Failed to read conversation turn count: ${error instanceof Error ? error.message : String(error)}`);
1170
+ }
1171
+ return null;
1172
+ }
1173
+ }
1174
+ return null;
1175
+ }
903
1176
  function isConversationUrl(url) {
904
1177
  return /\/c\/[a-z0-9-]+/i.test(url);
905
1178
  }