@@ -227,13 +227,33 @@ function recordDecision(
227227 appendStreamRow ( shell , { role : "system" , text, meta : "permission" } )
228228}
229229
230+ /**
231+ * Blocked-ness is domain state, not a paint detail: the turn watchdog and the
232+ * painter both need to know a gate is outstanding, whether or not it has
233+ * reached the screen yet. This is the only place that sees a gate's full
234+ * lifecycle (raised, possibly queued, eventually resolved), so it is the one
235+ * that reports it — callers fold the pair into their own turn state.
236+ */
237+ export type GateLifecycleHooks = {
238+ /** A gate was raised — queued or opened, whichever comes first. */
239+ readonly onGateOpened : ( ) => void
240+ /** A previously raised gate resolved. */
241+ readonly onGateClosed : ( ) => void
242+ }
243+
244+ const NOOP_GATE_HOOKS : GateLifecycleHooks = {
245+ onGateOpened : ( ) => { } ,
246+ onGateClosed : ( ) => { } ,
247+ }
248+
230249/**
231250 * Subscribe the permission/operator gate events to the shell's overlays.
232251 * Returns a dispose function that removes exactly the listeners this call added.
233252 */
234253export function wireGates (
235254 emitter : EventEmitter ,
236255 shell : AppShell ,
256+ hooks : GateLifecycleHooks = NOOP_GATE_HOOKS ,
237257) : ( ) => void {
238258 // The shell has one overlay host, and opening onto a busy one is a no-op.
239259 // Gates cannot be dropped that way — a lost ask_operator blocks the run with
@@ -255,6 +275,15 @@ export function wireGates(
255275 } )
256276
257277 function onPermission ( ev : PermissionGateEvent ) : void {
278+ hooks . onGateOpened ( )
279+ let closed = false
280+ const resolve : PermissionGateEvent [ "resolve" ] = ( outcome ) => {
281+ if ( ! closed ) {
282+ closed = true
283+ hooks . onGateClosed ( )
284+ }
285+ ev . resolve ( outcome )
286+ }
258287 const choices = permissionChoicesFromRequest ( ev . request )
259288 const collapsedBody = permissionBodyFromRequest ( ev . request , { hint : true } )
260289 // Nothing was collapsed → no expand affordance, so the overlay leaves the
@@ -299,15 +328,15 @@ export function wireGates(
299328 ...( sel . id !== undefined ? { id : sel . id } : { } ) ,
300329 }
301330 recordDecision ( shell , ev . request , choices , gateSelection )
302- ev . resolve ( approvalOutcomeFromSelection ( choices , gateSelection ) )
331+ resolve ( approvalOutcomeFromSelection ( choices , gateSelection ) )
303332 } ,
304333 // Esc must settle the awaited promise (as a deny), not abandon it —
305334 // an unresolved gate hangs the run until the process is killed.
306335 onCancel : ( ) => {
307336 if ( settled ) return
308337 settled = true
309338 clearTimers ( )
310- ev . resolve (
339+ resolve (
311340 approvalOutcomeFromSelection ( choices , {
312341 index : 0 ,
313342 id : PERMISSION_DENY_ID ,
@@ -338,7 +367,7 @@ export function wireGates(
338367 const idx = pending . indexOf ( open )
339368 if ( idx >= 0 ) pending . splice ( idx , 1 )
340369 }
341- ev . resolve ( { allow : false , message } )
370+ resolve ( { allow : false , message } )
342371 }
343372 function onAbort ( ) : void {
344373 autoDeny ( "tool no longer running; permission request denied" )
@@ -358,6 +387,15 @@ export function wireGates(
358387 }
359388
360389 function onOperator ( ev : OperatorGateEvent ) : void {
390+ hooks . onGateOpened ( )
391+ let closed = false
392+ const resolve : OperatorGateEvent [ "resolve" ] = ( result ) => {
393+ if ( ! closed ) {
394+ closed = true
395+ hooks . onGateClosed ( )
396+ }
397+ ev . resolve ( result )
398+ }
361399 const choices = operatorChoicesFromOptions ( ev . options )
362400 // Guarded the same way as the permission gate: correctness must not rest
363401 // on callers of closeInsetOverlay remembering to null the cancel hook
@@ -371,7 +409,7 @@ export function wireGates(
371409 onAccept : ( sel : OverlaySelection ) => {
372410 if ( settled ) return
373411 settled = true
374- ev . resolve (
412+ resolve (
375413 operatorResultFromSelection ( ev . options , {
376414 index : sel . index ,
377415 ...( sel . id !== undefined ? { id : sel . id } : { } ) ,
@@ -383,14 +421,14 @@ export function wireGates(
383421 onTextAnswer : ( text : string ) => {
384422 if ( settled ) return
385423 settled = true
386- ev . resolve ( operatorCustomResult ( text ) )
424+ resolve ( operatorCustomResult ( text ) )
387425 } ,
388426 // Esc must settle the awaited promise (as a cancel), not abandon it —
389427 // an unresolved gate hangs the run until the process is killed.
390428 onCancel : ( ) => {
391429 if ( settled ) return
392430 settled = true
393- ev . resolve ( operatorCancelResult ( ) )
431+ resolve ( operatorCancelResult ( ) )
394432 } ,
395433 } ) )
396434 }
0 commit comments