@@ -242,13 +242,51 @@ function recordOperatorDecision(
242242 appendStreamRow ( shell , { role : "system" , text, meta : "operator" } )
243243}
244244
245+ /**
246+ * Blocked-ness is domain state, not a paint detail: the turn watchdog and the
247+ * painter both need to know a gate is outstanding, whether or not it has
248+ * reached the screen yet. This is the only place that sees a gate's full
249+ * lifecycle (raised, possibly queued, eventually resolved), so it is the one
250+ * that reports it — callers fold the pair into their own turn state.
251+ */
252+ export type GateLifecycleHooks = {
253+ /** A gate was raised — queued or opened, whichever comes first. */
254+ readonly onGateOpened : ( ) => void
255+ /** A previously raised gate resolved. */
256+ readonly onGateClosed : ( ) => void
257+ }
258+
259+ const NOOP_GATE_HOOKS : GateLifecycleHooks = {
260+ onGateOpened : ( ) => { } ,
261+ onGateClosed : ( ) => { } ,
262+ }
263+
264+ /**
265+ * Wrap a gate's `resolve` so `onGateClosed` fires exactly once no matter
266+ * which of accept / cancel / auto-deny settles it first.
267+ */
268+ function onceClosed < T > (
269+ onGateClosed : ( ) => void ,
270+ resolve : ( value : T ) => void ,
271+ ) : ( value : T ) => void {
272+ let closed = false
273+ return ( value ) => {
274+ if ( ! closed ) {
275+ closed = true
276+ onGateClosed ( )
277+ }
278+ resolve ( value )
279+ }
280+ }
281+
245282/**
246283 * Subscribe the permission/operator gate events to the shell's overlays.
247284 * Returns a dispose function that removes exactly the listeners this call added.
248285 */
249286export function wireGates (
250287 emitter : EventEmitter ,
251288 shell : AppShell ,
289+ hooks : GateLifecycleHooks = NOOP_GATE_HOOKS ,
252290) : ( ) => void {
253291 // The shell has one overlay host, and opening onto a busy one is a no-op.
254292 // Gates cannot be dropped that way — a lost ask_operator blocks the run with
@@ -270,6 +308,8 @@ export function wireGates(
270308 } )
271309
272310 function onPermission ( ev : PermissionGateEvent ) : void {
311+ hooks . onGateOpened ( )
312+ const resolve = onceClosed ( hooks . onGateClosed , ev . resolve )
273313 const choices = permissionChoicesFromRequest ( ev . request )
274314 const collapsedBody = permissionBodyFromRequest ( ev . request , { hint : true } )
275315 // Nothing was collapsed → no expand affordance, so the overlay leaves the
@@ -318,7 +358,7 @@ export function wireGates(
318358 ...( sel . id !== undefined ? { id : sel . id } : { } ) ,
319359 }
320360 recordDecision ( shell , ev . request , choices , gateSelection )
321- ev . resolve ( approvalOutcomeFromSelection ( choices , gateSelection ) )
361+ resolve ( approvalOutcomeFromSelection ( choices , gateSelection ) )
322362 } ,
323363 // Esc must settle the awaited promise (as a deny), not abandon it —
324364 // an unresolved gate hangs the run until the process is killed.
@@ -328,7 +368,7 @@ export function wireGates(
328368 clearTimers ( )
329369 const gateSelection = { index : 0 , id : PERMISSION_DENY_ID }
330370 recordDecision ( shell , ev . request , choices , gateSelection )
331- ev . resolve ( approvalOutcomeFromSelection ( choices , gateSelection ) )
371+ resolve ( approvalOutcomeFromSelection ( choices , gateSelection ) )
332372 } ,
333373 } )
334374 }
@@ -358,7 +398,7 @@ export function wireGates(
358398 const idx = pending . indexOf ( open )
359399 if ( idx >= 0 ) pending . splice ( idx , 1 )
360400 }
361- ev . resolve ( { allow : false , message } )
401+ resolve ( { allow : false , message } )
362402 }
363403 function onAbort ( ) : void {
364404 autoDeny ( "tool no longer running; permission request denied" )
@@ -378,6 +418,8 @@ export function wireGates(
378418 }
379419
380420 function onOperator ( ev : OperatorGateEvent ) : void {
421+ hooks . onGateOpened ( )
422+ const resolve = onceClosed ( hooks . onGateClosed , ev . resolve )
381423 const choices = operatorChoicesFromOptions ( ev . options )
382424 // Guarded the same way as the permission gate: correctness must not rest
383425 // on callers of closeInsetOverlay remembering to null the cancel hook
@@ -396,7 +438,7 @@ export function wireGates(
396438 if ( settled ) return
397439 settled = true
398440 recordOperatorDecision ( shell , ev . question , sel . label )
399- ev . resolve (
441+ resolve (
400442 operatorResultFromSelection ( ev . options , {
401443 index : sel . index ,
402444 ...( sel . id !== undefined ? { id : sel . id } : { } ) ,
@@ -409,15 +451,15 @@ export function wireGates(
409451 if ( settled ) return
410452 settled = true
411453 recordOperatorDecision ( shell , ev . question , text )
412- ev . resolve ( operatorCustomResult ( text ) )
454+ resolve ( operatorCustomResult ( text ) )
413455 } ,
414456 // Esc must settle the awaited promise (as a cancel), not abandon it —
415457 // an unresolved gate hangs the run until the process is killed.
416458 onCancel : ( ) => {
417459 if ( settled ) return
418460 settled = true
419461 recordOperatorDecision ( shell , ev . question , "Cancelled" )
420- ev . resolve ( operatorCancelResult ( ) )
462+ resolve ( operatorCancelResult ( ) )
421463 } ,
422464 } ) )
423465 }
0 commit comments