@askjo/camofox-browser 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js CHANGED
@@ -1,12 +1,20 @@
1
- const { Camoufox, launchOptions } = require('camoufox-js');
2
- const { firefox } = require('playwright-core');
3
- const express = require('express');
4
- const crypto = require('crypto');
5
- const os = require('os');
6
- const { expandMacro } = require('./lib/macros');
7
- const { loadConfig } = require('./lib/config');
8
- const { windowSnapshot } = require('./lib/snapshot');
9
- const { detectYtDlp, hasYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } = require('./lib/youtube');
1
+ import { Camoufox, launchOptions } from 'camoufox-js';
2
+ import { firefox } from 'playwright-core';
3
+ import express from 'express';
4
+ import crypto from 'crypto';
5
+ import os from 'os';
6
+ import { expandMacro } from './lib/macros.js';
7
+ import { loadConfig } from './lib/config.js';
8
+ import { windowSnapshot } from './lib/snapshot.js';
9
+ import {
10
+ MAX_DOWNLOAD_INLINE_BYTES,
11
+ clearTabDownloads,
12
+ clearSessionDownloads,
13
+ attachDownloadListener,
14
+ getDownloadsList,
15
+ extractPageImages,
16
+ } from './lib/downloads.js';
17
+ import { detectYtDlp, hasYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } from './lib/youtube.js';
10
18
 
11
19
  const CONFIG = loadConfig();
12
20
 
@@ -72,6 +80,16 @@ function timingSafeCompare(a, b) {
72
80
  return crypto.timingSafeEqual(bufA, bufB);
73
81
  }
74
82
 
83
+ // Custom error for stale/unknown element refs — returned as 422 instead of 500
84
+ class StaleRefsError extends Error {
85
+ constructor(ref, maxRef, totalRefs) {
86
+ super(`Unknown ref: ${ref} (valid refs: e1-${maxRef}, ${totalRefs} total). Refs reset after navigation - call snapshot first.`);
87
+ this.name = 'StaleRefsError';
88
+ this.code = 'stale_refs';
89
+ this.ref = ref;
90
+ }
91
+ }
92
+
75
93
  function safeError(err) {
76
94
  if (CONFIG.nodeEnv === 'production') {
77
95
  log('error', 'internal error', { error: err.message, stack: err.stack });
@@ -80,6 +98,17 @@ function safeError(err) {
80
98
  return err.message;
81
99
  }
82
100
 
101
+ // Send error response with appropriate status code (422 for stale refs, 500 otherwise)
102
+ function sendError(res, err, extraFields = {}) {
103
+ const status = err instanceof StaleRefsError ? 422 : (err.statusCode || 500);
104
+ const body = { error: safeError(err), ...extraFields };
105
+ if (err instanceof StaleRefsError) {
106
+ body.code = 'stale_refs';
107
+ body.ref = err.ref;
108
+ }
109
+ res.status(status).json(body);
110
+ }
111
+
83
112
  function validateUrl(url) {
84
113
  try {
85
114
  const parsed = new URL(url);
@@ -92,26 +121,38 @@ function validateUrl(url) {
92
121
  }
93
122
  }
94
123
 
124
+ function isLoopbackAddress(address) {
125
+ if (!address) return false;
126
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
127
+ }
128
+
95
129
  // Import cookies into a user's browser context (Playwright cookies format)
96
130
  // POST /sessions/:userId/cookies { cookies: Cookie[] }
97
131
  //
98
132
  // SECURITY:
99
133
  // Cookie injection moves this from "anonymous browsing" to "authenticated browsing".
100
- // This endpoint is DISABLED unless CAMOFOX_API_KEY is set.
101
- // When enabled, caller must send: Authorization: Bearer <CAMOFOX_API_KEY>
134
+ // By default, this endpoint is protected by CAMOFOX_API_KEY.
135
+ // For local development convenience, when CAMOFOX_API_KEY is NOT set, we allow
136
+ // unauthenticated cookie import ONLY from loopback (127.0.0.1 / ::1) and ONLY
137
+ // when NODE_ENV != production.
102
138
  app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (req, res) => {
103
139
  try {
104
- if (!CONFIG.apiKey) {
105
- return res.status(403).json({
106
- error: 'Cookie import is disabled. Set CAMOFOX_API_KEY to enable this endpoint.',
107
- });
108
- }
109
- const apiKey = CONFIG.apiKey;
110
-
111
- const auth = String(req.headers['authorization'] || '');
112
- const match = auth.match(/^Bearer\s+(.+)$/i);
113
- if (!match || !timingSafeCompare(match[1], apiKey)) {
114
- return res.status(403).json({ error: 'Forbidden' });
140
+ if (CONFIG.apiKey) {
141
+ const apiKey = CONFIG.apiKey;
142
+ const auth = String(req.headers['authorization'] || '');
143
+ const match = auth.match(/^Bearer\s+(.+)$/i);
144
+ if (!match || !timingSafeCompare(match[1], apiKey)) {
145
+ return res.status(403).json({ error: 'Forbidden' });
146
+ }
147
+ } else {
148
+ const remoteAddress = req.socket?.remoteAddress || '';
149
+ const allowUnauthedLocal = CONFIG.nodeEnv !== 'production' && isLoopbackAddress(remoteAddress);
150
+ if (!allowUnauthedLocal) {
151
+ return res.status(403).json({
152
+ error:
153
+ 'Cookie import is disabled without CAMOFOX_API_KEY except for loopback requests in non-production environments.',
154
+ });
155
+ }
115
156
  }
116
157
 
117
158
  const userId = req.params.userId;
@@ -169,12 +210,13 @@ app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (r
169
210
 
170
211
  let browser = null;
171
212
  // userId -> { context, tabGroups: Map<sessionKey, Map<tabId, TabState>>, lastAccess }
172
- // TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, toolCalls: number }
213
+ // TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, downloads: Array, toolCalls: number }
173
214
  // Note: sessionKey was previously called listItemId - both are accepted for backward compatibility
174
215
  const sessions = new Map();
175
216
 
176
217
  const SESSION_TIMEOUT_MS = CONFIG.sessionTimeoutMs;
177
218
  const MAX_SNAPSHOT_NODES = 500;
219
+ const TAB_INACTIVITY_MS = CONFIG.tabInactivityMs;
178
220
  const MAX_SESSIONS = CONFIG.maxSessions;
179
221
  const MAX_TABS_PER_SESSION = CONFIG.maxTabsPerSession;
180
222
  const MAX_TABS_GLOBAL = CONFIG.maxTabsGlobal;
@@ -184,39 +226,70 @@ const PAGE_CLOSE_TIMEOUT_MS = 5000;
184
226
  const NAVIGATE_TIMEOUT_MS = CONFIG.navigateTimeoutMs;
185
227
  const BUILDREFS_TIMEOUT_MS = CONFIG.buildrefsTimeoutMs;
186
228
  const FAILURE_THRESHOLD = 3;
187
- const TAB_LOCK_TIMEOUT_MS = 30000;
229
+ const MAX_CONSECUTIVE_TIMEOUTS = 3;
230
+ const TAB_LOCK_TIMEOUT_MS = 35000; // Must be > HANDLER_TIMEOUT_MS so active op times out first
231
+
232
+ // Proper mutex for tab serialization. The old Promise-chain lock on timeout proceeded
233
+ // WITHOUT the lock, allowing concurrent Playwright operations that corrupt CDP state.
234
+ class TabLock {
235
+ constructor() {
236
+ this.queue = [];
237
+ this.active = false;
238
+ }
188
239
 
189
- // Per-tab locks to serialize operations on the same tab
190
- // tabId -> Promise (the currently executing operation)
191
- const tabLocks = new Map();
240
+ acquire(timeoutMs) {
241
+ return new Promise((resolve, reject) => {
242
+ const entry = { resolve, reject, timer: null };
243
+ entry.timer = setTimeout(() => {
244
+ const idx = this.queue.indexOf(entry);
245
+ if (idx !== -1) this.queue.splice(idx, 1);
246
+ reject(new Error('Tab lock queue timeout'));
247
+ }, timeoutMs);
248
+ this.queue.push(entry);
249
+ this._tryNext();
250
+ });
251
+ }
192
252
 
193
- async function withTabLock(tabId, operation) {
194
- // Wait for any pending operation on this tab to complete
195
- const pending = tabLocks.get(tabId);
196
- if (pending) {
197
- try {
198
- await Promise.race([
199
- pending,
200
- new Promise((_, reject) => setTimeout(() => reject(new Error('Tab lock timeout')), TAB_LOCK_TIMEOUT_MS))
201
- ]);
202
- } catch (e) {
203
- if (e.message === 'Tab lock timeout') {
204
- log('warn', 'tab lock timeout, proceeding', { tabId });
205
- }
253
+ release() {
254
+ this.active = false;
255
+ this._tryNext();
256
+ }
257
+
258
+ _tryNext() {
259
+ if (this.active || this.queue.length === 0) return;
260
+ this.active = true;
261
+ const entry = this.queue.shift();
262
+ clearTimeout(entry.timer);
263
+ entry.resolve();
264
+ }
265
+
266
+ drain() {
267
+ this.active = true;
268
+ for (const entry of this.queue) {
269
+ clearTimeout(entry.timer);
270
+ entry.reject(new Error('Tab destroyed'));
206
271
  }
272
+ this.queue = [];
207
273
  }
208
-
209
- // Execute this operation and store the promise
210
- const promise = operation();
211
- tabLocks.set(tabId, promise);
212
-
274
+ }
275
+
276
+ // Per-tab locks to serialize operations on the same tab
277
+ const tabLocks = new Map(); // tabId -> TabLock
278
+
279
+ function getTabLock(tabId) {
280
+ if (!tabLocks.has(tabId)) tabLocks.set(tabId, new TabLock());
281
+ return tabLocks.get(tabId);
282
+ }
283
+
284
+ // Timeout is INSIDE the lock so each operation gets its full budget
285
+ // regardless of how long it waited in the queue.
286
+ async function withTabLock(tabId, operation, timeoutMs = HANDLER_TIMEOUT_MS) {
287
+ const lock = getTabLock(tabId);
288
+ await lock.acquire(TAB_LOCK_TIMEOUT_MS);
213
289
  try {
214
- return await promise;
290
+ return await withTimeout(operation(), timeoutMs, 'action');
215
291
  } finally {
216
- // Clean up if this is still the active lock
217
- if (tabLocks.get(tabId) === promise) {
218
- tabLocks.delete(tabId);
219
- }
292
+ lock.release();
220
293
  }
221
294
  }
222
295
 
@@ -425,6 +498,20 @@ function normalizeUserId(userId) {
425
498
  async function getSession(userId) {
426
499
  const key = normalizeUserId(userId);
427
500
  let session = sessions.get(key);
501
+
502
+ // Check if existing session's context is still alive
503
+ if (session) {
504
+ try {
505
+ // Lightweight probe: pages() is synchronous-ish and throws if context is dead
506
+ session.context.pages();
507
+ } catch (err) {
508
+ log('warn', 'session context dead, recreating', { userId: key, error: err.message });
509
+ session.context.close().catch(() => {});
510
+ sessions.delete(key);
511
+ session = null;
512
+ }
513
+ }
514
+
428
515
  if (!session) {
429
516
  if (sessions.size >= MAX_SESSIONS) {
430
517
  throw new Error('Maximum concurrent sessions reached');
@@ -460,6 +547,94 @@ function getTabGroup(session, listItemId) {
460
547
  return group;
461
548
  }
462
549
 
550
+ function isDeadContextError(err) {
551
+ const msg = err && err.message || '';
552
+ return msg.includes('Target page, context or browser has been closed') ||
553
+ msg.includes('browser has been closed') ||
554
+ msg.includes('Context closed') ||
555
+ msg.includes('Browser closed');
556
+ }
557
+
558
+ function isTimeoutError(err) {
559
+ const msg = err && err.message || '';
560
+ return msg.includes('timed out after') ||
561
+ (msg.includes('Timeout') && msg.includes('exceeded'));
562
+ }
563
+
564
+ function isTabLockQueueTimeout(err) {
565
+ return err && err.message === 'Tab lock queue timeout';
566
+ }
567
+
568
+ function isTabDestroyedError(err) {
569
+ return err && err.message === 'Tab destroyed';
570
+ }
571
+
572
+ // Centralized error handler for route catch blocks.
573
+ // Auto-destroys dead browser sessions and returns appropriate status codes.
574
+ function handleRouteError(err, req, res, extraFields = {}) {
575
+ const userId = req.body?.userId || req.query?.userId;
576
+ if (userId && isDeadContextError(err)) {
577
+ destroySession(userId);
578
+ }
579
+ // Track consecutive timeouts per tab and auto-destroy stuck tabs
580
+ if (userId && isTimeoutError(err)) {
581
+ const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
582
+ const session = sessions.get(normalizeUserId(userId));
583
+ if (session && tabId) {
584
+ const found = findTab(session, tabId);
585
+ if (found) {
586
+ found.tabState.consecutiveTimeouts++;
587
+ if (found.tabState.consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) {
588
+ log('warn', 'auto-destroying tab after consecutive timeouts', { tabId, count: found.tabState.consecutiveTimeouts });
589
+ destroyTab(session, tabId);
590
+ }
591
+ }
592
+ }
593
+ }
594
+ // Lock queue timeout = tab is stuck. Destroy immediately.
595
+ if (userId && isTabLockQueueTimeout(err)) {
596
+ const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
597
+ const session = sessions.get(normalizeUserId(userId));
598
+ if (session && tabId) {
599
+ destroyTab(session, tabId);
600
+ }
601
+ return res.status(503).json({ error: 'Tab unresponsive and has been destroyed. Open a new tab.', ...extraFields });
602
+ }
603
+ // Tab was destroyed while this request was queued in the lock
604
+ if (isTabDestroyedError(err)) {
605
+ return res.status(410).json({ error: 'Tab was destroyed. Open a new tab.', ...extraFields });
606
+ }
607
+ sendError(res, err, extraFields);
608
+ }
609
+
610
+ function destroyTab(session, tabId) {
611
+ const lock = tabLocks.get(tabId);
612
+ if (lock) {
613
+ lock.drain();
614
+ tabLocks.delete(tabId);
615
+ }
616
+ for (const [listItemId, group] of session.tabGroups) {
617
+ if (group.has(tabId)) {
618
+ const tabState = group.get(tabId);
619
+ log('warn', 'destroying stuck tab', { tabId, listItemId, toolCalls: tabState.toolCalls });
620
+ safePageClose(tabState.page);
621
+ group.delete(tabId);
622
+ if (group.size === 0) session.tabGroups.delete(listItemId);
623
+ return true;
624
+ }
625
+ }
626
+ return false;
627
+ }
628
+
629
+ function destroySession(userId) {
630
+ const key = normalizeUserId(userId);
631
+ const session = sessions.get(key);
632
+ if (!session) return;
633
+ log('warn', 'destroying dead session', { userId: key });
634
+ session.context.close().catch(() => {});
635
+ sessions.delete(key);
636
+ }
637
+
463
638
  function findTab(session, tabId) {
464
639
  for (const [listItemId, group] of session.tabGroups) {
465
640
  if (group.has(tabId)) {
@@ -475,11 +650,15 @@ function createTabState(page) {
475
650
  page,
476
651
  refs: new Map(),
477
652
  visitedUrls: new Set(),
653
+ downloads: [],
478
654
  toolCalls: 0,
655
+ consecutiveTimeouts: 0,
479
656
  lastSnapshot: null,
480
657
  };
481
658
  }
482
659
 
660
+
661
+
483
662
  async function waitForPageReady(page, options = {}) {
484
663
  const { timeout = 10000, waitForNetwork = true } = options;
485
664
 
@@ -569,6 +748,156 @@ async function dismissConsentDialogs(page) {
569
748
  }
570
749
  }
571
750
 
751
+ // --- Google SERP detection ---
752
+ function isGoogleSerp(url) {
753
+ try {
754
+ const parsed = new URL(url);
755
+ return parsed.hostname.includes('google.') && parsed.pathname === '/search';
756
+ } catch {
757
+ return false;
758
+ }
759
+ }
760
+
761
+ // --- Google SERP: combined extraction (refs + snapshot in one DOM pass) ---
762
+ // Returns { refs: Map, snapshot: string }
763
+ async function extractGoogleSerp(page) {
764
+ const refs = new Map();
765
+ if (!page || page.isClosed()) return { refs, snapshot: '' };
766
+
767
+ const start = Date.now();
768
+
769
+ const alreadyRendered = await page.evaluate(() => !!document.querySelector('#rso h3, #search h3, #rso [data-snhf]')).catch(() => false);
770
+ if (!alreadyRendered) {
771
+ try {
772
+ await page.waitForSelector('#rso h3, #search h3, #rso [data-snhf]', { timeout: 5000 });
773
+ } catch {
774
+ try {
775
+ await page.waitForSelector('#rso a[href]:not([href^="/search"]), #search a[href]:not([href^="/search"])', { timeout: 2000 });
776
+ } catch {}
777
+ }
778
+ }
779
+
780
+ const extracted = await page.evaluate(() => {
781
+ const snapshot = [];
782
+ const elements = [];
783
+ let refCounter = 1;
784
+
785
+ function addRef(role, name) {
786
+ const id = 'e' + refCounter++;
787
+ elements.push({ id, role, name });
788
+ return id;
789
+ }
790
+
791
+ snapshot.push('- heading "' + document.title.replace(/"/g, '\\"') + '"');
792
+
793
+ const searchInput = document.querySelector('input[name="q"], textarea[name="q"]');
794
+ if (searchInput) {
795
+ const name = 'Search';
796
+ const refId = addRef('searchbox', name);
797
+ snapshot.push('- searchbox "' + name + '" [' + refId + ']: ' + (searchInput.value || ''));
798
+ }
799
+
800
+ const navContainer = document.querySelector('div[role="navigation"], div[role="list"]');
801
+ if (navContainer) {
802
+ const navLinks = navContainer.querySelectorAll('a');
803
+ if (navLinks.length > 0) {
804
+ snapshot.push('- navigation:');
805
+ navLinks.forEach(a => {
806
+ const text = (a.textContent || '').trim();
807
+ if (!text || text.length < 1) return;
808
+ if (/^\d+$/.test(text) && parseInt(text) < 50) return;
809
+ const refId = addRef('link', text);
810
+ snapshot.push(' - link "' + text + '" [' + refId + ']');
811
+ });
812
+ }
813
+ }
814
+
815
+ const resultContainer = document.querySelector('#rso') || document.querySelector('#search');
816
+ if (resultContainer) {
817
+ const resultBlocks = resultContainer.querySelectorAll(':scope > div');
818
+ for (const block of resultBlocks) {
819
+ const h3 = block.querySelector('h3');
820
+ const mainLink = h3 ? h3.closest('a') : null;
821
+
822
+ if (h3 && mainLink) {
823
+ const title = h3.textContent.trim().replace(/"/g, '\\"');
824
+ const href = mainLink.href;
825
+ const cite = block.querySelector('cite');
826
+ const displayUrl = cite ? cite.textContent.trim() : '';
827
+
828
+ let snippet = '';
829
+ for (const sel of ['[data-sncf]', '[data-content-feature="1"]', '.VwiC3b', 'div[style*="-webkit-line-clamp"]', 'span.aCOpRe']) {
830
+ const el = block.querySelector(sel);
831
+ if (el) { snippet = el.textContent.trim().slice(0, 300); break; }
832
+ }
833
+ if (!snippet) {
834
+ const allText = block.textContent.trim().replace(/\s+/g, ' ');
835
+ const titleLen = title.length + (displayUrl ? displayUrl.length : 0);
836
+ if (allText.length > titleLen + 20) {
837
+ snippet = allText.slice(titleLen).trim().slice(0, 300);
838
+ }
839
+ }
840
+
841
+ const refId = addRef('link', title);
842
+ snapshot.push('- link "' + title + '" [' + refId + ']:');
843
+ snapshot.push(' - /url: ' + href);
844
+ if (displayUrl) snapshot.push(' - cite: ' + displayUrl);
845
+ if (snippet) snapshot.push(' - text: ' + snippet);
846
+ } else {
847
+ const blockLinks = block.querySelectorAll('a[href^="http"]:not([href*="google.com/search"])');
848
+ if (blockLinks.length > 0) {
849
+ const blockText = block.textContent.trim().replace(/\s+/g, ' ').slice(0, 200);
850
+ if (blockText.length > 10) {
851
+ snapshot.push('- group:');
852
+ snapshot.push(' - text: ' + blockText);
853
+ blockLinks.forEach(a => {
854
+ const linkText = (a.textContent || '').trim().replace(/"/g, '\\"').slice(0, 100);
855
+ if (linkText.length > 2) {
856
+ const refId = addRef('link', linkText);
857
+ snapshot.push(' - link "' + linkText + '" [' + refId + ']:');
858
+ snapshot.push(' - /url: ' + a.href);
859
+ }
860
+ });
861
+ }
862
+ }
863
+ }
864
+ }
865
+ }
866
+
867
+ const paaItems = document.querySelectorAll('[jsname="Cpkphb"], div.related-question-pair');
868
+ if (paaItems.length > 0) {
869
+ snapshot.push('- heading "People also ask"');
870
+ paaItems.forEach(q => {
871
+ const text = (q.textContent || '').trim().replace(/"/g, '\\"').slice(0, 150);
872
+ if (text) {
873
+ const refId = addRef('button', text);
874
+ snapshot.push(' - button "' + text + '" [' + refId + ']');
875
+ }
876
+ });
877
+ }
878
+
879
+ const nextLink = document.querySelector('#botstuff a[aria-label="Next page"], td.d6cvqb a, a#pnnext');
880
+ if (nextLink) {
881
+ const refId = addRef('link', 'Next');
882
+ snapshot.push('- navigation "pagination":');
883
+ snapshot.push(' - link "Next" [' + refId + ']');
884
+ }
885
+
886
+ return { snapshot: snapshot.join('\n'), elements };
887
+ });
888
+
889
+ const seenCounts = new Map();
890
+ for (const el of extracted.elements) {
891
+ const key = `${el.role}:${el.name}`;
892
+ const nth = seenCounts.get(key) || 0;
893
+ seenCounts.set(key, nth + 1);
894
+ refs.set(el.id, { role: el.role, name: el.name, nth });
895
+ }
896
+
897
+ log('info', 'extractGoogleSerp', { elapsed: Date.now() - start, refs: refs.size });
898
+ return { refs, snapshot: extracted.snapshot };
899
+ }
900
+
572
901
  async function buildRefs(page) {
573
902
  const refs = new Map();
574
903
 
@@ -577,6 +906,13 @@ async function buildRefs(page) {
577
906
  return refs;
578
907
  }
579
908
 
909
+ // Google SERP fast path — skip ariaSnapshot entirely
910
+ const url = page.url();
911
+ if (isGoogleSerp(url)) {
912
+ const { refs: googleRefs } = await extractGoogleSerp(page);
913
+ return googleRefs;
914
+ }
915
+
580
916
  const start = Date.now();
581
917
 
582
918
  // Hard total timeout on the entire buildRefs operation
@@ -719,7 +1055,12 @@ app.post('/youtube/transcript', async (req, res) => {
719
1055
 
720
1056
  let result;
721
1057
  if (hasYtDlp()) {
722
- result = await ytDlpTranscript(reqId, url, videoId, lang);
1058
+ try {
1059
+ result = await ytDlpTranscript(reqId, url, videoId, lang);
1060
+ } catch (ytErr) {
1061
+ log('warn', 'yt-dlp failed, falling back to browser', { reqId, error: ytErr.message });
1062
+ result = await browserTranscript(reqId, url, videoId, lang);
1063
+ }
723
1064
  } else {
724
1065
  result = await browserTranscript(reqId, url, videoId, lang);
725
1066
  }
@@ -759,16 +1100,52 @@ async function browserTranscript(reqId, url, videoId, lang) {
759
1100
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATE_TIMEOUT_MS });
760
1101
  await page.waitForTimeout(2000);
761
1102
 
1103
+ // Extract caption track URLs and metadata from ytInitialPlayerResponse
762
1104
  const meta = await page.evaluate(() => {
763
1105
  const r = window.ytInitialPlayerResponse || (typeof ytInitialPlayerResponse !== 'undefined' ? ytInitialPlayerResponse : null);
764
- if (!r) return { title: '' };
1106
+ if (!r) return { title: '', tracks: [] };
765
1107
  const tracks = r?.captions?.playerCaptionsTracklistRenderer?.captionTracks || [];
766
1108
  return {
767
1109
  title: r?.videoDetails?.title || '',
768
- languages: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual' })),
1110
+ tracks: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual', url: t.baseUrl })),
769
1111
  };
770
1112
  });
771
1113
 
1114
+ log('info', 'youtube transcript: extracted caption tracks', { reqId, title: meta.title, trackCount: meta.tracks.length, tracks: meta.tracks.map(t => t.code) });
1115
+
1116
+ // Strategy A: Fetch caption track URL directly from ytInitialPlayerResponse
1117
+ // These URLs are freshly signed by YouTube and work immediately
1118
+ if (meta.tracks && meta.tracks.length > 0) {
1119
+ const track = meta.tracks.find(t => t.code === lang) || meta.tracks[0];
1120
+ if (track && track.url) {
1121
+ const captionUrl = track.url + (track.url.includes('?') ? '&' : '?') + 'fmt=json3';
1122
+ log('info', 'youtube transcript: fetching caption track', { reqId, lang: track.code, url: captionUrl.substring(0, 100) });
1123
+ try {
1124
+ const captionResp = await page.evaluate(async (fetchUrl) => {
1125
+ const resp = await fetch(fetchUrl);
1126
+ return resp.ok ? await resp.text() : null;
1127
+ }, captionUrl);
1128
+ if (captionResp && captionResp.length > 0) {
1129
+ let transcriptText = null;
1130
+ if (captionResp.trimStart().startsWith('{')) transcriptText = parseJson3(captionResp);
1131
+ else if (captionResp.includes('WEBVTT')) transcriptText = parseVtt(captionResp);
1132
+ else if (captionResp.includes('<text')) transcriptText = parseXml(captionResp);
1133
+ if (transcriptText && transcriptText.trim()) {
1134
+ return {
1135
+ status: 'ok', transcript: transcriptText,
1136
+ video_url: url, video_id: videoId, video_title: meta.title,
1137
+ language: track.code, total_words: transcriptText.split(/\s+/).length,
1138
+ available_languages: meta.tracks.map(t => ({ code: t.code, name: t.name, kind: t.kind })),
1139
+ };
1140
+ }
1141
+ }
1142
+ } catch (fetchErr) {
1143
+ log('warn', 'youtube transcript: caption track fetch failed', { reqId, error: fetchErr.message });
1144
+ }
1145
+ }
1146
+ }
1147
+
1148
+ // Strategy B: Play video and intercept timedtext network response
772
1149
  await page.evaluate(() => {
773
1150
  const v = document.querySelector('video');
774
1151
  if (v) { v.muted = true; v.play().catch(() => {}); }
@@ -781,7 +1158,7 @@ async function browserTranscript(reqId, url, videoId, lang) {
781
1158
  if (!interceptedCaptions) {
782
1159
  return {
783
1160
  status: 'error', code: 404,
784
- message: 'No captions loaded during playback (video may have no captions, or ad blocked it)',
1161
+ message: 'No captions available for this video',
785
1162
  video_url: url, video_id: videoId, title: meta.title,
786
1163
  };
787
1164
  }
@@ -838,33 +1215,42 @@ app.post('/tabs', async (req, res) => {
838
1215
  return res.status(400).json({ error: 'userId and sessionKey required' });
839
1216
  }
840
1217
 
841
- const session = await getSession(userId);
842
-
843
- let totalTabs = 0;
844
- for (const group of session.tabGroups.values()) totalTabs += group.size;
845
- if (totalTabs >= MAX_TABS_PER_SESSION) {
846
- return res.status(429).json({ error: 'Maximum tabs per session reached' });
847
- }
848
-
849
- const group = getTabGroup(session, resolvedSessionKey);
850
-
851
- const page = await session.context.newPage();
852
- const tabId = crypto.randomUUID();
853
- const tabState = createTabState(page);
854
- group.set(tabId, tabState);
855
-
856
- if (url) {
857
- const urlErr = validateUrl(url);
858
- if (urlErr) return res.status(400).json({ error: urlErr });
859
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
860
- tabState.visitedUrls.add(url);
861
- }
862
-
863
- log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
864
- res.json({ tabId, url: page.url() });
1218
+ const result = await withTimeout((async () => {
1219
+ const session = await getSession(userId);
1220
+
1221
+ let totalTabs = 0;
1222
+ for (const group of session.tabGroups.values()) totalTabs += group.size;
1223
+ if (totalTabs >= MAX_TABS_PER_SESSION) {
1224
+ throw Object.assign(new Error('Maximum tabs per session reached'), { statusCode: 429 });
1225
+ }
1226
+
1227
+ if (getTotalTabCount() >= MAX_TABS_GLOBAL) {
1228
+ throw Object.assign(new Error('Maximum global tabs reached'), { statusCode: 429 });
1229
+ }
1230
+
1231
+ const group = getTabGroup(session, resolvedSessionKey);
1232
+
1233
+ const page = await session.context.newPage();
1234
+ const tabId = crypto.randomUUID();
1235
+ const tabState = createTabState(page);
1236
+ attachDownloadListener(tabState, tabId);
1237
+ group.set(tabId, tabState);
1238
+
1239
+ if (url) {
1240
+ const urlErr = validateUrl(url);
1241
+ if (urlErr) throw Object.assign(new Error(urlErr), { statusCode: 400 });
1242
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1243
+ tabState.visitedUrls.add(url);
1244
+ }
1245
+
1246
+ log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
1247
+ return { tabId, url: page.url() };
1248
+ })(), HANDLER_TIMEOUT_MS, 'tab create');
1249
+
1250
+ res.json(result);
865
1251
  } catch (err) {
866
1252
  log('error', 'tab create failed', { reqId: req.reqId, error: err.message });
867
- res.status(500).json({ error: safeError(err) });
1253
+ handleRouteError(err, req, res);
868
1254
  }
869
1255
  });
870
1256
 
@@ -906,7 +1292,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
906
1292
  const group = getTabGroup(session, resolvedSessionKey);
907
1293
  if (oldestGroup) oldestGroup.delete(oldestTabId);
908
1294
  group.set(tabId, tabState);
909
- tabLocks.delete(oldestTabId);
1295
+ { const _l = tabLocks.get(oldestTabId); if (_l) _l.drain(); tabLocks.delete(oldestTabId); }
910
1296
  log('info', 'tab recycled (limit reached)', { reqId: req.reqId, tabId, recycledFrom: oldestTabId, userId });
911
1297
  } else {
912
1298
  throw new Error('Maximum tabs per session reached');
@@ -914,6 +1300,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
914
1300
  } else {
915
1301
  const page = await session.context.newPage();
916
1302
  tabState = createTabState(page);
1303
+ attachDownloadListener(tabState, tabId, log);
917
1304
  const group = getTabGroup(session, resolvedSessionKey);
918
1305
  group.set(tabId, tabState);
919
1306
  log('info', 'tab auto-created on navigate', { reqId: req.reqId, tabId, userId });
@@ -921,7 +1308,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
921
1308
  } else {
922
1309
  tabState = found.tabState;
923
1310
  }
924
- tabState.toolCalls++;
1311
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
925
1312
 
926
1313
  let targetUrl = url;
927
1314
  if (macro) {
@@ -937,6 +1324,15 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
937
1324
  await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
938
1325
  tabState.visitedUrls.add(targetUrl);
939
1326
  tabState.lastSnapshot = null;
1327
+
1328
+ // For Google SERP: skip eager ref building during navigate.
1329
+ // Results render asynchronously after DOMContentLoaded — the snapshot
1330
+ // call will wait for and extract them.
1331
+ if (isGoogleSerp(tabState.page.url())) {
1332
+ tabState.refs = new Map();
1333
+ return { ok: true, tabId, url: tabState.page.url(), refsAvailable: false, googleSerp: true };
1334
+ }
1335
+
940
1336
  tabState.refs = await buildRefs(tabState.page);
941
1337
  return { ok: true, tabId, url: tabState.page.url(), refsAvailable: tabState.refs.size > 0 };
942
1338
  });
@@ -947,7 +1343,10 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
947
1343
  } catch (err) {
948
1344
  log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
949
1345
  const status = err.message && err.message.startsWith('Blocked URL scheme') ? 400 : 500;
950
- res.status(status).json({ error: safeError(err) });
1346
+ if (status === 400) {
1347
+ return res.status(400).json({ error: safeError(err) });
1348
+ }
1349
+ handleRouteError(err, req, res);
951
1350
  }
952
1351
  });
953
1352
 
@@ -963,7 +1362,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
963
1362
  if (!found) return res.status(404).json({ error: 'Tab not found' });
964
1363
 
965
1364
  const { tabState } = found;
966
- tabState.toolCalls++;
1365
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
967
1366
 
968
1367
  // Cached chunk retrieval for offset>0 requests
969
1368
  if (offset > 0 && tabState.lastSnapshot) {
@@ -978,6 +1377,31 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
978
1377
  }
979
1378
 
980
1379
  const result = await withUserLimit(userId, () => withTimeout((async () => {
1380
+ const pageUrl = tabState.page.url();
1381
+
1382
+ // Google SERP fast path — DOM extraction instead of ariaSnapshot
1383
+ if (isGoogleSerp(pageUrl)) {
1384
+ const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
1385
+ tabState.refs = googleRefs;
1386
+ tabState.lastSnapshot = googleSnapshot;
1387
+ const annotatedYaml = googleSnapshot;
1388
+ const win = windowSnapshot(annotatedYaml, 0);
1389
+ const response = {
1390
+ url: pageUrl,
1391
+ snapshot: win.text,
1392
+ refsCount: tabState.refs.size,
1393
+ truncated: win.truncated,
1394
+ totalChars: win.totalChars,
1395
+ hasMore: win.hasMore,
1396
+ nextOffset: win.nextOffset,
1397
+ };
1398
+ if (req.query.includeScreenshot === 'true') {
1399
+ const pngBuffer = await tabState.page.screenshot({ type: 'png' });
1400
+ response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
1401
+ }
1402
+ return response;
1403
+ }
1404
+
981
1405
  tabState.refs = await buildRefs(tabState.page);
982
1406
  const ariaYaml = await getAriaSnapshot(tabState.page);
983
1407
 
@@ -1040,7 +1464,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
1040
1464
  res.json(result);
1041
1465
  } catch (err) {
1042
1466
  log('error', 'snapshot failed', { reqId: req.reqId, tabId: req.params.tabId, error: err.message });
1043
- res.status(500).json({ error: safeError(err) });
1467
+ handleRouteError(err, req, res);
1044
1468
  }
1045
1469
  });
1046
1470
 
@@ -1058,7 +1482,7 @@ app.post('/tabs/:tabId/wait', async (req, res) => {
1058
1482
  res.json({ ok: true, ready });
1059
1483
  } catch (err) {
1060
1484
  log('error', 'wait failed', { reqId: req.reqId, error: err.message });
1061
- res.status(500).json({ error: safeError(err) });
1485
+ handleRouteError(err, req, res);
1062
1486
  }
1063
1487
  });
1064
1488
 
@@ -1074,13 +1498,15 @@ app.post('/tabs/:tabId/click', async (req, res) => {
1074
1498
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1075
1499
 
1076
1500
  const { tabState } = found;
1077
- tabState.toolCalls++;
1501
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1078
1502
 
1079
1503
  if (!ref && !selector) {
1080
1504
  return res.status(400).json({ error: 'ref or selector required' });
1081
1505
  }
1082
1506
 
1083
- const result = await withUserLimit(userId, () => withTimeout(withTabLock(tabId, async () => {
1507
+ const result = await withUserLimit(userId, () => withTabLock(tabId, async () => {
1508
+ const clickStart = Date.now();
1509
+ const remainingBudget = () => Math.max(0, HANDLER_TIMEOUT_MS - 2000 - (Date.now() - clickStart));
1084
1510
  // Full mouse event sequence for stubborn JS click handlers (mirrors Swift WebView.swift)
1085
1511
  // Dispatches: mouseover → mouseenter → mousedown → mouseup → click
1086
1512
  const dispatchMouseSequence = async (locator) => {
@@ -1102,18 +1528,32 @@ app.post('/tabs/:tabId/click', async (req, res) => {
1102
1528
  log('info', 'mouse sequence dispatched', { x: x.toFixed(0), y: y.toFixed(0) });
1103
1529
  };
1104
1530
 
1531
+ // On Google SERPs, skip the normal click attempt (always intercepted by overlays)
1532
+ // and go directly to force click — saves 5s timeout per click
1533
+ const onGoogleSerp = isGoogleSerp(tabState.page.url());
1534
+
1105
1535
  const doClick = async (locatorOrSelector, isLocator) => {
1106
1536
  const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
1107
1537
 
1538
+ if (onGoogleSerp) {
1539
+ try {
1540
+ await locator.click({ timeout: 3000, force: true });
1541
+ } catch (forceErr) {
1542
+ log('warn', 'google force click failed, trying mouse sequence');
1543
+ await dispatchMouseSequence(locator);
1544
+ }
1545
+ return;
1546
+ }
1547
+
1108
1548
  try {
1109
1549
  // First try normal click (respects visibility, enabled, not-obscured)
1110
- await locator.click({ timeout: 5000 });
1550
+ await locator.click({ timeout: 3000 });
1111
1551
  } catch (err) {
1112
1552
  // Fallback 1: If intercepted by overlay, retry with force
1113
1553
  if (err.message.includes('intercepts pointer events')) {
1114
1554
  log('warn', 'click intercepted, retrying with force');
1115
1555
  try {
1116
- await locator.click({ timeout: 5000, force: true });
1556
+ await locator.click({ timeout: 3000, force: true });
1117
1557
  } catch (forceErr) {
1118
1558
  // Fallback 2: Full mouse event sequence for stubborn JS handlers
1119
1559
  log('warn', 'force click failed, trying mouse sequence');
@@ -1131,35 +1571,93 @@ app.post('/tabs/:tabId/click', async (req, res) => {
1131
1571
 
1132
1572
  if (ref) {
1133
1573
  let locator = refToLocator(tabState.page, ref, tabState.refs);
1134
- if (!locator && tabState.refs.size === 0) {
1135
- // Auto-refresh refs on stale state before failing
1136
- log('info', 'auto-refreshing stale refs before click', { ref });
1137
- tabState.refs = await buildRefs(tabState.page);
1574
+ if (!locator) {
1575
+ // Use tight timeout (4s max) to leave budget for click + post-click buildRefs
1576
+ log('info', 'auto-refreshing refs before click', { ref, hadRefs: tabState.refs.size });
1577
+ try {
1578
+ const preClickBudget = Math.min(4000, remainingBudget());
1579
+ const refreshPromise = buildRefs(tabState.page);
1580
+ const refreshBudget = new Promise((_, reject) => setTimeout(() => reject(new Error('pre_click_refs_timeout')), preClickBudget));
1581
+ tabState.refs = await Promise.race([refreshPromise, refreshBudget]);
1582
+ } catch (e) {
1583
+ if (e.message === 'pre_click_refs_timeout' || e.message === 'buildRefs_timeout') {
1584
+ log('warn', 'pre-click buildRefs timed out, proceeding without refresh');
1585
+ } else {
1586
+ throw e;
1587
+ }
1588
+ }
1138
1589
  locator = refToLocator(tabState.page, ref, tabState.refs);
1139
1590
  }
1140
1591
  if (!locator) {
1141
1592
  const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none';
1142
- throw new Error(`Unknown ref: ${ref} (valid refs: e1-${maxRef}, ${tabState.refs.size} total). Refs reset after navigation - call snapshot first.`);
1593
+ throw new StaleRefsError(ref, maxRef, tabState.refs.size);
1143
1594
  }
1144
1595
  await doClick(locator, true);
1145
1596
  } else {
1146
1597
  await doClick(selector, false);
1147
1598
  }
1148
1599
 
1149
- await tabState.page.waitForTimeout(500);
1600
+ // If clicking on a Google SERP, wait for potential navigation to complete
1601
+ if (onGoogleSerp) {
1602
+ try {
1603
+ await tabState.page.waitForLoadState('domcontentloaded', { timeout: 3000 });
1604
+ } catch {}
1605
+ await tabState.page.waitForTimeout(200);
1606
+ // Skip buildRefs here — SERP clicks typically navigate to a new page,
1607
+ // and the caller always requests /snapshot next which rebuilds refs.
1608
+ tabState.lastSnapshot = null;
1609
+ tabState.refs = new Map();
1610
+ const newUrl = tabState.page.url();
1611
+ tabState.visitedUrls.add(newUrl);
1612
+ return { ok: true, url: newUrl, refsAvailable: false };
1613
+ } else {
1614
+ await tabState.page.waitForTimeout(500);
1615
+ }
1150
1616
  tabState.lastSnapshot = null;
1151
- tabState.refs = await buildRefs(tabState.page);
1617
+ // buildRefs after click — use remaining budget (min 2s) so we don't blow the handler timeout.
1618
+ // If it times out, return without refs (caller's next /snapshot will rebuild them).
1619
+ const postClickBudget = Math.max(2000, remainingBudget());
1620
+ try {
1621
+ const refsPromise = buildRefs(tabState.page);
1622
+ const refsBudget = new Promise((_, reject) => setTimeout(() => reject(new Error('post_click_refs_timeout')), postClickBudget));
1623
+ tabState.refs = await Promise.race([refsPromise, refsBudget]);
1624
+ } catch (e) {
1625
+ if (e.message === 'post_click_refs_timeout' || e.message === 'buildRefs_timeout') {
1626
+ log('warn', 'post-click buildRefs timed out, returning without refs', { budget: postClickBudget, elapsed: Date.now() - clickStart });
1627
+ tabState.refs = new Map();
1628
+ } else {
1629
+ throw e;
1630
+ }
1631
+ }
1152
1632
 
1153
1633
  const newUrl = tabState.page.url();
1154
1634
  tabState.visitedUrls.add(newUrl);
1155
1635
  return { ok: true, url: newUrl, refsAvailable: tabState.refs.size > 0 };
1156
- }), HANDLER_TIMEOUT_MS, 'click'));
1636
+ }));
1157
1637
 
1158
1638
  log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
1159
1639
  res.json(result);
1160
1640
  } catch (err) {
1161
1641
  log('error', 'click failed', { reqId: req.reqId, tabId, error: err.message });
1162
- res.status(500).json({ error: safeError(err) });
1642
+ if (err.message?.includes('timed out')) {
1643
+ try {
1644
+ const session = sessions.get(normalizeUserId(req.body.userId));
1645
+ const found = session && findTab(session, tabId);
1646
+ if (found?.tabState?.page && !found.tabState.page.isClosed()) {
1647
+ found.tabState.refs = await buildRefs(found.tabState.page);
1648
+ found.tabState.lastSnapshot = null;
1649
+ return res.status(500).json({
1650
+ error: safeError(err),
1651
+ hint: 'The page may have changed. Call snapshot to see the current state and retry.',
1652
+ url: found.tabState.page.url(),
1653
+ refsCount: found.tabState.refs.size,
1654
+ });
1655
+ }
1656
+ } catch (refreshErr) {
1657
+ log('warn', 'post-timeout refresh failed', { error: refreshErr.message });
1658
+ }
1659
+ }
1660
+ handleRouteError(err, req, res);
1163
1661
  }
1164
1662
  });
1165
1663
 
@@ -1174,7 +1672,7 @@ app.post('/tabs/:tabId/type', async (req, res) => {
1174
1672
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1175
1673
 
1176
1674
  const { tabState } = found;
1177
- tabState.toolCalls++;
1675
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1178
1676
 
1179
1677
  if (!ref && !selector) {
1180
1678
  return res.status(400).json({ error: 'ref or selector required' });
@@ -1182,8 +1680,13 @@ app.post('/tabs/:tabId/type', async (req, res) => {
1182
1680
 
1183
1681
  await withTabLock(tabId, async () => {
1184
1682
  if (ref) {
1185
- const locator = refToLocator(tabState.page, ref, tabState.refs);
1186
- if (!locator) throw new Error(`Unknown ref: ${ref}`);
1683
+ let locator = refToLocator(tabState.page, ref, tabState.refs);
1684
+ if (!locator) {
1685
+ log('info', 'auto-refreshing refs before fill', { ref, hadRefs: tabState.refs.size });
1686
+ tabState.refs = await buildRefs(tabState.page);
1687
+ locator = refToLocator(tabState.page, ref, tabState.refs);
1688
+ }
1689
+ if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
1187
1690
  await locator.fill(text, { timeout: 10000 });
1188
1691
  } else {
1189
1692
  await tabState.page.fill(selector, text, { timeout: 10000 });
@@ -1193,7 +1696,25 @@ app.post('/tabs/:tabId/type', async (req, res) => {
1193
1696
  res.json({ ok: true });
1194
1697
  } catch (err) {
1195
1698
  log('error', 'type failed', { reqId: req.reqId, error: err.message });
1196
- res.status(500).json({ error: safeError(err) });
1699
+ if (err.message?.includes('timed out') || err.message?.includes('not an <input>')) {
1700
+ try {
1701
+ const session = sessions.get(normalizeUserId(req.body.userId));
1702
+ const found = session && findTab(session, tabId);
1703
+ if (found?.tabState?.page && !found.tabState.page.isClosed()) {
1704
+ found.tabState.refs = await buildRefs(found.tabState.page);
1705
+ found.tabState.lastSnapshot = null;
1706
+ return res.status(500).json({
1707
+ error: safeError(err),
1708
+ hint: 'The page may have changed. Call snapshot to see the current state and retry.',
1709
+ url: found.tabState.page.url(),
1710
+ refsCount: found.tabState.refs.size,
1711
+ });
1712
+ }
1713
+ } catch (refreshErr) {
1714
+ log('warn', 'post-timeout refresh failed', { error: refreshErr.message });
1715
+ }
1716
+ }
1717
+ handleRouteError(err, req, res);
1197
1718
  }
1198
1719
  });
1199
1720
 
@@ -1208,7 +1729,7 @@ app.post('/tabs/:tabId/press', async (req, res) => {
1208
1729
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1209
1730
 
1210
1731
  const { tabState } = found;
1211
- tabState.toolCalls++;
1732
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1212
1733
 
1213
1734
  await withTabLock(tabId, async () => {
1214
1735
  await tabState.page.keyboard.press(key);
@@ -1217,7 +1738,7 @@ app.post('/tabs/:tabId/press', async (req, res) => {
1217
1738
  res.json({ ok: true });
1218
1739
  } catch (err) {
1219
1740
  log('error', 'press failed', { reqId: req.reqId, error: err.message });
1220
- res.status(500).json({ error: safeError(err) });
1741
+ handleRouteError(err, req, res);
1221
1742
  }
1222
1743
  });
1223
1744
 
@@ -1230,7 +1751,7 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
1230
1751
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1231
1752
 
1232
1753
  const { tabState } = found;
1233
- tabState.toolCalls++;
1754
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1234
1755
 
1235
1756
  const delta = direction === 'up' ? -amount : amount;
1236
1757
  await tabState.page.mouse.wheel(0, delta);
@@ -1239,7 +1760,7 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
1239
1760
  res.json({ ok: true });
1240
1761
  } catch (err) {
1241
1762
  log('error', 'scroll failed', { reqId: req.reqId, error: err.message });
1242
- res.status(500).json({ error: safeError(err) });
1763
+ handleRouteError(err, req, res);
1243
1764
  }
1244
1765
  });
1245
1766
 
@@ -1254,18 +1775,18 @@ app.post('/tabs/:tabId/back', async (req, res) => {
1254
1775
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1255
1776
 
1256
1777
  const { tabState } = found;
1257
- tabState.toolCalls++;
1778
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1258
1779
 
1259
- const result = await withTimeout(withTabLock(tabId, async () => {
1780
+ const result = await withTabLock(tabId, async () => {
1260
1781
  await tabState.page.goBack({ timeout: 10000 });
1261
1782
  tabState.refs = await buildRefs(tabState.page);
1262
1783
  return { ok: true, url: tabState.page.url() };
1263
- }), HANDLER_TIMEOUT_MS, 'back');
1784
+ });
1264
1785
 
1265
1786
  res.json(result);
1266
1787
  } catch (err) {
1267
1788
  log('error', 'back failed', { reqId: req.reqId, error: err.message });
1268
- res.status(500).json({ error: safeError(err) });
1789
+ handleRouteError(err, req, res);
1269
1790
  }
1270
1791
  });
1271
1792
 
@@ -1280,18 +1801,18 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
1280
1801
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1281
1802
 
1282
1803
  const { tabState } = found;
1283
- tabState.toolCalls++;
1804
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1284
1805
 
1285
- const result = await withTimeout(withTabLock(tabId, async () => {
1806
+ const result = await withTabLock(tabId, async () => {
1286
1807
  await tabState.page.goForward({ timeout: 10000 });
1287
1808
  tabState.refs = await buildRefs(tabState.page);
1288
1809
  return { ok: true, url: tabState.page.url() };
1289
- }), HANDLER_TIMEOUT_MS, 'forward');
1810
+ });
1290
1811
 
1291
1812
  res.json(result);
1292
1813
  } catch (err) {
1293
1814
  log('error', 'forward failed', { reqId: req.reqId, error: err.message });
1294
- res.status(500).json({ error: safeError(err) });
1815
+ handleRouteError(err, req, res);
1295
1816
  }
1296
1817
  });
1297
1818
 
@@ -1306,18 +1827,18 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
1306
1827
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1307
1828
 
1308
1829
  const { tabState } = found;
1309
- tabState.toolCalls++;
1830
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1310
1831
 
1311
- const result = await withTimeout(withTabLock(tabId, async () => {
1832
+ const result = await withTabLock(tabId, async () => {
1312
1833
  await tabState.page.reload({ timeout: 30000 });
1313
1834
  tabState.refs = await buildRefs(tabState.page);
1314
1835
  return { ok: true, url: tabState.page.url() };
1315
- }), HANDLER_TIMEOUT_MS, 'refresh');
1836
+ });
1316
1837
 
1317
1838
  res.json(result);
1318
1839
  } catch (err) {
1319
1840
  log('error', 'refresh failed', { reqId: req.reqId, error: err.message });
1320
- res.status(500).json({ error: safeError(err) });
1841
+ handleRouteError(err, req, res);
1321
1842
  }
1322
1843
  });
1323
1844
 
@@ -1335,7 +1856,7 @@ app.get('/tabs/:tabId/links', async (req, res) => {
1335
1856
  }
1336
1857
 
1337
1858
  const { tabState } = found;
1338
- tabState.toolCalls++;
1859
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1339
1860
 
1340
1861
  const allLinks = await tabState.page.evaluate(() => {
1341
1862
  const links = [];
@@ -1358,6 +1879,59 @@ app.get('/tabs/:tabId/links', async (req, res) => {
1358
1879
  });
1359
1880
  } catch (err) {
1360
1881
  log('error', 'links failed', { reqId: req.reqId, error: err.message });
1882
+ handleRouteError(err, req, res);
1883
+ }
1884
+ });
1885
+
1886
+ // Get captured downloads
1887
+ app.get('/tabs/:tabId/downloads', async (req, res) => {
1888
+ try {
1889
+ const userId = req.query.userId;
1890
+ const includeData = req.query.includeData === 'true';
1891
+ const consume = req.query.consume === 'true';
1892
+ const maxBytesRaw = Number(req.query.maxBytes);
1893
+ const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? maxBytesRaw : MAX_DOWNLOAD_INLINE_BYTES;
1894
+ const session = sessions.get(normalizeUserId(userId));
1895
+ const found = session && findTab(session, req.params.tabId);
1896
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
1897
+
1898
+ const { tabState } = found;
1899
+ tabState.toolCalls++;
1900
+
1901
+ const downloads = await getDownloadsList(tabState, { includeData, maxBytes });
1902
+
1903
+ if (consume) {
1904
+ await clearTabDownloads(tabState);
1905
+ }
1906
+
1907
+ res.json({ tabId: req.params.tabId, downloads });
1908
+ } catch (err) {
1909
+ log('error', 'downloads failed', { reqId: req.reqId, error: err.message });
1910
+ res.status(500).json({ error: safeError(err) });
1911
+ }
1912
+ });
1913
+
1914
+ // Get image elements from current page
1915
+ app.get('/tabs/:tabId/images', async (req, res) => {
1916
+ try {
1917
+ const userId = req.query.userId;
1918
+ const includeData = req.query.includeData === 'true';
1919
+ const maxBytesRaw = Number(req.query.maxBytes);
1920
+ const limitRaw = Number(req.query.limit);
1921
+ const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? maxBytesRaw : MAX_DOWNLOAD_INLINE_BYTES;
1922
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 20) : 8;
1923
+ const session = sessions.get(normalizeUserId(userId));
1924
+ const found = session && findTab(session, req.params.tabId);
1925
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
1926
+
1927
+ const { tabState } = found;
1928
+ tabState.toolCalls++;
1929
+
1930
+ const images = await extractPageImages(tabState.page, { includeData, maxBytes, limit });
1931
+
1932
+ res.json({ tabId: req.params.tabId, images });
1933
+ } catch (err) {
1934
+ log('error', 'images failed', { reqId: req.reqId, error: err.message });
1361
1935
  res.status(500).json({ error: safeError(err) });
1362
1936
  }
1363
1937
  });
@@ -1377,7 +1951,7 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
1377
1951
  res.send(buffer);
1378
1952
  } catch (err) {
1379
1953
  log('error', 'screenshot failed', { reqId: req.reqId, error: err.message });
1380
- res.status(500).json({ error: safeError(err) });
1954
+ handleRouteError(err, req, res);
1381
1955
  }
1382
1956
  });
1383
1957
 
@@ -1396,11 +1970,36 @@ app.get('/tabs/:tabId/stats', async (req, res) => {
1396
1970
  listItemId, // Legacy compatibility
1397
1971
  url: tabState.page.url(),
1398
1972
  visitedUrls: Array.from(tabState.visitedUrls),
1973
+ downloadsCount: Array.isArray(tabState.downloads) ? tabState.downloads.length : 0,
1399
1974
  toolCalls: tabState.toolCalls,
1400
1975
  refsCount: tabState.refs.size
1401
1976
  });
1402
1977
  } catch (err) {
1403
1978
  log('error', 'stats failed', { reqId: req.reqId, error: err.message });
1979
+ handleRouteError(err, req, res);
1980
+ }
1981
+ });
1982
+
1983
+ // Evaluate JavaScript in page context
1984
+ app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, res) => {
1985
+ try {
1986
+ const { userId, expression } = req.body;
1987
+ if (!userId) return res.status(400).json({ error: 'userId is required' });
1988
+ if (!expression) return res.status(400).json({ error: 'expression is required' });
1989
+
1990
+ const session = sessions.get(normalizeUserId(userId));
1991
+ const found = session && findTab(session, req.params.tabId);
1992
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
1993
+
1994
+ session.lastAccess = Date.now();
1995
+ const { tabState } = found;
1996
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1997
+
1998
+ const result = await tabState.page.evaluate(expression);
1999
+ log('info', 'evaluate', { reqId: req.reqId, tabId: req.params.tabId, userId, resultType: typeof result });
2000
+ res.json({ ok: true, result });
2001
+ } catch (err) {
2002
+ log('error', 'evaluate failed', { reqId: req.reqId, error: err.message });
1404
2003
  res.status(500).json({ error: safeError(err) });
1405
2004
  }
1406
2005
  });
@@ -1412,9 +2011,10 @@ app.delete('/tabs/:tabId', async (req, res) => {
1412
2011
  const session = sessions.get(normalizeUserId(userId));
1413
2012
  const found = session && findTab(session, req.params.tabId);
1414
2013
  if (found) {
2014
+ await clearTabDownloads(found.tabState);
1415
2015
  await safePageClose(found.tabState.page);
1416
2016
  found.group.delete(req.params.tabId);
1417
- tabLocks.delete(req.params.tabId);
2017
+ { const _l = tabLocks.get(req.params.tabId); if (_l) _l.drain(); tabLocks.delete(req.params.tabId); }
1418
2018
  if (found.group.size === 0) {
1419
2019
  session.tabGroups.delete(found.listItemId);
1420
2020
  }
@@ -1423,7 +2023,7 @@ app.delete('/tabs/:tabId', async (req, res) => {
1423
2023
  res.json({ ok: true });
1424
2024
  } catch (err) {
1425
2025
  log('error', 'tab close failed', { reqId: req.reqId, error: err.message });
1426
- res.status(500).json({ error: safeError(err) });
2026
+ handleRouteError(err, req, res);
1427
2027
  }
1428
2028
  });
1429
2029
 
@@ -1435,6 +2035,7 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
1435
2035
  const group = session?.tabGroups.get(req.params.listItemId);
1436
2036
  if (group) {
1437
2037
  for (const [tabId, tabState] of group) {
2038
+ await clearTabDownloads(tabState);
1438
2039
  await safePageClose(tabState.page);
1439
2040
  tabLocks.delete(tabId);
1440
2041
  }
@@ -1444,7 +2045,7 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
1444
2045
  res.json({ ok: true });
1445
2046
  } catch (err) {
1446
2047
  log('error', 'tab group close failed', { reqId: req.reqId, error: err.message });
1447
- res.status(500).json({ error: safeError(err) });
2048
+ handleRouteError(err, req, res);
1448
2049
  }
1449
2050
  });
1450
2051
 
@@ -1454,6 +2055,7 @@ app.delete('/sessions/:userId', async (req, res) => {
1454
2055
  const userId = normalizeUserId(req.params.userId);
1455
2056
  const session = sessions.get(userId);
1456
2057
  if (session) {
2058
+ await clearSessionDownloads(session);
1457
2059
  await session.context.close();
1458
2060
  sessions.delete(userId);
1459
2061
  log('info', 'session closed', { userId });
@@ -1462,7 +2064,7 @@ app.delete('/sessions/:userId', async (req, res) => {
1462
2064
  res.json({ ok: true });
1463
2065
  } catch (err) {
1464
2066
  log('error', 'session close failed', { error: err.message });
1465
- res.status(500).json({ error: safeError(err) });
2067
+ handleRouteError(err, req, res);
1466
2068
  }
1467
2069
  });
1468
2070
 
@@ -1471,6 +2073,7 @@ setInterval(() => {
1471
2073
  const now = Date.now();
1472
2074
  for (const [userId, session] of sessions) {
1473
2075
  if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
2076
+ clearSessionDownloads(session).catch(() => {});
1474
2077
  session.context.close().catch(() => {});
1475
2078
  sessions.delete(userId);
1476
2079
  log('info', 'session expired', { userId });
@@ -1482,6 +2085,37 @@ setInterval(() => {
1482
2085
  }
1483
2086
  }, 60_000);
1484
2087
 
2088
+ // Per-tab inactivity reaper — close tabs idle for TAB_INACTIVITY_MS
2089
+ setInterval(() => {
2090
+ const now = Date.now();
2091
+ for (const [userId, session] of sessions) {
2092
+ for (const [listItemId, group] of session.tabGroups) {
2093
+ for (const [tabId, tabState] of group) {
2094
+ if (!tabState._lastReaperCheck) {
2095
+ tabState._lastReaperCheck = now;
2096
+ tabState._lastReaperToolCalls = tabState.toolCalls;
2097
+ continue;
2098
+ }
2099
+ if (tabState.toolCalls === tabState._lastReaperToolCalls) {
2100
+ const idleMs = now - tabState._lastReaperCheck;
2101
+ if (idleMs >= TAB_INACTIVITY_MS) {
2102
+ log('info', 'tab reaped (inactive)', { userId, tabId, listItemId, idleMs, toolCalls: tabState.toolCalls });
2103
+ safePageClose(tabState.page);
2104
+ group.delete(tabId);
2105
+ { const _l = tabLocks.get(tabId); if (_l) _l.drain(); tabLocks.delete(tabId); }
2106
+ }
2107
+ } else {
2108
+ tabState._lastReaperCheck = now;
2109
+ tabState._lastReaperToolCalls = tabState.toolCalls;
2110
+ }
2111
+ }
2112
+ if (group.size === 0) {
2113
+ session.tabGroups.delete(listItemId);
2114
+ }
2115
+ }
2116
+ }
2117
+ }, 60_000);
2118
+
1485
2119
  // =============================================================================
1486
2120
  // OpenClaw-compatible endpoint aliases
1487
2121
  // These allow camoufox to be used as a profile backend for OpenClaw's browser tool
@@ -1526,7 +2160,7 @@ app.get('/tabs', async (req, res) => {
1526
2160
  res.json({ running: true, tabs });
1527
2161
  } catch (err) {
1528
2162
  log('error', 'list tabs failed', { reqId: req.reqId, error: err.message });
1529
- res.status(500).json({ error: safeError(err) });
2163
+ handleRouteError(err, req, res);
1530
2164
  }
1531
2165
  });
1532
2166
 
@@ -1546,6 +2180,11 @@ app.post('/tabs/open', async (req, res) => {
1546
2180
 
1547
2181
  const session = await getSession(userId);
1548
2182
 
2183
+ // Check global tab limit first
2184
+ if (getTotalTabCount() >= MAX_TABS_GLOBAL) {
2185
+ return res.status(429).json({ error: 'Maximum global tabs reached' });
2186
+ }
2187
+
1549
2188
  let totalTabs = 0;
1550
2189
  for (const g of session.tabGroups.values()) totalTabs += g.size;
1551
2190
  if (totalTabs >= MAX_TABS_PER_SESSION) {
@@ -1557,6 +2196,7 @@ app.post('/tabs/open', async (req, res) => {
1557
2196
  const page = await session.context.newPage();
1558
2197
  const tabId = crypto.randomUUID();
1559
2198
  const tabState = createTabState(page);
2199
+ attachDownloadListener(tabState, tabId, log);
1560
2200
  group.set(tabId, tabState);
1561
2201
 
1562
2202
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
@@ -1572,7 +2212,7 @@ app.post('/tabs/open', async (req, res) => {
1572
2212
  });
1573
2213
  } catch (err) {
1574
2214
  log('error', 'openclaw tab open failed', { reqId: req.reqId, error: err.message });
1575
- res.status(500).json({ error: safeError(err) });
2215
+ handleRouteError(err, req, res);
1576
2216
  }
1577
2217
  });
1578
2218
 
@@ -1597,6 +2237,11 @@ app.post('/stop', async (req, res) => {
1597
2237
  await browser.close().catch(() => {});
1598
2238
  browser = null;
1599
2239
  }
2240
+ const cleanupTasks = [];
2241
+ for (const session of sessions.values()) {
2242
+ cleanupTasks.push(clearSessionDownloads(session));
2243
+ }
2244
+ await Promise.all(cleanupTasks);
1600
2245
  sessions.clear();
1601
2246
  res.json({ ok: true, stopped: true, profile: 'camoufox' });
1602
2247
  } catch (err) {
@@ -1625,19 +2270,27 @@ app.post('/navigate', async (req, res) => {
1625
2270
  }
1626
2271
 
1627
2272
  const { tabState } = found;
1628
- tabState.toolCalls++;
2273
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1629
2274
 
1630
- const result = await withTimeout(withTabLock(targetId, async () => {
2275
+ const result = await withTabLock(targetId, async () => {
1631
2276
  await tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1632
2277
  tabState.visitedUrls.add(url);
2278
+ tabState.lastSnapshot = null;
2279
+
2280
+ // Google SERP: defer extraction to snapshot call
2281
+ if (isGoogleSerp(tabState.page.url())) {
2282
+ tabState.refs = new Map();
2283
+ return { ok: true, targetId, url: tabState.page.url(), googleSerp: true };
2284
+ }
2285
+
1633
2286
  tabState.refs = await buildRefs(tabState.page);
1634
2287
  return { ok: true, targetId, url: tabState.page.url() };
1635
- }), HANDLER_TIMEOUT_MS, 'openclaw-navigate');
2288
+ });
1636
2289
 
1637
2290
  res.json(result);
1638
2291
  } catch (err) {
1639
2292
  log('error', 'openclaw navigate failed', { reqId: req.reqId, error: err.message });
1640
- res.status(500).json({ error: safeError(err) });
2293
+ handleRouteError(err, req, res);
1641
2294
  }
1642
2295
  });
1643
2296
 
@@ -1657,7 +2310,7 @@ app.get('/snapshot', async (req, res) => {
1657
2310
  }
1658
2311
 
1659
2312
  const { tabState } = found;
1660
- tabState.toolCalls++;
2313
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1661
2314
 
1662
2315
  // Cached chunk retrieval
1663
2316
  if (offset > 0 && tabState.lastSnapshot) {
@@ -1670,6 +2323,28 @@ app.get('/snapshot', async (req, res) => {
1670
2323
  return res.json(response);
1671
2324
  }
1672
2325
 
2326
+ const pageUrl = tabState.page.url();
2327
+
2328
+ // Google SERP fast path
2329
+ if (isGoogleSerp(pageUrl)) {
2330
+ const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
2331
+ tabState.refs = googleRefs;
2332
+ tabState.lastSnapshot = googleSnapshot;
2333
+ const annotatedYaml = googleSnapshot;
2334
+ const win = windowSnapshot(annotatedYaml, 0);
2335
+ const response = {
2336
+ ok: true, format: 'aria', targetId, url: pageUrl,
2337
+ snapshot: win.text, refsCount: tabState.refs.size,
2338
+ truncated: win.truncated, totalChars: win.totalChars,
2339
+ hasMore: win.hasMore, nextOffset: win.nextOffset,
2340
+ };
2341
+ if (req.query.includeScreenshot === 'true') {
2342
+ const pngBuffer = await tabState.page.screenshot({ type: 'png' });
2343
+ response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
2344
+ }
2345
+ return res.json(response);
2346
+ }
2347
+
1673
2348
  tabState.refs = await buildRefs(tabState.page);
1674
2349
 
1675
2350
  const ariaYaml = await getAriaSnapshot(tabState.page);
@@ -1722,7 +2397,7 @@ app.get('/snapshot', async (req, res) => {
1722
2397
  res.json(response);
1723
2398
  } catch (err) {
1724
2399
  log('error', 'openclaw snapshot failed', { reqId: req.reqId, error: err.message });
1725
- res.status(500).json({ error: safeError(err) });
2400
+ handleRouteError(err, req, res);
1726
2401
  }
1727
2402
  });
1728
2403
 
@@ -1746,9 +2421,9 @@ app.post('/act', async (req, res) => {
1746
2421
  }
1747
2422
 
1748
2423
  const { tabState } = found;
1749
- tabState.toolCalls++;
2424
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1750
2425
 
1751
- const result = await withTimeout(withTabLock(targetId, async () => {
2426
+ const result = await withTabLock(targetId, async () => {
1752
2427
  switch (kind) {
1753
2428
  case 'click': {
1754
2429
  const { ref, selector, doubleClick } = params;
@@ -1758,7 +2433,7 @@ app.post('/act', async (req, res) => {
1758
2433
 
1759
2434
  const doClick = async (locatorOrSelector, isLocator) => {
1760
2435
  const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
1761
- const clickOpts = { timeout: 5000 };
2436
+ const clickOpts = { timeout: 3000 };
1762
2437
  if (doubleClick) clickOpts.clickCount = 2;
1763
2438
 
1764
2439
  try {
@@ -1773,8 +2448,13 @@ app.post('/act', async (req, res) => {
1773
2448
  };
1774
2449
 
1775
2450
  if (ref) {
1776
- const locator = refToLocator(tabState.page, ref, tabState.refs);
1777
- if (!locator) throw new Error(`Unknown ref: ${ref}`);
2451
+ let locator = refToLocator(tabState.page, ref, tabState.refs);
2452
+ if (!locator) {
2453
+ log('info', 'auto-refreshing refs before click (openclaw)', { ref, hadRefs: tabState.refs.size });
2454
+ tabState.refs = await buildRefs(tabState.page);
2455
+ locator = refToLocator(tabState.page, ref, tabState.refs);
2456
+ }
2457
+ if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
1778
2458
  await doClick(locator, true);
1779
2459
  } else {
1780
2460
  await doClick(selector, false);
@@ -1795,8 +2475,13 @@ app.post('/act', async (req, res) => {
1795
2475
  }
1796
2476
 
1797
2477
  if (ref) {
1798
- const locator = refToLocator(tabState.page, ref, tabState.refs);
1799
- if (!locator) throw new Error(`Unknown ref: ${ref}`);
2478
+ let locator = refToLocator(tabState.page, ref, tabState.refs);
2479
+ if (!locator) {
2480
+ log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size });
2481
+ tabState.refs = await buildRefs(tabState.page);
2482
+ locator = refToLocator(tabState.page, ref, tabState.refs);
2483
+ }
2484
+ if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
1800
2485
  await locator.fill(text, { timeout: 10000 });
1801
2486
  if (submit) await tabState.page.keyboard.press('Enter');
1802
2487
  } else {
@@ -1817,8 +2502,12 @@ app.post('/act', async (req, res) => {
1817
2502
  case 'scrollIntoView': {
1818
2503
  const { ref, direction = 'down', amount = 500 } = params;
1819
2504
  if (ref) {
1820
- const locator = refToLocator(tabState.page, ref, tabState.refs);
1821
- if (!locator) throw new Error(`Unknown ref: ${ref}`);
2505
+ let locator = refToLocator(tabState.page, ref, tabState.refs);
2506
+ if (!locator) {
2507
+ tabState.refs = await buildRefs(tabState.page);
2508
+ locator = refToLocator(tabState.page, ref, tabState.refs);
2509
+ }
2510
+ if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
1822
2511
  await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
1823
2512
  } else {
1824
2513
  const delta = direction === 'up' ? -amount : amount;
@@ -1833,8 +2522,12 @@ app.post('/act', async (req, res) => {
1833
2522
  if (!ref && !selector) throw new Error('ref or selector required');
1834
2523
 
1835
2524
  if (ref) {
1836
- const locator = refToLocator(tabState.page, ref, tabState.refs);
1837
- if (!locator) throw new Error(`Unknown ref: ${ref}`);
2525
+ let locator = refToLocator(tabState.page, ref, tabState.refs);
2526
+ if (!locator) {
2527
+ tabState.refs = await buildRefs(tabState.page);
2528
+ locator = refToLocator(tabState.page, ref, tabState.refs);
2529
+ }
2530
+ if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
1838
2531
  await locator.hover({ timeout: 5000 });
1839
2532
  } else {
1840
2533
  await tabState.page.locator(selector).hover({ timeout: 5000 });
@@ -1857,19 +2550,19 @@ app.post('/act', async (req, res) => {
1857
2550
  case 'close': {
1858
2551
  await safePageClose(tabState.page);
1859
2552
  found.group.delete(targetId);
1860
- tabLocks.delete(targetId);
2553
+ { const _l = tabLocks.get(targetId); if (_l) _l.drain(); tabLocks.delete(targetId); }
1861
2554
  return { ok: true, targetId };
1862
2555
  }
1863
2556
 
1864
2557
  default:
1865
2558
  throw new Error(`Unsupported action kind: ${kind}`);
1866
2559
  }
1867
- }), HANDLER_TIMEOUT_MS, 'act');
2560
+ });
1868
2561
 
1869
2562
  res.json(result);
1870
2563
  } catch (err) {
1871
2564
  log('error', 'act failed', { reqId: req.reqId, kind: req.body?.kind, error: err.message });
1872
- res.status(500).json({ error: safeError(err) });
2565
+ handleRouteError(err, req, res);
1873
2566
  }
1874
2567
  });
1875
2568
 
@@ -1895,14 +2588,20 @@ setInterval(() => {
1895
2588
  // Active health probe — detect hung browser even when isConnected() lies
1896
2589
  setInterval(async () => {
1897
2590
  if (!browser || healthState.isRecovering) return;
1898
- // Skip probe if operations are in flight
1899
- if (healthState.activeOps > 0) {
2591
+ const timeSinceSuccess = Date.now() - healthState.lastSuccessfulNav;
2592
+ // Skip probe if operations are in flight AND last success was recent.
2593
+ // If it's been >120s since any successful operation, probe anyway —
2594
+ // active ops are likely stuck on a frozen browser and will time out eventually.
2595
+ if (healthState.activeOps > 0 && timeSinceSuccess < 120000) {
1900
2596
  log('info', 'health probe skipped, operations active', { activeOps: healthState.activeOps });
1901
2597
  return;
1902
2598
  }
1903
- const timeSinceSuccess = Date.now() - healthState.lastSuccessfulNav;
1904
2599
  if (timeSinceSuccess < 120000) return;
1905
2600
 
2601
+ if (healthState.activeOps > 0) {
2602
+ log('warn', 'health probe forced despite active ops', { activeOps: healthState.activeOps, timeSinceSuccessMs: timeSinceSuccess });
2603
+ }
2604
+
1906
2605
  let testContext;
1907
2606
  try {
1908
2607
  testContext = await browser.newContext();
@@ -1954,9 +2653,16 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
1954
2653
  process.on('SIGINT', () => gracefulShutdown('SIGINT'));
1955
2654
 
1956
2655
  const PORT = CONFIG.port;
1957
- const server = app.listen(PORT, () => {
2656
+ const server = app.listen(PORT, async () => {
1958
2657
  log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
1959
- // Browser launches lazily on first request (saves ~550MB when idle)
2658
+ // Pre-warm browser so first request doesn't eat a 6-7s cold start
2659
+ try {
2660
+ const start = Date.now();
2661
+ await ensureBrowser();
2662
+ log('info', 'browser pre-warmed', { ms: Date.now() - start });
2663
+ } catch (err) {
2664
+ log('error', 'browser pre-warm failed (will retry on first request)', { error: err.message });
2665
+ }
1960
2666
  });
1961
2667
 
1962
2668
  server.on('error', (err) => {