@@ -134,7 +134,11 @@ export function useMonitoring({
134134 const [ appState , setAppState ] = useState < AppStateStatus > ( AppState . currentState )
135135
136136 const foregroundMonitorAbortRef = useRef < AbortController | null > ( null )
137+ const foregroundPollIntervalRef = useRef < ReturnType < typeof setInterval > | null > ( null )
137138 const monitorJobRef = useRef < MonitorJob | null > ( null )
139+ const syncSessionStateRef = useRef <
140+ ( ( input : { serverID : string ; sessionID : string ; preserveStatusLabel ?: boolean } ) => Promise < void > ) | null
141+ > ( null )
138142 const pendingNotificationEventsRef = useRef < { payload : NotificationPayload ; source : "received" | "response" } [ ] > ( [ ] )
139143 const notificationHandlerRef = useRef < ( payload : NotificationPayload , source : "received" | "response" ) => void > (
140144 ( payload , source ) => {
@@ -240,6 +244,10 @@ export function useMonitoring({
240244 aborter . abort ( )
241245 foregroundMonitorAbortRef . current = null
242246 }
247+ if ( foregroundPollIntervalRef . current ) {
248+ clearInterval ( foregroundPollIntervalRef . current )
249+ foregroundPollIntervalRef . current = null
250+ }
243251 } , [ ] )
244252
245253 const loadLatestAssistantResponse = useCallback (
@@ -378,52 +386,89 @@ export function useMonitoring({
378386
379387 const base = job . opencodeBaseURL . replace ( / \/ + $ / , "" )
380388
381- void ( async ( ) => {
382- try {
383- const response = await expoFetch ( `${ base } /event` , {
384- signal : abortController . signal ,
385- headers : {
386- Accept : "text/event-stream" ,
387- "Cache-Control" : "no-cache" ,
388- } ,
389- } )
390-
391- if ( ! response . ok || ! response . body ) {
392- throw new Error ( `SSE monitor failed (${ response . status } )` )
393- }
394-
395- for await ( const message of parseSSEStream ( response . body ) ) {
396- let parsed : OpenCodeEvent | null = null
397- try {
398- parsed = JSON . parse ( message . data ) as OpenCodeEvent
399- } catch {
400- continue
389+ // SSE stream with automatic recovery on failure or natural close
390+ const connectSSE = ( ) => {
391+ void ( async ( ) => {
392+ try {
393+ const response = await expoFetch ( `${ base } /event` , {
394+ signal : abortController . signal ,
395+ headers : {
396+ Accept : "text/event-stream" ,
397+ "Cache-Control" : "no-cache" ,
398+ } ,
399+ } )
400+
401+ if ( ! response . ok || ! response . body ) {
402+ throw new Error ( `SSE monitor failed (${ response . status } )` )
401403 }
402404
403- if ( ! parsed ) continue
404- const sessionID = extractSessionID ( parsed )
405- if ( sessionID !== job . sessionID ) continue
405+ for await ( const message of parseSSEStream ( response . body ) ) {
406+ let parsed : OpenCodeEvent | null = null
407+ try {
408+ parsed = JSON . parse ( message . data ) as OpenCodeEvent
409+ } catch {
410+ continue
411+ }
412+
413+ if ( ! parsed ) continue
414+ const sessionID = extractSessionID ( parsed )
415+ if ( sessionID !== job . sessionID ) continue
406416
407- if ( parsed . type === "permission.asked" ) {
408- const request = parsePendingPermissionRequest ( parsed . properties )
409- if ( request ) {
410- upsertPendingPermission ( request )
417+ if ( parsed . type === "permission.asked" ) {
418+ const request = parsePendingPermissionRequest ( parsed . properties )
419+ if ( request ) {
420+ upsertPendingPermission ( request )
421+ }
411422 }
412- }
413423
414- const eventType = classifyMonitorEvent ( parsed )
415- if ( ! eventType ) continue
424+ const eventType = classifyMonitorEvent ( parsed )
425+ if ( ! eventType ) continue
426+
427+ const active = monitorJobRef . current
428+ if ( ! active || active . id !== job . id ) return
429+ handleMonitorEvent ( eventType , job )
430+ }
416431
417- const active = monitorJobRef . current
418- if ( ! active || active . id !== job . id ) return
419- handleMonitorEvent ( eventType , job )
432+ // Stream ended naturally (server closed connection) -- fall through to recovery
433+ } catch {
434+ if ( abortController . signal . aborted ) return
435+ // SSE failed (network drop, server restart, etc.) -- fall through to recovery
420436 }
421- } catch {
437+
438+ // Recovery: if this job is still active and we weren't explicitly aborted, poll session status
422439 if ( abortController . signal . aborted ) return
440+ const active = monitorJobRef . current
441+ if ( ! active || active . id !== job . id ) return
442+
443+ const serverID = activeServerIdRef . current
444+ const sessionID = activeSessionIdRef . current
445+ if ( serverID && sessionID ) {
446+ void syncSessionStateRef . current ?.( { serverID, sessionID } )
447+ }
448+ } ) ( )
449+ }
450+
451+ connectSSE ( )
452+
453+ // Periodic polling fallback: check session status every 20s in case SSE silently drops
454+ foregroundPollIntervalRef . current = setInterval ( ( ) => {
455+ const active = monitorJobRef . current
456+ if ( ! active || active . id !== job . id ) {
457+ if ( foregroundPollIntervalRef . current ) {
458+ clearInterval ( foregroundPollIntervalRef . current )
459+ foregroundPollIntervalRef . current = null
460+ }
461+ return
423462 }
424- } ) ( )
463+
464+ const serverID = activeServerIdRef . current
465+ const sessionID = activeSessionIdRef . current
466+ if ( serverID && sessionID ) {
467+ void syncSessionStateRef . current ?.( { serverID, sessionID, preserveStatusLabel : true } )
468+ }
469+ } , 20_000 )
425470 } ,
426- [ handleMonitorEvent , stopForegroundMonitor , upsertPendingPermission ] ,
471+ [ activeServerIdRef , activeSessionIdRef , handleMonitorEvent , stopForegroundMonitor , upsertPendingPermission ] ,
427472 )
428473
429474 const beginMonitoring = useCallback (
@@ -518,9 +563,24 @@ export function useMonitoring({
518563 if ( ! input . preserveStatusLabel ) {
519564 setMonitorStatus ( "" )
520565 }
566+ return
567+ }
568+
569+ // runtimeStatus is null (fetch failed or unparseable) -- retry after a short delay
570+ // if a monitor job is still active, so we don't leave the user stuck
571+ if ( runtimeStatus === null && monitorJobRef . current ) {
572+ setTimeout ( ( ) => {
573+ const serverID = activeServerIdRef . current
574+ const sessionID = activeSessionIdRef . current
575+ if ( serverID && sessionID && monitorJobRef . current ) {
576+ void syncSessionStateRef . current ?.( { serverID, sessionID } )
577+ }
578+ } , 5_000 )
521579 }
522580 } ,
523581 [
582+ activeServerIdRef ,
583+ activeSessionIdRef ,
524584 appState ,
525585 fetchSessionRuntimeStatus ,
526586 loadLatestAssistantResponse ,
@@ -532,6 +592,10 @@ export function useMonitoring({
532592 ] ,
533593 )
534594
595+ useEffect ( ( ) => {
596+ syncSessionStateRef . current = syncSessionState
597+ } , [ syncSessionState ] )
598+
535599 const handleNotificationPayload = useCallback (
536600 async ( payload : NotificationPayload , source : "received" | "response" ) => {
537601 const activeServer = activeServerIdRef . current
0 commit comments