@@ -30,6 +30,10 @@ const pending = new Map()
3030/** @type {Set<number> } */
3131const tabOperationLocks = new Set ( )
3232
33+ // Tabs currently in a detach/re-attach cycle after navigation.
34+ /** @type {Set<number> } */
35+ const reattachPending = new Set ( )
36+
3337// Reconnect state for exponential backoff.
3438let reconnectAttempt = 0
3539let reconnectTimer = null
@@ -190,6 +194,8 @@ function onRelayClosed(reason) {
190194 p . reject ( new Error ( `Relay disconnected (${ reason } )` ) )
191195 }
192196
197+ reattachPending . clear ( )
198+
193199 for ( const [ tabId , tab ] of tabs . entries ( ) ) {
194200 if ( tab . state === 'connected' ) {
195201 setBadge ( tabId , 'connecting' )
@@ -493,6 +499,16 @@ async function connectOrToggleForActiveTab() {
493499 tabOperationLocks . add ( tabId )
494500
495501 try {
502+ if ( reattachPending . has ( tabId ) ) {
503+ reattachPending . delete ( tabId )
504+ setBadge ( tabId , 'off' )
505+ void chrome . action . setTitle ( {
506+ tabId,
507+ title : 'OpenClaw Browser Relay (click to attach/detach)' ,
508+ } )
509+ return
510+ }
511+
496512 const existing = tabs . get ( tabId )
497513 if ( existing ?. state === 'connected' ) {
498514 await detachTab ( tabId , 'toggle' )
@@ -632,50 +648,106 @@ function onDebuggerEvent(source, method, params) {
632648 }
633649}
634650
635- // Navigation/reload fires target_closed but the tab is still alive — Chrome
636- // just swaps the renderer process. Suppress the detach event to the relay and
637- // seamlessly re-attach after a short grace period.
638- function onDebuggerDetach ( source , reason ) {
651+ async function onDebuggerDetach ( source , reason ) {
639652 const tabId = source . tabId
640653 if ( ! tabId ) return
641654 if ( ! tabs . has ( tabId ) ) return
642655
643- if ( reason === 'target_closed' ) {
644- const oldState = tabs . get ( tabId )
645- setBadge ( tabId , 'connecting' )
646- void chrome . action . setTitle ( {
647- tabId,
648- title : 'OpenClaw Browser Relay: re-attaching after navigation…' ,
649- } )
656+ if ( reason === 'canceled_by_user' || reason === 'replaced_with_devtools' ) {
657+ void detachTab ( tabId , reason )
658+ return
659+ }
650660
651- setTimeout ( async ( ) => {
652- try {
653- // If user manually detached during the grace period, bail out.
654- if ( ! tabs . has ( tabId ) ) return
655- const tab = await chrome . tabs . get ( tabId )
656- if ( tab && relayWs ?. readyState === WebSocket . OPEN ) {
657- console . log ( `Re-attaching tab ${ tabId } after navigation` )
658- if ( oldState ?. sessionId ) tabBySession . delete ( oldState . sessionId )
659- tabs . delete ( tabId )
660- await attachTab ( tabId , { skipAttachedEvent : false } )
661- } else {
662- // Tab gone or relay down — full cleanup.
663- void detachTab ( tabId , reason )
664- }
665- } catch ( err ) {
666- console . warn ( `Failed to re-attach tab ${ tabId } after navigation:` , err . message )
667- void detachTab ( tabId , reason )
668- }
669- } , 500 )
661+ let tabInfo
662+ try {
663+ tabInfo = await chrome . tabs . get ( tabId )
664+ } catch {
665+ void detachTab ( tabId , reason )
670666 return
671667 }
672668
673- // Non-navigation detach (user action, crash, etc.) — full cleanup.
674- void detachTab ( tabId , reason )
669+ if ( tabInfo . url ?. startsWith ( 'chrome://' ) || tabInfo . url ?. startsWith ( 'chrome-extension://' ) ) {
670+ void detachTab ( tabId , reason )
671+ return
672+ }
673+
674+ if ( reattachPending . has ( tabId ) ) return
675+
676+ const oldTab = tabs . get ( tabId )
677+ const oldSessionId = oldTab ?. sessionId
678+ const oldTargetId = oldTab ?. targetId
679+
680+ if ( oldSessionId ) tabBySession . delete ( oldSessionId )
681+ tabs . delete ( tabId )
682+ for ( const [ childSessionId , parentTabId ] of childSessionToTab . entries ( ) ) {
683+ if ( parentTabId === tabId ) childSessionToTab . delete ( childSessionId )
684+ }
685+
686+ if ( oldSessionId && oldTargetId ) {
687+ try {
688+ sendToRelay ( {
689+ method : 'forwardCDPEvent' ,
690+ params : {
691+ method : 'Target.detachedFromTarget' ,
692+ params : { sessionId : oldSessionId , targetId : oldTargetId , reason : 'navigation-reattach' } ,
693+ } ,
694+ } )
695+ } catch {
696+ // Relay may be down.
697+ }
698+ }
699+
700+ reattachPending . add ( tabId )
701+ setBadge ( tabId , 'connecting' )
702+ void chrome . action . setTitle ( {
703+ tabId,
704+ title : 'OpenClaw Browser Relay: re-attaching after navigation…' ,
705+ } )
706+
707+ const delays = [ 300 , 700 , 1500 ]
708+ for ( let attempt = 0 ; attempt < delays . length ; attempt ++ ) {
709+ await new Promise ( ( r ) => setTimeout ( r , delays [ attempt ] ) )
710+
711+ if ( ! reattachPending . has ( tabId ) ) return
712+
713+ try {
714+ await chrome . tabs . get ( tabId )
715+ } catch {
716+ reattachPending . delete ( tabId )
717+ setBadge ( tabId , 'off' )
718+ return
719+ }
720+
721+ if ( ! relayWs || relayWs . readyState !== WebSocket . OPEN ) {
722+ reattachPending . delete ( tabId )
723+ setBadge ( tabId , 'error' )
724+ void chrome . action . setTitle ( {
725+ tabId,
726+ title : 'OpenClaw Browser Relay: relay disconnected during re-attach' ,
727+ } )
728+ return
729+ }
730+
731+ try {
732+ await attachTab ( tabId )
733+ reattachPending . delete ( tabId )
734+ return
735+ } catch {
736+ // continue retries
737+ }
738+ }
739+
740+ reattachPending . delete ( tabId )
741+ setBadge ( tabId , 'off' )
742+ void chrome . action . setTitle ( {
743+ tabId,
744+ title : 'OpenClaw Browser Relay: re-attach failed (click to retry)' ,
745+ } )
675746}
676747
677748// Tab lifecycle listeners — clean up stale entries.
678749chrome . tabs . onRemoved . addListener ( ( tabId ) => void whenReady ( ( ) => {
750+ reattachPending . delete ( tabId )
679751 if ( ! tabs . has ( tabId ) ) return
680752 const tab = tabs . get ( tabId )
681753 if ( tab ?. sessionId ) tabBySession . delete ( tab . sessionId )
0 commit comments