@@ -660,5 +660,250 @@ export function createConductorCommands(): Command {
660660 await conductor . start ( ) ;
661661 } ) ;
662662
663+ // --- monitor ---
664+ cmd
665+ . command ( 'monitor' )
666+ . description ( 'Interactive TUI dashboard for conductor monitoring' )
667+ . option ( '--interval <seconds>' , 'Auto-refresh interval in seconds' , '10' )
668+ . option ( '--no-interactive' , 'Disable interactive keys (CI/pipe mode)' )
669+ . action ( async ( options ) => {
670+ const interval = parseInt ( options . interval , 10 ) * 1000 ;
671+ const interactive = options . interactive !== false ;
672+ let currentMode : 'dashboard' | 'status' | 'usage' | 'json' = 'dashboard' ;
673+ let paused = false ;
674+ let refreshInterval = interval ;
675+
676+ const b = '\x1b[1m' ;
677+ const d = '\x1b[2m' ;
678+ const cyan = '\x1b[36m' ;
679+ const r = '\x1b[0m' ;
680+
681+ function readStatuses ( ) : AgentStatusFile [ ] {
682+ const agentsDir = join (
683+ homedir ( ) ,
684+ '.stackmemory' ,
685+ 'conductor' ,
686+ 'agents'
687+ ) ;
688+ if ( ! existsSync ( agentsDir ) ) return [ ] ;
689+ const entries = readdirSync ( agentsDir , { withFileTypes : true } ) ;
690+ const statuses : AgentStatusFile [ ] = [ ] ;
691+ for ( const entry of entries ) {
692+ if ( ! entry . isDirectory ( ) ) continue ;
693+ const statusPath = join ( agentsDir , entry . name , 'status.json' ) ;
694+ if ( ! existsSync ( statusPath ) ) continue ;
695+ try {
696+ statuses . push ( JSON . parse ( readFileSync ( statusPath , 'utf-8' ) ) ) ;
697+ } catch {
698+ // skip corrupt files
699+ }
700+ }
701+ statuses . sort (
702+ ( a , b ) =>
703+ new Date ( b . lastUpdate ) . getTime ( ) - new Date ( a . lastUpdate ) . getTime ( )
704+ ) ;
705+ return statuses ;
706+ }
707+
708+ function printStatusTable ( statuses : AgentStatusFile [ ] ) : void {
709+ if ( statuses . length === 0 ) {
710+ console . log ( ' No active agents' ) ;
711+ return ;
712+ }
713+ const header = `${ 'Issue' . padEnd ( 12 ) } ${ 'Phase' . padEnd ( 16 ) } ${ 'Tools' . padStart ( 7 ) } ${ 'Files' . padStart ( 7 ) } ${ 'Tokens' . padStart ( 9 ) } Last Update` ;
714+ console . log ( header ) ;
715+ for ( const s of statuses ) {
716+ const elapsed = Date . now ( ) - new Date ( s . lastUpdate ) . getTime ( ) ;
717+ const line = `${ s . issue . padEnd ( 12 ) } ${ s . phase . padEnd ( 16 ) } ${ String ( s . toolCalls ) . padStart ( 7 ) } ${ String ( s . filesModified ) . padStart ( 7 ) } ${ String ( s . tokensUsed ) . padStart ( 9 ) } ${ formatElapsed ( elapsed ) } ` ;
718+ console . log ( line ) ;
719+ }
720+ }
721+
722+ async function getUsage ( ) : Promise < Record < string , unknown > > {
723+ const conductor = new Conductor ( { repoRoot : process . cwd ( ) } ) ;
724+ await conductor . scanUsageLogs ( ) ;
725+ return conductor . getUsageSummary ( ) as Record < string , unknown > ;
726+ }
727+
728+ async function render ( ) : Promise < void > {
729+ // Clear screen
730+ process . stdout . write ( '\x1b[2J\x1b[H' ) ;
731+
732+ const pauseTag = paused ? ' [PAUSED]' : '' ;
733+ const intervalSec = Math . round ( refreshInterval / 1000 ) ;
734+ console . log (
735+ `${ b } ══════════════════════════════════════════════════${ r } `
736+ ) ;
737+ console . log (
738+ `${ b } Conductor Monitor${ r } ${ new Date ( ) . toLocaleTimeString ( ) } ${ pauseTag } `
739+ ) ;
740+ console . log (
741+ ` Mode: ${ cyan } ${ currentMode } ${ r } | Refresh: ${ intervalSec } s`
742+ ) ;
743+ if ( interactive ) {
744+ console . log (
745+ ` ${ d } [s]tatus [u]sage [d]ashboard [j]son [l]ogs [r]efresh [p]ause [+/-] [q]uit${ r } `
746+ ) ;
747+ }
748+ console . log (
749+ `${ b } ══════════════════════════════════════════════════${ r } `
750+ ) ;
751+ console . log ( '' ) ;
752+
753+ const statuses = readStatuses ( ) ;
754+
755+ switch ( currentMode ) {
756+ case 'dashboard' : {
757+ printStatusTable ( statuses ) ;
758+ console . log ( '' ) ;
759+ const usage = await getUsage ( ) ;
760+ printUsageSummary ( usage ) ;
761+ break ;
762+ }
763+ case 'status' :
764+ printStatusTable ( statuses ) ;
765+ break ;
766+ case 'usage' : {
767+ const usage = await getUsage ( ) ;
768+ printUsageSummary ( usage ) ;
769+ break ;
770+ }
771+ case 'json' : {
772+ const usage = await getUsage ( ) ;
773+ console . log ( JSON . stringify ( usage , null , 2 ) ) ;
774+ break ;
775+ }
776+ }
777+
778+ console . log ( '' ) ;
779+ console . log (
780+ `${ d } ──────────────────────────────────────────────────${ r } `
781+ ) ;
782+ }
783+
784+ // Initial render
785+ await render ( ) ;
786+
787+ if ( ! interactive ) {
788+ // Non-interactive: just loop with setInterval
789+ const timer = setInterval ( async ( ) => {
790+ if ( ! paused ) await render ( ) ;
791+ } , refreshInterval ) ;
792+ await new Promise < void > ( ( resolve ) => {
793+ process . on ( 'SIGINT' , ( ) => {
794+ clearInterval ( timer ) ;
795+ resolve ( ) ;
796+ } ) ;
797+ process . on ( 'SIGTERM' , ( ) => {
798+ clearInterval ( timer ) ;
799+ resolve ( ) ;
800+ } ) ;
801+ } ) ;
802+ return ;
803+ }
804+
805+ // Interactive mode: raw stdin for keypress handling
806+ if ( process . stdin . isTTY ) {
807+ process . stdin . setRawMode ( true ) ;
808+ }
809+ process . stdin . resume ( ) ;
810+ process . stdin . setEncoding ( 'utf-8' ) ;
811+
812+ let refreshTimer : ReturnType < typeof setTimeout > | null = null ;
813+
814+ function scheduleRefresh ( ) : void {
815+ if ( refreshTimer ) clearTimeout ( refreshTimer ) ;
816+ refreshTimer = setTimeout ( async ( ) => {
817+ if ( ! paused ) await render ( ) ;
818+ scheduleRefresh ( ) ;
819+ } , refreshInterval ) ;
820+ }
821+
822+ scheduleRefresh ( ) ;
823+
824+ process . stdin . on ( 'data' , async ( key : string ) => {
825+ switch ( key ) {
826+ case 's' :
827+ currentMode = 'status' ;
828+ await render ( ) ;
829+ break ;
830+ case 'u' :
831+ currentMode = 'usage' ;
832+ await render ( ) ;
833+ break ;
834+ case 'd' :
835+ currentMode = 'dashboard' ;
836+ await render ( ) ;
837+ break ;
838+ case 'j' :
839+ currentMode = 'json' ;
840+ await render ( ) ;
841+ break ;
842+ case 'l' : {
843+ // Show log picker
844+ process . stdout . write ( '\x1b[2J\x1b[H' ) ;
845+ const statuses = readStatuses ( ) ;
846+ if ( statuses . length === 0 ) {
847+ console . log ( 'No active agents to show logs for.' ) ;
848+ } else {
849+ console . log ( 'Active issues:' ) ;
850+ console . log ( '' ) ;
851+ for ( const s of statuses ) {
852+ console . log ( ` ${ s . issue } (${ s . phase } )` ) ;
853+ }
854+ console . log ( '' ) ;
855+ console . log ( 'Use: stackmemory conductor logs <ISSUE-ID> -f' ) ;
856+ }
857+ console . log ( '\nPress any key to return...' ) ;
858+ // Wait for next keypress to return
859+ await new Promise < void > ( ( resolve ) => {
860+ process . stdin . once ( 'data' , ( ) => resolve ( ) ) ;
861+ } ) ;
862+ await render ( ) ;
863+ break ;
864+ }
865+ case 'r' :
866+ await render ( ) ;
867+ break ;
868+ case 'p' :
869+ paused = ! paused ;
870+ await render ( ) ;
871+ break ;
872+ case '+' :
873+ case '=' :
874+ refreshInterval += 5000 ;
875+ await render ( ) ;
876+ scheduleRefresh ( ) ;
877+ break ;
878+ case '-' :
879+ case '_' :
880+ if ( refreshInterval > 5000 ) refreshInterval -= 5000 ;
881+ await render ( ) ;
882+ scheduleRefresh ( ) ;
883+ break ;
884+ case 'q' :
885+ case '\x03' : // Ctrl+C
886+ if ( refreshTimer ) clearTimeout ( refreshTimer ) ;
887+ if ( process . stdin . isTTY ) process . stdin . setRawMode ( false ) ;
888+ process . exit ( 0 ) ;
889+ break ;
890+ }
891+ } ) ;
892+
893+ // Keep alive
894+ await new Promise < void > ( ( resolve ) => {
895+ process . on ( 'SIGINT' , ( ) => {
896+ if ( refreshTimer ) clearTimeout ( refreshTimer ) ;
897+ if ( process . stdin . isTTY ) process . stdin . setRawMode ( false ) ;
898+ resolve ( ) ;
899+ } ) ;
900+ process . on ( 'SIGTERM' , ( ) => {
901+ if ( refreshTimer ) clearTimeout ( refreshTimer ) ;
902+ if ( process . stdin . isTTY ) process . stdin . setRawMode ( false ) ;
903+ resolve ( ) ;
904+ } ) ;
905+ } ) ;
906+ } ) ;
907+
663908 return cmd ;
664909}
0 commit comments