diff --git a/CHANGELOG.md b/CHANGELOG.md index 492a4adbe..d38be984d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- pi-agents-tmux: Monitor tree task rows show elapsed/total run-time instead + of a jumpy local `HH:MM` clock (`updatedAt` is no longer a time source); + detail-pane timestamps render local human time instead of UTC ISO, and the + Task Summary gains a Duration line once terminal; running elapsed keeps + ticking even with spinner animation off (VST-316). + - growth-guards: the pre-commit shim chain runs `preflight --staged` when that skill is installed beside it — a human committing outside any harness gets the deterministic checks CI would report, first; a repository's first diff --git a/pi-extensions/pi-agents-tmux/CHANGELOG.md b/pi-extensions/pi-agents-tmux/CHANGELOG.md index 58882bd1a..467e64629 100644 --- a/pi-extensions/pi-agents-tmux/CHANGELOG.md +++ b/pi-extensions/pi-agents-tmux/CHANGELOG.md @@ -2,6 +2,10 @@ ## Consumer-impacting changes +### 2.8.3 + +- Agents popup times are readable run-times, not machine stamps (vstack VST-316). Monitor tree task rows show elapsed run-time — `createdAt` → now while active, `createdAt` → `completedAt` once terminal — instead of a bare local `HH:MM` clock that was ambiguous (duration vs wall time) and jumped whenever a registry poll refreshed `updatedAt`; `updatedAt` is no longer a time-of-day source anywhere in the tree. Detail panes (Session Start/Latest, Task Summary Created/Done) render local human time (`Mar 24, 16:59`) instead of UTC ISO, and the Task Summary gains a Duration line once the task is terminal (the Summary text is cached until status changes, so a live elapsed there would freeze). Running elapsed is minute-granular (`<1m` under a minute) and keeps ticking even with spinner animation disabled — the popup timer now re-renders on a slow cadence while any task is live. + ### 2.8.2 - Oneshot transcript records are written strictly in event order. Each record was a separate concurrent `appendFile` call; such calls land in any order under load, so a transcript could carry an earlier `message_end` after the final buffered partial and `getFinalOutput`/last-assistant-text extraction reported the wrong message (vstack#1311). Appends now run through one ordered chain (`createTranscriptAppender`); a failed write never blocks the next record and surfaces on the result as a `transcript write failed` diagnostic instead of being dropped. diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/browser.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/browser.ts index 3cbc8e30d..15e35eabf 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/browser.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/browser.ts @@ -203,13 +203,26 @@ function createAgentsBrowserComponent( // finished one) and animate spinners. It renders only when something moved, so // an idle pop-up stays still. Always armed — agents can start after the pop-up // opened, when nothing was animating yet. + let lastElapsedRender = Date.now(); const liveTimer = setInterval(() => { if (closed) return; if (refreshMonitorView()) { + lastElapsedRender = Date.now(); requestRender(); return; } - if (spinnersAnimated() && getActiveItems().some((item) => isDashboardAnimatingStatus(item.status))) requestRender(); + const hasLiveItems = getActiveItems().some((item) => isDashboardAnimatingStatus(item.status)); + if (spinnersAnimated() && hasLiveItems) { + lastElapsedRender = Date.now(); + requestRender(); + return; + } + // Elapsed run-times tick at minute granularity; keep them moving even + // with spinner animation off and no lifecycle changes. + if (hasLiveItems && Date.now() - lastElapsedRender >= 20_000) { + lastElapsedRender = Date.now(); + requestRender(); + } }, 120); liveTimer.unref?.(); const scheduleResizeRender = () => { diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-session-detail.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-session-detail.ts index 62dcd8bae..916e30773 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-session-detail.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-session-detail.ts @@ -5,6 +5,7 @@ import { effortFromModelId, modelWithoutEffortSuffix, normalizeReasoningEffort } import type { AgentBrowserUiState, TraceViewerItem } from "../types.js"; import { recordRunEffort, recordRunModel } from "./agents-tab.js"; import { + formatLocalDateTime, monitorStatusIcon, monitorStatusText, monitorTaskRowLabel, @@ -13,13 +14,6 @@ import { import { renderTraceContentLine } from "./monitor-task-detail.js"; import { agentPaneTitle } from "./shared.js"; -function formatDateTime(raw: string | undefined): string { - if (!raw) return "—"; - const date = new Date(raw); - if (!Number.isFinite(date.getTime())) return raw; - return date.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z"); -} - function formatDurationBetween(start: string | undefined, end: string | undefined): string { const startMs = Date.parse(start ?? ""); const endMs = Date.parse(end ?? ""); @@ -81,8 +75,8 @@ export function renderMonitorSessionDetail(group: MonitorSessionGroup | undefine model ? `Model ${model}` : "", effort ? `Effort ${effort}` : "", sessionDetail ? `Session ${sessionDetail}` : "", - `Start ${formatDateTime(group.createdAt)}`, - `Latest ${formatDateTime(group.latestAt)}`, + `Start ${formatLocalDateTime(group.createdAt)}`, + `Latest ${formatLocalDateTime(group.latestAt)}`, `Duration ${formatDurationBetween(group.createdAt, group.latestAt)}`, `Tasks ${taskCountText} · ${monitorStatusBreakdown(group)}`, group.usage ? `Usage ${formatUsageStats(group.usage)}` : "Usage —", diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-task-detail.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-task-detail.ts index 1e1432c9e..a09f5f152 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-task-detail.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-task-detail.ts @@ -11,6 +11,7 @@ import { highlightInlinePreview, } from "../format.js"; import { readTextFileIfExists, recordTraceRef } from "../renderers.js"; +import { monitorStatusIsTerminal } from "../task-records.js"; import { formatTranscriptForDisplay, inputDeliveryLabel } from "../transcripts.js"; import { MONITOR_SUBTAB_LABELS, @@ -19,6 +20,7 @@ import { type PaneTaskRecord, type TraceViewerItem, } from "../types.js"; +import { formatLocalDateTime, monitorTaskRunTime } from "./monitor-tree.js"; import { agentActivePill, agentDivider, agentInactivePill, agentPaneTitle } from "./shared.js"; function wrapPlainNoEllipsis(text: string, width: number): string[] { @@ -218,8 +220,11 @@ export async function traceViewerItems(record: PaneTaskRecord, taskNumber?: numb `Task ID ${record.taskId}`, usage ? `Usage ${usage}` : "", delivery ? `Delivery ${delivery}` : "", - `Created ${record.createdAt}`, - record.completedAt ? `Done ${record.completedAt}` : "", + `Created ${formatLocalDateTime(record.createdAt)}`, + record.completedAt ? `Done ${formatLocalDateTime(record.completedAt)}` : "", + // Duration only once terminal: this text is cached until the record's + // status changes, so a live elapsed here would freeze at load time. + monitorStatusIsTerminal(record.status) ? `Duration ${monitorTaskRunTime(record)}` : "", artifactLines.length ? BLANK : "", artifactLines.length ? "Artifacts" : "", artifactLines.length ? "---------" : "", diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-tree.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-tree.ts index 87a7dba97..38b8faf31 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-tree.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/browser/monitor-tree.ts @@ -80,12 +80,38 @@ export function monitorStatusText(status: PaneTaskStatus, theme: Theme): string return theme.fg(paneCompletionTone(status), status); } -function recordClockTime(record: PaneTaskRecord): string { - const raw = record.completedAt ?? record.updatedAt ?? record.createdAt; - if (!raw) return "--:--"; - const date = new Date(raw); - if (!Number.isFinite(date.getTime())) return "--:--"; - return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +const MONTH_LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +export function formatLocalDateTime(raw: string | undefined): string { + const ms = Date.parse(raw ?? ""); + if (!Number.isFinite(ms)) return "—"; + const date = new Date(ms); + const year = date.getFullYear() === new Date().getFullYear() ? "" : ` ${date.getFullYear()}`; + return `${MONTH_LABELS[date.getMonth()]} ${date.getDate()}${year}, ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +export function formatRunDuration(startRaw: string | undefined, endRaw?: string): string { + const startMs = Date.parse(startRaw ?? ""); + if (!Number.isFinite(startMs)) return "—"; + const endMs = endRaw === undefined ? Date.now() : Date.parse(endRaw); + if (!Number.isFinite(endMs) || endMs < startMs) return "—"; + const totalSeconds = Math.floor((endMs - startMs) / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`; + if (minutes > 0) return `${minutes}m`; + return `${totalSeconds}s`; +} + +// Run-time only: elapsed while active, total once terminal. `updatedAt` is +// never a source — registry polls refresh it, which made the old clock-of-day +// display jump on every poll. +export function monitorTaskRunTime(record: PaneTaskRecord): string { + if (monitorStatusIsTerminal(record.status)) return formatRunDuration(record.createdAt, record.completedAt ?? ""); + // Minute granularity while running: the popup refreshes elapsed times on a + // slow cadence, so a seconds display would visibly lag between renders. + const elapsed = formatRunDuration(record.createdAt); + return /^\d+s$/.test(elapsed) ? "<1m" : elapsed; } function recordInvocationTimestamp(record: PaneTaskRecord): number { @@ -95,10 +121,10 @@ function recordInvocationTimestamp(record: PaneTaskRecord): number { export function monitorTaskRowLabel(record: PaneTaskRecord, taskNumbers: Map): string { const number = taskNumbers.get(record.taskId); - const clock = recordClockTime(record); + const runTime = monitorTaskRunTime(record); // `#1` is suppressed so the first task per session reads as plain `Task ·