@askjo/camofox-browser 1.5.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/Dockerfile +17 -2
  2. package/README.md +138 -8
  3. package/camofox.config.json +18 -0
  4. package/lib/auth.js +71 -0
  5. package/lib/config.js +27 -1
  6. package/lib/cookies.js +38 -1
  7. package/lib/downloads.js +10 -2
  8. package/lib/extract.js +74 -0
  9. package/lib/inflight.js +16 -0
  10. package/lib/metrics.js +29 -0
  11. package/lib/openapi.js +100 -0
  12. package/lib/persistence.js +89 -0
  13. package/lib/plugins.js +175 -0
  14. package/lib/reporter.js +751 -0
  15. package/lib/tmp-cleanup.js +40 -0
  16. package/lib/tracing.js +137 -0
  17. package/openclaw.plugin.json +1 -1
  18. package/package.json +8 -2
  19. package/plugins/persistence/AGENTS.md +37 -0
  20. package/plugins/persistence/README.md +48 -0
  21. package/plugins/persistence/index.js +124 -0
  22. package/plugins/persistence/persistence.test.js +117 -0
  23. package/plugins/persistence/plugin.test.js +98 -0
  24. package/plugins/vnc/AGENTS.md +42 -0
  25. package/plugins/vnc/README.md +165 -0
  26. package/plugins/vnc/apt.txt +7 -0
  27. package/plugins/vnc/index.js +142 -0
  28. package/plugins/vnc/spawn.js +8 -0
  29. package/plugins/vnc/vnc-launcher.js +64 -0
  30. package/plugins/vnc/vnc-watcher.sh +82 -0
  31. package/plugins/vnc/vnc.test.js +204 -0
  32. package/plugins/youtube/AGENTS.md +25 -0
  33. package/plugins/youtube/apt.txt +1 -0
  34. package/plugins/youtube/index.js +206 -0
  35. package/plugins/youtube/post-install.sh +5 -0
  36. package/plugins/youtube/youtube.test.js +41 -0
  37. package/scripts/exec.js +8 -0
  38. package/scripts/generate-openapi.js +24 -0
  39. package/scripts/install-plugin-deps.sh +63 -0
  40. package/scripts/plugin.js +342 -0
  41. package/scripts/plugin.test.js +117 -0
  42. package/server.js +2124 -355
  43. /package/{lib → plugins/youtube}/youtube.js +0 -0
package/server.js CHANGED
@@ -3,11 +3,14 @@ import { VirtualDisplay } from 'camoufox-js/dist/virtdisplay.js';
3
3
  import { firefox } from 'playwright-core';
4
4
  import express from 'express';
5
5
  import crypto from 'crypto';
6
+ import fs from 'fs';
6
7
  import os from 'os';
7
8
  import { expandMacro } from './lib/macros.js';
8
9
  import { loadConfig } from './lib/config.js';
9
10
  import { normalizePlaywrightProxy, createProxyPool, buildProxyUrl } from './lib/proxy.js';
10
11
  import { createFlyHelpers } from './lib/fly.js';
12
+ import { createPluginEvents, loadPlugins } from './lib/plugins.js';
13
+ import { requireAuth, timingSafeCompare as _timingSafeCompare, isLoopbackAddress as _isLoopbackAddress } from './lib/auth.js';
11
14
  import { windowSnapshot } from './lib/snapshot.js';
12
15
  import {
13
16
  MAX_DOWNLOAD_INLINE_BYTES,
@@ -17,17 +20,50 @@ import {
17
20
  getDownloadsList,
18
21
  } from './lib/downloads.js';
19
22
  import { extractPageImages } from './lib/images.js';
20
- import { detectYtDlp, hasYtDlp, ensureYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } from './lib/youtube.js';
23
+ import { extractDeterministic, validateSchema as validateExtractSchema } from './lib/extract.js';
21
24
  import {
22
- initMetrics, getRegister, isMetricsEnabled,
25
+ ensureTracesDir, resolveTracePath, tracePathFor, makeTraceFilename,
26
+ listUserTraces, statTrace, deleteTrace, sweepOldTraces,
27
+ } from './lib/tracing.js';
28
+
29
+ import {
30
+ initMetrics, getRegister, isMetricsEnabled, createMetric,
23
31
  startMemoryReporter, stopMemoryReporter,
24
32
  } from './lib/metrics.js';
25
33
  import { actionFromReq, classifyError } from './lib/request-utils.js';
34
+ import { cleanupOrphanedTempFiles } from './lib/tmp-cleanup.js';
35
+ import { coalesceInflight } from './lib/inflight.js';
36
+ import { createReporter, createTabHealthTracker } from './lib/reporter.js';
37
+ import { mountDocs } from './lib/openapi.js';
26
38
 
27
39
  const CONFIG = loadConfig();
28
40
 
41
+ // --- Crash reporter (opt-in, anonymized GitHub issues) ---
42
+ import { readFileSync } from 'fs';
43
+ const _pkgVersion = (() => { try { return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; } catch { return 'unknown'; } })();
44
+ const reporter = createReporter({ ...CONFIG, version: _pkgVersion });
45
+ reporter.startWatchdog(5000, () => {
46
+ const summary = [];
47
+ for (const [userId, session] of sessions) {
48
+ const urls = [];
49
+ for (const group of session.tabGroups.values()) {
50
+ for (const tab of group.values()) {
51
+ try { if (tab.page) urls.push(tab.page.url()); } catch {}
52
+ }
53
+ }
54
+ summary.push({ userId, urls });
55
+ }
56
+ return { sessions: sessions.size, summary };
57
+ });
58
+
59
+ // --- Plugin event bus ---
60
+ const pluginEvents = createPluginEvents();
61
+
62
+ // --- Shared auth middleware ---
63
+ const authMiddleware = () => requireAuth(CONFIG);
64
+
29
65
  const {
30
- requestsTotal, requestDuration, pageLoadDuration,
66
+ requestsTotal, requestDuration, pageLoadDuration, snapshotBytes,
31
67
  activeTabsGauge, tabLockQueueDepth,
32
68
  tabLockTimeoutsTotal,
33
69
  failuresTotal, browserRestartsTotal, tabsDestroyedTotal,
@@ -106,16 +142,9 @@ const SKIP_PATTERNS = [
106
142
  /date/i, /calendar/i, /picker/i, /datepicker/i
107
143
  ];
108
144
 
109
- function timingSafeCompare(a, b) {
110
- if (typeof a !== 'string' || typeof b !== 'string') return false;
111
- const bufA = Buffer.from(a);
112
- const bufB = Buffer.from(b);
113
- if (bufA.length !== bufB.length) {
114
- crypto.timingSafeEqual(bufA, bufA);
115
- return false;
116
- }
117
- return crypto.timingSafeEqual(bufA, bufB);
118
- }
145
+ // timingSafeCompare and isLoopbackAddress imported from lib/auth.js
146
+ const timingSafeCompare = _timingSafeCompare;
147
+ const isLoopbackAddress = _isLoopbackAddress;
119
148
 
120
149
  // Custom error for stale/unknown element refs — returned as 422 instead of 500
121
150
  class StaleRefsError extends Error {
@@ -158,20 +187,88 @@ function validateUrl(url) {
158
187
  }
159
188
  }
160
189
 
161
- function isLoopbackAddress(address) {
162
- if (!address) return false;
163
- return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
164
- }
190
+ // isLoopbackAddress — now imported from lib/auth.js (see top of file)
165
191
 
166
192
  // Import cookies into a user's browser context (Playwright cookies format)
167
193
  // POST /sessions/:userId/cookies { cookies: Cookie[] }
168
194
  //
169
195
  // SECURITY:
170
196
  // Cookie injection moves this from "anonymous browsing" to "authenticated browsing".
171
- // By default, this endpoint is protected by CAMOFOX_API_KEY.
172
- // For local development convenience, when CAMOFOX_API_KEY is NOT set, we allow
173
- // unauthenticated cookie import ONLY from loopback (127.0.0.1 / ::1) and ONLY
174
- // when NODE_ENV != production.
197
+ /**
198
+ * @openapi
199
+ * /sessions/{userId}/cookies:
200
+ * post:
201
+ * tags: [Sessions]
202
+ * summary: Import cookies into a user session
203
+ * description: Import cookies for authenticated browsing. Requires BearerAuth in production.
204
+ * security:
205
+ * - BearerAuth: []
206
+ * parameters:
207
+ * - name: userId
208
+ * in: path
209
+ * required: true
210
+ * schema:
211
+ * type: string
212
+ * description: Session owner identifier.
213
+ * requestBody:
214
+ * required: true
215
+ * content:
216
+ * application/json:
217
+ * schema:
218
+ * type: object
219
+ * required: [cookies]
220
+ * properties:
221
+ * cookies:
222
+ * type: array
223
+ * maxItems: 500
224
+ * items:
225
+ * type: object
226
+ * required: [name, value, domain]
227
+ * properties:
228
+ * name:
229
+ * type: string
230
+ * value:
231
+ * type: string
232
+ * domain:
233
+ * type: string
234
+ * path:
235
+ * type: string
236
+ * expires:
237
+ * type: number
238
+ * httpOnly:
239
+ * type: boolean
240
+ * secure:
241
+ * type: boolean
242
+ * sameSite:
243
+ * type: string
244
+ * enum: [Strict, Lax, None]
245
+ * responses:
246
+ * 200:
247
+ * description: Cookies imported.
248
+ * content:
249
+ * application/json:
250
+ * schema:
251
+ * type: object
252
+ * properties:
253
+ * ok:
254
+ * type: boolean
255
+ * userId:
256
+ * type: string
257
+ * count:
258
+ * type: integer
259
+ * 400:
260
+ * description: Invalid cookie data.
261
+ * content:
262
+ * application/json:
263
+ * schema:
264
+ * $ref: '#/components/schemas/Error'
265
+ * 403:
266
+ * description: Forbidden.
267
+ * content:
268
+ * application/json:
269
+ * schema:
270
+ * $ref: '#/components/schemas/Error'
271
+ */
175
272
  app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (req, res) => {
176
273
  try {
177
274
  if (CONFIG.apiKey) {
@@ -238,6 +335,7 @@ app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (r
238
335
  await session.context.addCookies(sanitized);
239
336
  const result = { ok: true, userId: String(userId), count: sanitized.length };
240
337
  log('info', 'cookies imported', { reqId: req.reqId, userId: String(userId), count: sanitized.length });
338
+ pluginEvents.emit('session:cookies:import', { userId: String(userId), count: sanitized.length });
241
339
  res.json(result);
242
340
  } catch (err) {
243
341
  failuresTotal.labels(classifyError(err), 'set_cookies').inc();
@@ -486,15 +584,14 @@ async function restartBrowser(reason) {
486
584
  healthState.isRecovering = true;
487
585
  browserRestartsTotal.labels(reason).inc();
488
586
  log('error', 'restarting browser', { reason, failures: healthState.consecutiveNavFailures });
587
+ pluginEvents.emit('browser:restart', { reason });
489
588
  try {
490
- for (const [, session] of sessions) {
491
- await session.context.close().catch(() => {});
492
- }
493
- sessions.clear();
589
+ await closeAllSessions(`browser_restart:${reason}`, { clearDownloads: true, clearLocks: true });
494
590
  if (browser) {
495
591
  await browser.close().catch(() => {});
496
592
  browser = null;
497
593
  }
594
+ pluginEvents.emit('browser:closed', { reason });
498
595
  browserLaunchPromise = null;
499
596
  await ensureBrowser();
500
597
  healthState.consecutiveNavFailures = 0;
@@ -575,7 +672,7 @@ async function launchBrowserInstance() {
575
672
 
576
673
  try {
577
674
  if (os.platform() === 'linux') {
578
- localVirtualDisplay = new VirtualDisplay();
675
+ localVirtualDisplay = pluginCtx.createVirtualDisplay();
579
676
  vdDisplay = localVirtualDisplay.get();
580
677
  log('info', 'xvfb virtual display started', { display: vdDisplay, attempt });
581
678
  }
@@ -608,6 +705,7 @@ async function launchBrowserInstance() {
608
705
  virtual_display: vdDisplay,
609
706
  });
610
707
  options.proxy = normalizePlaywrightProxy(options.proxy);
708
+ await pluginEvents.emitAsync('browser:launching', { options });
611
709
 
612
710
  candidateBrowser = await firefox.launch(options);
613
711
 
@@ -638,6 +736,7 @@ async function launchBrowserInstance() {
638
736
  browserLaunchProxy = launchProxy;
639
737
  browser = candidateBrowser;
640
738
  attachBrowserCleanup(browser, localVirtualDisplay);
739
+ pluginEvents.emit('browser:launched', { browser, display: vdDisplay });
641
740
 
642
741
  log('info', 'camoufox launched', {
643
742
  attempt,
@@ -671,10 +770,7 @@ async function ensureBrowser() {
671
770
  log('warn', 'browser disconnected, clearing dead sessions and relaunching', {
672
771
  deadSessions: sessions.size,
673
772
  });
674
- for (const [userId, session] of sessions) {
675
- await session.context.close().catch(() => {});
676
- }
677
- sessions.clear();
773
+ await closeAllSessions('browser_disconnected', { clearDownloads: true, clearLocks: true });
678
774
  // Clean up virtual display from dead browser before relaunching
679
775
  if (virtualDisplay) {
680
776
  virtualDisplay.kill();
@@ -698,58 +794,137 @@ function normalizeUserId(userId) {
698
794
  return String(userId);
699
795
  }
700
796
 
701
- async function getSession(userId) {
797
+ const sessionCreations = new Map();
798
+
799
+ function clearSessionLocks(session) {
800
+ if (!session?.tabGroups) return;
801
+ for (const [, group] of session.tabGroups) {
802
+ for (const tabId of group.keys()) {
803
+ const lock = tabLocks.get(tabId);
804
+ if (lock) {
805
+ lock.drain();
806
+ tabLocks.delete(tabId);
807
+ }
808
+ }
809
+ }
810
+ refreshTabLockQueueDepth();
811
+ }
812
+
813
+ async function closeSession(userId, session, {
814
+ reason = 'session_closed',
815
+ clearDownloads = true,
816
+ clearLocks = true,
817
+ } = {}) {
818
+ if (!session) return;
819
+
820
+ const key = normalizeUserId(userId);
821
+
822
+ if (clearDownloads) {
823
+ await clearSessionDownloads(session).catch(() => {});
824
+ }
825
+
826
+ await pluginEvents.emitAsync('session:destroying', { userId: key, reason });
827
+ if (session.tracePath) {
828
+ try {
829
+ await session.context.tracing.stop({ path: session.tracePath });
830
+ log('info', 'tracing saved', { userId: key, path: session.tracePath });
831
+ } catch (err) {
832
+ log('warn', 'tracing.stop failed', { userId: key, error: err.message });
833
+ }
834
+ }
835
+
836
+ await session.context.close().catch(() => {});
837
+ sessions.delete(key);
838
+ await pluginEvents.emitAsync('session:destroyed', { userId: key, reason });
839
+
840
+ if (clearLocks) {
841
+ clearSessionLocks(session);
842
+ }
843
+
844
+ refreshActiveTabsGauge();
845
+ }
846
+
847
+ async function closeAllSessions(reason, { clearDownloads = true, clearLocks = true } = {}) {
848
+ const openSessions = Array.from(sessions.entries());
849
+ for (const [userId, session] of openSessions) {
850
+ await closeSession(userId, session, { reason, clearDownloads, clearLocks });
851
+ }
852
+ }
853
+
854
+ async function getSession(userId, { trace = false } = {}) {
702
855
  const key = normalizeUserId(userId);
703
856
  let session = sessions.get(key);
704
857
 
705
858
  // Check if existing session's context is still alive
706
859
  if (session) {
707
- try {
708
- // Lightweight probe: pages() is synchronous-ish and throws if context is dead
709
- session.context.pages();
710
- } catch (err) {
711
- log('warn', 'session context dead, recreating', { userId: key, error: err.message });
712
- session.context.close().catch(() => {});
713
- sessions.delete(key);
860
+ if (session._closing) {
861
+ // Session is being torn down by reaper/expiry treat as dead
714
862
  session = null;
863
+ } else {
864
+ try {
865
+ // Lightweight probe: pages() is synchronous-ish and throws if context is dead
866
+ session.context.pages();
867
+ } catch (err) {
868
+ log('warn', 'session context dead, recreating', { userId: key, error: err.message });
869
+ await closeSession(key, session, { reason: 'dead_context', clearDownloads: true, clearLocks: true });
870
+ session = null;
871
+ }
715
872
  }
716
873
  }
717
874
 
718
875
  if (!session) {
719
- if (sessions.size >= MAX_SESSIONS) {
720
- throw new Error('Maximum concurrent sessions reached');
721
- }
722
- const b = await ensureBrowser();
723
- const contextOptions = {
724
- viewport: { width: 1280, height: 720 },
725
- permissions: ['geolocation'],
726
- };
727
- // When geoip is active (proxy configured), camoufox auto-configures
728
- // locale/timezone/geolocation from the proxy IP. Without proxy, use defaults.
729
- if (!CONFIG.proxy.host) {
730
- contextOptions.locale = 'en-US';
731
- contextOptions.timezoneId = 'America/Los_Angeles';
732
- contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 };
733
- }
734
- let sessionProxy = null;
735
- if (proxyPool?.canRotateSessions) {
736
- sessionProxy = proxyPool.getNext(`ctx-${key}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
737
- contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
738
- log('info', 'session proxy assigned', { userId: key, sessionId: sessionProxy.sessionId });
739
- } else if (proxyPool) {
740
- sessionProxy = proxyPool.getNext();
741
- contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
742
- log('info', 'session proxy assigned', { userId: key, proxy: sessionProxy.server });
743
- }
744
- const context = await b.newContext(contextOptions);
745
-
746
- session = { context, tabGroups: new Map(), lastAccess: Date.now(), proxySessionId: sessionProxy?.sessionId || null };
747
- sessions.set(key, session);
748
- log('info', 'session created', {
749
- userId: key,
750
- proxyMode: proxyPool?.mode || null,
751
- proxyServer: sessionProxy?.server || browserLaunchProxy?.server || null,
752
- proxySession: sessionProxy?.sessionId || browserLaunchProxy?.sessionId || null,
876
+ session = await coalesceInflight(sessionCreations, key, async () => {
877
+ if (sessions.size >= MAX_SESSIONS) {
878
+ throw new Error('Maximum concurrent sessions reached');
879
+ }
880
+ const b = await ensureBrowser();
881
+ const contextOptions = {
882
+ viewport: { width: 1280, height: 720 },
883
+ permissions: ['geolocation'],
884
+ };
885
+ // When geoip is active (proxy configured), camoufox auto-configures
886
+ // locale/timezone/geolocation from the proxy IP. Without proxy, use defaults.
887
+ if (!CONFIG.proxy.host) {
888
+ contextOptions.locale = 'en-US';
889
+ contextOptions.timezoneId = 'America/Los_Angeles';
890
+ contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 };
891
+ }
892
+ let sessionProxy = null;
893
+ if (proxyPool?.canRotateSessions) {
894
+ sessionProxy = proxyPool.getNext(`ctx-${key}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
895
+ contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
896
+ log('info', 'session proxy assigned', { userId: key, sessionId: sessionProxy.sessionId });
897
+ } else if (proxyPool) {
898
+ sessionProxy = proxyPool.getNext();
899
+ contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
900
+ log('info', 'session proxy assigned', { userId: key, proxy: sessionProxy.server });
901
+ }
902
+ await pluginEvents.emitAsync('session:creating', { userId: key, contextOptions });
903
+ const context = await b.newContext(contextOptions);
904
+
905
+ let tracePath = null;
906
+ if (trace) {
907
+ const traceDir = ensureTracesDir(CONFIG.tracesDir, key);
908
+ tracePath = tracePathFor(CONFIG.tracesDir, key, makeTraceFilename());
909
+ try {
910
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
911
+ log('info', 'tracing enabled for session', { userId: key, traceDir, tracePath });
912
+ } catch (err) {
913
+ log('warn', 'tracing.start failed; session will not be traced', { userId: key, error: err.message });
914
+ tracePath = null;
915
+ }
916
+ }
917
+
918
+ const created = { context, tabGroups: new Map(), lastAccess: Date.now(), proxySessionId: sessionProxy?.sessionId || null, tracePath };
919
+ sessions.set(key, created);
920
+ await pluginEvents.emitAsync('session:created', { userId: key, context });
921
+ log('info', 'session created', {
922
+ userId: key,
923
+ proxyMode: proxyPool?.mode || null,
924
+ proxyServer: sessionProxy?.server || browserLaunchProxy?.server || null,
925
+ proxySession: sessionProxy?.sessionId || browserLaunchProxy?.sessionId || null,
926
+ });
927
+ return created;
753
928
  });
754
929
  }
755
930
  session.lastAccess = Date.now();
@@ -801,6 +976,10 @@ function handleRouteError(err, req, res, extraFields = {}) {
801
976
  failuresTotal.labels(failureType, action).inc();
802
977
 
803
978
  const userId = req.body?.userId || req.query?.userId;
979
+ const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
980
+ if (tabId) {
981
+ pluginEvents.emit('tab:error', { userId, tabId, error: err });
982
+ }
804
983
  if (userId && isDeadContextError(err)) {
805
984
  destroySession(userId);
806
985
  }
@@ -823,17 +1002,16 @@ function handleRouteError(err, req, res, extraFields = {}) {
823
1002
  found.tabState.consecutiveTimeouts++;
824
1003
  if (found.tabState.consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) {
825
1004
  log('warn', 'auto-destroying tab after consecutive timeouts', { tabId, count: found.tabState.consecutiveTimeouts });
826
- destroyTab(session, tabId, 'consecutive_timeouts');
1005
+ destroyTab(session, tabId, 'consecutive_timeouts', userId);
827
1006
  }
828
1007
  }
829
1008
  }
830
1009
  }
831
1010
  // Lock queue timeout = tab is stuck. Destroy immediately.
832
1011
  if (userId && isTabLockQueueTimeout(err)) {
833
- const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
834
1012
  const session = sessions.get(normalizeUserId(userId));
835
1013
  if (session && tabId) {
836
- destroyTab(session, tabId, 'lock_queue');
1014
+ destroyTab(session, tabId, 'lock_queue', userId);
837
1015
  }
838
1016
  return res.status(503).json({ error: 'Tab unresponsive and has been destroyed. Open a new tab.', ...extraFields });
839
1017
  }
@@ -841,10 +1019,38 @@ function handleRouteError(err, req, res, extraFields = {}) {
841
1019
  if (isTabDestroyedError(err)) {
842
1020
  return res.status(410).json({ error: 'Tab was destroyed. Open a new tab.', ...extraFields });
843
1021
  }
1022
+ // --- Frustration detection: report when a tab hits a streak of failures ---
1023
+ // Individual failures are noise. 3+ consecutive = the site is persistently broken.
1024
+ const FRUSTRATION_TYPES = new Set(['timeout', 'dead_context', 'nav_aborted']);
1025
+ if (FRUSTRATION_TYPES.has(failureType) && userId && tabId) {
1026
+ const session = sessions.get(normalizeUserId(userId));
1027
+ const found = session && findTab(session, tabId);
1028
+ if (found) {
1029
+ const ts = found.tabState;
1030
+ ts.consecutiveFailures = (ts.consecutiveFailures || 0) + 1;
1031
+ if (!ts.failureJournal) ts.failureJournal = [];
1032
+ ts.failureJournal.push({ type: failureType, action, at: Date.now() });
1033
+ if (ts.failureJournal.length > 20) ts.failureJournal = ts.failureJournal.slice(-20);
1034
+
1035
+ if (ts.consecutiveFailures === 3) {
1036
+ reporter.reportHang(action, req.startTime ? Date.now() - req.startTime : 0, {
1037
+ error: err,
1038
+ url: ts.lastRequestedUrl || undefined,
1039
+ healthSnapshot: ts.healthTracker ? ts.healthTracker.snapshot() : undefined,
1040
+ context: {
1041
+ failureType,
1042
+ consecutiveFailures: ts.consecutiveFailures,
1043
+ toolCalls: ts.toolCalls,
1044
+ journal: ts.failureJournal.map(j => `${j.type}:${j.action}`),
1045
+ },
1046
+ });
1047
+ }
1048
+ }
1049
+ }
844
1050
  sendError(res, err, extraFields);
845
1051
  }
846
1052
 
847
- function destroyTab(session, tabId, reason) {
1053
+ function destroyTab(session, tabId, reason, userId) {
848
1054
  const lock = tabLocks.get(tabId);
849
1055
  if (lock) {
850
1056
  lock.drain();
@@ -860,6 +1066,7 @@ function destroyTab(session, tabId, reason) {
860
1066
  if (group.size === 0) session.tabGroups.delete(listItemId);
861
1067
  refreshActiveTabsGauge();
862
1068
  if (reason) tabsDestroyedTotal.labels(reason).inc();
1069
+ pluginEvents.emit('tab:destroyed', { userId: userId || null, tabId, reason: reason || 'unknown' });
863
1070
  return true;
864
1071
  }
865
1072
  }
@@ -871,7 +1078,7 @@ function destroyTab(session, tabId, reason) {
871
1078
  * Closes the old tab's page and removes it from its group.
872
1079
  * Returns { recycledTabId, recycledFromGroup } or null if no tab to recycle.
873
1080
  */
874
- async function recycleOldestTab(session, reqId) {
1081
+ async function recycleOldestTab(session, reqId, userId) {
875
1082
  let oldestTab = null;
876
1083
  let oldestGroup = null;
877
1084
  let oldestGroupKey = null;
@@ -895,6 +1102,7 @@ async function recycleOldestTab(session, reqId) {
895
1102
  if (lock) { lock.drain(); tabLocks.delete(oldestTabId); }
896
1103
  refreshTabLockQueueDepth();
897
1104
  tabsRecycledTotal.inc();
1105
+ pluginEvents.emit('tab:recycled', { userId: userId || null, tabId: oldestTabId });
898
1106
  log('info', 'tab recycled (limit reached)', { reqId, recycledTabId: oldestTabId, recycledFromGroup: oldestGroupKey });
899
1107
  return { recycledTabId: oldestTabId, recycledFromGroup: oldestGroupKey };
900
1108
  }
@@ -904,8 +1112,8 @@ function destroySession(userId) {
904
1112
  const session = sessions.get(key);
905
1113
  if (!session) return;
906
1114
  log('warn', 'destroying dead session', { userId: key });
907
- session.context.close().catch(() => {});
908
1115
  sessions.delete(key);
1116
+ closeSession(key, session, { reason: 'destroy_session', clearDownloads: true, clearLocks: true }).catch(() => {});
909
1117
  }
910
1118
 
911
1119
  function findTab(session, tabId) {
@@ -919,6 +1127,7 @@ function findTab(session, tabId) {
919
1127
  }
920
1128
 
921
1129
  function createTabState(page) {
1130
+ const healthTracker = createTabHealthTracker(page);
922
1131
  return {
923
1132
  page,
924
1133
  refs: new Map(),
@@ -926,9 +1135,13 @@ function createTabState(page) {
926
1135
  downloads: [],
927
1136
  toolCalls: 0,
928
1137
  consecutiveTimeouts: 0,
1138
+ consecutiveFailures: 0,
1139
+ failureJournal: [],
1140
+ healthTracker,
929
1141
  lastSnapshot: null,
930
1142
  lastRequestedUrl: null,
931
1143
  googleRetryCount: 0,
1144
+ navigateAbort: null,
932
1145
  };
933
1146
  }
934
1147
 
@@ -949,8 +1162,7 @@ async function rotateGoogleTab(userId, sessionKey, tabId, previousTabState, reas
949
1162
  const key = normalizeUserId(userId);
950
1163
  const oldSession = sessions.get(key);
951
1164
  if (oldSession) {
952
- await oldSession.context.close().catch(() => {});
953
- sessions.delete(key);
1165
+ await closeSession(key, oldSession, { reason: 'google_rotate_context', clearDownloads: true, clearLocks: true });
954
1166
  }
955
1167
  const session = await getSession(userId);
956
1168
  const group = getTabGroup(session, sessionKey);
@@ -958,7 +1170,7 @@ async function rotateGoogleTab(userId, sessionKey, tabId, previousTabState, reas
958
1170
  const tabState = createTabState(page);
959
1171
  tabState.googleRetryCount = (previousTabState.googleRetryCount || 0) + 1;
960
1172
  tabState.lastRequestedUrl = previousTabState.lastRequestedUrl;
961
- attachDownloadListener(tabState, tabId, log);
1173
+ attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
962
1174
  group.set(tabId, tabState);
963
1175
  refreshActiveTabsGauge();
964
1176
 
@@ -1447,189 +1659,48 @@ async function refreshTabRefs(tabState, options = {}) {
1447
1659
  return refreshedRefs;
1448
1660
  }
1449
1661
 
1450
- // --- YouTube transcript ---
1451
- // Implementation extracted to lib/youtube.js to avoid scanner false positives
1452
- // (child_process + app.post in same file triggers OpenClaw skill-scanner)
1453
-
1454
- await detectYtDlp(log);
1455
-
1456
- app.post('/youtube/transcript', async (req, res) => {
1457
- const reqId = req.reqId;
1458
- try {
1459
- const { url, languages = ['en'] } = req.body;
1460
- if (!url) return res.status(400).json({ error: 'url is required' });
1461
-
1462
- const urlErr = validateUrl(url);
1463
- if (urlErr) return res.status(400).json({ error: urlErr });
1464
-
1465
- const videoIdMatch = url.match(
1466
- /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/
1467
- );
1468
- if (!videoIdMatch) {
1469
- return res.status(400).json({ error: 'Could not extract YouTube video ID from URL' });
1470
- }
1471
- const videoId = videoIdMatch[1];
1472
- const lang = languages[0] || 'en';
1473
-
1474
- // Re-detect yt-dlp if startup detection failed (transient issue)
1475
- await ensureYtDlp(log);
1476
-
1477
- const ytDlpProxyUrl = buildProxyUrl(proxyPool, CONFIG.proxy);
1478
- log('info', 'youtube transcript: starting', { reqId, videoId, lang, method: hasYtDlp() ? 'yt-dlp' : 'browser', hasProxy: !!ytDlpProxyUrl });
1479
-
1480
- let result;
1481
- if (hasYtDlp()) {
1482
- try {
1483
- result = await ytDlpTranscript(reqId, url, videoId, lang, ytDlpProxyUrl);
1484
- } catch (ytErr) {
1485
- log('warn', 'yt-dlp threw, falling back to browser', { reqId, error: ytErr.message });
1486
- result = null;
1487
- }
1488
- // If yt-dlp returned an error result (e.g. no captions) or threw, try browser
1489
- if (!result || result.status !== 'ok') {
1490
- if (result) log('warn', 'yt-dlp returned error, falling back to browser', { reqId, status: result.status, code: result.code });
1491
- result = await browserTranscript(reqId, url, videoId, lang);
1492
- }
1493
- } else {
1494
- result = await browserTranscript(reqId, url, videoId, lang);
1495
- }
1496
-
1497
- log('info', 'youtube transcript: done', { reqId, videoId, status: result.status, words: result.total_words });
1498
- res.json(result);
1499
- } catch (err) {
1500
- failuresTotal.labels(classifyError(err), 'youtube_transcript').inc();
1501
- log('error', 'youtube transcript failed', { reqId, error: err.message, stack: err.stack });
1502
- res.status(500).json({ error: safeError(err) });
1503
- }
1504
- });
1505
-
1506
- // Browser fallback — play video, intercept timedtext network response
1507
- async function browserTranscript(reqId, url, videoId, lang) {
1508
- return await withUserLimit('__yt_transcript__', async () => {
1509
- await ensureBrowser();
1510
- const session = await getSession('__yt_transcript__');
1511
- const page = await session.context.newPage();
1512
-
1513
- try {
1514
- await page.addInitScript(() => {
1515
- const origPlay = HTMLMediaElement.prototype.play;
1516
- HTMLMediaElement.prototype.play = function() { this.volume = 0; this.muted = true; return origPlay.call(this); };
1517
- });
1518
-
1519
- let interceptedCaptions = null;
1520
- page.on('response', async (response) => {
1521
- const respUrl = response.url();
1522
- if (respUrl.includes('/api/timedtext') && respUrl.includes(`v=${videoId}`) && !interceptedCaptions) {
1523
- try {
1524
- const body = await response.text();
1525
- if (body && body.length > 0) interceptedCaptions = body;
1526
- } catch {}
1527
- }
1528
- });
1529
-
1530
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATE_TIMEOUT_MS });
1531
- await page.waitForTimeout(2000);
1532
-
1533
- // Extract caption track URLs and metadata from ytInitialPlayerResponse
1534
- const meta = await page.evaluate(() => {
1535
- const r = window.ytInitialPlayerResponse || (typeof ytInitialPlayerResponse !== 'undefined' ? ytInitialPlayerResponse : null);
1536
- if (!r) return { title: '', tracks: [] };
1537
- const tracks = r?.captions?.playerCaptionsTracklistRenderer?.captionTracks || [];
1538
- return {
1539
- title: r?.videoDetails?.title || '',
1540
- tracks: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual', url: t.baseUrl })),
1541
- };
1542
- });
1543
-
1544
- log('info', 'youtube transcript: extracted caption tracks', { reqId, title: meta.title, trackCount: meta.tracks.length, tracks: meta.tracks.map(t => t.code) });
1545
-
1546
- // Strategy A: Fetch caption track URL directly from ytInitialPlayerResponse
1547
- // These URLs are freshly signed by YouTube and work immediately
1548
- if (meta.tracks && meta.tracks.length > 0) {
1549
- const track = meta.tracks.find(t => t.code === lang) || meta.tracks[0];
1550
- if (track && track.url) {
1551
- const captionUrl = track.url + (track.url.includes('?') ? '&' : '?') + 'fmt=json3';
1552
- log('info', 'youtube transcript: fetching caption track', { reqId, lang: track.code, url: captionUrl.substring(0, 100) });
1553
- try {
1554
- const captionResp = await page.evaluate(async (fetchUrl) => {
1555
- const resp = await fetch(fetchUrl);
1556
- return resp.ok ? await resp.text() : null;
1557
- }, captionUrl);
1558
- if (captionResp && captionResp.length > 0) {
1559
- let transcriptText = null;
1560
- if (captionResp.trimStart().startsWith('{')) transcriptText = parseJson3(captionResp);
1561
- else if (captionResp.includes('WEBVTT')) transcriptText = parseVtt(captionResp);
1562
- else if (captionResp.includes('<text')) transcriptText = parseXml(captionResp);
1563
- if (transcriptText && transcriptText.trim()) {
1564
- return {
1565
- status: 'ok', transcript: transcriptText,
1566
- video_url: url, video_id: videoId, video_title: meta.title,
1567
- language: track.code, total_words: transcriptText.split(/\s+/).length,
1568
- available_languages: meta.tracks.map(t => ({ code: t.code, name: t.name, kind: t.kind })),
1569
- };
1570
- }
1571
- }
1572
- } catch (fetchErr) {
1573
- log('warn', 'youtube transcript: caption track fetch failed', { reqId, error: fetchErr.message });
1574
- }
1575
- }
1576
- }
1577
-
1578
- // Strategy B: Play video and intercept timedtext network response
1579
- await page.evaluate(() => {
1580
- const v = document.querySelector('video');
1581
- if (v) { v.muted = true; v.play().catch(() => {}); }
1582
- }).catch(() => {});
1583
-
1584
- for (let i = 0; i < 40 && !interceptedCaptions; i++) {
1585
- await page.waitForTimeout(500);
1586
- }
1587
-
1588
- if (!interceptedCaptions) {
1589
- return {
1590
- status: 'error', code: 404,
1591
- message: 'No captions available for this video',
1592
- video_url: url, video_id: videoId, title: meta.title,
1593
- };
1594
- }
1595
-
1596
- log('info', 'youtube transcript: intercepted captions', { reqId, len: interceptedCaptions.length });
1597
-
1598
- let transcriptText = null;
1599
- if (interceptedCaptions.trimStart().startsWith('{')) transcriptText = parseJson3(interceptedCaptions);
1600
- else if (interceptedCaptions.includes('WEBVTT')) transcriptText = parseVtt(interceptedCaptions);
1601
- else if (interceptedCaptions.includes('<text')) transcriptText = parseXml(interceptedCaptions);
1602
-
1603
- if (!transcriptText || !transcriptText.trim()) {
1604
- return {
1605
- status: 'error', code: 404,
1606
- message: 'Caption data intercepted but could not be parsed',
1607
- video_url: url, video_id: videoId, title: meta.title,
1608
- };
1609
- }
1610
-
1611
- return {
1612
- status: 'ok', transcript: transcriptText,
1613
- video_url: url, video_id: videoId, video_title: meta.title,
1614
- language: lang, total_words: transcriptText.split(/\s+/).length,
1615
- available_languages: meta.languages,
1616
- };
1617
- } finally {
1618
- await safePageClose(page);
1619
- // Clean up phantom transcript session if no tabs remain
1620
- const ytSession = sessions.get(normalizeUserId('__yt_transcript__'));
1621
- if (ytSession) {
1622
- let totalTabs = 0;
1623
- for (const g of ytSession.tabGroups.values()) totalTabs += g.size;
1624
- if (totalTabs === 0) {
1625
- ytSession.context.close().catch(() => {});
1626
- sessions.delete(normalizeUserId('__yt_transcript__'));
1627
- }
1628
- }
1629
- }
1630
- });
1631
- }
1632
1662
 
1663
+ /**
1664
+ * @openapi
1665
+ * /health:
1666
+ * get:
1667
+ * tags: [System]
1668
+ * summary: Health check
1669
+ * description: Detailed health with tab/session counts and failure tracking.
1670
+ * responses:
1671
+ * 200:
1672
+ * description: Healthy.
1673
+ * content:
1674
+ * application/json:
1675
+ * schema:
1676
+ * type: object
1677
+ * properties:
1678
+ * ok:
1679
+ * type: boolean
1680
+ * engine:
1681
+ * type: string
1682
+ * browserConnected:
1683
+ * type: boolean
1684
+ * browserRunning:
1685
+ * type: boolean
1686
+ * activeTabs:
1687
+ * type: integer
1688
+ * activeSessions:
1689
+ * type: integer
1690
+ * consecutiveFailures:
1691
+ * type: integer
1692
+ * 503:
1693
+ * description: Unhealthy or recovering.
1694
+ * content:
1695
+ * application/json:
1696
+ * schema:
1697
+ * type: object
1698
+ * properties:
1699
+ * ok:
1700
+ * type: boolean
1701
+ * recovering:
1702
+ * type: boolean
1703
+ */
1633
1704
  app.get('/health', (req, res) => {
1634
1705
  if (healthState.isRecovering) {
1635
1706
  return res.status(503).json({ ok: false, engine: 'camoufox', recovering: true });
@@ -1658,6 +1729,27 @@ app.get('/health', (req, res) => {
1658
1729
  });
1659
1730
  });
1660
1731
 
1732
+ /**
1733
+ * @openapi
1734
+ * /metrics:
1735
+ * get:
1736
+ * tags: [System]
1737
+ * summary: Prometheus metrics
1738
+ * description: Returns Prometheus text exposition format. Requires PROMETHEUS_ENABLED=1.
1739
+ * responses:
1740
+ * 200:
1741
+ * description: Prometheus metrics.
1742
+ * content:
1743
+ * text/plain:
1744
+ * schema:
1745
+ * type: string
1746
+ * 404:
1747
+ * description: Metrics disabled.
1748
+ * content:
1749
+ * application/json:
1750
+ * schema:
1751
+ * $ref: '#/components/schemas/Error'
1752
+ */
1661
1753
  app.get('/metrics', async (_req, res) => {
1662
1754
  const reg = getRegister();
1663
1755
  if (!reg) {
@@ -1669,24 +1761,92 @@ app.get('/metrics', async (_req, res) => {
1669
1761
  });
1670
1762
 
1671
1763
  // Create new tab
1764
+ /**
1765
+ * @openapi
1766
+ * /tabs:
1767
+ * post:
1768
+ * tags: [Tabs]
1769
+ * summary: Create a new tab
1770
+ * description: Creates a tab in the given session. Optionally navigates to an initial URL.
1771
+ * requestBody:
1772
+ * required: true
1773
+ * content:
1774
+ * application/json:
1775
+ * schema:
1776
+ * type: object
1777
+ * required: [userId, sessionKey]
1778
+ * properties:
1779
+ * userId:
1780
+ * type: string
1781
+ * description: Session owner.
1782
+ * sessionKey:
1783
+ * type: string
1784
+ * description: Tab group identifier.
1785
+ * listItemId:
1786
+ * type: string
1787
+ * description: Legacy alias for sessionKey.
1788
+ * url:
1789
+ * type: string
1790
+ * description: Optional initial URL.
1791
+ * trace:
1792
+ * type: boolean
1793
+ * description: Enable Playwright tracing for this session (screenshots, DOM snapshots, network). Must be set on first tab creation; cannot be added to an existing session.
1794
+ * responses:
1795
+ * 200:
1796
+ * description: Tab created.
1797
+ * content:
1798
+ * application/json:
1799
+ * schema:
1800
+ * type: object
1801
+ * properties:
1802
+ * tabId:
1803
+ * type: string
1804
+ * url:
1805
+ * type: string
1806
+ * 400:
1807
+ * description: Missing required fields.
1808
+ * content:
1809
+ * application/json:
1810
+ * schema:
1811
+ * $ref: '#/components/schemas/Error'
1812
+ * 429:
1813
+ * description: Tab limit reached.
1814
+ * content:
1815
+ * application/json:
1816
+ * schema:
1817
+ * $ref: '#/components/schemas/Error'
1818
+ * 409:
1819
+ * description: Cannot enable tracing on an existing session.
1820
+ * content:
1821
+ * application/json:
1822
+ * schema:
1823
+ * $ref: '#/components/schemas/Error'
1824
+ */
1672
1825
  app.post('/tabs', async (req, res) => {
1673
1826
  try {
1674
- const { userId, sessionKey, listItemId, url } = req.body;
1827
+ const { userId, sessionKey, listItemId, url, trace } = req.body;
1675
1828
  // Accept both sessionKey (preferred) and listItemId (legacy) for backward compatibility
1676
1829
  const resolvedSessionKey = sessionKey || listItemId;
1677
1830
  if (!userId || !resolvedSessionKey) {
1678
1831
  return res.status(400).json({ error: 'userId and sessionKey required' });
1679
1832
  }
1680
-
1833
+
1681
1834
  const result = await withTimeout((async () => {
1682
- const session = await getSession(userId);
1835
+ const existing = sessions.get(normalizeUserId(userId));
1836
+ if (trace && existing && !existing.tracePath) {
1837
+ throw Object.assign(
1838
+ new Error('trace must be set on session creation. DELETE /sessions/:userId first to restart with tracing.'),
1839
+ { statusCode: 409 },
1840
+ );
1841
+ }
1842
+ const session = await getSession(userId, { trace: !!trace });
1683
1843
 
1684
1844
  let totalTabs = 0;
1685
1845
  for (const group of session.tabGroups.values()) totalTabs += group.size;
1686
1846
 
1687
1847
  // Recycle oldest tab when limits are reached instead of rejecting
1688
1848
  if (totalTabs >= MAX_TABS_PER_SESSION || getTotalTabCount() >= MAX_TABS_GLOBAL) {
1689
- const recycled = await recycleOldestTab(session, req.reqId);
1849
+ const recycled = await recycleOldestTab(session, req.reqId, userId);
1690
1850
  if (!recycled) {
1691
1851
  throw Object.assign(new Error('Maximum tabs per session reached'), { statusCode: 429 });
1692
1852
  }
@@ -1697,7 +1857,7 @@ app.post('/tabs', async (req, res) => {
1697
1857
  const page = await session.context.newPage();
1698
1858
  const tabId = fly.makeTabId();
1699
1859
  const tabState = createTabState(page);
1700
- attachDownloadListener(tabState, tabId);
1860
+ attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
1701
1861
  group.set(tabId, tabState);
1702
1862
  refreshActiveTabsGauge();
1703
1863
 
@@ -1709,6 +1869,7 @@ app.post('/tabs', async (req, res) => {
1709
1869
  tabState.visitedUrls.add(url);
1710
1870
  }
1711
1871
 
1872
+ pluginEvents.emit('tab:created', { userId, tabId, page, url: page.url() });
1712
1873
  log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
1713
1874
  return { tabId, url: page.url() };
1714
1875
  })(), requestTimeoutMs(), 'tab create');
@@ -1721,6 +1882,61 @@ app.post('/tabs', async (req, res) => {
1721
1882
  });
1722
1883
 
1723
1884
  // Navigate
1885
+ /**
1886
+ * @openapi
1887
+ * /tabs/{tabId}/navigate:
1888
+ * post:
1889
+ * tags: [Navigation]
1890
+ * summary: Navigate a tab to a URL or macro
1891
+ * description: Navigate to a URL or expand a search macro. Auto-creates tab if not found.
1892
+ * parameters:
1893
+ * - name: tabId
1894
+ * in: path
1895
+ * required: true
1896
+ * schema:
1897
+ * type: string
1898
+ * requestBody:
1899
+ * required: true
1900
+ * content:
1901
+ * application/json:
1902
+ * schema:
1903
+ * type: object
1904
+ * required: [userId]
1905
+ * properties:
1906
+ * userId:
1907
+ * type: string
1908
+ * url:
1909
+ * type: string
1910
+ * macro:
1911
+ * type: string
1912
+ * description: Search macro (e.g. @google_search).
1913
+ * query:
1914
+ * type: string
1915
+ * description: Search query for macro.
1916
+ * sessionKey:
1917
+ * type: string
1918
+ * listItemId:
1919
+ * type: string
1920
+ * responses:
1921
+ * 200:
1922
+ * description: Navigation result with snapshot.
1923
+ * content:
1924
+ * application/json:
1925
+ * schema:
1926
+ * type: object
1927
+ * 400:
1928
+ * description: Bad request.
1929
+ * content:
1930
+ * application/json:
1931
+ * schema:
1932
+ * $ref: '#/components/schemas/Error'
1933
+ * 404:
1934
+ * description: Tab not found.
1935
+ * content:
1936
+ * application/json:
1937
+ * schema:
1938
+ * $ref: '#/components/schemas/Error'
1939
+ */
1724
1940
  app.post('/tabs/:tabId/navigate', async (req, res) => {
1725
1941
  const tabId = req.params.tabId;
1726
1942
 
@@ -1741,7 +1957,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1741
1957
  for (const g of session.tabGroups.values()) sessionTabs += g.size;
1742
1958
  if (getTotalTabCount() >= MAX_TABS_GLOBAL || sessionTabs >= MAX_TABS_PER_SESSION) {
1743
1959
  // Recycle oldest tab to free a slot, then create new page
1744
- const recycled = await recycleOldestTab(session, req.reqId);
1960
+ const recycled = await recycleOldestTab(session, req.reqId, userId);
1745
1961
  if (!recycled) {
1746
1962
  throw new Error('Maximum tabs per session reached');
1747
1963
  }
@@ -1749,7 +1965,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1749
1965
  {
1750
1966
  const page = await session.context.newPage();
1751
1967
  tabState = createTabState(page);
1752
- attachDownloadListener(tabState, tabId, log);
1968
+ attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
1753
1969
  const group = getTabGroup(session, resolvedSessionKey);
1754
1970
  group.set(tabId, tabState);
1755
1971
  refreshActiveTabsGauge();
@@ -1758,7 +1974,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1758
1974
  } else {
1759
1975
  tabState = found.tabState;
1760
1976
  }
1761
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1977
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
1762
1978
 
1763
1979
  let targetUrl = url;
1764
1980
  if (macro && macro !== '__NO__' && macro !== 'none' && macro !== 'null') {
@@ -1776,9 +1992,21 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1776
1992
 
1777
1993
  const navigateCurrentPage = async () => {
1778
1994
  tabState.lastRequestedUrl = targetUrl;
1779
- await withPageLoadDuration('navigate', () => tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }));
1780
- tabState.visitedUrls.add(targetUrl);
1781
- tabState.lastSnapshot = null;
1995
+ const ac = tabState.navigateAbort = new AbortController();
1996
+ const gotoP = withPageLoadDuration('navigate', () => tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }));
1997
+ try {
1998
+ await Promise.race([
1999
+ gotoP,
2000
+ new Promise((_, reject) => ac.signal.addEventListener('abort', () => reject(new Error('Navigation aborted: tab deleted')), { once: true })),
2001
+ ]);
2002
+ tabState.visitedUrls.add(targetUrl);
2003
+ tabState.lastSnapshot = null;
2004
+ } catch (err) {
2005
+ gotoP.catch(() => {}); // suppress unhandled rejection from still-pending goto
2006
+ throw err;
2007
+ } finally {
2008
+ tabState.navigateAbort = null;
2009
+ }
1782
2010
  };
1783
2011
 
1784
2012
  const prewarmGoogleHome = async () => {
@@ -1796,15 +2024,14 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1796
2024
  const key = normalizeUserId(userId);
1797
2025
  const oldSession = sessions.get(key);
1798
2026
  if (oldSession) {
1799
- await oldSession.context.close().catch(() => {});
1800
- sessions.delete(key);
2027
+ await closeSession(key, oldSession, { reason: 'google_blocked_context_rotate', clearDownloads: true, clearLocks: true });
1801
2028
  }
1802
2029
  session = await getSession(userId);
1803
2030
  const group = getTabGroup(session, currentSessionKey);
1804
2031
  const page = await session.context.newPage();
1805
2032
  tabState = createTabState(page);
1806
2033
  tabState.googleRetryCount = previousRetryCount + 1;
1807
- attachDownloadListener(tabState, tabId, log);
2034
+ attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
1808
2035
  group.set(tabId, tabState);
1809
2036
  refreshActiveTabsGauge();
1810
2037
  };
@@ -1845,6 +2072,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1845
2072
  })(), requestTimeoutMs(), 'navigate'));
1846
2073
 
1847
2074
  log('info', 'navigated', { reqId: req.reqId, tabId, url: result.url });
2075
+ pluginEvents.emit('tab:navigated', { userId: req.body.userId, tabId, url: result.url, prevUrl: null });
1848
2076
  res.json(result);
1849
2077
  } catch (err) {
1850
2078
  log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
@@ -1857,6 +2085,69 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1857
2085
  });
1858
2086
 
1859
2087
  // Snapshot
2088
+ /**
2089
+ * @openapi
2090
+ * /tabs/{tabId}/snapshot:
2091
+ * get:
2092
+ * tags: [Content]
2093
+ * summary: Accessibility snapshot
2094
+ * description: Returns accessibility tree with element refs. Supports pagination via offset.
2095
+ * parameters:
2096
+ * - name: tabId
2097
+ * in: path
2098
+ * required: true
2099
+ * schema:
2100
+ * type: string
2101
+ * - name: userId
2102
+ * in: query
2103
+ * required: true
2104
+ * schema:
2105
+ * type: string
2106
+ * - name: format
2107
+ * in: query
2108
+ * schema:
2109
+ * type: string
2110
+ * enum: [text, json]
2111
+ * default: text
2112
+ * - name: offset
2113
+ * in: query
2114
+ * schema:
2115
+ * type: integer
2116
+ * description: Character offset for paginated retrieval.
2117
+ * - name: includeScreenshot
2118
+ * in: query
2119
+ * schema:
2120
+ * type: string
2121
+ * enum: ['true', 'false']
2122
+ * responses:
2123
+ * 200:
2124
+ * description: Snapshot.
2125
+ * content:
2126
+ * application/json:
2127
+ * schema:
2128
+ * type: object
2129
+ * properties:
2130
+ * url:
2131
+ * type: string
2132
+ * snapshot:
2133
+ * type: string
2134
+ * refsCount:
2135
+ * type: integer
2136
+ * truncated:
2137
+ * type: boolean
2138
+ * totalChars:
2139
+ * type: integer
2140
+ * hasMore:
2141
+ * type: boolean
2142
+ * nextOffset:
2143
+ * type: integer
2144
+ * 404:
2145
+ * description: Tab not found.
2146
+ * content:
2147
+ * application/json:
2148
+ * schema:
2149
+ * $ref: '#/components/schemas/Error'
2150
+ */
1860
2151
  app.get('/tabs/:tabId/snapshot', async (req, res) => {
1861
2152
  try {
1862
2153
  const userId = req.query.userId;
@@ -1868,7 +2159,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
1868
2159
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1869
2160
 
1870
2161
  const { tabState } = found;
1871
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2162
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
1872
2163
 
1873
2164
  // Cached chunk retrieval for offset>0 requests
1874
2165
  if (offset > 0 && tabState.lastSnapshot) {
@@ -1909,6 +2200,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
1909
2200
  const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
1910
2201
  tabState.refs = googleRefs;
1911
2202
  tabState.lastSnapshot = googleSnapshot;
2203
+ snapshotBytes.labels('google_serp').observe(Buffer.byteLength(googleSnapshot, 'utf8'));
1912
2204
  const annotatedYaml = googleSnapshot;
1913
2205
  const win = windowSnapshot(annotatedYaml, 0);
1914
2206
  const response = {
@@ -1965,6 +2257,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
1965
2257
  }
1966
2258
 
1967
2259
  tabState.lastSnapshot = annotatedYaml;
2260
+ if (annotatedYaml) snapshotBytes.labels('full').observe(Buffer.byteLength(annotatedYaml, 'utf8'));
1968
2261
  const win = windowSnapshot(annotatedYaml, 0);
1969
2262
 
1970
2263
  const response = {
@@ -1985,6 +2278,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
1985
2278
  return response;
1986
2279
  })(), requestTimeoutMs(), 'snapshot'));
1987
2280
 
2281
+ pluginEvents.emit('tab:snapshot', { userId: req.query.userId, tabId: req.params.tabId, snapshot: result.snapshot });
1988
2282
  log('info', 'snapshot', { reqId: req.reqId, tabId: req.params.tabId, url: result.url, snapshotLen: result.snapshot?.length, refsCount: result.refsCount, hasScreenshot: !!result.screenshot, truncated: result.truncated });
1989
2283
  res.json(result);
1990
2284
  } catch (err) {
@@ -1994,6 +2288,50 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
1994
2288
  });
1995
2289
 
1996
2290
  // Wait for page ready
2291
+ /**
2292
+ * @openapi
2293
+ * /tabs/{tabId}/wait:
2294
+ * post:
2295
+ * tags: [Interaction]
2296
+ * summary: Wait for a selector or timeout
2297
+ * parameters:
2298
+ * - name: tabId
2299
+ * in: path
2300
+ * required: true
2301
+ * schema:
2302
+ * type: string
2303
+ * requestBody:
2304
+ * required: true
2305
+ * content:
2306
+ * application/json:
2307
+ * schema:
2308
+ * type: object
2309
+ * required: [userId]
2310
+ * properties:
2311
+ * userId:
2312
+ * type: string
2313
+ * selector:
2314
+ * type: string
2315
+ * timeout:
2316
+ * type: integer
2317
+ * description: Max wait in ms.
2318
+ * responses:
2319
+ * 200:
2320
+ * description: Wait completed.
2321
+ * content:
2322
+ * application/json:
2323
+ * schema:
2324
+ * type: object
2325
+ * properties:
2326
+ * ok:
2327
+ * type: boolean
2328
+ * 404:
2329
+ * description: Tab not found.
2330
+ * content:
2331
+ * application/json:
2332
+ * schema:
2333
+ * $ref: '#/components/schemas/Error'
2334
+ */
1997
2335
  app.post('/tabs/:tabId/wait', async (req, res) => {
1998
2336
  try {
1999
2337
  const { userId, timeout = 10000, waitForNetwork = true } = req.body;
@@ -2012,6 +2350,64 @@ app.post('/tabs/:tabId/wait', async (req, res) => {
2012
2350
  });
2013
2351
 
2014
2352
  // Click
2353
+ /**
2354
+ * @openapi
2355
+ * /tabs/{tabId}/click:
2356
+ * post:
2357
+ * tags: [Interaction]
2358
+ * summary: Click an element
2359
+ * description: Click by element ref, CSS selector, or coordinates.
2360
+ * parameters:
2361
+ * - name: tabId
2362
+ * in: path
2363
+ * required: true
2364
+ * schema:
2365
+ * type: string
2366
+ * requestBody:
2367
+ * required: true
2368
+ * content:
2369
+ * application/json:
2370
+ * schema:
2371
+ * type: object
2372
+ * required: [userId]
2373
+ * properties:
2374
+ * userId:
2375
+ * type: string
2376
+ * ref:
2377
+ * type: string
2378
+ * description: Element ref ID (e.g. "e3").
2379
+ * selector:
2380
+ * type: string
2381
+ * description: CSS selector fallback.
2382
+ * doubleClick:
2383
+ * type: boolean
2384
+ * coordinates:
2385
+ * type: object
2386
+ * properties:
2387
+ * x:
2388
+ * type: number
2389
+ * y:
2390
+ * type: number
2391
+ * responses:
2392
+ * 200:
2393
+ * description: Click result with optional post-action snapshot.
2394
+ * content:
2395
+ * application/json:
2396
+ * schema:
2397
+ * type: object
2398
+ * 400:
2399
+ * description: Bad request.
2400
+ * content:
2401
+ * application/json:
2402
+ * schema:
2403
+ * $ref: '#/components/schemas/Error'
2404
+ * 404:
2405
+ * description: Tab not found.
2406
+ * content:
2407
+ * application/json:
2408
+ * schema:
2409
+ * $ref: '#/components/schemas/Error'
2410
+ */
2015
2411
  app.post('/tabs/:tabId/click', async (req, res) => {
2016
2412
  const tabId = req.params.tabId;
2017
2413
 
@@ -2023,7 +2419,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
2023
2419
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2024
2420
 
2025
2421
  const { tabState } = found;
2026
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2422
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2027
2423
 
2028
2424
  if (!ref && !selector) {
2029
2425
  return res.status(400).json({ error: 'ref or selector required' });
@@ -2157,6 +2553,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
2157
2553
  }));
2158
2554
 
2159
2555
  log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
2556
+ pluginEvents.emit('tab:click', { userId: req.body.userId, tabId, ref: req.body.ref, selector: req.body.selector });
2160
2557
  res.json(result);
2161
2558
  } catch (err) {
2162
2559
  log('error', 'click failed', { reqId: req.reqId, tabId, error: err.message });
@@ -2183,37 +2580,117 @@ app.post('/tabs/:tabId/click', async (req, res) => {
2183
2580
  });
2184
2581
 
2185
2582
  // Type
2583
+ /**
2584
+ * @openapi
2585
+ * /tabs/{tabId}/type:
2586
+ * post:
2587
+ * tags: [Interaction]
2588
+ * summary: Type text into an element
2589
+ * description: Types text into a focused element or a specific ref/selector.
2590
+ * parameters:
2591
+ * - name: tabId
2592
+ * in: path
2593
+ * required: true
2594
+ * schema:
2595
+ * type: string
2596
+ * requestBody:
2597
+ * required: true
2598
+ * content:
2599
+ * application/json:
2600
+ * schema:
2601
+ * type: object
2602
+ * required: [userId, text]
2603
+ * properties:
2604
+ * userId:
2605
+ * type: string
2606
+ * ref:
2607
+ * type: string
2608
+ * selector:
2609
+ * type: string
2610
+ * text:
2611
+ * type: string
2612
+ * clear:
2613
+ * type: boolean
2614
+ * description: Clear field before typing.
2615
+ * submit:
2616
+ * type: boolean
2617
+ * description: Press Enter after typing.
2618
+ * responses:
2619
+ * 200:
2620
+ * description: Type result.
2621
+ * content:
2622
+ * application/json:
2623
+ * schema:
2624
+ * type: object
2625
+ * 400:
2626
+ * description: Bad request.
2627
+ * content:
2628
+ * application/json:
2629
+ * schema:
2630
+ * $ref: '#/components/schemas/Error'
2631
+ * 404:
2632
+ * description: Tab not found.
2633
+ * content:
2634
+ * application/json:
2635
+ * schema:
2636
+ * $ref: '#/components/schemas/Error'
2637
+ */
2186
2638
  app.post('/tabs/:tabId/type', async (req, res) => {
2187
2639
  const tabId = req.params.tabId;
2188
2640
 
2189
2641
  try {
2190
- const { userId, ref, selector, text } = req.body;
2642
+ const { userId, ref, selector, text, mode = 'fill', delay = 30, submit = false, pressEnter = false } = req.body;
2191
2643
  const session = sessions.get(normalizeUserId(userId));
2192
2644
  const found = session && findTab(session, tabId);
2193
2645
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2194
2646
 
2195
2647
  const { tabState } = found;
2196
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2648
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2197
2649
 
2198
- if (!ref && !selector) {
2199
- return res.status(400).json({ error: 'ref or selector required' });
2650
+ if (mode !== 'fill' && mode !== 'keyboard') {
2651
+ return res.status(400).json({ error: "mode must be 'fill' or 'keyboard'" });
2652
+ }
2653
+ if (typeof text !== 'string') {
2654
+ return res.status(400).json({ error: 'text is required' });
2200
2655
  }
2656
+ // keyboard mode: ref/selector are optional (types into current focus)
2657
+ if (mode === 'fill' && !ref && !selector) {
2658
+ return res.status(400).json({ error: 'ref or selector required for mode=fill' });
2659
+ }
2660
+ const shouldSubmit = submit || pressEnter;
2201
2661
 
2202
2662
  await withTabLock(tabId, async () => {
2663
+ // Resolve and focus the target if ref/selector provided
2664
+ let locator = null;
2203
2665
  if (ref) {
2204
- let locator = refToLocator(tabState.page, ref, tabState.refs);
2666
+ locator = refToLocator(tabState.page, ref, tabState.refs);
2205
2667
  if (!locator) {
2206
- log('info', 'auto-refreshing refs before fill', { ref, hadRefs: tabState.refs.size });
2668
+ log('info', 'auto-refreshing refs before type', { ref, hadRefs: tabState.refs.size, mode });
2207
2669
  tabState.refs = await refreshTabRefs(tabState, { reason: 'type' });
2208
2670
  locator = refToLocator(tabState.page, ref, tabState.refs);
2209
2671
  }
2210
2672
  if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
2211
- await locator.fill(text, { timeout: 10000 });
2673
+ }
2674
+
2675
+ if (mode === 'fill') {
2676
+ if (locator) {
2677
+ await locator.fill(text, { timeout: 10000 });
2678
+ } else {
2679
+ await tabState.page.fill(selector, text, { timeout: 10000 });
2680
+ }
2212
2681
  } else {
2213
- await tabState.page.fill(selector, text, { timeout: 10000 });
2682
+ // keyboard mode char-by-char real key events (required for Ember/contenteditable)
2683
+ if (locator) {
2684
+ await locator.focus({ timeout: 10000 });
2685
+ } else if (selector) {
2686
+ await tabState.page.focus(selector, { timeout: 10000 });
2687
+ }
2688
+ await tabState.page.keyboard.type(text, { delay });
2214
2689
  }
2690
+ if (shouldSubmit) await tabState.page.keyboard.press('Enter');
2215
2691
  });
2216
2692
 
2693
+ pluginEvents.emit('tab:type', { userId: req.body.userId, tabId, text: req.body.text, ref: req.body.ref, mode: req.body.mode || 'fill' });
2217
2694
  res.json({ ok: true });
2218
2695
  } catch (err) {
2219
2696
  log('error', 'type failed', { reqId: req.reqId, error: err.message });
@@ -2240,6 +2717,48 @@ app.post('/tabs/:tabId/type', async (req, res) => {
2240
2717
  });
2241
2718
 
2242
2719
  // Press key
2720
+ /**
2721
+ * @openapi
2722
+ * /tabs/{tabId}/press:
2723
+ * post:
2724
+ * tags: [Interaction]
2725
+ * summary: Press a keyboard key
2726
+ * parameters:
2727
+ * - name: tabId
2728
+ * in: path
2729
+ * required: true
2730
+ * schema:
2731
+ * type: string
2732
+ * requestBody:
2733
+ * required: true
2734
+ * content:
2735
+ * application/json:
2736
+ * schema:
2737
+ * type: object
2738
+ * required: [userId, key]
2739
+ * properties:
2740
+ * userId:
2741
+ * type: string
2742
+ * key:
2743
+ * type: string
2744
+ * description: Key name (e.g. "Enter", "Escape", "Tab").
2745
+ * responses:
2746
+ * 200:
2747
+ * description: Key pressed.
2748
+ * content:
2749
+ * application/json:
2750
+ * schema:
2751
+ * type: object
2752
+ * properties:
2753
+ * ok:
2754
+ * type: boolean
2755
+ * 404:
2756
+ * description: Tab not found.
2757
+ * content:
2758
+ * application/json:
2759
+ * schema:
2760
+ * $ref: '#/components/schemas/Error'
2761
+ */
2243
2762
  app.post('/tabs/:tabId/press', async (req, res) => {
2244
2763
  const tabId = req.params.tabId;
2245
2764
 
@@ -2250,12 +2769,13 @@ app.post('/tabs/:tabId/press', async (req, res) => {
2250
2769
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2251
2770
 
2252
2771
  const { tabState } = found;
2253
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2772
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2254
2773
 
2255
2774
  await withTabLock(tabId, async () => {
2256
2775
  await tabState.page.keyboard.press(key);
2257
2776
  });
2258
2777
 
2778
+ pluginEvents.emit('tab:press', { userId, tabId, key });
2259
2779
  res.json({ ok: true });
2260
2780
  } catch (err) {
2261
2781
  log('error', 'press failed', { reqId: req.reqId, error: err.message });
@@ -2264,6 +2784,51 @@ app.post('/tabs/:tabId/press', async (req, res) => {
2264
2784
  });
2265
2785
 
2266
2786
  // Scroll
2787
+ /**
2788
+ * @openapi
2789
+ * /tabs/{tabId}/scroll:
2790
+ * post:
2791
+ * tags: [Interaction]
2792
+ * summary: Scroll the page
2793
+ * parameters:
2794
+ * - name: tabId
2795
+ * in: path
2796
+ * required: true
2797
+ * schema:
2798
+ * type: string
2799
+ * requestBody:
2800
+ * required: true
2801
+ * content:
2802
+ * application/json:
2803
+ * schema:
2804
+ * type: object
2805
+ * required: [userId]
2806
+ * properties:
2807
+ * userId:
2808
+ * type: string
2809
+ * direction:
2810
+ * type: string
2811
+ * description: '"up" or "down" (default "down").'
2812
+ * amount:
2813
+ * type: integer
2814
+ * description: Pixels to scroll.
2815
+ * responses:
2816
+ * 200:
2817
+ * description: Scroll result.
2818
+ * content:
2819
+ * application/json:
2820
+ * schema:
2821
+ * type: object
2822
+ * properties:
2823
+ * ok:
2824
+ * type: boolean
2825
+ * 404:
2826
+ * description: Tab not found.
2827
+ * content:
2828
+ * application/json:
2829
+ * schema:
2830
+ * $ref: '#/components/schemas/Error'
2831
+ */
2267
2832
  app.post('/tabs/:tabId/scroll', async (req, res) => {
2268
2833
  try {
2269
2834
  const { userId, direction = 'down', amount = 500 } = req.body;
@@ -2272,13 +2837,14 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
2272
2837
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2273
2838
 
2274
2839
  const { tabState } = found;
2275
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2840
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2276
2841
 
2277
2842
  const isVertical = direction === 'up' || direction === 'down';
2278
2843
  const delta = (direction === 'up' || direction === 'left') ? -amount : amount;
2279
2844
  await tabState.page.mouse.wheel(isVertical ? 0 : delta, isVertical ? delta : 0);
2280
2845
  await tabState.page.waitForTimeout(300);
2281
2846
 
2847
+ pluginEvents.emit('tab:scroll', { userId, tabId: req.params.tabId, direction, amount });
2282
2848
  res.json({ ok: true });
2283
2849
  } catch (err) {
2284
2850
  log('error', 'scroll failed', { reqId: req.reqId, error: err.message });
@@ -2287,6 +2853,47 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
2287
2853
  });
2288
2854
 
2289
2855
  // Back
2856
+ /**
2857
+ * @openapi
2858
+ * /tabs/{tabId}/back:
2859
+ * post:
2860
+ * tags: [Navigation]
2861
+ * summary: Go back
2862
+ * parameters:
2863
+ * - name: tabId
2864
+ * in: path
2865
+ * required: true
2866
+ * schema:
2867
+ * type: string
2868
+ * requestBody:
2869
+ * required: true
2870
+ * content:
2871
+ * application/json:
2872
+ * schema:
2873
+ * type: object
2874
+ * required: [userId]
2875
+ * properties:
2876
+ * userId:
2877
+ * type: string
2878
+ * responses:
2879
+ * 200:
2880
+ * description: Navigated back.
2881
+ * content:
2882
+ * application/json:
2883
+ * schema:
2884
+ * type: object
2885
+ * properties:
2886
+ * ok:
2887
+ * type: boolean
2888
+ * url:
2889
+ * type: string
2890
+ * 404:
2891
+ * description: Tab not found.
2892
+ * content:
2893
+ * application/json:
2894
+ * schema:
2895
+ * $ref: '#/components/schemas/Error'
2896
+ */
2290
2897
  app.post('/tabs/:tabId/back', async (req, res) => {
2291
2898
  const tabId = req.params.tabId;
2292
2899
 
@@ -2297,7 +2904,7 @@ app.post('/tabs/:tabId/back', async (req, res) => {
2297
2904
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2298
2905
 
2299
2906
  const { tabState } = found;
2300
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2907
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2301
2908
 
2302
2909
  const result = await withTabLock(tabId, async () => {
2303
2910
  try {
@@ -2323,6 +2930,47 @@ app.post('/tabs/:tabId/back', async (req, res) => {
2323
2930
  });
2324
2931
 
2325
2932
  // Forward
2933
+ /**
2934
+ * @openapi
2935
+ * /tabs/{tabId}/forward:
2936
+ * post:
2937
+ * tags: [Navigation]
2938
+ * summary: Go forward
2939
+ * parameters:
2940
+ * - name: tabId
2941
+ * in: path
2942
+ * required: true
2943
+ * schema:
2944
+ * type: string
2945
+ * requestBody:
2946
+ * required: true
2947
+ * content:
2948
+ * application/json:
2949
+ * schema:
2950
+ * type: object
2951
+ * required: [userId]
2952
+ * properties:
2953
+ * userId:
2954
+ * type: string
2955
+ * responses:
2956
+ * 200:
2957
+ * description: Navigated forward.
2958
+ * content:
2959
+ * application/json:
2960
+ * schema:
2961
+ * type: object
2962
+ * properties:
2963
+ * ok:
2964
+ * type: boolean
2965
+ * url:
2966
+ * type: string
2967
+ * 404:
2968
+ * description: Tab not found.
2969
+ * content:
2970
+ * application/json:
2971
+ * schema:
2972
+ * $ref: '#/components/schemas/Error'
2973
+ */
2326
2974
  app.post('/tabs/:tabId/forward', async (req, res) => {
2327
2975
  const tabId = req.params.tabId;
2328
2976
 
@@ -2333,7 +2981,7 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
2333
2981
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2334
2982
 
2335
2983
  const { tabState } = found;
2336
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2984
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2337
2985
 
2338
2986
  const result = await withTabLock(tabId, async () => {
2339
2987
  await tabState.page.goForward({ timeout: 10000 });
@@ -2349,6 +2997,47 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
2349
2997
  });
2350
2998
 
2351
2999
  // Refresh
3000
+ /**
3001
+ * @openapi
3002
+ * /tabs/{tabId}/refresh:
3003
+ * post:
3004
+ * tags: [Navigation]
3005
+ * summary: Refresh page
3006
+ * parameters:
3007
+ * - name: tabId
3008
+ * in: path
3009
+ * required: true
3010
+ * schema:
3011
+ * type: string
3012
+ * requestBody:
3013
+ * required: true
3014
+ * content:
3015
+ * application/json:
3016
+ * schema:
3017
+ * type: object
3018
+ * required: [userId]
3019
+ * properties:
3020
+ * userId:
3021
+ * type: string
3022
+ * responses:
3023
+ * 200:
3024
+ * description: Page refreshed.
3025
+ * content:
3026
+ * application/json:
3027
+ * schema:
3028
+ * type: object
3029
+ * properties:
3030
+ * ok:
3031
+ * type: boolean
3032
+ * url:
3033
+ * type: string
3034
+ * 404:
3035
+ * description: Tab not found.
3036
+ * content:
3037
+ * application/json:
3038
+ * schema:
3039
+ * $ref: '#/components/schemas/Error'
3040
+ */
2352
3041
  app.post('/tabs/:tabId/refresh', async (req, res) => {
2353
3042
  const tabId = req.params.tabId;
2354
3043
 
@@ -2359,7 +3048,7 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
2359
3048
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2360
3049
 
2361
3050
  const { tabState } = found;
2362
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
3051
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2363
3052
 
2364
3053
  const result = await withTabLock(tabId, async () => {
2365
3054
  await tabState.page.reload({ timeout: 30000 });
@@ -2375,6 +3064,49 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
2375
3064
  });
2376
3065
 
2377
3066
  // Get links
3067
+ /**
3068
+ * @openapi
3069
+ * /tabs/{tabId}/links:
3070
+ * get:
3071
+ * tags: [Content]
3072
+ * summary: Extract page links
3073
+ * parameters:
3074
+ * - name: tabId
3075
+ * in: path
3076
+ * required: true
3077
+ * schema:
3078
+ * type: string
3079
+ * - name: userId
3080
+ * in: query
3081
+ * required: true
3082
+ * schema:
3083
+ * type: string
3084
+ * responses:
3085
+ * 200:
3086
+ * description: Links extracted.
3087
+ * content:
3088
+ * application/json:
3089
+ * schema:
3090
+ * type: object
3091
+ * properties:
3092
+ * links:
3093
+ * type: array
3094
+ * items:
3095
+ * type: object
3096
+ * properties:
3097
+ * text:
3098
+ * type: string
3099
+ * href:
3100
+ * type: string
3101
+ * ref:
3102
+ * type: string
3103
+ * 404:
3104
+ * description: Tab not found.
3105
+ * content:
3106
+ * application/json:
3107
+ * schema:
3108
+ * $ref: '#/components/schemas/Error'
3109
+ */
2378
3110
  app.get('/tabs/:tabId/links', async (req, res) => {
2379
3111
  try {
2380
3112
  const userId = req.query.userId;
@@ -2388,7 +3120,7 @@ app.get('/tabs/:tabId/links', async (req, res) => {
2388
3120
  }
2389
3121
 
2390
3122
  const { tabState } = found;
2391
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
3123
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2392
3124
 
2393
3125
  const allLinks = await tabState.page.evaluate(() => {
2394
3126
  const links = [];
@@ -2416,6 +3148,49 @@ app.get('/tabs/:tabId/links', async (req, res) => {
2416
3148
  });
2417
3149
 
2418
3150
  // Get captured downloads
3151
+ /**
3152
+ * @openapi
3153
+ * /tabs/{tabId}/downloads:
3154
+ * get:
3155
+ * tags: [Content]
3156
+ * summary: List tab downloads
3157
+ * parameters:
3158
+ * - name: tabId
3159
+ * in: path
3160
+ * required: true
3161
+ * schema:
3162
+ * type: string
3163
+ * - name: userId
3164
+ * in: query
3165
+ * required: true
3166
+ * schema:
3167
+ * type: string
3168
+ * responses:
3169
+ * 200:
3170
+ * description: Downloads list.
3171
+ * content:
3172
+ * application/json:
3173
+ * schema:
3174
+ * type: object
3175
+ * properties:
3176
+ * downloads:
3177
+ * type: array
3178
+ * items:
3179
+ * type: object
3180
+ * properties:
3181
+ * filename:
3182
+ * type: string
3183
+ * url:
3184
+ * type: string
3185
+ * state:
3186
+ * type: string
3187
+ * 404:
3188
+ * description: Tab not found.
3189
+ * content:
3190
+ * application/json:
3191
+ * schema:
3192
+ * $ref: '#/components/schemas/Error'
3193
+ */
2419
3194
  app.get('/tabs/:tabId/downloads', async (req, res) => {
2420
3195
  try {
2421
3196
  const userId = req.query.userId;
@@ -2445,6 +3220,51 @@ app.get('/tabs/:tabId/downloads', async (req, res) => {
2445
3220
  });
2446
3221
 
2447
3222
  // Get image elements from current page
3223
+ /**
3224
+ * @openapi
3225
+ * /tabs/{tabId}/images:
3226
+ * get:
3227
+ * tags: [Content]
3228
+ * summary: Extract page images
3229
+ * parameters:
3230
+ * - name: tabId
3231
+ * in: path
3232
+ * required: true
3233
+ * schema:
3234
+ * type: string
3235
+ * - name: userId
3236
+ * in: query
3237
+ * required: true
3238
+ * schema:
3239
+ * type: string
3240
+ * responses:
3241
+ * 200:
3242
+ * description: Images extracted.
3243
+ * content:
3244
+ * application/json:
3245
+ * schema:
3246
+ * type: object
3247
+ * properties:
3248
+ * images:
3249
+ * type: array
3250
+ * items:
3251
+ * type: object
3252
+ * properties:
3253
+ * src:
3254
+ * type: string
3255
+ * alt:
3256
+ * type: string
3257
+ * width:
3258
+ * type: integer
3259
+ * height:
3260
+ * type: integer
3261
+ * 404:
3262
+ * description: Tab not found.
3263
+ * content:
3264
+ * application/json:
3265
+ * schema:
3266
+ * $ref: '#/components/schemas/Error'
3267
+ */
2448
3268
  app.get('/tabs/:tabId/images', async (req, res) => {
2449
3269
  try {
2450
3270
  const userId = req.query.userId;
@@ -2471,6 +3291,46 @@ app.get('/tabs/:tabId/images', async (req, res) => {
2471
3291
  });
2472
3292
 
2473
3293
  // Screenshot
3294
+ /**
3295
+ * @openapi
3296
+ * /tabs/{tabId}/screenshot:
3297
+ * get:
3298
+ * tags: [Content]
3299
+ * summary: Take a screenshot
3300
+ * description: Returns a base64-encoded PNG screenshot.
3301
+ * parameters:
3302
+ * - name: tabId
3303
+ * in: path
3304
+ * required: true
3305
+ * schema:
3306
+ * type: string
3307
+ * - name: userId
3308
+ * in: query
3309
+ * required: true
3310
+ * schema:
3311
+ * type: string
3312
+ * responses:
3313
+ * 200:
3314
+ * description: Screenshot.
3315
+ * content:
3316
+ * application/json:
3317
+ * schema:
3318
+ * type: object
3319
+ * properties:
3320
+ * screenshot:
3321
+ * type: object
3322
+ * properties:
3323
+ * data:
3324
+ * type: string
3325
+ * mimeType:
3326
+ * type: string
3327
+ * 404:
3328
+ * description: Tab not found.
3329
+ * content:
3330
+ * application/json:
3331
+ * schema:
3332
+ * $ref: '#/components/schemas/Error'
3333
+ */
2474
3334
  app.get('/tabs/:tabId/screenshot', async (req, res) => {
2475
3335
  try {
2476
3336
  const userId = req.query.userId;
@@ -2481,6 +3341,7 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
2481
3341
 
2482
3342
  const { tabState } = found;
2483
3343
  const buffer = await tabState.page.screenshot({ type: 'png', fullPage });
3344
+ pluginEvents.emit('tab:screenshot', { userId, tabId: req.params.tabId, buffer });
2484
3345
  res.set('Content-Type', 'image/png');
2485
3346
  res.send(buffer);
2486
3347
  } catch (err) {
@@ -2490,6 +3351,53 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
2490
3351
  });
2491
3352
 
2492
3353
  // Stats
3354
+ /**
3355
+ * @openapi
3356
+ * /tabs/{tabId}/stats:
3357
+ * get:
3358
+ * tags: [Tabs]
3359
+ * summary: Tab statistics
3360
+ * description: Returns tab metadata including URL, tool call count, visited URLs, download/failure counts.
3361
+ * parameters:
3362
+ * - name: tabId
3363
+ * in: path
3364
+ * required: true
3365
+ * schema:
3366
+ * type: string
3367
+ * - name: userId
3368
+ * in: query
3369
+ * required: true
3370
+ * schema:
3371
+ * type: string
3372
+ * responses:
3373
+ * 200:
3374
+ * description: Tab stats.
3375
+ * content:
3376
+ * application/json:
3377
+ * schema:
3378
+ * type: object
3379
+ * properties:
3380
+ * tabId:
3381
+ * type: string
3382
+ * url:
3383
+ * type: string
3384
+ * toolCalls:
3385
+ * type: integer
3386
+ * visitedUrls:
3387
+ * type: array
3388
+ * items:
3389
+ * type: string
3390
+ * downloadCount:
3391
+ * type: integer
3392
+ * consecutiveFailures:
3393
+ * type: integer
3394
+ * 404:
3395
+ * description: Tab not found.
3396
+ * content:
3397
+ * application/json:
3398
+ * schema:
3399
+ * $ref: '#/components/schemas/Error'
3400
+ */
2493
3401
  app.get('/tabs/:tabId/stats', async (req, res) => {
2494
3402
  try {
2495
3403
  const userId = req.query.userId;
@@ -2515,6 +3423,56 @@ app.get('/tabs/:tabId/stats', async (req, res) => {
2515
3423
  });
2516
3424
 
2517
3425
  // Evaluate JavaScript in page context
3426
+ /**
3427
+ * @openapi
3428
+ * /tabs/{tabId}/evaluate:
3429
+ * post:
3430
+ * tags: [Interaction]
3431
+ * summary: Evaluate JavaScript in tab
3432
+ * description: Runs arbitrary JS in the page context and returns the result.
3433
+ * parameters:
3434
+ * - name: tabId
3435
+ * in: path
3436
+ * required: true
3437
+ * schema:
3438
+ * type: string
3439
+ * requestBody:
3440
+ * required: true
3441
+ * content:
3442
+ * application/json:
3443
+ * schema:
3444
+ * type: object
3445
+ * required: [userId, expression]
3446
+ * properties:
3447
+ * userId:
3448
+ * type: string
3449
+ * expression:
3450
+ * type: string
3451
+ * description: JavaScript expression to evaluate.
3452
+ * responses:
3453
+ * 200:
3454
+ * description: Evaluation result.
3455
+ * content:
3456
+ * application/json:
3457
+ * schema:
3458
+ * type: object
3459
+ * properties:
3460
+ * ok:
3461
+ * type: boolean
3462
+ * result: {}
3463
+ * 400:
3464
+ * description: Bad request.
3465
+ * content:
3466
+ * application/json:
3467
+ * schema:
3468
+ * $ref: '#/components/schemas/Error'
3469
+ * 404:
3470
+ * description: Tab not found.
3471
+ * content:
3472
+ * application/json:
3473
+ * schema:
3474
+ * $ref: '#/components/schemas/Error'
3475
+ */
2518
3476
  app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, res) => {
2519
3477
  try {
2520
3478
  const { userId, expression } = req.body;
@@ -2527,9 +3485,11 @@ app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, re
2527
3485
 
2528
3486
  session.lastAccess = Date.now();
2529
3487
  const { tabState } = found;
2530
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
3488
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2531
3489
 
3490
+ pluginEvents.emit('tab:evaluate', { userId, tabId: req.params.tabId, expression });
2532
3491
  const result = await tabState.page.evaluate(expression);
3492
+ pluginEvents.emit('tab:evaluated', { userId, tabId: req.params.tabId, result });
2533
3493
  log('info', 'evaluate', { reqId: req.reqId, tabId: req.params.tabId, userId, resultType: typeof result });
2534
3494
  res.json({ ok: true, result });
2535
3495
  } catch (err) {
@@ -2539,7 +3499,192 @@ app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, re
2539
3499
  }
2540
3500
  });
2541
3501
 
3502
+ // Structured extraction using JSON Schema with x-ref hints
3503
+ /**
3504
+ * @openapi
3505
+ * /tabs/{tabId}/extract:
3506
+ * post:
3507
+ * tags: [Content]
3508
+ * summary: Structured data extraction via JSON Schema
3509
+ * description: |
3510
+ * Extracts structured data from the current page using a JSON Schema whose properties
3511
+ * carry `x-ref` hints pointing at snapshot element refs (e.g. `e1`, `e2`).
3512
+ * Call `GET /tabs/{tabId}/snapshot` first to populate the ref table.
3513
+ * parameters:
3514
+ * - name: tabId
3515
+ * in: path
3516
+ * required: true
3517
+ * schema:
3518
+ * type: string
3519
+ * requestBody:
3520
+ * required: true
3521
+ * content:
3522
+ * application/json:
3523
+ * schema:
3524
+ * type: object
3525
+ * required: [userId, schema]
3526
+ * properties:
3527
+ * userId:
3528
+ * type: string
3529
+ * schema:
3530
+ * type: object
3531
+ * description: |
3532
+ * JSON Schema with `type: "object"` and a `properties` map.
3533
+ * Each property may include `x-ref` (a snapshot element ref) and an optional
3534
+ * `type` (`string`, `number`, `integer`, `boolean`).
3535
+ * required: [type, properties]
3536
+ * properties:
3537
+ * type:
3538
+ * type: string
3539
+ * enum: [object]
3540
+ * properties:
3541
+ * type: object
3542
+ * additionalProperties:
3543
+ * type: object
3544
+ * properties:
3545
+ * type:
3546
+ * type: string
3547
+ * enum: [string, number, integer, boolean, object, "null"]
3548
+ * x-ref:
3549
+ * type: string
3550
+ * description: Snapshot element ref (e.g. `e1`).
3551
+ * required:
3552
+ * type: array
3553
+ * items:
3554
+ * type: string
3555
+ * description: Property names that must resolve to a non-null value.
3556
+ * responses:
3557
+ * 200:
3558
+ * description: Extraction succeeded.
3559
+ * content:
3560
+ * application/json:
3561
+ * schema:
3562
+ * type: object
3563
+ * properties:
3564
+ * ok:
3565
+ * type: boolean
3566
+ * data:
3567
+ * type: object
3568
+ * description: Extracted key-value pairs matching the input schema.
3569
+ * 400:
3570
+ * description: Missing userId, missing schema, or invalid schema.
3571
+ * content:
3572
+ * application/json:
3573
+ * schema:
3574
+ * $ref: '#/components/schemas/Error'
3575
+ * 404:
3576
+ * description: Tab not found.
3577
+ * content:
3578
+ * application/json:
3579
+ * schema:
3580
+ * $ref: '#/components/schemas/Error'
3581
+ * 409:
3582
+ * description: No refs available — call snapshot first.
3583
+ * content:
3584
+ * application/json:
3585
+ * schema:
3586
+ * type: object
3587
+ * properties:
3588
+ * error:
3589
+ * type: string
3590
+ * snapshot:
3591
+ * type: string
3592
+ * nullable: true
3593
+ * 422:
3594
+ * description: Extraction failed (e.g. required ref not found).
3595
+ * content:
3596
+ * application/json:
3597
+ * schema:
3598
+ * type: object
3599
+ * properties:
3600
+ * ok:
3601
+ * type: boolean
3602
+ * error:
3603
+ * type: string
3604
+ * snapshot:
3605
+ * type: string
3606
+ * nullable: true
3607
+ * 500:
3608
+ * description: Internal server error.
3609
+ * content:
3610
+ * application/json:
3611
+ * schema:
3612
+ * $ref: '#/components/schemas/Error'
3613
+ */
3614
+ app.post('/tabs/:tabId/extract', express.json({ limit: '256kb' }), async (req, res) => {
3615
+ try {
3616
+ const { userId, schema } = req.body;
3617
+ if (!userId) return res.status(400).json({ error: 'userId is required' });
3618
+ if (!schema) return res.status(400).json({ error: 'schema is required' });
3619
+
3620
+ const check = validateExtractSchema(schema);
3621
+ if (!check.ok) return res.status(400).json({ error: check.error });
3622
+
3623
+ const session = sessions.get(normalizeUserId(userId));
3624
+ const found = session && findTab(session, req.params.tabId);
3625
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
3626
+
3627
+ session.lastAccess = Date.now();
3628
+ const { tabState } = found;
3629
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
3630
+
3631
+ if (!tabState.refs || tabState.refs.size === 0) {
3632
+ return res.status(409).json({
3633
+ error: 'no refs available — call GET /tabs/:tabId/snapshot first to build the ref table',
3634
+ snapshot: tabState.lastSnapshot || null,
3635
+ });
3636
+ }
3637
+
3638
+ try {
3639
+ const data = extractDeterministic({ schema, refs: tabState.refs });
3640
+ log('info', 'extract', { reqId: req.reqId, tabId: req.params.tabId, userId, keys: Object.keys(data) });
3641
+ res.json({ ok: true, data });
3642
+ } catch (extractErr) {
3643
+ log('warn', 'extract failed', { reqId: req.reqId, error: extractErr.message });
3644
+ res.status(422).json({ ok: false, error: extractErr.message, snapshot: tabState.lastSnapshot || null });
3645
+ }
3646
+ } catch (err) {
3647
+ failuresTotal.labels(classifyError(err), 'extract').inc();
3648
+ log('error', 'extract error', { reqId: req.reqId, error: err.message });
3649
+ res.status(500).json({ error: safeError(err) });
3650
+ }
3651
+ });
3652
+
2542
3653
  // Close tab
3654
+ /**
3655
+ * @openapi
3656
+ * /tabs/{tabId}:
3657
+ * delete:
3658
+ * tags: [Tabs]
3659
+ * summary: Close a tab
3660
+ * parameters:
3661
+ * - name: tabId
3662
+ * in: path
3663
+ * required: true
3664
+ * schema:
3665
+ * type: string
3666
+ * - name: userId
3667
+ * in: query
3668
+ * required: true
3669
+ * schema:
3670
+ * type: string
3671
+ * responses:
3672
+ * 200:
3673
+ * description: Tab closed.
3674
+ * content:
3675
+ * application/json:
3676
+ * schema:
3677
+ * type: object
3678
+ * properties:
3679
+ * ok:
3680
+ * type: boolean
3681
+ * 404:
3682
+ * description: Tab not found.
3683
+ * content:
3684
+ * application/json:
3685
+ * schema:
3686
+ * $ref: '#/components/schemas/Error'
3687
+ */
2543
3688
  app.delete('/tabs/:tabId', async (req, res) => {
2544
3689
  try {
2545
3690
  const userId = req.query.userId || req.body?.userId;
@@ -2547,6 +3692,7 @@ app.delete('/tabs/:tabId', async (req, res) => {
2547
3692
  const session = sessions.get(normalizeUserId(userId));
2548
3693
  const found = session && findTab(session, req.params.tabId);
2549
3694
  if (found) {
3695
+ if (found.tabState.navigateAbort) found.tabState.navigateAbort.abort();
2550
3696
  await clearTabDownloads(found.tabState);
2551
3697
  await safePageClose(found.tabState.page);
2552
3698
  found.group.delete(req.params.tabId);
@@ -2565,6 +3711,42 @@ app.delete('/tabs/:tabId', async (req, res) => {
2565
3711
  });
2566
3712
 
2567
3713
  // Close tab group
3714
+ /**
3715
+ * @openapi
3716
+ * /tabs/group/{listItemId}:
3717
+ * delete:
3718
+ * tags: [Tabs]
3719
+ * summary: Close all tabs in a group
3720
+ * parameters:
3721
+ * - name: listItemId
3722
+ * in: path
3723
+ * required: true
3724
+ * schema:
3725
+ * type: string
3726
+ * - name: userId
3727
+ * in: query
3728
+ * required: true
3729
+ * schema:
3730
+ * type: string
3731
+ * responses:
3732
+ * 200:
3733
+ * description: Group closed.
3734
+ * content:
3735
+ * application/json:
3736
+ * schema:
3737
+ * type: object
3738
+ * properties:
3739
+ * ok:
3740
+ * type: boolean
3741
+ * closed:
3742
+ * type: integer
3743
+ * 404:
3744
+ * description: Session not found.
3745
+ * content:
3746
+ * application/json:
3747
+ * schema:
3748
+ * $ref: '#/components/schemas/Error'
3749
+ */
2568
3750
  app.delete('/tabs/group/:listItemId', async (req, res) => {
2569
3751
  try {
2570
3752
  const userId = req.query.userId || req.body?.userId;
@@ -2593,27 +3775,260 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
2593
3775
  }
2594
3776
  });
2595
3777
 
3778
+ // List trace files for a session
3779
+ /**
3780
+ * @openapi
3781
+ * /sessions/{userId}/traces:
3782
+ * get:
3783
+ * tags: [Sessions]
3784
+ * summary: List trace files
3785
+ * description: Returns all Playwright trace zip files for the given user session, sorted newest first.
3786
+ * security:
3787
+ * - BearerAuth: []
3788
+ * parameters:
3789
+ * - name: userId
3790
+ * in: path
3791
+ * required: true
3792
+ * schema:
3793
+ * type: string
3794
+ * description: Session owner identifier.
3795
+ * responses:
3796
+ * 200:
3797
+ * description: Trace list.
3798
+ * content:
3799
+ * application/json:
3800
+ * schema:
3801
+ * type: object
3802
+ * properties:
3803
+ * traces:
3804
+ * type: array
3805
+ * items:
3806
+ * type: object
3807
+ * properties:
3808
+ * filename:
3809
+ * type: string
3810
+ * sizeBytes:
3811
+ * type: integer
3812
+ * createdAt:
3813
+ * type: number
3814
+ * modifiedAt:
3815
+ * type: number
3816
+ * 403:
3817
+ * description: Forbidden.
3818
+ * content:
3819
+ * application/json:
3820
+ * schema:
3821
+ * $ref: '#/components/schemas/Error'
3822
+ * 500:
3823
+ * description: Server error.
3824
+ * content:
3825
+ * application/json:
3826
+ * schema:
3827
+ * $ref: '#/components/schemas/Error'
3828
+ */
3829
+ app.get('/sessions/:userId/traces', authMiddleware(), async (req, res) => {
3830
+ try {
3831
+ const userId = normalizeUserId(req.params.userId);
3832
+ const traces = await listUserTraces(CONFIG.tracesDir, userId);
3833
+ res.json({ traces });
3834
+ } catch (err) {
3835
+ log('error', 'list traces failed', { error: err.message });
3836
+ res.status(500).json({ error: err.message });
3837
+ }
3838
+ });
3839
+
3840
+ // Stream one trace file
3841
+ /**
3842
+ * @openapi
3843
+ * /sessions/{userId}/traces/{filename}:
3844
+ * get:
3845
+ * tags: [Sessions]
3846
+ * summary: Download a trace file
3847
+ * description: Streams a Playwright trace zip for viewing in trace.playwright.dev.
3848
+ * security:
3849
+ * - BearerAuth: []
3850
+ * parameters:
3851
+ * - name: userId
3852
+ * in: path
3853
+ * required: true
3854
+ * schema:
3855
+ * type: string
3856
+ * description: Session owner identifier.
3857
+ * - name: filename
3858
+ * in: path
3859
+ * required: true
3860
+ * schema:
3861
+ * type: string
3862
+ * description: Trace zip filename.
3863
+ * responses:
3864
+ * 200:
3865
+ * description: Trace zip stream.
3866
+ * content:
3867
+ * application/zip:
3868
+ * schema:
3869
+ * type: string
3870
+ * format: binary
3871
+ * 400:
3872
+ * description: Invalid filename.
3873
+ * content:
3874
+ * application/json:
3875
+ * schema:
3876
+ * $ref: '#/components/schemas/Error'
3877
+ * 404:
3878
+ * description: Trace not found.
3879
+ * content:
3880
+ * application/json:
3881
+ * schema:
3882
+ * $ref: '#/components/schemas/Error'
3883
+ * 403:
3884
+ * description: Forbidden.
3885
+ * content:
3886
+ * application/json:
3887
+ * schema:
3888
+ * $ref: '#/components/schemas/Error'
3889
+ * 500:
3890
+ * description: Server error.
3891
+ * content:
3892
+ * application/json:
3893
+ * schema:
3894
+ * $ref: '#/components/schemas/Error'
3895
+ */
3896
+ app.get('/sessions/:userId/traces/:filename', authMiddleware(), async (req, res) => {
3897
+ try {
3898
+ const userId = normalizeUserId(req.params.userId);
3899
+ const full = resolveTracePath(CONFIG.tracesDir, userId, req.params.filename);
3900
+ if (!full) return res.status(400).json({ error: 'invalid filename' });
3901
+ const st = await statTrace(full);
3902
+ if (!st) return res.status(404).json({ error: 'not found' });
3903
+ res.setHeader('Content-Type', 'application/zip');
3904
+ res.setHeader('Content-Length', String(st.size));
3905
+ const stream = fs.createReadStream(full);
3906
+ stream.on('error', (err) => {
3907
+ if (!res.headersSent) res.status(404).json({ error: 'not found' });
3908
+ else res.destroy();
3909
+ });
3910
+ stream.pipe(res);
3911
+ } catch (err) {
3912
+ log('error', 'stream trace failed', { error: err.message });
3913
+ res.status(500).json({ error: err.message });
3914
+ }
3915
+ });
3916
+
3917
+ // Delete one trace file
3918
+ /**
3919
+ * @openapi
3920
+ * /sessions/{userId}/traces/{filename}:
3921
+ * delete:
3922
+ * tags: [Sessions]
3923
+ * summary: Delete a trace file
3924
+ * description: Removes a specific Playwright trace zip from the server.
3925
+ * security:
3926
+ * - BearerAuth: []
3927
+ * parameters:
3928
+ * - name: userId
3929
+ * in: path
3930
+ * required: true
3931
+ * schema:
3932
+ * type: string
3933
+ * description: Session owner identifier.
3934
+ * - name: filename
3935
+ * in: path
3936
+ * required: true
3937
+ * schema:
3938
+ * type: string
3939
+ * description: Trace zip filename.
3940
+ * responses:
3941
+ * 200:
3942
+ * description: Trace deleted.
3943
+ * content:
3944
+ * application/json:
3945
+ * schema:
3946
+ * type: object
3947
+ * properties:
3948
+ * ok:
3949
+ * type: boolean
3950
+ * 400:
3951
+ * description: Invalid filename.
3952
+ * content:
3953
+ * application/json:
3954
+ * schema:
3955
+ * $ref: '#/components/schemas/Error'
3956
+ * 404:
3957
+ * description: Trace not found.
3958
+ * content:
3959
+ * application/json:
3960
+ * schema:
3961
+ * $ref: '#/components/schemas/Error'
3962
+ * 403:
3963
+ * description: Forbidden.
3964
+ * content:
3965
+ * application/json:
3966
+ * schema:
3967
+ * $ref: '#/components/schemas/Error'
3968
+ * 500:
3969
+ * description: Server error.
3970
+ * content:
3971
+ * application/json:
3972
+ * schema:
3973
+ * $ref: '#/components/schemas/Error'
3974
+ */
3975
+ app.delete('/sessions/:userId/traces/:filename', authMiddleware(), async (req, res) => {
3976
+ try {
3977
+ const userId = normalizeUserId(req.params.userId);
3978
+ const full = resolveTracePath(CONFIG.tracesDir, userId, req.params.filename);
3979
+ if (!full) return res.status(400).json({ error: 'invalid filename' });
3980
+ try {
3981
+ await deleteTrace(full);
3982
+ } catch (err) {
3983
+ if (err.code === 'ENOENT') return res.status(404).json({ error: 'not found' });
3984
+ throw err;
3985
+ }
3986
+ res.json({ ok: true });
3987
+ } catch (err) {
3988
+ log('error', 'delete trace failed', { error: err.message });
3989
+ res.status(500).json({ error: err.message });
3990
+ }
3991
+ });
3992
+
2596
3993
  // Close session
3994
+ /**
3995
+ * @openapi
3996
+ * /sessions/{userId}:
3997
+ * delete:
3998
+ * tags: [Sessions]
3999
+ * summary: Destroy a user session
4000
+ * description: Closes all tabs and cleans up state for the given userId.
4001
+ * parameters:
4002
+ * - name: userId
4003
+ * in: path
4004
+ * required: true
4005
+ * schema:
4006
+ * type: string
4007
+ * responses:
4008
+ * 200:
4009
+ * description: Session destroyed.
4010
+ * content:
4011
+ * application/json:
4012
+ * schema:
4013
+ * type: object
4014
+ * properties:
4015
+ * ok:
4016
+ * type: boolean
4017
+ * closed:
4018
+ * type: integer
4019
+ * 404:
4020
+ * description: Session not found.
4021
+ * content:
4022
+ * application/json:
4023
+ * schema:
4024
+ * $ref: '#/components/schemas/Error'
4025
+ */
2597
4026
  app.delete('/sessions/:userId', async (req, res) => {
2598
4027
  try {
2599
4028
  const userId = normalizeUserId(req.params.userId);
2600
4029
  const session = sessions.get(userId);
2601
4030
  if (session) {
2602
- await clearSessionDownloads(session);
2603
- await session.context.close();
2604
- sessions.delete(userId);
2605
- // Remove any lingering tab locks for the session
2606
- for (const [listItemId, group] of session.tabGroups) {
2607
- for (const tabId of group.keys()) {
2608
- const lock = tabLocks.get(tabId);
2609
- if (lock) {
2610
- lock.drain();
2611
- tabLocks.delete(tabId);
2612
- }
2613
- }
2614
- }
2615
- refreshTabLockQueueDepth();
2616
- refreshActiveTabsGauge();
4031
+ await closeSession(userId, session, { reason: 'api_delete_session', clearDownloads: true, clearLocks: true });
2617
4032
  log('info', 'session closed', { userId });
2618
4033
  }
2619
4034
  if (sessions.size === 0) scheduleBrowserIdleShutdown();
@@ -2627,13 +4042,13 @@ app.delete('/sessions/:userId', async (req, res) => {
2627
4042
  // Cleanup stale sessions
2628
4043
  setInterval(() => {
2629
4044
  const now = Date.now();
2630
- for (const [userId, session] of sessions) {
4045
+ for (const [userId, session] of Array.from(sessions.entries())) {
2631
4046
  if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
4047
+ session._closing = true;
4048
+ const idleMs = now - session.lastAccess;
2632
4049
  sessionsExpiredTotal.inc();
2633
- clearSessionDownloads(session).catch(() => {});
2634
- session.context.close().catch(() => {});
2635
- sessions.delete(userId);
2636
- refreshActiveTabsGauge();
4050
+ pluginEvents.emit('session:expired', { userId, idleMs });
4051
+ closeSession(userId, session, { reason: 'session_timeout', clearDownloads: true, clearLocks: true }).catch(() => {});
2637
4052
  log('info', 'session expired', { userId });
2638
4053
  }
2639
4054
  }
@@ -2677,12 +4092,10 @@ setInterval(() => {
2677
4092
  }
2678
4093
  // Clean up sessions with zero tabs remaining — free browser context memory
2679
4094
  if (session.tabGroups.size === 0) {
4095
+ session._closing = true;
2680
4096
  log('info', 'session empty after tab reaper, closing', { userId });
2681
- clearSessionDownloads(session).catch(() => {});
2682
- session.context.close().catch(() => {});
2683
- sessions.delete(userId);
4097
+ closeSession(userId, session, { reason: 'tab_reaper_empty_session', clearDownloads: true, clearLocks: true }).catch(() => {});
2684
4098
  sessionsExpiredTotal.inc();
2685
- refreshActiveTabsGauge();
2686
4099
  }
2687
4100
  }
2688
4101
  if (sessions.size === 0) scheduleBrowserIdleShutdown();
@@ -2694,6 +4107,34 @@ setInterval(() => {
2694
4107
  // =============================================================================
2695
4108
 
2696
4109
  // GET / - Status (passive — does not launch browser)
4110
+ /**
4111
+ * @openapi
4112
+ * /:
4113
+ * get:
4114
+ * tags: [System]
4115
+ * summary: Server status
4116
+ * description: Returns basic server liveness and browser state.
4117
+ * responses:
4118
+ * 200:
4119
+ * description: Server status.
4120
+ * content:
4121
+ * application/json:
4122
+ * schema:
4123
+ * type: object
4124
+ * properties:
4125
+ * ok:
4126
+ * type: boolean
4127
+ * enabled:
4128
+ * type: boolean
4129
+ * running:
4130
+ * type: boolean
4131
+ * engine:
4132
+ * type: string
4133
+ * browserConnected:
4134
+ * type: boolean
4135
+ * browserRunning:
4136
+ * type: boolean
4137
+ */
2697
4138
  app.get('/', (req, res) => {
2698
4139
  const running = browser !== null && (browser.isConnected?.() ?? false);
2699
4140
  res.json({
@@ -2707,6 +4148,45 @@ app.get('/', (req, res) => {
2707
4148
  });
2708
4149
 
2709
4150
  // GET /tabs - List all tabs (OpenClaw expects this)
4151
+ /**
4152
+ * @openapi
4153
+ * /tabs:
4154
+ * get:
4155
+ * tags: [Tabs]
4156
+ * summary: List open tabs
4157
+ * description: Returns all tabs for a given userId.
4158
+ * parameters:
4159
+ * - name: userId
4160
+ * in: query
4161
+ * schema:
4162
+ * type: string
4163
+ * description: Filter by session owner.
4164
+ * responses:
4165
+ * 200:
4166
+ * description: Tab list.
4167
+ * content:
4168
+ * application/json:
4169
+ * schema:
4170
+ * type: object
4171
+ * properties:
4172
+ * running:
4173
+ * type: boolean
4174
+ * tabs:
4175
+ * type: array
4176
+ * items:
4177
+ * type: object
4178
+ * properties:
4179
+ * tabId:
4180
+ * type: string
4181
+ * targetId:
4182
+ * type: string
4183
+ * url:
4184
+ * type: string
4185
+ * title:
4186
+ * type: string
4187
+ * listItemId:
4188
+ * type: string
4189
+ */
2710
4190
  app.get('/tabs', async (req, res) => {
2711
4191
  try {
2712
4192
  const userId = req.query.userId;
@@ -2737,6 +4217,41 @@ app.get('/tabs', async (req, res) => {
2737
4217
  });
2738
4218
 
2739
4219
  // POST /tabs/open - Open tab (alias for POST /tabs, OpenClaw format)
4220
+ /**
4221
+ * @openapi
4222
+ * /tabs/open:
4223
+ * post:
4224
+ * tags: [Legacy]
4225
+ * summary: Open tab (OpenClaw format)
4226
+ * deprecated: true
4227
+ * requestBody:
4228
+ * required: true
4229
+ * content:
4230
+ * application/json:
4231
+ * schema:
4232
+ * type: object
4233
+ * required: [userId, url]
4234
+ * properties:
4235
+ * userId:
4236
+ * type: string
4237
+ * url:
4238
+ * type: string
4239
+ * listItemId:
4240
+ * type: string
4241
+ * responses:
4242
+ * 200:
4243
+ * description: Tab opened.
4244
+ * content:
4245
+ * application/json:
4246
+ * schema:
4247
+ * type: object
4248
+ * 400:
4249
+ * description: Bad request.
4250
+ * content:
4251
+ * application/json:
4252
+ * schema:
4253
+ * $ref: '#/components/schemas/Error'
4254
+ */
2740
4255
  app.post('/tabs/open', async (req, res) => {
2741
4256
  try {
2742
4257
  const { url, userId, listItemId = 'default' } = req.body;
@@ -2756,7 +4271,7 @@ app.post('/tabs/open', async (req, res) => {
2756
4271
  let totalTabs = 0;
2757
4272
  for (const g of session.tabGroups.values()) totalTabs += g.size;
2758
4273
  if (totalTabs >= MAX_TABS_PER_SESSION || getTotalTabCount() >= MAX_TABS_GLOBAL) {
2759
- const recycled = await recycleOldestTab(session, req.reqId);
4274
+ const recycled = await recycleOldestTab(session, req.reqId, userId);
2760
4275
  if (!recycled) {
2761
4276
  return res.status(429).json({ error: 'Maximum tabs per session reached' });
2762
4277
  }
@@ -2767,7 +4282,7 @@ app.post('/tabs/open', async (req, res) => {
2767
4282
  const page = await session.context.newPage();
2768
4283
  const tabId = fly.makeTabId();
2769
4284
  const tabState = createTabState(page);
2770
- attachDownloadListener(tabState, tabId, log);
4285
+ attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
2771
4286
  group.set(tabId, tabState);
2772
4287
  refreshActiveTabsGauge();
2773
4288
 
@@ -2789,6 +4304,32 @@ app.post('/tabs/open', async (req, res) => {
2789
4304
  });
2790
4305
 
2791
4306
  // POST /start - Start browser (OpenClaw expects this)
4307
+ /**
4308
+ * @openapi
4309
+ * /start:
4310
+ * post:
4311
+ * tags: [Browser]
4312
+ * summary: Start browser
4313
+ * description: Ensures the browser process is running. Idempotent.
4314
+ * responses:
4315
+ * 200:
4316
+ * description: Browser started.
4317
+ * content:
4318
+ * application/json:
4319
+ * schema:
4320
+ * type: object
4321
+ * properties:
4322
+ * ok:
4323
+ * type: boolean
4324
+ * profile:
4325
+ * type: string
4326
+ * 500:
4327
+ * description: Launch failed.
4328
+ * content:
4329
+ * application/json:
4330
+ * schema:
4331
+ * $ref: '#/components/schemas/Error'
4332
+ */
2792
4333
  app.post('/start', async (req, res) => {
2793
4334
  try {
2794
4335
  await ensureBrowser();
@@ -2800,6 +4341,36 @@ app.post('/start', async (req, res) => {
2800
4341
  });
2801
4342
 
2802
4343
  // POST /stop - Stop browser (OpenClaw expects this)
4344
+ /**
4345
+ * @openapi
4346
+ * /stop:
4347
+ * post:
4348
+ * tags: [Browser]
4349
+ * summary: Stop browser
4350
+ * description: Stops the browser and closes all sessions. Requires x-admin-key header.
4351
+ * security:
4352
+ * - BearerAuth: []
4353
+ * responses:
4354
+ * 200:
4355
+ * description: Browser stopped.
4356
+ * content:
4357
+ * application/json:
4358
+ * schema:
4359
+ * type: object
4360
+ * properties:
4361
+ * ok:
4362
+ * type: boolean
4363
+ * stopped:
4364
+ * type: boolean
4365
+ * profile:
4366
+ * type: string
4367
+ * 403:
4368
+ * description: Forbidden.
4369
+ * content:
4370
+ * application/json:
4371
+ * schema:
4372
+ * $ref: '#/components/schemas/Error'
4373
+ */
2803
4374
  app.post('/stop', async (req, res) => {
2804
4375
  try {
2805
4376
  const adminKey = req.headers['x-admin-key'];
@@ -2810,26 +4381,7 @@ app.post('/stop', async (req, res) => {
2810
4381
  await browser.close().catch(() => {});
2811
4382
  browser = null;
2812
4383
  }
2813
- const cleanupTasks = [];
2814
- for (const session of sessions.values()) {
2815
- cleanupTasks.push(clearSessionDownloads(session));
2816
- }
2817
- await Promise.all(cleanupTasks);
2818
- for (const session of sessions.values()) {
2819
- for (const [, group] of session.tabGroups) {
2820
- for (const tabId of group.keys()) {
2821
- const lock = tabLocks.get(tabId);
2822
- if (lock) {
2823
- lock.drain();
2824
- tabLocks.delete(tabId);
2825
- }
2826
- }
2827
- }
2828
- }
2829
- tabLocks.clear();
2830
- sessions.clear();
2831
- refreshActiveTabsGauge();
2832
- refreshTabLockQueueDepth();
4384
+ await closeAllSessions('admin_stop', { clearDownloads: true, clearLocks: true });
2833
4385
  res.json({ ok: true, stopped: true, profile: 'camoufox' });
2834
4386
  } catch (err) {
2835
4387
  res.status(500).json({ ok: false, error: safeError(err) });
@@ -2837,6 +4389,48 @@ app.post('/stop', async (req, res) => {
2837
4389
  });
2838
4390
 
2839
4391
  // POST /navigate - Navigate (OpenClaw format with targetId in body)
4392
+ /**
4393
+ * @openapi
4394
+ * /navigate:
4395
+ * post:
4396
+ * tags: [Legacy]
4397
+ * summary: Navigate (OpenClaw format)
4398
+ * description: Navigate with targetId in body instead of path param.
4399
+ * deprecated: true
4400
+ * requestBody:
4401
+ * required: true
4402
+ * content:
4403
+ * application/json:
4404
+ * schema:
4405
+ * type: object
4406
+ * required: [userId, url]
4407
+ * properties:
4408
+ * userId:
4409
+ * type: string
4410
+ * targetId:
4411
+ * type: string
4412
+ * url:
4413
+ * type: string
4414
+ * responses:
4415
+ * 200:
4416
+ * description: Navigation result.
4417
+ * content:
4418
+ * application/json:
4419
+ * schema:
4420
+ * type: object
4421
+ * 400:
4422
+ * description: Bad request.
4423
+ * content:
4424
+ * application/json:
4425
+ * schema:
4426
+ * $ref: '#/components/schemas/Error'
4427
+ * 404:
4428
+ * description: Tab not found.
4429
+ * content:
4430
+ * application/json:
4431
+ * schema:
4432
+ * $ref: '#/components/schemas/Error'
4433
+ */
2840
4434
  app.post('/navigate', async (req, res) => {
2841
4435
  try {
2842
4436
  const { targetId, url, userId } = req.body;
@@ -2857,7 +4451,7 @@ app.post('/navigate', async (req, res) => {
2857
4451
  }
2858
4452
 
2859
4453
  const { tabState } = found;
2860
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
4454
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2861
4455
 
2862
4456
  const result = await withTabLock(targetId, async () => {
2863
4457
  await withPageLoadDuration('navigate', () => tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
@@ -2882,6 +4476,58 @@ app.post('/navigate', async (req, res) => {
2882
4476
  });
2883
4477
 
2884
4478
  // GET /snapshot - Snapshot (OpenClaw format with query params)
4479
+ /**
4480
+ * @openapi
4481
+ * /snapshot:
4482
+ * get:
4483
+ * tags: [Legacy]
4484
+ * summary: Snapshot (OpenClaw format)
4485
+ * description: Snapshot with targetId/userId as query params.
4486
+ * deprecated: true
4487
+ * parameters:
4488
+ * - name: targetId
4489
+ * in: query
4490
+ * required: true
4491
+ * schema:
4492
+ * type: string
4493
+ * - name: userId
4494
+ * in: query
4495
+ * required: true
4496
+ * schema:
4497
+ * type: string
4498
+ * - name: format
4499
+ * in: query
4500
+ * schema:
4501
+ * type: string
4502
+ * - name: offset
4503
+ * in: query
4504
+ * schema:
4505
+ * type: integer
4506
+ * - name: includeScreenshot
4507
+ * in: query
4508
+ * schema:
4509
+ * type: string
4510
+ * enum: ['true', 'false']
4511
+ * responses:
4512
+ * 200:
4513
+ * description: Snapshot.
4514
+ * content:
4515
+ * application/json:
4516
+ * schema:
4517
+ * type: object
4518
+ * 400:
4519
+ * description: Bad request.
4520
+ * content:
4521
+ * application/json:
4522
+ * schema:
4523
+ * $ref: '#/components/schemas/Error'
4524
+ * 404:
4525
+ * description: Tab not found.
4526
+ * content:
4527
+ * application/json:
4528
+ * schema:
4529
+ * $ref: '#/components/schemas/Error'
4530
+ */
2885
4531
  app.get('/snapshot', async (req, res) => {
2886
4532
  try {
2887
4533
  const { targetId, userId, format = 'text' } = req.query;
@@ -2897,7 +4543,7 @@ app.get('/snapshot', async (req, res) => {
2897
4543
  }
2898
4544
 
2899
4545
  const { tabState } = found;
2900
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
4546
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2901
4547
 
2902
4548
  // Cached chunk retrieval
2903
4549
  if (offset > 0 && tabState.lastSnapshot) {
@@ -2917,6 +4563,7 @@ app.get('/snapshot', async (req, res) => {
2917
4563
  const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
2918
4564
  tabState.refs = googleRefs;
2919
4565
  tabState.lastSnapshot = googleSnapshot;
4566
+ snapshotBytes.labels('google_serp').observe(Buffer.byteLength(googleSnapshot, 'utf8'));
2920
4567
  const annotatedYaml = googleSnapshot;
2921
4568
  const win = windowSnapshot(annotatedYaml, 0);
2922
4569
  const response = {
@@ -2961,6 +4608,7 @@ app.get('/snapshot', async (req, res) => {
2961
4608
  }
2962
4609
 
2963
4610
  tabState.lastSnapshot = annotatedYaml;
4611
+ if (annotatedYaml) snapshotBytes.labels('full').observe(Buffer.byteLength(annotatedYaml, 'utf8'));
2964
4612
  const win = windowSnapshot(annotatedYaml, 0);
2965
4613
 
2966
4614
  const response = {
@@ -2990,6 +4638,61 @@ app.get('/snapshot', async (req, res) => {
2990
4638
 
2991
4639
  // POST /act - Combined action endpoint (OpenClaw format)
2992
4640
  // Routes to click/type/scroll/press/etc based on 'kind' parameter
4641
+ /**
4642
+ * @openapi
4643
+ * /act:
4644
+ * post:
4645
+ * tags: [Legacy]
4646
+ * summary: Combined action (OpenClaw format)
4647
+ * description: Routes to click/type/scroll/press/etc based on "kind" parameter.
4648
+ * deprecated: true
4649
+ * requestBody:
4650
+ * required: true
4651
+ * content:
4652
+ * application/json:
4653
+ * schema:
4654
+ * type: object
4655
+ * required: [userId, kind]
4656
+ * properties:
4657
+ * userId:
4658
+ * type: string
4659
+ * kind:
4660
+ * type: string
4661
+ * description: 'Action kind: click, type, scroll, press, key, select_option, drag, hover, screenshot, wait, back, forward.'
4662
+ * targetId:
4663
+ * type: string
4664
+ * ref:
4665
+ * type: string
4666
+ * selector:
4667
+ * type: string
4668
+ * text:
4669
+ * type: string
4670
+ * key:
4671
+ * type: string
4672
+ * direction:
4673
+ * type: string
4674
+ * url:
4675
+ * type: string
4676
+ * responses:
4677
+ * 200:
4678
+ * description: Action result.
4679
+ * content:
4680
+ * application/json:
4681
+ * schema:
4682
+ * type: object
4683
+ * 400:
4684
+ * description: Bad request.
4685
+ * content:
4686
+ * application/json:
4687
+ * schema:
4688
+ * $ref: '#/components/schemas/Error'
4689
+ * 404:
4690
+ * description: Tab not found.
4691
+ * content:
4692
+ * application/json:
4693
+ * schema:
4694
+ * $ref: '#/components/schemas/Error'
4695
+ */
2993
4696
  app.post('/act', async (req, res) => {
2994
4697
  try {
2995
4698
  const { kind, targetId, userId, ...params } = req.body;
@@ -3008,7 +4711,7 @@ app.post('/act', async (req, res) => {
3008
4711
  }
3009
4712
 
3010
4713
  const { tabState } = found;
3011
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
4714
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
3012
4715
 
3013
4716
  const result = await withTabLock(targetId, async () => {
3014
4717
  switch (kind) {
@@ -3053,28 +4756,43 @@ app.post('/act', async (req, res) => {
3053
4756
  }
3054
4757
 
3055
4758
  case 'type': {
3056
- const { ref, selector, text, submit } = params;
3057
- if (!ref && !selector) {
3058
- throw new Error('ref or selector required');
4759
+ const { ref, selector, text, submit, mode = 'fill', delay = 30 } = params;
4760
+ if (mode === 'fill' && !ref && !selector) {
4761
+ throw new Error('ref or selector required for mode=fill');
3059
4762
  }
3060
4763
  if (typeof text !== 'string') {
3061
4764
  throw new Error('text is required');
3062
4765
  }
4766
+ if (mode !== 'fill' && mode !== 'keyboard') {
4767
+ throw new Error("mode must be 'fill' or 'keyboard'");
4768
+ }
3063
4769
 
4770
+ let locator = null;
3064
4771
  if (ref) {
3065
- let locator = refToLocator(tabState.page, ref, tabState.refs);
4772
+ locator = refToLocator(tabState.page, ref, tabState.refs);
3066
4773
  if (!locator) {
3067
- log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size });
4774
+ log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size, mode });
3068
4775
  tabState.refs = await buildRefs(tabState.page);
3069
4776
  locator = refToLocator(tabState.page, ref, tabState.refs);
3070
4777
  }
3071
4778
  if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
3072
- await locator.fill(text, { timeout: 10000 });
3073
- if (submit) await tabState.page.keyboard.press('Enter');
4779
+ }
4780
+
4781
+ if (mode === 'fill') {
4782
+ if (locator) {
4783
+ await locator.fill(text, { timeout: 10000 });
4784
+ } else {
4785
+ await tabState.page.fill(selector, text, { timeout: 10000 });
4786
+ }
3074
4787
  } else {
3075
- await tabState.page.fill(selector, text, { timeout: 10000 });
3076
- if (submit) await tabState.page.keyboard.press('Enter');
4788
+ if (locator) {
4789
+ await locator.focus({ timeout: 10000 });
4790
+ } else if (selector) {
4791
+ await tabState.page.focus(selector, { timeout: 10000 });
4792
+ }
4793
+ await tabState.page.keyboard.type(text, { delay });
3077
4794
  }
4795
+ if (submit) await tabState.page.keyboard.press('Enter');
3078
4796
  return { ok: true, targetId };
3079
4797
  }
3080
4798
 
@@ -3208,7 +4926,9 @@ setInterval(async () => {
3208
4926
 
3209
4927
  // Crash logging
3210
4928
  process.on('uncaughtException', (err) => {
4929
+ pluginEvents.emit('browser:error', { error: err });
3211
4930
  log('error', 'uncaughtException', { error: err.message, stack: err.stack });
4931
+ reporter.reportCrash(err);
3212
4932
  process.exit(1);
3213
4933
  });
3214
4934
  process.on('unhandledRejection', (reason) => {
@@ -3222,6 +4942,7 @@ async function gracefulShutdown(signal) {
3222
4942
  if (shuttingDown) return;
3223
4943
  shuttingDown = true;
3224
4944
  log('info', 'shutting down', { signal });
4945
+ pluginEvents.emit('server:shutdown', { signal });
3225
4946
 
3226
4947
  const forceTimeout = setTimeout(() => {
3227
4948
  log('error', 'shutdown timed out, forcing exit');
@@ -3232,9 +4953,11 @@ async function gracefulShutdown(signal) {
3232
4953
  server.close();
3233
4954
  stopMemoryReporter();
3234
4955
 
3235
- for (const [userId, session] of sessions) {
3236
- await session.context.close().catch(() => {});
3237
- }
4956
+ await closeAllSessions(`shutdown:${signal}`, {
4957
+ clearDownloads: false,
4958
+ clearLocks: false,
4959
+ });
4960
+
3238
4961
  if (browser) await browser.close().catch(() => {});
3239
4962
  process.exit(0);
3240
4963
  }
@@ -3248,15 +4971,61 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
3248
4971
  // Fly's auto_stop_machines=false + min_machines_running=2 handles scaling.
3249
4972
 
3250
4973
  const PORT = CONFIG.port;
4974
+ pluginEvents.emit('server:starting', { port: PORT });
4975
+
4976
+ // Load plugins before starting the server
4977
+ const pluginCtx = {
4978
+ sessions,
4979
+ config: CONFIG,
4980
+ log,
4981
+ events: pluginEvents,
4982
+ auth: authMiddleware,
4983
+ ensureBrowser,
4984
+ getSession,
4985
+ destroySession,
4986
+ closeSession,
4987
+ withUserLimit,
4988
+ safePageClose,
4989
+ normalizeUserId,
4990
+ validateUrl,
4991
+ safeError,
4992
+ buildProxyUrl,
4993
+ proxyPool,
4994
+ failuresTotal,
4995
+ metricsRegistry: getRegister,
4996
+ createMetric,
4997
+ /** Factory for Xvfb virtual display. Plugins can replace this to customise resolution/args. */
4998
+ createVirtualDisplay: () => new VirtualDisplay(),
4999
+ /** The upstream VirtualDisplay class — plugins can subclass it. */
5000
+ VirtualDisplay,
5001
+ };
5002
+ const loadedPlugins = await loadPlugins(app, pluginCtx);
5003
+
5004
+ // --- OpenAPI docs (after all routes are registered) ---
5005
+ mountDocs(app);
5006
+
3251
5007
  const server = app.listen(PORT, async () => {
3252
5008
  startMemoryReporter();
3253
5009
  refreshActiveTabsGauge();
3254
5010
  refreshTabLockQueueDepth();
5011
+ pluginEvents.emit('server:started', { port: PORT, pid: process.pid, plugins: loadedPlugins });
3255
5012
  if (FLY_MACHINE_ID) {
3256
5013
  log('info', 'server started (fly)', { port: PORT, pid: process.pid, machineId: FLY_MACHINE_ID, nodeVersion: process.version });
3257
5014
  } else {
3258
5015
  log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
3259
5016
  }
5017
+ const tmpCleanup = cleanupOrphanedTempFiles({ tmpDir: os.tmpdir() });
5018
+ if (tmpCleanup.removed > 0) {
5019
+ log('info', 'cleaned up orphaned camoufox temp files', tmpCleanup);
5020
+ }
5021
+ const traceSweep = sweepOldTraces({
5022
+ baseDir: CONFIG.tracesDir,
5023
+ ttlMs: CONFIG.tracesTtlHours * 3600 * 1000,
5024
+ maxBytesPerFile: CONFIG.tracesMaxBytes,
5025
+ });
5026
+ if (traceSweep.removedTtl > 0 || traceSweep.removedOversized > 0) {
5027
+ log('info', 'swept old traces', traceSweep);
5028
+ }
3260
5029
  // Pre-warm browser so first request doesn't eat a 6-7s cold start
3261
5030
  try {
3262
5031
  const start = Date.now();