@@ -26,6 +26,46 @@ const STABLE_CONNECTION_WINDOW_MS = 10_000;
2626const STABLE_CONNECTION_TIMEOUT_MS = 45_000 ;
2727const PAIRING_RELOAD_FLAG = "nemoclaw:pairing-bootstrap-reloaded" ;
2828const FORCED_RELOAD_DELAY_MS = 1_000 ;
29+ const PAIRING_STATUS_POLL_MS = 500 ;
30+ const PAIRING_REARM_INTERVAL_MS = 4_000 ;
31+ const OVERLAY_SHOW_DELAY_MS = 400 ;
32+ const PAIRING_BOOTSTRAPPED_FLAG = "nemoclaw:pairing-bootstrap-complete" ;
33+ const POST_READY_SETTLE_MS = 750 ;
34+ const WARM_START_CONNECTION_WINDOW_MS = 500 ;
35+ const WARM_START_TIMEOUT_MS = 2_500 ;
36+ const READINESS_HANDLED = Symbol ( "pairing-bootstrap-readiness-handled" ) ;
37+
38+ interface PairingBootstrapState {
39+ status ?: string ;
40+ approvedCount ?: number ;
41+ active ?: boolean ;
42+ lastApprovalDeviceId ?: string ;
43+ lastError ?: string ;
44+ sawBrowserPaired ?: boolean ;
45+ }
46+
47+ const PAIRING_STATUS_PRIORITY : Record < string , number > = {
48+ idle : 0 ,
49+ armed : 1 ,
50+ pending : 2 ,
51+ approving : 3 ,
52+ "approved-pending-settle" : 4 ,
53+ "paired-other-device" : 5 ,
54+ paired : 6 ,
55+ timeout : 7 ,
56+ error : 7 ,
57+ } ;
58+
59+ function isPairingTerminal ( state : PairingBootstrapState | null ) : boolean {
60+ if ( ! state ) return false ;
61+ if ( state . active ) return false ;
62+ return state . status === "paired" || state . status === "timeout" || state . status === "error" ;
63+ }
64+
65+ function isPairingRecoveryEligible ( state : PairingBootstrapState | null ) : boolean {
66+ if ( ! state ) return false ;
67+ return state . status === "paired" ;
68+ }
2969
3070function inject ( ) : boolean {
3171 const hasButton = injectButton ( ) ;
@@ -71,6 +111,7 @@ function setConnectOverlayText(text: string): void {
71111}
72112
73113function revealApp ( ) : void {
114+ markPairingBootstrapped ( ) ;
74115 document . body . setAttribute ( "data-nemoclaw-ready" , "" ) ;
75116 const overlay = document . querySelector ( ".nemoclaw-connect-overlay" ) ;
76117 if ( overlay ) {
@@ -80,82 +121,274 @@ function revealApp(): void {
80121 startDenialWatcher ( ) ;
81122}
82123
83- function shouldForcePairingReload ( ) : boolean {
124+ function shouldAllowRecoveryReload ( ) : boolean {
84125 try {
85126 return sessionStorage . getItem ( PAIRING_RELOAD_FLAG ) !== "1" ;
86127 } catch {
87128 return true ;
88129 }
89130}
90131
91- function markPairingReloadComplete ( ) : void {
132+ function isPairingBootstrapped ( ) : boolean {
92133 try {
93- sessionStorage . setItem ( PAIRING_RELOAD_FLAG , "1" ) ;
134+ return sessionStorage . getItem ( PAIRING_BOOTSTRAPPED_FLAG ) === "1" ;
135+ } catch {
136+ return false ;
137+ }
138+ }
139+
140+ function markPairingBootstrapped ( ) : void {
141+ try {
142+ sessionStorage . setItem ( PAIRING_BOOTSTRAPPED_FLAG , "1" ) ;
94143 } catch {
95144 // ignore storage failures
96145 }
97146}
98147
99- function clearPairingReloadFlag ( ) : void {
148+ function markRecoveryReloadUsed ( ) : void {
100149 try {
101- sessionStorage . removeItem ( PAIRING_RELOAD_FLAG ) ;
150+ sessionStorage . setItem ( PAIRING_RELOAD_FLAG , "1" ) ;
102151 } catch {
103152 // ignore storage failures
104153 }
105154}
106155
107- function forcePairingReload ( reason : string , overlayText : string ) : void {
108- console . info ( `[NeMoClaw] pairing bootstrap: forcing one-time reload (${ reason } )` ) ;
109- markPairingReloadComplete ( ) ;
110- setConnectOverlayText ( overlayText ) ;
111- window . setTimeout ( ( ) => window . location . reload ( ) , FORCED_RELOAD_DELAY_MS ) ;
156+ async function fetchPairingBootstrapState ( method : "GET" | "POST" ) : Promise < PairingBootstrapState | null > {
157+ try {
158+ const res = await fetch ( "/api/pairing-bootstrap" , { method } ) ;
159+ if ( ! res . ok ) return null ;
160+ return ( await res . json ( ) ) as PairingBootstrapState ;
161+ } catch {
162+ return null ;
163+ }
164+ }
165+
166+ function getOverlayTextForPairingState ( state : PairingBootstrapState | null ) : string | null {
167+ switch ( state ?. status ) {
168+ case "armed" :
169+ return "Preparing device pairing bootstrap..." ;
170+ case "pending" :
171+ return "Waiting for device pairing request..." ;
172+ case "approving" :
173+ return "Approving device pairing..." ;
174+ case "approved-pending-settle" :
175+ return "Device pairing approved. Waiting for dashboard device to finish pairing..." ;
176+ case "paired-other-device" :
177+ return "Pairing another device. Waiting for browser dashboard pairing..." ;
178+ case "paired" :
179+ return "Device paired. Finalizing dashboard..." ;
180+ case "approved" :
181+ return "Device pairing approved. Waiting for browser dashboard pairing..." ;
182+ case "timeout" :
183+ return "Pairing bootstrap timed out. Opening dashboard..." ;
184+ case "error" :
185+ return "Pairing bootstrap hit an error. Opening dashboard..." ;
186+ default :
187+ return null ;
188+ }
112189}
113190
114191function bootstrap ( ) {
115192 console . info ( "[NeMoClaw] pairing bootstrap: start" ) ;
116- showConnectOverlay ( ) ;
117193
118- const finalizeConnectedState = async ( ) => {
119- setConnectOverlayText ( "Device pairing approved. Finalizing dashboard..." ) ;
120- console . info ( "[NeMoClaw] pairing bootstrap: reconnect detected" ) ;
121- if ( shouldForcePairingReload ( ) ) {
122- forcePairingReload ( "post-reconnect" , "Device pairing approved. Reloading dashboard..." ) ;
123- return ;
194+ let pairingPollTimer = 0 ;
195+ let overlayTimer = 0 ;
196+ let stopped = false ;
197+ let dashboardStable = false ;
198+ let latestPairingState : PairingBootstrapState | null = null ;
199+ let lastPairingStartAt = 0 ;
200+ let overlayVisible = false ;
201+ let overlayPriority = - 1 ;
202+
203+ const stopPairingPoll = ( ) => {
204+ stopped = true ;
205+ if ( pairingPollTimer ) window . clearTimeout ( pairingPollTimer ) ;
206+ if ( overlayTimer ) window . clearTimeout ( overlayTimer ) ;
207+ } ;
208+
209+ const ensureOverlayVisible = ( ) => {
210+ if ( overlayVisible ) return ;
211+ overlayVisible = true ;
212+ showConnectOverlay ( ) ;
213+ } ;
214+
215+ const setMonotonicOverlayText = ( text : string | null , status ?: string ) => {
216+ if ( ! text ) return ;
217+ const nextPriority = PAIRING_STATUS_PRIORITY [ status || "" ] ?? overlayPriority ;
218+ if ( nextPriority < overlayPriority ) return ;
219+ overlayPriority = nextPriority ;
220+ setConnectOverlayText ( text ) ;
221+ } ;
222+
223+ const scheduleOverlay = ( ) => {
224+ if ( overlayVisible || overlayTimer ) return ;
225+ overlayTimer = window . setTimeout ( ( ) => {
226+ overlayTimer = 0 ;
227+ ensureOverlayVisible ( ) ;
228+ } , OVERLAY_SHOW_DELAY_MS ) ;
229+ } ;
230+
231+ const pollPairingState = async ( ) => {
232+ if ( stopped ) return null ;
233+ const state = await fetchPairingBootstrapState ( "GET" ) ;
234+ latestPairingState = state ;
235+ const text = getOverlayTextForPairingState ( state ) ;
236+ setMonotonicOverlayText ( text , state ?. status ) ;
237+
238+ if (
239+ ! stopped &&
240+ ! dashboardStable &&
241+ state &&
242+ ! state . active &&
243+ ! isPairingTerminal ( state ) &&
244+ Date . now ( ) - lastPairingStartAt >= PAIRING_REARM_INTERVAL_MS
245+ ) {
246+ const rearmed = await fetchPairingBootstrapState ( "POST" ) ;
247+ if ( rearmed ) {
248+ latestPairingState = rearmed ;
249+ lastPairingStartAt = Date . now ( ) ;
250+ const rearmedText = getOverlayTextForPairingState ( rearmed ) ;
251+ setMonotonicOverlayText ( rearmedText , rearmed . status ) ;
252+ }
253+ }
254+
255+ pairingPollTimer = window . setTimeout ( pollPairingState , PAIRING_STATUS_POLL_MS ) ;
256+ return state ;
257+ } ;
258+
259+ const waitForDashboardReadiness = async ( timeoutMs : number , overlayText : string ) => {
260+ ensureOverlayVisible ( ) ;
261+ setConnectOverlayText ( overlayText ) ;
262+ await waitForStableConnection ( STABLE_CONNECTION_WINDOW_MS , timeoutMs ) ;
263+ } ;
264+
265+ const handlePairingTerminalWithoutStableConnection = async ( reason : string ) => {
266+ const state = latestPairingState || ( await fetchPairingBootstrapState ( "GET" ) ) ;
267+ const status = state ?. status || "unknown" ;
268+ if ( isPairingRecoveryEligible ( state ) && shouldAllowRecoveryReload ( ) ) {
269+ console . warn ( `[NeMoClaw] pairing bootstrap: ${ reason } ; pairing=${ status } ; forcing one recovery reload` ) ;
270+ stopPairingPoll ( ) ;
271+ markRecoveryReloadUsed ( ) ;
272+ setConnectOverlayText ( "Pairing succeeded. Recovering dashboard..." ) ;
273+ window . setTimeout ( ( ) => window . location . reload ( ) , 750 ) ;
274+ return true ;
124275 }
125- setConnectOverlayText ( "Device pairing approved. Verifying dashboard health..." ) ;
126- try {
127- console . info ( "[NeMoClaw] pairing bootstrap: waiting for stable post-reload connection" ) ;
128- await waitForStableConnection (
129- STABLE_CONNECTION_WINDOW_MS ,
130- STABLE_CONNECTION_TIMEOUT_MS ,
131- ) ;
132- } catch {
133- console . warn ( "[NeMoClaw] pairing bootstrap: stable post-reload connection check timed out; delaying reveal" ) ;
134- await new Promise ( ( resolve ) => setTimeout ( resolve , POST_PAIRING_SETTLE_DELAY_MS ) ) ;
276+ if ( isPairingTerminal ( state ) ) {
277+ console . warn ( `[NeMoClaw] pairing bootstrap: ${ reason } ; pairing=${ status } ; revealing app without further delay` ) ;
278+ stopPairingPoll ( ) ;
279+ revealApp ( ) ;
280+ return true ;
135281 }
136- console . info ( "[NeMoClaw] pairing bootstrap: reveal app" ) ;
137- clearPairingReloadFlag ( ) ;
138- revealApp ( ) ;
282+ return false ;
139283 } ;
140284
141- waitForReconnect ( INITIAL_CONNECT_TIMEOUT_MS )
142- . then ( finalizeConnectedState )
143- . catch ( async ( ) => {
144- console . warn ( "[NeMoClaw] pairing bootstrap: initial reconnect timed out; extending wait" ) ;
145- if ( shouldForcePairingReload ( ) ) {
146- forcePairingReload ( "initial-timeout" , "Pairing is still settling. Reloading dashboard..." ) ;
147- return ;
285+ const runReadinessFlow = ( ) => {
286+ waitForDashboardReadiness (
287+ INITIAL_CONNECT_TIMEOUT_MS ,
288+ "Auto-approving device pairing. Hang tight..." ,
289+ )
290+ . catch ( async ( ) => {
291+ console . warn ( "[NeMoClaw] pairing bootstrap: initial dashboard readiness check timed out; extending wait" ) ;
292+ if ( await handlePairingTerminalWithoutStableConnection ( "initial readiness timed out" ) ) {
293+ throw READINESS_HANDLED ;
294+ }
295+ return waitForDashboardReadiness (
296+ EXTENDED_CONNECT_TIMEOUT_MS ,
297+ "Still waiting for device pairing approval..." ,
298+ ) ;
299+ } )
300+ . then ( async ( ) => {
301+ await new Promise ( ( resolve ) => window . setTimeout ( resolve , POST_READY_SETTLE_MS ) ) ;
302+ const settledState = await fetchPairingBootstrapState ( "GET" ) ;
303+ if ( settledState ) latestPairingState = settledState ;
304+
305+ dashboardStable = true ;
306+ console . info ( "[NeMoClaw] pairing bootstrap: reveal app" ) ;
307+ stopPairingPoll ( ) ;
308+ setConnectOverlayText ( "Device pairing approved. Opening dashboard..." ) ;
309+ revealApp ( ) ;
310+ } )
311+ . catch ( async ( err : unknown ) => {
312+ if ( err === READINESS_HANDLED ) return ;
313+ if ( stopped ) return ;
314+ if ( dashboardStable ) return ;
315+ if ( await handlePairingTerminalWithoutStableConnection ( "extended readiness timed out" ) ) {
316+ return ;
317+ }
318+ const state = latestPairingState || ( await fetchPairingBootstrapState ( "GET" ) ) ;
319+ const status = state ?. status || "unknown" ;
320+ console . warn ( `[NeMoClaw] pairing bootstrap: readiness timed out; revealing app anyway (status=${ status } )` ) ;
321+ stopPairingPoll ( ) ;
322+ revealApp ( ) ;
323+ } ) ;
324+ } ;
325+
326+ void ( async ( ) => {
327+ const initialState = await fetchPairingBootstrapState ( "GET" ) ;
328+ latestPairingState = initialState ;
329+
330+ if ( initialState && ! initialState . active && isPairingTerminal ( initialState ) ) {
331+ const shouldWarmStart = isPairingBootstrapped ( ) || initialState . status === "paired" ;
332+ if ( shouldWarmStart ) {
333+ try {
334+ await waitForStableConnection ( WARM_START_CONNECTION_WINDOW_MS , WARM_START_TIMEOUT_MS ) ;
335+ console . info ( "[NeMoClaw] pairing bootstrap: warm start succeeded" ) ;
336+ stopPairingPoll ( ) ;
337+ revealApp ( ) ;
338+ return ;
339+ } catch {
340+ // Fall through to normal bootstrap flow.
341+ }
148342 }
149- setConnectOverlayText ( "Still waiting for device pairing approval..." ) ;
343+ }
344+
345+ if ( initialState === null ) {
346+ // Endpoint missing or failed — fall back to reconnect-only flow.
347+ showConnectOverlay ( ) ;
150348 try {
151- await waitForReconnect ( EXTENDED_CONNECT_TIMEOUT_MS ) ;
152- await finalizeConnectedState ( ) ;
349+ await waitForReconnect ( INITIAL_CONNECT_TIMEOUT_MS ) ;
350+ setConnectOverlayText ( "Device pairing approved. Finalizing dashboard..." ) ;
351+ if ( shouldAllowRecoveryReload ( ) ) {
352+ markRecoveryReloadUsed ( ) ;
353+ setConnectOverlayText ( "Device pairing approved. Reloading dashboard..." ) ;
354+ window . setTimeout ( ( ) => window . location . reload ( ) , FORCED_RELOAD_DELAY_MS ) ;
355+ return ;
356+ }
357+ await waitForStableConnection ( STABLE_CONNECTION_WINDOW_MS , STABLE_CONNECTION_TIMEOUT_MS ) ;
153358 } catch {
154- console . warn ( "[NeMoClaw] pairing bootstrap: extended reconnect timed out; revealing app anyway" ) ;
155- clearPairingReloadFlag ( ) ;
156- revealApp ( ) ;
359+ setConnectOverlayText ( "Still waiting for device pairing approval..." ) ;
360+ try {
361+ await waitForReconnect ( EXTENDED_CONNECT_TIMEOUT_MS ) ;
362+ await waitForStableConnection ( STABLE_CONNECTION_WINDOW_MS , STABLE_CONNECTION_TIMEOUT_MS ) ;
363+ } catch {
364+ // reveal anyway
365+ }
157366 }
158- } ) ;
367+ revealApp ( ) ;
368+ return ;
369+ }
370+
371+ scheduleOverlay ( ) ;
372+ const initialText = getOverlayTextForPairingState ( initialState ) ;
373+ if ( initialText ) {
374+ ensureOverlayVisible ( ) ;
375+ setMonotonicOverlayText ( initialText , initialState ?. status ) ;
376+ }
377+
378+ if ( ! initialState . active && ! isPairingTerminal ( initialState ) ) {
379+ ensureOverlayVisible ( ) ;
380+ const started = await fetchPairingBootstrapState ( "POST" ) ;
381+ if ( started ) {
382+ latestPairingState = started ;
383+ lastPairingStartAt = Date . now ( ) ;
384+ const startedText = getOverlayTextForPairingState ( started ) ;
385+ setMonotonicOverlayText ( startedText , started . status ) ;
386+ }
387+ }
388+
389+ await pollPairingState ( ) ;
390+ runReadinessFlow ( ) ;
391+ } ) ( ) ;
159392
160393 const keysIngested = ingestKeysFromUrl ( ) ;
161394
0 commit comments