agent-orchestrator/packages/cli/src/commands/status.ts

518 lines
16 KiB
TypeScript

import chalk from "chalk";
import type { Command } from "commander";
import {
type Agent,
type SCM,
type Session,
type PRInfo,
type CIStatus,
type ReviewDecision,
type ActivityState,
type Tracker,
type ProjectConfig,
isOrchestratorSession,
loadConfig,
} from "@composio/ao-core";
import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js";
import {
banner,
header,
formatAge,
activityIcon,
ciStatusIcon,
reviewDecisionIcon,
padCol,
} from "../lib/format.js";
import { getAgentByName, getAgentByNameFromRegistry, getSCMFromRegistry } from "../lib/plugins.js";
import { getPluginRegistry, getSessionManager } from "../lib/create-session-manager.js";
interface SessionInfo {
name: string;
role: "worker" | "orchestrator";
branch: string | null;
status: string | null;
summary: string | null;
claudeSummary: string | null;
pr: string | null;
prNumber: number | null;
issue: string | null;
lastActivity: string;
project: string | null;
ciStatus: CIStatus | null;
reviewDecision: ReviewDecision | null;
pendingThreads: number | null;
activity: ActivityState | null;
}
interface StatusOptions {
project?: string;
json?: boolean;
watch?: boolean;
interval?: string;
}
const DEFAULT_WATCH_INTERVAL_SECONDS = 5;
function parseWatchIntervalSeconds(value?: string): number {
if (!value) return DEFAULT_WATCH_INTERVAL_SECONDS;
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
throw new Error("--interval must be a positive integer number of seconds.");
}
return parsed;
}
function maybeClearScreen(): void {
if (process.stdout.isTTY) {
process.stdout.write("\x1Bc");
}
}
async function gatherSessionInfo(
session: Session,
agent: Agent,
scm: SCM,
projectConfig: ReturnType<typeof loadConfig>,
): Promise<SessionInfo> {
const sessionPrefix = projectConfig.projects[session.projectId]?.sessionPrefix ?? session.projectId;
const allSessionPrefixes = Object.entries(projectConfig.projects).map(
([id, p]) => p.sessionPrefix ?? id,
);
const suppressPROwnership = isOrchestratorSession(session, sessionPrefix, allSessionPrefixes);
let branch = session.branch;
const status = session.status;
const summary = session.metadata["summary"] ?? null;
const prUrl = suppressPROwnership ? null : (session.metadata["pr"] ?? null);
const issue = session.issueId;
// Get live branch from worktree if available
if (session.workspacePath) {
const liveBranch = await git(["branch", "--show-current"], session.workspacePath);
if (liveBranch) branch = liveBranch;
}
// Get last activity time from tmux
const tmuxTarget = session.runtimeHandle?.id ?? session.id;
const activityTs = await getTmuxActivity(tmuxTarget);
const lastActivity = activityTs ? formatAge(activityTs) : "-";
// Get agent's auto-generated summary via introspection
let claudeSummary: string | null = null;
try {
const introspection = await agent.getSessionInfo(session);
claudeSummary = introspection?.summary ?? null;
} catch {
// Summary extraction failed — not critical
}
// Use activity from session (already enriched by sessionManager.list())
const activity = session.activity;
// Fetch PR, CI, and review data from SCM
let prNumber: number | null = null;
let ciStatus: CIStatus | null = null;
let reviewDecision: ReviewDecision | null = null;
let pendingThreads: number | null = null;
// Extract PR number from metadata URL as fallback
if (prUrl) {
const prMatch = /\/pull\/(\d+)/.exec(prUrl);
if (prMatch) {
prNumber = parseInt(prMatch[1], 10);
}
}
if (branch && !suppressPROwnership) {
try {
const project = projectConfig.projects[session.projectId];
if (project) {
const prInfo: PRInfo | null = await scm.detectPR(session, project);
if (prInfo) {
prNumber = prInfo.number;
const [ci, review, threads] = await Promise.all([
scm.getCISummary(prInfo).catch(() => null),
scm.getReviewDecision(prInfo).catch(() => null),
scm.getPendingComments(prInfo).catch(() => null),
]);
ciStatus = ci;
reviewDecision = review;
pendingThreads = threads !== null ? threads.length : null;
}
}
} catch {
// SCM lookup failed — not critical
}
}
return {
name: session.id,
role: isOrchestratorSession(session, sessionPrefix, allSessionPrefixes) ? "orchestrator" : "worker",
branch,
status,
summary,
claudeSummary,
pr: prUrl,
prNumber,
issue,
lastActivity,
project: session.projectId,
ciStatus,
reviewDecision,
pendingThreads,
activity,
};
}
// Column widths for the table
const COL = {
session: 14,
branch: 24,
pr: 6,
ci: 6,
review: 6,
threads: 4,
activity: 9,
age: 8,
};
function printTableHeader(): void {
const hdr =
padCol("Session", COL.session) +
padCol("Branch", COL.branch) +
padCol("PR", COL.pr) +
padCol("CI", COL.ci) +
padCol("Rev", COL.review) +
padCol("Thr", COL.threads) +
padCol("Activity", COL.activity) +
"Age";
console.log(chalk.dim(` ${hdr}`));
const totalWidth =
COL.session + COL.branch + COL.pr + COL.ci + COL.review + COL.threads + COL.activity + 3;
console.log(chalk.dim(` ${"─".repeat(totalWidth)}`));
}
function printSessionRow(info: SessionInfo): void {
const prStr = info.prNumber ? `#${info.prNumber}` : "-";
const row =
padCol(chalk.green(info.name), COL.session) +
padCol(info.branch ? chalk.cyan(info.branch) : chalk.dim("-"), COL.branch) +
padCol(info.prNumber ? chalk.blue(prStr) : chalk.dim(prStr), COL.pr) +
padCol(ciStatusIcon(info.ciStatus), COL.ci) +
padCol(reviewDecisionIcon(info.reviewDecision), COL.review) +
padCol(
info.pendingThreads !== null && info.pendingThreads > 0
? chalk.yellow(String(info.pendingThreads))
: chalk.dim(info.pendingThreads !== null ? "0" : "-"),
COL.threads,
) +
padCol(activityIcon(info.activity), COL.activity) +
chalk.dim(info.lastActivity);
console.log(` ${row}`);
// Show summary on a second line if available
const displaySummary = info.claudeSummary || info.summary;
if (displaySummary) {
console.log(` ${" ".repeat(COL.session)}${chalk.dim(displaySummary.slice(0, 60))}`);
}
}
function printOrchestratorRow(info: SessionInfo): void {
const lastActivity =
info.lastActivity === "-" ? chalk.dim("unknown") : chalk.dim(info.lastActivity);
console.log(
` ${chalk.magenta("Orchestrator:")} ${chalk.green(info.name)} ${chalk.dim("(")}${lastActivity}${chalk.dim(")")}`,
);
const displaySummary = info.claudeSummary || info.summary;
if (displaySummary) {
console.log(` ${chalk.dim(displaySummary.slice(0, 60))}`);
}
}
export function registerStatus(program: Command): void {
program
.command("status")
.description("Show all sessions with branch, activity, PR, and CI status")
.option("-p, --project <id>", "Filter by project ID")
.option("--json", "Output as JSON")
.option("-w, --watch", "Refresh the status view continuously")
.option("--interval <seconds>", "Refresh interval in seconds (default: 5)")
.action(async (opts: StatusOptions) => {
if (opts.watch && opts.json) {
console.error(chalk.red("--watch cannot be used with --json."));
process.exit(1);
}
let watchIntervalSeconds = DEFAULT_WATCH_INTERVAL_SECONDS;
if (opts.watch) {
try {
watchIntervalSeconds = parseWatchIntervalSeconds(opts.interval);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
}
const renderStatus = async (refreshing = false): Promise<void> => {
if (refreshing) {
maybeClearScreen();
}
let config: ReturnType<typeof loadConfig>;
try {
config = loadConfig();
} catch {
console.log(chalk.yellow("No config found. Run `ao init` first."));
console.log(chalk.dim("Falling back to session discovery...\n"));
await showFallbackStatus();
return;
}
if (opts.project && !config.projects[opts.project]) {
console.error(chalk.red(`Unknown project: ${opts.project}`));
process.exit(1);
}
// Use session manager to list sessions (metadata-based, not tmux-based)
const sm = await getSessionManager(config);
const registry = await getPluginRegistry(config);
const sessions = await sm.list(opts.project);
if (!opts.json) {
console.log(banner("AGENT ORCHESTRATOR STATUS"));
if (opts.watch) {
console.log(
chalk.dim(
`Refreshing every ${watchIntervalSeconds}s. Press Ctrl+C to exit.`,
),
);
console.log();
} else {
console.log();
}
}
// Group sessions by project
const byProject = new Map<string, Session[]>();
for (const s of sessions) {
const list = byProject.get(s.projectId) ?? [];
list.push(s);
byProject.set(s.projectId, list);
}
// Show projects that have no sessions too (if not filtered)
const projectIds = opts.project ? [opts.project] : Object.keys(config.projects);
const jsonOutput: SessionInfo[] = [];
let totalWorkers = 0;
let totalOrchestrators = 0;
for (const projectId of projectIds) {
const projectConfig = config.projects[projectId];
if (!projectConfig) continue;
const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) =>
a.id.localeCompare(b.id),
);
// Resolve agent and SCM for this project via the shared registry
const agentName = projectConfig.agent ?? config.defaults.agent;
const agent = getAgentByNameFromRegistry(registry, agentName);
const scm = getSCMFromRegistry(registry, config, projectId);
if (!opts.json) {
console.log(header(projectConfig.name || projectId));
}
if (projectSessions.length === 0) {
if (!opts.json) {
console.log(chalk.dim(" (no active sessions)"));
console.log();
}
continue;
}
// Gather all session info in parallel
const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config));
const sessionInfos = await Promise.all(infoPromises);
const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator");
const workers = sessionInfos.filter((info) => info.role === "worker");
totalWorkers += workers.length;
totalOrchestrators += orchestrators.length;
for (const info of sessionInfos) {
if (opts.json) {
jsonOutput.push(info);
}
}
if (opts.json) {
continue;
}
if (orchestrators.length > 0) {
for (const info of orchestrators) {
printOrchestratorRow(info);
}
}
if (workers.length === 0) {
console.log(chalk.dim(" (no active sessions)"));
console.log();
continue;
}
printTableHeader();
for (const info of workers) {
printSessionRow(info);
}
console.log();
}
if (opts.json) {
console.log(JSON.stringify(jsonOutput, null, 2));
} else {
console.log(
chalk.dim(
` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` +
(totalOrchestrators > 0
? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}`
: ""),
),
);
// Check for issues awaiting verification across all projects
try {
let unverifiedTotal = 0;
for (const projectId of projectIds) {
const project: ProjectConfig | undefined = config.projects[projectId];
if (!project?.tracker?.plugin) continue;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
try {
const issues = await tracker.listIssues(
{ state: "open", labels: ["merged-unverified"], limit: 20 },
project,
);
unverifiedTotal += issues.length;
} catch {
// Tracker query failed — not critical
}
}
if (unverifiedTotal > 0) {
console.log(
chalk.yellow(
`${unverifiedTotal} issue${unverifiedTotal !== 1 ? "s" : ""} awaiting verification (use \`ao verify --list\` to see them)`,
),
);
}
} catch {
// Plugin registry or tracker unavailable — skip silently
}
console.log();
}
};
await renderStatus();
if (!opts.watch) {
return;
}
let rendering = false;
const watchTimer = setInterval(() => {
if (rendering) return;
rendering = true;
void renderStatus(true)
.catch((err) => {
console.error(
chalk.red(
`Watch refresh failed: ${err instanceof Error ? err.message : String(err)}`,
),
);
})
.finally(() => {
rendering = false;
});
}, watchIntervalSeconds * 1000);
const shutdown = (): void => {
clearInterval(watchTimer);
process.exit(0);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
});
}
async function showFallbackStatus(): Promise<void> {
const allTmux = await getTmuxSessions();
if (allTmux.length === 0) {
console.log(chalk.dim("No tmux sessions found."));
return;
}
console.log(banner("AGENT ORCHESTRATOR STATUS"));
console.log();
console.log(
chalk.dim(` ${allTmux.length} tmux session${allTmux.length !== 1 ? "s" : ""} found\n`),
);
// Use claude-code as default agent for fallback introspection
const agent = getAgentByName("claude-code");
const sortedSessions = allTmux.sort();
// Pre-fetch activity and introspection in parallel
const details = await Promise.all(
sortedSessions.map(async (session) => {
const activityTsPromise = getTmuxActivity(session).catch(() => null);
const sessionObj: Session = {
id: session,
projectId: "",
status: "working",
activity: null,
branch: null,
issueId: null,
pr: null,
workspacePath: null,
runtimeHandle: { id: session, runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
};
const introspectionPromise = agent.getSessionInfo(sessionObj).catch(() => null);
const [activityTs, introspection] = await Promise.all([
activityTsPromise,
introspectionPromise,
]);
return { activityTs, introspection };
}),
);
for (let i = 0; i < sortedSessions.length; i++) {
const session = sortedSessions[i];
const { activityTs, introspection } = details[i];
const lastActivity = activityTs ? formatAge(activityTs) : "-";
console.log(` ${chalk.green(session)} ${chalk.dim(`(${lastActivity})`)}`);
if (introspection?.summary) {
console.log(` ${chalk.dim("Claude:")} ${introspection.summary.slice(0, 65)}`);
}
}
console.log();
}