playwriter 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/bippy.js +5 -5
  2. package/dist/browser-config.d.ts.map +1 -1
  3. package/dist/browser-config.js +8 -2
  4. package/dist/browser-config.js.map +1 -1
  5. package/dist/browser-install.d.ts +16 -0
  6. package/dist/browser-install.d.ts.map +1 -0
  7. package/dist/browser-install.js +237 -0
  8. package/dist/browser-install.js.map +1 -0
  9. package/dist/cdp-relay.d.ts.map +1 -1
  10. package/dist/cdp-relay.js +261 -29
  11. package/dist/cdp-relay.js.map +1 -1
  12. package/dist/chrome-discovery.d.ts.map +1 -1
  13. package/dist/chrome-discovery.js +8 -0
  14. package/dist/chrome-discovery.js.map +1 -1
  15. package/dist/cli.js +578 -17
  16. package/dist/cli.js.map +1 -1
  17. package/dist/cloud-client.d.ts +56 -0
  18. package/dist/cloud-client.d.ts.map +1 -0
  19. package/dist/cloud-client.js +120 -0
  20. package/dist/cloud-client.js.map +1 -0
  21. package/dist/executor.d.ts +46 -3
  22. package/dist/executor.d.ts.map +1 -1
  23. package/dist/executor.js +249 -26
  24. package/dist/executor.js.map +1 -1
  25. package/dist/extension/background.js +106 -23
  26. package/dist/extension/manifest.json +1 -1
  27. package/dist/playwright-import.d.ts +19 -0
  28. package/dist/playwright-import.d.ts.map +1 -0
  29. package/dist/playwright-import.js +39 -0
  30. package/dist/playwright-import.js.map +1 -0
  31. package/dist/prompt.md +32 -0
  32. package/dist/readability.js +1 -1
  33. package/dist/relay-session.test.js +1 -1
  34. package/dist/relay-session.test.js.map +1 -1
  35. package/dist/relay-state.d.ts +1 -0
  36. package/dist/relay-state.d.ts.map +1 -1
  37. package/dist/relay-state.js +18 -0
  38. package/dist/relay-state.js.map +1 -1
  39. package/dist/relay-state.test.js +22 -0
  40. package/dist/relay-state.test.js.map +1 -1
  41. package/dist/selector-generator.js +1 -1
  42. package/dist/utils.d.ts +2 -2
  43. package/dist/utils.d.ts.map +1 -1
  44. package/dist/utils.js +4 -4
  45. package/dist/utils.js.map +1 -1
  46. package/package.json +3 -1
  47. package/src/browser-config.ts +11 -2
  48. package/src/browser-install.ts +283 -0
  49. package/src/cdp-relay.ts +306 -32
  50. package/src/chrome-discovery.ts +9 -0
  51. package/src/cli.ts +645 -19
  52. package/src/cloud-client.ts +172 -0
  53. package/src/executor.ts +295 -28
  54. package/src/playwright-import.ts +58 -0
  55. package/src/relay-session.test.ts +1 -1
  56. package/src/relay-state.test.ts +32 -0
  57. package/src/relay-state.ts +19 -1
  58. package/src/skill.md +154 -14
  59. package/src/utils.ts +4 -5
package/src/cdp-relay.ts CHANGED
@@ -24,8 +24,11 @@ Buffer.prototype[util.inspect.custom] = function () {
24
24
  return `<Buffer ${this.length} bytes>`
25
25
  }
26
26
 
27
+ import fs from 'node:fs'
28
+ import os from 'node:os'
29
+ import path from 'node:path'
27
30
  import { EventEmitter } from 'node:events'
28
- import { VERSION, EXTENSION_IDS } from './utils.js'
31
+ import { VERSION, EXTENSION_IDS, shouldAutoEnablePlaywriter } from './utils.js'
29
32
  import { createCdpLogger, type CdpLogEntry, type CdpLogger } from './cdp-log.js'
30
33
  import { RecordingRelay } from './recording-relay.js'
31
34
  import { appendSessionToWsUrl } from './chrome-discovery.js'
@@ -156,22 +159,6 @@ export async function startPlayWriterCDPRelayServer({
156
159
  return null
157
160
  }
158
161
 
159
- const buildStableExtensionKey = (info: relayState.ExtensionInfo, connectionId: string): string => {
160
- if (info.id) {
161
- return `profile:${info.id}`
162
- }
163
- if (info.email) {
164
- return `email:${info.email}`
165
- }
166
- if (info.installId) {
167
- return `install:${info.browser || 'unknown'}:${info.installId}`
168
- }
169
- if (info.browser) {
170
- return `browser:${info.browser}`
171
- }
172
- return `connection:${connectionId}`
173
- }
174
-
175
162
  const normalizeSessionId = (value: string | number | null | undefined): string | null => {
176
163
  if (value === undefined || value === null) {
177
164
  return null
@@ -521,11 +508,10 @@ export async function startPlayWriterCDPRelayServer({
521
508
  return recordingRelays.get(connId) || null
522
509
  }
523
510
 
524
- // Auto-create initial tab when PLAYWRITER_AUTO_ENABLE is set and no targets exist.
525
- // This allows Playwright to connect and immediately have a page to work with.
526
- async function maybeAutoCreateInitialTab(options: { extensionId: string; autoEnable: boolean }): Promise<void> {
527
- const { extensionId, autoEnable } = options
528
- if (!autoEnable && !process.env.PLAYWRITER_AUTO_ENABLE) {
511
+ // Auto-create an initial blank tab when no targets exist. Set
512
+ // PLAYWRITER_AUTO_ENABLE=false to require manually enabled tabs instead.
513
+ async function maybeAutoCreateInitialTab(extensionId: string): Promise<void> {
514
+ if (!shouldAutoEnablePlaywriter()) {
529
515
  return
530
516
  }
531
517
  const conn = getExtensionConnection(extensionId)
@@ -655,14 +641,12 @@ export async function startPlayWriterCDPRelayServer({
655
641
  params,
656
642
  sessionId,
657
643
  source,
658
- autoEnable,
659
644
  }: {
660
645
  extensionId: string | null
661
646
  method: CDPCommand['method'] | (string & {})
662
647
  params: CDPCommand['params']
663
648
  sessionId?: CDPCommand['sessionId']
664
649
  source?: CDPCommand['source']
665
- autoEnable: boolean
666
650
  }) {
667
651
  const conn = getExtensionConnection(extensionId)
668
652
  const connectedTargets = conn?.connectedTargets || new Map<string, relayState.ConnectedTarget>()
@@ -702,7 +686,7 @@ export async function startPlayWriterCDPRelayServer({
702
686
  break
703
687
  }
704
688
  if (conn) {
705
- await maybeAutoCreateInitialTab({ extensionId: conn.id, autoEnable })
689
+ await maybeAutoCreateInitialTab(conn.id)
706
690
  }
707
691
  // Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
708
692
  // Playwright relies on these (with parentFrameId) when reconnecting over CDP.
@@ -840,6 +824,13 @@ export async function startPlayWriterCDPRelayServer({
840
824
  }
841
825
 
842
826
  const app = new Hono()
827
+
828
+ // Global error handler — ensures server errors are logged, not silently swallowed
829
+ app.onError((err, c) => {
830
+ logger?.error('Unhandled route error:', err)
831
+ return c.json({ error: err.message }, 500)
832
+ })
833
+
843
834
  // CORS middleware for HTTP endpoints - only allows our specific extension IDs.
844
835
  // This prevents other extensions from reading responses via fetch/XHR.
845
836
  // WebSocket connections have their own separate origin validation.
@@ -1112,7 +1103,6 @@ export async function startPlayWriterCDPRelayServer({
1112
1103
  const clientId = c.req.param('clientId') || 'default'
1113
1104
  const url = new URL(c.req.url, 'http://localhost')
1114
1105
  const requestedExtensionId = url.searchParams.get('extensionId')
1115
- const autoEnable = url.searchParams.get('autoEnable') === '1'
1116
1106
  // When extensionId is explicit, resolve directly. Otherwise use fallback which
1117
1107
  // handles single-extension and uniquely-active-extension cases (#52).
1118
1108
  const resolvedExtension = requestedExtensionId
@@ -1204,7 +1194,6 @@ export async function startPlayWriterCDPRelayServer({
1204
1194
  params,
1205
1195
  sessionId,
1206
1196
  source,
1207
- autoEnable,
1208
1197
  })
1209
1198
 
1210
1199
  if (method === 'Target.setAutoAttach' && !sessionId) {
@@ -1403,7 +1392,7 @@ export async function startPlayWriterCDPRelayServer({
1403
1392
  const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
1404
1393
  return {
1405
1394
  onOpen(_event, ws) {
1406
- const stableKey = buildStableExtensionKey(incomingExtensionInfo, connectionId)
1395
+ const stableKey = relayState.buildStableExtensionKey(incomingExtensionInfo, connectionId)
1407
1396
 
1408
1397
  // Check for existing connection with same stableKey and close it
1409
1398
  const existingExt = relayState.findExtensionByStableKey(store.getState(), stableKey)
@@ -1935,9 +1924,27 @@ export async function startPlayWriterCDPRelayServer({
1935
1924
  404,
1936
1925
  )
1937
1926
  }
1938
- const result = await existingExecutor.execute(code, timeout)
1927
+ // Touch cloud session activity tracking if this session is cloud-backed
1928
+ const cloudTracking = cloudSessionTracking.get(sessionId)
1929
+ if (cloudTracking) {
1930
+ cloudTracking.lastActivityAt = Date.now()
1931
+ cloudTracking.activeExecutions++
1932
+ }
1933
+
1934
+ let result: Awaited<ReturnType<typeof existingExecutor.execute>>
1935
+ try {
1936
+ result = await existingExecutor.execute(code, timeout)
1937
+ } finally {
1938
+ if (cloudTracking) {
1939
+ cloudTracking.activeExecutions--
1940
+ cloudTracking.lastActivityAt = Date.now()
1941
+ }
1942
+ }
1939
1943
 
1940
- return c.json(result)
1944
+ // Use the cloudTracking snapshot captured before execute (not a fresh
1945
+ // map lookup) so long-running executes that outlive idle cleanup still
1946
+ // report isCloud correctly.
1947
+ return c.json({ ...result, isCloud: Boolean(cloudTracking) })
1941
1948
  } catch (error: any) {
1942
1949
  logger?.error('Execute endpoint error:', error)
1943
1950
  return c.json({ text: `Server error: ${error.message}`, images: [], screenshots: [], isError: true }, 500)
@@ -1983,18 +1990,60 @@ export async function startPlayWriterCDPRelayServer({
1983
1990
  app.post('/cli/session/new', async (c) => {
1984
1991
  const body = (await c.req.json().catch(() => ({}))) as {
1985
1992
  extensionId?: string | null
1986
- autoEnable?: boolean
1987
1993
  cwd?: string
1988
1994
  /** Direct CDP WebSocket URL — bypasses extension, connects straight to Chrome */
1989
1995
  cdpEndpoint?: string
1996
+ /** Launch a headless Chrome via chromium.launch() — no extension or relay CDP routing */
1997
+ headless?: boolean
1990
1998
  /** Browser name from discovery (e.g. "Chrome", "Brave") */
1991
1999
  browser?: string
1992
2000
  /** Profile info from discovery */
1993
2001
  profiles?: Array<{ name: string; email: string }>
2002
+ /** Cloud session tracking metadata (set by CLI when connecting to a cloud browser) */
2003
+ cloud?: {
2004
+ cloudSessionId: string
2005
+ cloudBaseUrl: string
2006
+ cloudToken: string
2007
+ /** BU VM hard timeout (ISO string or epoch ms) */
2008
+ timeoutAt?: string | number
2009
+ /** Block images/video/fonts to save proxy bandwidth */
2010
+ blockProxyResources?: boolean
2011
+ }
1994
2012
  }
1995
2013
  const sessionId = String(nextSessionNumber++)
1996
2014
  const cwd = body.cwd
1997
2015
 
2016
+ // Headless mode: launch Chrome via chromium.launch(), no extension needed.
2017
+ // Force connection immediately so missing Chrome errors surface at creation time,
2018
+ // not on first execute call.
2019
+ if (body.headless) {
2020
+ const manager = await getExecutorManager()
2021
+ const executor = manager.getExecutor({
2022
+ sessionId,
2023
+ cwd,
2024
+ cdpConfig: { headless: true },
2025
+ sessionMetadata: {
2026
+ extensionId: null,
2027
+ browser: 'Chrome (Headless)',
2028
+ profile: null,
2029
+ },
2030
+ })
2031
+ try {
2032
+ await executor.reset()
2033
+ } catch (error) {
2034
+ manager.deleteExecutor(sessionId)
2035
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 500)
2036
+ }
2037
+ const metadata = executor.getSessionMetadata()
2038
+ return c.json({
2039
+ id: sessionId,
2040
+ mode: 'headless' as const,
2041
+ extensionId: metadata.extensionId,
2042
+ browser: metadata.browser,
2043
+ profile: metadata.profile,
2044
+ })
2045
+ }
2046
+
1998
2047
  // Direct CDP mode: skip extension lookup, pass direct WebSocket URL to executor
1999
2048
  if (body.cdpEndpoint) {
2000
2049
  if (!body.cdpEndpoint.startsWith('ws://') && !body.cdpEndpoint.startsWith('wss://')) {
@@ -2002,6 +2051,9 @@ export async function startPlayWriterCDPRelayServer({
2002
2051
  }
2003
2052
  // Use first profile from discovery for session metadata (if available)
2004
2053
  const firstProfile = body.profiles?.[0]
2054
+ const cloudTimeoutAt = body.cloud?.timeoutAt
2055
+ ? (typeof body.cloud.timeoutAt === 'string' ? new Date(body.cloud.timeoutAt).getTime() : body.cloud.timeoutAt)
2056
+ : undefined
2005
2057
  const manager = await getExecutorManager()
2006
2058
  const executor = manager.getExecutor({
2007
2059
  sessionId,
@@ -2012,8 +2064,23 @@ export async function startPlayWriterCDPRelayServer({
2012
2064
  browser: body.browser || null,
2013
2065
  profile: firstProfile ? { email: firstProfile.email, id: firstProfile.name } : null,
2014
2066
  },
2067
+ cloudSession: body.cloud ? { timeoutAt: cloudTimeoutAt, blockProxyResources: body.cloud.blockProxyResources } : undefined,
2015
2068
  })
2016
2069
  const metadata = executor.getSessionMetadata()
2070
+
2071
+ // Register cloud session tracking if cloud metadata was provided
2072
+ if (body.cloud) {
2073
+ cloudSessionTracking.set(sessionId, {
2074
+ cloudSessionId: body.cloud.cloudSessionId,
2075
+ cloudBaseUrl: body.cloud.cloudBaseUrl,
2076
+ cloudToken: body.cloud.cloudToken,
2077
+ lastActivityAt: Date.now(),
2078
+ activeExecutions: 0,
2079
+ timeoutAt: cloudTimeoutAt,
2080
+ })
2081
+ persistCloudSessions()
2082
+ }
2083
+
2017
2084
  return c.json({
2018
2085
  id: sessionId,
2019
2086
  mode: 'direct' as const,
@@ -2037,7 +2104,6 @@ export async function startPlayWriterCDPRelayServer({
2037
2104
  const executor = manager.getExecutor({
2038
2105
  sessionId,
2039
2106
  cwd,
2040
- cdpConfig: { host: '127.0.0.1', port, token, extensionId: conn.stableKey, autoEnable: body.autoEnable === true },
2041
2107
  sessionMetadata: {
2042
2108
  extensionId: conn.stableKey,
2043
2109
  browser: conn.info.browser || null,
@@ -2074,11 +2140,32 @@ export async function startPlayWriterCDPRelayServer({
2074
2140
  }
2075
2141
 
2076
2142
  const manager = await getExecutorManager()
2143
+ const executor = manager.getSession(sessionId)
2144
+
2145
+ // Close headless context before deleting to prevent context/page leaks
2146
+ // on the shared headless browser. Only affects headless sessions.
2147
+ if (executor) {
2148
+ await executor.closeHeadlessContext()
2149
+ }
2150
+
2077
2151
  const deleted = manager.deleteExecutor(sessionId)
2078
2152
 
2079
2153
  if (!deleted) {
2080
2154
  return c.json({ error: `Session ${sessionId} not found` }, 404)
2081
2155
  }
2156
+
2157
+ // If this was a cloud-backed session, stop the VM only if no other
2158
+ // relay session is still using the same cloud VM (reference counting).
2159
+ const cloudTracking = cloudSessionTracking.get(sessionId)
2160
+ if (cloudTracking) {
2161
+ const shouldStopVm = !hasOtherCloudReferences(sessionId, cloudTracking.cloudSessionId)
2162
+ cloudSessionTracking.delete(sessionId)
2163
+ persistCloudSessions()
2164
+ if (shouldStopVm) {
2165
+ disconnectCloudVm(cloudTracking)
2166
+ }
2167
+ }
2168
+
2082
2169
  return c.json({ success: true })
2083
2170
  } catch (error: any) {
2084
2171
  logger?.error('Delete session endpoint error:', error)
@@ -2153,6 +2240,180 @@ export async function startPlayWriterCDPRelayServer({
2153
2240
  return c.json(result)
2154
2241
  })
2155
2242
 
2243
+ // ============================================================================
2244
+ // Cloud session idle tracking
2245
+ //
2246
+ // Tracks lastActivityAt for cloud-backed sessions (those created via
2247
+ // cdpEndpoint pointing to Browser Use VMs). A background interval checks
2248
+ // every 60s and disconnects sessions idle > 10 minutes by calling the
2249
+ // website's /api/cloud/disconnect endpoint.
2250
+ // ============================================================================
2251
+
2252
+ interface CloudSessionTracking {
2253
+ cloudSessionId: string
2254
+ /** Website base URL for disconnect calls */
2255
+ cloudBaseUrl: string
2256
+ /** Bearer token for website API */
2257
+ cloudToken: string
2258
+ lastActivityAt: number
2259
+ /** Number of currently running execute calls — skip idle timeout while > 0 */
2260
+ activeExecutions: number
2261
+ /** BU VM hard timeout (epoch ms) — used to warn users before expiration */
2262
+ timeoutAt?: number
2263
+ }
2264
+
2265
+ const cloudSessionTracking = new Map<string, CloudSessionTracking>()
2266
+ const CLOUD_IDLE_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
2267
+
2268
+ /** Check if any OTHER relay session references the same cloud VM.
2269
+ * Used to prevent stopping a VM that's still used by another relay session
2270
+ * (e.g. user attached twice via `session new --browser cloud-1`). */
2271
+ function hasOtherCloudReferences(relaySessionId: string, cloudSessionId: string): boolean {
2272
+ for (const [otherId, tracking] of cloudSessionTracking) {
2273
+ if (otherId !== relaySessionId && tracking.cloudSessionId === cloudSessionId) {
2274
+ return true
2275
+ }
2276
+ }
2277
+ return false
2278
+ }
2279
+
2280
+ /** Disconnect a cloud VM via the website API (best-effort, non-blocking). */
2281
+ function disconnectCloudVm(tracking: CloudSessionTracking): void {
2282
+ fetch(new URL('/api/cloud/disconnect', tracking.cloudBaseUrl).toString(), {
2283
+ method: 'POST',
2284
+ headers: {
2285
+ Authorization: `Bearer ${tracking.cloudToken}`,
2286
+ 'Content-Type': 'application/json',
2287
+ },
2288
+ body: JSON.stringify({ cloudSessionId: tracking.cloudSessionId }),
2289
+ }).catch((err) => {
2290
+ logger?.error('[Cloud] Failed to disconnect cloud session:', err)
2291
+ })
2292
+ }
2293
+
2294
+ // ── Cloud session crash recovery ──────────────────────────────────
2295
+ // Persist cloud session IDs to disk so orphaned VMs can be cleaned up
2296
+ // if the relay process crashes. On startup, read the file and disconnect
2297
+ // any leftover VMs (best-effort).
2298
+
2299
+ const CLOUD_SESSIONS_FILE = path.join(os.homedir(), '.playwriter', 'cloud-sessions.json')
2300
+
2301
+ interface PersistedCloudSession {
2302
+ cloudSessionId: string
2303
+ cloudBaseUrl: string
2304
+ cloudToken: string
2305
+ }
2306
+
2307
+ function persistCloudSessions(): void {
2308
+ // Dedupe by cloudSessionId — multiple relay sessions can reference the same VM
2309
+ const seen = new Set<string>()
2310
+ const entries: PersistedCloudSession[] = []
2311
+ for (const t of cloudSessionTracking.values()) {
2312
+ if (seen.has(t.cloudSessionId)) continue
2313
+ seen.add(t.cloudSessionId)
2314
+ entries.push({
2315
+ cloudSessionId: t.cloudSessionId,
2316
+ cloudBaseUrl: t.cloudBaseUrl,
2317
+ cloudToken: t.cloudToken,
2318
+ })
2319
+ }
2320
+ try {
2321
+ const dir = path.dirname(CLOUD_SESSIONS_FILE)
2322
+ fs.mkdirSync(dir, { recursive: true })
2323
+ if (entries.length > 0) {
2324
+ // Atomic write: write to temp file then rename, so a crash mid-write
2325
+ // doesn't leave corrupt JSON that blocks future cleanup.
2326
+ const tmpFile = CLOUD_SESSIONS_FILE + '.tmp'
2327
+ fs.writeFileSync(tmpFile, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 })
2328
+ fs.renameSync(tmpFile, CLOUD_SESSIONS_FILE)
2329
+ } else {
2330
+ // No active sessions — remove file to avoid stale data
2331
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* already gone */ }
2332
+ }
2333
+ } catch {
2334
+ // Best-effort: don't crash relay if disk write fails
2335
+ }
2336
+ }
2337
+
2338
+ function cleanupOrphanedCloudSessions(): void {
2339
+ let raw: string
2340
+ try {
2341
+ raw = fs.readFileSync(CLOUD_SESSIONS_FILE, 'utf-8')
2342
+ } catch {
2343
+ return // No file — nothing to clean up
2344
+ }
2345
+
2346
+ let entries: PersistedCloudSession[]
2347
+ try {
2348
+ const parsed = JSON.parse(raw)
2349
+ if (!Array.isArray(parsed)) return
2350
+ // Validate shape: each entry must have cloudSessionId and cloudBaseUrl
2351
+ entries = parsed.filter((e): e is PersistedCloudSession => {
2352
+ return e && typeof e.cloudSessionId === 'string' && typeof e.cloudBaseUrl === 'string' && typeof e.cloudToken === 'string'
2353
+ })
2354
+ } catch {
2355
+ // Corrupt JSON (e.g. crash during non-atomic write) — just remove it
2356
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* ignore */ }
2357
+ return
2358
+ }
2359
+ if (!entries.length) {
2360
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* ignore */ }
2361
+ return
2362
+ }
2363
+
2364
+ logger?.log(pc.yellow(`[Cloud] Found ${entries.length} orphaned cloud session(s) from previous relay. Cleaning up...`))
2365
+ // Remove file after we've read it — disconnect calls are best-effort async.
2366
+ // If they fail, the BU VM will eventually hit its own timeout anyway.
2367
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* ignore */ }
2368
+
2369
+ for (const entry of entries) {
2370
+ disconnectCloudVm({
2371
+ cloudSessionId: entry.cloudSessionId,
2372
+ cloudBaseUrl: entry.cloudBaseUrl,
2373
+ cloudToken: entry.cloudToken,
2374
+ lastActivityAt: 0,
2375
+ activeExecutions: 0,
2376
+ })
2377
+ }
2378
+ }
2379
+
2380
+ const cloudIdleInterval = setInterval(async () => {
2381
+ const now = Date.now()
2382
+ // Collect idle sessions first, then process — avoid mutating map during iteration
2383
+ const idleSessions: Array<[string, CloudSessionTracking]> = []
2384
+ for (const [sessionId, tracking] of cloudSessionTracking) {
2385
+ // VM already past BU hard timeout — schedule for cleanup regardless of activity
2386
+ if (tracking.timeoutAt && tracking.timeoutAt <= now) {
2387
+ idleSessions.push([sessionId, tracking])
2388
+ continue
2389
+ }
2390
+ // Timeout warnings are handled by the executor on each execute() call
2391
+ // (deduped by minute bucket) — no need to enqueue from the relay interval.
2392
+
2393
+ if (tracking.activeExecutions > 0) continue
2394
+ if (now - tracking.lastActivityAt > CLOUD_IDLE_TIMEOUT_MS) {
2395
+ idleSessions.push([sessionId, tracking])
2396
+ }
2397
+ }
2398
+
2399
+ if (idleSessions.length > 0) {
2400
+ for (const [sessionId, tracking] of idleSessions) {
2401
+ logger?.log(
2402
+ pc.yellow(`[Cloud] Stopping idle relay session ${sessionId} (idle > 10 min)`),
2403
+ )
2404
+ // Check if other relay sessions reference the same cloud VM.
2405
+ // Only stop the VM when this is the last relay session for it.
2406
+ const shouldStopVm = !hasOtherCloudReferences(sessionId, tracking.cloudSessionId)
2407
+ cloudSessionTracking.delete(sessionId)
2408
+ executorManager?.deleteExecutor(sessionId)
2409
+ if (shouldStopVm) {
2410
+ disconnectCloudVm(tracking)
2411
+ }
2412
+ }
2413
+ persistCloudSessions()
2414
+ }
2415
+ }, 60_000)
2416
+
2156
2417
  // Use createAdaptorServer instead of serve() so we control the listen()
2157
2418
  // timing. This lets us inject WebSocket upgrade handlers before binding and
2158
2419
  // await the bind to surface EADDRINUSE as a catchable error (issue #75).
@@ -2173,6 +2434,11 @@ export async function startPlayWriterCDPRelayServer({
2173
2434
  server.listen(port, host)
2174
2435
  })
2175
2436
 
2437
+ // Clean up orphaned cloud sessions from a previous relay crash.
2438
+ // Must run AFTER successful listen — if another relay is already running,
2439
+ // we'd fail with EADDRINUSE but only after killing its live VMs.
2440
+ cleanupOrphanedCloudSessions()
2441
+
2176
2442
  const wsHost = `ws://${host}:${port}`
2177
2443
  const cdpEndpoint = `${wsHost}/cdp`
2178
2444
  const extensionEndpoint = `${wsHost}/extension`
@@ -2198,11 +2464,19 @@ export async function startPlayWriterCDPRelayServer({
2198
2464
  ext.ws?.close(1000, 'Server stopped')
2199
2465
  }
2200
2466
 
2467
+ // Close shared headless browser if any headless sessions were created (fire-and-forget)
2468
+ void import('./executor.js').then(({ PlaywrightExecutor }) => {
2469
+ return PlaywrightExecutor.closeSharedHeadlessBrowser()
2470
+ })
2471
+
2201
2472
  // Reset store state
2202
2473
  store.setState({
2203
2474
  extensions: new Map(),
2204
2475
  playwrightClients: new Map(),
2205
2476
  })
2477
+ clearInterval(cloudIdleInterval)
2478
+ cloudSessionTracking.clear()
2479
+ persistCloudSessions() // Remove the file on graceful shutdown
2206
2480
  server.close()
2207
2481
  emitter.removeAllListeners()
2208
2482
  },
@@ -170,6 +170,15 @@ export async function resolveDirectInput(input: string): Promise<string> {
170
170
  return input
171
171
  }
172
172
 
173
+ // Cloud browser providers (Browser Use, Browserbase) return https:// CDP URLs.
174
+ // Convert to wss:// since the WebSocket connection uses the same hostname.
175
+ if (input.startsWith('https://')) {
176
+ return 'wss://' + input.slice('https://'.length)
177
+ }
178
+ if (input.startsWith('http://')) {
179
+ return 'ws://' + input.slice('http://'.length)
180
+ }
181
+
173
182
  const match = input.match(/^([^:]+):(\d+)$/)
174
183
  if (!match) {
175
184
  throw new Error(