Skip to content

Commit 8b422c2

Browse files
author
StackMemory Bot (CLI)
committed
feat(conductor): add interactive monitor TUI command
stackmemory conductor monitor [--interval <s>] [--no-interactive] Interactive dashboard with live status + usage views, keyboard navigation (s/u/d/j/l/r/p/+/-/q), auto-refresh with pause support, and CI-friendly non-interactive mode.
1 parent 2720327 commit 8b422c2

File tree

1 file changed

+245
-0
lines changed

1 file changed

+245
-0
lines changed

src/cli/commands/orchestrate.ts

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)