@askjo/camofox-browser 1.3.1 → 1.4.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.
package/server.js CHANGED
@@ -1,12 +1,27 @@
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 { normalizePlaywrightProxy } from './lib/proxy.js';
9
+ import { windowSnapshot } from './lib/snapshot.js';
10
+ import {
11
+ MAX_DOWNLOAD_INLINE_BYTES,
12
+ clearTabDownloads,
13
+ clearSessionDownloads,
14
+ attachDownloadListener,
15
+ getDownloadsList,
16
+ extractPageImages,
17
+ } from './lib/downloads.js';
18
+ import { detectYtDlp, hasYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } from './lib/youtube.js';
19
+ import {
20
+ register as metricsRegister,
21
+ requestsTotal, requestDuration, pageLoadDuration,
22
+ activeTabsGauge, tabLockQueueDepth,
23
+ tabLockTimeoutsTotal, startMemoryReporter, actionFromReq,
24
+ } from './lib/metrics.js';
10
25
 
11
26
  const CONFIG = loadConfig();
12
27
 
@@ -29,20 +44,34 @@ function log(level, msg, fields = {}) {
29
44
  const app = express();
30
45
  app.use(express.json({ limit: '100kb' }));
31
46
 
32
- // Request logging middleware
47
+ // Request logging + metrics middleware
33
48
  app.use((req, res, next) => {
34
- if (req.path === '/health') return next();
35
49
  const reqId = crypto.randomUUID().slice(0, 8);
36
50
  req.reqId = reqId;
37
51
  req.startTime = Date.now();
52
+
38
53
  const userId = req.body?.userId || req.query?.userId || '-';
39
- log('info', 'req', { reqId, method: req.method, path: req.path, userId });
54
+ if (req.path !== '/health') {
55
+ log('info', 'req', { reqId, method: req.method, path: req.path, userId });
56
+ }
57
+
58
+ const action = actionFromReq(req);
59
+ const done = requestDuration.startTimer({ action });
60
+
40
61
  const origEnd = res.end.bind(res);
41
62
  res.end = function (...args) {
42
63
  const ms = Date.now() - req.startTime;
43
- log('info', 'res', { reqId, status: res.statusCode, ms });
64
+ const isErrorStatus = res.statusCode >= 400;
65
+ requestsTotal.labels(action, isErrorStatus ? 'error' : 'success').inc();
66
+ done();
67
+
68
+ if (req.path !== '/health') {
69
+ log('info', 'res', { reqId, status: res.statusCode, ms });
70
+ }
71
+
44
72
  return origEnd(...args);
45
73
  };
74
+
46
75
  next();
47
76
  });
48
77
 
@@ -72,6 +101,16 @@ function timingSafeCompare(a, b) {
72
101
  return crypto.timingSafeEqual(bufA, bufB);
73
102
  }
74
103
 
104
+ // Custom error for stale/unknown element refs — returned as 422 instead of 500
105
+ class StaleRefsError extends Error {
106
+ constructor(ref, maxRef, totalRefs) {
107
+ super(`Unknown ref: ${ref} (valid refs: e1-${maxRef}, ${totalRefs} total). Refs reset after navigation - call snapshot first.`);
108
+ this.name = 'StaleRefsError';
109
+ this.code = 'stale_refs';
110
+ this.ref = ref;
111
+ }
112
+ }
113
+
75
114
  function safeError(err) {
76
115
  if (CONFIG.nodeEnv === 'production') {
77
116
  log('error', 'internal error', { error: err.message, stack: err.stack });
@@ -80,6 +119,17 @@ function safeError(err) {
80
119
  return err.message;
81
120
  }
82
121
 
122
+ // Send error response with appropriate status code (422 for stale refs, 500 otherwise)
123
+ function sendError(res, err, extraFields = {}) {
124
+ const status = err instanceof StaleRefsError ? 422 : (err.statusCode || 500);
125
+ const body = { error: safeError(err), ...extraFields };
126
+ if (err instanceof StaleRefsError) {
127
+ body.code = 'stale_refs';
128
+ body.ref = err.ref;
129
+ }
130
+ res.status(status).json(body);
131
+ }
132
+
83
133
  function validateUrl(url) {
84
134
  try {
85
135
  const parsed = new URL(url);
@@ -92,26 +142,38 @@ function validateUrl(url) {
92
142
  }
93
143
  }
94
144
 
145
+ function isLoopbackAddress(address) {
146
+ if (!address) return false;
147
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
148
+ }
149
+
95
150
  // Import cookies into a user's browser context (Playwright cookies format)
96
151
  // POST /sessions/:userId/cookies { cookies: Cookie[] }
97
152
  //
98
153
  // SECURITY:
99
154
  // 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>
155
+ // By default, this endpoint is protected by CAMOFOX_API_KEY.
156
+ // For local development convenience, when CAMOFOX_API_KEY is NOT set, we allow
157
+ // unauthenticated cookie import ONLY from loopback (127.0.0.1 / ::1) and ONLY
158
+ // when NODE_ENV != production.
102
159
  app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (req, res) => {
103
160
  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' });
161
+ if (CONFIG.apiKey) {
162
+ const apiKey = CONFIG.apiKey;
163
+ const auth = String(req.headers['authorization'] || '');
164
+ const match = auth.match(/^Bearer\s+(.+)$/i);
165
+ if (!match || !timingSafeCompare(match[1], apiKey)) {
166
+ return res.status(403).json({ error: 'Forbidden' });
167
+ }
168
+ } else {
169
+ const remoteAddress = req.socket?.remoteAddress || '';
170
+ const allowUnauthedLocal = CONFIG.nodeEnv !== 'production' && isLoopbackAddress(remoteAddress);
171
+ if (!allowUnauthedLocal) {
172
+ return res.status(403).json({
173
+ error:
174
+ 'Cookie import is disabled without CAMOFOX_API_KEY except for loopback requests in non-production environments.',
175
+ });
176
+ }
115
177
  }
116
178
 
117
179
  const userId = req.params.userId;
@@ -169,12 +231,13 @@ app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (r
169
231
 
170
232
  let browser = null;
171
233
  // userId -> { context, tabGroups: Map<sessionKey, Map<tabId, TabState>>, lastAccess }
172
- // TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, toolCalls: number }
234
+ // TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, downloads: Array, toolCalls: number }
173
235
  // Note: sessionKey was previously called listItemId - both are accepted for backward compatibility
174
236
  const sessions = new Map();
175
237
 
176
238
  const SESSION_TIMEOUT_MS = CONFIG.sessionTimeoutMs;
177
239
  const MAX_SNAPSHOT_NODES = 500;
240
+ const TAB_INACTIVITY_MS = CONFIG.tabInactivityMs;
178
241
  const MAX_SESSIONS = CONFIG.maxSessions;
179
242
  const MAX_TABS_PER_SESSION = CONFIG.maxTabsPerSession;
180
243
  const MAX_TABS_GLOBAL = CONFIG.maxTabsGlobal;
@@ -184,39 +247,76 @@ const PAGE_CLOSE_TIMEOUT_MS = 5000;
184
247
  const NAVIGATE_TIMEOUT_MS = CONFIG.navigateTimeoutMs;
185
248
  const BUILDREFS_TIMEOUT_MS = CONFIG.buildrefsTimeoutMs;
186
249
  const FAILURE_THRESHOLD = 3;
187
- const TAB_LOCK_TIMEOUT_MS = 30000;
250
+ const MAX_CONSECUTIVE_TIMEOUTS = 3;
251
+ const TAB_LOCK_TIMEOUT_MS = 35000; // Must be > HANDLER_TIMEOUT_MS so active op times out first
252
+
253
+ // Proper mutex for tab serialization. The old Promise-chain lock on timeout proceeded
254
+ // WITHOUT the lock, allowing concurrent Playwright operations that corrupt CDP state.
255
+ class TabLock {
256
+ constructor() {
257
+ this.queue = [];
258
+ this.active = false;
259
+ }
188
260
 
189
- // Per-tab locks to serialize operations on the same tab
190
- // tabId -> Promise (the currently executing operation)
191
- const tabLocks = new Map();
261
+ acquire(timeoutMs) {
262
+ return new Promise((resolve, reject) => {
263
+ const entry = { resolve, reject, timer: null };
264
+ entry.timer = setTimeout(() => {
265
+ const idx = this.queue.indexOf(entry);
266
+ if (idx !== -1) this.queue.splice(idx, 1);
267
+ tabLockTimeoutsTotal.inc();
268
+ refreshTabLockQueueDepth();
269
+ reject(new Error('Tab lock queue timeout'));
270
+ }, timeoutMs);
271
+ this.queue.push(entry);
272
+ refreshTabLockQueueDepth();
273
+ this._tryNext();
274
+ });
275
+ }
192
276
 
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
- }
277
+ release() {
278
+ this.active = false;
279
+ this._tryNext();
280
+ refreshTabLockQueueDepth();
281
+ }
282
+
283
+ _tryNext() {
284
+ if (this.active || this.queue.length === 0) return;
285
+ this.active = true;
286
+ const entry = this.queue.shift();
287
+ clearTimeout(entry.timer);
288
+ refreshTabLockQueueDepth();
289
+ entry.resolve();
290
+ }
291
+
292
+ drain() {
293
+ this.active = true;
294
+ for (const entry of this.queue) {
295
+ clearTimeout(entry.timer);
296
+ entry.reject(new Error('Tab destroyed'));
206
297
  }
298
+ this.queue = [];
299
+ refreshTabLockQueueDepth();
207
300
  }
208
-
209
- // Execute this operation and store the promise
210
- const promise = operation();
211
- tabLocks.set(tabId, promise);
212
-
301
+ }
302
+
303
+ // Per-tab locks to serialize operations on the same tab
304
+ const tabLocks = new Map(); // tabId -> TabLock
305
+
306
+ function getTabLock(tabId) {
307
+ if (!tabLocks.has(tabId)) tabLocks.set(tabId, new TabLock());
308
+ return tabLocks.get(tabId);
309
+ }
310
+
311
+ // Timeout is INSIDE the lock so each operation gets its full budget
312
+ // regardless of how long it waited in the queue.
313
+ async function withTabLock(tabId, operation, timeoutMs = HANDLER_TIMEOUT_MS) {
314
+ const lock = getTabLock(tabId);
315
+ await lock.acquire(TAB_LOCK_TIMEOUT_MS);
213
316
  try {
214
- return await promise;
317
+ return await withTimeout(operation(), timeoutMs, 'action');
215
318
  } finally {
216
- // Clean up if this is still the active lock
217
- if (tabLocks.get(tabId) === promise) {
218
- tabLocks.delete(tabId);
219
- }
319
+ lock.release();
220
320
  }
221
321
  }
222
322
 
@@ -390,6 +490,7 @@ async function launchBrowserInstance() {
390
490
  proxy: proxy,
391
491
  geoip: !!proxy,
392
492
  });
493
+ options.proxy = normalizePlaywrightProxy(options.proxy);
393
494
 
394
495
  browser = await firefox.launch(options);
395
496
  log('info', 'camoufox launched');
@@ -425,6 +526,20 @@ function normalizeUserId(userId) {
425
526
  async function getSession(userId) {
426
527
  const key = normalizeUserId(userId);
427
528
  let session = sessions.get(key);
529
+
530
+ // Check if existing session's context is still alive
531
+ if (session) {
532
+ try {
533
+ // Lightweight probe: pages() is synchronous-ish and throws if context is dead
534
+ session.context.pages();
535
+ } catch (err) {
536
+ log('warn', 'session context dead, recreating', { userId: key, error: err.message });
537
+ session.context.close().catch(() => {});
538
+ sessions.delete(key);
539
+ session = null;
540
+ }
541
+ }
542
+
428
543
  if (!session) {
429
544
  if (sessions.size >= MAX_SESSIONS) {
430
545
  throw new Error('Maximum concurrent sessions reached');
@@ -460,6 +575,96 @@ function getTabGroup(session, listItemId) {
460
575
  return group;
461
576
  }
462
577
 
578
+ function isDeadContextError(err) {
579
+ const msg = err && err.message || '';
580
+ return msg.includes('Target page, context or browser has been closed') ||
581
+ msg.includes('browser has been closed') ||
582
+ msg.includes('Context closed') ||
583
+ msg.includes('Browser closed');
584
+ }
585
+
586
+ function isTimeoutError(err) {
587
+ const msg = err && err.message || '';
588
+ return msg.includes('timed out after') ||
589
+ (msg.includes('Timeout') && msg.includes('exceeded'));
590
+ }
591
+
592
+ function isTabLockQueueTimeout(err) {
593
+ return err && err.message === 'Tab lock queue timeout';
594
+ }
595
+
596
+ function isTabDestroyedError(err) {
597
+ return err && err.message === 'Tab destroyed';
598
+ }
599
+
600
+ // Centralized error handler for route catch blocks.
601
+ // Auto-destroys dead browser sessions and returns appropriate status codes.
602
+ function handleRouteError(err, req, res, extraFields = {}) {
603
+ const userId = req.body?.userId || req.query?.userId;
604
+ if (userId && isDeadContextError(err)) {
605
+ destroySession(userId);
606
+ }
607
+ // Track consecutive timeouts per tab and auto-destroy stuck tabs
608
+ if (userId && isTimeoutError(err)) {
609
+ const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
610
+ const session = sessions.get(normalizeUserId(userId));
611
+ if (session && tabId) {
612
+ const found = findTab(session, tabId);
613
+ if (found) {
614
+ found.tabState.consecutiveTimeouts++;
615
+ if (found.tabState.consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) {
616
+ log('warn', 'auto-destroying tab after consecutive timeouts', { tabId, count: found.tabState.consecutiveTimeouts });
617
+ destroyTab(session, tabId);
618
+ }
619
+ }
620
+ }
621
+ }
622
+ // Lock queue timeout = tab is stuck. Destroy immediately.
623
+ if (userId && isTabLockQueueTimeout(err)) {
624
+ const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
625
+ const session = sessions.get(normalizeUserId(userId));
626
+ if (session && tabId) {
627
+ destroyTab(session, tabId);
628
+ }
629
+ return res.status(503).json({ error: 'Tab unresponsive and has been destroyed. Open a new tab.', ...extraFields });
630
+ }
631
+ // Tab was destroyed while this request was queued in the lock
632
+ if (isTabDestroyedError(err)) {
633
+ return res.status(410).json({ error: 'Tab was destroyed. Open a new tab.', ...extraFields });
634
+ }
635
+ sendError(res, err, extraFields);
636
+ }
637
+
638
+ function destroyTab(session, tabId) {
639
+ const lock = tabLocks.get(tabId);
640
+ if (lock) {
641
+ lock.drain();
642
+ tabLocks.delete(tabId);
643
+ refreshTabLockQueueDepth();
644
+ }
645
+ for (const [listItemId, group] of session.tabGroups) {
646
+ if (group.has(tabId)) {
647
+ const tabState = group.get(tabId);
648
+ log('warn', 'destroying stuck tab', { tabId, listItemId, toolCalls: tabState.toolCalls });
649
+ safePageClose(tabState.page);
650
+ group.delete(tabId);
651
+ if (group.size === 0) session.tabGroups.delete(listItemId);
652
+ refreshActiveTabsGauge();
653
+ return true;
654
+ }
655
+ }
656
+ return false;
657
+ }
658
+
659
+ function destroySession(userId) {
660
+ const key = normalizeUserId(userId);
661
+ const session = sessions.get(key);
662
+ if (!session) return;
663
+ log('warn', 'destroying dead session', { userId: key });
664
+ session.context.close().catch(() => {});
665
+ sessions.delete(key);
666
+ }
667
+
463
668
  function findTab(session, tabId) {
464
669
  for (const [listItemId, group] of session.tabGroups) {
465
670
  if (group.has(tabId)) {
@@ -475,11 +680,36 @@ function createTabState(page) {
475
680
  page,
476
681
  refs: new Map(),
477
682
  visitedUrls: new Set(),
683
+ downloads: [],
478
684
  toolCalls: 0,
685
+ consecutiveTimeouts: 0,
479
686
  lastSnapshot: null,
480
687
  };
481
688
  }
482
689
 
690
+ function refreshActiveTabsGauge() {
691
+ activeTabsGauge.set(getTotalTabCount());
692
+ }
693
+
694
+ function refreshTabLockQueueDepth() {
695
+ let queued = 0;
696
+ for (const lock of tabLocks.values()) {
697
+ if (lock?.queue) queued += lock.queue.length;
698
+ }
699
+ tabLockQueueDepth.set(queued);
700
+ }
701
+
702
+ async function withPageLoadDuration(action, fn) {
703
+ const end = pageLoadDuration.startTimer();
704
+ try {
705
+ return await fn();
706
+ } finally {
707
+ end();
708
+ }
709
+ }
710
+
711
+
712
+
483
713
  async function waitForPageReady(page, options = {}) {
484
714
  const { timeout = 10000, waitForNetwork = true } = options;
485
715
 
@@ -569,6 +799,156 @@ async function dismissConsentDialogs(page) {
569
799
  }
570
800
  }
571
801
 
802
+ // --- Google SERP detection ---
803
+ function isGoogleSerp(url) {
804
+ try {
805
+ const parsed = new URL(url);
806
+ return parsed.hostname.includes('google.') && parsed.pathname === '/search';
807
+ } catch {
808
+ return false;
809
+ }
810
+ }
811
+
812
+ // --- Google SERP: combined extraction (refs + snapshot in one DOM pass) ---
813
+ // Returns { refs: Map, snapshot: string }
814
+ async function extractGoogleSerp(page) {
815
+ const refs = new Map();
816
+ if (!page || page.isClosed()) return { refs, snapshot: '' };
817
+
818
+ const start = Date.now();
819
+
820
+ const alreadyRendered = await page.evaluate(() => !!document.querySelector('#rso h3, #search h3, #rso [data-snhf]')).catch(() => false);
821
+ if (!alreadyRendered) {
822
+ try {
823
+ await page.waitForSelector('#rso h3, #search h3, #rso [data-snhf]', { timeout: 5000 });
824
+ } catch {
825
+ try {
826
+ await page.waitForSelector('#rso a[href]:not([href^="/search"]), #search a[href]:not([href^="/search"])', { timeout: 2000 });
827
+ } catch {}
828
+ }
829
+ }
830
+
831
+ const extracted = await page.evaluate(() => {
832
+ const snapshot = [];
833
+ const elements = [];
834
+ let refCounter = 1;
835
+
836
+ function addRef(role, name) {
837
+ const id = 'e' + refCounter++;
838
+ elements.push({ id, role, name });
839
+ return id;
840
+ }
841
+
842
+ snapshot.push('- heading "' + document.title.replace(/"/g, '\\"') + '"');
843
+
844
+ const searchInput = document.querySelector('input[name="q"], textarea[name="q"]');
845
+ if (searchInput) {
846
+ const name = 'Search';
847
+ const refId = addRef('searchbox', name);
848
+ snapshot.push('- searchbox "' + name + '" [' + refId + ']: ' + (searchInput.value || ''));
849
+ }
850
+
851
+ const navContainer = document.querySelector('div[role="navigation"], div[role="list"]');
852
+ if (navContainer) {
853
+ const navLinks = navContainer.querySelectorAll('a');
854
+ if (navLinks.length > 0) {
855
+ snapshot.push('- navigation:');
856
+ navLinks.forEach(a => {
857
+ const text = (a.textContent || '').trim();
858
+ if (!text || text.length < 1) return;
859
+ if (/^\d+$/.test(text) && parseInt(text) < 50) return;
860
+ const refId = addRef('link', text);
861
+ snapshot.push(' - link "' + text + '" [' + refId + ']');
862
+ });
863
+ }
864
+ }
865
+
866
+ const resultContainer = document.querySelector('#rso') || document.querySelector('#search');
867
+ if (resultContainer) {
868
+ const resultBlocks = resultContainer.querySelectorAll(':scope > div');
869
+ for (const block of resultBlocks) {
870
+ const h3 = block.querySelector('h3');
871
+ const mainLink = h3 ? h3.closest('a') : null;
872
+
873
+ if (h3 && mainLink) {
874
+ const title = h3.textContent.trim().replace(/"/g, '\\"');
875
+ const href = mainLink.href;
876
+ const cite = block.querySelector('cite');
877
+ const displayUrl = cite ? cite.textContent.trim() : '';
878
+
879
+ let snippet = '';
880
+ for (const sel of ['[data-sncf]', '[data-content-feature="1"]', '.VwiC3b', 'div[style*="-webkit-line-clamp"]', 'span.aCOpRe']) {
881
+ const el = block.querySelector(sel);
882
+ if (el) { snippet = el.textContent.trim().slice(0, 300); break; }
883
+ }
884
+ if (!snippet) {
885
+ const allText = block.textContent.trim().replace(/\s+/g, ' ');
886
+ const titleLen = title.length + (displayUrl ? displayUrl.length : 0);
887
+ if (allText.length > titleLen + 20) {
888
+ snippet = allText.slice(titleLen).trim().slice(0, 300);
889
+ }
890
+ }
891
+
892
+ const refId = addRef('link', title);
893
+ snapshot.push('- link "' + title + '" [' + refId + ']:');
894
+ snapshot.push(' - /url: ' + href);
895
+ if (displayUrl) snapshot.push(' - cite: ' + displayUrl);
896
+ if (snippet) snapshot.push(' - text: ' + snippet);
897
+ } else {
898
+ const blockLinks = block.querySelectorAll('a[href^="http"]:not([href*="google.com/search"])');
899
+ if (blockLinks.length > 0) {
900
+ const blockText = block.textContent.trim().replace(/\s+/g, ' ').slice(0, 200);
901
+ if (blockText.length > 10) {
902
+ snapshot.push('- group:');
903
+ snapshot.push(' - text: ' + blockText);
904
+ blockLinks.forEach(a => {
905
+ const linkText = (a.textContent || '').trim().replace(/"/g, '\\"').slice(0, 100);
906
+ if (linkText.length > 2) {
907
+ const refId = addRef('link', linkText);
908
+ snapshot.push(' - link "' + linkText + '" [' + refId + ']:');
909
+ snapshot.push(' - /url: ' + a.href);
910
+ }
911
+ });
912
+ }
913
+ }
914
+ }
915
+ }
916
+ }
917
+
918
+ const paaItems = document.querySelectorAll('[jsname="Cpkphb"], div.related-question-pair');
919
+ if (paaItems.length > 0) {
920
+ snapshot.push('- heading "People also ask"');
921
+ paaItems.forEach(q => {
922
+ const text = (q.textContent || '').trim().replace(/"/g, '\\"').slice(0, 150);
923
+ if (text) {
924
+ const refId = addRef('button', text);
925
+ snapshot.push(' - button "' + text + '" [' + refId + ']');
926
+ }
927
+ });
928
+ }
929
+
930
+ const nextLink = document.querySelector('#botstuff a[aria-label="Next page"], td.d6cvqb a, a#pnnext');
931
+ if (nextLink) {
932
+ const refId = addRef('link', 'Next');
933
+ snapshot.push('- navigation "pagination":');
934
+ snapshot.push(' - link "Next" [' + refId + ']');
935
+ }
936
+
937
+ return { snapshot: snapshot.join('\n'), elements };
938
+ });
939
+
940
+ const seenCounts = new Map();
941
+ for (const el of extracted.elements) {
942
+ const key = `${el.role}:${el.name}`;
943
+ const nth = seenCounts.get(key) || 0;
944
+ seenCounts.set(key, nth + 1);
945
+ refs.set(el.id, { role: el.role, name: el.name, nth });
946
+ }
947
+
948
+ log('info', 'extractGoogleSerp', { elapsed: Date.now() - start, refs: refs.size });
949
+ return { refs, snapshot: extracted.snapshot };
950
+ }
951
+
572
952
  async function buildRefs(page) {
573
953
  const refs = new Map();
574
954
 
@@ -577,6 +957,13 @@ async function buildRefs(page) {
577
957
  return refs;
578
958
  }
579
959
 
960
+ // Google SERP fast path — skip ariaSnapshot entirely
961
+ const url = page.url();
962
+ if (isGoogleSerp(url)) {
963
+ const { refs: googleRefs } = await extractGoogleSerp(page);
964
+ return googleRefs;
965
+ }
966
+
580
967
  const start = Date.now();
581
968
 
582
969
  // Hard total timeout on the entire buildRefs operation
@@ -719,7 +1106,12 @@ app.post('/youtube/transcript', async (req, res) => {
719
1106
 
720
1107
  let result;
721
1108
  if (hasYtDlp()) {
722
- result = await ytDlpTranscript(reqId, url, videoId, lang);
1109
+ try {
1110
+ result = await ytDlpTranscript(reqId, url, videoId, lang);
1111
+ } catch (ytErr) {
1112
+ log('warn', 'yt-dlp failed, falling back to browser', { reqId, error: ytErr.message });
1113
+ result = await browserTranscript(reqId, url, videoId, lang);
1114
+ }
723
1115
  } else {
724
1116
  result = await browserTranscript(reqId, url, videoId, lang);
725
1117
  }
@@ -759,16 +1151,52 @@ async function browserTranscript(reqId, url, videoId, lang) {
759
1151
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATE_TIMEOUT_MS });
760
1152
  await page.waitForTimeout(2000);
761
1153
 
1154
+ // Extract caption track URLs and metadata from ytInitialPlayerResponse
762
1155
  const meta = await page.evaluate(() => {
763
1156
  const r = window.ytInitialPlayerResponse || (typeof ytInitialPlayerResponse !== 'undefined' ? ytInitialPlayerResponse : null);
764
- if (!r) return { title: '' };
1157
+ if (!r) return { title: '', tracks: [] };
765
1158
  const tracks = r?.captions?.playerCaptionsTracklistRenderer?.captionTracks || [];
766
1159
  return {
767
1160
  title: r?.videoDetails?.title || '',
768
- languages: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual' })),
1161
+ tracks: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual', url: t.baseUrl })),
769
1162
  };
770
1163
  });
771
1164
 
1165
+ log('info', 'youtube transcript: extracted caption tracks', { reqId, title: meta.title, trackCount: meta.tracks.length, tracks: meta.tracks.map(t => t.code) });
1166
+
1167
+ // Strategy A: Fetch caption track URL directly from ytInitialPlayerResponse
1168
+ // These URLs are freshly signed by YouTube and work immediately
1169
+ if (meta.tracks && meta.tracks.length > 0) {
1170
+ const track = meta.tracks.find(t => t.code === lang) || meta.tracks[0];
1171
+ if (track && track.url) {
1172
+ const captionUrl = track.url + (track.url.includes('?') ? '&' : '?') + 'fmt=json3';
1173
+ log('info', 'youtube transcript: fetching caption track', { reqId, lang: track.code, url: captionUrl.substring(0, 100) });
1174
+ try {
1175
+ const captionResp = await page.evaluate(async (fetchUrl) => {
1176
+ const resp = await fetch(fetchUrl);
1177
+ return resp.ok ? await resp.text() : null;
1178
+ }, captionUrl);
1179
+ if (captionResp && captionResp.length > 0) {
1180
+ let transcriptText = null;
1181
+ if (captionResp.trimStart().startsWith('{')) transcriptText = parseJson3(captionResp);
1182
+ else if (captionResp.includes('WEBVTT')) transcriptText = parseVtt(captionResp);
1183
+ else if (captionResp.includes('<text')) transcriptText = parseXml(captionResp);
1184
+ if (transcriptText && transcriptText.trim()) {
1185
+ return {
1186
+ status: 'ok', transcript: transcriptText,
1187
+ video_url: url, video_id: videoId, video_title: meta.title,
1188
+ language: track.code, total_words: transcriptText.split(/\s+/).length,
1189
+ available_languages: meta.tracks.map(t => ({ code: t.code, name: t.name, kind: t.kind })),
1190
+ };
1191
+ }
1192
+ }
1193
+ } catch (fetchErr) {
1194
+ log('warn', 'youtube transcript: caption track fetch failed', { reqId, error: fetchErr.message });
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ // Strategy B: Play video and intercept timedtext network response
772
1200
  await page.evaluate(() => {
773
1201
  const v = document.querySelector('video');
774
1202
  if (v) { v.muted = true; v.play().catch(() => {}); }
@@ -781,7 +1209,7 @@ async function browserTranscript(reqId, url, videoId, lang) {
781
1209
  if (!interceptedCaptions) {
782
1210
  return {
783
1211
  status: 'error', code: 404,
784
- message: 'No captions loaded during playback (video may have no captions, or ad blocked it)',
1212
+ message: 'No captions available for this video',
785
1213
  video_url: url, video_id: videoId, title: meta.title,
786
1214
  };
787
1215
  }
@@ -828,6 +1256,11 @@ app.get('/health', (req, res) => {
828
1256
  });
829
1257
  });
830
1258
 
1259
+ app.get('/metrics', async (_req, res) => {
1260
+ res.set('Content-Type', metricsRegister.contentType);
1261
+ res.send(await metricsRegister.metrics());
1262
+ });
1263
+
831
1264
  // Create new tab
832
1265
  app.post('/tabs', async (req, res) => {
833
1266
  try {
@@ -838,33 +1271,43 @@ app.post('/tabs', async (req, res) => {
838
1271
  return res.status(400).json({ error: 'userId and sessionKey required' });
839
1272
  }
840
1273
 
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() });
1274
+ const result = await withTimeout((async () => {
1275
+ const session = await getSession(userId);
1276
+
1277
+ let totalTabs = 0;
1278
+ for (const group of session.tabGroups.values()) totalTabs += group.size;
1279
+ if (totalTabs >= MAX_TABS_PER_SESSION) {
1280
+ throw Object.assign(new Error('Maximum tabs per session reached'), { statusCode: 429 });
1281
+ }
1282
+
1283
+ if (getTotalTabCount() >= MAX_TABS_GLOBAL) {
1284
+ throw Object.assign(new Error('Maximum global tabs reached'), { statusCode: 429 });
1285
+ }
1286
+
1287
+ const group = getTabGroup(session, resolvedSessionKey);
1288
+
1289
+ const page = await session.context.newPage();
1290
+ const tabId = crypto.randomUUID();
1291
+ const tabState = createTabState(page);
1292
+ attachDownloadListener(tabState, tabId);
1293
+ group.set(tabId, tabState);
1294
+ refreshActiveTabsGauge();
1295
+
1296
+ if (url) {
1297
+ const urlErr = validateUrl(url);
1298
+ if (urlErr) throw Object.assign(new Error(urlErr), { statusCode: 400 });
1299
+ await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
1300
+ tabState.visitedUrls.add(url);
1301
+ }
1302
+
1303
+ log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
1304
+ return { tabId, url: page.url() };
1305
+ })(), HANDLER_TIMEOUT_MS, 'tab create');
1306
+
1307
+ res.json(result);
865
1308
  } catch (err) {
866
1309
  log('error', 'tab create failed', { reqId: req.reqId, error: err.message });
867
- res.status(500).json({ error: safeError(err) });
1310
+ handleRouteError(err, req, res);
868
1311
  }
869
1312
  });
870
1313
 
@@ -906,7 +1349,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
906
1349
  const group = getTabGroup(session, resolvedSessionKey);
907
1350
  if (oldestGroup) oldestGroup.delete(oldestTabId);
908
1351
  group.set(tabId, tabState);
909
- tabLocks.delete(oldestTabId);
1352
+ { const _l = tabLocks.get(oldestTabId); if (_l) _l.drain(); tabLocks.delete(oldestTabId); }
910
1353
  log('info', 'tab recycled (limit reached)', { reqId: req.reqId, tabId, recycledFrom: oldestTabId, userId });
911
1354
  } else {
912
1355
  throw new Error('Maximum tabs per session reached');
@@ -914,17 +1357,19 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
914
1357
  } else {
915
1358
  const page = await session.context.newPage();
916
1359
  tabState = createTabState(page);
1360
+ attachDownloadListener(tabState, tabId, log);
917
1361
  const group = getTabGroup(session, resolvedSessionKey);
918
1362
  group.set(tabId, tabState);
1363
+ refreshActiveTabsGauge();
919
1364
  log('info', 'tab auto-created on navigate', { reqId: req.reqId, tabId, userId });
920
1365
  }
921
1366
  } else {
922
1367
  tabState = found.tabState;
923
1368
  }
924
- tabState.toolCalls++;
1369
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
925
1370
 
926
1371
  let targetUrl = url;
927
- if (macro) {
1372
+ if (macro && macro !== '__NO__' && macro !== 'none' && macro !== 'null') {
928
1373
  targetUrl = expandMacro(macro, query) || url;
929
1374
  }
930
1375
 
@@ -934,9 +1379,18 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
934
1379
  if (urlErr) throw new Error(urlErr);
935
1380
 
936
1381
  return await withTabLock(tabId, async () => {
937
- await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
1382
+ await withPageLoadDuration('navigate', () => tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }));
938
1383
  tabState.visitedUrls.add(targetUrl);
939
1384
  tabState.lastSnapshot = null;
1385
+
1386
+ // For Google SERP: skip eager ref building during navigate.
1387
+ // Results render asynchronously after DOMContentLoaded — the snapshot
1388
+ // call will wait for and extract them.
1389
+ if (isGoogleSerp(tabState.page.url())) {
1390
+ tabState.refs = new Map();
1391
+ return { ok: true, tabId, url: tabState.page.url(), refsAvailable: false, googleSerp: true };
1392
+ }
1393
+
940
1394
  tabState.refs = await buildRefs(tabState.page);
941
1395
  return { ok: true, tabId, url: tabState.page.url(), refsAvailable: tabState.refs.size > 0 };
942
1396
  });
@@ -946,8 +1400,11 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
946
1400
  res.json(result);
947
1401
  } catch (err) {
948
1402
  log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
949
- const status = err.message && err.message.startsWith('Blocked URL scheme') ? 400 : 500;
950
- res.status(status).json({ error: safeError(err) });
1403
+ const is400 = err.message && (err.message.startsWith('Blocked URL scheme') || err.message === 'url or macro required');
1404
+ if (is400) {
1405
+ return res.status(400).json({ error: safeError(err) });
1406
+ }
1407
+ handleRouteError(err, req, res);
951
1408
  }
952
1409
  });
953
1410
 
@@ -963,7 +1420,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
963
1420
  if (!found) return res.status(404).json({ error: 'Tab not found' });
964
1421
 
965
1422
  const { tabState } = found;
966
- tabState.toolCalls++;
1423
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
967
1424
 
968
1425
  // Cached chunk retrieval for offset>0 requests
969
1426
  if (offset > 0 && tabState.lastSnapshot) {
@@ -978,6 +1435,31 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
978
1435
  }
979
1436
 
980
1437
  const result = await withUserLimit(userId, () => withTimeout((async () => {
1438
+ const pageUrl = tabState.page.url();
1439
+
1440
+ // Google SERP fast path — DOM extraction instead of ariaSnapshot
1441
+ if (isGoogleSerp(pageUrl)) {
1442
+ const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
1443
+ tabState.refs = googleRefs;
1444
+ tabState.lastSnapshot = googleSnapshot;
1445
+ const annotatedYaml = googleSnapshot;
1446
+ const win = windowSnapshot(annotatedYaml, 0);
1447
+ const response = {
1448
+ url: pageUrl,
1449
+ snapshot: win.text,
1450
+ refsCount: tabState.refs.size,
1451
+ truncated: win.truncated,
1452
+ totalChars: win.totalChars,
1453
+ hasMore: win.hasMore,
1454
+ nextOffset: win.nextOffset,
1455
+ };
1456
+ if (req.query.includeScreenshot === 'true') {
1457
+ const pngBuffer = await tabState.page.screenshot({ type: 'png' });
1458
+ response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
1459
+ }
1460
+ return response;
1461
+ }
1462
+
981
1463
  tabState.refs = await buildRefs(tabState.page);
982
1464
  const ariaYaml = await getAriaSnapshot(tabState.page);
983
1465
 
@@ -1040,7 +1522,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
1040
1522
  res.json(result);
1041
1523
  } catch (err) {
1042
1524
  log('error', 'snapshot failed', { reqId: req.reqId, tabId: req.params.tabId, error: err.message });
1043
- res.status(500).json({ error: safeError(err) });
1525
+ handleRouteError(err, req, res);
1044
1526
  }
1045
1527
  });
1046
1528
 
@@ -1058,7 +1540,7 @@ app.post('/tabs/:tabId/wait', async (req, res) => {
1058
1540
  res.json({ ok: true, ready });
1059
1541
  } catch (err) {
1060
1542
  log('error', 'wait failed', { reqId: req.reqId, error: err.message });
1061
- res.status(500).json({ error: safeError(err) });
1543
+ handleRouteError(err, req, res);
1062
1544
  }
1063
1545
  });
1064
1546
 
@@ -1074,13 +1556,15 @@ app.post('/tabs/:tabId/click', async (req, res) => {
1074
1556
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1075
1557
 
1076
1558
  const { tabState } = found;
1077
- tabState.toolCalls++;
1559
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1078
1560
 
1079
1561
  if (!ref && !selector) {
1080
1562
  return res.status(400).json({ error: 'ref or selector required' });
1081
1563
  }
1082
1564
 
1083
- const result = await withUserLimit(userId, () => withTimeout(withTabLock(tabId, async () => {
1565
+ const result = await withUserLimit(userId, () => withTabLock(tabId, async () => {
1566
+ const clickStart = Date.now();
1567
+ const remainingBudget = () => Math.max(0, HANDLER_TIMEOUT_MS - 2000 - (Date.now() - clickStart));
1084
1568
  // Full mouse event sequence for stubborn JS click handlers (mirrors Swift WebView.swift)
1085
1569
  // Dispatches: mouseover → mouseenter → mousedown → mouseup → click
1086
1570
  const dispatchMouseSequence = async (locator) => {
@@ -1102,18 +1586,32 @@ app.post('/tabs/:tabId/click', async (req, res) => {
1102
1586
  log('info', 'mouse sequence dispatched', { x: x.toFixed(0), y: y.toFixed(0) });
1103
1587
  };
1104
1588
 
1589
+ // On Google SERPs, skip the normal click attempt (always intercepted by overlays)
1590
+ // and go directly to force click — saves 5s timeout per click
1591
+ const onGoogleSerp = isGoogleSerp(tabState.page.url());
1592
+
1105
1593
  const doClick = async (locatorOrSelector, isLocator) => {
1106
1594
  const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
1107
1595
 
1596
+ if (onGoogleSerp) {
1597
+ try {
1598
+ await locator.click({ timeout: 3000, force: true });
1599
+ } catch (forceErr) {
1600
+ log('warn', 'google force click failed, trying mouse sequence');
1601
+ await dispatchMouseSequence(locator);
1602
+ }
1603
+ return;
1604
+ }
1605
+
1108
1606
  try {
1109
1607
  // First try normal click (respects visibility, enabled, not-obscured)
1110
- await locator.click({ timeout: 5000 });
1608
+ await locator.click({ timeout: 3000 });
1111
1609
  } catch (err) {
1112
1610
  // Fallback 1: If intercepted by overlay, retry with force
1113
1611
  if (err.message.includes('intercepts pointer events')) {
1114
1612
  log('warn', 'click intercepted, retrying with force');
1115
1613
  try {
1116
- await locator.click({ timeout: 5000, force: true });
1614
+ await locator.click({ timeout: 3000, force: true });
1117
1615
  } catch (forceErr) {
1118
1616
  // Fallback 2: Full mouse event sequence for stubborn JS handlers
1119
1617
  log('warn', 'force click failed, trying mouse sequence');
@@ -1131,35 +1629,93 @@ app.post('/tabs/:tabId/click', async (req, res) => {
1131
1629
 
1132
1630
  if (ref) {
1133
1631
  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);
1632
+ if (!locator) {
1633
+ // Use tight timeout (4s max) to leave budget for click + post-click buildRefs
1634
+ log('info', 'auto-refreshing refs before click', { ref, hadRefs: tabState.refs.size });
1635
+ try {
1636
+ const preClickBudget = Math.min(4000, remainingBudget());
1637
+ const refreshPromise = buildRefs(tabState.page);
1638
+ const refreshBudget = new Promise((_, reject) => setTimeout(() => reject(new Error('pre_click_refs_timeout')), preClickBudget));
1639
+ tabState.refs = await Promise.race([refreshPromise, refreshBudget]);
1640
+ } catch (e) {
1641
+ if (e.message === 'pre_click_refs_timeout' || e.message === 'buildRefs_timeout') {
1642
+ log('warn', 'pre-click buildRefs timed out, proceeding without refresh');
1643
+ } else {
1644
+ throw e;
1645
+ }
1646
+ }
1138
1647
  locator = refToLocator(tabState.page, ref, tabState.refs);
1139
1648
  }
1140
1649
  if (!locator) {
1141
1650
  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.`);
1651
+ throw new StaleRefsError(ref, maxRef, tabState.refs.size);
1143
1652
  }
1144
1653
  await doClick(locator, true);
1145
1654
  } else {
1146
1655
  await doClick(selector, false);
1147
1656
  }
1148
1657
 
1149
- await tabState.page.waitForTimeout(500);
1658
+ // If clicking on a Google SERP, wait for potential navigation to complete
1659
+ if (onGoogleSerp) {
1660
+ try {
1661
+ await tabState.page.waitForLoadState('domcontentloaded', { timeout: 3000 });
1662
+ } catch {}
1663
+ await tabState.page.waitForTimeout(200);
1664
+ // Skip buildRefs here — SERP clicks typically navigate to a new page,
1665
+ // and the caller always requests /snapshot next which rebuilds refs.
1666
+ tabState.lastSnapshot = null;
1667
+ tabState.refs = new Map();
1668
+ const newUrl = tabState.page.url();
1669
+ tabState.visitedUrls.add(newUrl);
1670
+ return { ok: true, url: newUrl, refsAvailable: false };
1671
+ } else {
1672
+ await tabState.page.waitForTimeout(500);
1673
+ }
1150
1674
  tabState.lastSnapshot = null;
1151
- tabState.refs = await buildRefs(tabState.page);
1675
+ // buildRefs after click — use remaining budget (min 2s) so we don't blow the handler timeout.
1676
+ // If it times out, return without refs (caller's next /snapshot will rebuild them).
1677
+ const postClickBudget = Math.max(2000, remainingBudget());
1678
+ try {
1679
+ const refsPromise = buildRefs(tabState.page);
1680
+ const refsBudget = new Promise((_, reject) => setTimeout(() => reject(new Error('post_click_refs_timeout')), postClickBudget));
1681
+ tabState.refs = await Promise.race([refsPromise, refsBudget]);
1682
+ } catch (e) {
1683
+ if (e.message === 'post_click_refs_timeout' || e.message === 'buildRefs_timeout') {
1684
+ log('warn', 'post-click buildRefs timed out, returning without refs', { budget: postClickBudget, elapsed: Date.now() - clickStart });
1685
+ tabState.refs = new Map();
1686
+ } else {
1687
+ throw e;
1688
+ }
1689
+ }
1152
1690
 
1153
1691
  const newUrl = tabState.page.url();
1154
1692
  tabState.visitedUrls.add(newUrl);
1155
1693
  return { ok: true, url: newUrl, refsAvailable: tabState.refs.size > 0 };
1156
- }), HANDLER_TIMEOUT_MS, 'click'));
1694
+ }));
1157
1695
 
1158
1696
  log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
1159
1697
  res.json(result);
1160
1698
  } catch (err) {
1161
1699
  log('error', 'click failed', { reqId: req.reqId, tabId, error: err.message });
1162
- res.status(500).json({ error: safeError(err) });
1700
+ if (err.message?.includes('timed out')) {
1701
+ try {
1702
+ const session = sessions.get(normalizeUserId(req.body.userId));
1703
+ const found = session && findTab(session, tabId);
1704
+ if (found?.tabState?.page && !found.tabState.page.isClosed()) {
1705
+ found.tabState.refs = await buildRefs(found.tabState.page);
1706
+ found.tabState.lastSnapshot = null;
1707
+ return res.status(500).json({
1708
+ error: safeError(err),
1709
+ hint: 'The page may have changed. Call snapshot to see the current state and retry.',
1710
+ url: found.tabState.page.url(),
1711
+ refsCount: found.tabState.refs.size,
1712
+ });
1713
+ }
1714
+ } catch (refreshErr) {
1715
+ log('warn', 'post-timeout refresh failed', { error: refreshErr.message });
1716
+ }
1717
+ }
1718
+ handleRouteError(err, req, res);
1163
1719
  }
1164
1720
  });
1165
1721
 
@@ -1174,7 +1730,7 @@ app.post('/tabs/:tabId/type', async (req, res) => {
1174
1730
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1175
1731
 
1176
1732
  const { tabState } = found;
1177
- tabState.toolCalls++;
1733
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1178
1734
 
1179
1735
  if (!ref && !selector) {
1180
1736
  return res.status(400).json({ error: 'ref or selector required' });
@@ -1182,8 +1738,13 @@ app.post('/tabs/:tabId/type', async (req, res) => {
1182
1738
 
1183
1739
  await withTabLock(tabId, async () => {
1184
1740
  if (ref) {
1185
- const locator = refToLocator(tabState.page, ref, tabState.refs);
1186
- if (!locator) throw new Error(`Unknown ref: ${ref}`);
1741
+ let locator = refToLocator(tabState.page, ref, tabState.refs);
1742
+ if (!locator) {
1743
+ log('info', 'auto-refreshing refs before fill', { ref, hadRefs: tabState.refs.size });
1744
+ tabState.refs = await buildRefs(tabState.page);
1745
+ locator = refToLocator(tabState.page, ref, tabState.refs);
1746
+ }
1747
+ if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
1187
1748
  await locator.fill(text, { timeout: 10000 });
1188
1749
  } else {
1189
1750
  await tabState.page.fill(selector, text, { timeout: 10000 });
@@ -1193,7 +1754,25 @@ app.post('/tabs/:tabId/type', async (req, res) => {
1193
1754
  res.json({ ok: true });
1194
1755
  } catch (err) {
1195
1756
  log('error', 'type failed', { reqId: req.reqId, error: err.message });
1196
- res.status(500).json({ error: safeError(err) });
1757
+ if (err.message?.includes('timed out') || err.message?.includes('not an <input>')) {
1758
+ try {
1759
+ const session = sessions.get(normalizeUserId(req.body.userId));
1760
+ const found = session && findTab(session, tabId);
1761
+ if (found?.tabState?.page && !found.tabState.page.isClosed()) {
1762
+ found.tabState.refs = await buildRefs(found.tabState.page);
1763
+ found.tabState.lastSnapshot = null;
1764
+ return res.status(500).json({
1765
+ error: safeError(err),
1766
+ hint: 'The page may have changed. Call snapshot to see the current state and retry.',
1767
+ url: found.tabState.page.url(),
1768
+ refsCount: found.tabState.refs.size,
1769
+ });
1770
+ }
1771
+ } catch (refreshErr) {
1772
+ log('warn', 'post-timeout refresh failed', { error: refreshErr.message });
1773
+ }
1774
+ }
1775
+ handleRouteError(err, req, res);
1197
1776
  }
1198
1777
  });
1199
1778
 
@@ -1208,7 +1787,7 @@ app.post('/tabs/:tabId/press', async (req, res) => {
1208
1787
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1209
1788
 
1210
1789
  const { tabState } = found;
1211
- tabState.toolCalls++;
1790
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1212
1791
 
1213
1792
  await withTabLock(tabId, async () => {
1214
1793
  await tabState.page.keyboard.press(key);
@@ -1217,7 +1796,7 @@ app.post('/tabs/:tabId/press', async (req, res) => {
1217
1796
  res.json({ ok: true });
1218
1797
  } catch (err) {
1219
1798
  log('error', 'press failed', { reqId: req.reqId, error: err.message });
1220
- res.status(500).json({ error: safeError(err) });
1799
+ handleRouteError(err, req, res);
1221
1800
  }
1222
1801
  });
1223
1802
 
@@ -1230,7 +1809,7 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
1230
1809
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1231
1810
 
1232
1811
  const { tabState } = found;
1233
- tabState.toolCalls++;
1812
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1234
1813
 
1235
1814
  const delta = direction === 'up' ? -amount : amount;
1236
1815
  await tabState.page.mouse.wheel(0, delta);
@@ -1239,7 +1818,7 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
1239
1818
  res.json({ ok: true });
1240
1819
  } catch (err) {
1241
1820
  log('error', 'scroll failed', { reqId: req.reqId, error: err.message });
1242
- res.status(500).json({ error: safeError(err) });
1821
+ handleRouteError(err, req, res);
1243
1822
  }
1244
1823
  });
1245
1824
 
@@ -1254,18 +1833,28 @@ app.post('/tabs/:tabId/back', async (req, res) => {
1254
1833
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1255
1834
 
1256
1835
  const { tabState } = found;
1257
- tabState.toolCalls++;
1258
-
1259
- const result = await withTimeout(withTabLock(tabId, async () => {
1260
- await tabState.page.goBack({ timeout: 10000 });
1836
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1837
+
1838
+ const result = await withTabLock(tabId, async () => {
1839
+ try {
1840
+ await tabState.page.goBack({ timeout: 10000 });
1841
+ } catch (navErr) {
1842
+ // NS_BINDING_CANCELLED_OLD_LOAD: Firefox cancels the old load when going back.
1843
+ // The navigation itself succeeded — just the prior page's load was interrupted.
1844
+ if (navErr.message && navErr.message.includes('NS_BINDING_CANCELLED')) {
1845
+ log('info', 'goBack cancelled old load (expected)', { reqId: req.reqId, tabId });
1846
+ } else {
1847
+ throw navErr;
1848
+ }
1849
+ }
1261
1850
  tabState.refs = await buildRefs(tabState.page);
1262
1851
  return { ok: true, url: tabState.page.url() };
1263
- }), HANDLER_TIMEOUT_MS, 'back');
1852
+ });
1264
1853
 
1265
1854
  res.json(result);
1266
1855
  } catch (err) {
1267
1856
  log('error', 'back failed', { reqId: req.reqId, error: err.message });
1268
- res.status(500).json({ error: safeError(err) });
1857
+ handleRouteError(err, req, res);
1269
1858
  }
1270
1859
  });
1271
1860
 
@@ -1280,18 +1869,18 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
1280
1869
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1281
1870
 
1282
1871
  const { tabState } = found;
1283
- tabState.toolCalls++;
1872
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1284
1873
 
1285
- const result = await withTimeout(withTabLock(tabId, async () => {
1874
+ const result = await withTabLock(tabId, async () => {
1286
1875
  await tabState.page.goForward({ timeout: 10000 });
1287
1876
  tabState.refs = await buildRefs(tabState.page);
1288
1877
  return { ok: true, url: tabState.page.url() };
1289
- }), HANDLER_TIMEOUT_MS, 'forward');
1878
+ });
1290
1879
 
1291
1880
  res.json(result);
1292
1881
  } catch (err) {
1293
1882
  log('error', 'forward failed', { reqId: req.reqId, error: err.message });
1294
- res.status(500).json({ error: safeError(err) });
1883
+ handleRouteError(err, req, res);
1295
1884
  }
1296
1885
  });
1297
1886
 
@@ -1306,18 +1895,18 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
1306
1895
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1307
1896
 
1308
1897
  const { tabState } = found;
1309
- tabState.toolCalls++;
1898
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1310
1899
 
1311
- const result = await withTimeout(withTabLock(tabId, async () => {
1900
+ const result = await withTabLock(tabId, async () => {
1312
1901
  await tabState.page.reload({ timeout: 30000 });
1313
1902
  tabState.refs = await buildRefs(tabState.page);
1314
1903
  return { ok: true, url: tabState.page.url() };
1315
- }), HANDLER_TIMEOUT_MS, 'refresh');
1904
+ });
1316
1905
 
1317
1906
  res.json(result);
1318
1907
  } catch (err) {
1319
1908
  log('error', 'refresh failed', { reqId: req.reqId, error: err.message });
1320
- res.status(500).json({ error: safeError(err) });
1909
+ handleRouteError(err, req, res);
1321
1910
  }
1322
1911
  });
1323
1912
 
@@ -1335,7 +1924,7 @@ app.get('/tabs/:tabId/links', async (req, res) => {
1335
1924
  }
1336
1925
 
1337
1926
  const { tabState } = found;
1338
- tabState.toolCalls++;
1927
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1339
1928
 
1340
1929
  const allLinks = await tabState.page.evaluate(() => {
1341
1930
  const links = [];
@@ -1358,6 +1947,59 @@ app.get('/tabs/:tabId/links', async (req, res) => {
1358
1947
  });
1359
1948
  } catch (err) {
1360
1949
  log('error', 'links failed', { reqId: req.reqId, error: err.message });
1950
+ handleRouteError(err, req, res);
1951
+ }
1952
+ });
1953
+
1954
+ // Get captured downloads
1955
+ app.get('/tabs/:tabId/downloads', async (req, res) => {
1956
+ try {
1957
+ const userId = req.query.userId;
1958
+ const includeData = req.query.includeData === 'true';
1959
+ const consume = req.query.consume === 'true';
1960
+ const maxBytesRaw = Number(req.query.maxBytes);
1961
+ const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? maxBytesRaw : MAX_DOWNLOAD_INLINE_BYTES;
1962
+ const session = sessions.get(normalizeUserId(userId));
1963
+ const found = session && findTab(session, req.params.tabId);
1964
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
1965
+
1966
+ const { tabState } = found;
1967
+ tabState.toolCalls++;
1968
+
1969
+ const downloads = await getDownloadsList(tabState, { includeData, maxBytes });
1970
+
1971
+ if (consume) {
1972
+ await clearTabDownloads(tabState);
1973
+ }
1974
+
1975
+ res.json({ tabId: req.params.tabId, downloads });
1976
+ } catch (err) {
1977
+ log('error', 'downloads failed', { reqId: req.reqId, error: err.message });
1978
+ res.status(500).json({ error: safeError(err) });
1979
+ }
1980
+ });
1981
+
1982
+ // Get image elements from current page
1983
+ app.get('/tabs/:tabId/images', async (req, res) => {
1984
+ try {
1985
+ const userId = req.query.userId;
1986
+ const includeData = req.query.includeData === 'true';
1987
+ const maxBytesRaw = Number(req.query.maxBytes);
1988
+ const limitRaw = Number(req.query.limit);
1989
+ const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? maxBytesRaw : MAX_DOWNLOAD_INLINE_BYTES;
1990
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 20) : 8;
1991
+ const session = sessions.get(normalizeUserId(userId));
1992
+ const found = session && findTab(session, req.params.tabId);
1993
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
1994
+
1995
+ const { tabState } = found;
1996
+ tabState.toolCalls++;
1997
+
1998
+ const images = await extractPageImages(tabState.page, { includeData, maxBytes, limit });
1999
+
2000
+ res.json({ tabId: req.params.tabId, images });
2001
+ } catch (err) {
2002
+ log('error', 'images failed', { reqId: req.reqId, error: err.message });
1361
2003
  res.status(500).json({ error: safeError(err) });
1362
2004
  }
1363
2005
  });
@@ -1377,7 +2019,7 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
1377
2019
  res.send(buffer);
1378
2020
  } catch (err) {
1379
2021
  log('error', 'screenshot failed', { reqId: req.reqId, error: err.message });
1380
- res.status(500).json({ error: safeError(err) });
2022
+ handleRouteError(err, req, res);
1381
2023
  }
1382
2024
  });
1383
2025
 
@@ -1396,11 +2038,36 @@ app.get('/tabs/:tabId/stats', async (req, res) => {
1396
2038
  listItemId, // Legacy compatibility
1397
2039
  url: tabState.page.url(),
1398
2040
  visitedUrls: Array.from(tabState.visitedUrls),
2041
+ downloadsCount: Array.isArray(tabState.downloads) ? tabState.downloads.length : 0,
1399
2042
  toolCalls: tabState.toolCalls,
1400
2043
  refsCount: tabState.refs.size
1401
2044
  });
1402
2045
  } catch (err) {
1403
2046
  log('error', 'stats failed', { reqId: req.reqId, error: err.message });
2047
+ handleRouteError(err, req, res);
2048
+ }
2049
+ });
2050
+
2051
+ // Evaluate JavaScript in page context
2052
+ app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, res) => {
2053
+ try {
2054
+ const { userId, expression } = req.body;
2055
+ if (!userId) return res.status(400).json({ error: 'userId is required' });
2056
+ if (!expression) return res.status(400).json({ error: 'expression is required' });
2057
+
2058
+ const session = sessions.get(normalizeUserId(userId));
2059
+ const found = session && findTab(session, req.params.tabId);
2060
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
2061
+
2062
+ session.lastAccess = Date.now();
2063
+ const { tabState } = found;
2064
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2065
+
2066
+ const result = await tabState.page.evaluate(expression);
2067
+ log('info', 'evaluate', { reqId: req.reqId, tabId: req.params.tabId, userId, resultType: typeof result });
2068
+ res.json({ ok: true, result });
2069
+ } catch (err) {
2070
+ log('error', 'evaluate failed', { reqId: req.reqId, error: err.message });
1404
2071
  res.status(500).json({ error: safeError(err) });
1405
2072
  }
1406
2073
  });
@@ -1412,18 +2079,20 @@ app.delete('/tabs/:tabId', async (req, res) => {
1412
2079
  const session = sessions.get(normalizeUserId(userId));
1413
2080
  const found = session && findTab(session, req.params.tabId);
1414
2081
  if (found) {
2082
+ await clearTabDownloads(found.tabState);
1415
2083
  await safePageClose(found.tabState.page);
1416
2084
  found.group.delete(req.params.tabId);
1417
- tabLocks.delete(req.params.tabId);
2085
+ { const _l = tabLocks.get(req.params.tabId); if (_l) _l.drain(); tabLocks.delete(req.params.tabId); refreshTabLockQueueDepth(); }
1418
2086
  if (found.group.size === 0) {
1419
2087
  session.tabGroups.delete(found.listItemId);
1420
2088
  }
2089
+ refreshActiveTabsGauge();
1421
2090
  log('info', 'tab closed', { reqId: req.reqId, tabId: req.params.tabId, userId });
1422
2091
  }
1423
2092
  res.json({ ok: true });
1424
2093
  } catch (err) {
1425
2094
  log('error', 'tab close failed', { reqId: req.reqId, error: err.message });
1426
- res.status(500).json({ error: safeError(err) });
2095
+ handleRouteError(err, req, res);
1427
2096
  }
1428
2097
  });
1429
2098
 
@@ -1435,16 +2104,23 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
1435
2104
  const group = session?.tabGroups.get(req.params.listItemId);
1436
2105
  if (group) {
1437
2106
  for (const [tabId, tabState] of group) {
2107
+ await clearTabDownloads(tabState);
1438
2108
  await safePageClose(tabState.page);
1439
- tabLocks.delete(tabId);
2109
+ const lock = tabLocks.get(tabId);
2110
+ if (lock) {
2111
+ lock.drain();
2112
+ tabLocks.delete(tabId);
2113
+ }
1440
2114
  }
1441
2115
  session.tabGroups.delete(req.params.listItemId);
2116
+ refreshTabLockQueueDepth();
2117
+ refreshActiveTabsGauge();
1442
2118
  log('info', 'tab group closed', { reqId: req.reqId, listItemId: req.params.listItemId, userId });
1443
2119
  }
1444
2120
  res.json({ ok: true });
1445
2121
  } catch (err) {
1446
2122
  log('error', 'tab group close failed', { reqId: req.reqId, error: err.message });
1447
- res.status(500).json({ error: safeError(err) });
2123
+ handleRouteError(err, req, res);
1448
2124
  }
1449
2125
  });
1450
2126
 
@@ -1454,15 +2130,28 @@ app.delete('/sessions/:userId', async (req, res) => {
1454
2130
  const userId = normalizeUserId(req.params.userId);
1455
2131
  const session = sessions.get(userId);
1456
2132
  if (session) {
2133
+ await clearSessionDownloads(session);
1457
2134
  await session.context.close();
1458
2135
  sessions.delete(userId);
2136
+ // Remove any lingering tab locks for the session
2137
+ for (const [listItemId, group] of session.tabGroups) {
2138
+ for (const tabId of group.keys()) {
2139
+ const lock = tabLocks.get(tabId);
2140
+ if (lock) {
2141
+ lock.drain();
2142
+ tabLocks.delete(tabId);
2143
+ }
2144
+ }
2145
+ }
2146
+ refreshTabLockQueueDepth();
2147
+ refreshActiveTabsGauge();
1459
2148
  log('info', 'session closed', { userId });
1460
2149
  }
1461
2150
  if (sessions.size === 0) scheduleBrowserIdleShutdown();
1462
2151
  res.json({ ok: true });
1463
2152
  } catch (err) {
1464
2153
  log('error', 'session close failed', { error: err.message });
1465
- res.status(500).json({ error: safeError(err) });
2154
+ handleRouteError(err, req, res);
1466
2155
  }
1467
2156
  });
1468
2157
 
@@ -1471,8 +2160,10 @@ setInterval(() => {
1471
2160
  const now = Date.now();
1472
2161
  for (const [userId, session] of sessions) {
1473
2162
  if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
2163
+ clearSessionDownloads(session).catch(() => {});
1474
2164
  session.context.close().catch(() => {});
1475
2165
  sessions.delete(userId);
2166
+ refreshActiveTabsGauge();
1476
2167
  log('info', 'session expired', { userId });
1477
2168
  }
1478
2169
  }
@@ -1480,6 +2171,40 @@ setInterval(() => {
1480
2171
  if (sessions.size === 0) {
1481
2172
  scheduleBrowserIdleShutdown();
1482
2173
  }
2174
+ refreshTabLockQueueDepth();
2175
+ }, 60_000);
2176
+
2177
+ // Per-tab inactivity reaper — close tabs idle for TAB_INACTIVITY_MS
2178
+ setInterval(() => {
2179
+ const now = Date.now();
2180
+ for (const [userId, session] of sessions) {
2181
+ for (const [listItemId, group] of session.tabGroups) {
2182
+ for (const [tabId, tabState] of group) {
2183
+ if (!tabState._lastReaperCheck) {
2184
+ tabState._lastReaperCheck = now;
2185
+ tabState._lastReaperToolCalls = tabState.toolCalls;
2186
+ continue;
2187
+ }
2188
+ if (tabState.toolCalls === tabState._lastReaperToolCalls) {
2189
+ const idleMs = now - tabState._lastReaperCheck;
2190
+ if (idleMs >= TAB_INACTIVITY_MS) {
2191
+ log('info', 'tab reaped (inactive)', { userId, tabId, listItemId, idleMs, toolCalls: tabState.toolCalls });
2192
+ safePageClose(tabState.page);
2193
+ group.delete(tabId);
2194
+ { const _l = tabLocks.get(tabId); if (_l) _l.drain(); tabLocks.delete(tabId); }
2195
+ refreshTabLockQueueDepth();
2196
+ refreshActiveTabsGauge();
2197
+ }
2198
+ } else {
2199
+ tabState._lastReaperCheck = now;
2200
+ tabState._lastReaperToolCalls = tabState.toolCalls;
2201
+ }
2202
+ }
2203
+ if (group.size === 0) {
2204
+ session.tabGroups.delete(listItemId);
2205
+ }
2206
+ }
2207
+ }
1483
2208
  }, 60_000);
1484
2209
 
1485
2210
  // =============================================================================
@@ -1526,7 +2251,7 @@ app.get('/tabs', async (req, res) => {
1526
2251
  res.json({ running: true, tabs });
1527
2252
  } catch (err) {
1528
2253
  log('error', 'list tabs failed', { reqId: req.reqId, error: err.message });
1529
- res.status(500).json({ error: safeError(err) });
2254
+ handleRouteError(err, req, res);
1530
2255
  }
1531
2256
  });
1532
2257
 
@@ -1546,6 +2271,11 @@ app.post('/tabs/open', async (req, res) => {
1546
2271
 
1547
2272
  const session = await getSession(userId);
1548
2273
 
2274
+ // Check global tab limit first
2275
+ if (getTotalTabCount() >= MAX_TABS_GLOBAL) {
2276
+ return res.status(429).json({ error: 'Maximum global tabs reached' });
2277
+ }
2278
+
1549
2279
  let totalTabs = 0;
1550
2280
  for (const g of session.tabGroups.values()) totalTabs += g.size;
1551
2281
  if (totalTabs >= MAX_TABS_PER_SESSION) {
@@ -1557,9 +2287,11 @@ app.post('/tabs/open', async (req, res) => {
1557
2287
  const page = await session.context.newPage();
1558
2288
  const tabId = crypto.randomUUID();
1559
2289
  const tabState = createTabState(page);
2290
+ attachDownloadListener(tabState, tabId, log);
1560
2291
  group.set(tabId, tabState);
2292
+ refreshActiveTabsGauge();
1561
2293
 
1562
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
2294
+ await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
1563
2295
  tabState.visitedUrls.add(url);
1564
2296
 
1565
2297
  log('info', 'openclaw tab opened', { reqId: req.reqId, tabId, url: page.url() });
@@ -1572,7 +2304,7 @@ app.post('/tabs/open', async (req, res) => {
1572
2304
  });
1573
2305
  } catch (err) {
1574
2306
  log('error', 'openclaw tab open failed', { reqId: req.reqId, error: err.message });
1575
- res.status(500).json({ error: safeError(err) });
2307
+ handleRouteError(err, req, res);
1576
2308
  }
1577
2309
  });
1578
2310
 
@@ -1597,7 +2329,26 @@ app.post('/stop', async (req, res) => {
1597
2329
  await browser.close().catch(() => {});
1598
2330
  browser = null;
1599
2331
  }
2332
+ const cleanupTasks = [];
2333
+ for (const session of sessions.values()) {
2334
+ cleanupTasks.push(clearSessionDownloads(session));
2335
+ }
2336
+ await Promise.all(cleanupTasks);
2337
+ for (const session of sessions.values()) {
2338
+ for (const [, group] of session.tabGroups) {
2339
+ for (const tabId of group.keys()) {
2340
+ const lock = tabLocks.get(tabId);
2341
+ if (lock) {
2342
+ lock.drain();
2343
+ tabLocks.delete(tabId);
2344
+ }
2345
+ }
2346
+ }
2347
+ }
2348
+ tabLocks.clear();
1600
2349
  sessions.clear();
2350
+ refreshActiveTabsGauge();
2351
+ refreshTabLockQueueDepth();
1601
2352
  res.json({ ok: true, stopped: true, profile: 'camoufox' });
1602
2353
  } catch (err) {
1603
2354
  res.status(500).json({ ok: false, error: safeError(err) });
@@ -1625,19 +2376,27 @@ app.post('/navigate', async (req, res) => {
1625
2376
  }
1626
2377
 
1627
2378
  const { tabState } = found;
1628
- tabState.toolCalls++;
2379
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1629
2380
 
1630
- const result = await withTimeout(withTabLock(targetId, async () => {
1631
- await tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
2381
+ const result = await withTabLock(targetId, async () => {
2382
+ await withPageLoadDuration('navigate', () => tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
1632
2383
  tabState.visitedUrls.add(url);
2384
+ tabState.lastSnapshot = null;
2385
+
2386
+ // Google SERP: defer extraction to snapshot call
2387
+ if (isGoogleSerp(tabState.page.url())) {
2388
+ tabState.refs = new Map();
2389
+ return { ok: true, targetId, url: tabState.page.url(), googleSerp: true };
2390
+ }
2391
+
1633
2392
  tabState.refs = await buildRefs(tabState.page);
1634
2393
  return { ok: true, targetId, url: tabState.page.url() };
1635
- }), HANDLER_TIMEOUT_MS, 'openclaw-navigate');
2394
+ });
1636
2395
 
1637
2396
  res.json(result);
1638
2397
  } catch (err) {
1639
2398
  log('error', 'openclaw navigate failed', { reqId: req.reqId, error: err.message });
1640
- res.status(500).json({ error: safeError(err) });
2399
+ handleRouteError(err, req, res);
1641
2400
  }
1642
2401
  });
1643
2402
 
@@ -1657,7 +2416,7 @@ app.get('/snapshot', async (req, res) => {
1657
2416
  }
1658
2417
 
1659
2418
  const { tabState } = found;
1660
- tabState.toolCalls++;
2419
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1661
2420
 
1662
2421
  // Cached chunk retrieval
1663
2422
  if (offset > 0 && tabState.lastSnapshot) {
@@ -1670,6 +2429,28 @@ app.get('/snapshot', async (req, res) => {
1670
2429
  return res.json(response);
1671
2430
  }
1672
2431
 
2432
+ const pageUrl = tabState.page.url();
2433
+
2434
+ // Google SERP fast path
2435
+ if (isGoogleSerp(pageUrl)) {
2436
+ const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
2437
+ tabState.refs = googleRefs;
2438
+ tabState.lastSnapshot = googleSnapshot;
2439
+ const annotatedYaml = googleSnapshot;
2440
+ const win = windowSnapshot(annotatedYaml, 0);
2441
+ const response = {
2442
+ ok: true, format: 'aria', targetId, url: pageUrl,
2443
+ snapshot: win.text, refsCount: tabState.refs.size,
2444
+ truncated: win.truncated, totalChars: win.totalChars,
2445
+ hasMore: win.hasMore, nextOffset: win.nextOffset,
2446
+ };
2447
+ if (req.query.includeScreenshot === 'true') {
2448
+ const pngBuffer = await tabState.page.screenshot({ type: 'png' });
2449
+ response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
2450
+ }
2451
+ return res.json(response);
2452
+ }
2453
+
1673
2454
  tabState.refs = await buildRefs(tabState.page);
1674
2455
 
1675
2456
  const ariaYaml = await getAriaSnapshot(tabState.page);
@@ -1722,7 +2503,7 @@ app.get('/snapshot', async (req, res) => {
1722
2503
  res.json(response);
1723
2504
  } catch (err) {
1724
2505
  log('error', 'openclaw snapshot failed', { reqId: req.reqId, error: err.message });
1725
- res.status(500).json({ error: safeError(err) });
2506
+ handleRouteError(err, req, res);
1726
2507
  }
1727
2508
  });
1728
2509
 
@@ -1746,9 +2527,9 @@ app.post('/act', async (req, res) => {
1746
2527
  }
1747
2528
 
1748
2529
  const { tabState } = found;
1749
- tabState.toolCalls++;
2530
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1750
2531
 
1751
- const result = await withTimeout(withTabLock(targetId, async () => {
2532
+ const result = await withTabLock(targetId, async () => {
1752
2533
  switch (kind) {
1753
2534
  case 'click': {
1754
2535
  const { ref, selector, doubleClick } = params;
@@ -1758,7 +2539,7 @@ app.post('/act', async (req, res) => {
1758
2539
 
1759
2540
  const doClick = async (locatorOrSelector, isLocator) => {
1760
2541
  const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
1761
- const clickOpts = { timeout: 5000 };
2542
+ const clickOpts = { timeout: 3000 };
1762
2543
  if (doubleClick) clickOpts.clickCount = 2;
1763
2544
 
1764
2545
  try {
@@ -1773,8 +2554,13 @@ app.post('/act', async (req, res) => {
1773
2554
  };
1774
2555
 
1775
2556
  if (ref) {
1776
- const locator = refToLocator(tabState.page, ref, tabState.refs);
1777
- if (!locator) throw new Error(`Unknown ref: ${ref}`);
2557
+ let locator = refToLocator(tabState.page, ref, tabState.refs);
2558
+ if (!locator) {
2559
+ log('info', 'auto-refreshing refs before click (openclaw)', { ref, hadRefs: tabState.refs.size });
2560
+ tabState.refs = await buildRefs(tabState.page);
2561
+ locator = refToLocator(tabState.page, ref, tabState.refs);
2562
+ }
2563
+ if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
1778
2564
  await doClick(locator, true);
1779
2565
  } else {
1780
2566
  await doClick(selector, false);
@@ -1795,8 +2581,13 @@ app.post('/act', async (req, res) => {
1795
2581
  }
1796
2582
 
1797
2583
  if (ref) {
1798
- const locator = refToLocator(tabState.page, ref, tabState.refs);
1799
- if (!locator) throw new Error(`Unknown ref: ${ref}`);
2584
+ let locator = refToLocator(tabState.page, ref, tabState.refs);
2585
+ if (!locator) {
2586
+ log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size });
2587
+ tabState.refs = await buildRefs(tabState.page);
2588
+ locator = refToLocator(tabState.page, ref, tabState.refs);
2589
+ }
2590
+ if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
1800
2591
  await locator.fill(text, { timeout: 10000 });
1801
2592
  if (submit) await tabState.page.keyboard.press('Enter');
1802
2593
  } else {
@@ -1817,8 +2608,12 @@ app.post('/act', async (req, res) => {
1817
2608
  case 'scrollIntoView': {
1818
2609
  const { ref, direction = 'down', amount = 500 } = params;
1819
2610
  if (ref) {
1820
- const locator = refToLocator(tabState.page, ref, tabState.refs);
1821
- if (!locator) throw new Error(`Unknown ref: ${ref}`);
2611
+ let locator = refToLocator(tabState.page, ref, tabState.refs);
2612
+ if (!locator) {
2613
+ tabState.refs = await buildRefs(tabState.page);
2614
+ locator = refToLocator(tabState.page, ref, tabState.refs);
2615
+ }
2616
+ if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
1822
2617
  await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
1823
2618
  } else {
1824
2619
  const delta = direction === 'up' ? -amount : amount;
@@ -1833,8 +2628,12 @@ app.post('/act', async (req, res) => {
1833
2628
  if (!ref && !selector) throw new Error('ref or selector required');
1834
2629
 
1835
2630
  if (ref) {
1836
- const locator = refToLocator(tabState.page, ref, tabState.refs);
1837
- if (!locator) throw new Error(`Unknown ref: ${ref}`);
2631
+ let locator = refToLocator(tabState.page, ref, tabState.refs);
2632
+ if (!locator) {
2633
+ tabState.refs = await buildRefs(tabState.page);
2634
+ locator = refToLocator(tabState.page, ref, tabState.refs);
2635
+ }
2636
+ if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
1838
2637
  await locator.hover({ timeout: 5000 });
1839
2638
  } else {
1840
2639
  await tabState.page.locator(selector).hover({ timeout: 5000 });
@@ -1857,19 +2656,19 @@ app.post('/act', async (req, res) => {
1857
2656
  case 'close': {
1858
2657
  await safePageClose(tabState.page);
1859
2658
  found.group.delete(targetId);
1860
- tabLocks.delete(targetId);
2659
+ { const _l = tabLocks.get(targetId); if (_l) _l.drain(); tabLocks.delete(targetId); }
1861
2660
  return { ok: true, targetId };
1862
2661
  }
1863
2662
 
1864
2663
  default:
1865
2664
  throw new Error(`Unsupported action kind: ${kind}`);
1866
2665
  }
1867
- }), HANDLER_TIMEOUT_MS, 'act');
2666
+ });
1868
2667
 
1869
2668
  res.json(result);
1870
2669
  } catch (err) {
1871
2670
  log('error', 'act failed', { reqId: req.reqId, kind: req.body?.kind, error: err.message });
1872
- res.status(500).json({ error: safeError(err) });
2671
+ handleRouteError(err, req, res);
1873
2672
  }
1874
2673
  });
1875
2674
 
@@ -1895,14 +2694,20 @@ setInterval(() => {
1895
2694
  // Active health probe — detect hung browser even when isConnected() lies
1896
2695
  setInterval(async () => {
1897
2696
  if (!browser || healthState.isRecovering) return;
1898
- // Skip probe if operations are in flight
1899
- if (healthState.activeOps > 0) {
2697
+ const timeSinceSuccess = Date.now() - healthState.lastSuccessfulNav;
2698
+ // Skip probe if operations are in flight AND last success was recent.
2699
+ // If it's been >120s since any successful operation, probe anyway —
2700
+ // active ops are likely stuck on a frozen browser and will time out eventually.
2701
+ if (healthState.activeOps > 0 && timeSinceSuccess < 120000) {
1900
2702
  log('info', 'health probe skipped, operations active', { activeOps: healthState.activeOps });
1901
2703
  return;
1902
2704
  }
1903
- const timeSinceSuccess = Date.now() - healthState.lastSuccessfulNav;
1904
2705
  if (timeSinceSuccess < 120000) return;
1905
2706
 
2707
+ if (healthState.activeOps > 0) {
2708
+ log('warn', 'health probe forced despite active ops', { activeOps: healthState.activeOps, timeSinceSuccessMs: timeSinceSuccess });
2709
+ }
2710
+
1906
2711
  let testContext;
1907
2712
  try {
1908
2713
  testContext = await browser.newContext();
@@ -1942,6 +2747,7 @@ async function gracefulShutdown(signal) {
1942
2747
  forceTimeout.unref();
1943
2748
 
1944
2749
  server.close();
2750
+ stopMemoryReporter();
1945
2751
 
1946
2752
  for (const [userId, session] of sessions) {
1947
2753
  await session.context.close().catch(() => {});
@@ -1954,9 +2760,20 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
1954
2760
  process.on('SIGINT', () => gracefulShutdown('SIGINT'));
1955
2761
 
1956
2762
  const PORT = CONFIG.port;
1957
- const server = app.listen(PORT, () => {
2763
+ const server = app.listen(PORT, async () => {
2764
+ startMemoryReporter();
2765
+ refreshActiveTabsGauge();
2766
+ refreshTabLockQueueDepth();
1958
2767
  log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
1959
- // Browser launches lazily on first request (saves ~550MB when idle)
2768
+ // Pre-warm browser so first request doesn't eat a 6-7s cold start
2769
+ try {
2770
+ const start = Date.now();
2771
+ await ensureBrowser();
2772
+ log('info', 'browser pre-warmed', { ms: Date.now() - start });
2773
+ scheduleBrowserIdleShutdown();
2774
+ } catch (err) {
2775
+ log('error', 'browser pre-warm failed (will retry on first request)', { error: err.message });
2776
+ }
1960
2777
  });
1961
2778
 
1962
2779
  server.on('error', (err) => {