import { statSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@mariozechner/pi-coding-agent"; import { formatBackgroundIndicator, getBackgroundStatusDetails, type BackgroundState } from "./background"; import { listRunDirs, tryReadRunState } from "./artifacts"; import { discoverSubagents, filterVisibleSubagents, type SubagentRegistry } from "./definitions"; import type { BackgroundJobSnapshot, ResumableSubagentRun } from "./types"; import { ensureActiveThemeLoaded, getActiveTheme } from "../../vera-theme/src/public"; void ensureActiveThemeLoaded(); /** High-level health bucket for the subagent runtime. */ export type SubagentOverallStatus = "ok" | "partial" | "degraded"; /** Human-readable issue summary derived from a status snapshot. */ export interface SubagentStatusSummary { overallStatus: SubagentOverallStatus; coreIssues: string[]; optionalIssues: string[]; } /** Machine-readable snapshot of discovered definitions and session runtime state. */ export interface SubagentSystemStatus { cwd: string; userDir: string; projectDir: string | null; /** Registry counts include only Vera-visible definitions after filtering. */ registry: { total: number; userCount: number; projectCount: number; names: string[]; errors: string[]; }; /** Live session surface observed from the current extension context. */ runtime: { sessionActive: boolean; hasUI: boolean; registeredTools: string[]; registeredCommands: string[]; statusIndicator: string | null; }; /** Session-owned background queue and recent job outcomes. */ background: { total: number; queued: number; running: number; finished: number; done: number; error: number; aborted: number; recentJobs: Array<{ jobId: string; status: "queued" | "running" | "done" | "error" | "aborted"; agent: string; task: string; summary: string; }>; }; summary: SubagentStatusSummary; } /** Global live runtime pointer used by diagnostics for the active session. */ export interface LiveSubagentRuntimeState { registry: SubagentRegistry; background: BackgroundState; sessionCtx?: any; } const REGISTERED_TOOLS = ["subagent", "subagent_status", "subagent_cancel", "subagent_inspect"]; const REGISTERED_COMMANDS = ["/subagents"]; export const STALE_RUNNING_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour /** * Determines whether a run's recorded phase is misleading because the process * has not flushed run.json recently. Pure function; takes mtime explicitly so * tests do not need to manipulate the filesystem. */ export function isStaleRunning( persisted: { phase?: string }, mtimeMs: number, now: number = Date.now(), ): boolean { return persisted.phase === "running" && (now - mtimeMs) > STALE_RUNNING_THRESHOLD_MS; } const COLOR_MAP: Record = { cyan: "accent", blue: "muted", green: "success", yellow: "warning", red: "error", slate: "dim", white: "text", }; const LIVE_RUNTIME_KEY = "__vera_subagents_live_runtime__"; function getGlobalStore(): { current: LiveSubagentRuntimeState | null } { const target = globalThis as typeof globalThis & { [LIVE_RUNTIME_KEY]?: { current: LiveSubagentRuntimeState | null } }; if (!target[LIVE_RUNTIME_KEY]) { target[LIVE_RUNTIME_KEY] = { current: null }; } return target[LIVE_RUNTIME_KEY]!; } function paint(text: string, color: string, enabled: boolean, bold = false): string { if (!enabled) return text; const semantic = COLOR_MAP[color] ?? "text"; const painted = getActiveTheme().fg(semantic, text); return bold ? paintBold(painted) : painted; } function paintBold(text: string): string { return getActiveTheme().bold(text); } function bool(value: boolean): string { return value ? "yes" : "no"; } function previewList(values: string[], max = 6): string { if (values.length === 0) return "(none)"; if (values.length <= max) return values.join(", "); return `${values.slice(0, max).join(", ")} … +${values.length - max} more`; } function clip(text: string, max = 160): string { const normalized = String(text ?? "").replace(/\s+/g, " ").trim(); if (!normalized) return ""; return normalized.length > max ? `${normalized.slice(0, max - 3)}...` : normalized; } function summarize(status: Omit): SubagentStatusSummary { const coreIssues: string[] = []; const optionalIssues: string[] = []; if (status.registry.errors.length > 0) { optionalIssues.push(`${status.registry.errors.length} YAML discovery/parsing issue(s)`); } if (status.registry.total === 0) { optionalIssues.push("no YAML-defined subagents discovered"); } if (status.background.error > 0) { optionalIssues.push(`${status.background.error} background job(s) failed`); } if (status.background.aborted > 0) { optionalIssues.push(`${status.background.aborted} background job(s) aborted`); } if (!status.runtime.hasUI) { optionalIssues.push("no UI attached for live subagent status indicator"); } return { overallStatus: coreIssues.length > 0 ? "degraded" : optionalIssues.length > 0 ? "partial" : "ok", coreIssues, optionalIssues, }; } export function setLiveSubagentRuntimeState(state: LiveSubagentRuntimeState | null): void { getGlobalStore().current = state; } export function collectResumableSubagentRuns(jobs: BackgroundJobSnapshot[] = []): ResumableSubagentRun[] { const inMemoryRunIds = new Set(jobs.map((job) => job.details.runId).filter(Boolean)); return listRunDirs().flatMap((dir) => { const persisted = tryReadRunState(dir); if (!persisted || persisted.phase === "done" || !persisted.runId || inMemoryRunIds.has(persisted.runId)) return []; let mtime = persisted.startedAt ?? Date.now(); try { mtime = statSync(dir).mtimeMs; } catch { // Fall back to startedAt when stat is unavailable. } return [{ runId: persisted.runId, phase: persisted.phase, agent: persisted.agent?.name, startedAt: persisted.startedAt, isStale: isStaleRunning(persisted, mtime), }]; }); } export function collectSubagentSystemStatus(cwd: string): SubagentSystemStatus { const discovered = filterVisibleSubagents(discoverSubagents(cwd)); const live = getGlobalStore().current; // Prefer the live session state only when it carries real data. An empty // live.registry can result from a Pi extension reload that published a // fresh state before any refresh ran; falling back to `discovered` keeps // diagnostics honest in that window. const liveRegistry = live?.registry; const liveHasData = liveRegistry !== undefined && (liveRegistry.definitions.length > 0 || liveRegistry.errors.length > 0); const registry = liveHasData ? liveRegistry! : discovered; const background = live ? getBackgroundStatusDetails(live.background) : { queued: 0, running: 0, finished: 0, jobs: [] }; const done = background.jobs.filter((job) => job.status === "done").length; const error = background.jobs.filter((job) => job.status === "error").length; const aborted = background.jobs.filter((job) => job.status === "aborted").length; const status: Omit = { cwd, userDir: join(getAgentDir(), "subagents"), projectDir: registry.projectDir, registry: { total: registry.definitions.length, userCount: registry.definitions.filter((definition) => definition.source === "user").length, projectCount: registry.definitions.filter((definition) => definition.source === "project").length, names: registry.definitions.map((definition) => definition.name), errors: registry.errors, }, runtime: { sessionActive: Boolean(live), hasUI: Boolean(live?.sessionCtx?.hasUI), registeredTools: [...REGISTERED_TOOLS], registeredCommands: [...REGISTERED_COMMANDS], statusIndicator: live ? formatBackgroundIndicator(live.background) : null, }, background: { total: background.jobs.length, queued: background.queued, running: background.running, finished: background.finished, done, error, aborted, recentJobs: background.jobs.slice(0, 8).map((job) => ({ jobId: job.jobId, status: job.status, agent: job.details.agent?.name ?? "generic", task: clip(job.details.task, 180), summary: clip( job.status === "done" ? job.details.finalText || job.details.lastEvent || "(done)" : job.details.error || job.details.liveText || job.details.lastEvent || `(${job.status})`, 220, ), })), }, }; return { ...status, summary: summarize(status), }; } export function buildSubagentSystemReport(input: { status: SubagentSystemStatus; colorize?: boolean; actionLines?: string[]; }): string { const { status } = input; const colorize = input.colorize === true; const lines: string[] = []; const stateStyle = (state?: "ok" | "warn" | "bad") => { if (state === "ok") return { marker: "●", color: "green" }; if (state === "warn") return { marker: "◐", color: "yellow" }; if (state === "bad") return { marker: "✕", color: "red" }; return { marker: "·", color: "slate" }; }; const section = (title: string) => { lines.push(paint(`├─ ${title}`, "cyan", colorize, true)); }; const item = (label: string, value: string, state?: "ok" | "warn" | "bad") => { const style = stateStyle(state); const marker = paint(style.marker, style.color, colorize, true); const labelText = paint(label.padEnd(22), "blue", colorize, true); const valueText = paint(value, state ? style.color : "white", colorize); lines.push(`│ ${marker} ${labelText} ${valueText}`); }; const issueBlock = (title: string, issues: string[], state: "ok" | "warn" | "bad") => { item(title, issues.length > 0 ? `${issues.length} issue(s)` : "clear", state); for (const issue of issues) { lines.push(`│ ${paint("↳", "slate", colorize)} ${paint(issue, "slate", colorize)}`); } }; const overallBadge = status.summary.overallStatus === "ok" ? paint("[ OK ]", "green", colorize, true) : status.summary.overallStatus === "partial" ? paint("[ PARTIAL ]", "yellow", colorize, true) : paint("[ DEGRADED ]", "red", colorize, true); lines.push(`${paint("STATUS", "cyan", colorize, true)} ${overallBadge}`); lines.push(`${paint("SUBAGENTS", "cyan", colorize, true)} ${paint(status.cwd, "white", colorize, true)}`); lines.push(paint("│", "slate", colorize)); if (input.actionLines && input.actionLines.length > 0) { section("ACTION"); for (const line of input.actionLines) { item("note", line, "ok"); } lines.push(paint("│", "slate", colorize)); } section("SUMMARY"); issueBlock("core", status.summary.coreIssues, status.summary.coreIssues.length > 0 ? "bad" : "ok"); issueBlock("optional", status.summary.optionalIssues, status.summary.optionalIssues.length > 0 ? "warn" : "ok"); lines.push(paint("│", "slate", colorize)); section("REGISTRY"); item("total definitions", String(status.registry.total), status.registry.total > 0 ? "ok" : "warn"); item("user definitions", String(status.registry.userCount), status.registry.userCount > 0 ? "ok" : undefined); item("project definitions", String(status.registry.projectCount), status.registry.projectCount > 0 ? "ok" : undefined); item("definition names", previewList(status.registry.names), status.registry.names.length > 0 ? "ok" : "warn"); item("user dir", status.userDir, "ok"); item("project dir", status.projectDir ?? "(none)", status.projectDir ? "ok" : undefined); issueBlock("discovery errors", status.registry.errors, status.registry.errors.length > 0 ? "warn" : "ok"); lines.push(paint("│", "slate", colorize)); section("RUNTIME"); item("session active", bool(status.runtime.sessionActive), status.runtime.sessionActive ? "ok" : "warn"); item("ui attached", bool(status.runtime.hasUI), status.runtime.hasUI ? "ok" : "warn"); item("tools", status.runtime.registeredTools.join(", "), "ok"); item("commands", status.runtime.registeredCommands.join(", "), "ok"); item("status indicator", status.runtime.statusIndicator ?? "(idle)", status.runtime.statusIndicator ? "ok" : undefined); lines.push(paint("│", "slate", colorize)); section("BACKGROUND JOBS"); item("total", String(status.background.total), status.background.total > 0 ? "ok" : undefined); item("queued", String(status.background.queued), status.background.queued > 0 ? "warn" : "ok"); item("running", String(status.background.running), status.background.running > 0 ? "warn" : "ok"); item("finished", String(status.background.finished), "ok"); item("done", String(status.background.done), status.background.done > 0 ? "ok" : undefined); item("error", String(status.background.error), status.background.error > 0 ? "warn" : "ok"); item("aborted", String(status.background.aborted), status.background.aborted > 0 ? "warn" : "ok"); if (status.background.recentJobs.length === 0) { item("recent jobs", "(none)", undefined); } else { for (const job of status.background.recentJobs) { const state = job.status === "done" ? "ok" : job.status === "error" ? "bad" : "warn"; item(job.jobId, `${job.status} · ${job.agent} · ${job.summary}`, state); } } return lines.join("\n"); }