Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pi-extensions/pi-agents-tmux/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion pi-extensions/pi-agents-tmux/extensions/subagent/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
bmethod marked this conversation as resolved.
liveTimer.unref?.();
const scheduleResizeRender = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ?? "");
Expand Down Expand Up @@ -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 —",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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[] {
Expand Down Expand Up @@ -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 ? "---------" : "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
bmethod marked this conversation as resolved.
}

function recordInvocationTimestamp(record: PaneTaskRecord): number {
Expand All @@ -95,10 +121,10 @@ function recordInvocationTimestamp(record: PaneTaskRecord): number {

export function monitorTaskRowLabel(record: PaneTaskRecord, taskNumbers: Map<string, number>): 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 · <time>`.
// Numbers only appear from the second task onward.
return number && number > 1 ? `#${number} · ${clock}` : `· ${clock}`;
return number && number > 1 ? `#${number} · ${runTime}` : `· ${runTime}`;
}

export function buildMonitorSessionGroups(records: PaneTaskRecord[]): MonitorSessionGroup[] {
Expand Down
2 changes: 1 addition & 1 deletion pi-extensions/pi-agents-tmux/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vanillagreen/pi-agents-tmux",
"version": "2.8.2",
"version": "2.8.3",
"description": "Pi extension for delegating work to project or user agents, including persistent tmux agent panes.",
"license": "MIT",
"keywords": [
Expand Down
5 changes: 3 additions & 2 deletions pi-extensions/pi-agents-tmux/tests/dashboard-ux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,7 @@ test("Monitor numbers repeated agent launches as sessions and resets task number
const tree = renderMonitorTree(monitorTreeRows(groups), records, new Set(), uiState({ tab: "monitor", pane: "list" }), 180, theme as any, 20).join("\n").replace(/\x1b\[[0-9;]*m/g, "");
assert.match(tree, /reviewer-arch · 1 task · /);
// `#1` is suppressed everywhere; only `#2+` should appear.
assert.match(tree, /Task · \d{2}:\d{2} · completed/);
assert.match(tree, /Task · 0s · completed/);
assert.doesNotMatch(tree, /Task #1\b/);
assert.doesNotMatch(tree, /bg · reviewer-arch|session #2 · fresh|reviewer-arch #2/);

Expand Down Expand Up @@ -942,7 +942,8 @@ test("Monitor session selection shows aggregate detail", () => {
assert.match(plain, /Usage:/);
assert.match(plain, /Pane ID:\s+%1/);
assert.match(plain, /Transcript:\s+\/tmp\/planner-session\.jsonl/);
assert.match(plain, /Task #2 · \d{2}:\d{2} · running/);
// Running rows show elapsed run-time (createdAt → now), not clock-of-day.
assert.match(plain, /Task #2 · \d+h \d{2}m · running/);

const colored = renderMonitorSessionDetail(group, taskNumberById([first, second]), uiState({ tab: "monitor" }), 140, 40, ansiTheme as any).join("\n");
assert.match(colored, /\x1b\[35m\x1b\[1mSession/);
Expand Down
85 changes: 85 additions & 0 deletions pi-extensions/pi-agents-tmux/tests/monitor-times.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
formatLocalDateTime,
formatRunDuration,
monitorTaskRowLabel,
monitorTaskRunTime,
} from "../extensions/subagent/browser/monitor-tree.js";
import { traceViewerItems } from "../extensions/subagent/browser/monitor-task-detail.js";
import type { PaneTaskRecord } from "../extensions/subagent/types.js";

function record(overrides: Partial<PaneTaskRecord>): PaneTaskRecord {
return {
agent: "reviewer",
createdAt: new Date().toISOString(),
status: "running",
task: "review the diff",
taskId: "task-1",
...overrides,
};
}

test("running task row shows elapsed run-time, never clock-of-day", () => {
const createdAt = new Date(Date.now() - 5 * 60_000).toISOString();
const rec = record({ createdAt, updatedAt: new Date().toISOString() });
const label = monitorTaskRowLabel(rec, new Map());
assert.equal(label, "· 5m");
});

test("updatedAt never moves the displayed time", () => {
const createdAt = new Date(Date.now() - 12 * 60_000).toISOString();
const early = monitorTaskRunTime(record({ createdAt, updatedAt: "2001-01-01T01:01:00Z" }));
const late = monitorTaskRunTime(record({ createdAt, updatedAt: new Date().toISOString() }));
assert.equal(early, late);
assert.equal(early, "12m");
});
Comment thread
bmethod marked this conversation as resolved.

test("terminal task shows total createdAt→completedAt duration, stable across polls", () => {
const createdAt = "2026-03-24T23:59:33Z";
const completedAt = "2026-03-25T00:16:58Z";
const rec = record({ completedAt, createdAt, status: "completed", updatedAt: new Date().toISOString() });
assert.equal(monitorTaskRunTime(rec), "17m");
assert.equal(monitorTaskRowLabel(rec, new Map([["task-1", 2]])), "#2 · 17m");
});

test("terminal task without completedAt shows a dash, not updatedAt", () => {
const rec = record({ createdAt: "2026-03-24T23:59:33Z", status: "failed", updatedAt: new Date().toISOString() });
assert.equal(monitorTaskRunTime(rec), "—");
});

test("running sub-minute elapsed is minute-granular", () => {
const rec = record({ createdAt: new Date(Date.now() - 20_000).toISOString() });
assert.equal(monitorTaskRunTime(rec), "<1m");
});

test("task Summary carries Duration only once terminal", async () => {
const done = record({ completedAt: "2026-03-25T00:16:58Z", createdAt: "2026-03-24T23:59:33Z", status: "completed" });
const live = record({ createdAt: new Date(Date.now() - 5 * 60_000).toISOString() });
const doneItems = await traceViewerItems(done);
const liveItems = await traceViewerItems(live);
assert.match(doneItems[0]!.text, /Duration {2}17m(\n|$)/);
assert.doesNotMatch(liveItems[0]!.text, /Duration/);
});

test("formatRunDuration shapes", () => {
assert.equal(formatRunDuration("2026-03-24T00:00:00Z", "2026-03-24T01:01:40Z"), "1h 01m");
assert.equal(formatRunDuration("2026-03-24T00:00:00Z", "2026-03-24T00:00:45Z"), "45s");
assert.equal(formatRunDuration("2026-03-24T01:00:00Z", "2026-03-24T00:00:00Z"), "—");
assert.equal(formatRunDuration("not-a-date", "2026-03-24T00:00:00Z"), "—");
assert.equal(formatRunDuration(undefined), "—");
});

test("formatLocalDateTime renders local wall time, not UTC ISO", () => {
const iso = "2026-03-24T23:59:33Z";
const date = new Date(iso);
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const year = date.getFullYear() === new Date().getFullYear() ? "" : ` ${date.getFullYear()}`;
const expected = `${months[date.getMonth()]} ${date.getDate()}${year}, ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
const rendered = formatLocalDateTime(iso);
assert.equal(rendered, expected);
assert.doesNotMatch(rendered, /Z$/);
assert.doesNotMatch(rendered, /T\d{2}/);
assert.equal(formatLocalDateTime("garbage"), "—");
assert.equal(formatLocalDateTime(undefined), "—");
});
4 changes: 2 additions & 2 deletions tools/size-ratchet-baseline.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ cli/src/vsix.rs 637
cli/tests/hook_contract.rs 1548
cli/tests/hook_lifecycle.rs 1545
hooks/block-repo-copy.sh 511
pi-extensions/pi-agents-tmux/extensions/subagent/browser.ts 637
pi-extensions/pi-agents-tmux/extensions/subagent/browser.ts 650
pi-extensions/pi-agents-tmux/extensions/subagent/dashboard.ts 431
pi-extensions/pi-agents-tmux/extensions/subagent/dispatch.ts 580
pi-extensions/pi-agents-tmux/extensions/subagent/format.ts 776
Expand All @@ -46,7 +46,7 @@ pi-extensions/pi-agents-tmux/extensions/subagent/runner.ts 1327
pi-extensions/pi-agents-tmux/extensions/subagent/subagent-render.ts 473
pi-extensions/pi-agents-tmux/extensions/subagent/tasks.ts 1010
pi-extensions/pi-agents-tmux/extensions/subagent/types.ts 487
pi-extensions/pi-agents-tmux/tests/dashboard-ux.test.ts 1170
pi-extensions/pi-agents-tmux/tests/dashboard-ux.test.ts 1171
pi-extensions/pi-agents-tmux/tests/session-lanes.test.ts 2439
pi-extensions/pi-background-tasks/extensions/background-tasks.ts 986
pi-extensions/pi-background-tasks/extensions/wake-events.ts 600
Expand Down
Loading