@@ -90,6 +90,8 @@ const SKIP_SAY_TYPES = new Set([
9090
9191/** Key offset for reasoning content to avoid collision with text content delta tracking */
9292const REASONING_KEY_OFFSET = 1_000_000_000
93+ /** Grace period to wait for final say:command_output after status:exited */
94+ const COMMAND_OUTPUT_EXIT_GRACE_MS = 250
9395
9496export class JsonEventEmitter {
9597 private mode : "json" | "stream-json"
@@ -115,8 +117,8 @@ export class JsonEventEmitter {
115117 private statusDrivenCommandOutputIds = new Set < number > ( )
116118 // Track command ids that already emitted a terminal command_output done event.
117119 private completedCommandOutputIds = new Set < number > ( )
118- // Suppress the next say:command_output completion message after status-driven streaming .
119- private suppressNextCommandOutputSay = false
120+ // Track exited commands awaiting final say:command_output completion.
121+ private pendingCommandCompletionByToolUseId = new Map < number , { exitCode ?: number ; timer : NodeJS . Timeout } > ( )
120122 // Track the completion result content
121123 private completionResultContent : string | undefined
122124 // Track the latest assistant text as a fallback for result.content.
@@ -338,6 +340,7 @@ export class JsonEventEmitter {
338340
339341 if ( isDone ) {
340342 event . done = true
343+ this . clearPendingCommandCompletion ( commandId )
341344 this . previousCommandOutputByToolUseId . delete ( commandId )
342345 this . statusDrivenCommandOutputIds . delete ( commandId )
343346 this . completedCommandOutputIds . add ( commandId )
@@ -368,6 +371,7 @@ export class JsonEventEmitter {
368371 } )
369372
370373 if ( isDone ) {
374+ this . clearPendingCommandCompletion ( commandId )
371375 this . previousCommandOutputByToolUseId . delete ( commandId )
372376 this . statusDrivenCommandOutputIds . delete ( commandId )
373377 this . completedCommandOutputIds . add ( commandId )
@@ -387,17 +391,47 @@ export class JsonEventEmitter {
387391 this . emitCommandOutputEvent ( commandId , outputSnapshot , false )
388392 }
389393
394+ public markCommandOutputExited ( exitCode ?: number ) : void {
395+ const commandId = this . activeCommandToolUseId
396+ if ( commandId === undefined ) {
397+ return
398+ }
399+
400+ this . statusDrivenCommandOutputIds . add ( commandId )
401+ this . clearPendingCommandCompletion ( commandId )
402+
403+ const timer = setTimeout ( ( ) => {
404+ // Fallback close if final say:command_output never arrives.
405+ if ( ! this . pendingCommandCompletionByToolUseId . has ( commandId ) ) {
406+ return
407+ }
408+ this . pendingCommandCompletionByToolUseId . delete ( commandId )
409+ this . emitCommandOutputEvent ( commandId , undefined , true , exitCode )
410+ } , COMMAND_OUTPUT_EXIT_GRACE_MS )
411+ timer . unref ?.( )
412+
413+ this . pendingCommandCompletionByToolUseId . set ( commandId , { exitCode, timer } )
414+ }
415+
390416 public emitCommandOutputDone ( exitCode ?: number ) : void {
391417 const commandId = this . activeCommandToolUseId
392418 if ( commandId === undefined ) {
393419 return
394420 }
395421
396422 this . statusDrivenCommandOutputIds . add ( commandId )
397- this . suppressNextCommandOutputSay = true
398423 this . emitCommandOutputEvent ( commandId , undefined , true , exitCode )
399424 }
400425
426+ private clearPendingCommandCompletion ( commandId : number ) : void {
427+ const pending = this . pendingCommandCompletionByToolUseId . get ( commandId )
428+ if ( ! pending ) {
429+ return
430+ }
431+ clearTimeout ( pending . timer )
432+ this . pendingCommandCompletionByToolUseId . delete ( commandId )
433+ }
434+
401435 /**
402436 * Get content to send for a message (delta for streaming, full for json mode).
403437 */
@@ -624,9 +658,19 @@ export class JsonEventEmitter {
624658 const toolInfo = parseToolInfo ( msg . text )
625659
626660 if ( subtype === "command" ) {
661+ if ( this . activeCommandToolUseId !== undefined && this . activeCommandToolUseId !== msg . ts ) {
662+ const previousCommandId = this . activeCommandToolUseId
663+ const pending = this . pendingCommandCompletionByToolUseId . get ( previousCommandId )
664+ if ( pending ) {
665+ clearTimeout ( pending . timer )
666+ this . pendingCommandCompletionByToolUseId . delete ( previousCommandId )
667+ this . emitCommandOutputEvent ( previousCommandId , undefined , true , pending . exitCode )
668+ }
669+ }
670+
627671 this . activeCommandToolUseId = msg . ts
628672 this . completedCommandOutputIds . delete ( msg . ts )
629- this . suppressNextCommandOutputSay = false
673+ this . clearPendingCommandCompletion ( msg . ts )
630674
631675 if ( isStreamingPartial ) {
632676 const commandDelta = this . computeStructuredDelta ( msg . ts , msg . text )
@@ -707,17 +751,26 @@ export class JsonEventEmitter {
707751 }
708752
709753 private handleCommandOutputMessage ( msg : ClineMessage , isDone : boolean ) : void {
710- if ( this . suppressNextCommandOutputSay ) {
711- if ( isDone ) {
712- this . suppressNextCommandOutputSay = false
754+ const commandId = this . activeCommandToolUseId ?? msg . ts
755+ if ( this . completedCommandOutputIds . has ( commandId ) ) {
756+ return
757+ }
758+
759+ const pending = this . pendingCommandCompletionByToolUseId . get ( commandId )
760+ if ( pending ) {
761+ if ( ! isDone ) {
762+ return
713763 }
764+ clearTimeout ( pending . timer )
765+ this . pendingCommandCompletionByToolUseId . delete ( commandId )
766+ this . emitCommandOutputEvent ( commandId , msg . text , true , pending . exitCode )
714767 return
715768 }
716769
717- const commandId = this . activeCommandToolUseId ?? msg . ts
718- if ( this . statusDrivenCommandOutputIds . has ( commandId ) || this . completedCommandOutputIds . has ( commandId ) ) {
770+ if ( this . statusDrivenCommandOutputIds . has ( commandId ) ) {
719771 return
720772 }
773+
721774 this . emitCommandOutputEvent ( commandId , msg . text , isDone )
722775 }
723776
@@ -841,7 +894,10 @@ export class JsonEventEmitter {
841894 this . previousCommandOutputByToolUseId . clear ( )
842895 this . statusDrivenCommandOutputIds . clear ( )
843896 this . completedCommandOutputIds . clear ( )
844- this . suppressNextCommandOutputSay = false
897+ for ( const pending of this . pendingCommandCompletionByToolUseId . values ( ) ) {
898+ clearTimeout ( pending . timer )
899+ }
900+ this . pendingCommandCompletionByToolUseId . clear ( )
845901 this . completionResultContent = undefined
846902 this . lastAssistantText = undefined
847903 this . expectPromptEchoAsUser = true
0 commit comments