@@ -15,7 +15,11 @@ import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
1515import { createRequire } from "node:module" ;
1616import { join } from "node:path" ;
1717import { tmpdir } from "node:os" ;
18- import { approveAll , type CopilotClient } from "@github/copilot-sdk" ;
18+ import {
19+ approveAll ,
20+ type CopilotClient ,
21+ type SessionEvent ,
22+ } from "@github/copilot-sdk" ;
1923import type {
2024 PluginManifest ,
2125 AuditResult ,
@@ -655,130 +659,126 @@ export async function deepAudit(
655659 }
656660 signal ?. addEventListener ( "abort" , onAbort , { once : true } ) ;
657661
658- const unsubscribe = auditSession . on (
659- ( event : { type : string ; data ?: Record < string , unknown > } ) => {
660- // Trace every event type so we can see what's happening
661- trace (
662- `event: ${ event . type } ${ JSON . stringify ( event . data ?? { } ) . slice ( 0 , 300 ) } ` ,
663- ) ;
664-
665- // Only reset inactivity timer on CONTENT-BEARING events.
666- // Usage events and other housekeeping must NOT reset the
667- // timer — otherwise the audit can hang indefinitely getting
668- // usage events with no actual content.
669- const isContentEvent =
670- event . type === "assistant.message" ||
671- event . type === "assistant.message_delta" ||
672- event . type === "assistant.reasoning_delta" ||
673- event . type === "assistant.turn_start" ||
674- event . type === "assistant.turn_end" ;
675-
676- if ( isContentEvent ) {
677- clearTimeout ( timeoutId ) ;
678- timeoutId = setTimeout ( ( ) => {
679- unsubscribe ( ) ;
680- signal ?. removeEventListener ( "abort" , onAbort ) ;
681- reject (
682- new Error (
683- `Audit timed out — no events for ${ AUDIT_INACTIVITY_MS / 1000 } s` ,
684- ) ,
685- ) ;
686- } , AUDIT_INACTIVITY_MS ) ;
687- }
662+ const unsubscribe = auditSession . on ( ( event : SessionEvent ) => {
663+ // Trace every event type so we can see what's happening
664+ trace (
665+ `event: ${ event . type } ${ JSON . stringify ( event . data ?? { } ) . slice ( 0 , 300 ) } ` ,
666+ ) ;
688667
689- if ( event . type === "assistant.message" ) {
690- // Final complete message — extract content
691- const content = ( event . data as { content ?: string } ) ?. content ;
692- if ( content ) text = content ;
693- } else if ( event . type === "assistant.reasoning_delta" ) {
694- // Model is reasoning — forward to progress callback.
695- // The progress callback handles display logic (e.g.
696- // suppressing the preview after streaming starts).
697- const delta = ( event . data as { deltaContent ?: string } )
698- ?. deltaContent ;
699- if ( delta ) {
700- progress ( "reasoning" , delta ) ;
701- }
702- } else if ( event . type === "assistant.message_delta" ) {
703- // Streaming delta — accumulate text for the final response.
704- // Skip whitespace-only deltas for progress display — the
705- // model can emit "\n\n" before reasoning starts (undocumented
706- // behaviour). We still accumulate the text but don't trigger
707- // the "Receiving audit report" UI for whitespace.
708- const delta = ( event . data as { deltaContent ?: string } )
709- ?. deltaContent ;
710- if ( delta ) {
711- text += delta ;
712- if ( text . trim ( ) . length > 0 ) {
713- progress (
714- "streaming" ,
715- `Receiving audit report (${ text . length . toLocaleString ( ) } chars)...` ,
716- ) ;
717- }
718- }
719- } else if (
720- event . type === "assistant.turn_start" ||
721- event . type === "assistant.turn_end"
722- ) {
723- // Turn lifecycle events — the CLI server fires these during
724- // multi-call continuation loops. They're the ONLY reliable
725- // life sign during dead zones where no message/reasoning
726- // deltas arrive (known SDK bug — see github/copilot-sdk#524).
727- // Forward to progress for spinner feedback.
728- const turnId = ( event . data as { turnId ?: string } ) ?. turnId ;
729- progress (
730- "turn" ,
731- `${ event . type === "assistant.turn_start" ? "Turn" : "Turn complete" } ${ turnId ?? "" } ` . trim ( ) ,
732- ) ;
733- } else if ( event . type === "assistant.usage" ) {
734- // Accumulate usage stats — don't print each one.
735- // We emit a single aggregate summary at completion.
736- const d = event . data as {
737- inputTokens ?: number ;
738- outputTokens ?: number ;
739- cacheReadTokens ?: number ;
740- cost ?: number ;
741- duration ?: number ;
742- } ;
743- totalInputTokens += d . inputTokens ?? 0 ;
744- totalOutputTokens += d . outputTokens ?? 0 ;
745- totalCacheReadTokens += d . cacheReadTokens ?? 0 ;
746- totalCost += d . cost ?? 0 ;
747- totalDurationMs += d . duration ?? 0 ;
748- usageEventCount ++ ;
749- // Update spinner so user knows it's still alive
750- progress (
751- "usage-tick" ,
752- `Analysis in progress (${ usageEventCount } API call${ usageEventCount === 1 ? "" : "s" } )...` ,
753- ) ;
754- } else if ( event . type === "session.idle" ) {
755- clearTimeout ( timeoutId ) ;
668+ // Only reset inactivity timer on CONTENT-BEARING events.
669+ // Usage events and other housekeeping must NOT reset the
670+ // timer — otherwise the audit can hang indefinitely getting
671+ // usage events with no actual content.
672+ const isContentEvent =
673+ event . type === "assistant.message" ||
674+ event . type === "assistant.message_delta" ||
675+ event . type === "assistant.reasoning_delta" ||
676+ event . type === "assistant.turn_start" ||
677+ event . type === "assistant.turn_end" ;
678+
679+ if ( isContentEvent ) {
680+ clearTimeout ( timeoutId ) ;
681+ timeoutId = setTimeout ( ( ) => {
756682 unsubscribe ( ) ;
757683 signal ?. removeEventListener ( "abort" , onAbort ) ;
758- // Emit aggregated usage summary
759- if ( usageEventCount > 0 ) {
684+ reject (
685+ new Error (
686+ `Audit timed out — no events for ${ AUDIT_INACTIVITY_MS / 1000 } s` ,
687+ ) ,
688+ ) ;
689+ } , AUDIT_INACTIVITY_MS ) ;
690+ }
691+
692+ if ( event . type === "assistant.message" ) {
693+ // Final complete message — extract content
694+ const content = ( event . data as { content ?: string } ) ?. content ;
695+ if ( content ) text = content ;
696+ } else if ( event . type === "assistant.reasoning_delta" ) {
697+ // Model is reasoning — forward to progress callback.
698+ // The progress callback handles display logic (e.g.
699+ // suppressing the preview after streaming starts).
700+ const delta = ( event . data as { deltaContent ?: string } ) ?. deltaContent ;
701+ if ( delta ) {
702+ progress ( "reasoning" , delta ) ;
703+ }
704+ } else if ( event . type === "assistant.message_delta" ) {
705+ // Streaming delta — accumulate text for the final response.
706+ // Skip whitespace-only deltas for progress display — the
707+ // model can emit "\n\n" before reasoning starts (undocumented
708+ // behaviour). We still accumulate the text but don't trigger
709+ // the "Receiving audit report" UI for whitespace.
710+ const delta = ( event . data as { deltaContent ?: string } ) ?. deltaContent ;
711+ if ( delta ) {
712+ text += delta ;
713+ if ( text . trim ( ) . length > 0 ) {
760714 progress (
761- "usage" ,
762- JSON . stringify ( {
763- inputTokens : totalInputTokens ,
764- outputTokens : totalOutputTokens ,
765- cacheReadTokens : totalCacheReadTokens ,
766- cost : totalCost ,
767- duration : totalDurationMs ,
768- } ) ,
715+ "streaming" ,
716+ `Receiving audit report (${ text . length . toLocaleString ( ) } chars)...` ,
769717 ) ;
770718 }
771- resolve ( text ) ;
772- } else if ( event . type === "session.error" ) {
773- clearTimeout ( timeoutId ) ;
774- unsubscribe ( ) ;
775- signal ?. removeEventListener ( "abort" , onAbort ) ;
776- const msg =
777- ( event . data as { message ?: string } ) ?. message ?? "unknown error" ;
778- reject ( new Error ( msg ) ) ;
779719 }
780- } ,
781- ) ;
720+ } else if (
721+ event . type === "assistant.turn_start" ||
722+ event . type === "assistant.turn_end"
723+ ) {
724+ // Turn lifecycle events — the CLI server fires these during
725+ // multi-call continuation loops. They're the ONLY reliable
726+ // life sign during dead zones where no message/reasoning
727+ // deltas arrive (known SDK bug — see github/copilot-sdk#524).
728+ // Forward to progress for spinner feedback.
729+ const turnId = ( event . data as { turnId ?: string } ) ?. turnId ;
730+ progress (
731+ "turn" ,
732+ `${ event . type === "assistant.turn_start" ? "Turn" : "Turn complete" } ${ turnId ?? "" } ` . trim ( ) ,
733+ ) ;
734+ } else if ( event . type === "assistant.usage" ) {
735+ // Accumulate usage stats — don't print each one.
736+ // We emit a single aggregate summary at completion.
737+ const d = event . data as {
738+ inputTokens ?: number ;
739+ outputTokens ?: number ;
740+ cacheReadTokens ?: number ;
741+ cost ?: number ;
742+ duration ?: number ;
743+ } ;
744+ totalInputTokens += d . inputTokens ?? 0 ;
745+ totalOutputTokens += d . outputTokens ?? 0 ;
746+ totalCacheReadTokens += d . cacheReadTokens ?? 0 ;
747+ totalCost += d . cost ?? 0 ;
748+ totalDurationMs += d . duration ?? 0 ;
749+ usageEventCount ++ ;
750+ // Update spinner so user knows it's still alive
751+ progress (
752+ "usage-tick" ,
753+ `Analysis in progress (${ usageEventCount } API call${ usageEventCount === 1 ? "" : "s" } )...` ,
754+ ) ;
755+ } else if ( event . type === "session.idle" ) {
756+ clearTimeout ( timeoutId ) ;
757+ unsubscribe ( ) ;
758+ signal ?. removeEventListener ( "abort" , onAbort ) ;
759+ // Emit aggregated usage summary
760+ if ( usageEventCount > 0 ) {
761+ progress (
762+ "usage" ,
763+ JSON . stringify ( {
764+ inputTokens : totalInputTokens ,
765+ outputTokens : totalOutputTokens ,
766+ cacheReadTokens : totalCacheReadTokens ,
767+ cost : totalCost ,
768+ duration : totalDurationMs ,
769+ } ) ,
770+ ) ;
771+ }
772+ resolve ( text ) ;
773+ } else if ( event . type === "session.error" ) {
774+ clearTimeout ( timeoutId ) ;
775+ unsubscribe ( ) ;
776+ signal ?. removeEventListener ( "abort" , onAbort ) ;
777+ const msg =
778+ ( event . data as { message ?: string } ) ?. message ?? "unknown error" ;
779+ reject ( new Error ( msg ) ) ;
780+ }
781+ } ) ;
782782
783783 // Start inactivity timer
784784 timeoutId = setTimeout ( ( ) => {
0 commit comments