diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dbc54b2a..4813dce7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,11 @@ An owning checkout's lock file that exists and cannot be parsed no longer reads as absent: unknown ownership is not permission to clear another checkout's recovery marker (VST-258). +- pi-agents-tmux: the Agents popup Transcript tab is an event timeline + (paired tool rows, capped previews, `✖`-marked failures, line-boundary tail + with a dropped-events note) instead of raw JSONL, and `e` opens the raw + file in `$VISUAL`/`$EDITOR` (VST-327). + - 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 diff --git a/pi-extensions/pi-agents-tmux/CHANGELOG.md b/pi-extensions/pi-agents-tmux/CHANGELOG.md index 467e64629..3ebb8ef57 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.4 + +- The Agents popup Transcript tab renders an event timeline instead of raw JSONL (vstack VST-327). One row per event — elapsed stamp, kind, capped one-line detail — covering input, assistant text/thinking previews, turn boundaries, and lifecycle records; a tool call collapses into a single row pairing start with result (name, primary argument, status, duration, result size), a tool-call-only assistant message renders as its calls, and errors/aborts/non-zero exits are `✖`-marked rows. No event type falls through to a raw JSONL line: unrecognized types render as their type and size. The tail read grew 24 KB → 256 KB, is byte-based and streaming (only the final window is materialized; the dropped prefix is newline-counted through a fixed buffer), cuts on a line boundary only, and states how many earlier events were dropped. Decoded text is scrubbed of C0/C1 terminal controls before rendering — JSON.parse would otherwise revive escaped OSC/CSI sequences the raw view kept inert. Native pane-session `message` records render as conversation content, and only trouble-shaped lifecycle records (`abort_*`, `*_failed`, escalations) carry the failure tone. New `e` key in the trace viewer opens the item's file in `$VISUAL`/`$EDITOR` (own tmux window), listed in the footer hint. + ### 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. diff --git a/pi-extensions/pi-agents-tmux/README.md b/pi-extensions/pi-agents-tmux/README.md index 4508a5a59..34ddbbff5 100644 --- a/pi-extensions/pi-agents-tmux/README.md +++ b/pi-extensions/pi-agents-tmux/README.md @@ -13,6 +13,7 @@ Delegate work to specialized agents from a running Pi session. Agents run either - Monitor groups tasks by session (pane, bg lane, bg one-shot) under expandable Active and Completed sections, with active sessions first and newest invocations first inside each section; repeated same-agent launches get session numbers and task numbers reset per session. Steering/follow-up delivery mode is shown in expanded rows and trace metadata. - Chat completion rows show actual results, never a repeat of the original request. - Task detail shows Summary and Completion tabs; Summary contains task metadata, artifacts, and task text, while Completion contains result summary, files changed, and validation. +- The Transcript tab renders an event timeline — one row per event with elapsed stamp, kind, and a capped one-line detail; tool calls collapse into a single row (name, primary argument, status, duration, result size), failures are `✖`-marked, and a budget-cut tail states how many earlier events are not shown. `e` in the trace viewer opens the raw transcript file in `$VISUAL`/`$EDITOR` (own tmux window). - Bg one-shot transcripts keep enough history to inspect results without creating oversized session files. - When a task needs manual completion, result views include useful repository context when available. - Dashboard widget shows live state, turns, input/output/reasoning tokens, and cost for every spawned agent; working agents stay above attention/completed agents, newest invocations lead each bucket, and activity updates or completion polling do not reshuffle rows. Once you hide it, lifecycle updates do not reopen it until you toggle it back in. 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 a09f5f152..2d38fd880 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 @@ -10,9 +10,10 @@ import { formatUsageStats, highlightInlinePreview, } from "../format.js"; -import { readTextFileIfExists, recordTraceRef } from "../renderers.js"; +import { readTextFileIfExists, readTranscriptTail, recordTraceRef } from "../renderers.js"; import { monitorStatusIsTerminal } from "../task-records.js"; -import { formatTranscriptForDisplay, inputDeliveryLabel } from "../transcripts.js"; +import { formatTranscriptForDisplay } from "../transcript-timeline.js"; +import { inputDeliveryLabel } from "../transcripts.js"; import { MONITOR_SUBTAB_LABELS, type AgentBrowserUiState, @@ -77,6 +78,17 @@ export function renderTraceContentLine(raw: string, type: TraceViewerItem["type" const line = raw.replace(/\t/g, " "); const trimmed = line.trim(); if (!trimmed) return [""]; + // Transcript timeline rows: `[elapsed] kind · detail`, `✖`-marked when the + // event is an error/abort so a failed run stands out without scrolling. + const timeline = line.match(/^([ ✖])\[([^\]]+)\] (.*)$/); + if (timeline) { + const [, mark, stamp, rest] = timeline; + if (mark === "✖") return wrapTextWithAnsi(theme.fg("error", `✖ [${stamp}] ${rest}`), width); + const sep = rest!.indexOf(" · "); + const kind = sep === -1 ? rest! : rest!.slice(0, sep); + const detail = sep === -1 ? "" : rest!.slice(sep); + return wrapTextWithAnsi(`${theme.fg("dim", `[${stamp}]`)} ${theme.fg("accent", theme.bold(kind))}${theme.fg("toolOutput", detail)}`, width); + } if (/^── .+ ──$/.test(trimmed)) return wrapTextWithAnsi(theme.fg("muted", trimmed.replace(/(input|assistant|user|tool call|tool start|tool end|turn start|turn end|agent end|exit)/i, (match) => theme.fg("accent", theme.bold(match)))), width); if (/^-{3,}$/.test(trimmed)) return []; if (/^(Overview|Metadata|Summary|Files changed|Validation|Notes|Task|Artifacts|Session|Task list|System Prompt)$/i.test(trimmed)) { @@ -258,9 +270,9 @@ export async function traceViewerItems(record: PaneTaskRecord, taskNumber?: numb ...completionJsonSection, ].filter(Boolean).join("\n"); const common = { agent: record.agent, createdAt: record.completedAt ?? record.createdAt, ref, status: record.status, summary: summaryText }; - const transcript = await readTextFileIfExists(record.transcriptPath, 24_000); + const transcript = await readTranscriptTail(record.transcriptPath); const transcriptItem = record.transcriptPath - ? [{ ...common, label: "Transcript", path: record.transcriptPath, text: transcript ? formatTranscriptForDisplay(transcript) : "Transcript file could not be read.", type: "transcript" as const }] + ? [{ ...common, label: "Transcript", path: record.transcriptPath, text: transcript ? formatTranscriptForDisplay(transcript.text, { droppedEvents: transcript.droppedLines, originTs: transcript.originTs, taskTerminal: monitorStatusIsTerminal(record.status) }) : "Transcript file could not be read.", type: "transcript" as const }] : []; return [ { ...common, label: "Summary", text: summary, type: "summary" as const }, diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/browser/trace-viewer.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/browser/trace-viewer.ts index 826c5d3b7..613f048ee 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/browser/trace-viewer.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/browser/trace-viewer.ts @@ -1,6 +1,8 @@ +import { spawn } from "node:child_process"; import { type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent"; import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui"; import { ansiYellow, compactPath, divider, simpleFrame } from "../format.js"; +import { shellQuote } from "../names.js"; import { TRACE_VIEWER_MAX_HEIGHT, TRACE_VIEWER_WIDTH, @@ -13,7 +15,7 @@ function traceViewerLines(state: TraceViewerState, width: number, rows: number, const innerWidth = Math.max(1, width - 4); const frameRows = Math.max(8, rows); const item = state.items[state.selected] ?? state.items[0]; - const help = `${ansiYellow("tab/←→")} ${theme.fg("dim", "sections · ")}${ansiYellow("-/=")} ${theme.fg("dim", "page")}`; + const help = `${ansiYellow("tab/←→")} ${theme.fg("dim", "sections · ")}${ansiYellow("-/=")} ${theme.fg("dim", "page")}${item?.path ? `${theme.fg("dim", " · ")}${ansiYellow("e")} ${theme.fg("dim", "editor")}` : ""}`; const tabs = renderTraceTabBar(state.items, state.selected, innerWidth, theme); const meta = [ item?.ref ? theme.fg("accent", item.ref) : "", @@ -48,6 +50,27 @@ function traceViewerLines(state: TraceViewerState, width: number, rows: number, return simpleFrame(innerLines.slice(0, frameRows - 2), width, theme, state.title); } +/** + * Open the item's backing file in $VISUAL/$EDITOR. The popup owns the + * terminal, so the editor gets its own tmux window; outside tmux (or with no + * editor configured) the path is surfaced instead of fighting over the tty. + */ +export function openTraceItemInEditor(ctx: ExtensionContext, path: string | undefined): void { + if (!path) return; + const editor = process.env.VISUAL?.trim() || process.env.EDITOR?.trim(); + if (!editor) { + ctx.ui.notify(`Set $VISUAL or $EDITOR to open ${path}`, "warning"); + return; + } + if (!process.env.TMUX) { + ctx.ui.notify(`Not inside tmux — open manually: ${editor} ${path}`, "warning"); + return; + } + const child = spawn("tmux", ["new-window", "-n", "transcript", `${editor} ${shellQuote(path)}`], { detached: true, stdio: "ignore" }); + child.on("error", (error) => ctx.ui.notify(`Could not open editor window: ${error instanceof Error ? error.message : String(error)}`, "error")); + child.unref(); +} + export async function openTraceViewer(ctx: ExtensionContext, title: string, items: TraceViewerItem[]): Promise { if (!ctx.hasUI) { ctx.ui.notify(title, "info"); @@ -63,6 +86,7 @@ export async function openTraceViewer(ctx: ExtensionContext, title: string, item if (matchesKey(data, "down")) { state.scroll += 1; tui.requestRender(); return; } if (matchesKey(data, "-") || matchesKey(data, "pageup" as any) || matchesKey(data, "page_up" as any)) { state.scroll = Math.max(0, state.scroll - tracePageRows); tui.requestRender(); return; } if (matchesKey(data, "=") || matchesKey(data, "pagedown" as any) || matchesKey(data, "page_down" as any)) { state.scroll += tracePageRows; tui.requestRender(); return; } + if (matchesKey(data, "e")) { openTraceItemInEditor(ctx, (state.items[state.selected] ?? state.items[0])?.path); return; } if (matchesKey(data, "left")) { state.selected = (state.selected + state.items.length - 1) % state.items.length; state.scroll = 0; tui.requestRender(); return; } if (matchesKey(data, "right") || matchesKey(data, "tab")) { state.selected = (state.selected + 1) % state.items.length; state.scroll = 0; tui.requestRender(); return; } }, diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts index 7dc103a20..30eaa689f 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts @@ -260,6 +260,76 @@ export async function readTextFileIfExists(filePath: string | undefined, maxByte } } +/** + * Tail-read a JSONL transcript on a byte budget without ever cutting + * mid-record: only the final window is materialized (the dropped prefix is + * newline-counted through a small reusable buffer, never held in memory), + * the cut lands on a line boundary, and the dropped-event count lets the + * caller say what is not shown instead of rendering a leading fragment. + */ +export async function readTranscriptTail(filePath: string | undefined, maxBytes = 256_000): Promise<{ text: string; droppedLines: number; originTs?: unknown } | undefined> { + if (!filePath) return undefined; + let handle: fs.promises.FileHandle | undefined; + try { + handle = await fs.promises.open(filePath, "r"); + const { size } = await handle.stat(); + if (size <= maxBytes) return { droppedLines: 0, text: (await handle.readFile()).toString("utf-8") }; + const start = size - maxBytes; + let droppedLines = 0; + // The first record's stamp survives the cut so elapsed times keep the + // session's real origin instead of restarting at the tail. + let originTs: unknown; + const chunk = Buffer.alloc(64 * 1024); + // The first record is read to ITS OWN newline — which can sit past the + // cut offset when the record (it embeds the full task prompt) is longer + // than the whole dropped prefix — bounded at 1MB. + { + let firstLine = Buffer.alloc(0); + for (let position = 0; position < size && firstLine.length <= 1024 * 1024; ) { + const { bytesRead } = await handle.read(chunk, 0, chunk.length, position); + if (bytesRead <= 0) break; + const boundary = chunk.subarray(0, bytesRead).indexOf(10); + firstLine = Buffer.concat([firstLine, chunk.subarray(0, boundary === -1 ? bytesRead : boundary)]); + position += bytesRead; + if (boundary !== -1) break; + } + try { + const parsed = JSON.parse(firstLine.toString("utf-8")); + originTs = parsed?.ts ?? parsed?.timestamp; + } catch { + originTs = undefined; + } + } + for (let position = 0; position < start; ) { + const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, start - position), position); + if (bytesRead <= 0) break; + for (let index = 0; index < bytesRead; index += 1) if (chunk[index] === 10) droppedLines += 1; + position += bytesRead; + } + const tail = Buffer.alloc(maxBytes); + const { bytesRead } = await handle.read(tail, 0, maxBytes, start); + let text = tail.subarray(0, Math.max(0, bytesRead)).toString("utf-8"); + const prev = Buffer.alloc(1); + await handle.read(prev, 0, 1, start - 1); + if (prev[0] !== 10) { + // The window opens mid-record: drop the fragment (counted — its + // newline sits inside the window). A single record larger than the + // whole window has no boundary; it stays as one line for the + // formatter to label rather than vanishing into a blank tab. + const boundary = text.indexOf("\n"); + if (boundary !== -1) { + droppedLines += 1; + text = text.slice(boundary + 1); + } + } + return { droppedLines, originTs, text }; + } catch { + return undefined; + } finally { + await handle?.close().catch(() => undefined); + } +} + export async function formatTraceView(record: PaneTaskRecord, verbose = false): Promise { const base = formatTaskRecordResult(record, true); const transcript = await readTextFileIfExists(record.transcriptPath, verbose ? 80_000 : 24_000); diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts new file mode 100644 index 000000000..964afc9db --- /dev/null +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts @@ -0,0 +1,279 @@ +import { inputDeliveryLabel, normalizeTranscriptRecordEvent, numberValue, oneLine, stringValue } from "./transcripts.js"; + +const TIMELINE_PREVIEW_MAX = 160; + +function formatByteSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +function formatElapsedStamp(ms: number | undefined): string { + if (ms === undefined || !Number.isFinite(ms) || ms < 0) return "--:--"; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +function formatToolDuration(ms: number | undefined): string | undefined { + if (ms === undefined || !Number.isFinite(ms) || ms < 0) return undefined; + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + const totalSeconds = Math.round(ms / 1000); + return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`; +} + +/** The argument that best identifies what a tool call targeted. */ +function primaryToolArgument(args: unknown): string | undefined { + if (typeof args === "string") return args || undefined; + if (!args || typeof args !== "object" || Array.isArray(args)) return undefined; + const record = args as Record; + for (const key of ["command", "cmd", "path", "file_path", "filePath", "pattern", "query", "url", "name", "task"]) { + if (typeof record[key] === "string" && record[key]) return record[key] as string; + } + const firstString = Object.values(record).find((value) => typeof value === "string" && value); + return firstString as string | undefined; +} + +function payloadByteSize(value: unknown): number { + if (value === undefined) return 0; + if (typeof value === "string") return Buffer.byteLength(value, "utf8"); + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? 0 : Buffer.byteLength(serialized, "utf8"); + } catch { + return 0; + } +} + +interface TimelineRow { + stamp: string; + kind: string; + detail?: string; + error?: boolean; +} + +// JSON.parse revives escaped C0/C1 controls (OSC/CSI included) that the old +// raw-JSONL view kept escaped; a hostile tool output must not reach the +// terminal as live sequences. +function stripTerminalControls(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, ""); +} + +function renderTimelineRow(row: TimelineRow): string { + const detail = row.detail ? ` · ${oneLine(row.detail, TIMELINE_PREVIEW_MAX)}` : ""; + return stripTerminalControls(`${row.error ? "✖" : " "}[${row.stamp}] ${row.kind}${detail}`); +} + +function describeInputEvent(event: any): TimelineRow { + const delivery = inputDeliveryLabel(event.streamingBehavior ?? event.streaming_behavior) ?? "idle"; + const source = stringValue(event.source); + const preview = stringValue(event.textPreview ?? event.text_preview ?? event.text) ?? ""; + const truncated = event.textTruncated === true || event.text_truncated === true; + const imagesCount = numberValue(event.imagesCount ?? event.images_count); + const meta = [delivery, source, imagesCount ? `${imagesCount} image${imagesCount === 1 ? "" : "s"}` : undefined].filter(Boolean).join(", "); + return { stamp: "", kind: `input (${meta})`, detail: [preview, truncated ? "(truncated)" : ""].filter(Boolean).join(" ") }; +} + +function messageContentRows(message: any): Array> { + const role = stringValue(message?.role) ?? "assistant"; + // Pane sessions store failed tools as toolResult messages with isError — + // there is no separate tool_execution_end to carry the failure tone. + const failed = message?.isError === true; + const content = message?.content; + if (typeof content === "string") return [{ detail: content, error: failed, kind: role }]; + if (!Array.isArray(content)) return [{ detail: `(no text, ${formatByteSize(payloadByteSize(message))})`, error: failed, kind: role }]; + const rows: Array> = []; + const toolNames: string[] = []; + for (const part of content) { + if (!part || typeof part !== "object") continue; + if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking) rows.push({ detail: part.thinking, error: failed, kind: "thinking" }); + else if (part.type === "text" && typeof part.text === "string" && part.text) rows.push({ detail: part.text, error: failed, kind: role }); + else if (typeof part.type === "string" && part.type.toLowerCase().includes("tool")) { + toolNames.push(stringValue(part.name) ?? stringValue(part.toolName) ?? stringValue(part.tool_name) ?? "tool"); + } + } + // Tool calls always surface as a compact row — pane sessions have no + // separate tool_execution_* records, so this is their only trace — and a + // tool-call-only message reads as its calls, never as raw JSON. + if (toolNames.length > 0) rows.push({ detail: `${toolNames.length} tool call${toolNames.length === 1 ? "" : "s"}: ${toolNames.join(", ")}`, error: failed, kind: role }); + if (rows.length === 0) return [{ detail: `(no text, ${formatByteSize(payloadByteSize(message))})`, error: failed, kind: role }]; + return rows; +} + +/** + * Render a transcript as a chronological timeline: one row per event — + * elapsed stamp, kind, capped one-line detail. Every event type the writer + * emits gets a row or a deliberate elision (`message_start`, `turn_start` + * pairs carry no information beyond their boundary); an unrecognized type + * renders as its type and size, never as a raw JSONL dump. Tool calls + * collapse into one row pairing start with result. `droppedEvents` (from a + * budgeted tail read) is stated up front so a cut transcript never reads as + * complete. + */ +export function formatTranscriptForDisplay(raw: string, options?: { droppedEvents?: number; originTs?: unknown; taskTerminal?: boolean }): string { + const rows: TimelineRow[] = []; + const originMs = typeof options?.originTs === "number" ? options.originTs : Date.parse(String(options?.originTs ?? "")); + let firstTs: number | undefined = Number.isFinite(originMs) ? originMs : undefined; + // Open tool calls by id (fallback: FIFO per name), pointing at the row to + // complete — id-less same-named calls pair first-started-first-ended. + const openTools = new Map>(); + const stampFor = (record: any): { stamp: string; atMs?: number } => { + // One-shot writer records stamp `ts`; native pane session entries stamp + // `timestamp` (ISO string or epoch ms). + const raw = record?.ts ?? record?.timestamp; + const atMs = typeof raw === "number" ? raw : Date.parse(raw ?? ""); + if (!Number.isFinite(atMs)) return { stamp: "--:--" }; + if (firstTs === undefined) firstTs = atMs; + return { atMs, stamp: formatElapsedStamp(atMs - firstTs) }; + }; + const push = (stamp: string, kind: string, detail?: string, error?: boolean): TimelineRow => { + const row: TimelineRow = { detail, error, kind, stamp }; + rows.push(row); + return row; + }; + if (options?.droppedEvents) push("--:--", `↑ ${options.droppedEvents} earlier event${options.droppedEvents === 1 ? "" : "s"} not shown`, "open the transcript file for the full record"); + for (const line of raw.split(/\r?\n/)) { + if (!line.trim()) continue; + let record: any; + try { + record = JSON.parse(line); + } catch { + push("--:--", "unparseable line", formatByteSize(payloadByteSize(line)), true); + continue; + } + const { stamp, atMs } = stampFor(record); + const recordType = stringValue(record?.type); + // Writer-level records (no wrapped Pi event). + if (recordType === "start") { + push(stamp, "session start", [stringValue(record.agent), stringValue(record.task)].filter(Boolean).join(" · ")); + continue; + } + if (recordType === "diagnostic") { + push(stamp, "diagnostic", stringValue(record.diagnostic) ?? "(no detail)", true); + continue; + } + if (recordType === "timeout") { + push(stamp, "timeout", stringValue(record.reason) ?? "(no reason)", true); + continue; + } + if (recordType === "exit") { + const code = record.code ?? "unknown"; + push(stamp, "exit", `code ${code}`, code !== 0); + continue; + } + if (recordType === "message" && record.message && typeof record.message === "object") { + // Native pane-session entries carry the message at the record level. + for (const part of messageContentRows(record.message)) push(stamp, part.kind, part.detail, part.error); + continue; + } + if (recordType && !("event" in (record ?? {})) && !("stream" in (record ?? {}))) { + // Other writer/session records. Only trouble-shaped types get the + // failure tone; anything else is a neutral labeled row. + const troubled = /^abort_|_failed$|_escalation$|^error$|^process_error$/.test(recordType); + push(stamp, recordType, stringValue(record.diagnostic) ?? stringValue(record.error) ?? stringValue(record.signal) ?? formatByteSize(payloadByteSize(record)), troubled); + continue; + } + if (typeof record?.text === "string" && record?.stream === "stderr") { + push(stamp, "stderr", record.text, true); + continue; + } + const normalized = normalizeTranscriptRecordEvent(record); + const event = normalized.event; + const type = typeof event?.type === "string" ? (event.type as string) : undefined; + if (!event || typeof event !== "object" || !type) { + push(stamp, "unlabeled record", formatByteSize(payloadByteSize(line)), true); + continue; + } + switch (type) { + case "session": + case "agent_start": { + push(stamp, type === "session" ? "session" : "agent start", [stringValue(event.agent), stringValue(event.model)].filter(Boolean).join(" · ") || undefined); + break; + } + case "start": + case "message_start": + case "turn_start": + // Boundary openers carry nothing their closer or content rows do not. + break; + case "turn_end": { + push(stamp, "turn end"); + break; + } + case "input": { + const row = describeInputEvent(event); + push(stamp, row.kind, row.detail); + break; + } + case "message_update": { + // Only present in full-stream transcripts or buffered failure + // flushes; the reconstructed partial is the readable part. + const partial = record.partialMessage ?? event.partialMessage; + if (partial) for (const part of messageContentRows(partial)) push(stamp, `${part.kind} (partial)`, part.detail, part.error); + break; + } + case "message_end": { + const message = event.message && typeof event.message === "object" ? event.message : event; + for (const part of messageContentRows(message)) push(stamp, part.kind, part.detail, part.error); + break; + } + case "tool_execution_start": { + const name = stringValue(event.toolName ?? event.tool_name) ?? stringValue(event.name) ?? "tool"; + const target = primaryToolArgument(event.args ?? event.arguments ?? event.input ?? event.params); + const label = target ? `tool ${name} (${oneLine(target, 60)})` : `tool ${name}`; + const id = stringValue(event.toolCallId ?? event.tool_call_id ?? event.toolUseId ?? event.tool_use_id) ?? `name:${name}`; + const row = push(stamp, label, "running"); + const queue = openTools.get(id) ?? []; + queue.push({ label, row, startedAtMs: atMs }); + openTools.set(id, queue); + break; + } + case "tool_execution_update": + // Folded into the paired tool row; the full payload stays in the file. + break; + case "tool_execution_end": { + const name = stringValue(event.toolName ?? event.tool_name) ?? stringValue(event.name) ?? "tool"; + const id = stringValue(event.toolCallId ?? event.tool_call_id ?? event.toolUseId ?? event.tool_use_id) ?? `name:${name}`; + const open = openTools.get(id)?.shift(); + const failed = event.isError === true || event.is_error === true || stringValue(event.status) === "error"; + const status = stringValue(event.status) ?? (failed ? "error" : "ok"); + const resultSize = payloadByteSize(event.result ?? event.output ?? event.content); + const duration = open?.startedAtMs !== undefined && atMs !== undefined ? formatToolDuration(atMs - open.startedAtMs) : undefined; + const detail = [status, duration, resultSize ? formatByteSize(resultSize) : undefined].filter(Boolean).join(" · "); + if (open) { + open.row.detail = detail; + open.row.error = failed; + } else { + push(stamp, `tool ${name}`, detail, failed); + } + break; + } + case "agent_end": { + push(stamp, "agent end", stringValue(event.finalTextPreview ?? event.final_text_preview)); + break; + } + case "error": { + push(stamp, "error", stringValue(event.message) ?? stringValue(event.error) ?? formatByteSize(payloadByteSize(event)), true); + break; + } + default: { + push(stamp, type, formatByteSize(payloadByteSize(line))); + } + } + } + // An unmatched start is a failure only once the task itself has ended; a + // live task legitimately has its newest tool call still open. + if (options?.taskTerminal !== false) { + for (const queue of openTools.values()) { + for (const open of queue) { + open.row.error = true; + open.row.detail = "no result recorded"; + } + } + } + return rows.map(renderTimelineRow).join("\n"); +} diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts index 7f576901f..d2a0d67b0 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts @@ -168,73 +168,15 @@ export function inputDeliveryLabel(value: unknown): string | undefined { return normalizeInputDelivery(value); } -function oneLine(text: string, maxChars = 500): string { +export function oneLine(text: string, maxChars = 500): string { const compact = text.replace(/\s+/g, " ").trim(); return compact.length > maxChars ? `${compact.slice(0, maxChars - 1)}…` : compact; } -function stringValue(value: unknown): string | undefined { +export function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -function numberValue(value: unknown): number | undefined { +export function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } - -function textFromMessageContent(content: unknown): string | undefined { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return undefined; - const text = content.find((part: any) => part?.type === "text" && typeof part.text === "string"); - return text?.text; -} - -export function describeTranscriptEvent(record: any): string | undefined { - const normalized = normalizeTranscriptRecordEvent(record); - const event = normalized.event; - if (!event || typeof event !== "object") return undefined; - const type = typeof event.type === "string" ? event.type : undefined; - if (type === "input") { - const delivery = inputDeliveryLabel(event.streamingBehavior ?? event.streaming_behavior) ?? "idle"; - const source = stringValue(event.source); - const preview = stringValue(event.textPreview ?? event.text_preview ?? event.text) ?? ""; - const bytes = numberValue(event.textBytes ?? event.text_bytes); - const truncated = event.textTruncated === true || event.text_truncated === true; - const imagesCount = numberValue(event.imagesCount ?? event.images_count); - const meta = [delivery, source, imagesCount !== undefined ? `${imagesCount} image${imagesCount === 1 ? "" : "s"}` : undefined] - .filter(Boolean) - .join(" · "); - const suffix = [bytes !== undefined ? `${bytes}B` : undefined, truncated ? "truncated" : undefined].filter(Boolean).join(" · "); - return [`── input${meta ? ` (${meta})` : ""} ──`, preview ? `${oneLine(preview)}${suffix ? ` (${suffix})` : ""}` : suffix].filter(Boolean).join("\n"); - } - if (type === "message_end") { - const message = event.message && typeof event.message === "object" ? event.message : event; - const role = stringValue(message.role); - const text = textFromMessageContent(message.content); - if (role && text) return [`── ${role} message ──`, oneLine(text)].join("\n"); - } - if (type === "tool_execution_start" || type === "tool_execution_end") { - const toolName = stringValue(event.toolName ?? event.tool_name) ?? stringValue(event.name); - const status = stringValue(event.status); - return `── ${type === "tool_execution_start" ? "tool start" : "tool end"}${toolName ? ` (${toolName})` : ""}${status ? ` · ${status}` : ""} ──`; - } - if (type === "agent_end") { - const preview = stringValue(event.finalTextPreview ?? event.final_text_preview); - return [`── agent end ──`, preview ? oneLine(preview) : undefined].filter(Boolean).join("\n"); - } - if (record?.type === "exit") return `── exit ──\ncode ${record.code ?? "unknown"}`; - return undefined; -} - -export function formatTranscriptForDisplay(raw: string): string { - const lines: string[] = []; - for (const line of raw.split(/\r?\n/)) { - if (!line.trim()) continue; - try { - const parsed = JSON.parse(line); - lines.push(describeTranscriptEvent(parsed) ?? line); - } catch { - lines.push(line); - } - } - return lines.join("\n"); -} diff --git a/pi-extensions/pi-agents-tmux/package.json b/pi-extensions/pi-agents-tmux/package.json index 6875b062d..c25b01ed6 100644 --- a/pi-extensions/pi-agents-tmux/package.json +++ b/pi-extensions/pi-agents-tmux/package.json @@ -1,6 +1,6 @@ { "name": "@vanillagreen/pi-agents-tmux", - "version": "2.8.3", + "version": "2.8.4", "description": "Pi extension for delegating work to project or user agents, including persistent tmux agent panes.", "license": "MIT", "keywords": [ diff --git a/pi-extensions/pi-agents-tmux/tests/dashboard-ux.test.ts b/pi-extensions/pi-agents-tmux/tests/dashboard-ux.test.ts index 12a513ef3..61d7d7357 100644 --- a/pi-extensions/pi-agents-tmux/tests/dashboard-ux.test.ts +++ b/pi-extensions/pi-agents-tmux/tests/dashboard-ux.test.ts @@ -1122,10 +1122,8 @@ test("Monitor trace labels delivery mode and humanizes input transcript events", assert.match(items[0]!.text, /Delivery follow-up/); assert.equal(items[2]!.label, "Transcript"); assert.equal(items[2]!.type, "transcript"); - assert.match(items[2]!.text, /── input \(follow-up · extension · 0 images\) ──/); - assert.match(items[2]!.text, /Please follow up after current turn/); - assert.match(items[2]!.text, /── assistant message ──/); - assert.match(items[2]!.text, /done with follow-up/); + assert.match(items[2]!.text, /input \(follow-up, extension\) · Please follow up after current turn/); + assert.match(items[2]!.text, /assistant · done with follow-up/); }); test("Monitor completion tab shows persisted bg result without JSON warning", async () => { diff --git a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts new file mode 100644 index 000000000..2c7092872 --- /dev/null +++ b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { after } from "node:test"; +import { readTranscriptTail } from "../extensions/subagent/renderers.js"; +import { formatTranscriptForDisplay } from "../extensions/subagent/transcript-timeline.js"; + +const T0 = "2026-03-24T23:59:33.000Z"; +const at = (seconds: number) => new Date(Date.parse(T0) + seconds * 1000).toISOString(); +const line = (record: unknown) => JSON.stringify(record); +const stream = (seconds: number, event: unknown) => line({ ts: at(seconds), stream: "stdout", raw: "", event }); + +test("unrecognized event types render as type and size, never a payload dump", () => { + const payload = "SECRET_PAYLOAD_".repeat(200); + const out = formatTranscriptForDisplay(stream(0, { type: "weird_thing", blob: payload })); + assert.match(out, /weird_thing/); + assert.match(out, /KB|B\b/); + assert.doesNotMatch(out, /SECRET_PAYLOAD/); +}); + +test("a tool call collapses to one row: name, target, status, duration, result size", () => { + const out = formatTranscriptForDisplay([ + stream(0, { toolCallId: "c1", toolName: "bash", type: "tool_execution_start", args: { command: "git status" } }), + stream(1, { toolCallId: "c1", type: "tool_execution_update", result: "PARTIAL_OUTPUT ".repeat(100) }), + stream(3, { toolCallId: "c1", toolName: "bash", type: "tool_execution_end", status: "ok", result: "x".repeat(2500) }), + ].join("\n")); + const rows = out.split("\n"); + assert.equal(rows.length, 1); + assert.match(rows[0]!, /tool bash \(git status\) · ok · 3\.0s · 2\.4KB/); + assert.doesNotMatch(out, /PARTIAL_OUTPUT/); + assert.doesNotMatch(out, /xxxx/); +}); + +test("a failed tool end and a start with no end are error rows", () => { + const failed = formatTranscriptForDisplay([ + stream(0, { toolCallId: "c1", toolName: "bash", type: "tool_execution_start", args: { command: "false" } }), + stream(1, { isError: true, toolCallId: "c1", toolName: "bash", type: "tool_execution_end" }), + ].join("\n")); + assert.match(failed, /^✖.*tool bash/m); + const dangling = formatTranscriptForDisplay(stream(0, { toolCallId: "c9", toolName: "read", type: "tool_execution_start" })); + assert.match(dangling, /^✖.*tool read.*no result recorded/m); + // A live task legitimately has its newest call still open — no failure mark. + const live = formatTranscriptForDisplay(stream(0, { toolCallId: "c9", toolName: "read", type: "tool_execution_start" }), { taskTerminal: false }); + assert.match(live, /^ .*tool read.*running/m); + assert.doesNotMatch(live, /✖/); +}); + +test("assistant text and thinking render as capped previews", () => { + const out = formatTranscriptForDisplay(stream(0, { + message: { content: [{ thinking: "let me think ".repeat(100), type: "thinking" }, { text: "the answer ".repeat(100), type: "text" }], role: "assistant" }, + type: "message_end", + })); + const rows = out.split("\n"); + assert.equal(rows.length, 2); + assert.match(rows[0]!, /thinking · let me think/); + assert.match(rows[1]!, /assistant · the answer/); + for (const row of rows) assert.ok(row.length < 220, `row too long: ${row.length}`); +}); + +test("a tool-call-only assistant message renders as its calls, not message JSON", () => { + const out = formatTranscriptForDisplay(stream(0, { + message: { content: [{ id: "a", name: "bash", type: "toolCall" }, { id: "b", name: "read", type: "toolCall" }], role: "assistant" }, + type: "message_end", + })); + assert.match(out, /assistant · 2 tool calls: bash, read/); + assert.doesNotMatch(out, /\{/); +}); + +test("tool durations never render 60s; sizes are UTF-8 bytes", () => { + const out = formatTranscriptForDisplay([ + stream(0, { toolCallId: "c1", toolName: "bash", type: "tool_execution_start" }), + stream(119.5, { toolCallId: "c1", toolName: "bash", type: "tool_execution_end", result: "café" }), + ].join("\n")); + assert.match(out, /2m 0s/); + assert.doesNotMatch(out, /60s/); + assert.match(out, /5B/); +}); + +test("toolUseId variants pair out-of-order same-named calls correctly", () => { + const out = formatTranscriptForDisplay([ + stream(0, { args: { command: "first" }, toolName: "bash", toolUseId: "u1", type: "tool_execution_start" }), + stream(1, { args: { command: "second" }, toolName: "bash", tool_use_id: "u2", type: "tool_execution_start" }), + stream(2, { toolName: "bash", tool_use_id: "u2", type: "tool_execution_end", status: "ok" }), + stream(9, { isError: true, toolName: "bash", toolUseId: "u1", type: "tool_execution_end" }), + ].join("\n")); + const rows = out.split("\n"); + assert.match(rows[0]!, /^✖.*tool bash \(first\) · error · 9\.0s/); + assert.match(rows[1]!, /^ .*tool bash \(second\) · ok · 1\.0s/); +}); + +test("id-less same-named tool calls pair first-started-first-ended", () => { + const out = formatTranscriptForDisplay([ + stream(0, { args: { command: "first" }, toolName: "bash", type: "tool_execution_start" }), + stream(1, { args: { command: "second" }, toolName: "bash", type: "tool_execution_start" }), + stream(2, { toolName: "bash", type: "tool_execution_end", status: "ok" }), + stream(10, { toolName: "bash", type: "tool_execution_end", status: "ok" }), + ].join("\n")); + const rows = out.split("\n"); + assert.match(rows[0]!, /tool bash \(first\) · ok · 2\.0s/); + assert.match(rows[1]!, /tool bash \(second\) · ok · 9\.0s/); +}); + +test("user input, turn boundaries, and elapsed stamps", () => { + const out = formatTranscriptForDisplay([ + line({ agent: "reviewer", task: "review the diff", ts: at(0), type: "start" }), + stream(0, { source: "user", streamingBehavior: "steer", text: "look again", type: "input" }), + stream(83, { type: "turn_end" }), + ].join("\n")); + assert.match(out, /\[0:00\] session start · reviewer/); + assert.match(out, /\[0:00\] input \(steer, user\) · look again/); + assert.match(out, /\[1:23\] turn end/); +}); + +test("message_start and turn_start are deliberately elided", () => { + const out = formatTranscriptForDisplay([ + stream(0, { type: "turn_start" }), + stream(0, { message: { role: "assistant" }, type: "message_start" }), + ].join("\n")); + assert.equal(out, ""); +}); + +test("process_error carries its message and the failure tone", () => { + const out = formatTranscriptForDisplay(line({ error: "spawn ENOENT", ts: at(0), type: "process_error" })); + assert.match(out, /^✖\[0:00\] process_error · spawn ENOENT$/); +}); + +test("errors, diagnostics, and non-zero exits are distinct rows; exit 0 is not", () => { + const out = formatTranscriptForDisplay([ + line({ diagnostic: "transcript write failed", ts: at(0), type: "diagnostic" }), + stream(1, { message: "boom", type: "error" }), + line({ code: 1, ts: at(2), type: "exit" }), + ].join("\n")); + for (const row of out.split("\n")) assert.match(row, /^✖/); + assert.match(formatTranscriptForDisplay(line({ code: 0, ts: at(0), type: "exit" })), /^ \[0:00\] exit · code 0$/); +}); + +test("an unparseable line renders labeled with its size, not verbatim", () => { + const fragment = '":{"partial json fragment' + "z".repeat(100); + const out = formatTranscriptForDisplay(fragment); + assert.match(out, /^✖.*unparseable line · 125B$/m); + assert.doesNotMatch(out, /partial json fragment/); +}); + +test("decoded control sequences never reach the terminal", () => { + const out = formatTranscriptForDisplay(stream(0, { + message: { content: [{ text: "safe\u001b]52;c;evil\u0007text\u009bmore", type: "text" }], role: "assistant" }, + type: "message_end", + })); + assert.doesNotMatch(out, /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/); + assert.match(out, /safe.*text.*more/); +}); + +test("native pane-session message records render as content, not failures", () => { + const out = formatTranscriptForDisplay(line({ message: { content: [{ text: "pane says hi", type: "text" }], role: "assistant" }, ts: at(0), type: "message" })); + assert.match(out, /^ \[0:00\] assistant · pane says hi$/); +}); + +test("pane-session timestamp field yields elapsed stamps, string or numeric", () => { + const out = formatTranscriptForDisplay([ + line({ message: { content: [{ text: "one", type: "text" }], role: "user" }, timestamp: at(0), type: "message" }), + line({ message: { content: [{ text: "two", type: "text" }], role: "assistant" }, timestamp: Date.parse(at(83)), type: "message" }), + ].join("\n")); + assert.match(out, /\[0:00\] user · one/); + assert.match(out, /\[1:23\] assistant · two/); + assert.doesNotMatch(out, /--:--/); +}); + +test("a failed pane toolResult message carries the failure tone", () => { + const out = formatTranscriptForDisplay(line({ + message: { content: [{ text: "command not found", type: "text" }], isError: true, role: "toolResult" }, + timestamp: at(0), + type: "message", + })); + assert.match(out, /^✖\[0:00\] toolResult · command not found$/); +}); + +test("a mixed text+toolCall message keeps its compact tool-call row", () => { + const out = formatTranscriptForDisplay(line({ + message: { content: [{ text: "let me check", type: "text" }, { name: "bash", type: "toolCall" }], role: "assistant" }, + timestamp: at(0), + type: "message", + })); + assert.match(out, /assistant · let me check/); + assert.match(out, /assistant · 1 tool call: bash/); +}); + +test("benign lifecycle records are neutral rows; trouble-shaped ones are not", () => { + const benign = formatTranscriptForDisplay(line({ ts: at(0), type: "settled_shutdown" })); + assert.match(benign, /^ \[0:00\] settled_shutdown/); + const trouble = formatTranscriptForDisplay(line({ diagnostic: "close hung", ts: at(0), type: "abort_close_timeout" })); + assert.match(trouble, /^✖\[0:00\] abort_close_timeout · close hung$/); +}); + +test("droppedEvents is stated up front", () => { + const out = formatTranscriptForDisplay(stream(0, { type: "turn_end" }), { droppedEvents: 3 }); + assert.match(out.split("\n")[0]!, /↑ 3 earlier events not shown/); +}); + +const TMP = mkdtempSync(join(tmpdir(), "transcript-tail-")); +after(() => rmSync(TMP, { force: true, recursive: true })); + +test("a truncated timeline keeps the session's original elapsed origin", () => { + const out = formatTranscriptForDisplay(stream(7200, { type: "turn_end" }), { droppedEvents: 3, originTs: at(0) }); + assert.match(out, /\[2:00:00\] turn end/); +}); + +test("readTranscriptTail cuts on a line boundary and counts dropped events", async () => { + const path = join(TMP, "t.jsonl"); + const records = Array.from({ length: 50 }, (_, index) => line({ index, ts: at(index), type: "turn_end" })); + writeFileSync(path, `${records.join("\n")}\n`); + const whole = await readTranscriptTail(path); + assert.equal(whole?.droppedLines, 0); + const tail = await readTranscriptTail(path, 500); + assert.ok(tail && tail.droppedLines > 0, "expected a cut"); + assert.equal(tail!.originTs, at(0)); + // First record longer than the dropped prefix: its newline sits past the + // cut, and the origin must still be recovered. + const bigFirst = join(TMP, "bigfirst.jsonl"); + const huge = line({ task: "x".repeat(600), ts: at(0), type: "start" }); + writeFileSync(bigFirst, `${huge}\n${line({ ts: at(7200), type: "turn_end" })}\n`); + const cut = await readTranscriptTail(bigFirst, 500); + assert.ok(cut && cut.droppedLines > 0, "expected a cut"); + assert.equal(cut!.originTs, at(0)); + assert.equal(tail!.droppedLines + tail!.text.split("\n").filter(Boolean).length, 50); + for (const kept of tail!.text.split("\n").filter(Boolean)) assert.doesNotThrow(() => JSON.parse(kept)); + assert.equal(await readTranscriptTail(join(TMP, "missing.jsonl")), undefined); +}); diff --git a/tools/size-ratchet-baseline.tsv b/tools/size-ratchet-baseline.tsv index 4a5248f22..d96fb2934 100644 --- a/tools/size-ratchet-baseline.tsv +++ b/tools/size-ratchet-baseline.tsv @@ -57,7 +57,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 1171 +pi-extensions/pi-agents-tmux/tests/dashboard-ux.test.ts 1169 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