@@ -155,10 +155,18 @@ class WizardSessionPrompter implements WizardPrompter {
155155 return Boolean ( res ) ;
156156 }
157157
158- progress ( _label : string ) : WizardProgress {
158+ progress ( label : string ) : WizardProgress {
159+ // Emit progress as non-interactive steps so remote clients see live status
160+ // during slow operations. Clients render the message and re-poll wizard.next
161+ // WITHOUT an answer to continue. Local CLI keeps using the clack prompter.
162+ this . session . emitProgress ( label ) ;
159163 return {
160- update : ( _message ) => { } ,
161- stop : ( _message ) => { } ,
164+ update : ( message ) => this . session . emitProgress ( message ) ,
165+ stop : ( message ) => {
166+ if ( message !== undefined ) {
167+ this . session . emitProgress ( message ) ;
168+ }
169+ } ,
162170 } ;
163171 }
164172
@@ -176,6 +184,9 @@ export class WizardSession {
176184 private currentStep : WizardStep | null = null ;
177185 private stepDeferred : Deferred < WizardStep | null > | null = null ;
178186 private pendingTerminalResolution = false ;
187+ // Latest undelivered progress step. Progress is non-interactive and "latest
188+ // wins", so it is held as a single slot rather than an unbounded queue.
189+ private pendingProgress : WizardStep | null = null ;
179190 private answerDeferred = new Map < string , Deferred < unknown > > ( ) ;
180191 private status : WizardSessionStatus = "running" ;
181192 private error : string | undefined ;
@@ -186,24 +197,39 @@ export class WizardSession {
186197 }
187198
188199 async next ( ) : Promise < WizardNextResult > {
189- if ( this . currentStep ) {
190- return { done : false , step : this . currentStep , status : this . status } ;
191- }
192- if ( this . pendingTerminalResolution ) {
193- this . pendingTerminalResolution = false ;
194- return { done : true , status : this . status , error : this . error } ;
195- }
196- if ( this . status !== "running" ) {
197- return { done : true , status : this . status , error : this . error } ;
198- }
199- if ( ! this . stepDeferred ) {
200- this . stepDeferred = createDeferred ( ) ;
201- }
202- const step = await this . stepDeferred . promise ;
203- if ( step ) {
204- return { done : false , step, status : this . status } ;
200+ // Loop so a progress wake never decides completion. A progress emit resolves
201+ // the shared step waiter with null while the session is still running; with
202+ // concurrent next() callers, one consumes the progress and the rest must
203+ // re-classify state (and re-wait) rather than surface a spurious done.
204+ for ( ; ; ) {
205+ // Deliver any in-flight progress before the pending prompt so remote
206+ // clients see live status during slow operations (installs, auth waits).
207+ // This cannot starve an interactive prompt: the runner is sequential, so
208+ // while it awaits a prompt it is not emitting progress, and latest-wins
209+ // keeps at most one progress step pending.
210+ const progress = this . takePendingProgress ( ) ;
211+ if ( progress ) {
212+ return { done : false , step : progress , status : this . status } ;
213+ }
214+ if ( this . currentStep ) {
215+ return { done : false , step : this . currentStep , status : this . status } ;
216+ }
217+ if ( this . pendingTerminalResolution ) {
218+ this . pendingTerminalResolution = false ;
219+ return { done : true , status : this . status , error : this . error } ;
220+ }
221+ if ( this . status !== "running" ) {
222+ return { done : true , status : this . status , error : this . error } ;
223+ }
224+ if ( ! this . stepDeferred ) {
225+ this . stepDeferred = createDeferred ( ) ;
226+ }
227+ // Wait for the next event (prompt pushed, progress emitted, or terminal),
228+ // then re-loop to classify it via the checks above. The resolved value is
229+ // intentionally ignored: a real step is read back from currentStep, and a
230+ // null resolution falls through to progress/terminal/status handling.
231+ await this . stepDeferred . promise ;
205232 }
206- return { done : true , status : this . status , error : this . error } ;
207233 }
208234
209235 async answer ( stepId : string , value : unknown ) : Promise < void > {
@@ -223,6 +249,7 @@ export class WizardSession {
223249 this . status = "cancelled" ;
224250 this . error = "cancelled" ;
225251 this . currentStep = null ;
252+ this . pendingProgress = null ;
226253 for ( const [ , deferred ] of this . answerDeferred ) {
227254 // Reject all pending prompt promises so the runner can unwind through its
228255 // normal cancellation path.
@@ -264,6 +291,41 @@ export class WizardSession {
264291 return await deferred . promise ;
265292 }
266293
294+ /**
295+ * Emits a non-interactive progress step for remote clients. Fire-and-forget:
296+ * the runner does not block, and clients re-poll wizard.next without an answer
297+ * to continue. Latest-wins, so an undelivered progress step is replaced.
298+ */
299+ emitProgress ( message : string ) : void {
300+ if ( this . status !== "running" ) {
301+ return ;
302+ }
303+ this . pendingProgress = {
304+ id : randomUUID ( ) ,
305+ type : "progress" ,
306+ message,
307+ executor : "client" ,
308+ } ;
309+ this . wakePendingProgress ( ) ;
310+ }
311+
312+ private takePendingProgress ( ) : WizardStep | null {
313+ const step = this . pendingProgress ;
314+ this . pendingProgress = null ;
315+ return step ;
316+ }
317+
318+ private wakePendingProgress ( ) : void {
319+ // Wake a long-polling next() so live progress is delivered mid-operation.
320+ // Resolves with null; next() re-checks the pending progress slot on wake.
321+ if ( ! this . stepDeferred ) {
322+ return ;
323+ }
324+ const deferred = this . stepDeferred ;
325+ this . stepDeferred = null ;
326+ deferred . resolve ( null ) ;
327+ }
328+
267329 private resolveStep ( step : WizardStep | null ) {
268330 if ( ! this . stepDeferred ) {
269331 if ( step === null ) {
0 commit comments