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/dist/cdp-relay.js CHANGED
@@ -9,8 +9,11 @@ import util from 'node:util';
9
9
  Buffer.prototype[util.inspect.custom] = function () {
10
10
  return `<Buffer ${this.length} bytes>`;
11
11
  };
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
12
15
  import { EventEmitter } from 'node:events';
13
- import { VERSION, EXTENSION_IDS } from './utils.js';
16
+ import { VERSION, EXTENSION_IDS, shouldAutoEnablePlaywriter } from './utils.js';
14
17
  import { createCdpLogger } from './cdp-log.js';
15
18
  import { RecordingRelay } from './recording-relay.js';
16
19
  import { appendSessionToWsUrl } from './chrome-discovery.js';
@@ -105,21 +108,6 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
105
108
  }
106
109
  return null;
107
110
  };
108
- const buildStableExtensionKey = (info, connectionId) => {
109
- if (info.id) {
110
- return `profile:${info.id}`;
111
- }
112
- if (info.email) {
113
- return `email:${info.email}`;
114
- }
115
- if (info.installId) {
116
- return `install:${info.browser || 'unknown'}:${info.installId}`;
117
- }
118
- if (info.browser) {
119
- return `browser:${info.browser}`;
120
- }
121
- return `connection:${connectionId}`;
122
- };
123
111
  const normalizeSessionId = (value) => {
124
112
  if (value === undefined || value === null) {
125
113
  return null;
@@ -368,11 +356,10 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
368
356
  }
369
357
  return recordingRelays.get(connId) || null;
370
358
  };
371
- // Auto-create initial tab when PLAYWRITER_AUTO_ENABLE is set and no targets exist.
372
- // This allows Playwright to connect and immediately have a page to work with.
373
- async function maybeAutoCreateInitialTab(options) {
374
- const { extensionId, autoEnable } = options;
375
- if (!autoEnable && !process.env.PLAYWRITER_AUTO_ENABLE) {
359
+ // Auto-create an initial blank tab when no targets exist. Set
360
+ // PLAYWRITER_AUTO_ENABLE=false to require manually enabled tabs instead.
361
+ async function maybeAutoCreateInitialTab(extensionId) {
362
+ if (!shouldAutoEnablePlaywriter()) {
376
363
  return;
377
364
  }
378
365
  const conn = getExtensionConnection(extensionId);
@@ -462,7 +449,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
462
449
  }
463
450
  }));
464
451
  }
465
- async function routeCdpCommand({ extensionId, method, params, sessionId, source, autoEnable, }) {
452
+ async function routeCdpCommand({ extensionId, method, params, sessionId, source, }) {
466
453
  const conn = getExtensionConnection(extensionId);
467
454
  const connectedTargets = conn?.connectedTargets || new Map();
468
455
  const resolvedExtensionId = conn?.id || extensionId;
@@ -499,7 +486,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
499
486
  break;
500
487
  }
501
488
  if (conn) {
502
- await maybeAutoCreateInitialTab({ extensionId: conn.id, autoEnable });
489
+ await maybeAutoCreateInitialTab(conn.id);
503
490
  }
504
491
  // Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
505
492
  // Playwright relies on these (with parentFrameId) when reconnecting over CDP.
@@ -614,6 +601,11 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
614
601
  });
615
602
  }
616
603
  const app = new Hono();
604
+ // Global error handler — ensures server errors are logged, not silently swallowed
605
+ app.onError((err, c) => {
606
+ logger?.error('Unhandled route error:', err);
607
+ return c.json({ error: err.message }, 500);
608
+ });
617
609
  // CORS middleware for HTTP endpoints - only allows our specific extension IDs.
618
610
  // This prevents other extensions from reading responses via fetch/XHR.
619
611
  // WebSocket connections have their own separate origin validation.
@@ -858,7 +850,6 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
858
850
  const clientId = c.req.param('clientId') || 'default';
859
851
  const url = new URL(c.req.url, 'http://localhost');
860
852
  const requestedExtensionId = url.searchParams.get('extensionId');
861
- const autoEnable = url.searchParams.get('autoEnable') === '1';
862
853
  // When extensionId is explicit, resolve directly. Otherwise use fallback which
863
854
  // handles single-extension and uniquely-active-extension cases (#52).
864
855
  const resolvedExtension = requestedExtensionId
@@ -935,7 +926,6 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
935
926
  params,
936
927
  sessionId,
937
928
  source,
938
- autoEnable,
939
929
  });
940
930
  if (method === 'Target.setAutoAttach' && !sessionId) {
941
931
  // Re-read state after async routeCdpCommand — targets may have changed
@@ -1097,7 +1087,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1097
1087
  const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1098
1088
  return {
1099
1089
  onOpen(_event, ws) {
1100
- const stableKey = buildStableExtensionKey(incomingExtensionInfo, connectionId);
1090
+ const stableKey = relayState.buildStableExtensionKey(incomingExtensionInfo, connectionId);
1101
1091
  // Check for existing connection with same stableKey and close it
1102
1092
  const existingExt = relayState.findExtensionByStableKey(store.getState(), stableKey);
1103
1093
  if (existingExt && existingExt.id !== connectionId) {
@@ -1545,8 +1535,26 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1545
1535
  if (!existingExecutor) {
1546
1536
  return c.json({ text: `Session ${sessionId} not found. Run 'playwriter session new' first.`, images: [], screenshots: [], isError: true }, 404);
1547
1537
  }
1548
- const result = await existingExecutor.execute(code, timeout);
1549
- return c.json(result);
1538
+ // Touch cloud session activity tracking if this session is cloud-backed
1539
+ const cloudTracking = cloudSessionTracking.get(sessionId);
1540
+ if (cloudTracking) {
1541
+ cloudTracking.lastActivityAt = Date.now();
1542
+ cloudTracking.activeExecutions++;
1543
+ }
1544
+ let result;
1545
+ try {
1546
+ result = await existingExecutor.execute(code, timeout);
1547
+ }
1548
+ finally {
1549
+ if (cloudTracking) {
1550
+ cloudTracking.activeExecutions--;
1551
+ cloudTracking.lastActivityAt = Date.now();
1552
+ }
1553
+ }
1554
+ // Use the cloudTracking snapshot captured before execute (not a fresh
1555
+ // map lookup) so long-running executes that outlive idle cleanup still
1556
+ // report isCloud correctly.
1557
+ return c.json({ ...result, isCloud: Boolean(cloudTracking) });
1550
1558
  }
1551
1559
  catch (error) {
1552
1560
  logger?.error('Execute endpoint error:', error);
@@ -1588,6 +1596,37 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1588
1596
  const body = (await c.req.json().catch(() => ({})));
1589
1597
  const sessionId = String(nextSessionNumber++);
1590
1598
  const cwd = body.cwd;
1599
+ // Headless mode: launch Chrome via chromium.launch(), no extension needed.
1600
+ // Force connection immediately so missing Chrome errors surface at creation time,
1601
+ // not on first execute call.
1602
+ if (body.headless) {
1603
+ const manager = await getExecutorManager();
1604
+ const executor = manager.getExecutor({
1605
+ sessionId,
1606
+ cwd,
1607
+ cdpConfig: { headless: true },
1608
+ sessionMetadata: {
1609
+ extensionId: null,
1610
+ browser: 'Chrome (Headless)',
1611
+ profile: null,
1612
+ },
1613
+ });
1614
+ try {
1615
+ await executor.reset();
1616
+ }
1617
+ catch (error) {
1618
+ manager.deleteExecutor(sessionId);
1619
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
1620
+ }
1621
+ const metadata = executor.getSessionMetadata();
1622
+ return c.json({
1623
+ id: sessionId,
1624
+ mode: 'headless',
1625
+ extensionId: metadata.extensionId,
1626
+ browser: metadata.browser,
1627
+ profile: metadata.profile,
1628
+ });
1629
+ }
1591
1630
  // Direct CDP mode: skip extension lookup, pass direct WebSocket URL to executor
1592
1631
  if (body.cdpEndpoint) {
1593
1632
  if (!body.cdpEndpoint.startsWith('ws://') && !body.cdpEndpoint.startsWith('wss://')) {
@@ -1595,6 +1634,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1595
1634
  }
1596
1635
  // Use first profile from discovery for session metadata (if available)
1597
1636
  const firstProfile = body.profiles?.[0];
1637
+ const cloudTimeoutAt = body.cloud?.timeoutAt
1638
+ ? (typeof body.cloud.timeoutAt === 'string' ? new Date(body.cloud.timeoutAt).getTime() : body.cloud.timeoutAt)
1639
+ : undefined;
1598
1640
  const manager = await getExecutorManager();
1599
1641
  const executor = manager.getExecutor({
1600
1642
  sessionId,
@@ -1605,8 +1647,21 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1605
1647
  browser: body.browser || null,
1606
1648
  profile: firstProfile ? { email: firstProfile.email, id: firstProfile.name } : null,
1607
1649
  },
1650
+ cloudSession: body.cloud ? { timeoutAt: cloudTimeoutAt, blockProxyResources: body.cloud.blockProxyResources } : undefined,
1608
1651
  });
1609
1652
  const metadata = executor.getSessionMetadata();
1653
+ // Register cloud session tracking if cloud metadata was provided
1654
+ if (body.cloud) {
1655
+ cloudSessionTracking.set(sessionId, {
1656
+ cloudSessionId: body.cloud.cloudSessionId,
1657
+ cloudBaseUrl: body.cloud.cloudBaseUrl,
1658
+ cloudToken: body.cloud.cloudToken,
1659
+ lastActivityAt: Date.now(),
1660
+ activeExecutions: 0,
1661
+ timeoutAt: cloudTimeoutAt,
1662
+ });
1663
+ persistCloudSessions();
1664
+ }
1610
1665
  return c.json({
1611
1666
  id: sessionId,
1612
1667
  mode: 'direct',
@@ -1629,7 +1684,6 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1629
1684
  const executor = manager.getExecutor({
1630
1685
  sessionId,
1631
1686
  cwd,
1632
- cdpConfig: { host: '127.0.0.1', port, token, extensionId: conn.stableKey, autoEnable: body.autoEnable === true },
1633
1687
  sessionMetadata: {
1634
1688
  extensionId: conn.stableKey,
1635
1689
  browser: conn.info.browser || null,
@@ -1662,10 +1716,27 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1662
1716
  return c.json({ error: 'sessionId is required' }, 400);
1663
1717
  }
1664
1718
  const manager = await getExecutorManager();
1719
+ const executor = manager.getSession(sessionId);
1720
+ // Close headless context before deleting to prevent context/page leaks
1721
+ // on the shared headless browser. Only affects headless sessions.
1722
+ if (executor) {
1723
+ await executor.closeHeadlessContext();
1724
+ }
1665
1725
  const deleted = manager.deleteExecutor(sessionId);
1666
1726
  if (!deleted) {
1667
1727
  return c.json({ error: `Session ${sessionId} not found` }, 404);
1668
1728
  }
1729
+ // If this was a cloud-backed session, stop the VM only if no other
1730
+ // relay session is still using the same cloud VM (reference counting).
1731
+ const cloudTracking = cloudSessionTracking.get(sessionId);
1732
+ if (cloudTracking) {
1733
+ const shouldStopVm = !hasOtherCloudReferences(sessionId, cloudTracking.cloudSessionId);
1734
+ cloudSessionTracking.delete(sessionId);
1735
+ persistCloudSessions();
1736
+ if (shouldStopVm) {
1737
+ disconnectCloudVm(cloudTracking);
1738
+ }
1739
+ }
1669
1740
  return c.json({ success: true });
1670
1741
  }
1671
1742
  catch (error) {
@@ -1728,6 +1799,156 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1728
1799
  const result = await relay.cancelRecording(cancelParams);
1729
1800
  return c.json(result);
1730
1801
  });
1802
+ const cloudSessionTracking = new Map();
1803
+ const CLOUD_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
1804
+ /** Check if any OTHER relay session references the same cloud VM.
1805
+ * Used to prevent stopping a VM that's still used by another relay session
1806
+ * (e.g. user attached twice via `session new --browser cloud-1`). */
1807
+ function hasOtherCloudReferences(relaySessionId, cloudSessionId) {
1808
+ for (const [otherId, tracking] of cloudSessionTracking) {
1809
+ if (otherId !== relaySessionId && tracking.cloudSessionId === cloudSessionId) {
1810
+ return true;
1811
+ }
1812
+ }
1813
+ return false;
1814
+ }
1815
+ /** Disconnect a cloud VM via the website API (best-effort, non-blocking). */
1816
+ function disconnectCloudVm(tracking) {
1817
+ fetch(new URL('/api/cloud/disconnect', tracking.cloudBaseUrl).toString(), {
1818
+ method: 'POST',
1819
+ headers: {
1820
+ Authorization: `Bearer ${tracking.cloudToken}`,
1821
+ 'Content-Type': 'application/json',
1822
+ },
1823
+ body: JSON.stringify({ cloudSessionId: tracking.cloudSessionId }),
1824
+ }).catch((err) => {
1825
+ logger?.error('[Cloud] Failed to disconnect cloud session:', err);
1826
+ });
1827
+ }
1828
+ // ── Cloud session crash recovery ──────────────────────────────────
1829
+ // Persist cloud session IDs to disk so orphaned VMs can be cleaned up
1830
+ // if the relay process crashes. On startup, read the file and disconnect
1831
+ // any leftover VMs (best-effort).
1832
+ const CLOUD_SESSIONS_FILE = path.join(os.homedir(), '.playwriter', 'cloud-sessions.json');
1833
+ function persistCloudSessions() {
1834
+ // Dedupe by cloudSessionId — multiple relay sessions can reference the same VM
1835
+ const seen = new Set();
1836
+ const entries = [];
1837
+ for (const t of cloudSessionTracking.values()) {
1838
+ if (seen.has(t.cloudSessionId))
1839
+ continue;
1840
+ seen.add(t.cloudSessionId);
1841
+ entries.push({
1842
+ cloudSessionId: t.cloudSessionId,
1843
+ cloudBaseUrl: t.cloudBaseUrl,
1844
+ cloudToken: t.cloudToken,
1845
+ });
1846
+ }
1847
+ try {
1848
+ const dir = path.dirname(CLOUD_SESSIONS_FILE);
1849
+ fs.mkdirSync(dir, { recursive: true });
1850
+ if (entries.length > 0) {
1851
+ // Atomic write: write to temp file then rename, so a crash mid-write
1852
+ // doesn't leave corrupt JSON that blocks future cleanup.
1853
+ const tmpFile = CLOUD_SESSIONS_FILE + '.tmp';
1854
+ fs.writeFileSync(tmpFile, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 });
1855
+ fs.renameSync(tmpFile, CLOUD_SESSIONS_FILE);
1856
+ }
1857
+ else {
1858
+ // No active sessions — remove file to avoid stale data
1859
+ try {
1860
+ fs.unlinkSync(CLOUD_SESSIONS_FILE);
1861
+ }
1862
+ catch { /* already gone */ }
1863
+ }
1864
+ }
1865
+ catch {
1866
+ // Best-effort: don't crash relay if disk write fails
1867
+ }
1868
+ }
1869
+ function cleanupOrphanedCloudSessions() {
1870
+ let raw;
1871
+ try {
1872
+ raw = fs.readFileSync(CLOUD_SESSIONS_FILE, 'utf-8');
1873
+ }
1874
+ catch {
1875
+ return; // No file — nothing to clean up
1876
+ }
1877
+ let entries;
1878
+ try {
1879
+ const parsed = JSON.parse(raw);
1880
+ if (!Array.isArray(parsed))
1881
+ return;
1882
+ // Validate shape: each entry must have cloudSessionId and cloudBaseUrl
1883
+ entries = parsed.filter((e) => {
1884
+ return e && typeof e.cloudSessionId === 'string' && typeof e.cloudBaseUrl === 'string' && typeof e.cloudToken === 'string';
1885
+ });
1886
+ }
1887
+ catch {
1888
+ // Corrupt JSON (e.g. crash during non-atomic write) — just remove it
1889
+ try {
1890
+ fs.unlinkSync(CLOUD_SESSIONS_FILE);
1891
+ }
1892
+ catch { /* ignore */ }
1893
+ return;
1894
+ }
1895
+ if (!entries.length) {
1896
+ try {
1897
+ fs.unlinkSync(CLOUD_SESSIONS_FILE);
1898
+ }
1899
+ catch { /* ignore */ }
1900
+ return;
1901
+ }
1902
+ logger?.log(pc.yellow(`[Cloud] Found ${entries.length} orphaned cloud session(s) from previous relay. Cleaning up...`));
1903
+ // Remove file after we've read it — disconnect calls are best-effort async.
1904
+ // If they fail, the BU VM will eventually hit its own timeout anyway.
1905
+ try {
1906
+ fs.unlinkSync(CLOUD_SESSIONS_FILE);
1907
+ }
1908
+ catch { /* ignore */ }
1909
+ for (const entry of entries) {
1910
+ disconnectCloudVm({
1911
+ cloudSessionId: entry.cloudSessionId,
1912
+ cloudBaseUrl: entry.cloudBaseUrl,
1913
+ cloudToken: entry.cloudToken,
1914
+ lastActivityAt: 0,
1915
+ activeExecutions: 0,
1916
+ });
1917
+ }
1918
+ }
1919
+ const cloudIdleInterval = setInterval(async () => {
1920
+ const now = Date.now();
1921
+ // Collect idle sessions first, then process — avoid mutating map during iteration
1922
+ const idleSessions = [];
1923
+ for (const [sessionId, tracking] of cloudSessionTracking) {
1924
+ // VM already past BU hard timeout — schedule for cleanup regardless of activity
1925
+ if (tracking.timeoutAt && tracking.timeoutAt <= now) {
1926
+ idleSessions.push([sessionId, tracking]);
1927
+ continue;
1928
+ }
1929
+ // Timeout warnings are handled by the executor on each execute() call
1930
+ // (deduped by minute bucket) — no need to enqueue from the relay interval.
1931
+ if (tracking.activeExecutions > 0)
1932
+ continue;
1933
+ if (now - tracking.lastActivityAt > CLOUD_IDLE_TIMEOUT_MS) {
1934
+ idleSessions.push([sessionId, tracking]);
1935
+ }
1936
+ }
1937
+ if (idleSessions.length > 0) {
1938
+ for (const [sessionId, tracking] of idleSessions) {
1939
+ logger?.log(pc.yellow(`[Cloud] Stopping idle relay session ${sessionId} (idle > 10 min)`));
1940
+ // Check if other relay sessions reference the same cloud VM.
1941
+ // Only stop the VM when this is the last relay session for it.
1942
+ const shouldStopVm = !hasOtherCloudReferences(sessionId, tracking.cloudSessionId);
1943
+ cloudSessionTracking.delete(sessionId);
1944
+ executorManager?.deleteExecutor(sessionId);
1945
+ if (shouldStopVm) {
1946
+ disconnectCloudVm(tracking);
1947
+ }
1948
+ }
1949
+ persistCloudSessions();
1950
+ }
1951
+ }, 60_000);
1731
1952
  // Use createAdaptorServer instead of serve() so we control the listen()
1732
1953
  // timing. This lets us inject WebSocket upgrade handlers before binding and
1733
1954
  // await the bind to surface EADDRINUSE as a catchable error (issue #75).
@@ -1746,6 +1967,10 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1746
1967
  server.once('error', onError);
1747
1968
  server.listen(port, host);
1748
1969
  });
1970
+ // Clean up orphaned cloud sessions from a previous relay crash.
1971
+ // Must run AFTER successful listen — if another relay is already running,
1972
+ // we'd fail with EADDRINUSE but only after killing its live VMs.
1973
+ cleanupOrphanedCloudSessions();
1749
1974
  const wsHost = `ws://${host}:${port}`;
1750
1975
  const cdpEndpoint = `${wsHost}/cdp`;
1751
1976
  const extensionEndpoint = `${wsHost}/extension`;
@@ -1766,11 +1991,18 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1766
1991
  }
1767
1992
  ext.ws?.close(1000, 'Server stopped');
1768
1993
  }
1994
+ // Close shared headless browser if any headless sessions were created (fire-and-forget)
1995
+ void import('./executor.js').then(({ PlaywrightExecutor }) => {
1996
+ return PlaywrightExecutor.closeSharedHeadlessBrowser();
1997
+ });
1769
1998
  // Reset store state
1770
1999
  store.setState({
1771
2000
  extensions: new Map(),
1772
2001
  playwrightClients: new Map(),
1773
2002
  });
2003
+ clearInterval(cloudIdleInterval);
2004
+ cloudSessionTracking.clear();
2005
+ persistCloudSessions(); // Remove the file on graceful shutdown
1774
2006
  server.close();
1775
2007
  emitter.removeAllListeners();
1776
2008
  },