feat: enrich `ao status` with PR, CI, review, threads, and activity columns

The status command now displays a rich table with branch, PR number, CI
status, review decision, pending review threads, and agent activity state.
Data is fetched from the SCM plugin (GitHub) in parallel for performance.

Closes INT-1348

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-14 17:20:48 +05:30
parent 1eba42097b
commit 5b68fccbd0
6 changed files with 428 additions and 35 deletions

View File

@ -3,11 +3,15 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const { mockTmux, mockGit, mockConfigRef, mockIntrospect } = vi.hoisted(() => ({
const { mockTmux, mockGit, mockConfigRef, mockIntrospect, mockDetectPR, mockGetCISummary, mockGetReviewDecision, mockGetPendingComments } = vi.hoisted(() => ({
mockTmux: vi.fn(),
mockGit: vi.fn(),
mockConfigRef: { current: null as Record<string, unknown> | null },
mockIntrospect: vi.fn(),
mockDetectPR: vi.fn(),
mockGetCISummary: vi.fn(),
mockGetReviewDecision: vi.fn(),
mockGetPendingComments: vi.fn(),
}));
vi.mock("../../src/lib/shell.js", () => ({
@ -38,13 +42,27 @@ vi.mock("../../src/lib/plugins.js", () => ({
name: "claude-code",
processName: "claude",
detectActivity: () => "idle",
introspect: mockIntrospect,
getSessionInfo: mockIntrospect,
}),
getAgentByName: () => ({
name: "claude-code",
processName: "claude",
detectActivity: () => "idle",
introspect: mockIntrospect,
getSessionInfo: mockIntrospect,
}),
getSCM: () => ({
name: "github",
detectPR: mockDetectPR,
getCISummary: mockGetCISummary,
getReviewDecision: mockGetReviewDecision,
getPendingComments: mockGetPendingComments,
getAutomatedComments: vi.fn().mockResolvedValue([]),
getCIChecks: vi.fn().mockResolvedValue([]),
getReviews: vi.fn().mockResolvedValue([]),
getMergeability: vi.fn().mockResolvedValue({ mergeable: true, ciPassing: true, approved: false, noConflicts: true, blockers: [] }),
getPRState: vi.fn().mockResolvedValue("open"),
mergePR: vi.fn(),
closePR: vi.fn(),
}),
}));
@ -75,6 +93,7 @@ beforeEach(() => {
path: "/home/user/my-app",
defaultBranch: "main",
sessionPrefix: "app",
scm: { plugin: "github" },
},
},
notifiers: {},
@ -94,6 +113,14 @@ beforeEach(() => {
mockGit.mockReset();
mockIntrospect.mockReset();
mockIntrospect.mockResolvedValue(null);
mockDetectPR.mockReset();
mockDetectPR.mockResolvedValue(null);
mockGetCISummary.mockReset();
mockGetCISummary.mockResolvedValue("none");
mockGetReviewDecision.mockReset();
mockGetReviewDecision.mockResolvedValue("none");
mockGetPendingComments.mockReset();
mockGetPendingComments.mockResolvedValue([]);
});
afterEach(() => {
@ -213,4 +240,169 @@ describe("status command", () => {
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("live-branch");
});
it("shows table header with column names", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, "app-1"), "branch=main\nstatus=idle\n");
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") return null;
return null;
});
mockGit.mockResolvedValue(null);
await program.parseAsync(["node", "test", "status"]);
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("Session");
expect(output).toContain("Branch");
expect(output).toContain("PR");
expect(output).toContain("CI");
expect(output).toContain("Activity");
});
it("shows PR number, CI status, review decision, and threads", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt\nbranch=feat/test\nstatus=working\n",
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") return String(Math.floor(Date.now() / 1000) - 60);
return null;
});
mockGit.mockResolvedValue("feat/test");
mockDetectPR.mockResolvedValue({
number: 42,
url: "https://github.com/org/repo/pull/42",
title: "Test PR",
owner: "org",
repo: "repo",
branch: "feat/test",
baseBranch: "main",
isDraft: false,
});
mockGetCISummary.mockResolvedValue("passing");
mockGetReviewDecision.mockResolvedValue("approved");
mockGetPendingComments.mockResolvedValue([
{ id: "1", author: "reviewer", body: "fix this", isResolved: false, createdAt: new Date(), url: "" },
{ id: "2", author: "reviewer2", body: "fix that", isResolved: false, createdAt: new Date(), url: "" },
]);
await program.parseAsync(["node", "test", "status"]);
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("#42");
expect(output).toContain("pass");
expect(output).toContain("ok"); // approved
expect(output).toContain("2"); // pending threads
});
it("shows failing CI and changes_requested review", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt\nbranch=feat/broken\nstatus=working\n",
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") return null;
return null;
});
mockGit.mockResolvedValue("feat/broken");
mockDetectPR.mockResolvedValue({
number: 7,
url: "https://github.com/org/repo/pull/7",
title: "Broken PR",
owner: "org",
repo: "repo",
branch: "feat/broken",
baseBranch: "main",
isDraft: false,
});
mockGetCISummary.mockResolvedValue("failing");
mockGetReviewDecision.mockResolvedValue("changes_requested");
mockGetPendingComments.mockResolvedValue([]);
await program.parseAsync(["node", "test", "status"]);
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("#7");
expect(output).toContain("fail");
expect(output).toContain("chg!"); // changes_requested
});
it("handles SCM errors gracefully", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt\nbranch=feat/err\nstatus=working\n",
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") return null;
return null;
});
mockGit.mockResolvedValue("feat/err");
mockDetectPR.mockRejectedValue(new Error("gh failed"));
await program.parseAsync(["node", "test", "status"]);
// Should still show the session without crashing
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("app-1");
expect(output).toContain("feat/err");
});
it("outputs JSON with enriched fields", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt\nbranch=feat/json\nstatus=working\n",
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") return String(Math.floor(Date.now() / 1000));
return null;
});
mockGit.mockResolvedValue("feat/json");
mockDetectPR.mockResolvedValue({
number: 10,
url: "https://github.com/org/repo/pull/10",
title: "JSON PR",
owner: "org",
repo: "repo",
branch: "feat/json",
baseBranch: "main",
isDraft: false,
});
mockGetCISummary.mockResolvedValue("passing");
mockGetReviewDecision.mockResolvedValue("pending");
mockGetPendingComments.mockResolvedValue([]);
await program.parseAsync(["node", "test", "status", "--json"]);
const jsonCalls = consoleSpy.mock.calls.map((c) => c[0]).join("");
const parsed = JSON.parse(jsonCalls);
expect(parsed).toHaveLength(1);
expect(parsed[0].prNumber).toBe(10);
expect(parsed[0].ciStatus).toBe("passing");
expect(parsed[0].reviewDecision).toBe("pending");
expect(parsed[0].pendingThreads).toBe(0);
});
});

View File

@ -20,6 +20,7 @@
"@agent-orchestrator/plugin-agent-claude-code": "workspace:*",
"@agent-orchestrator/plugin-agent-codex": "workspace:*",
"@agent-orchestrator/plugin-agent-aider": "workspace:*",
"@agent-orchestrator/plugin-scm-github": "workspace:*",
"chalk": "^5.4.0",
"commander": "^13.0.0",
"ora": "^8.1.0",

View File

@ -2,15 +2,29 @@ import chalk from "chalk";
import type { Command } from "commander";
import {
type Agent,
type SCM,
type OrchestratorConfig,
type Session,
type RuntimeHandle,
type ProjectConfig,
type PRInfo,
type CIStatus,
type ReviewDecision,
type ActivityState,
loadConfig,
} from "@agent-orchestrator/core";
import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js";
import { getSessionDir, readMetadata } from "../lib/metadata.js";
import { banner, header, formatAge, statusColor } from "../lib/format.js";
import { getAgent, getAgentByName } from "../lib/plugins.js";
import {
banner,
header,
formatAge,
activityIcon,
ciStatusIcon,
reviewDecisionIcon,
padCol,
} from "../lib/format.js";
import { getAgent, getAgentByName, getSCM } from "../lib/plugins.js";
import { matchesPrefix } from "../lib/session-utils.js";
interface SessionInfo {
@ -20,16 +34,25 @@ interface SessionInfo {
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;
}
/**
* Build a minimal Session object for agent.getSessionInfo().
* Build a minimal Session object for agent.getSessionInfo() and SCM.detectPR().
* Only runtimeHandle and workspacePath are needed by the introspection logic.
*/
function buildSessionForIntrospect(sessionName: string, workspacePath?: string): Session {
function buildSessionForIntrospect(
sessionName: string,
workspacePath?: string,
branch?: string | null,
): Session {
const handle: RuntimeHandle = {
id: sessionName,
runtimeName: "tmux",
@ -40,7 +63,7 @@ function buildSessionForIntrospect(sessionName: string, workspacePath?: string):
projectId: "",
status: "working",
activity: "idle",
branch: null,
branch: branch ?? null,
issueId: null,
pr: null,
workspacePath: workspacePath || null,
@ -56,6 +79,8 @@ async function gatherSessionInfo(
sessionName: string,
sessionDir: string,
agent: Agent,
scm: SCM,
projectConfig: ProjectConfig,
): Promise<SessionInfo> {
const metaFile = `${sessionDir}/${sessionName}`;
const meta = readMetadata(metaFile);
@ -63,7 +88,7 @@ async function gatherSessionInfo(
let branch = meta?.branch ?? null;
const status = meta?.status ?? null;
const summary = meta?.summary ?? null;
const pr = meta?.pr ?? null;
const prUrl = meta?.pr ?? null;
const issue = meta?.issue ?? null;
const project = meta?.project ?? null;
@ -78,45 +103,122 @@ async function gatherSessionInfo(
const activityTs = await getTmuxActivity(sessionName);
const lastActivity = activityTs ? formatAge(activityTs) : "-";
// Get agent's auto-generated summary via introspection
// Get agent's auto-generated summary and activity via introspection
let claudeSummary: string | null = null;
let activity: ActivityState | null = null;
try {
const session = buildSessionForIntrospect(sessionName, worktree);
const session = buildSessionForIntrospect(sessionName, worktree, branch);
const introspection = await agent.getSessionInfo(session);
claudeSummary = introspection?.summary ?? null;
// Detect activity from agent's last message type
if (introspection?.lastMessageType === "summary") {
activity = "idle";
} else if (introspection?.lastMessageType) {
activity = "active";
}
} catch {
// Introspection failed — not critical
}
// 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;
if (branch) {
try {
const session = buildSessionForIntrospect(sessionName, worktree, branch);
const prInfo: PRInfo | null = await scm.detectPR(session, projectConfig);
if (prInfo) {
prNumber = prInfo.number;
// Fetch CI, reviews, and threads in parallel
const [ci, review, threads] = await Promise.all([
scm.getCISummary(prInfo).catch(() => null),
scm.getReviewDecision(prInfo).catch(() => null),
scm.getPendingComments(prInfo).catch(() => []),
]);
ciStatus = ci;
reviewDecision = review;
pendingThreads = threads ? threads.length : null;
}
} catch {
// SCM lookup failed — not critical, we still show what we have
}
}
return {
name: sessionName,
branch,
status,
summary,
claudeSummary,
pr,
pr: prUrl,
prNumber,
issue,
lastActivity,
project,
ciStatus,
reviewDecision,
pendingThreads,
activity,
};
}
function printSession(info: SessionInfo): void {
const statusStr = info.status ? ` ${statusColor(info.status)}` : "";
console.log(` ${chalk.green(info.name)} ${chalk.dim(`(${info.lastActivity})`)}${statusStr}`);
if (info.branch) {
console.log(` ${chalk.dim("Branch:")} ${info.branch}`);
}
if (info.issue) {
console.log(` ${chalk.dim("Issue:")} ${info.issue}`);
}
if (info.pr) {
console.log(` ${chalk.dim("PR:")} ${chalk.blue(info.pr)}`);
}
if (info.claudeSummary) {
console.log(` ${chalk.dim("Claude:")} ${info.claudeSummary.slice(0, 65)}`);
} else if (info.summary) {
console.log(` ${chalk.dim("Summary:")} ${info.summary.slice(0, 65)}`);
// 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))}`);
}
}
@ -160,8 +262,9 @@ export function registerStatus(program: Command): void {
const sessionDir = getSessionDir(config.dataDir, projectId);
const projectSessions = allTmux.filter((s) => matchesPrefix(s, prefix));
// Resolve agent for this project
// Resolve plugins for this project
const agent = getAgent(config, projectId);
const scm = getSCM(config, projectId);
if (!opts.json) {
console.log(header(projectConfig.name || projectId));
@ -177,15 +280,29 @@ export function registerStatus(program: Command): void {
totalSessions += projectSessions.length;
for (const session of projectSessions.sort()) {
const info = await gatherSessionInfo(session, sessionDir, agent);
if (!opts.json) {
printTableHeader();
}
// Gather all session info in parallel
const infoPromises = projectSessions
.sort()
.map((session) =>
gatherSessionInfo(session, sessionDir, agent, scm, projectConfig),
);
const sessionInfos = await Promise.all(infoPromises);
for (const info of sessionInfos) {
if (opts.json) {
jsonOutput.push(info);
} else {
printSession(info);
console.log();
printSessionRow(info);
}
}
if (!opts.json) {
console.log();
}
}
if (opts.json) {
@ -193,7 +310,7 @@ export function registerStatus(program: Command): void {
} else {
console.log(
chalk.dim(
`\n ${totalSessions} active session${totalSessions !== 1 ? "s" : ""} across ${Object.keys(projects).length} project${Object.keys(projects).length !== 1 ? "s" : ""}`,
` ${totalSessions} active session${totalSessions !== 1 ? "s" : ""} across ${Object.keys(projects).length} project${Object.keys(projects).length !== 1 ? "s" : ""}`,
),
);
console.log();

View File

@ -1,4 +1,5 @@
import chalk from "chalk";
import type { CIStatus, ReviewDecision, ActivityState } from "@agent-orchestrator/core";
export function header(title: string): string {
const line = "─".repeat(76);
@ -55,3 +56,65 @@ export function statusColor(status: string): string {
return status;
}
}
export function ciStatusIcon(status: CIStatus | null): string {
switch (status) {
case "passing":
return chalk.green("pass");
case "failing":
return chalk.red("fail");
case "pending":
return chalk.yellow("pend");
case "none":
case null:
return chalk.dim("-");
}
}
export function reviewDecisionIcon(decision: ReviewDecision | null): string {
switch (decision) {
case "approved":
return chalk.green("ok");
case "changes_requested":
return chalk.red("chg!");
case "pending":
return chalk.yellow("rev?");
case "none":
case null:
return chalk.dim("-");
}
}
export function activityIcon(activity: ActivityState | null): string {
switch (activity) {
case "active":
return chalk.green("working");
case "idle":
return chalk.yellow("idle");
case "waiting_input":
return chalk.magenta("waiting");
case "blocked":
return chalk.red("blocked");
case "exited":
return chalk.dim("exited");
case null:
return chalk.dim("-");
}
}
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\u001b\[[0-9;]*m/g;
/** Pad/truncate a string to exactly `width` visible characters */
export function padCol(str: string, width: number): string {
// Strip ANSI codes to measure visible length
const visible = str.replace(ANSI_RE, "");
if (visible.length > width) {
// Truncate visible content, re-apply truncation
const plain = visible.slice(0, width - 1) + "\u2026";
return plain.padEnd(width);
}
// Pad with spaces based on visible length
const padding = width - visible.length;
return str + " ".repeat(Math.max(0, padding));
}

View File

@ -1,7 +1,8 @@
import type { Agent, OrchestratorConfig } from "@agent-orchestrator/core";
import type { Agent, OrchestratorConfig, SCM } from "@agent-orchestrator/core";
import claudeCodePlugin from "@agent-orchestrator/plugin-agent-claude-code";
import codexPlugin from "@agent-orchestrator/plugin-agent-codex";
import aiderPlugin from "@agent-orchestrator/plugin-agent-aider";
import githubSCMPlugin from "@agent-orchestrator/plugin-scm-github";
const agentPlugins: Record<string, { create(): Agent }> = {
"claude-code": claudeCodePlugin,
@ -9,6 +10,10 @@ const agentPlugins: Record<string, { create(): Agent }> = {
aider: aiderPlugin,
};
const scmPlugins: Record<string, { create(): SCM }> = {
github: githubSCMPlugin,
};
/**
* Resolve the Agent plugin for a project (or fall back to the config default).
* Direct import no dynamic loading needed since the CLI depends on all agent plugins.
@ -31,3 +36,15 @@ export function getAgentByName(name: string): Agent {
}
return plugin.create();
}
/**
* Resolve the SCM plugin for a project (or fall back to "github").
*/
export function getSCM(config: OrchestratorConfig, projectId: string): SCM {
const scmName = config.projects[projectId]?.scm?.plugin || "github";
const plugin = scmPlugins[scmName];
if (!plugin) {
throw new Error(`Unknown SCM plugin: ${scmName}`);
}
return plugin.create();
}

View File

@ -41,6 +41,9 @@ importers:
'@agent-orchestrator/plugin-agent-codex':
specifier: workspace:*
version: link:../plugins/agent-codex
'@agent-orchestrator/plugin-scm-github':
specifier: workspace:*
version: link:../plugins/scm-github
chalk:
specifier: ^5.4.0
version: 5.6.2