From 64c17c9f5590745f7cb797345221c4a734178f95 Mon Sep 17 00:00:00 2001 From: bmethod Date: Mon, 17 Aug 2026 05:38:41 -0700 Subject: [PATCH 01/11] feat(pi-agents-tmux): Transcript tab renders an event timeline; open-in-editor key (VST-327) Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5 --- .../subagent/browser/monitor-task-detail.ts | 17 +- .../subagent/browser/trace-viewer.ts | 26 +- .../extensions/subagent/renderers.ts | 23 ++ .../extensions/subagent/transcripts.ts | 268 +++++++++++++++--- .../pi-agents-tmux/tests/dashboard-ux.test.ts | 6 +- .../tests/transcript-timeline.test.ts | 121 ++++++++ 6 files changed, 415 insertions(+), 46 deletions(-) create mode 100644 pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts 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..3817507b4 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,7 +10,7 @@ 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 { @@ -77,6 +77,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 +269,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 }) : "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..cf5fad544 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts @@ -260,6 +260,29 @@ export async function readTextFileIfExists(filePath: string | undefined, maxByte } } +/** + * Tail-read a JSONL transcript on a byte budget without ever cutting + * mid-record: the cut lands on the first line boundary inside the kept + * window, and the dropped prefix is counted so the caller can say how many + * events are not shown instead of rendering a leading fragment. + */ +export async function readTranscriptTail(filePath: string | undefined, maxBytes = 256_000): Promise<{ text: string; droppedLines: number } | undefined> { + if (!filePath) return undefined; + try { + const content = await fs.promises.readFile(filePath, "utf-8"); + if (content.length <= maxBytes) return { droppedLines: 0, text: content }; + const cutStart = content.length - maxBytes; + const boundary = content.indexOf("\n", cutStart); + if (boundary === -1) return { droppedLines: Math.max(0, content.split("\n").length - 1), text: "" }; + const dropped = content.slice(0, boundary + 1); + let droppedLines = 0; + for (let index = dropped.indexOf("\n"); index !== -1; index = dropped.indexOf("\n", index + 1)) droppedLines += 1; + return { droppedLines, text: content.slice(boundary + 1) }; + } catch { + return 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/transcripts.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts index 7f576901f..49b5fe070 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts @@ -188,53 +188,245 @@ function textFromMessageContent(content: unknown): string | undefined { 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"); +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`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 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; } - 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}` : ""} ──`; + 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 value.length; + try { + return JSON.stringify(value)?.length ?? 0; + } catch { + return 0; } - if (type === "agent_end") { - const preview = stringValue(event.finalTextPreview ?? event.final_text_preview); - return [`── agent end ──`, preview ? oneLine(preview) : undefined].filter(Boolean).join("\n"); +} + +interface TimelineRow { + stamp: string; + kind: string; + detail?: string; + error?: boolean; +} + +function renderTimelineRow(row: TimelineRow): string { + const detail = row.detail ? ` · ${oneLine(row.detail, TIMELINE_PREVIEW_MAX)}` : ""; + return `${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"; + const content = message?.content; + if (typeof content === "string") return [{ kind: role, detail: content }]; + if (!Array.isArray(content)) return [{ kind: role, detail: `(no text, ${formatByteSize(payloadByteSize(message))})` }]; + 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({ kind: "thinking", detail: part.thinking }); + else if (part.type === "text" && typeof part.text === "string" && part.text) rows.push({ kind: role, detail: part.text }); + else if (typeof part.type === "string" && part.type.toLowerCase().includes("tool")) { + toolNames.push(stringValue(part.name) ?? stringValue(part.toolName) ?? stringValue(part.tool_name) ?? "tool"); + } } - if (record?.type === "exit") return `── exit ──\ncode ${record.code ?? "unknown"}`; - return undefined; + // A tool-call-only assistant message reads as its calls, never as raw JSON. + if (rows.length === 0 && toolNames.length > 0) return [{ kind: role, detail: `${toolNames.length} tool call${toolNames.length === 1 ? "" : "s"}: ${toolNames.join(", ")}` }]; + if (rows.length === 0) return [{ kind: role, detail: `(no text, ${formatByteSize(payloadByteSize(message))})` }]; + return rows; } -export function formatTranscriptForDisplay(raw: string): string { - const lines: string[] = []; +/** + * 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 }): string { + const rows: TimelineRow[] = []; + let firstTs: number | undefined; + // Open tool calls by id (fallback: name), pointing at the row to complete. + const openTools = new Map(); + const stampFor = (record: any): { stamp: string; atMs?: number } => { + const atMs = Date.parse(record?.ts ?? ""); + 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 { - const parsed = JSON.parse(line); - lines.push(describeTranscriptEvent(parsed) ?? line); + record = JSON.parse(line); } catch { - lines.push(line); + push("--:--", "unparseable line", formatByteSize(line.length), 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 && !("event" in (record ?? {})) && !("stream" in (record ?? {}))) { + // Other writer records (abort_close_timeout, settled_shutdown_*, …): + // lifecycle trouble, labeled and toned as such. + push(stamp, recordType, stringValue(record.diagnostic) ?? stringValue(record.signal) ?? formatByteSize(payloadByteSize(record)), true); + 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(line.length), 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) ?? `name:${name}`; + const row = push(stamp, label, "no result recorded"); + openTools.set(id, { label, row, startedAtMs: atMs }); + 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) ?? `name:${name}`; + const open = openTools.get(id); + openTools.delete(id); + 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(line.length)); + } } } - return lines.join("\n"); + for (const open of openTools.values()) open.row.error = true; + return rows.map(renderTimelineRow).join("\n"); } 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..b901c1926 --- /dev/null +++ b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts @@ -0,0 +1,121 @@ +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/transcripts.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); +}); + +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("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("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("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("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!.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); +}); From d0f1591b5b23c75c940bbca3cbf13b690d64f7c4 Mon Sep 17 00:00:00 2001 From: bmethod Date: Mon, 17 Aug 2026 05:39:24 -0700 Subject: [PATCH 02/11] feat(pi-agents-tmux): 2.8.4 changelog and version for the Transcript timeline (VST-327) Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5 --- CHANGELOG.md | 5 +++++ pi-extensions/pi-agents-tmux/CHANGELOG.md | 4 ++++ pi-extensions/pi-agents-tmux/package.json | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) 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..257c5080f 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, cuts on a line boundary only, and states how many earlier events were dropped. 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/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": [ From 16d0202949705e9cb708257cfb137cb25a49a5dc Mon Sep 17 00:00:00 2001 From: bmethod Date: Mon, 17 Aug 2026 05:48:46 -0700 Subject: [PATCH 03/11] fix(pi-agents-tmux): timeline scrubs terminal controls, streams the tail window, tones only trouble-shaped records (VST-327) Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5 --- pi-extensions/pi-agents-tmux/CHANGELOG.md | 2 +- .../extensions/subagent/renderers.ts | 46 ++++++++++++++----- .../extensions/subagent/transcripts.ts | 22 +++++++-- .../tests/transcript-timeline.test.ts | 21 +++++++++ 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/pi-extensions/pi-agents-tmux/CHANGELOG.md b/pi-extensions/pi-agents-tmux/CHANGELOG.md index 257c5080f..3ebb8ef57 100644 --- a/pi-extensions/pi-agents-tmux/CHANGELOG.md +++ b/pi-extensions/pi-agents-tmux/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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, cuts on a line boundary only, and states how many earlier events were dropped. New `e` key in the trace viewer opens the item's file in `$VISUAL`/`$EDITOR` (own tmux window), listed in the footer hint. +- 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 diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts index cf5fad544..7450f8bce 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts @@ -262,24 +262,48 @@ export async function readTextFileIfExists(filePath: string | undefined, maxByte /** * Tail-read a JSONL transcript on a byte budget without ever cutting - * mid-record: the cut lands on the first line boundary inside the kept - * window, and the dropped prefix is counted so the caller can say how many - * events are not shown instead of rendering a leading fragment. + * 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 } | undefined> { if (!filePath) return undefined; + let handle: fs.promises.FileHandle | undefined; try { - const content = await fs.promises.readFile(filePath, "utf-8"); - if (content.length <= maxBytes) return { droppedLines: 0, text: content }; - const cutStart = content.length - maxBytes; - const boundary = content.indexOf("\n", cutStart); - if (boundary === -1) return { droppedLines: Math.max(0, content.split("\n").length - 1), text: "" }; - const dropped = content.slice(0, boundary + 1); + 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; - for (let index = dropped.indexOf("\n"); index !== -1; index = dropped.indexOf("\n", index + 1)) droppedLines += 1; - return { droppedLines, text: content.slice(boundary + 1) }; + const chunk = Buffer.alloc(64 * 1024); + 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, text }; } catch { return undefined; + } finally { + await handle?.close().catch(() => undefined); } } diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts index 49b5fe070..3ef3cb0a6 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts @@ -243,9 +243,17 @@ interface TimelineRow { 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 `${row.error ? "✖" : " "}[${row.stamp}] ${row.kind}${detail}`; + return stripTerminalControls(`${row.error ? "✖" : " "}[${row.stamp}] ${row.kind}${detail}`); } function describeInputEvent(event: any): TimelineRow { @@ -335,10 +343,16 @@ export function formatTranscriptForDisplay(raw: string, options?: { droppedEvent 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 records (abort_close_timeout, settled_shutdown_*, …): - // lifecycle trouble, labeled and toned as such. - push(stamp, recordType, stringValue(record.diagnostic) ?? stringValue(record.signal) ?? formatByteSize(payloadByteSize(record)), true); + // 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$/.test(recordType); + push(stamp, recordType, stringValue(record.diagnostic) ?? stringValue(record.signal) ?? formatByteSize(payloadByteSize(record)), troubled); continue; } if (typeof record?.text === "string" && record?.stream === "stderr") { diff --git a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts index b901c1926..d30348fd7 100644 --- a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts +++ b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts @@ -99,6 +99,27 @@ test("an unparseable line renders labeled with its size, not verbatim", () => { 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("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/); From d4536f18ce855951a952c1d46dcd3dcdd1125e54 Mon Sep 17 00:00:00 2001 From: bmethod Date: Mon, 17 Aug 2026 05:52:25 -0700 Subject: [PATCH 04/11] refactor(pi-agents-tmux): the timeline renderer moves to transcript-timeline.ts at the ratchet seam Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5 --- .../subagent/browser/monitor-task-detail.ts | 3 +- .../subagent/transcript-timeline.ts | 258 +++++++++++++++++ .../extensions/subagent/transcripts.ts | 270 +----------------- .../tests/transcript-timeline.test.ts | 2 +- 4 files changed, 264 insertions(+), 269 deletions(-) create mode 100644 pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts 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 3817507b4..e51ba2eb8 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 @@ -12,7 +12,8 @@ import { } from "../format.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, 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..e8f002a5f --- /dev/null +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts @@ -0,0 +1,258 @@ +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`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 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 value.length; + try { + return JSON.stringify(value)?.length ?? 0; + } 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"; + const content = message?.content; + if (typeof content === "string") return [{ kind: role, detail: content }]; + if (!Array.isArray(content)) return [{ kind: role, detail: `(no text, ${formatByteSize(payloadByteSize(message))})` }]; + 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({ kind: "thinking", detail: part.thinking }); + else if (part.type === "text" && typeof part.text === "string" && part.text) rows.push({ kind: role, detail: part.text }); + else if (typeof part.type === "string" && part.type.toLowerCase().includes("tool")) { + toolNames.push(stringValue(part.name) ?? stringValue(part.toolName) ?? stringValue(part.tool_name) ?? "tool"); + } + } + // A tool-call-only assistant message reads as its calls, never as raw JSON. + if (rows.length === 0 && toolNames.length > 0) return [{ kind: role, detail: `${toolNames.length} tool call${toolNames.length === 1 ? "" : "s"}: ${toolNames.join(", ")}` }]; + if (rows.length === 0) return [{ kind: role, detail: `(no text, ${formatByteSize(payloadByteSize(message))})` }]; + 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 }): string { + const rows: TimelineRow[] = []; + let firstTs: number | undefined; + // Open tool calls by id (fallback: name), pointing at the row to complete. + const openTools = new Map(); + const stampFor = (record: any): { stamp: string; atMs?: number } => { + const atMs = Date.parse(record?.ts ?? ""); + 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(line.length), 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$/.test(recordType); + push(stamp, recordType, stringValue(record.diagnostic) ?? 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(line.length), 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) ?? `name:${name}`; + const row = push(stamp, label, "no result recorded"); + openTools.set(id, { label, row, startedAtMs: atMs }); + 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) ?? `name:${name}`; + const open = openTools.get(id); + openTools.delete(id); + 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(line.length)); + } + } + } + for (const open of openTools.values()) open.row.error = true; + 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 3ef3cb0a6..d2a0d67b0 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcripts.ts @@ -168,279 +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; -} - -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`; - const seconds = ms / 1000; - if (seconds < 60) return `${seconds.toFixed(1)}s`; - return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 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 value.length; - try { - return JSON.stringify(value)?.length ?? 0; - } 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"; - const content = message?.content; - if (typeof content === "string") return [{ kind: role, detail: content }]; - if (!Array.isArray(content)) return [{ kind: role, detail: `(no text, ${formatByteSize(payloadByteSize(message))})` }]; - 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({ kind: "thinking", detail: part.thinking }); - else if (part.type === "text" && typeof part.text === "string" && part.text) rows.push({ kind: role, detail: part.text }); - else if (typeof part.type === "string" && part.type.toLowerCase().includes("tool")) { - toolNames.push(stringValue(part.name) ?? stringValue(part.toolName) ?? stringValue(part.tool_name) ?? "tool"); - } - } - // A tool-call-only assistant message reads as its calls, never as raw JSON. - if (rows.length === 0 && toolNames.length > 0) return [{ kind: role, detail: `${toolNames.length} tool call${toolNames.length === 1 ? "" : "s"}: ${toolNames.join(", ")}` }]; - if (rows.length === 0) return [{ kind: role, detail: `(no text, ${formatByteSize(payloadByteSize(message))})` }]; - 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 }): string { - const rows: TimelineRow[] = []; - let firstTs: number | undefined; - // Open tool calls by id (fallback: name), pointing at the row to complete. - const openTools = new Map(); - const stampFor = (record: any): { stamp: string; atMs?: number } => { - const atMs = Date.parse(record?.ts ?? ""); - 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(line.length), 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$/.test(recordType); - push(stamp, recordType, stringValue(record.diagnostic) ?? 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(line.length), 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) ?? `name:${name}`; - const row = push(stamp, label, "no result recorded"); - openTools.set(id, { label, row, startedAtMs: atMs }); - 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) ?? `name:${name}`; - const open = openTools.get(id); - openTools.delete(id); - 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(line.length)); - } - } - } - for (const open of openTools.values()) open.row.error = true; - return rows.map(renderTimelineRow).join("\n"); -} diff --git a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts index d30348fd7..ca6992485 100644 --- a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts +++ b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts @@ -4,7 +4,7 @@ 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/transcripts.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(); From 77e4e57fcaaeb8920ce0128c270ca7a3cbc3974b Mon Sep 17 00:00:00 2001 From: bmethod Date: Mon, 17 Aug 2026 06:22:32 -0700 Subject: [PATCH 05/11] chore(size-ratchet): dashboard-ux.test.ts tightens to 1169 Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5 --- tools/size-ratchet-baseline.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a9fc4bdafb9fa581b32e7e07e5654adfdbb312f1 Mon Sep 17 00:00:00 2001 From: bmethod Date: Mon, 17 Aug 2026 06:26:17 -0700 Subject: [PATCH 06/11] fix(pi-agents-tmux): tool durations carry seconds in [0,59], sizes are UTF-8 bytes, id-less calls pair FIFO Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5 --- .../subagent/transcript-timeline.ts | 31 ++++++++++--------- .../tests/transcript-timeline.test.ts | 22 +++++++++++++ 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts index e8f002a5f..8b6e6c036 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts @@ -21,9 +21,9 @@ function formatElapsedStamp(ms: number | undefined): string { function formatToolDuration(ms: number | undefined): string | undefined { if (ms === undefined || !Number.isFinite(ms) || ms < 0) return undefined; if (ms < 1000) return `${ms}ms`; - const seconds = ms / 1000; - if (seconds < 60) return `${seconds.toFixed(1)}s`; - return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`; + 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. */ @@ -40,9 +40,10 @@ function primaryToolArgument(args: unknown): string | undefined { function payloadByteSize(value: unknown): number { if (value === undefined) return 0; - if (typeof value === "string") return value.length; + if (typeof value === "string") return Buffer.byteLength(value, "utf8"); try { - return JSON.stringify(value)?.length ?? 0; + const serialized = JSON.stringify(value); + return serialized === undefined ? 0 : Buffer.byteLength(serialized, "utf8"); } catch { return 0; } @@ -112,8 +113,9 @@ function messageContentRows(message: any): Array(); + // 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 } => { const atMs = Date.parse(record?.ts ?? ""); if (!Number.isFinite(atMs)) return { stamp: "--:--" }; @@ -132,7 +134,7 @@ export function formatTranscriptForDisplay(raw: string, options?: { droppedEvent try { record = JSON.parse(line); } catch { - push("--:--", "unparseable line", formatByteSize(line.length), true); + push("--:--", "unparseable line", formatByteSize(payloadByteSize(line)), true); continue; } const { stamp, atMs } = stampFor(record); @@ -175,7 +177,7 @@ export function formatTranscriptForDisplay(raw: string, options?: { droppedEvent 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(line.length), true); + push(stamp, "unlabeled record", formatByteSize(payloadByteSize(line)), true); continue; } switch (type) { @@ -216,7 +218,9 @@ export function formatTranscriptForDisplay(raw: string, options?: { droppedEvent const label = target ? `tool ${name} (${oneLine(target, 60)})` : `tool ${name}`; const id = stringValue(event.toolCallId ?? event.tool_call_id) ?? `name:${name}`; const row = push(stamp, label, "no result recorded"); - openTools.set(id, { label, row, startedAtMs: atMs }); + const queue = openTools.get(id) ?? []; + queue.push({ label, row, startedAtMs: atMs }); + openTools.set(id, queue); break; } case "tool_execution_update": @@ -225,8 +229,7 @@ export function formatTranscriptForDisplay(raw: string, options?: { droppedEvent 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) ?? `name:${name}`; - const open = openTools.get(id); - openTools.delete(id); + 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); @@ -249,10 +252,10 @@ export function formatTranscriptForDisplay(raw: string, options?: { droppedEvent break; } default: { - push(stamp, type, formatByteSize(line.length)); + push(stamp, type, formatByteSize(payloadByteSize(line))); } } } - for (const open of openTools.values()) open.row.error = true; + for (const queue of openTools.values()) for (const open of queue) open.row.error = true; return rows.map(renderTimelineRow).join("\n"); } diff --git a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts index ca6992485..90721f236 100644 --- a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts +++ b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts @@ -63,6 +63,28 @@ test("a tool-call-only assistant message renders as its calls, not message JSON" 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("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" }), From d542984deb350526704883bc20b8ade6d3038ad8 Mon Sep 17 00:00:00 2001 From: bmethod Date: Mon, 17 Aug 2026 06:58:53 -0700 Subject: [PATCH 07/11] fix(pi-agents-tmux): live tasks keep open tool calls pending; process_error surfaces its message; README documents the timeline Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5 --- pi-extensions/pi-agents-tmux/README.md | 1 + .../subagent/browser/monitor-task-detail.ts | 2 +- .../subagent/transcript-timeline.ts | 19 ++++++++++++++----- .../tests/transcript-timeline.test.ts | 9 +++++++++ 4 files changed, 25 insertions(+), 6 deletions(-) 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 e51ba2eb8..5ccece38f 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 @@ -272,7 +272,7 @@ export async function traceViewerItems(record: PaneTaskRecord, taskNumber?: numb const common = { agent: record.agent, createdAt: record.completedAt ?? record.createdAt, ref, status: record.status, summary: summaryText }; const transcript = await readTranscriptTail(record.transcriptPath); const transcriptItem = record.transcriptPath - ? [{ ...common, label: "Transcript", path: record.transcriptPath, text: transcript ? formatTranscriptForDisplay(transcript.text, { droppedEvents: transcript.droppedLines }) : "Transcript file could not be read.", type: "transcript" as const }] + ? [{ ...common, label: "Transcript", path: record.transcriptPath, text: transcript ? formatTranscriptForDisplay(transcript.text, { droppedEvents: transcript.droppedLines, 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/transcript-timeline.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts index 8b6e6c036..a0cf9925a 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts @@ -110,7 +110,7 @@ function messageContentRows(message: any): Array { 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", () => { @@ -104,6 +108,11 @@ test("message_start and turn_start are deliberately elided", () => { 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" }), From df250df4c6ba0b240c75bf224e9bc8f3adeaad2e Mon Sep 17 00:00:00 2001 From: bmethod Date: Mon, 17 Aug 2026 07:28:01 -0700 Subject: [PATCH 08/11] fix(pi-agents-tmux): pane-session timestamps and mixed-message tool calls reach the timeline Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5 --- .../subagent/transcript-timeline.ts | 11 +++++++--- .../tests/transcript-timeline.test.ts | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts index a0cf9925a..68429d2fd 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts @@ -94,8 +94,10 @@ function messageContentRows(message: any): Array 0) return [{ kind: role, detail: `${toolNames.length} tool call${toolNames.length === 1 ? "" : "s"}: ${toolNames.join(", ")}` }]; + // 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({ kind: role, detail: `${toolNames.length} tool call${toolNames.length === 1 ? "" : "s"}: ${toolNames.join(", ")}` }); if (rows.length === 0) return [{ kind: role, detail: `(no text, ${formatByteSize(payloadByteSize(message))})` }]; return rows; } @@ -117,7 +119,10 @@ export function formatTranscriptForDisplay(raw: string, options?: { droppedEvent // complete — id-less same-named calls pair first-started-first-ended. const openTools = new Map>(); const stampFor = (record: any): { stamp: string; atMs?: number } => { - const atMs = Date.parse(record?.ts ?? ""); + // 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) }; diff --git a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts index 674002a0c..17ad6da1c 100644 --- a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts +++ b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts @@ -144,6 +144,26 @@ test("native pane-session message records render as content, not failures", () = 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 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/); From 00f6884ee8599a07a90b7b7951608a692a593d50 Mon Sep 17 00:00:00 2001 From: bmethod Date: Mon, 17 Aug 2026 08:56:27 -0700 Subject: [PATCH 09/11] fix(pi-agents-tmux): failed pane toolResult messages carry the failure tone Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5 --- .../extensions/subagent/transcript-timeline.ts | 15 +++++++++------ .../tests/transcript-timeline.test.ts | 9 +++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts index 68429d2fd..06848a1b0 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts @@ -81,15 +81,18 @@ function describeInputEvent(event: any): TimelineRow { 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 [{ kind: role, detail: content }]; - if (!Array.isArray(content)) return [{ kind: role, detail: `(no text, ${formatByteSize(payloadByteSize(message))})` }]; + 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({ kind: "thinking", detail: part.thinking }); - else if (part.type === "text" && typeof part.text === "string" && part.text) rows.push({ kind: role, detail: part.text }); + 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"); } @@ -97,8 +100,8 @@ function messageContentRows(message: any): Array 0) rows.push({ kind: role, detail: `${toolNames.length} tool call${toolNames.length === 1 ? "" : "s"}: ${toolNames.join(", ")}` }); - if (rows.length === 0) return [{ kind: role, detail: `(no text, ${formatByteSize(payloadByteSize(message))})` }]; + 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; } diff --git a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts index 17ad6da1c..1c1128a2b 100644 --- a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts +++ b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts @@ -154,6 +154,15 @@ test("pane-session timestamp field yields elapsed stamps, string or numeric", () 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" }, From eeeebadf7c3394a5ea44116222a5b399154c40b0 Mon Sep 17 00:00:00 2001 From: bmethod Date: Mon, 17 Aug 2026 09:03:14 -0700 Subject: [PATCH 10/11] fix(pi-agents-tmux): a truncated timeline keeps the session's elapsed origin Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5 --- .../subagent/browser/monitor-task-detail.ts | 2 +- .../extensions/subagent/renderers.ts | 22 +++++++++++++++++-- .../subagent/transcript-timeline.ts | 5 +++-- .../tests/transcript-timeline.test.ts | 6 +++++ 4 files changed, 30 insertions(+), 5 deletions(-) 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 5ccece38f..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 @@ -272,7 +272,7 @@ export async function traceViewerItems(record: PaneTaskRecord, taskNumber?: numb const common = { agent: record.agent, createdAt: record.completedAt ?? record.createdAt, ref, status: record.status, summary: summaryText }; const transcript = await readTranscriptTail(record.transcriptPath); const transcriptItem = record.transcriptPath - ? [{ ...common, label: "Transcript", path: record.transcriptPath, text: transcript ? formatTranscriptForDisplay(transcript.text, { droppedEvents: transcript.droppedLines, taskTerminal: monitorStatusIsTerminal(record.status) }) : "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/renderers.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts index 7450f8bce..da47c655a 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts @@ -267,7 +267,7 @@ export async function readTextFileIfExists(filePath: string | undefined, maxByte * 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 } | undefined> { +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 { @@ -276,10 +276,28 @@ export async function readTranscriptTail(filePath: string | undefined, maxBytes 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; + let firstLine: Buffer | undefined; const chunk = Buffer.alloc(64 * 1024); for (let position = 0; position < start; ) { const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, start - position), position); if (bytesRead <= 0) break; + if (originTs === undefined && firstLine !== null) { + const boundary = chunk.subarray(0, bytesRead).indexOf(10); + const piece = chunk.subarray(0, boundary === -1 ? bytesRead : boundary); + firstLine = firstLine ? Buffer.concat([firstLine, piece]) : Buffer.from(piece); + if (boundary !== -1 || firstLine.length > 1024 * 1024) { + try { + const parsed = JSON.parse(firstLine.toString("utf-8")); + originTs = parsed?.ts ?? parsed?.timestamp; + } catch { + originTs = undefined; + } + firstLine = null as never; + } + } for (let index = 0; index < bytesRead; index += 1) if (chunk[index] === 10) droppedLines += 1; position += bytesRead; } @@ -299,7 +317,7 @@ export async function readTranscriptTail(filePath: string | undefined, maxBytes text = text.slice(boundary + 1); } } - return { droppedLines, text }; + return { droppedLines, originTs, text }; } catch { return undefined; } finally { diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts index 06848a1b0..5a736bbf9 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts @@ -115,9 +115,10 @@ function messageContentRows(message: any): Array>(); diff --git a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts index 1c1128a2b..51b57b9c5 100644 --- a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts +++ b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts @@ -188,6 +188,11 @@ test("droppedEvents is stated up front", () => { 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" })); @@ -196,6 +201,7 @@ test("readTranscriptTail cuts on a line boundary and counts dropped events", asy 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)); 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); From 0688760319bc512dd48f90fa1143990d2244ddcc Mon Sep 17 00:00:00 2001 From: bmethod Date: Mon, 17 Aug 2026 09:11:17 -0700 Subject: [PATCH 11/11] fix(pi-agents-tmux): toolUseId variants pair calls; the origin survives a first record crossing the cut Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5 --- .../extensions/subagent/renderers.ts | 35 +++++++++++-------- .../subagent/transcript-timeline.ts | 4 +-- .../tests/transcript-timeline.test.ts | 20 +++++++++++ 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts index da47c655a..30eaa689f 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts @@ -279,25 +279,30 @@ export async function readTranscriptTail(filePath: string | undefined, maxBytes // 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; - let firstLine: Buffer | undefined; 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; - if (originTs === undefined && firstLine !== null) { - const boundary = chunk.subarray(0, bytesRead).indexOf(10); - const piece = chunk.subarray(0, boundary === -1 ? bytesRead : boundary); - firstLine = firstLine ? Buffer.concat([firstLine, piece]) : Buffer.from(piece); - if (boundary !== -1 || firstLine.length > 1024 * 1024) { - try { - const parsed = JSON.parse(firstLine.toString("utf-8")); - originTs = parsed?.ts ?? parsed?.timestamp; - } catch { - originTs = undefined; - } - firstLine = null as never; - } - } for (let index = 0; index < bytesRead; index += 1) if (chunk[index] === 10) droppedLines += 1; position += bytesRead; } diff --git a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts index 5a736bbf9..964afc9db 100644 --- a/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts +++ b/pi-extensions/pi-agents-tmux/extensions/subagent/transcript-timeline.ts @@ -225,7 +225,7 @@ export function formatTranscriptForDisplay(raw: string, options?: { droppedEvent 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) ?? `name:${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 }); @@ -237,7 +237,7 @@ export function formatTranscriptForDisplay(raw: string, options?: { droppedEvent 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) ?? `name:${name}`; + 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"); diff --git a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts index 51b57b9c5..2c7092872 100644 --- a/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts +++ b/pi-extensions/pi-agents-tmux/tests/transcript-timeline.test.ts @@ -77,6 +77,18 @@ test("tool durations never render 60s; sizes are UTF-8 bytes", () => { 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" }), @@ -202,6 +214,14 @@ test("readTranscriptTail cuts on a line boundary and counts dropped events", asy 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);