import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts"; import { type LiveAgentHandle, listLiveAgents, listLiveAgentsByWorkspace } from "../../runtime/live-session/live-agent-manager.ts"; import { formatCost } from "../../state/usage.ts"; import { visibleWidth } from "../../utils/visual.ts"; import { formatCount, formatDuration } from "../format-helpers.ts"; import { computeLiveDurationMs } from "../live-duration.ts"; import { overflowHint, statusIcon } from "../rail.ts"; import type { RunDashboardOptions } from "../run-dashboard.ts"; import type { RunUiSnapshot } from "../snapshot-types.ts"; import { spinnerFrame } from "../spinner.ts"; import { iconForStatus } from "../status-colors.ts"; import { PANE_THEME } from "./pane-theme.ts"; /** * Fixed visible widths for the per-agent numeric metrics (finding V-1). * Right-aligning each field to a constant width stops the right edge of the * stats block from jittering every tick as values change width * (e.g. `9.9s`→`10.0s`, `950`→`1.0k`, `$0.9`→`$1.0`). */ const TOKENS_METRIC_WIDTH = 5; // `1.2k`, `12.3k`, `123` const COST_METRIC_WIDTH = 7; // `$0.0500`, `$1.50` const DURATION_METRIC_WIDTH = 6; // `3.0s`, `59.9s`, `5m14s` (formatDuration) /** * Width-aware right-align (padStart) for a numeric metric so its column stays * stable across ticks. Uses `visibleWidth` so wide/CJK glyphs never misalign * the padding. Values wider than `width` are returned verbatim (no truncation) * so outliers overflow gracefully instead of crashing the render. */ function alignMetric(value: string, width: number): string { const gap = width - visibleWidth(value); return gap > 0 ? " ".repeat(gap) + value : value; } /** * Agent status glyph, from the shared RAIL vocabulary (`statusIcon`). Two * deliberate exceptions, both kept because rail.ts cannot express them: * - `running` → the animated braille spinner (a live activity frame, not a * static status icon); * - `waiting`/`needs_attention`/`blocked` → ⏳/⚠/⏸: `statusIcon` has only * four outputs (✓ ✗ ⟳ ○) and would collapse all three into `○`, losing * the signal; the shared `status-colors` vocabulary is used for exactly * these. * `queued` now renders rail's `○` instead of the legacy `◦`. */ function agentGlyph(status: string | undefined, taskId: string | undefined): string { switch (status) { case "running": return spinnerFrame(taskId ?? ""); case "waiting": case "needs_attention": case "blocked": return iconForStatus(status); default: return statusIcon(status ?? "unknown", PANE_THEME); } } /** * Returns true if this agent did real work (LLM call, tool use, or non-trivial duration). * Scaffold-only agents (no tokens, no tools, no turns) are skipped in the agents pane — * they represent pipeline infrastructure steps, not actual agent execution. */ function isRealAgent(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle, nowMs?: number): boolean { if (agent.runtime === "live-session" || agent.runtime === "child-process") return true; // Scaffold agents with real work done are still worth showing const tokens = (agent.usage?.input ?? 0) + (agent.usage?.output ?? 0) + (agent.usage?.cacheRead ?? 0) + (agent.usage?.cacheWrite ?? 0); if (tokens > 0) return true; const turns = (agent.usage as { turns?: number } | undefined)?.turns; if (turns != null && turns > 0) return true; if ((agent.progress?.toolCount ?? 0) > 0) return true; // If it's still running and has been alive for > 30s, it might be real if (liveHandle) { const ms = (nowMs ?? Date.now()) - liveHandle.activity.startedAtMs; if (ms > 30_000) return true; } return false; } const TOOL_LABELS: Record = { read: "reading", bash: "cmd", edit: "editing", write: "writing", grep: "searching", find: "finding", ls: "listing", }; function describeActivity(handle: LiveAgentHandle): string { const act = handle.activity; if (act.activeTools.size > 0) { const tools = [...new Set([...act.activeTools.values()])].map((t) => TOOL_LABELS[t] ?? t); return tools.join(", ") + "…"; } if (act.responseText?.trim()) { const line = act.responseText .split("\n") .find((l) => l.trim()) ?.trim() ?? ""; return line.length > 40 ? line.slice(0, 40) + "…" : line; } return "thinking…"; } export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: RunDashboardOptions = {}): string[] { if (!snapshot) return ["(snapshot unavailable)"]; if (!snapshot.agents.length) return ["(no agents)"]; // Filter live agents by workspaceId for session isolation const allLive = options.workspaceId ? listLiveAgentsByWorkspace(options.workspaceId) : listLiveAgents(); const liveForRun = allLive.filter((h) => h.runId === snapshot.runId); const { completed, total } = snapshot.progress; const lines: string[] = []; // One pinned clock (D6-T4) for the whole pane render. const nowMs = options.nowMs ?? Date.now(); // RR-021 WI-4.3c: first-wins Map keyed by taskId — the two `.find` sites // below (real-agent filter + per-agent handle) must keep seeing the SAME // (first) matching handle, so the build guards with has() instead of set() // overwriting later duplicates. const liveByTaskId = new Map(); for (const handle of liveForRun) { if (!liveByTaskId.has(handle.taskId)) liveByTaskId.set(handle.taskId, handle); } const realAgents = snapshot.agents.filter((a) => isRealAgent(a, liveByTaskId.get(a.taskId), nowMs)); const lineCount = Math.min(realAgents.length, 12); const label = realAgents.length !== snapshot.agents.length ? `${formatCount(realAgents.length, "real agent")} (${formatCount(snapshot.agents.length, "agent")} total)` : formatCount(realAgents.length, "agent"); // `3/3 tasks · 3 agents` — the label arrives pre-pluralised by the caller // ("N agents"); the task tally keeps the slash form on purpose (it reads as // progress, not as a count), with the noun pluralised by the TOTAL // (formatCount, L9): `1/1 task`, `3/3 tasks`. lines.push(`${completed}/${formatCount(total, "task")} · ${label}`); for (const agent of realAgents.slice(0, 12)) { const liveHandle = liveByTaskId.get(agent.taskId); const icon = agentGlyph(agent.status, agent.taskId); const role = `${agent.role ?? "?"}`; // Compact activity line const activity = liveHandle ? describeActivity(liveHandle) : agent.progress?.currentTool ? `${TOOL_LABELS[agent.progress.currentTool] ?? agent.progress.currentTool}…` : agent.status === "running" ? "thinking…" : agent.status === "queued" ? "queued" : agent.status === "failed" ? (agent.error ?? "failed") : "done"; // Stats: tokens + cost + duration const stats: string[] = []; const tokenTotal = (agent.usage?.input ?? 0) + (agent.usage?.output ?? 0) + (agent.usage?.cacheRead ?? 0) + (agent.usage?.cacheWrite ?? 0); if (tokenTotal > 0) { const tok = tokenTotal >= 1000 ? `${(tokenTotal / 1000).toFixed(1)}k` : `${tokenTotal}`; stats.push(alignMetric(tok, TOKENS_METRIC_WIDTH)); } // Per-agent cost (Round 17 BS-1): the data is already on task.usage.cost; // surface it live so the user sees $ burn per agent during a run. if (agent.usage?.cost && agent.usage.cost > 0) { stats.push(alignMetric(formatCost(agent.usage.cost), COST_METRIC_WIDTH)); } if (liveHandle) { // Round 23 (BUG 1): the duration math here was naive — // (completedAtMs ?? Date.now()) - startedAtMs // which produced a giant NEGATIVE duration whenever startedAtMs was // 0/undefined/bad, or a race set completedAtMs < startedAtMs. This // fired for EVERY running live agent in the dashboard. Use the shared, // validated computeLiveDurationMs (mirrors widget-formatters.ts). const ms = computeLiveDurationMs(liveHandle.activity, nowMs); stats.push(alignMetric(formatDuration(ms), DURATION_METRIC_WIDTH)); if (options.showModel !== false && liveHandle.modelName && liveHandle.modelName !== "default") { stats.push(liveHandle.modelName); } } else if (agent.startedAt) { // Tier 13 (2026-09-21): a FINISHED agent's span must end at completedAt, // never at the wall clock. The naive `nowMs - startedAt` made every // completed agent of an old run inflate forever — the dashboard showed // `01_explore … 29m39s` while the tool card showed the real `8m22s` for // the same agent. Mirror computeLiveDurationMs: prefer a sane completedAt, // else fall back to now; never emit a negative or absurd span. const startedMs = new Date(agent.startedAt).getTime(); const rawCompleted = agent.completedAt ? new Date(agent.completedAt).getTime() : Number.NaN; const completedMs = Number.isFinite(rawCompleted) && rawCompleted >= startedMs && rawCompleted <= nowMs ? rawCompleted : nowMs; const ms = completedMs - startedMs; if (Number.isFinite(ms) && ms >= 0) stats.push(alignMetric(formatDuration(ms), DURATION_METRIC_WIDTH)); } const statsStr = stats.length ? ` · ${stats.join(" ")}` : ""; lines.push(` ${icon} ${agent.taskId ?? "?"} ${role}${statsStr}`); lines.push(` ${activity}`); } // Overflow in the ONE rail dialect (`▲ n above` / `▼ m below`), replacing // the legacy `… +N more`. The count is derived from the SAME list the cap // slices (`realAgents`), not from `snapshot.agents` — the pre-migration // line could report rows that were never hidden (scaffold agents filtered // out above). const hiddenBelow = realAgents.length - 12; if (hiddenBelow > 0) lines.push(` ${overflowHint(0, hiddenBelow, PANE_THEME)}`); return lines; }