@@ -141,9 +141,18 @@ export class McpContext implements Context {
141141 // Idle page reaper: tracks last activity time per page.
142142 static readonly PAGE_IDLE_TIMEOUT_MS = 5 * 60 * 1000 ; // 5 minutes
143143 static readonly IDLE_CHECK_INTERVAL_MS = 60 * 1000 ; // 60 seconds
144+ static readonly #INTERNAL_URL_PREFIXES = [
145+ 'about:' ,
146+ 'chrome://' ,
147+ 'chrome-extension://' ,
148+ 'chrome-untrusted://' ,
149+ 'devtools://' ,
150+ ] ;
144151 #pageLastActivity = new Map < Page , number > ( ) ;
145152 #idleReaperTimer?: ReturnType < typeof setInterval > ;
146153 #onIdleBrowserEmpty?: ( ) => void ;
154+ // Tracks when we first noticed only internal pages remaining.
155+ #onlyInternalPagesSince?: number ;
147156
148157 #uniqueBackendNodeIdToMcpId = new Map < string , string > ( ) ;
149158
@@ -195,6 +204,8 @@ export class McpContext implements Context {
195204 */
196205 touchPage ( page : Page ) : void {
197206 this . #pageLastActivity. set ( page , Date . now ( ) ) ;
207+ // A user is interacting with a page — reset the internal-only timer.
208+ this . #onlyInternalPagesSince = undefined ;
198209 }
199210
200211 /**
@@ -234,6 +245,13 @@ export class McpContext implements Context {
234245 }
235246 }
236247
248+ static #isInternalPage( page : Page ) : boolean {
249+ const url = page . url ( ) ;
250+ return McpContext . #INTERNAL_URL_PREFIXES. some ( prefix =>
251+ url . startsWith ( prefix ) ,
252+ ) ;
253+ }
254+
237255 async #reapIdlePages( ) : Promise < void > {
238256 const now = Date . now ( ) ;
239257 const idleTimeout = McpContext . PAGE_IDLE_TIMEOUT_MS ;
@@ -245,8 +263,21 @@ export class McpContext implements Context {
245263 return ; // Browser may be disconnected.
246264 }
247265
266+ // Also get ALL pages from the browser (including those filtered by
267+ // createPagesSnapshot) so we can detect internal-only state.
268+ let allBrowserPages : Page [ ] ;
269+ try {
270+ allBrowserPages = await this . browser . pages ( true ) ;
271+ } catch {
272+ return ;
273+ }
274+
248275 const pagesToClose : Page [ ] = [ ] ;
249276 for ( const page of pages ) {
277+ // Skip internal pages — they can't be closed and will respawn.
278+ if ( McpContext . #isInternalPage( page ) ) {
279+ continue ;
280+ }
250281 const lastActivity = this . #pageLastActivity. get ( page ) ;
251282 // Pages without a recorded timestamp get one now (first seen).
252283 if ( lastActivity === undefined ) {
@@ -258,11 +289,7 @@ export class McpContext implements Context {
258289 }
259290 }
260291
261- if ( pagesToClose . length === 0 ) {
262- return ;
263- }
264-
265- // Close idle pages. If ALL pages are idle, close them all.
292+ // Close idle user pages.
266293 for ( const page of pagesToClose ) {
267294 try {
268295 this . logger (
@@ -275,16 +302,50 @@ export class McpContext implements Context {
275302 }
276303 }
277304
278- // Refresh after closing .
305+ // Re-check all browser pages to determine if only internal pages remain .
279306 try {
280- pages = await this . createPagesSnapshot ( ) ;
307+ allBrowserPages = await this . browser . pages ( true ) ;
281308 } catch {
282- pages = [ ] ;
309+ allBrowserPages = [ ] ;
283310 }
284311
285- if ( pages . length === 0 && this . #onIdleBrowserEmpty) {
312+ const hasUserPages = allBrowserPages . some (
313+ p => ! p . isClosed ( ) && ! McpContext . #isInternalPage( p ) ,
314+ ) ;
315+
316+ if ( hasUserPages ) {
317+ // User pages still exist — reset the internal-only timer.
318+ this . #onlyInternalPagesSince = undefined ;
319+ return ;
320+ }
321+
322+ // Only internal pages (or no pages) remain.
323+ if ( ! this . #onIdleBrowserEmpty) {
324+ return ;
325+ }
326+
327+ if ( allBrowserPages . length === 0 ) {
328+ // Truly empty — kill immediately.
286329 this . logger ( 'All pages closed by idle reaper, triggering browser cleanup' ) ;
287330 this . #onIdleBrowserEmpty( ) ;
331+ return ;
332+ }
333+
334+ // Internal pages only — start or check the grace timer.
335+ if ( this . #onlyInternalPagesSince === undefined ) {
336+ this . #onlyInternalPagesSince = now ;
337+ this . logger (
338+ `Only internal pages remain (${ allBrowserPages . length } ), starting ${ idleTimeout / 1000 } s grace period` ,
339+ ) ;
340+ return ;
341+ }
342+
343+ if ( now - this . #onlyInternalPagesSince > idleTimeout ) {
344+ this . logger (
345+ `Only internal pages for ${ Math . round ( ( now - this . #onlyInternalPagesSince) / 1000 ) } s, triggering browser cleanup` ,
346+ ) ;
347+ this . #onlyInternalPagesSince = undefined ;
348+ this . #onIdleBrowserEmpty( ) ;
288349 }
289350 }
290351
0 commit comments